mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
guh
This commit is contained in:
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
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui</title>
|
||||
<script type="module" crossorigin src="/assets/index-BrPsNUkx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DZL3Zaf5.css">
|
||||
<script type="module" crossorigin src="/assets/index-CJa482Eg.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BUE8ypH_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+52
-201
@@ -10,28 +10,11 @@ import MobileControls, { MobileJoystick, AuxMotorControls } from './components/M
|
||||
import { DriveControlProvider } from './context/DriveControlContext.jsx';
|
||||
import { useVideoRequests } from './hooks/useVideoRequests.js';
|
||||
|
||||
function StatusBadge({ connected, role, mode }) {
|
||||
const color = connected ? 'bg-emerald-500/20 text-emerald-300' : 'bg-red-500/20 text-red-200';
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1 text-[0.65rem] uppercase tracking-[0.2em]">
|
||||
<span className={`rounded-full px-2 py-0.5 font-medium ${color}`}>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-800/80 px-2 py-0.5 text-slate-200">
|
||||
Role {role || 'unknown'}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-800/80 px-2 py-0.5 text-slate-200">
|
||||
Mode {mode || '--'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomCameraPanel() {
|
||||
return (
|
||||
<div className="min-h-[8rem] rounded-lg border border-slate-800 bg-slate-900/70 p-2 text-[0.75rem] text-slate-400">
|
||||
<p className="text-center text-xs uppercase tracking-[0.3em] text-slate-500">Room camera</p>
|
||||
<p className="mt-2 text-center text-sm text-slate-300">Feed placeholder. Wire upcoming room cam here.</p>
|
||||
<div className="min-h-[8rem] rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-200">
|
||||
<p className="text-center text-xs text-slate-400">Room camera</p>
|
||||
<p className="mt-1 text-center text-sm text-slate-200">Feed placeholder. Wire upcoming room cam here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,14 +52,12 @@ function useLayoutMode() {
|
||||
function LogPanel() {
|
||||
const { logs } = useSession();
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-900/70 p-4">
|
||||
<header className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">Server logs</h2>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||
{logs.length} entries
|
||||
</span>
|
||||
</header>
|
||||
<div className="h-64 overflow-y-auto rounded-xl bg-slate-950/40 p-3 text-xs font-mono text-slate-300">
|
||||
<div className="rounded-sm bg-[#1b1b1b] p-1 text-xs text-slate-200">
|
||||
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span>Server logs</span>
|
||||
<span>{logs.length}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-48 overflow-y-auto rounded-sm bg-black/40 p-1 font-mono text-[0.65rem] text-slate-300">
|
||||
{logs.length === 0 ? (
|
||||
<p>No logs yet.</p>
|
||||
) : (
|
||||
@@ -84,10 +65,10 @@ function LogPanel() {
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry) => (
|
||||
<div key={entry.id} className="mb-2">
|
||||
<div key={entry.id} className="mb-1">
|
||||
<span className="text-slate-500">{entry.timestamp}</span>{' '}
|
||||
<span className="text-cyan-300">[{entry.level}]</span>{' '}
|
||||
{entry.label && <span className="text-pink-300">[{entry.label}]</span>}{' '}
|
||||
<span className="text-slate-300">[{entry.level}]</span>{' '}
|
||||
{entry.label && <span className="text-slate-400">[{entry.label}]</span>}{' '}
|
||||
<span>{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
@@ -97,113 +78,6 @@ function LogPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function RosterPanel() {
|
||||
const { session, requestControl } = useSession();
|
||||
const roster = session?.roster ?? [];
|
||||
const isAdmin = session?.role === 'admin' || session?.role === 'lockdown';
|
||||
const [pending, setPending] = useState({});
|
||||
|
||||
async function handleRequest(roverId) {
|
||||
setPending((prev) => ({ ...prev, [roverId]: true }));
|
||||
try {
|
||||
await requestControl(roverId);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending((prev) => ({ ...prev, [roverId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<h2 className="text-lg font-semibold text-white">Rovers</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{roster.length === 0 && <p className="text-slate-400">No rovers registered.</p>}
|
||||
{roster.map((rover) => (
|
||||
<div
|
||||
key={rover.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-slate-950/40 px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-white">{rover.name}</p>
|
||||
<p className="text-xs text-slate-400">Locked: {rover.locked ? 'yes' : 'no'}</p>
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRequest(rover.id)}
|
||||
disabled={pending[rover.id]}
|
||||
className="rounded-full bg-blue-600 px-4 py-1 text-sm font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
{pending[rover.id] ? 'Requesting…' : 'Request control'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">Auto-assigned</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentCard() {
|
||||
const { session, releaseControl } = useSession();
|
||||
const assignment = session?.assignment;
|
||||
const roverId = assignment?.roverId;
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handleRelease() {
|
||||
if (!roverId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await releaseControl(roverId);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<h2 className="text-lg font-semibold text-white">Assignment</h2>
|
||||
{roverId ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-slate-300">Currently driving rover {roverId}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRelease}
|
||||
disabled={busy}
|
||||
className="rounded-full bg-red-600 px-4 py-1 text-sm font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Releasing…' : 'Release control'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 text-slate-400">
|
||||
<p>Status: {assignment?.status ?? 'not assigned'}</p>
|
||||
{assignment?.queuePosition && (
|
||||
<p>Queue position: {assignment.queuePosition}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionInspector() {
|
||||
const { session } = useSession();
|
||||
const formatted = useMemo(() => JSON.stringify(session ?? {}, null, 2), [session]);
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<h2 className="text-lg font-semibold text-white">Session snapshot</h2>
|
||||
<pre className="mt-4 max-h-64 overflow-y-auto text-sm text-teal-200">{formatted}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DriverVideoPanel() {
|
||||
const { session } = useSession();
|
||||
const roverId = session?.assignment?.roverId;
|
||||
@@ -217,25 +91,19 @@ function DriverVideoPanel() {
|
||||
}, [session?.roster, roverId]);
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<header className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Video Feed</p>
|
||||
<h2 className="text-2xl font-semibold text-white">
|
||||
{roverId ? `Rover ${roverId}` : 'No rover assigned'}
|
||||
</h2>
|
||||
</div>
|
||||
</header>
|
||||
<section className="rounded-sm bg-[#1b1b1b] p-1">
|
||||
{roverId ? (
|
||||
<VideoTile
|
||||
sessionInfo={info}
|
||||
label={roverId}
|
||||
muted={false}
|
||||
telemetryFrame={frame}
|
||||
batteryConfig={batteryConfig}
|
||||
/>
|
||||
<div className="min-h-[55vh]">
|
||||
<VideoTile
|
||||
sessionInfo={info}
|
||||
label={roverId}
|
||||
muted={false}
|
||||
telemetryFrame={frame}
|
||||
batteryConfig={batteryConfig}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">Assignment required to initialize video.</p>
|
||||
<p className="text-sm text-slate-400">Assign a rover to view the video feed.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
@@ -262,17 +130,17 @@ function AuthPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<h2 className="text-lg font-semibold text-white">Authentication</h2>
|
||||
<form className="mt-4 flex flex-col gap-3" onSubmit={handleLogin}>
|
||||
<div className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-200">
|
||||
<p className="text-xs text-slate-400">Admin login</p>
|
||||
<form className="mt-1 flex flex-col gap-1" onSubmit={handleLogin}>
|
||||
<input
|
||||
className="rounded-lg border border-slate-800 bg-slate-950/60 px-3 py-2 text-white"
|
||||
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded-lg border border-slate-800 bg-slate-950/60 px-3 py-2 text-white"
|
||||
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
@@ -281,25 +149,25 @@ function AuthPanel() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-emerald-600 py-2 font-semibold text-white disabled:opacity-50"
|
||||
className="rounded-sm bg-slate-200 px-1 py-1 text-xs font-semibold text-black disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Logging in…' : 'Login as admin'}
|
||||
{loading ? 'Logging in…' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<div className="mt-1 flex gap-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole('user')}
|
||||
className="rounded-lg border border-slate-700 px-3 py-1 text-sm text-slate-200"
|
||||
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
|
||||
>
|
||||
Driver mode
|
||||
Driver
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole('spectator')}
|
||||
className="rounded-lg border border-slate-700 px-3 py-1 text-sm text-slate-200"
|
||||
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
|
||||
>
|
||||
Spectator mode
|
||||
Spectator
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,24 +176,19 @@ function AuthPanel() {
|
||||
|
||||
function DesktopLayout() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_minmax(0,1fr)] gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<section className="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.6fr)_minmax(0,0.9fr)] gap-1">
|
||||
<TelemetryPanel />
|
||||
<DriverVideoPanel />
|
||||
<DrivePanel />
|
||||
</section>
|
||||
<section className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)] gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<section className="grid grid-cols-[repeat(3,minmax(0,1fr))] gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<AuthPanel />
|
||||
<AdminPanel />
|
||||
<RosterPanel />
|
||||
</div>
|
||||
<RoomCameraPanel />
|
||||
<div className="flex flex-col gap-2">
|
||||
<AssignmentCard />
|
||||
<LogPanel />
|
||||
<SessionInspector />
|
||||
</div>
|
||||
<LogPanel />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
@@ -333,47 +196,41 @@ function DesktopLayout() {
|
||||
|
||||
function MobilePortraitLayout() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<DriverVideoPanel />
|
||||
<MobileControls />
|
||||
<DrivePanel />
|
||||
<TelemetryPanel />
|
||||
<RosterPanel />
|
||||
<AssignmentCard />
|
||||
<AuthPanel />
|
||||
<AdminPanel />
|
||||
<RoomCameraPanel />
|
||||
<LogPanel />
|
||||
<SessionInspector />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileLandscapeLayout() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<section className="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.2fr)_minmax(0,0.9fr)] gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_minmax(0,1fr)] gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<DrivePanel />
|
||||
<AuxMotorControls />
|
||||
</div>
|
||||
<DriverVideoPanel />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<MobileJoystick />
|
||||
<TelemetryPanel />
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<RosterPanel />
|
||||
<AssignmentCard />
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<AuthPanel />
|
||||
<AdminPanel />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<RoomCameraPanel />
|
||||
<LogPanel />
|
||||
<SessionInspector />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -381,21 +238,15 @@ function MobileLandscapeLayout() {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { connected, session } = useSession();
|
||||
const layout = useLayoutMode();
|
||||
const renderedLayout = layout === 'desktop' ? <DesktopLayout /> : layout === 'mobile-landscape' ? <MobileLandscapeLayout /> : <MobilePortraitLayout />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-slate-50">
|
||||
<main className="mx-auto flex w-full max-w-screen-2xl flex-col gap-2 px-1 py-1">
|
||||
<DriveControlProvider>
|
||||
<div className="flex justify-end">
|
||||
<StatusBadge connected={connected} role={session?.role} mode={session?.mode} />
|
||||
</div>
|
||||
{renderedLayout}
|
||||
<AlertFeed />
|
||||
</DriveControlProvider>
|
||||
</main>
|
||||
<DriveControlProvider>
|
||||
<main className="flex w-full flex-col gap-1 px-1 py-1">{renderedLayout}</main>
|
||||
<AlertFeed />
|
||||
</DriveControlProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,49 +57,43 @@ export default function AdminPanel() {
|
||||
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>
|
||||
<section className="rounded-sm bg-[#1b1b1b] p-1 text-xs text-slate-200">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span>Admin controls</span>
|
||||
<select
|
||||
value={currentMode}
|
||||
onChange={handleModeChange}
|
||||
className="rounded-sm bg-black/40 px-1 py-0.5 text-[0.7rem] text-slate-200"
|
||||
>
|
||||
{MODES.map((mode) => (
|
||||
<option key={mode.key} value={mode.key}>
|
||||
{mode.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="mt-1 space-y-1">
|
||||
{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 key={rover.id} className="flex flex-wrap items-center justify-between gap-1 rounded-sm bg-black/30 px-1 py-1">
|
||||
<div>
|
||||
<p className="text-white">{rover.name}</p>
|
||||
<p className="text-xs text-amber-200">Locked: {lockMap[rover.id] ? 'yes' : 'no'}</p>
|
||||
<p className="text-slate-100">{rover.name}</p>
|
||||
<p className="text-[0.6rem] text-slate-500">{lockMap[rover.id] ? 'locked' : 'unlocked'}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-1 text-[0.6rem]">
|
||||
<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"
|
||||
className="rounded-sm bg-black/50 px-1 py-0.5 text-slate-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"
|
||||
className="rounded-sm bg-black/50 px-1 py-0.5 text-slate-100"
|
||||
>
|
||||
Force Control
|
||||
Force
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function AlertFeed() {
|
||||
if (!visible.length) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-2 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
|
||||
<div className="pointer-events-none fixed top-1 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-1">
|
||||
{visible.map((toast) => (
|
||||
<AlertToast key={toast.key} alert={toast.alert} />
|
||||
))}
|
||||
@@ -41,14 +41,14 @@ export default function AlertFeed() {
|
||||
function AlertToast({ alert }) {
|
||||
const colorClasses =
|
||||
alert.color === 'error'
|
||||
? 'border-red-500/50 bg-red-500/20 text-red-100'
|
||||
? 'bg-red-500/30 text-red-100'
|
||||
: alert.color === 'warn'
|
||||
? 'border-amber-500/50 bg-amber-500/20 text-amber-100'
|
||||
: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-100';
|
||||
? 'bg-amber-500/30 text-amber-100'
|
||||
: 'bg-emerald-500/30 text-emerald-100';
|
||||
return (
|
||||
<div className={`pointer-events-auto rounded-md border px-3 py-2 text-[0.75rem] shadow-lg shadow-black/60 ${colorClasses}`}>
|
||||
<p className="text-[0.6rem] uppercase tracking-[0.4em] opacity-70">{alert.title || 'Alert'}</p>
|
||||
<p className="text-sm font-semibold">{alert.message}</p>
|
||||
<div className={`pointer-events-auto rounded-sm px-2 py-1 text-[0.75rem] shadow-lg shadow-black/60 ${colorClasses}`}>
|
||||
<p className="text-[0.65rem] text-slate-300">{alert.title || 'Alert'}</p>
|
||||
<p className="text-sm text-white">{alert.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,25 +19,25 @@ export default function DrivePanel() {
|
||||
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-100">
|
||||
<div className="flex items-center justify-between text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">
|
||||
<span>Drive Control</span>
|
||||
<section className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-100">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Drive control</span>
|
||||
<span>{roverId ? `Rover ${roverId}` : 'unassigned'}</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="mt-1 space-y-1">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runStartDockFull}
|
||||
disabled={!roverId}
|
||||
className="w-full rounded border border-emerald-500/40 bg-emerald-500/10 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.3em] text-emerald-200 disabled:opacity-40"
|
||||
className="w-full rounded-sm bg-black/50 px-1 py-1 text-xs text-slate-200 disabled:opacity-30"
|
||||
>
|
||||
Enable Driving Mode
|
||||
Enable driving mode
|
||||
</button>
|
||||
<p className="mt-1 text-[0.65rem] text-slate-400">Runs Start → Dock → Full commands to ready the rover.</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 text-[0.6rem]">
|
||||
<StatusPill label="Driving mode" active={drivingMode} />
|
||||
{updated && <span className="text-slate-500">Updated {updated}</span>}
|
||||
<p className="mt-1 text-xs text-slate-400">Runs Start → Dock → Full to prep sensors.</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 text-[0.65rem]">
|
||||
<StatusPill label="Driving" active={drivingMode} />
|
||||
{updated && <span className="text-slate-500">Sensors {updated}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -45,13 +45,11 @@ export default function DrivePanel() {
|
||||
type="button"
|
||||
onClick={seekDock}
|
||||
disabled={!roverId}
|
||||
className="w-full rounded border border-cyan-500/40 bg-cyan-500/10 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.3em] text-cyan-200 disabled:opacity-40"
|
||||
className="w-full rounded-sm bg-black/50 px-1 py-1 text-xs text-slate-200 disabled:opacity-30"
|
||||
>
|
||||
Seek Dock
|
||||
Seek dock
|
||||
</button>
|
||||
<p className="mt-1 text-[0.65rem] text-slate-400">
|
||||
Point the rover straight at the dock, about one foot away, before triggering.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">Point the rover straight at the dock, about one foot away.</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<StatusPill label={docked ? 'Docked' : 'Not docked'} active={docked} />
|
||||
<StatusPill label={charging ? 'Charging' : 'Not charging'} active={charging} />
|
||||
@@ -59,7 +57,7 @@ export default function DrivePanel() {
|
||||
</div>
|
||||
<SpeedRow left={speeds.left} right={speeds.right} />
|
||||
<div>
|
||||
<p className="text-[0.6rem] uppercase tracking-[0.3em] text-slate-500">Manual OI</p>
|
||||
<p className="text-xs text-slate-400">Manual OI</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{manualOiButtons.map((btn) => (
|
||||
<button
|
||||
@@ -67,7 +65,7 @@ export default function DrivePanel() {
|
||||
type="button"
|
||||
onClick={() => sendOiCommand(btn.key)}
|
||||
disabled={!roverId}
|
||||
className="rounded border border-slate-800 px-2 py-0.5 text-[0.65rem] uppercase tracking-[0.2em] text-slate-200 disabled:opacity-30"
|
||||
className="rounded-sm bg-black/40 px-1 py-0.5 text-[0.65rem] text-slate-200 disabled:opacity-30"
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
@@ -79,26 +77,26 @@ export default function DrivePanel() {
|
||||
type="button"
|
||||
onClick={stopMotors}
|
||||
disabled={!roverId}
|
||||
className="flex-1 rounded border border-red-500/50 bg-red-500/10 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.3em] text-red-200 disabled:opacity-40"
|
||||
className="flex-1 rounded-sm bg-red-600/60 px-1 py-1 text-xs text-white disabled:opacity-40"
|
||||
>
|
||||
Stop Motors
|
||||
Stop motors
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-[0.6rem] text-slate-500">Sensor streaming auto-starts after each OI change.</p>
|
||||
<p className="mt-1 text-[0.65rem] text-slate-500">Sensor streaming auto-starts after each OI change.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeedRow({ left, right }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1 rounded border border-slate-900 bg-black/50 p-1 text-[0.7rem]">
|
||||
<div className="grid grid-cols-2 gap-1 rounded-sm bg-black/40 p-1 text-[0.7rem]">
|
||||
<div>
|
||||
<p className="text-[0.55rem] uppercase tracking-[0.3em] text-slate-500">Left</p>
|
||||
<p className="text-[0.6rem] text-slate-500">Left</p>
|
||||
<p className="font-semibold text-slate-100">{left}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[0.55rem] uppercase tracking-[0.3em] text-slate-500">Right</p>
|
||||
<p className="text-[0.6rem] text-slate-500">Right</p>
|
||||
<p className="font-semibold text-slate-100">{right}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,10 +106,8 @@ function SpeedRow({ left, right }) {
|
||||
function StatusPill({ label, active }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[0.55rem] uppercase tracking-[0.3em] ${
|
||||
active
|
||||
? 'border-emerald-400 bg-emerald-500/10 text-emerald-200'
|
||||
: 'border-slate-700 bg-slate-900 text-slate-500'
|
||||
className={`rounded-sm px-1 py-0.5 text-[0.6rem] ${
|
||||
active ? 'bg-emerald-500/30 text-emerald-100' : 'bg-black/40 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -27,9 +27,9 @@ function MobileJoystick() {
|
||||
}, [disabled, driveWithVector]);
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-100">
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">Joystick</div>
|
||||
<div className="mt-2 flex items-center justify-center">
|
||||
<section className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-100">
|
||||
<div className="text-xs text-slate-400">Joystick</div>
|
||||
<div className="mt-1 flex items-center justify-center">
|
||||
<Joystick
|
||||
size={120}
|
||||
baseColor="#0f172a"
|
||||
@@ -40,14 +40,14 @@ function MobileJoystick() {
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[0.65rem] text-slate-400">
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Drag to drive. Release to stop sending drive commands.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopMotors}
|
||||
disabled={disabled}
|
||||
className="mt-2 w-full rounded border border-slate-800 bg-black/40 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-slate-200 disabled:opacity-40"
|
||||
className="mt-1 w-full rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
|
||||
>
|
||||
Panic Stop
|
||||
</button>
|
||||
@@ -79,14 +79,14 @@ function AuxMotorControls() {
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-100">
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">Aux Motors</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<section className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-100">
|
||||
<div className="text-xs text-slate-400">Aux motors</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={runAllForward}
|
||||
disabled={disabled}
|
||||
className="flex-1 rounded border border-emerald-500/40 bg-emerald-500/10 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-emerald-200 disabled:opacity-40"
|
||||
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
|
||||
>
|
||||
All forward
|
||||
</button>
|
||||
@@ -94,19 +94,19 @@ function AuxMotorControls() {
|
||||
type="button"
|
||||
onClick={stopAll}
|
||||
disabled={disabled}
|
||||
className="flex-1 rounded border border-slate-800 bg-black/40 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-slate-200 disabled:opacity-40"
|
||||
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
|
||||
>
|
||||
Stop all
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-1 text-[0.7rem]">
|
||||
<div className="mt-1 grid grid-cols-2 gap-1 text-[0.75rem]">
|
||||
{auxButtons.map((btn) => (
|
||||
<button
|
||||
key={btn.label}
|
||||
type="button"
|
||||
onClick={() => !disabled && setAuxMotors(btn.values)}
|
||||
disabled={disabled}
|
||||
className="rounded border border-slate-800 bg-black/30 py-1 uppercase tracking-[0.2em] text-slate-200 disabled:opacity-30"
|
||||
className="rounded-sm bg-black/30 px-1 py-1 text-slate-200 disabled:opacity-30"
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
@@ -118,7 +118,7 @@ function AuxMotorControls() {
|
||||
|
||||
export default function MobileControlsStack() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<MobileJoystick />
|
||||
<AuxMotorControls />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
|
||||
@@ -7,50 +8,99 @@ function formatMetric(value, fallback = '--') {
|
||||
}
|
||||
|
||||
export default function TelemetryPanel() {
|
||||
const { session } = useSession();
|
||||
const { connected, session, requestControl } = useSession();
|
||||
const roverId = session?.assignment?.roverId;
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const sensors = frame?.sensors || {};
|
||||
const voltage = sensors.voltageMv != null ? (sensors.voltageMv / 1000).toFixed(2) : null;
|
||||
const current = sensors.currentMa != null ? (sensors.currentMa / 1000).toFixed(2) : null;
|
||||
const voltage = sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : null;
|
||||
const current = sensors.currentMa != null ? `${sensors.currentMa} mA` : null;
|
||||
const charge = sensors.batteryChargeMah;
|
||||
const capacity = sensors.batteryCapacityMah;
|
||||
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
|
||||
const rawSnippet = frame?.raw ? (frame.raw.length > 80 ? `${frame.raw.slice(0, 80)}…` : frame.raw) : null;
|
||||
const roster = session?.roster ?? [];
|
||||
const isAdmin = useMemo(
|
||||
() => session?.role === 'admin' || session?.role === 'lockdown' || session?.role === 'lockdown-admin',
|
||||
[session?.role],
|
||||
);
|
||||
const [pending, setPending] = useState({});
|
||||
|
||||
async function handleRequest(roverId) {
|
||||
if (!roverId) return;
|
||||
setPending((prev) => ({ ...prev, [roverId]: true }));
|
||||
try {
|
||||
await requestControl(roverId);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setPending((prev) => ({ ...prev, [roverId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-200">
|
||||
<div className="flex items-center justify-between text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">
|
||||
<span>Telemetry</span>
|
||||
<span>{updated ? `Updated ${updated}` : 'waiting'}</span>
|
||||
<section className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-200">
|
||||
<div className="text-[0.7rem] text-slate-400">
|
||||
<span>{connected ? 'online' : 'offline'}</span>
|
||||
<span> · role {session?.role || 'unknown'}</span>
|
||||
<span> · mode {session?.mode || '--'}</span>
|
||||
{updated && <span> · sensors {updated}</span>}
|
||||
</div>
|
||||
{!roverId ? (
|
||||
<p className="mt-2 text-[0.75rem] text-slate-400">Assignment required to view sensors.</p>
|
||||
<p className="mt-1 text-[0.75rem] text-slate-400">Assign a rover to view sensors.</p>
|
||||
) : !frame ? (
|
||||
<p className="mt-2 text-[0.75rem] text-slate-400">No sensor frames yet.</p>
|
||||
<p className="mt-1 text-[0.75rem] text-slate-400">Waiting for sensor frames…</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="mt-1 space-y-1 text-[0.75rem]">
|
||||
<Metric label="Charge" value={formatMetric(charge != null && capacity != null ? `${charge}/${capacity} mAh` : null)} />
|
||||
<Metric label="Charging" value={formatMetric(sensors.chargingState?.label)} />
|
||||
<Metric label="OI Mode" value={formatMetric(sensors.oiMode?.label)} />
|
||||
<Metric label="Voltage" value={formatMetric(voltage ? `${voltage} V` : null)} />
|
||||
<Metric label="Current" value={formatMetric(current ? `${current} A` : null)} />
|
||||
<Metric label="OI mode" value={formatMetric(sensors.oiMode?.label)} />
|
||||
<Metric label="Voltage" value={formatMetric(voltage)} />
|
||||
<Metric label="Current" value={formatMetric(current)} />
|
||||
</div>
|
||||
)}
|
||||
{rawSnippet && (
|
||||
<pre className="mt-2 overflow-hidden rounded border border-slate-800 bg-black/60 p-1 text-[0.6rem] text-lime-300">
|
||||
<pre className="mt-1 overflow-hidden rounded-sm bg-black/60 p-1 text-[0.6rem] text-lime-300">
|
||||
{rawSnippet}
|
||||
</pre>
|
||||
)}
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-slate-400">Rovers</p>
|
||||
{roster.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">No roster data.</p>
|
||||
) : (
|
||||
<ul className="mt-1 space-y-1 text-xs">
|
||||
{roster.map((rover) => (
|
||||
<li key={rover.id} className="flex items-center justify-between gap-1">
|
||||
<div>
|
||||
<p className="text-slate-200">{rover.name}</p>
|
||||
<p className="text-[0.6rem] text-slate-500">
|
||||
{rover.locked ? 'locked' : 'free'} · {rover.lastSeen ? 'seen' : 'unknown'}
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRequest(rover.id)}
|
||||
disabled={pending[rover.id]}
|
||||
className="rounded-sm bg-black/40 px-1 py-0.5 text-[0.6rem] text-slate-200 disabled:opacity-40"
|
||||
>
|
||||
{pending[rover.id] ? '...' : 'request'}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-[0.75rem]">
|
||||
<span className="text-slate-500">{label}</span>
|
||||
<span className="font-semibold text-slate-200">{value}</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<span className="text-slate-200">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,8 +124,8 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
: status;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="relative w-full overflow-hidden rounded-lg border border-slate-900 bg-black">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative w-full overflow-hidden rounded-sm bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted={forceMute || muted}
|
||||
@@ -137,21 +137,19 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
<HudOverlay frame={telemetryFrame} />
|
||||
<OvercurrentOverlay active={overcurrentActive} />
|
||||
</div>
|
||||
<BatteryBar
|
||||
charge={batteryCharge}
|
||||
capacity={batteryCapacity}
|
||||
config={batteryConfig}
|
||||
/>
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">
|
||||
<span className="text-slate-200">{label}</span> · {renderedStatus}
|
||||
</div>
|
||||
<BatteryBar charge={batteryCharge} capacity={batteryCapacity} config={batteryConfig} label={label} status={renderedStatus} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BatteryBar({ charge, capacity, config }) {
|
||||
function BatteryBar({ charge, capacity, config, label, status }) {
|
||||
if (charge == null || !config?.full || config.warn == null) {
|
||||
return null;
|
||||
return (
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>{label}</span>
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const span = config.full - config.warn;
|
||||
if (span <= 0) return null;
|
||||
@@ -160,23 +158,20 @@ function BatteryBar({ charge, capacity, config }) {
|
||||
const percentDisplay = Math.round(percent * 100);
|
||||
const depleted = normalized <= 0;
|
||||
const urgent = config.urgent != null && charge <= config.urgent;
|
||||
const barClass = depleted
|
||||
? 'bg-red-500 animate-pulse'
|
||||
: urgent
|
||||
? 'bg-amber-400'
|
||||
: 'bg-emerald-500';
|
||||
const barClass = depleted ? 'bg-red-500 animate-pulse' : urgent ? 'bg-amber-400' : 'bg-emerald-500';
|
||||
const capText = capacity ? `${charge}/${capacity}` : `${charge}`;
|
||||
return (
|
||||
<div className="rounded border border-slate-900 bg-slate-950/80 p-1">
|
||||
<div className="flex items-center justify-between text-[0.65rem] text-slate-300">
|
||||
<div className="space-y-1 rounded-sm bg-[#111] p-1 text-xs text-slate-200">
|
||||
<div className="flex items-center justify-between text-[0.65rem] text-slate-400">
|
||||
<span>{label}</span>
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[0.7rem]">
|
||||
<span>Battery</span>
|
||||
<span>{capText} mAh</span>
|
||||
</div>
|
||||
<div className="mt-1 h-2 w-full rounded-full bg-slate-800">
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] ${barClass}`}
|
||||
style={{ width: `${percentDisplay}%` }}
|
||||
/>
|
||||
<div className="h-2 w-full rounded-full bg-slate-800">
|
||||
<div className={`h-full rounded-full transition-[width] ${barClass}`} style={{ width: `${percentDisplay}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -199,20 +194,16 @@ function HudOverlay({ frame }) {
|
||||
{ label: 'B-R', active: bumps.bumpRight },
|
||||
];
|
||||
const wheelBadges = [
|
||||
{ label: 'DROP L', active: bumps.wheelDropLeft },
|
||||
{ label: 'DROP R', active: bumps.wheelDropRight },
|
||||
{ label: 'Drop L', active: bumps.wheelDropLeft },
|
||||
{ label: 'Drop R', active: bumps.wheelDropRight },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col justify-between p-2 text-[0.6rem] uppercase tracking-[0.3em] text-slate-200">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
pulse ? 'bg-emerald-300 shadow-[0_0_6px_rgba(16,185,129,0.9)]' : 'bg-slate-700'
|
||||
}`}
|
||||
></span>
|
||||
<span className="text-slate-400">SENSORS</span>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col justify-between p-1 text-[0.65rem] text-slate-200">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1 text-[0.55rem] text-slate-400">
|
||||
<span className={`h-2 w-2 rounded-full ${pulse ? 'bg-emerald-300 shadow-[0_0_4px_rgba(16,185,129,0.8)]' : 'bg-slate-700'}`} />
|
||||
<span>sensor</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{bumperBadges.map((badge) => (
|
||||
@@ -232,11 +223,7 @@ function HudOverlay({ frame }) {
|
||||
function HudBadge({ label, active }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-sm border px-1 py-0.5 text-[0.55rem] ${
|
||||
active
|
||||
? 'border-emerald-400 bg-emerald-500/10 text-emerald-200'
|
||||
: 'border-slate-700 bg-black/40 text-slate-500'
|
||||
}`}
|
||||
className={`rounded-sm px-1 py-0.5 text-[0.55rem] ${active ? 'bg-emerald-500/30 text-emerald-100' : 'bg-black/40 text-slate-500'}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
@@ -247,9 +234,7 @@ function OvercurrentOverlay({ active }) {
|
||||
if (!active) return null;
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-900/60">
|
||||
<div className="text-center text-lg font-black uppercase tracking-[0.6em] text-red-100 animate-pulse">
|
||||
Overcurrent
|
||||
</div>
|
||||
<div className="text-center text-sm font-semibold text-red-100 animate-pulse">Overcurrent</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { useSocket } from '../context/SocketContext.jsx';
|
||||
export function useVideoRequests(roverIds = []) {
|
||||
const socket = useSocket();
|
||||
const [sources, setSources] = useState({});
|
||||
const ids = useMemo(() => Array.from(new Set(roverIds.filter(Boolean))), [roverIds]);
|
||||
const idsKey = ids.join('|');
|
||||
const normalizedKey = Array.isArray(roverIds) ? roverIds.filter(Boolean).join('|') : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!ids.length) {
|
||||
if (!normalizedKey) {
|
||||
return undefined;
|
||||
}
|
||||
const ids = normalizedKey.split('|');
|
||||
let cancelled = false;
|
||||
ids.forEach((roverId) => {
|
||||
socket.emit('video:request', { roverId }, (resp = {}) => {
|
||||
@@ -25,9 +25,11 @@ export function useVideoRequests(roverIds = []) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [socket, idsKey, ids]);
|
||||
}, [socket, normalizedKey]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!ids.length) return {};
|
||||
if (!normalizedKey) return {};
|
||||
const ids = normalizedKey.split('|');
|
||||
const next = {};
|
||||
ids.forEach((id) => {
|
||||
if (sources[id]) {
|
||||
@@ -35,7 +37,7 @@ export function useVideoRequests(roverIds = []) {
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}, [ids, sources]);
|
||||
}, [normalizedKey, sources]);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user