From b09f17b787e562a3948ee042d714fdd0f91ee28e Mon Sep 17 00:00:00 2001 From: sl-webui Date: Mon, 2 Mar 2026 09:37:10 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(webui):=20fleet=20management=20dashboa?= =?UTF-8?q?rd=20=E2=80=94=20Issue=20#139?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ui/social-bot/src/App.jsx | 17 +- ui/social-bot/src/components/FleetPanel.jsx | 803 ++++++++++++++++++++ ui/social-bot/src/hooks/useFleet.js | 347 +++++++++ 3 files changed, 1166 insertions(+), 1 deletion(-) create mode 100644 ui/social-bot/src/components/FleetPanel.jsx create mode 100644 ui/social-bot/src/hooks/useFleet.js diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx index 2c4c78a..63b8edb 100644 --- a/ui/social-bot/src/App.jsx +++ b/ui/social-bot/src/App.jsx @@ -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' && } {activeTab === 'control' && } {activeTab === 'health' && } + + {activeTab === 'fleet' && } {/* ── Footer ── */} diff --git a/ui/social-bot/src/components/FleetPanel.jsx b/ui/social-bot/src/components/FleetPanel.jsx new file mode 100644 index 0000000..063257e --- /dev/null +++ b/ui/social-bot/src/components/FleetPanel.jsx @@ -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://:/stream?topic=&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 ( +
+
+
+ ADD ROBOT + +
+
+
+ + + + + + +
+
+ + +
+
+
+
+ ); +} + +// ── Sub-view: Robots List ──────────────────────────────────────────────────── + +function SocBar({ soc }) { + const color = soc < 20 ? '#ef4444' : soc < 40 ? '#f59e0b' : '#22c55e'; + return ( +
+
+
+
+ {soc}% +
+ ); +} + +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 ( +
onSelect(robot.id)} + > + {/* Header row */} +
+
+ {robot.name || robot.id} + {robot.variant} + +
+ + {/* Address */} +
{robot.host}:{robot.port}
+ + {/* Telemetry row */} + {conn.connected ? ( +
+
+ Battery + +
+
+ State + {d.state ?? '—'} +
+
+ Pipeline + {d.pipeline ?? '—'} +
+ {d.pose && ( +
+ pos {d.pose.x.toFixed(1)},{d.pose.y.toFixed(1)} + {' '}hdg {(d.pose.yaw * 180 / Math.PI).toFixed(0)}° +
+ )} +
+ ) : ( +
+ {conn.error ? ⚠ {conn.error} : 'Connecting…'} +
+ )} + + {d.alerts?.length > 0 && ( +
+ {d.alerts.slice(0, 3).map((a, i) => ( + = 2 ? 'bg-red-950 border-red-800 text-red-400' : 'bg-amber-950 border-amber-800 text-amber-400' + }`}>{a.name} + ))} +
+ )} +
+ ); +} + +function RobotsView({ robots, connections, robotData, onSelect, onRemove, onAdd, onScan, scanning }) { + return ( +
+
+
+ FLEET — {robots.length} ROBOT{robots.length !== 1 ? 'S' : ''} +
+
+ + +
+
+ {robots.length === 0 ? ( +
+ No robots configured. Add one or scan the LAN. +
+ ) : ( +
+ {robots.map(r => ( + + ))} +
+ )} +
+ ); +} + +// ── 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 ( +
+
+
FLEET MAP
+
+ + {zoom.toFixed(0)}px/m + + +
+
+
+ +
+ {/* Legend */} +
+ {robots.map((r, i) => ( + + ))} +
+
+ ); +} + +// ── 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 ( +
+
MISSION DISPATCH
+ + {connected.length === 0 && ( +
+ No robots connected. Connect at least one robot to dispatch missions. +
+ )} + +
+ + +
+ {['goal', 'patrol'].map(m => ( + + ))} +
+ + {mode === 'goal' ? ( +
+ {[['X (m)', goalX, setGoalX], ['Y (m)', goalY, setGoalY], ['Yaw (°)', goalYaw, setGoalYaw]].map(([lbl, val, setter]) => ( + + ))} +
+ ) : ( +