mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-18 02:20:47 -04:00
admin panel
This commit is contained in:
@@ -60,7 +60,7 @@
|
|||||||
- [x] mini summary app
|
- [x] mini summary app
|
||||||
- [x] spectator app
|
- [x] spectator app
|
||||||
- [x] vip audio upload card
|
- [x] vip audio upload card
|
||||||
- [ ] admin panel
|
- [x] admin panel
|
||||||
- [ ] drive dock action
|
- [ ] drive dock action
|
||||||
- [ ] gamepad mapping settings
|
- [ ] gamepad mapping settings
|
||||||
- [ ] mobile controls
|
- [ ] mobile controls
|
||||||
@@ -73,12 +73,14 @@
|
|||||||
- spectator app
|
- spectator app
|
||||||
- video tile
|
- video tile
|
||||||
- vip audio upload card
|
- vip audio upload card
|
||||||
|
- admin panel
|
||||||
|
|
||||||
### LARGE CHANGES
|
### LARGE CHANGES
|
||||||
- Split `webui/src/mini/MiniSummaryApp.jsx` into folderized modules under `webui/src/mini/MiniSummaryApp/` with a compatibility entrypoint preserved.
|
- Split `webui/src/mini/MiniSummaryApp.jsx` into folderized modules under `webui/src/mini/MiniSummaryApp/` with a compatibility entrypoint preserved.
|
||||||
- Split `webui/src/spectate/SpectatorApp.jsx` into folderized modules under `webui/src/spectate/SpectatorApp/` with a compatibility entrypoint preserved.
|
- Split `webui/src/spectate/SpectatorApp.jsx` into folderized modules under `webui/src/spectate/SpectatorApp/` with a compatibility entrypoint preserved.
|
||||||
- Split `webui/src/components/VideoTile.jsx` by extracting HUD, overlays, chat input, and constants into `webui/src/components/VideoTile/` while preserving the existing `VideoTile.jsx` public component API.
|
- Split `webui/src/components/VideoTile.jsx` by extracting HUD, overlays, chat input, and constants into `webui/src/components/VideoTile/` while preserving the existing `VideoTile.jsx` public component API.
|
||||||
- Split `webui/src/components/vip/VipAudioUploadCard.jsx` by extracting transport/audio helpers and UI atoms into `webui/src/components/vip/VipAudioUploadCard/` while preserving the existing `VipAudioUploadCard.jsx` import/export API.
|
- Split `webui/src/components/vip/VipAudioUploadCard.jsx` by extracting transport/audio helpers and UI atoms into `webui/src/components/vip/VipAudioUploadCard/` while preserving the existing `VipAudioUploadCard.jsx` import/export API.
|
||||||
|
- Split `webui/src/components/AdminPanel.jsx` into `webui/src/components/AdminPanel/` and extracted monitor/health/log/LLM helper modules; updated consumers to folder entrypoint and removed external wrapper file.
|
||||||
|
|
||||||
## Done criteria (per item)
|
## Done criteria (per item)
|
||||||
- [ ] Folderized structure created.
|
- [ ] Folderized structure created.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BB1PfW7u.js"></script>
|
<script type="module" crossorigin src="/assets/index-BmNE-cUP.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DVTOmRBl.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DVTOmRBl.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Admin-only IP log stream panel.
|
||||||
|
export default function AdminIpLogPanel({ entries }) {
|
||||||
|
const logs = entries || [];
|
||||||
|
return (
|
||||||
|
<div className="panel-section space-y-0.5 text-base">
|
||||||
|
<div className="flex items-center justify-between text-sm text-slate-400">
|
||||||
|
<span>Admin IP log</span>
|
||||||
|
<span>{logs.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="surface h-64 overflow-y-auto font-mono text-xs">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p>No admin log entries yet.</p>
|
||||||
|
) : (
|
||||||
|
logs
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((entry) => (
|
||||||
|
<div key={entry.id} className="surface">
|
||||||
|
<span className="text-amber-400">{entry.ts ? new Date(entry.ts).toLocaleTimeString() : '--'}</span>{' '}
|
||||||
|
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
|
||||||
|
<span className="text-slate-200">{entry.message}</span>{' '}
|
||||||
|
{entry.ip && <span className="text-cyan-300">{entry.ip}</span>}{' '}
|
||||||
|
{entry.meta && <span className="text-slate-500">{JSON.stringify(entry.meta)}</span>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">Admin-only log stream; IPs never appear in user data.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+7
-367
@@ -1,8 +1,10 @@
|
|||||||
|
// Admin control panel main composition and action handlers.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../../context/SessionContext.jsx';
|
||||||
import RoverRoster from './RoverRoster.jsx';
|
import RoverRoster from '../RoverRoster.jsx';
|
||||||
import ChatMessageRow from './ChatMessageRow.jsx';
|
import LlmCommentaryPanel from './LlmCommentaryPanel.jsx';
|
||||||
import { roverNameChromeStyle } from '../lib/roverColor.js';
|
import ReplaySnapshotHealth from './ReplaySnapshotHealth.jsx';
|
||||||
|
import AdminIpLogPanel from './AdminIpLogPanel.jsx';
|
||||||
|
|
||||||
const MODES = [
|
const MODES = [
|
||||||
{ key: 'open', label: 'Open' },
|
{ key: 'open', label: 'Open' },
|
||||||
@@ -11,7 +13,7 @@ const MODES = [
|
|||||||
{ key: 'lockdown', label: 'Lockdown' },
|
{ key: 'lockdown', label: 'Lockdown' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanelContent() {
|
||||||
const {
|
const {
|
||||||
session,
|
session,
|
||||||
lockRover,
|
lockRover,
|
||||||
@@ -492,365 +494,3 @@ export default function AdminPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LlmCommentaryPanel({ state, onClearHistory, clearingHistory }) {
|
|
||||||
const [selectedRunId, setSelectedRunId] = useState(null);
|
|
||||||
if (!state) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="panel-muted text-xs uppercase">LLM Commentary</div>
|
|
||||||
<div className="surface text-xs text-slate-300">No status received yet.</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const runtime = state.runtime || {};
|
|
||||||
const counters = state.counters || {};
|
|
||||||
const timings = state.timings || {};
|
|
||||||
const input = state.input || {};
|
|
||||||
const output = state.output || {};
|
|
||||||
const errors = state.errors || {};
|
|
||||||
const history = Array.isArray(state.history) ? state.history : [];
|
|
||||||
const selectedRun =
|
|
||||||
history.find((run) => run.runId === selectedRunId) || (history.length ? history[history.length - 1] : null);
|
|
||||||
const largeIndicator = buildLlmLargeIndicatorFromState(state);
|
|
||||||
const conversationRows = buildLlmConversationRowsFromMessages(input.modelMessages, output.raw);
|
|
||||||
const statPills = [
|
|
||||||
{ label: 'running', value: runtime.running ? 'yes' : 'no' },
|
|
||||||
{ label: 'in flight', value: runtime.inFlight ? 'yes' : 'no' },
|
|
||||||
{ label: 'phase', value: runtime.phase || '--' },
|
|
||||||
{ label: 'run id', value: runtime.currentRunId ?? '--' },
|
|
||||||
{ label: 'tick count', value: runtime.tickCount ?? 0 },
|
|
||||||
{
|
|
||||||
label: 'last tick',
|
|
||||||
value: runtime.lastTickAt ? new Date(runtime.lastTickAt).toLocaleString() : 'never',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'next run',
|
|
||||||
value: runtime.nextRunAt ? new Date(runtime.nextRunAt).toLocaleString() : 'n/a',
|
|
||||||
},
|
|
||||||
{ label: 'outcome', value: runtime.outcome || '--' },
|
|
||||||
{ label: 'reason', value: runtime.reason || '--' },
|
|
||||||
{ label: 'skip streak', value: counters.skipStreak ?? 0 },
|
|
||||||
{ label: 'clear count', value: counters.clearCount ?? 0 },
|
|
||||||
{ label: 'last gen', value: timings.lastGenerationMs != null ? `${timings.lastGenerationMs} ms` : '--' },
|
|
||||||
{ label: 'avg gen', value: timings.avgGenerationMs != null ? `${timings.avgGenerationMs} ms` : '--' },
|
|
||||||
{ label: 'gen count', value: timings.generationCount ?? 0 },
|
|
||||||
{ label: 'prompt chars', value: counters.promptChars ?? 0 },
|
|
||||||
{ label: 'active drivers', value: counters.snapshotSummary?.activeDrivers ?? 0 },
|
|
||||||
{ label: 'snapshot rovers', value: counters.snapshotSummary?.rovers ?? 0 },
|
|
||||||
{ label: 'snapshot chat', value: counters.snapshotSummary?.chatMessages ?? 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="panel-muted text-xs uppercase">LLM Commentary</div>
|
|
||||||
<div className="flex gap-0.5 text-xs">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClearHistory}
|
|
||||||
disabled={Boolean(clearingHistory)}
|
|
||||||
className="button-danger disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{clearingHistory ? 'Clearing...' : 'Clear LLM History'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className={`surface border text-center ${largeIndicator.className}`}>
|
|
||||||
<div className="text-[1.1rem] font-bold tracking-wide">{largeIndicator.label}</div>
|
|
||||||
<div className="text-xs text-slate-200">{largeIndicator.detail}</div>
|
|
||||||
</div>
|
|
||||||
<div className="surface flex flex-wrap gap-0.5 text-xs">
|
|
||||||
{statPills.map((pill) => (
|
|
||||||
<span
|
|
||||||
key={pill.label}
|
|
||||||
className="rounded border border-slate-600/60 bg-slate-800/70 px-0.5 py-0.25 text-[0.72rem] leading-tight text-slate-200"
|
|
||||||
>
|
|
||||||
{pill.label}: {pill.value}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{errors.message ? (
|
|
||||||
<div className="surface text-xs text-red-300 break-words">
|
|
||||||
Error: {errors.message}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{errors.details ? (
|
|
||||||
<details className="surface text-xs text-red-200">
|
|
||||||
<summary className="cursor-pointer select-none text-red-300">Failure details</summary>
|
|
||||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-red-200">
|
|
||||||
{JSON.stringify(errors.details, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
{output.generated ? (
|
|
||||||
<div className="surface text-xs text-slate-200 break-words">
|
|
||||||
Generated: {output.generated}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{output.posted ? (
|
|
||||||
<div className="surface text-xs text-emerald-200 break-words">
|
|
||||||
Posted: {output.posted}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="grid gap-0.5 md:grid-cols-2">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="panel-muted text-xs uppercase">Live Input Conversation</div>
|
|
||||||
<div className="surface max-h-72 space-y-0.5 overflow-y-auto">
|
|
||||||
{conversationRows.length ? (
|
|
||||||
conversationRows.map((row) => <ChatMessageRow key={row.id} message={row.message} />)
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-slate-300">No model conversation captured yet.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="panel-muted text-xs uppercase">Output + Error</div>
|
|
||||||
<div className="surface space-y-0.5 text-xs text-slate-200">
|
|
||||||
<div>Raw output: {output.raw?.trim() ? output.raw : '<none>'}</div>
|
|
||||||
<div>Posted: {output.posted || '<none>'}</div>
|
|
||||||
<div>
|
|
||||||
Output at:{' '}
|
|
||||||
{output.modelOutputAt ? new Date(output.modelOutputAt).toLocaleString() : 'n/a'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Failed at: {errors.failedAt ? new Date(errors.failedAt).toLocaleString() : 'n/a'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<details className="surface text-xs text-slate-200">
|
|
||||||
<summary className="cursor-pointer select-none text-slate-300">Full Monitor Payload</summary>
|
|
||||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
|
||||||
{JSON.stringify(state, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="panel-muted text-xs uppercase">Recent Runs</div>
|
|
||||||
<div className="surface max-h-52 space-y-0.5 overflow-y-auto text-xs">
|
|
||||||
{history.length ? (
|
|
||||||
history
|
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.map((run) => (
|
|
||||||
<button
|
|
||||||
key={run.runId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedRunId(run.runId)}
|
|
||||||
className={`w-full text-left surface ${
|
|
||||||
selectedRun?.runId === run.runId ? 'border border-sky-400/50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-slate-300">#{run.runId}</span>{' '}
|
|
||||||
<span className="text-slate-200">{run.outcome || run.phase || '--'}</span>{' '}
|
|
||||||
<span className="text-slate-400">{run.durationMs != null ? `${run.durationMs}ms` : '--'}</span>{' '}
|
|
||||||
<span className="text-slate-500">{run.reason || ''}</span>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-slate-300">No runs recorded yet.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selectedRun ? (
|
|
||||||
<details className="surface text-xs text-slate-200" open>
|
|
||||||
<summary className="cursor-pointer select-none text-slate-300">
|
|
||||||
Run #{selectedRun.runId} details
|
|
||||||
</summary>
|
|
||||||
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
|
||||||
{JSON.stringify(selectedRun, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLlmLargeIndicatorFromState(state) {
|
|
||||||
const runtime = state?.runtime || {};
|
|
||||||
const output = state?.output || {};
|
|
||||||
const errors = state?.errors || {};
|
|
||||||
if (runtime.inFlight) {
|
|
||||||
return {
|
|
||||||
label: 'IN FLIGHT',
|
|
||||||
detail: runtime.reason || 'Generating commentary now',
|
|
||||||
className: 'border-amber-400/60 bg-amber-700/20 text-amber-200',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (runtime.outcome === 'posted') {
|
|
||||||
return {
|
|
||||||
label: 'POSTED',
|
|
||||||
detail: output.posted ? `Last: ${output.posted}` : 'Commentary posted',
|
|
||||||
className: 'border-emerald-400/60 bg-emerald-700/20 text-emerald-200',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (runtime.outcome === 'skipped') {
|
|
||||||
return {
|
|
||||||
label: 'SKIPPED',
|
|
||||||
detail: runtime.reason || 'Model chose to skip',
|
|
||||||
className: 'border-slate-400/60 bg-slate-700/30 text-slate-200',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (runtime.outcome === 'failed') {
|
|
||||||
return {
|
|
||||||
label: 'FAILED',
|
|
||||||
detail: errors.message || runtime.reason || 'Tick failed',
|
|
||||||
className: 'border-red-400/60 bg-red-700/20 text-red-200',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
label: runtime.running ? 'IDLE' : 'STOPPED',
|
|
||||||
detail: runtime.reason || 'Waiting for next tick',
|
|
||||||
className: 'border-sky-400/50 bg-sky-700/20 text-sky-200',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
|
|
||||||
const now = Date.now();
|
|
||||||
const messages = Array.isArray(modelMessages) ? modelMessages : [];
|
|
||||||
const rows = messages.map((entry, index) => {
|
|
||||||
const role = String(entry?.role || '').toLowerCase();
|
|
||||||
const content =
|
|
||||||
typeof entry?.content === 'string' ? entry.content : JSON.stringify(entry?.content ?? null, null, 2);
|
|
||||||
const nickname =
|
|
||||||
role === 'system'
|
|
||||||
? 'LLM System'
|
|
||||||
: role === 'assistant'
|
|
||||||
? 'LLM Context'
|
|
||||||
: role === 'user'
|
|
||||||
? 'LLM Input'
|
|
||||||
: 'LLM Message';
|
|
||||||
return {
|
|
||||||
id: `llm-msg-${index}`,
|
|
||||||
message: {
|
|
||||||
ts: now + index,
|
|
||||||
nickname,
|
|
||||||
text: content,
|
|
||||||
role: 'spectator',
|
|
||||||
system: role === 'system',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
if (rawOutput != null) {
|
|
||||||
const raw = String(rawOutput);
|
|
||||||
rows.push({
|
|
||||||
id: 'llm-output',
|
|
||||||
message: {
|
|
||||||
ts: now + rows.length + 1,
|
|
||||||
nickname: 'LLM Output',
|
|
||||||
text: raw.trim() ? raw : '<empty>',
|
|
||||||
role: 'spectator',
|
|
||||||
system: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReplaySnapshotHealth({ health, roster = [] }) {
|
|
||||||
if (!health) return null;
|
|
||||||
const replay = health.replay || { sources: [], readyCount: 0, totalCount: 0 };
|
|
||||||
const snapshots = health.snapshots || { rovers: [], rooms: [] };
|
|
||||||
const roverColorFor = (id) =>
|
|
||||||
roster.find((entry) => String(entry.id) === String(id))?.color || null;
|
|
||||||
const replaySummary = `${replay.readyCount}/${replay.totalCount} sources ready`;
|
|
||||||
const roverStale = snapshots.rovers.filter((entry) => entry.stale).length;
|
|
||||||
const roomStale = snapshots.rooms.filter((entry) => entry.stale).length;
|
|
||||||
return (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="panel-muted text-xs uppercase">Health</div>
|
|
||||||
<div className="surface space-y-0.5 text-xs text-slate-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Replay segments</span>
|
|
||||||
<span className="text-slate-400">{replaySummary}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Rover snapshots</span>
|
|
||||||
<span className={roverStale ? 'text-amber-300' : 'text-emerald-300'}>
|
|
||||||
{snapshots.rovers.length - roverStale}/{snapshots.rovers.length} ok
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Room cameras</span>
|
|
||||||
<span className={roomStale ? 'text-amber-300' : 'text-emerald-300'}>
|
|
||||||
{snapshots.rooms.length - roomStale}/{snapshots.rooms.length} ok
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5 text-xs text-slate-300">
|
|
||||||
{replay.sources.map((source) => (
|
|
||||||
<div key={`${source.type}:${source.id}`} className="flex items-center justify-between">
|
|
||||||
<span
|
|
||||||
className={`${source.type === 'rover' ? 'rounded px-1 py-[1px] border border-transparent' : ''}`}
|
|
||||||
style={
|
|
||||||
source.type === 'rover'
|
|
||||||
? roverNameChromeStyle(roverColorFor(source.id), 0.16)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{source.label}
|
|
||||||
</span>
|
|
||||||
<span className={source.ready ? 'text-emerald-300' : 'text-amber-300'}>
|
|
||||||
{source.recentCount}/{source.neededCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5 text-xs text-slate-300">
|
|
||||||
{snapshots.rovers.map((entry) => (
|
|
||||||
<div key={`rover:${entry.id}`} className="flex items-center justify-between">
|
|
||||||
<span
|
|
||||||
className="rounded px-1 py-[1px] border border-transparent"
|
|
||||||
style={roverNameChromeStyle(roverColorFor(entry.id), 0.16)}
|
|
||||||
>
|
|
||||||
{entry.name}
|
|
||||||
</span>
|
|
||||||
<span className={entry.stale ? 'text-amber-300' : 'text-emerald-300'}>
|
|
||||||
{entry.stale ? 'stale' : 'ok'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5 text-xs text-slate-300">
|
|
||||||
{snapshots.rooms.map((entry) => (
|
|
||||||
<div key={`room:${entry.id}`} className="flex items-center justify-between">
|
|
||||||
<span>{entry.name}</span>
|
|
||||||
<span className={entry.stale ? 'text-amber-300' : 'text-emerald-300'}>
|
|
||||||
{entry.error ? 'error' : entry.stale ? 'stale' : 'ok'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminIpLogPanel({ entries }) {
|
|
||||||
const logs = entries || [];
|
|
||||||
return (
|
|
||||||
<div className="panel-section space-y-0.5 text-base">
|
|
||||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
|
||||||
<span>Admin IP log</span>
|
|
||||||
<span>{logs.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="surface h-64 overflow-y-auto font-mono text-xs">
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<p>No admin log entries yet.</p>
|
|
||||||
) : (
|
|
||||||
logs
|
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.map((entry) => (
|
|
||||||
<div key={entry.id} className="surface">
|
|
||||||
<span className="text-amber-400">
|
|
||||||
{entry.ts ? new Date(entry.ts).toLocaleTimeString() : '--'}
|
|
||||||
</span>{' '}
|
|
||||||
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
|
|
||||||
<span className="text-slate-200">{entry.message}</span>{' '}
|
|
||||||
{entry.ip && <span className="text-cyan-300">{entry.ip}</span>}{' '}
|
|
||||||
{entry.meta && <span className="text-slate-500">{JSON.stringify(entry.meta)}</span>}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500">Admin-only log stream; IPs never appear in user data.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// Admin LLM commentary monitor panel.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import ChatMessageRow from '../ChatMessageRow.jsx';
|
||||||
|
import { buildLlmLargeIndicatorFromState, buildLlmConversationRowsFromMessages } from './llmHelpers.js';
|
||||||
|
|
||||||
|
export default function LlmCommentaryPanel({ state, onClearHistory, clearingHistory }) {
|
||||||
|
const [selectedRunId, setSelectedRunId] = useState(null);
|
||||||
|
if (!state) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="panel-muted text-xs uppercase">LLM Commentary</div>
|
||||||
|
<div className="surface text-xs text-slate-300">No status received yet.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const runtime = state.runtime || {};
|
||||||
|
const counters = state.counters || {};
|
||||||
|
const timings = state.timings || {};
|
||||||
|
const input = state.input || {};
|
||||||
|
const output = state.output || {};
|
||||||
|
const errors = state.errors || {};
|
||||||
|
const history = Array.isArray(state.history) ? state.history : [];
|
||||||
|
const selectedRun =
|
||||||
|
history.find((run) => run.runId === selectedRunId) || (history.length ? history[history.length - 1] : null);
|
||||||
|
const largeIndicator = buildLlmLargeIndicatorFromState(state);
|
||||||
|
const conversationRows = buildLlmConversationRowsFromMessages(input.modelMessages, output.raw);
|
||||||
|
const statPills = [
|
||||||
|
{ label: 'running', value: runtime.running ? 'yes' : 'no' },
|
||||||
|
{ label: 'in flight', value: runtime.inFlight ? 'yes' : 'no' },
|
||||||
|
{ label: 'phase', value: runtime.phase || '--' },
|
||||||
|
{ label: 'run id', value: runtime.currentRunId ?? '--' },
|
||||||
|
{ label: 'tick count', value: runtime.tickCount ?? 0 },
|
||||||
|
{
|
||||||
|
label: 'last tick',
|
||||||
|
value: runtime.lastTickAt ? new Date(runtime.lastTickAt).toLocaleString() : 'never',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'next run',
|
||||||
|
value: runtime.nextRunAt ? new Date(runtime.nextRunAt).toLocaleString() : 'n/a',
|
||||||
|
},
|
||||||
|
{ label: 'outcome', value: runtime.outcome || '--' },
|
||||||
|
{ label: 'reason', value: runtime.reason || '--' },
|
||||||
|
{ label: 'skip streak', value: counters.skipStreak ?? 0 },
|
||||||
|
{ label: 'clear count', value: counters.clearCount ?? 0 },
|
||||||
|
{ label: 'last gen', value: timings.lastGenerationMs != null ? `${timings.lastGenerationMs} ms` : '--' },
|
||||||
|
{ label: 'avg gen', value: timings.avgGenerationMs != null ? `${timings.avgGenerationMs} ms` : '--' },
|
||||||
|
{ label: 'gen count', value: timings.generationCount ?? 0 },
|
||||||
|
{ label: 'prompt chars', value: counters.promptChars ?? 0 },
|
||||||
|
{ label: 'active drivers', value: counters.snapshotSummary?.activeDrivers ?? 0 },
|
||||||
|
{ label: 'snapshot rovers', value: counters.snapshotSummary?.rovers ?? 0 },
|
||||||
|
{ label: 'snapshot chat', value: counters.snapshotSummary?.chatMessages ?? 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="panel-muted text-xs uppercase">LLM Commentary</div>
|
||||||
|
<div className="flex gap-0.5 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearHistory}
|
||||||
|
disabled={Boolean(clearingHistory)}
|
||||||
|
className="button-danger disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{clearingHistory ? 'Clearing...' : 'Clear LLM History'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={`surface border text-center ${largeIndicator.className}`}>
|
||||||
|
<div className="text-[1.1rem] font-bold tracking-wide">{largeIndicator.label}</div>
|
||||||
|
<div className="text-xs text-slate-200">{largeIndicator.detail}</div>
|
||||||
|
</div>
|
||||||
|
<div className="surface flex flex-wrap gap-0.5 text-xs">
|
||||||
|
{statPills.map((pill) => (
|
||||||
|
<span
|
||||||
|
key={pill.label}
|
||||||
|
className="rounded border border-slate-600/60 bg-slate-800/70 px-0.5 py-0.25 text-[0.72rem] leading-tight text-slate-200"
|
||||||
|
>
|
||||||
|
{pill.label}: {pill.value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{errors.message ? (
|
||||||
|
<div className="surface text-xs text-red-300 break-words">Error: {errors.message}</div>
|
||||||
|
) : null}
|
||||||
|
{errors.details ? (
|
||||||
|
<details className="surface text-xs text-red-200">
|
||||||
|
<summary className="cursor-pointer select-none text-red-300">Failure details</summary>
|
||||||
|
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-red-200">
|
||||||
|
{JSON.stringify(errors.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
{output.generated ? (
|
||||||
|
<div className="surface text-xs text-slate-200 break-words">Generated: {output.generated}</div>
|
||||||
|
) : null}
|
||||||
|
{output.posted ? (
|
||||||
|
<div className="surface text-xs text-emerald-200 break-words">Posted: {output.posted}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="grid gap-0.5 md:grid-cols-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="panel-muted text-xs uppercase">Live Input Conversation</div>
|
||||||
|
<div className="surface max-h-72 space-y-0.5 overflow-y-auto">
|
||||||
|
{conversationRows.length ? (
|
||||||
|
conversationRows.map((row) => <ChatMessageRow key={row.id} message={row.message} />)
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-slate-300">No model conversation captured yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="panel-muted text-xs uppercase">Output + Error</div>
|
||||||
|
<div className="surface space-y-0.5 text-xs text-slate-200">
|
||||||
|
<div>Raw output: {output.raw?.trim() ? output.raw : '<none>'}</div>
|
||||||
|
<div>Posted: {output.posted || '<none>'}</div>
|
||||||
|
<div>Output at: {output.modelOutputAt ? new Date(output.modelOutputAt).toLocaleString() : 'n/a'}</div>
|
||||||
|
<div>Failed at: {errors.failedAt ? new Date(errors.failedAt).toLocaleString() : 'n/a'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details className="surface text-xs text-slate-200">
|
||||||
|
<summary className="cursor-pointer select-none text-slate-300">Full Monitor Payload</summary>
|
||||||
|
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||||
|
{JSON.stringify(state, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="panel-muted text-xs uppercase">Recent Runs</div>
|
||||||
|
<div className="surface max-h-52 space-y-0.5 overflow-y-auto text-xs">
|
||||||
|
{history.length ? (
|
||||||
|
history
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((run) => (
|
||||||
|
<button
|
||||||
|
key={run.runId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedRunId(run.runId)}
|
||||||
|
className={`w-full text-left surface ${
|
||||||
|
selectedRun?.runId === run.runId ? 'border border-sky-400/50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-slate-300">#{run.runId}</span>{' '}
|
||||||
|
<span className="text-slate-200">{run.outcome || run.phase || '--'}</span>{' '}
|
||||||
|
<span className="text-slate-400">{run.durationMs != null ? `${run.durationMs}ms` : '--'}</span>{' '}
|
||||||
|
<span className="text-slate-500">{run.reason || ''}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-slate-300">No runs recorded yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedRun ? (
|
||||||
|
<details className="surface text-xs text-slate-200" open>
|
||||||
|
<summary className="cursor-pointer select-none text-slate-300">Run #{selectedRun.runId} details</summary>
|
||||||
|
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||||
|
{JSON.stringify(selectedRun, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Replay and snapshot health status panel.
|
||||||
|
import { roverNameChromeStyle } from '../../lib/roverColor.js';
|
||||||
|
|
||||||
|
export default function ReplaySnapshotHealth({ health, roster = [] }) {
|
||||||
|
if (!health) return null;
|
||||||
|
const replay = health.replay || { sources: [], readyCount: 0, totalCount: 0 };
|
||||||
|
const snapshots = health.snapshots || { rovers: [], rooms: [] };
|
||||||
|
const roverColorFor = (id) => roster.find((entry) => String(entry.id) === String(id))?.color || null;
|
||||||
|
const replaySummary = `${replay.readyCount}/${replay.totalCount} sources ready`;
|
||||||
|
const roverStale = snapshots.rovers.filter((entry) => entry.stale).length;
|
||||||
|
const roomStale = snapshots.rooms.filter((entry) => entry.stale).length;
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="panel-muted text-xs uppercase">Health</div>
|
||||||
|
<div className="surface space-y-0.5 text-xs text-slate-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Replay segments</span>
|
||||||
|
<span className="text-slate-400">{replaySummary}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Rover snapshots</span>
|
||||||
|
<span className={roverStale ? 'text-amber-300' : 'text-emerald-300'}>
|
||||||
|
{snapshots.rovers.length - roverStale}/{snapshots.rovers.length} ok
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Room cameras</span>
|
||||||
|
<span className={roomStale ? 'text-amber-300' : 'text-emerald-300'}>
|
||||||
|
{snapshots.rooms.length - roomStale}/{snapshots.rooms.length} ok
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 text-xs text-slate-300">
|
||||||
|
{replay.sources.map((source) => (
|
||||||
|
<div key={`${source.type}:${source.id}`} className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`${source.type === 'rover' ? 'rounded px-1 py-[1px] border border-transparent' : ''}`}
|
||||||
|
style={source.type === 'rover' ? roverNameChromeStyle(roverColorFor(source.id), 0.16) : undefined}
|
||||||
|
>
|
||||||
|
{source.label}
|
||||||
|
</span>
|
||||||
|
<span className={source.ready ? 'text-emerald-300' : 'text-amber-300'}>
|
||||||
|
{source.recentCount}/{source.neededCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 text-xs text-slate-300">
|
||||||
|
{snapshots.rovers.map((entry) => (
|
||||||
|
<div key={`rover:${entry.id}`} className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className="rounded px-1 py-[1px] border border-transparent"
|
||||||
|
style={roverNameChromeStyle(roverColorFor(entry.id), 0.16)}
|
||||||
|
>
|
||||||
|
{entry.name}
|
||||||
|
</span>
|
||||||
|
<span className={entry.stale ? 'text-amber-300' : 'text-emerald-300'}>{entry.stale ? 'stale' : 'ok'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 text-xs text-slate-300">
|
||||||
|
{snapshots.rooms.map((entry) => (
|
||||||
|
<div key={`room:${entry.id}`} className="flex items-center justify-between">
|
||||||
|
<span>{entry.name}</span>
|
||||||
|
<span className={entry.stale ? 'text-amber-300' : 'text-emerald-300'}>
|
||||||
|
{entry.error ? 'error' : entry.stale ? 'stale' : 'ok'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import AdminPanelContent from './AdminPanelContent.jsx';
|
||||||
|
|
||||||
|
export default AdminPanelContent;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// LLM commentary monitor formatting helpers.
|
||||||
|
export function buildLlmLargeIndicatorFromState(state) {
|
||||||
|
const runtime = state?.runtime || {};
|
||||||
|
const output = state?.output || {};
|
||||||
|
const errors = state?.errors || {};
|
||||||
|
if (runtime.inFlight) {
|
||||||
|
return {
|
||||||
|
label: 'IN FLIGHT',
|
||||||
|
detail: runtime.reason || 'Generating commentary now',
|
||||||
|
className: 'border-amber-400/60 bg-amber-700/20 text-amber-200',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (runtime.outcome === 'posted') {
|
||||||
|
return {
|
||||||
|
label: 'POSTED',
|
||||||
|
detail: output.posted ? `Last: ${output.posted}` : 'Commentary posted',
|
||||||
|
className: 'border-emerald-400/60 bg-emerald-700/20 text-emerald-200',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (runtime.outcome === 'skipped') {
|
||||||
|
return {
|
||||||
|
label: 'SKIPPED',
|
||||||
|
detail: runtime.reason || 'Model chose to skip',
|
||||||
|
className: 'border-slate-400/60 bg-slate-700/30 text-slate-200',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (runtime.outcome === 'failed') {
|
||||||
|
return {
|
||||||
|
label: 'FAILED',
|
||||||
|
detail: errors.message || runtime.reason || 'Tick failed',
|
||||||
|
className: 'border-red-400/60 bg-red-700/20 text-red-200',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: runtime.running ? 'IDLE' : 'STOPPED',
|
||||||
|
detail: runtime.reason || 'Waiting for next tick',
|
||||||
|
className: 'border-sky-400/50 bg-sky-700/20 text-sky-200',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
|
||||||
|
const now = Date.now();
|
||||||
|
const messages = Array.isArray(modelMessages) ? modelMessages : [];
|
||||||
|
const rows = messages.map((entry, index) => {
|
||||||
|
const role = String(entry?.role || '').toLowerCase();
|
||||||
|
const content =
|
||||||
|
typeof entry?.content === 'string' ? entry.content : JSON.stringify(entry?.content ?? null, null, 2);
|
||||||
|
const nickname =
|
||||||
|
role === 'system'
|
||||||
|
? 'LLM System'
|
||||||
|
: role === 'assistant'
|
||||||
|
? 'LLM Context'
|
||||||
|
: role === 'user'
|
||||||
|
? 'LLM Input'
|
||||||
|
: 'LLM Message';
|
||||||
|
return {
|
||||||
|
id: `llm-msg-${index}`,
|
||||||
|
message: {
|
||||||
|
ts: now + index,
|
||||||
|
nickname,
|
||||||
|
text: content,
|
||||||
|
role: 'spectator',
|
||||||
|
system: role === 'system',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (rawOutput != null) {
|
||||||
|
const raw = String(rawOutput);
|
||||||
|
rows.push({
|
||||||
|
id: 'llm-output',
|
||||||
|
message: {
|
||||||
|
ts: now + rows.length + 1,
|
||||||
|
nickname: 'LLM Output',
|
||||||
|
text: raw.trim() ? raw : '<empty>',
|
||||||
|
role: 'spectator',
|
||||||
|
system: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useControlSystem } from '../controls/index.js';
|
import { useControlSystem } from '../controls/index.js';
|
||||||
import AuthPanel from './AuthPanel.jsx';
|
import AuthPanel from './AuthPanel.jsx';
|
||||||
import AdminPanel from './AdminPanel.jsx';
|
import AdminPanel from './AdminPanel/index.jsx';
|
||||||
import KeymapSettings from './KeymapSettings.jsx';
|
import KeymapSettings from './KeymapSettings.jsx';
|
||||||
import GamepadMappingSettings from './GamepadMappingSettings.jsx';
|
import GamepadMappingSettings from './GamepadMappingSettings.jsx';
|
||||||
import OvercurrentLimiterPanel from './OvercurrentLimiterPanel.jsx';
|
import OvercurrentLimiterPanel from './OvercurrentLimiterPanel.jsx';
|
||||||
|
|||||||
Reference in New Issue
Block a user