This commit is contained in:
legop3
2025-11-15 14:16:12 -05:00
parent e3a0161ba6
commit a8090ce2fb
8 changed files with 162 additions and 10 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title> <title>webui</title>
<script type="module" crossorigin src="/assets/index-n1qWThah.js"></script> <script type="module" crossorigin src="/assets/index-_yFT6hTb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cyy-WeOF.css"> <link rel="stylesheet" crossorigin href="/assets/index-CIpY4YLn.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+4
View File
@@ -3,6 +3,8 @@ import { useSession } from './context/SessionContext.jsx';
import TelemetryPanel from './components/TelemetryPanel.jsx'; import TelemetryPanel from './components/TelemetryPanel.jsx';
import VideoTile from './components/VideoTile.jsx'; import VideoTile from './components/VideoTile.jsx';
import DrivePanel from './components/DrivePanel.jsx'; import DrivePanel from './components/DrivePanel.jsx';
import AlertFeed from './components/AlertFeed.jsx';
import AdminPanel from './components/AdminPanel.jsx';
import { useVideoRequests } from './hooks/useVideoRequests.js'; import { useVideoRequests } from './hooks/useVideoRequests.js';
function StatusBadge({ connected, role, mode }) { function StatusBadge({ connected, role, mode }) {
@@ -268,6 +270,8 @@ function App() {
<TelemetryPanel /> <TelemetryPanel />
<AuthPanel /> <AuthPanel />
<SessionInspector /> <SessionInspector />
<AdminPanel />
<AlertFeed />
</section> </section>
<LogPanel /> <LogPanel />
</main> </main>
+110
View File
@@ -0,0 +1,110 @@
import { useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
const MODES = [
{ key: 'open', label: 'Open' },
{ key: 'turns', label: 'Turns' },
{ key: 'admin', label: 'Admin' },
{ key: 'lockdown', label: 'Lockdown' },
];
export default function AdminPanel() {
const { session, lockRover, setMode, requestControl } = useSession();
const roster = session?.roster ?? [];
const [lockStates, setLockStates] = useState({});
const isAdmin =
session?.role === 'admin' ||
session?.role === 'lockdown' ||
session?.role === 'lockdown-admin';
const currentMode = session?.mode ?? 'open';
const handleLockToggle = async (roverId, locked) => {
try {
await lockRover(roverId, locked);
setLockStates((prev) => ({ ...prev, [roverId]: locked }));
} catch (err) {
alert(err.message);
}
};
const handleModeChange = async (event) => {
const mode = event.target.value;
try {
await setMode(mode);
} catch (err) {
alert(err.message);
}
};
const handleForceControl = async (roverId) => {
try {
await requestControl(roverId, { force: true });
} catch (err) {
alert(err.message);
}
};
const lockMap = useMemo(() => {
const map = {};
roster.forEach((rover) => {
map[rover.id] = lockStates[rover.id] ?? rover.locked;
});
return map;
}, [roster, lockStates]);
if (!isAdmin) return null;
return (
<section className="rounded-2xl border border-amber-600/40 bg-amber-500/5 p-4 text-sm text-amber-100">
<header className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-white">Admin Controls</h2>
<div className="flex items-center gap-2">
<label className="text-xs uppercase tracking-[0.3em] text-amber-300">Server mode</label>
<select
value={currentMode}
onChange={handleModeChange}
className="rounded-md border border-amber-600/40 bg-slate-950/60 px-2 py-1 text-sm text-white"
>
{MODES.map((mode) => (
<option key={mode.key} value={mode.key}>
{mode.label}
</option>
))}
</select>
</div>
</header>
<div className="space-y-3">
{roster.map((rover) => (
<div
key={rover.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-600/30 bg-slate-950/30 px-3 py-2"
>
<div>
<p className="text-white">{rover.name}</p>
<p className="text-xs text-amber-200">Locked: {lockMap[rover.id] ? 'yes' : 'no'}</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
className="rounded-md border border-amber-500 px-3 py-1 text-xs font-semibold text-amber-100"
>
{lockMap[rover.id] ? 'Unlock' : 'Lock'}
</button>
<button
type="button"
onClick={() => handleForceControl(rover.id)}
className="rounded-md border border-amber-500 px-3 py-1 text-xs font-semibold text-amber-100"
>
Force Control
</button>
</div>
</div>
))}
</div>
</section>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { useMemo } from 'react';
import { useSession } from '../context/SessionContext.jsx';
function AlertCard({ alert }) {
const color =
alert.color === 'error'
? 'bg-red-500/10 border-red-500/40 text-red-100'
: alert.color === 'warn'
? 'bg-amber-500/10 border-amber-500/40 text-amber-100'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-100';
return (
<div className={`rounded-2xl border px-4 py-3 text-sm ${color}`}>
<p className="text-xs uppercase tracking-[0.3em] opacity-60">{alert.title || 'Alert'}</p>
<p className="mt-1 text-base font-semibold">{alert.message}</p>
</div>
);
}
export default function AlertFeed() {
const { alerts } = useSession();
const recent = useMemo(() => alerts.slice(-5).reverse(), [alerts]);
if (recent.length === 0) return null;
return (
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 className="text-lg font-semibold text-white">Recent Alerts</h2>
<div className="mt-4 space-y-3">{recent.map((alert) => <AlertCard key={alert.id || alert.message} alert={alert} />)}</div>
</section>
);
}
+9 -1
View File
@@ -33,6 +33,7 @@ export function SessionProvider({ children }) {
const emitWithAck = useAckEmitter(socket); const emitWithAck = useAckEmitter(socket);
const [session, setSession] = useState(null); const [session, setSession] = useState(null);
const [logs, setLogs] = useState([]); const [logs, setLogs] = useState([]);
const [alerts, setAlerts] = useState([]);
const [connected, setConnected] = useState(socket.connected); const [connected, setConnected] = useState(socket.connected);
useEffect(() => { useEffect(() => {
@@ -59,10 +60,14 @@ export function SessionProvider({ children }) {
socket.on('session:sync', handleSession); socket.on('session:sync', handleSession);
socket.on('log:init', handleLogInit); socket.on('log:init', handleLogInit);
socket.on('log:entry', handleLogEntry); socket.on('log:entry', handleLogEntry);
socket.on('alert:new', (payload = {}) => {
setAlerts((prev) => [...prev.slice(-49), payload]);
});
return () => { return () => {
socket.off('session:sync', handleSession); socket.off('session:sync', handleSession);
socket.off('log:init', handleLogInit); socket.off('log:init', handleLogInit);
socket.off('log:entry', handleLogEntry); socket.off('log:entry', handleLogEntry);
socket.off('alert:new');
}; };
}, [socket]); }, [socket]);
@@ -74,6 +79,8 @@ export function SessionProvider({ children }) {
emitWithAck('session:requestControl', { roverId, ...options }), emitWithAck('session:requestControl', { roverId, ...options }),
releaseControl: (roverId) => emitWithAck('session:releaseControl', { roverId }), releaseControl: (roverId) => emitWithAck('session:releaseControl', { roverId }),
subscribeAll: () => emitWithAck('session:subscribeAll'), subscribeAll: () => emitWithAck('session:subscribeAll'),
lockRover: (roverId, locked) => emitWithAck('session:lockRover', { roverId, locked }),
setMode: (mode) => emitWithAck('setMode', { mode }),
}), }),
[emitWithAck], [emitWithAck],
); );
@@ -83,9 +90,10 @@ export function SessionProvider({ children }) {
connected, connected,
session, session,
logs, logs,
alerts,
...actions, ...actions,
}), }),
[actions, connected, logs, session], [actions, alerts, connected, logs, session],
); );
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>; return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;