sl-webui b09f17b787 feat(webui): fleet management dashboard — Issue #139
- useFleet.js: multi-robot ROSLIB.Ros multiplexer with localStorage
  persistence; per-robot balance_state/control_mode/odom/diagnostics
  subscriptions; sendGoal, sendPatrol, scanSubnet helpers
- FleetPanel.jsx: fleet sub-views (Robots/Map/Missions/Video/Alerts)
  plus RobotDetail overlay reusing ImuPanel/BatteryPanel/MotorPanel/
  ControlMode/SystemHealth via subscribeRobot adapter pattern
- App.jsx: adds FLEET tab group pointing to FleetPanel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 09:37:10 -05:00

804 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
}