feat(webui): fleet management dashboard (Issue #139) #147

Merged
sl-jetson merged 3 commits from sl-webui/issue-139-fleet-dashboard into main 2026-03-02 09:51:11 -05:00
3 changed files with 1166 additions and 1 deletions
Showing only changes of commit b09f17b787 - Show all commits

View File

@ -1,11 +1,14 @@
/**
* App.jsx Saltybot Social + Telemetry Dashboard root component.
* App.jsx Saltybot Social + Telemetry + Fleet Dashboard root component.
*
* Social tabs (issue #107):
* Status | Faces | Conversation | Personality | Navigation
*
* Telemetry tabs (issue #126):
* IMU | Battery | Motors | Map | Control | Health
*
* Fleet tabs (issue #139):
* Fleet (self-contained via useFleet)
*/
import { useState, useCallback } from 'react';
@ -26,6 +29,9 @@ import { MapViewer } from './components/MapViewer.jsx';
import { ControlMode } from './components/ControlMode.jsx';
import { SystemHealth } from './components/SystemHealth.jsx';
// Fleet panel (issue #139)
import { FleetPanel } from './components/FleetPanel.jsx';
const TAB_GROUPS = [
{
label: 'SOCIAL',
@ -50,6 +56,13 @@ const TAB_GROUPS = [
{ id: 'health', label: 'Health', },
],
},
{
label: 'FLEET',
color: 'text-green-600',
tabs: [
{ id: 'fleet', label: 'Fleet' },
],
},
];
const DEFAULT_WS_URL = 'ws://localhost:9090';
@ -172,6 +185,8 @@ export default function App() {
{activeTab === 'map' && <MapViewer subscribe={subscribe} />}
{activeTab === 'control' && <ControlMode subscribe={subscribe} />}
{activeTab === 'health' && <SystemHealth subscribe={subscribe} />}
{activeTab === 'fleet' && <FleetPanel />}
</main>
{/* ── Footer ── */}

View File

@ -0,0 +1,803 @@
/**
* FleetPanel.jsx Multi-robot fleet management dashboard.
*
* Sub-views (internal tab nav):
* Robots | Map | Missions | Video | Alerts | Detail
*
* Uses useFleet() for multi-robot WebSocket multiplexing.
* Robot detail re-uses single-robot panel components via subscribeRobot adapter.
*
* Video streaming:
* http://<host>:<videoPort>/stream?topic=<videoTopic>&type=ros_compressed
* (web_video_server / mjpeg_server)
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import { useFleet } from '../hooks/useFleet.js';
// Reusable single-robot detail panels (re-used via subscribeRobot adapter)
import { ImuPanel } from './ImuPanel.jsx';
import { BatteryPanel } from './BatteryPanel.jsx';
import { MotorPanel } from './MotorPanel.jsx';
import { ControlMode } from './ControlMode.jsx';
import { SystemHealth } from './SystemHealth.jsx';
// Constants
const VARIANT_COLORS = {
balance: 'text-cyan-400 border-cyan-800',
rover: 'text-green-400 border-green-800',
tank: 'text-amber-400 border-amber-800',
social: 'text-purple-400 border-purple-800',
};
const FLEET_VIEWS = [
{ id: 'robots', label: 'Robots' },
{ id: 'map', label: 'Fleet Map'},
{ id: 'missions', label: 'Missions' },
{ id: 'video', label: 'Video' },
{ id: 'alerts', label: 'Alerts' },
];
// Add-Robot modal
function AddRobotModal({ onAdd, onClose }) {
const [form, setForm] = useState({
name: '', variant: 'balance', host: '', port: '9090',
videoPort: '8080', videoTopic: '/camera/panoramic/compressed',
});
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const handleSubmit = (e) => {
e.preventDefault();
if (!form.host.trim()) return;
onAdd({
...form,
port: Number(form.port),
videoPort: Number(form.videoPort),
});
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4">
<div className="bg-gray-950 border border-cyan-800 rounded-lg p-5 w-full max-w-md">
<div className="flex items-center justify-between mb-4">
<span className="text-cyan-400 font-bold tracking-widest text-sm">ADD ROBOT</span>
<button onClick={onClose} className="text-gray-600 hover:text-gray-300"></button>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<label className="col-span-2 space-y-1">
<span className="text-gray-500 text-xs">Display Name</span>
<input className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-cyan-700"
value={form.name} onChange={e => set('name', e.target.value)} placeholder="SaltyBot #2" />
</label>
<label className="space-y-1">
<span className="text-gray-500 text-xs">Variant</span>
<select className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-cyan-700"
value={form.variant} onChange={e => set('variant', e.target.value)}>
<option value="balance">Balance</option>
<option value="rover">Rover</option>
<option value="tank">Tank</option>
<option value="social">Social</option>
</select>
</label>
<label className="space-y-1">
<span className="text-gray-500 text-xs">Rosbridge Host</span>
<input className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-cyan-700"
value={form.host} onChange={e => set('host', e.target.value)} placeholder="192.168.1.101" required />
</label>
<label className="space-y-1">
<span className="text-gray-500 text-xs">Rosbridge Port</span>
<input type="number" className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-cyan-700"
value={form.port} onChange={e => set('port', e.target.value)} />
</label>
<label className="space-y-1">
<span className="text-gray-500 text-xs">Video Port</span>
<input type="number" className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-cyan-700"
value={form.videoPort} onChange={e => set('videoPort', e.target.value)} />
</label>
<label className="space-y-1">
<span className="text-gray-500 text-xs">Video Topic</span>
<input className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-cyan-700"
value={form.videoTopic} onChange={e => set('videoTopic', e.target.value)} />
</label>
</div>
<div className="flex gap-2 justify-end pt-2">
<button type="button" onClick={onClose}
className="px-3 py-1.5 rounded border border-gray-700 text-gray-400 hover:text-gray-200 text-sm">Cancel</button>
<button type="submit"
className="px-3 py-1.5 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-sm font-bold">Add Robot</button>
</div>
</form>
</div>
</div>
);
}
// Sub-view: Robots List
function SocBar({ soc }) {
const color = soc < 20 ? '#ef4444' : soc < 40 ? '#f59e0b' : '#22c55e';
return (
<div className="flex items-center gap-1.5 min-w-[60px]">
<div className="flex-1 h-1.5 bg-gray-800 rounded overflow-hidden">
<div className="h-full rounded" style={{ width: `${soc}%`, background: color }} />
</div>
<span className="text-xs w-7 text-right" style={{ color }}>{soc}%</span>
</div>
);
}
function RobotCard({ robot, connection, data, onSelect, onRemove }) {
const conn = connection ?? { connected: false, error: null };
const d = data ?? {};
const varColor = VARIANT_COLORS[robot.variant] ?? 'text-gray-400 border-gray-700';
return (
<div
className="bg-gray-950 border border-gray-800 rounded-lg p-3 flex flex-col gap-2 cursor-pointer hover:border-cyan-800 transition-colors"
onClick={() => onSelect(robot.id)}
>
{/* Header row */}
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full shrink-0 ${conn.connected ? 'bg-green-400' : conn.error ? 'bg-red-500 animate-pulse' : 'bg-gray-700'}`} />
<span className="font-bold text-sm text-gray-200 flex-1 truncate">{robot.name || robot.id}</span>
<span className={`text-xs border rounded px-1.5 py-0.5 font-mono uppercase ${varColor}`}>{robot.variant}</span>
<button
className="text-gray-700 hover:text-red-500 ml-1 text-xs"
title="Remove robot"
onClick={e => { e.stopPropagation(); onRemove(robot.id); }}
></button>
</div>
{/* Address */}
<div className="text-xs text-gray-600 font-mono">{robot.host}:{robot.port}</div>
{/* Telemetry row */}
{conn.connected ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
<div className="flex items-center justify-between">
<span className="text-gray-600">Battery</span>
<SocBar soc={Math.round(d.battery?.soc ?? 0)} />
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600">State</span>
<span className={`font-bold ${
d.state === 'ARMED' ? 'text-green-400' :
d.state === 'TILT FAULT' ? 'text-red-400 animate-pulse' :
d.state ? 'text-amber-400' : 'text-gray-600'
}`}>{d.state ?? '—'}</span>
</div>
<div className="flex items-center justify-between col-span-2">
<span className="text-gray-600">Pipeline</span>
<span className={`font-bold ${
d.pipeline === 'speaking' ? 'text-cyan-400' :
d.pipeline === 'thinking' ? 'text-purple-400' :
d.pipeline === 'listening' ? 'text-green-400' :
'text-gray-600'
}`}>{d.pipeline ?? '—'}</span>
</div>
{d.pose && (
<div className="col-span-2 text-gray-600">
pos <span className="text-cyan-600">{d.pose.x.toFixed(1)},{d.pose.y.toFixed(1)}</span>
{' '}hdg <span className="text-cyan-600">{(d.pose.yaw * 180 / Math.PI).toFixed(0)}°</span>
</div>
)}
</div>
) : (
<div className="text-xs text-gray-700">
{conn.error ? <span className="text-red-700"> {conn.error}</span> : 'Connecting…'}
</div>
)}
{d.alerts?.length > 0 && (
<div className="flex flex-wrap gap-1">
{d.alerts.slice(0, 3).map((a, i) => (
<span key={i} className={`text-xs px-1 rounded border ${
a.level >= 2 ? 'bg-red-950 border-red-800 text-red-400' : 'bg-amber-950 border-amber-800 text-amber-400'
}`}>{a.name}</span>
))}
</div>
)}
</div>
);
}
function RobotsView({ robots, connections, robotData, onSelect, onRemove, onAdd, onScan, scanning }) {
return (
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<div className="text-cyan-700 text-xs font-bold tracking-widest">
FLEET {robots.length} ROBOT{robots.length !== 1 ? 'S' : ''}
</div>
<div className="ml-auto flex gap-2">
<button
onClick={onScan}
disabled={scanning}
className="px-3 py-1 rounded border border-gray-700 text-gray-400 hover:border-cyan-700 hover:text-cyan-300 text-xs disabled:opacity-50"
>{scanning ? 'Scanning…' : 'Scan LAN'}</button>
<button
onClick={onAdd}
className="px-3 py-1 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-xs font-bold"
>+ Add Robot</button>
</div>
</div>
{robots.length === 0 ? (
<div className="text-center text-gray-600 text-sm py-10 border border-dashed border-gray-800 rounded-lg">
No robots configured. Add one or scan the LAN.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{robots.map(r => (
<RobotCard
key={r.id}
robot={r}
connection={connections[r.id]}
data={robotData[r.id]}
onSelect={onSelect}
onRemove={onRemove}
/>
))}
</div>
)}
</div>
);
}
// Sub-view: Fleet Map
const ROBOT_COLORS = ['#06b6d4','#f97316','#22c55e','#a855f7','#f59e0b','#ec4899','#84cc16','#38bdf8'];
function FleetMapView({ robots, robotData, onSelect }) {
const canvasRef = useRef(null);
const [zoom, setZoom] = useState(30); // px per metre
const [pan, setPan] = useState({ x: 0, y: 0 });
const dragging = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#050510';
ctx.fillRect(0, 0, W, H);
// Grid
ctx.strokeStyle = '#0d1b2a';
ctx.lineWidth = 1;
const gridSpacing = zoom;
const offX = (W / 2 + pan.x) % gridSpacing;
const offY = (H / 2 + pan.y) % gridSpacing;
for (let x = offX; x < W; x += gridSpacing) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
for (let y = offY; y < H; y += gridSpacing) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
// Origin cross
const cx = W / 2 + pan.x;
const cy = H / 2 + pan.y;
ctx.strokeStyle = '#1e3a4a';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(cx - 10, cy); ctx.lineTo(cx + 10, cy); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx, cy - 10); ctx.lineTo(cx, cy + 10); ctx.stroke();
// Draw each robot that has pose data
robots.forEach((robot, i) => {
const d = robotData[robot.id];
if (!d?.pose) return;
const color = ROBOT_COLORS[i % ROBOT_COLORS.length];
const rx = cx + d.pose.x * zoom;
const ry = cy - d.pose.y * zoom;
const arrowLen = Math.max(14, zoom * 0.5);
// Heading line
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(rx, ry);
ctx.lineTo(rx + Math.cos(d.pose.yaw) * arrowLen, ry - Math.sin(d.pose.yaw) * arrowLen);
ctx.stroke();
// Body circle
ctx.fillStyle = color;
ctx.shadowBlur = 10;
ctx.shadowColor = color;
ctx.beginPath();
ctx.arc(rx, ry, 7, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// Label
ctx.fillStyle = color;
ctx.font = '9px monospace';
ctx.textAlign = 'center';
ctx.fillText(robot.name || robot.id, rx, ry - 12);
});
}, [robots, robotData, zoom, pan]);
const onMouseDown = e => { dragging.current = { x: e.clientX - pan.x, y: e.clientY - pan.y }; };
const onMouseMove = e => {
if (!dragging.current) return;
setPan({ x: e.clientX - dragging.current.x, y: e.clientY - dragging.current.y });
};
const onMouseUp = () => { dragging.current = null; };
const botsWithPose = robots.filter(r => robotData[r.id]?.pose);
return (
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<div className="text-cyan-700 text-xs font-bold tracking-widest">FLEET MAP</div>
<div className="flex items-center gap-1 ml-auto">
<button onClick={() => setZoom(z => Math.min(z * 1.5, 200))}
className="px-2 py-1 rounded border border-gray-700 text-gray-300 hover:border-cyan-700 text-sm">+</button>
<span className="text-gray-500 text-xs w-16 text-center">{zoom.toFixed(0)}px/m</span>
<button onClick={() => setZoom(z => Math.max(z / 1.5, 5))}
className="px-2 py-1 rounded border border-gray-700 text-gray-300 hover:border-cyan-700 text-sm"></button>
<button onClick={() => { setZoom(30); setPan({ x: 0, y: 0 }); }}
className="px-2 py-1 rounded border border-gray-700 text-gray-400 hover:text-gray-200 text-xs ml-2">Reset</button>
</div>
</div>
<div className="bg-gray-950 rounded-lg border border-cyan-950 overflow-hidden">
<canvas
ref={canvasRef} width={600} height={400}
className="w-full cursor-grab active:cursor-grabbing block"
onMouseDown={onMouseDown} onMouseMove={onMouseMove}
onMouseUp={onMouseUp} onMouseLeave={onMouseUp}
/>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-3 text-xs">
{robots.map((r, i) => (
<button key={r.id}
className="flex items-center gap-1.5 hover:opacity-80"
onClick={() => onSelect(r.id)}
>
<div className="w-2.5 h-2.5 rounded-full" style={{ background: ROBOT_COLORS[i % ROBOT_COLORS.length] }} />
<span className="text-gray-500">{r.name || r.id}</span>
{botsWithPose.find(b => b.id === r.id) ? null : (
<span className="text-gray-700">(no pose)</span>
)}
</button>
))}
</div>
</div>
);
}
// Sub-view: Mission Dispatch
function MissionsView({ robots, connections, sendGoal, sendPatrol }) {
const [targetId, setTargetId] = useState('');
const [mode, setMode] = useState('goal'); // 'goal' | 'patrol'
const [goalX, setGoalX] = useState('');
const [goalY, setGoalY] = useState('');
const [goalYaw, setGoalYaw] = useState('0');
const [waypoints, setWaypoints] = useState('');
const [sent, setSent] = useState(null);
const connected = robots.filter(r => connections[r.id]?.connected);
const handleSend = () => {
if (!targetId) return;
if (mode === 'goal') {
sendGoal(targetId, parseFloat(goalX), parseFloat(goalY), parseFloat(goalYaw) * Math.PI / 180);
setSent(`Goal → (${goalX}, ${goalY}) sent to ${targetId}`);
} else {
try {
const pts = waypoints.trim().split('\n').map(line => {
const [x, y, yaw = 0] = line.split(',').map(Number);
return { x, y, yaw: yaw * Math.PI / 180 };
});
sendPatrol(targetId, pts);
setSent(`Patrol (${pts.length} wpts) sent to ${targetId}`);
} catch {
setSent('Error parsing waypoints');
}
}
setTimeout(() => setSent(null), 4000);
};
return (
<div className="space-y-4 max-w-lg">
<div className="text-cyan-700 text-xs font-bold tracking-widest">MISSION DISPATCH</div>
{connected.length === 0 && (
<div className="text-amber-700 text-xs border border-amber-900 rounded p-3">
No robots connected. Connect at least one robot to dispatch missions.
</div>
)}
<div className="space-y-3">
<label className="block space-y-1">
<span className="text-gray-500 text-xs">Target Robot</span>
<select
className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-cyan-700"
value={targetId} onChange={e => setTargetId(e.target.value)}
>
<option value=""> Select robot </option>
{connected.map(r => (
<option key={r.id} value={r.id}>{r.name || r.id}</option>
))}
</select>
</label>
<div className="flex gap-2">
{['goal', 'patrol'].map(m => (
<button key={m}
onClick={() => setMode(m)}
className={`px-3 py-1.5 rounded border text-xs font-bold uppercase tracking-wider ${
mode === m
? 'bg-cyan-950 border-cyan-700 text-cyan-300'
: 'border-gray-700 text-gray-500 hover:text-gray-300'
}`}
>{m === 'goal' ? 'Single Goal' : 'Patrol Route'}</button>
))}
</div>
{mode === 'goal' ? (
<div className="grid grid-cols-3 gap-2">
{[['X (m)', goalX, setGoalX], ['Y (m)', goalY, setGoalY], ['Yaw (°)', goalYaw, setGoalYaw]].map(([lbl, val, setter]) => (
<label key={lbl} className="space-y-1">
<span className="text-gray-500 text-xs">{lbl}</span>
<input type="number" step="0.1"
className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-cyan-700"
value={val} onChange={e => setter(e.target.value)} placeholder="0" />
</label>
))}
</div>
) : (
<label className="block space-y-1">
<span className="text-gray-500 text-xs">Waypoints (x,y,yaw° per line)</span>
<textarea
rows={5}
placeholder="0,0,0&#10;2,0,90&#10;2,2,180&#10;0,2,270"
className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-cyan-700 font-mono"
value={waypoints} onChange={e => setWaypoints(e.target.value)}
/>
</label>
)}
<button
onClick={handleSend}
disabled={!targetId}
className="w-full py-2 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 font-bold text-sm tracking-wider disabled:opacity-40 disabled:cursor-not-allowed"
>SEND MISSION</button>
{sent && (
<div className="text-green-400 text-xs border border-green-900 rounded p-2">{sent}</div>
)}
</div>
<div className="bg-gray-950 border border-gray-800 rounded p-3 text-xs text-gray-600 space-y-1">
<div className="text-gray-500 font-bold">Publish targets</div>
<div>/goal_pose (geometry_msgs/PoseStamped) single Nav2 goal</div>
<div>/outdoor/waypoints (geometry_msgs/PoseArray) GPS follower patrol</div>
</div>
</div>
);
}
// Sub-view: Video Grid
function VideoFeed({ robot, connected }) {
const [error, setError] = useState(false);
const src = `http://${robot.host}:${robot.videoPort}/stream?topic=${encodeURIComponent(robot.videoTopic)}&type=ros_compressed`;
return (
<div className="bg-gray-950 rounded-lg border border-gray-800 overflow-hidden">
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-gray-800">
<div className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-400' : 'bg-gray-700'}`} />
<span className="text-xs text-gray-400 font-mono truncate flex-1">{robot.name || robot.id}</span>
<span className="text-xs text-gray-700">{robot.videoTopic.split('/').pop()}</span>
</div>
{!connected ? (
<div className="aspect-video flex items-center justify-center text-gray-700 text-xs">
Robot disconnected
</div>
) : error ? (
<div className="aspect-video flex items-center justify-center text-red-900 text-xs text-center px-4">
Stream unavailable<br />
<span className="text-gray-700">{robot.host}:{robot.videoPort}</span>
</div>
) : (
<img
src={src}
alt={`${robot.name} camera`}
className="w-full aspect-video object-cover block bg-black"
onError={() => setError(true)}
/>
)}
</div>
);
}
function VideoView({ robots, connections }) {
return (
<div className="space-y-3">
<div className="text-cyan-700 text-xs font-bold tracking-widest">VIDEO GRID</div>
{robots.length === 0 ? (
<div className="text-gray-600 text-xs text-center py-8">No robots configured.</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{robots.map(r => (
<VideoFeed key={r.id} robot={r} connected={connections[r.id]?.connected} />
))}
</div>
)}
<div className="text-xs text-gray-700">
Streams via web_video_server on port {robots[0]?.videoPort ?? 8080}.
Start with: <code className="text-gray-600">ros2 run web_video_server web_video_server</code>
</div>
</div>
);
}
// Sub-view: Alert Panel
function AlertsView({ robots, robotData, connections }) {
const allAlerts = robots.flatMap(r => {
const d = robotData[r.id];
if (!d?.alerts?.length) return [];
return d.alerts.map(a => ({ ...a, robotId: r.id, robotName: r.name || r.id }));
});
const errors = allAlerts.filter(a => a.level >= 2);
const warns = allAlerts.filter(a => a.level === 1);
const offline = robots.filter(r => !connections[r.id]?.connected);
return (
<div className="space-y-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest">FLEET ALERTS</div>
{/* Summary */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: 'ERRORS', count: errors.length, color: errors.length ? 'text-red-400 bg-red-950 border-red-800' : 'text-gray-600 bg-gray-900 border-gray-800' },
{ label: 'WARNINGS',count: warns.length, color: warns.length ? 'text-amber-400 bg-amber-950 border-amber-800' : 'text-gray-600 bg-gray-900 border-gray-800' },
{ label: 'OFFLINE', count: offline.length, color: offline.length ? 'text-gray-400 bg-gray-900 border-gray-700' : 'text-gray-600 bg-gray-900 border-gray-800' },
].map(({ label, count, color }) => (
<div key={label} className={`rounded-lg border p-3 text-center ${color}`}>
<div className="text-2xl font-bold">{count}</div>
<div className="text-xs tracking-widest mt-0.5">{label}</div>
</div>
))}
</div>
{/* Offline robots */}
{offline.length > 0 && (
<div className="bg-gray-950 rounded-lg border border-gray-700 p-3">
<div className="text-gray-500 text-xs font-bold mb-2">OFFLINE ROBOTS</div>
<div className="space-y-1">
{offline.map(r => (
<div key={r.id} className="flex items-center gap-2 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-gray-600" />
<span className="text-gray-400">{r.name || r.id}</span>
<span className="text-gray-700">{r.host}:{r.port}</span>
{connections[r.id]?.error && (
<span className="text-red-800">{connections[r.id].error}</span>
)}
</div>
))}
</div>
</div>
)}
{/* Alert list */}
{allAlerts.length === 0 ? (
<div className="text-gray-600 text-xs text-center py-6 border border-dashed border-gray-800 rounded">
{robots.length === 0 ? 'No robots configured.' : 'No active alerts across fleet.'}
</div>
) : (
<div className="space-y-2">
{[...allAlerts]
.sort((a, b) => b.level - a.level)
.map((a, i) => (
<div key={i} className={`flex items-start gap-2 rounded border px-3 py-2 text-xs ${
a.level >= 2 ? 'bg-red-950 border-red-800' : 'bg-amber-950 border-amber-800'
}`}>
<div className={`w-1.5 h-1.5 rounded-full mt-0.5 shrink-0 ${a.level >= 2 ? 'bg-red-400 animate-pulse' : 'bg-amber-400'}`} />
<div className="flex-1">
<span className="font-bold text-gray-300">{a.name}</span>
{a.message && <span className="text-gray-500 ml-2">{a.message}</span>}
</div>
<span className="text-gray-500 shrink-0">{a.robotName}</span>
</div>
))
}
</div>
)}
</div>
);
}
// Sub-view: Robot Detail
const DETAIL_TABS = [
{ id: 'imu', label: 'IMU' },
{ id: 'battery', label: 'Battery' },
{ id: 'motors', label: 'Motors' },
{ id: 'control', label: 'Control' },
{ id: 'health', label: 'Health' },
];
function RobotDetail({ robot, connection, data, subscribeRobot, onBack }) {
const [tab, setTab] = useState('imu');
// Adapter: wrap subscribeRobot(id, ...) to match subscribe(name, type, cb) signature
const subscribe = useCallback(
(name, type, cb) => subscribeRobot(robot.id, name, type, cb),
[subscribeRobot, robot.id]
);
const conn = connection ?? { connected: false };
return (
<div className="space-y-3">
{/* Back + header */}
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="text-gray-500 hover:text-cyan-300 text-xs border border-gray-700 hover:border-cyan-800 rounded px-2 py-1"
> Back</button>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${conn.connected ? 'bg-green-400' : 'bg-gray-700'}`} />
<span className="font-bold text-gray-200">{robot.name || robot.id}</span>
<span className="text-gray-600 text-sm">{robot.host}:{robot.port}</span>
<span className={`text-xs border rounded px-1.5 py-0.5 font-mono uppercase ${VARIANT_COLORS[robot.variant] ?? 'text-gray-400 border-gray-700'}`}>
{robot.variant}
</span>
</div>
{data?.battery?.soc != null && (
<div className="ml-auto flex items-center gap-1.5">
<SocBar soc={Math.round(data.battery.soc)} />
<span className="text-xs text-gray-600">{data.battery.voltage?.toFixed(1)}V</span>
</div>
)}
</div>
{/* Detail tabs */}
<div className="flex gap-0.5 border-b border-gray-800">
{DETAIL_TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-3 py-2 text-xs font-bold tracking-wider border-b-2 transition-colors ${
tab === t.id
? 'border-cyan-500 text-cyan-300'
: 'border-transparent text-gray-600 hover:text-gray-300'
}`}
>{t.label}</button>
))}
</div>
{!conn.connected ? (
<div className="text-gray-600 text-sm text-center py-10">
Robot disconnected {conn.error ? `${conn.error}` : ''}
</div>
) : (
<div>
{tab === 'imu' && <ImuPanel subscribe={subscribe} />}
{tab === 'battery' && <BatteryPanel subscribe={subscribe} />}
{tab === 'motors' && <MotorPanel subscribe={subscribe} />}
{tab === 'control' && <ControlMode subscribe={subscribe} />}
{tab === 'health' && <SystemHealth subscribe={subscribe} />}
</div>
)}
</div>
);
}
// Root FleetPanel
export function FleetPanel() {
const {
robots, addRobot, removeRobot,
connections, robotData,
subscribeRobot, sendGoal, sendPatrol,
scanning, scanSubnet,
} = useFleet();
const [view, setView] = useState('robots');
const [selectedId, setSelectedId] = useState(null);
const [showAdd, setShowAdd] = useState(false);
const handleSelect = useCallback((id) => {
setSelectedId(id);
}, []);
const handleBack = useCallback(() => {
setSelectedId(null);
}, []);
const handleScanLan = useCallback(async () => {
const baseIp = robots[0]?.host ?? '192.168.1.100';
const found = await scanSubnet(baseIp, [9090, 9092]);
found.forEach(({ host, port }, i) => {
const existingHosts = robots.map(r => r.host);
if (!existingHosts.includes(host)) {
addRobot({
id: `discovered-${Date.now()}-${i}`,
name: `Discovered ${host}`,
variant: 'balance',
host,
port,
});
}
});
}, [robots, scanSubnet, addRobot]);
// If a robot is selected show RobotDetail overlay
if (selectedId) {
const robot = robots.find(r => r.id === selectedId);
if (robot) {
return (
<RobotDetail
robot={robot}
connection={connections[robot.id]}
data={robotData[robot.id]}
subscribeRobot={subscribeRobot}
onBack={handleBack}
/>
);
}
setSelectedId(null);
}
return (
<div className="space-y-4">
{/* Fleet sub-nav */}
<div className="flex gap-0.5 border-b border-gray-800">
{FLEET_VIEWS.map(v => (
<button key={v.id} onClick={() => setView(v.id)}
className={`px-3 py-2 text-xs font-bold tracking-wider border-b-2 transition-colors ${
view === v.id
? 'border-cyan-500 text-cyan-300'
: 'border-transparent text-gray-600 hover:text-gray-300'
}`}
>{v.label.toUpperCase()}</button>
))}
</div>
{/* Content */}
{view === 'robots' && (
<RobotsView
robots={robots}
connections={connections}
robotData={robotData}
onSelect={handleSelect}
onRemove={removeRobot}
onAdd={() => setShowAdd(true)}
onScan={handleScanLan}
scanning={scanning}
/>
)}
{view === 'map' && (
<FleetMapView robots={robots} robotData={robotData} onSelect={handleSelect} />
)}
{view === 'missions' && (
<MissionsView
robots={robots}
connections={connections}
sendGoal={sendGoal}
sendPatrol={sendPatrol}
/>
)}
{view === 'video' && (
<VideoView robots={robots} connections={connections} />
)}
{view === 'alerts' && (
<AlertsView robots={robots} robotData={robotData} connections={connections} />
)}
{showAdd && (
<AddRobotModal onAdd={addRobot} onClose={() => setShowAdd(false)} />
)}
</div>
);
}

View File

@ -0,0 +1,347 @@
/**
* useFleet.js Multi-robot fleet connection manager.
*
* Maintains one ROSLIB.Ros WebSocket connection per robot.
* Aggregates fleet-summary telemetry (battery, pose, alerts, mode) for each.
* Persists robot registry to localStorage.
*
* API:
* const { robots, addRobot, removeRobot, updateRobot,
* connections, robotData,
* subscribeRobot, publishRobot, scanSubnet } = useFleet();
*
* Robot shape:
* { id, name, variant, host, port, videoPort, videoTopic }
* variant: 'balance' | 'rover' | 'tank' | 'social'
* videoTopic: '/camera/panoramic/compressed' (default)
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import ROSLIB from 'roslib';
// ── Storage ────────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'saltybot_fleet_v2';
const DEFAULT_ROBOTS = [
{
id: 'salty-01',
name: 'SaltyBot #1',
variant: 'balance',
host: '192.168.1.100',
port: 9090,
videoPort: 8080,
videoTopic: '/camera/panoramic/compressed',
},
];
function loadRobots() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
return Array.isArray(saved) && saved.length > 0 ? saved : DEFAULT_ROBOTS;
} catch {
return DEFAULT_ROBOTS;
}
}
// ── Robot data structure ───────────────────────────────────────────────────────
const EMPTY_DATA = () => ({
state: null, // 'DISARMED'|'ARMED'|'TILT FAULT'
stm32Mode: null, // 'MANUAL'|'ASSISTED'
controlMode: null, // 'RC'|'RAMP_TO_AUTO'|'AUTO'|'RAMP_TO_RC'
blendAlpha: 0,
pipeline: null, // 'idle'|'listening'|'thinking'|'speaking'
pitch: 0,
motorCmd: 0,
battery: { voltage: 0, soc: 0 },
pose: null, // { x, y, yaw }
alerts: [], // [{ level, name, message }]
lastSeen: null,
});
// ── Hook ──────────────────────────────────────────────────────────────────────
export function useFleet() {
const [robots, setRobots] = useState(loadRobots);
const [connections, setConnections] = useState({});
const [robotData, setRobotData] = useState(() =>
Object.fromEntries(loadRobots().map(r => [r.id, EMPTY_DATA()]))
);
const [scanning, setScanning] = useState(false);
const rosRef = useRef({}); // { [id]: ROSLIB.Ros }
const subsRef = useRef({}); // { [id]: ROSLIB.Topic[] }
// Persist robots
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(robots));
}, [robots]);
// ── Connection lifecycle ────────────────────────────────────────────────────
useEffect(() => {
const currentIds = new Set(robots.map(r => r.id));
// Disconnect removed robots
Object.keys(rosRef.current).forEach(id => {
if (!currentIds.has(id)) {
subsRef.current[id]?.forEach(t => { try { t.unsubscribe(); } catch {} });
delete subsRef.current[id];
try { rosRef.current[id].close(); } catch {}
delete rosRef.current[id];
setConnections(c => { const n = { ...c }; delete n[id]; return n; });
}
});
// Connect new robots
robots.forEach(robot => {
if (rosRef.current[robot.id]) return;
const ros = new ROSLIB.Ros({ url: `ws://${robot.host}:${robot.port}` });
rosRef.current[robot.id] = ros;
subsRef.current[robot.id] = [];
ros.on('connection', () => {
setConnections(c => ({ ...c, [robot.id]: { connected: true, error: null } }));
_setupSubs(robot.id, ros);
});
ros.on('error', err => {
setConnections(c => ({
...c,
[robot.id]: { connected: false, error: String(err?.message ?? err ?? 'error') }
}));
});
ros.on('close', () => {
setConnections(c => ({
...c,
[robot.id]: { connected: false, error: null }
}));
});
});
return () => {
Object.values(subsRef.current).forEach(subs =>
subs.forEach(t => { try { t.unsubscribe(); } catch {} })
);
Object.values(rosRef.current).forEach(ros => { try { ros.close(); } catch {} });
rosRef.current = {};
subsRef.current = {};
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [robots]);
// ── Fleet-summary subscriptions ────────────────────────────────────────────
function _sub(id, ros, name, type, cb) {
const t = new ROSLIB.Topic({ ros, name, messageType: type });
t.subscribe(cb);
subsRef.current[id] = [...(subsRef.current[id] ?? []), t];
}
function _update(id, patch) {
setRobotData(prev => ({
...prev,
[id]: { ...(prev[id] ?? EMPTY_DATA()), ...patch },
}));
}
function _setupSubs(id, ros) {
// Balance state
_sub(id, ros, '/saltybot/balance_state', 'std_msgs/String', msg => {
try {
const d = JSON.parse(msg.data);
_update(id, {
state: d.state,
stm32Mode: d.mode,
pitch: d.pitch_deg ?? 0,
motorCmd: d.motor_cmd ?? 0,
lastSeen: Date.now(),
});
} catch {}
});
// Control mode (RC ↔ AUTO blend)
_sub(id, ros, '/saltybot/control_mode', 'std_msgs/String', msg => {
try {
const d = JSON.parse(msg.data);
_update(id, { controlMode: d.mode, blendAlpha: d.blend_alpha ?? 0 });
} catch {}
});
// Odometry → pose
_sub(id, ros, '/odom', 'nav_msgs/Odometry', msg => {
const p = msg.pose?.pose;
if (!p) return;
const o = p.orientation;
const yaw = Math.atan2(
2 * (o.w * o.z + o.x * o.y),
1 - 2 * (o.y * o.y + o.z * o.z)
);
_update(id, { pose: { x: p.position.x, y: p.position.y, yaw }, lastSeen: Date.now() });
});
// Diagnostics → battery + alerts
_sub(id, ros, '/diagnostics', 'diagnostic_msgs/DiagnosticArray', msg => {
const alerts = [];
const kv = {};
for (const status of msg.status ?? []) {
if (status.level > 0) {
alerts.push({ level: status.level, name: status.name, message: status.message });
}
for (const pair of status.values ?? []) kv[pair.key] = pair.value;
}
const patch = { alerts };
if (kv.battery_voltage_v != null) {
const v = parseFloat(kv.battery_voltage_v);
patch.battery = {
voltage: v,
soc: Math.max(0, Math.min(100, ((v - 12) / (16.8 - 12)) * 100)),
};
}
_update(id, patch);
});
// Social pipeline state (optional)
_sub(id, ros, '/social/orchestrator/state', 'std_msgs/String', msg => {
try {
_update(id, { pipeline: JSON.parse(msg.data)?.state ?? null });
} catch {}
});
}
// ── Public API ─────────────────────────────────────────────────────────────
const addRobot = useCallback(robot => {
const id = robot.id ?? `bot-${Date.now()}`;
const full = {
videoPort: 8080,
videoTopic: '/camera/panoramic/compressed',
...robot,
id,
};
setRobots(r => [...r.filter(b => b.id !== id), full]);
setRobotData(d => ({ ...d, [id]: EMPTY_DATA() }));
}, []);
const removeRobot = useCallback(id => {
setRobots(r => r.filter(b => b.id !== id));
setRobotData(d => { const n = { ...d }; delete n[id]; return n; });
}, []);
const updateRobot = useCallback((id, patch) => {
// Force reconnect by dropping the existing connection
subsRef.current[id]?.forEach(t => { try { t.unsubscribe(); } catch {} });
subsRef.current[id] = [];
try { rosRef.current[id]?.close(); } catch {}
delete rosRef.current[id];
setRobots(r => r.map(b => b.id === id ? { ...b, ...patch } : b));
}, []);
/** Subscribe to a topic on a specific robot. Returns unsubscribe fn. */
const subscribeRobot = useCallback((id, name, type, cb) => {
const ros = rosRef.current[id];
if (!ros) return () => {};
const t = new ROSLIB.Topic({ ros, name, messageType: type });
t.subscribe(cb);
subsRef.current[id] = [...(subsRef.current[id] ?? []), t];
return () => {
try { t.unsubscribe(); } catch {}
subsRef.current[id] = subsRef.current[id]?.filter(x => x !== t) ?? [];
};
}, []);
/** Publish to a topic on a specific robot. */
const publishRobot = useCallback((id, name, type, data) => {
const ros = rosRef.current[id];
if (!ros) return;
const t = new ROSLIB.Topic({ ros, name, messageType: type });
t.publish(new ROSLIB.Message(data));
}, []);
/** Send a single Nav2 goal pose to a robot. */
const sendGoal = useCallback((id, x, y, yaw = 0) => {
const cy2 = Math.cos(yaw / 2);
const sy2 = Math.sin(yaw / 2);
publishRobot(id, '/goal_pose', 'geometry_msgs/PoseStamped', {
header: { frame_id: 'map', stamp: { sec: 0, nanosec: 0 } },
pose: {
position: { x, y, z: 0 },
orientation: { x: 0, y: 0, z: sy2, w: cy2 },
},
});
}, [publishRobot]);
/** Send a patrol route (array of {x,y,yaw?}) to a robot via PoseArray → GPS follower. */
const sendPatrol = useCallback((id, waypoints) => {
publishRobot(id, '/outdoor/waypoints', 'geometry_msgs/PoseArray', {
header: { frame_id: 'map', stamp: { sec: 0, nanosec: 0 } },
poses: waypoints.map(({ x, y, yaw = 0 }) => {
const cy2 = Math.cos(yaw / 2);
const sy2 = Math.sin(yaw / 2);
return {
position: { x, y, z: 0 },
orientation: { x: 0, y: 0, z: sy2, w: cy2 },
};
}),
});
}, [publishRobot]);
/**
* Probe a list of candidate URLs to find reachable rosbridge servers.
* Reports each discovered robot back via the addRobot callback.
*/
const scanSubnet = useCallback(async (baseIp, portRange = [9090, 9097]) => {
setScanning(true);
const [portMin, portMax] = portRange;
const ipParts = baseIp.split('.');
const prefix = ipParts.slice(0, 3).join('.');
const results = [];
const probes = [];
for (let host4 = 1; host4 <= 254; host4++) {
for (let port = portMin; port <= portMax; port++) {
probes.push({ host: `${prefix}.${host4}`, port });
}
}
// Probe in batches of 32 to avoid thundering-herd
const BATCH = 32;
for (let i = 0; i < probes.length; i += BATCH) {
const batch = probes.slice(i, i + BATCH);
await Promise.allSettled(
batch.map(({ host, port }) =>
new Promise(resolve => {
const ws = new WebSocket(`ws://${host}:${port}`);
const timer = setTimeout(() => { try { ws.close(); } catch {} resolve(null); }, 800);
ws.onopen = () => {
clearTimeout(timer);
results.push({ host, port });
ws.close();
resolve({ host, port });
};
ws.onerror = () => { clearTimeout(timer); resolve(null); };
})
)
);
}
setScanning(false);
return results;
}, []);
return {
robots,
addRobot,
removeRobot,
updateRobot,
connections,
robotData,
subscribeRobot,
publishRobot,
sendGoal,
sendPatrol,
scanning,
scanSubnet,
};
}