mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
spectation
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-OlyDvT3F.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DoNH_msu.css">
|
||||
<script type="module" crossorigin src="/assets/index-xwzFeHXC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-JJWJQzRI.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -8,6 +8,9 @@ const app = express();
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json());
|
||||
app.use(express.static(config.staticDir));
|
||||
app.get('/spectate', (req, res) => {
|
||||
res.sendFile(path.join(config.staticDir, 'index.html'));
|
||||
});
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
|
||||
Generated
+55
-1
@@ -9,7 +9,8 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@@ -1887,6 +1888,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3390,6 +3400,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.9.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz",
|
||||
"integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.9.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz",
|
||||
"integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.9.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -3550,6 +3598,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
+13
-8
@@ -52,6 +52,7 @@ 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) {
|
||||
@@ -79,14 +80,18 @@ function RosterPanel() {
|
||||
<p className="text-white">{rover.name}</p>
|
||||
<p className="text-xs text-slate-400">Locked: {rover.locked ? 'yes' : 'no'}</p>
|
||||
</div>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
|
||||
export function useSensorFeed() {
|
||||
const socket = useSocket();
|
||||
const [frames, setFrames] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
function handleFrame({ roverId, sensors = {}, frame = {} }) {
|
||||
if (!roverId) return;
|
||||
setFrames((prev) => ({
|
||||
...prev,
|
||||
[roverId]: {
|
||||
roverId,
|
||||
sensors,
|
||||
raw: frame?.data || null,
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
socket.on('sensorFrame', handleFrame);
|
||||
return () => {
|
||||
socket.off('sensorFrame', handleFrame);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
return frames;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
|
||||
export function useSpectatorMode() {
|
||||
const { session, setRole, subscribeAll } = useSession();
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function ensureSpectator() {
|
||||
try {
|
||||
if (session?.role !== 'spectator') {
|
||||
await setRole('spectator');
|
||||
}
|
||||
await subscribeAll();
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to enter spectator mode', err);
|
||||
}
|
||||
}
|
||||
ensureSpectator();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session?.role, setRole, subscribeAll]);
|
||||
|
||||
return ready;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
|
||||
export function useVideoRequests(roverIds = []) {
|
||||
const socket = useSocket();
|
||||
const [sources, setSources] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
const ids = Array.from(new Set(roverIds.filter(Boolean)));
|
||||
if (!ids.length) return;
|
||||
let cancelled = false;
|
||||
|
||||
ids.forEach((roverId) => {
|
||||
socket.emit('video:request', { roverId }, (resp = {}) => {
|
||||
if (cancelled) return;
|
||||
setSources((prev) => ({
|
||||
...prev,
|
||||
[roverId]: resp,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [socket, roverIds.join('|')]);
|
||||
|
||||
return sources;
|
||||
}
|
||||
+8
-1
@@ -1,15 +1,22 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
import { SocketProvider } from './context/SocketContext.jsx'
|
||||
import { SessionProvider } from './context/SessionContext.jsx'
|
||||
import SpectatorApp from './spectate/SpectatorApp.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<SocketProvider>
|
||||
<SessionProvider>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<App />} />
|
||||
<Route path="/spectate" element={<SpectatorApp />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</SessionProvider>
|
||||
</SocketProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
|
||||
import { useSensorFeed } from '../hooks/useSensorFeed.js';
|
||||
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
||||
|
||||
function SpectatorStatus({ connected, ready, rosterCount }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span className={`rounded-full px-3 py-1 font-semibold ${connected ? 'bg-emerald-500/20 text-emerald-200' : 'bg-red-500/20 text-red-200'}`}>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
<span className={`rounded-full px-3 py-1 ${ready ? 'bg-blue-600/40 text-blue-100' : 'bg-slate-700 text-slate-200'}`}>
|
||||
Spectator mode {ready ? 'active' : 'pending…'}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-800 px-3 py-1 text-slate-200">
|
||||
Rovers: {rosterCount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogStream({ logs }) {
|
||||
return (
|
||||
<section className="rounded-3xl 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.3em] text-slate-500">latest {logs.length}</span>
|
||||
</header>
|
||||
<div className="h-60 overflow-y-auto rounded-2xl bg-slate-950/40 p-3 text-xs font-mono text-slate-300">
|
||||
{logs.length === 0 ? (
|
||||
<p>No log entries yet.</p>
|
||||
) : (
|
||||
logs
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry) => (
|
||||
<div key={entry.id} className="mb-2">
|
||||
<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>{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSensors(sensors = {}) {
|
||||
return Object.entries(sensors).map(([key, value]) => ({
|
||||
key,
|
||||
value: typeof value === 'object' ? JSON.stringify(value) : value,
|
||||
}));
|
||||
}
|
||||
|
||||
function RoverSpectatorCard({ rover, frame, videoInfo }) {
|
||||
const sensorList = formatSensors(frame?.sensors);
|
||||
return (
|
||||
<article className="space-y-4 rounded-3xl border border-slate-800 bg-slate-900/70 p-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h3 className="text-2xl font-semibold text-white">{rover.name}</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'}
|
||||
</p>
|
||||
</header>
|
||||
<div className="aspect-video w-full rounded-2xl border border-slate-800 bg-black/60 flex items-center justify-center text-sm text-slate-400">
|
||||
{videoInfo?.error ? (
|
||||
<span>Video error: {videoInfo.error}</span>
|
||||
) : videoInfo?.url ? (
|
||||
<div className="text-center text-xs text-slate-300">
|
||||
<p className="font-semibold text-white">Video feed placeholder</p>
|
||||
<p>WHEP URL: {videoInfo.url}</p>
|
||||
<p>Token: {videoInfo.token || 'retrieving…'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<span>Requesting video session…</span>
|
||||
)}
|
||||
</div>
|
||||
<section className="rounded-2xl border border-slate-800/80 bg-slate-950/50 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Telemetry</p>
|
||||
{sensorList.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-400">No sensor data yet.</p>
|
||||
) : (
|
||||
<dl className="mt-3 grid gap-2 text-sm text-slate-200">
|
||||
{sensorList.map(({ key, value }) => (
|
||||
<div key={key} className="flex justify-between gap-4">
|
||||
<dt className="text-slate-500">{key}</dt>
|
||||
<dd className="text-right font-semibold text-white">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SpectatorApp() {
|
||||
const { connected, session, logs } = useSession();
|
||||
const spectatorReady = useSpectatorMode();
|
||||
const frames = useSensorFeed();
|
||||
const roster = session?.roster ?? [];
|
||||
const videoSources = useVideoRequests(roster.map((rover) => rover.id));
|
||||
|
||||
const status = useMemo(
|
||||
() => ({ connected, ready: spectatorReady, rosterCount: roster.length }),
|
||||
[connected, spectatorReady, roster.length],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-50">
|
||||
<main className="mx-auto flex max-w-7xl flex-col gap-6 px-6 py-10">
|
||||
<header className="space-y-3">
|
||||
<h1 className="text-4xl font-semibold text-white">Spectator Console</h1>
|
||||
<p className="text-slate-400">Live rover telemetry & logs. This view is read-only.</p>
|
||||
<SpectatorStatus {...status} />
|
||||
</header>
|
||||
<section className="grid gap-6 lg:grid-cols-3">
|
||||
{roster.length === 0 ? (
|
||||
<p className="col-span-full text-slate-400">No rovers registered.</p>
|
||||
) : (
|
||||
roster.map((rover) => (
|
||||
<RoverSpectatorCard
|
||||
key={rover.id}
|
||||
rover={rover}
|
||||
frame={frames[rover.id]}
|
||||
videoInfo={videoSources[rover.id]}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
<LogStream logs={logs} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user