mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -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" />
|
<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-BrPsNUkx.js"></script>
|
<script type="module" crossorigin src="/assets/index-CJa482Eg.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DZL3Zaf5.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BUE8ypH_.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+41
-190
@@ -10,28 +10,11 @@ import MobileControls, { MobileJoystick, AuxMotorControls } from './components/M
|
|||||||
import { DriveControlProvider } from './context/DriveControlContext.jsx';
|
import { DriveControlProvider } from './context/DriveControlContext.jsx';
|
||||||
import { useVideoRequests } from './hooks/useVideoRequests.js';
|
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() {
|
function RoomCameraPanel() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[8rem] rounded-lg border border-slate-800 bg-slate-900/70 p-2 text-[0.75rem] text-slate-400">
|
<div className="min-h-[8rem] rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-200">
|
||||||
<p className="text-center text-xs uppercase tracking-[0.3em] text-slate-500">Room camera</p>
|
<p className="text-center text-xs text-slate-400">Room camera</p>
|
||||||
<p className="mt-2 text-center text-sm text-slate-300">Feed placeholder. Wire upcoming room cam here.</p>
|
<p className="mt-1 text-center text-sm text-slate-200">Feed placeholder. Wire upcoming room cam here.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,14 +52,12 @@ function useLayoutMode() {
|
|||||||
function LogPanel() {
|
function LogPanel() {
|
||||||
const { logs } = useSession();
|
const { logs } = useSession();
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-900/70 p-4">
|
<div className="rounded-sm bg-[#1b1b1b] p-1 text-xs text-slate-200">
|
||||||
<header className="mb-3 flex items-center justify-between">
|
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||||
<h2 className="text-lg font-semibold text-white">Server logs</h2>
|
<span>Server logs</span>
|
||||||
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
<span>{logs.length}</span>
|
||||||
{logs.length} entries
|
</div>
|
||||||
</span>
|
<div className="mt-1 h-48 overflow-y-auto rounded-sm bg-black/40 p-1 font-mono text-[0.65rem] text-slate-300">
|
||||||
</header>
|
|
||||||
<div className="h-64 overflow-y-auto rounded-xl bg-slate-950/40 p-3 text-xs font-mono text-slate-300">
|
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<p>No logs yet.</p>
|
<p>No logs yet.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -84,10 +65,10 @@ function LogPanel() {
|
|||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((entry) => (
|
.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-slate-500">{entry.timestamp}</span>{' '}
|
||||||
<span className="text-cyan-300">[{entry.level}]</span>{' '}
|
<span className="text-slate-300">[{entry.level}]</span>{' '}
|
||||||
{entry.label && <span className="text-pink-300">[{entry.label}]</span>}{' '}
|
{entry.label && <span className="text-slate-400">[{entry.label}]</span>}{' '}
|
||||||
<span>{entry.message}</span>
|
<span>{entry.message}</span>
|
||||||
</div>
|
</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() {
|
function DriverVideoPanel() {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const roverId = session?.assignment?.roverId;
|
const roverId = session?.assignment?.roverId;
|
||||||
@@ -217,16 +91,9 @@ function DriverVideoPanel() {
|
|||||||
}, [session?.roster, roverId]);
|
}, [session?.roster, roverId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
|
<section className="rounded-sm bg-[#1b1b1b] p-1">
|
||||||
<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>
|
|
||||||
{roverId ? (
|
{roverId ? (
|
||||||
|
<div className="min-h-[55vh]">
|
||||||
<VideoTile
|
<VideoTile
|
||||||
sessionInfo={info}
|
sessionInfo={info}
|
||||||
label={roverId}
|
label={roverId}
|
||||||
@@ -234,8 +101,9 @@ function DriverVideoPanel() {
|
|||||||
telemetryFrame={frame}
|
telemetryFrame={frame}
|
||||||
batteryConfig={batteryConfig}
|
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>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -262,17 +130,17 @@ function AuthPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
|
<div className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-200">
|
||||||
<h2 className="text-lg font-semibold text-white">Authentication</h2>
|
<p className="text-xs text-slate-400">Admin login</p>
|
||||||
<form className="mt-4 flex flex-col gap-3" onSubmit={handleLogin}>
|
<form className="mt-1 flex flex-col gap-1" onSubmit={handleLogin}>
|
||||||
<input
|
<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"
|
placeholder="Username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<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"
|
placeholder="Password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
@@ -281,25 +149,25 @@ function AuthPanel() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div className="mt-4 flex gap-3">
|
<div className="mt-1 flex gap-1 text-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRole('user')}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRole('spectator')}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,24 +176,19 @@ function AuthPanel() {
|
|||||||
|
|
||||||
function DesktopLayout() {
|
function DesktopLayout() {
|
||||||
return (
|
return (
|
||||||
<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-2">
|
<section className="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.6fr)_minmax(0,0.9fr)] gap-1">
|
||||||
<TelemetryPanel />
|
<TelemetryPanel />
|
||||||
<DriverVideoPanel />
|
<DriverVideoPanel />
|
||||||
<DrivePanel />
|
<DrivePanel />
|
||||||
</section>
|
</section>
|
||||||
<section className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)] gap-2">
|
<section className="grid grid-cols-[repeat(3,minmax(0,1fr))] gap-1">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<AuthPanel />
|
<AuthPanel />
|
||||||
<AdminPanel />
|
<AdminPanel />
|
||||||
<RosterPanel />
|
|
||||||
</div>
|
</div>
|
||||||
<RoomCameraPanel />
|
<RoomCameraPanel />
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<AssignmentCard />
|
|
||||||
<LogPanel />
|
<LogPanel />
|
||||||
<SessionInspector />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -333,47 +196,41 @@ function DesktopLayout() {
|
|||||||
|
|
||||||
function MobilePortraitLayout() {
|
function MobilePortraitLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<DriverVideoPanel />
|
<DriverVideoPanel />
|
||||||
<MobileControls />
|
<MobileControls />
|
||||||
<DrivePanel />
|
<DrivePanel />
|
||||||
<TelemetryPanel />
|
<TelemetryPanel />
|
||||||
<RosterPanel />
|
|
||||||
<AssignmentCard />
|
|
||||||
<AuthPanel />
|
<AuthPanel />
|
||||||
<AdminPanel />
|
<AdminPanel />
|
||||||
<RoomCameraPanel />
|
<RoomCameraPanel />
|
||||||
<LogPanel />
|
<LogPanel />
|
||||||
<SessionInspector />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileLandscapeLayout() {
|
function MobileLandscapeLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<section className="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.2fr)_minmax(0,0.9fr)] gap-2">
|
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_minmax(0,1fr)] gap-1">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<DrivePanel />
|
<DrivePanel />
|
||||||
<AuxMotorControls />
|
<AuxMotorControls />
|
||||||
</div>
|
</div>
|
||||||
<DriverVideoPanel />
|
<DriverVideoPanel />
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<MobileJoystick />
|
<MobileJoystick />
|
||||||
<TelemetryPanel />
|
<TelemetryPanel />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
|
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<RosterPanel />
|
|
||||||
<AssignmentCard />
|
|
||||||
<AuthPanel />
|
<AuthPanel />
|
||||||
<AdminPanel />
|
<AdminPanel />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<RoomCameraPanel />
|
<RoomCameraPanel />
|
||||||
<LogPanel />
|
<LogPanel />
|
||||||
<SessionInspector />
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,21 +238,15 @@ function MobileLandscapeLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { connected, session } = useSession();
|
|
||||||
const layout = useLayoutMode();
|
const layout = useLayoutMode();
|
||||||
const renderedLayout = layout === 'desktop' ? <DesktopLayout /> : layout === 'mobile-landscape' ? <MobileLandscapeLayout /> : <MobilePortraitLayout />;
|
const renderedLayout = layout === 'desktop' ? <DesktopLayout /> : layout === 'mobile-landscape' ? <MobileLandscapeLayout /> : <MobilePortraitLayout />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black text-slate-50">
|
<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>
|
<DriveControlProvider>
|
||||||
<div className="flex justify-end">
|
<main className="flex w-full flex-col gap-1 px-1 py-1">{renderedLayout}</main>
|
||||||
<StatusBadge connected={connected} role={session?.role} mode={session?.mode} />
|
|
||||||
</div>
|
|
||||||
{renderedLayout}
|
|
||||||
<AlertFeed />
|
<AlertFeed />
|
||||||
</DriveControlProvider>
|
</DriveControlProvider>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,15 +57,13 @@ export default function AdminPanel() {
|
|||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-amber-600/40 bg-amber-500/5 p-4 text-sm text-amber-100">
|
<section className="rounded-sm bg-[#1b1b1b] p-1 text-xs text-slate-200">
|
||||||
<header className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-1">
|
||||||
<h2 className="text-lg font-semibold text-white">Admin Controls</h2>
|
<span>Admin controls</span>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-xs uppercase tracking-[0.3em] text-amber-300">Server mode</label>
|
|
||||||
<select
|
<select
|
||||||
value={currentMode}
|
value={currentMode}
|
||||||
onChange={handleModeChange}
|
onChange={handleModeChange}
|
||||||
className="rounded-md border border-amber-600/40 bg-slate-950/60 px-2 py-1 text-sm text-white"
|
className="rounded-sm bg-black/40 px-1 py-0.5 text-[0.7rem] text-slate-200"
|
||||||
>
|
>
|
||||||
{MODES.map((mode) => (
|
{MODES.map((mode) => (
|
||||||
<option key={mode.key} value={mode.key}>
|
<option key={mode.key} value={mode.key}>
|
||||||
@@ -74,32 +72,28 @@ export default function AdminPanel() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="mt-1 space-y-1">
|
||||||
{roster.map((rover) => (
|
{roster.map((rover) => (
|
||||||
<div
|
<div key={rover.id} className="flex flex-wrap items-center justify-between gap-1 rounded-sm bg-black/30 px-1 py-1">
|
||||||
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>
|
<div>
|
||||||
<p className="text-white">{rover.name}</p>
|
<p className="text-slate-100">{rover.name}</p>
|
||||||
<p className="text-xs text-amber-200">Locked: {lockMap[rover.id] ? 'yes' : 'no'}</p>
|
<p className="text-[0.6rem] text-slate-500">{lockMap[rover.id] ? 'locked' : 'unlocked'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-1 text-[0.6rem]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
|
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'}
|
{lockMap[rover.id] ? 'Unlock' : 'Lock'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleForceControl(rover.id)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function AlertFeed() {
|
|||||||
if (!visible.length) return null;
|
if (!visible.length) return null;
|
||||||
|
|
||||||
return (
|
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) => (
|
{visible.map((toast) => (
|
||||||
<AlertToast key={toast.key} alert={toast.alert} />
|
<AlertToast key={toast.key} alert={toast.alert} />
|
||||||
))}
|
))}
|
||||||
@@ -41,14 +41,14 @@ export default function AlertFeed() {
|
|||||||
function AlertToast({ alert }) {
|
function AlertToast({ alert }) {
|
||||||
const colorClasses =
|
const colorClasses =
|
||||||
alert.color === 'error'
|
alert.color === 'error'
|
||||||
? 'border-red-500/50 bg-red-500/20 text-red-100'
|
? 'bg-red-500/30 text-red-100'
|
||||||
: alert.color === 'warn'
|
: alert.color === 'warn'
|
||||||
? 'border-amber-500/50 bg-amber-500/20 text-amber-100'
|
? 'bg-amber-500/30 text-amber-100'
|
||||||
: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-100';
|
: 'bg-emerald-500/30 text-emerald-100';
|
||||||
return (
|
return (
|
||||||
<div className={`pointer-events-auto rounded-md border px-3 py-2 text-[0.75rem] shadow-lg shadow-black/60 ${colorClasses}`}>
|
<div className={`pointer-events-auto rounded-sm px-2 py-1 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-[0.65rem] text-slate-300">{alert.title || 'Alert'}</p>
|
||||||
<p className="text-sm font-semibold">{alert.message}</p>
|
<p className="text-sm text-white">{alert.message}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,25 +19,25 @@ export default function DrivePanel() {
|
|||||||
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
|
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-100">
|
<section className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-100">
|
||||||
<div className="flex items-center justify-between text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
<span>Drive Control</span>
|
<span>Drive control</span>
|
||||||
<span>{roverId ? `Rover ${roverId}` : 'unassigned'}</span>
|
<span>{roverId ? `Rover ${roverId}` : 'unassigned'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-1 space-y-1">
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={runStartDockFull}
|
onClick={runStartDockFull}
|
||||||
disabled={!roverId}
|
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>
|
</button>
|
||||||
<p className="mt-1 text-[0.65rem] text-slate-400">Runs Start → Dock → Full commands to ready the rover.</p>
|
<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.6rem]">
|
<div className="mt-1 flex flex-wrap items-center gap-1 text-[0.65rem]">
|
||||||
<StatusPill label="Driving mode" active={drivingMode} />
|
<StatusPill label="Driving" active={drivingMode} />
|
||||||
{updated && <span className="text-slate-500">Updated {updated}</span>}
|
{updated && <span className="text-slate-500">Sensors {updated}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -45,13 +45,11 @@ export default function DrivePanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={seekDock}
|
onClick={seekDock}
|
||||||
disabled={!roverId}
|
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>
|
</button>
|
||||||
<p className="mt-1 text-[0.65rem] text-slate-400">
|
<p className="mt-1 text-xs text-slate-400">Point the rover straight at the dock, about one foot away.</p>
|
||||||
Point the rover straight at the dock, about one foot away, before triggering.
|
|
||||||
</p>
|
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
<StatusPill label={docked ? 'Docked' : 'Not docked'} active={docked} />
|
<StatusPill label={docked ? 'Docked' : 'Not docked'} active={docked} />
|
||||||
<StatusPill label={charging ? 'Charging' : 'Not charging'} active={charging} />
|
<StatusPill label={charging ? 'Charging' : 'Not charging'} active={charging} />
|
||||||
@@ -59,7 +57,7 @@ export default function DrivePanel() {
|
|||||||
</div>
|
</div>
|
||||||
<SpeedRow left={speeds.left} right={speeds.right} />
|
<SpeedRow left={speeds.left} right={speeds.right} />
|
||||||
<div>
|
<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">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{manualOiButtons.map((btn) => (
|
{manualOiButtons.map((btn) => (
|
||||||
<button
|
<button
|
||||||
@@ -67,7 +65,7 @@ export default function DrivePanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => sendOiCommand(btn.key)}
|
onClick={() => sendOiCommand(btn.key)}
|
||||||
disabled={!roverId}
|
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}
|
{btn.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -79,26 +77,26 @@ export default function DrivePanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={stopMotors}
|
onClick={stopMotors}
|
||||||
disabled={!roverId}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SpeedRow({ left, right }) {
|
function SpeedRow({ left, right }) {
|
||||||
return (
|
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>
|
<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>
|
<p className="font-semibold text-slate-100">{left}</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="font-semibold text-slate-100">{right}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,10 +106,8 @@ function SpeedRow({ left, right }) {
|
|||||||
function StatusPill({ label, active }) {
|
function StatusPill({ label, active }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`rounded-full border px-2 py-0.5 text-[0.55rem] uppercase tracking-[0.3em] ${
|
className={`rounded-sm px-1 py-0.5 text-[0.6rem] ${
|
||||||
active
|
active ? 'bg-emerald-500/30 text-emerald-100' : 'bg-black/40 text-slate-500'
|
||||||
? 'border-emerald-400 bg-emerald-500/10 text-emerald-200'
|
|
||||||
: 'border-slate-700 bg-slate-900 text-slate-500'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ function MobileJoystick() {
|
|||||||
}, [disabled, driveWithVector]);
|
}, [disabled, driveWithVector]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-100">
|
<section className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-100">
|
||||||
<div className="text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">Joystick</div>
|
<div className="text-xs text-slate-400">Joystick</div>
|
||||||
<div className="mt-2 flex items-center justify-center">
|
<div className="mt-1 flex items-center justify-center">
|
||||||
<Joystick
|
<Joystick
|
||||||
size={120}
|
size={120}
|
||||||
baseColor="#0f172a"
|
baseColor="#0f172a"
|
||||||
@@ -40,14 +40,14 @@ function MobileJoystick() {
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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.
|
Drag to drive. Release to stop sending drive commands.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={stopMotors}
|
onClick={stopMotors}
|
||||||
disabled={disabled}
|
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
|
Panic Stop
|
||||||
</button>
|
</button>
|
||||||
@@ -79,14 +79,14 @@ function AuxMotorControls() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-100">
|
<section className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-100">
|
||||||
<div className="text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">Aux Motors</div>
|
<div className="text-xs text-slate-400">Aux motors</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={runAllForward}
|
onClick={runAllForward}
|
||||||
disabled={disabled}
|
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
|
All forward
|
||||||
</button>
|
</button>
|
||||||
@@ -94,19 +94,19 @@ function AuxMotorControls() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={stopAll}
|
onClick={stopAll}
|
||||||
disabled={disabled}
|
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
|
Stop all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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) => (
|
{auxButtons.map((btn) => (
|
||||||
<button
|
<button
|
||||||
key={btn.label}
|
key={btn.label}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !disabled && setAuxMotors(btn.values)}
|
onClick={() => !disabled && setAuxMotors(btn.values)}
|
||||||
disabled={disabled}
|
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}
|
{btn.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -118,7 +118,7 @@ function AuxMotorControls() {
|
|||||||
|
|
||||||
export default function MobileControlsStack() {
|
export default function MobileControlsStack() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<MobileJoystick />
|
<MobileJoystick />
|
||||||
<AuxMotorControls />
|
<AuxMotorControls />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||||
|
|
||||||
@@ -7,50 +8,99 @@ function formatMetric(value, fallback = '--') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TelemetryPanel() {
|
export default function TelemetryPanel() {
|
||||||
const { session } = useSession();
|
const { connected, session, requestControl } = useSession();
|
||||||
const roverId = session?.assignment?.roverId;
|
const roverId = session?.assignment?.roverId;
|
||||||
const frame = useTelemetryFrame(roverId);
|
const frame = useTelemetryFrame(roverId);
|
||||||
const sensors = frame?.sensors || {};
|
const sensors = frame?.sensors || {};
|
||||||
const voltage = sensors.voltageMv != null ? (sensors.voltageMv / 1000).toFixed(2) : null;
|
const voltage = sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : null;
|
||||||
const current = sensors.currentMa != null ? (sensors.currentMa / 1000).toFixed(2) : null;
|
const current = sensors.currentMa != null ? `${sensors.currentMa} mA` : null;
|
||||||
const charge = sensors.batteryChargeMah;
|
const charge = sensors.batteryChargeMah;
|
||||||
const capacity = sensors.batteryCapacityMah;
|
const capacity = sensors.batteryCapacityMah;
|
||||||
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
|
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 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 (
|
return (
|
||||||
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-200">
|
<section className="rounded-sm bg-[#1b1b1b] p-1 text-sm text-slate-200">
|
||||||
<div className="flex items-center justify-between text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">
|
<div className="text-[0.7rem] text-slate-400">
|
||||||
<span>Telemetry</span>
|
<span>{connected ? 'online' : 'offline'}</span>
|
||||||
<span>{updated ? `Updated ${updated}` : 'waiting'}</span>
|
<span> · role {session?.role || 'unknown'}</span>
|
||||||
|
<span> · mode {session?.mode || '--'}</span>
|
||||||
|
{updated && <span> · sensors {updated}</span>}
|
||||||
</div>
|
</div>
|
||||||
{!roverId ? (
|
{!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 ? (
|
) : !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="Charge" value={formatMetric(charge != null && capacity != null ? `${charge}/${capacity} mAh` : null)} />
|
||||||
<Metric label="Charging" value={formatMetric(sensors.chargingState?.label)} />
|
<Metric label="Charging" value={formatMetric(sensors.chargingState?.label)} />
|
||||||
<Metric label="OI Mode" value={formatMetric(sensors.oiMode?.label)} />
|
<Metric label="OI mode" value={formatMetric(sensors.oiMode?.label)} />
|
||||||
<Metric label="Voltage" value={formatMetric(voltage ? `${voltage} V` : null)} />
|
<Metric label="Voltage" value={formatMetric(voltage)} />
|
||||||
<Metric label="Current" value={formatMetric(current ? `${current} A` : null)} />
|
<Metric label="Current" value={formatMetric(current)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rawSnippet && (
|
{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}
|
{rawSnippet}
|
||||||
</pre>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Metric({ label, value }) {
|
function Metric({ label, value }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between text-[0.75rem]">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-slate-500">{label}</span>
|
<span className="text-slate-400">{label}</span>
|
||||||
<span className="font-semibold text-slate-200">{value}</span>
|
<span className="text-slate-200">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,8 +124,8 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
|||||||
: status;
|
: status;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="relative w-full overflow-hidden rounded-lg border border-slate-900 bg-black">
|
<div className="relative w-full overflow-hidden rounded-sm bg-black">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
muted={forceMute || muted}
|
muted={forceMute || muted}
|
||||||
@@ -137,21 +137,19 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
|||||||
<HudOverlay frame={telemetryFrame} />
|
<HudOverlay frame={telemetryFrame} />
|
||||||
<OvercurrentOverlay active={overcurrentActive} />
|
<OvercurrentOverlay active={overcurrentActive} />
|
||||||
</div>
|
</div>
|
||||||
<BatteryBar
|
<BatteryBar charge={batteryCharge} capacity={batteryCapacity} config={batteryConfig} label={label} status={renderedStatus} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BatteryBar({ charge, capacity, config }) {
|
function BatteryBar({ charge, capacity, config, label, status }) {
|
||||||
if (charge == null || !config?.full || config.warn == null) {
|
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;
|
const span = config.full - config.warn;
|
||||||
if (span <= 0) return null;
|
if (span <= 0) return null;
|
||||||
@@ -160,23 +158,20 @@ function BatteryBar({ charge, capacity, config }) {
|
|||||||
const percentDisplay = Math.round(percent * 100);
|
const percentDisplay = Math.round(percent * 100);
|
||||||
const depleted = normalized <= 0;
|
const depleted = normalized <= 0;
|
||||||
const urgent = config.urgent != null && charge <= config.urgent;
|
const urgent = config.urgent != null && charge <= config.urgent;
|
||||||
const barClass = depleted
|
const barClass = depleted ? 'bg-red-500 animate-pulse' : urgent ? 'bg-amber-400' : 'bg-emerald-500';
|
||||||
? 'bg-red-500 animate-pulse'
|
|
||||||
: urgent
|
|
||||||
? 'bg-amber-400'
|
|
||||||
: 'bg-emerald-500';
|
|
||||||
const capText = capacity ? `${charge}/${capacity}` : `${charge}`;
|
const capText = capacity ? `${charge}/${capacity}` : `${charge}`;
|
||||||
return (
|
return (
|
||||||
<div className="rounded border border-slate-900 bg-slate-950/80 p-1">
|
<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-300">
|
<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>Battery</span>
|
||||||
<span>{capText} mAh</span>
|
<span>{capText} mAh</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 h-2 w-full rounded-full bg-slate-800">
|
<div className="h-2 w-full rounded-full bg-slate-800">
|
||||||
<div
|
<div className={`h-full rounded-full transition-[width] ${barClass}`} style={{ width: `${percentDisplay}%` }} />
|
||||||
className={`h-full rounded-full transition-[width] ${barClass}`}
|
|
||||||
style={{ width: `${percentDisplay}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -199,20 +194,16 @@ function HudOverlay({ frame }) {
|
|||||||
{ label: 'B-R', active: bumps.bumpRight },
|
{ label: 'B-R', active: bumps.bumpRight },
|
||||||
];
|
];
|
||||||
const wheelBadges = [
|
const wheelBadges = [
|
||||||
{ label: 'DROP L', active: bumps.wheelDropLeft },
|
{ label: 'Drop L', active: bumps.wheelDropLeft },
|
||||||
{ label: 'DROP R', active: bumps.wheelDropRight },
|
{ label: 'Drop R', active: bumps.wheelDropRight },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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="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-2">
|
<div className="flex items-center justify-between gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1 text-[0.55rem] text-slate-400">
|
||||||
<span
|
<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'}`} />
|
||||||
className={`h-2 w-2 rounded-full ${
|
<span>sensor</span>
|
||||||
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>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{bumperBadges.map((badge) => (
|
{bumperBadges.map((badge) => (
|
||||||
@@ -232,11 +223,7 @@ function HudOverlay({ frame }) {
|
|||||||
function HudBadge({ label, active }) {
|
function HudBadge({ label, active }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`rounded-sm border px-1 py-0.5 text-[0.55rem] ${
|
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'}`}
|
||||||
active
|
|
||||||
? 'border-emerald-400 bg-emerald-500/10 text-emerald-200'
|
|
||||||
: 'border-slate-700 bg-black/40 text-slate-500'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
@@ -247,9 +234,7 @@ function OvercurrentOverlay({ active }) {
|
|||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-900/60">
|
<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">
|
<div className="text-center text-sm font-semibold text-red-100 animate-pulse">Overcurrent</div>
|
||||||
Overcurrent
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { useSocket } from '../context/SocketContext.jsx';
|
|||||||
export function useVideoRequests(roverIds = []) {
|
export function useVideoRequests(roverIds = []) {
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const [sources, setSources] = useState({});
|
const [sources, setSources] = useState({});
|
||||||
const ids = useMemo(() => Array.from(new Set(roverIds.filter(Boolean))), [roverIds]);
|
const normalizedKey = Array.isArray(roverIds) ? roverIds.filter(Boolean).join('|') : '';
|
||||||
const idsKey = ids.join('|');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ids.length) {
|
if (!normalizedKey) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
const ids = normalizedKey.split('|');
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
ids.forEach((roverId) => {
|
ids.forEach((roverId) => {
|
||||||
socket.emit('video:request', { roverId }, (resp = {}) => {
|
socket.emit('video:request', { roverId }, (resp = {}) => {
|
||||||
@@ -25,9 +25,11 @@ export function useVideoRequests(roverIds = []) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [socket, idsKey, ids]);
|
}, [socket, normalizedKey]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!ids.length) return {};
|
if (!normalizedKey) return {};
|
||||||
|
const ids = normalizedKey.split('|');
|
||||||
const next = {};
|
const next = {};
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
if (sources[id]) {
|
if (sources[id]) {
|
||||||
@@ -35,7 +37,7 @@ export function useVideoRequests(roverIds = []) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
}, [ids, sources]);
|
}, [normalizedKey, sources]);
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user