sl-webui 78374668bf feat(ui): telemetry dashboard panels (issue #126)
Adds 6 telemetry tabs to the social-bot dashboard extending PR #112.

IMU Panel (/saltybot/imu, /saltybot/balance_state):
  - Canvas artificial horizon + compass tape
  - Three.js 3D robot orientation cube with quaternion SLERP
  - Angular velocity readouts, balance state detail

Battery Panel (/diagnostics):
  - Voltage, SoC, estimated current, runtime estimate
  - 120-point voltage history sparkline (2 min)
  - Reads battery_voltage_v, battery_soc_pct KeyValues from DiagnosticArray

Motor Panel (/saltybot/balance_state, /saltybot/rover_pwm):
  - Auto-detects balance vs rover mode
  - Bidirectional duty bars for left/right motors
  - Motor temp from /diagnostics, PID detail for balance bot

Map Viewer (/map, /odom, /outdoor/route):
  - OccupancyGrid canvas renderer (unknown/free/occupied colour-coded)
  - Robot position + heading arrow, Nav2/OSM path overlay (dashed)
  - Pan (mouse/touch) + zoom, 2 m scale bar

Control Mode (/saltybot/control_mode):
  - RC / RAMP_TO_AUTO / AUTO / RAMP_TO_RC state badge
  - Blend alpha progress bar
  - Safety flags: SLAM ok, RC link ok, stick override active
  - State machine diagram

System Health (/diagnostics):
  - CPU/GPU temperature gauges with colour-coded bars
  - RAM, GPU memory, disk resource bars
  - ROS2 node status list sorted by severity (ERROR → WARN → OK)

Also:
  - Three.js vendor chunk split (471 kB separate lazy chunk)
  - Updated App.jsx with grouped SOCIAL + TELEMETRY tab nav

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

197 lines
8.2 KiB
JavaScript

/**
* SystemHealth.jsx — System resource monitoring and ROS2 node status.
*
* Topics:
* /diagnostics (diagnostic_msgs/DiagnosticArray)
* Each DiagnosticStatus has: name, level (0=OK,1=WARN,2=ERROR,3=STALE)
* message, hardware_id, values: [{key,value}]
* Expected KeyValue sources (from tegrastats bridge / custom nodes):
* cpu_temp_c, gpu_temp_c, ram_used_mb, ram_total_mb,
* disk_used_gb, disk_total_gb, gpu_used_mb, gpu_total_mb
*/
import { useEffect, useState } from 'react';
const LEVEL_CONFIG = {
0: { label: 'OK', color: 'text-green-400', bg: 'bg-green-950', border: 'border-green-800', dot: 'bg-green-400' },
1: { label: 'WARN', color: 'text-amber-400', bg: 'bg-amber-950', border: 'border-amber-800', dot: 'bg-amber-400 animate-pulse' },
2: { label: 'ERROR', color: 'text-red-400', bg: 'bg-red-950', border: 'border-red-800', dot: 'bg-red-400 animate-pulse' },
3: { label: 'STALE', color: 'text-gray-500', bg: 'bg-gray-900', border: 'border-gray-700', dot: 'bg-gray-500' },
};
function ResourceBar({ label, used, total, unit, warnPct = 80 }) {
if (total == null || total === 0) return null;
const pct = Math.round((used / total) * 100);
const warn = pct >= warnPct;
const crit = pct >= 95;
const color = crit ? '#ef4444' : warn ? '#f59e0b' : '#06b6d4';
return (
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-gray-500">{label}</span>
<span className={warn ? 'text-amber-400' : 'text-gray-400'}>
{typeof used === 'number' ? used.toFixed(1) : used}/{typeof total === 'number' ? total.toFixed(1) : total} {unit}
<span className="text-gray-600 ml-1">({pct}%)</span>
</span>
</div>
<div className="w-full h-2.5 bg-gray-900 rounded overflow-hidden border border-gray-800">
<div
className="h-full transition-all duration-500 rounded"
style={{ width: `${pct}%`, background: color }}
/>
</div>
</div>
);
}
function TempGauge({ label, tempC }) {
if (tempC == null) return null;
const warn = tempC > 75;
const crit = tempC > 90;
const pct = Math.min(100, (tempC / 100) * 100);
const color = crit ? '#ef4444' : warn ? '#f59e0b' : '#22c55e';
return (
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 text-center">
<div className="text-gray-600 text-xs">{label}</div>
<div className="text-2xl font-bold mt-1" style={{ color }}>
{tempC.toFixed(0)}°C
</div>
<div className="w-full h-1.5 bg-gray-900 rounded overflow-hidden mt-1.5">
<div className="h-full rounded" style={{ width: `${pct}%`, background: color }} />
</div>
</div>
);
}
function NodeRow({ status }) {
const cfg = LEVEL_CONFIG[status.level] ?? LEVEL_CONFIG[3];
return (
<div className={`flex items-center gap-2 rounded px-3 py-1.5 border text-xs ${cfg.bg} ${cfg.border}`}>
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${cfg.dot}`} />
<span className="text-gray-300 font-bold truncate flex-1" title={status.name}>{status.name}</span>
<span className={`${cfg.color} shrink-0`}>{status.message || cfg.label}</span>
</div>
);
}
export function SystemHealth({ subscribe }) {
const [resources, setResources] = useState({
cpuTemp: null, gpuTemp: null,
ramUsed: null, ramTotal: null,
diskUsed: null, diskTotal: null,
gpuUsed: null, gpuTotal: null,
});
const [nodes, setNodes] = useState([]);
const [lastTs, setLastTs] = useState(null);
useEffect(() => {
const unsub = subscribe('/diagnostics', 'diagnostic_msgs/DiagnosticArray', (msg) => {
setLastTs(Date.now());
// Parse each DiagnosticStatus
const nodeList = [];
for (const status of msg.status ?? []) {
const kv = {};
for (const pair of status.values ?? []) kv[pair.key] = pair.value;
// Resource metrics
setResources(prev => {
const next = { ...prev };
if (kv.cpu_temp_c !== undefined) next.cpuTemp = parseFloat(kv.cpu_temp_c);
if (kv.gpu_temp_c !== undefined) next.gpuTemp = parseFloat(kv.gpu_temp_c);
if (kv.ram_used_mb !== undefined) next.ramUsed = parseFloat(kv.ram_used_mb) / 1024;
if (kv.ram_total_mb !== undefined) next.ramTotal = parseFloat(kv.ram_total_mb) / 1024;
if (kv.disk_used_gb !== undefined) next.diskUsed = parseFloat(kv.disk_used_gb);
if (kv.disk_total_gb!== undefined) next.diskTotal = parseFloat(kv.disk_total_gb);
if (kv.gpu_used_mb !== undefined) next.gpuUsed = parseFloat(kv.gpu_used_mb);
if (kv.gpu_total_mb !== undefined) next.gpuTotal = parseFloat(kv.gpu_total_mb);
return next;
});
// Collect all statuses as node rows
nodeList.push({ name: status.name, level: status.level, message: status.message });
}
if (nodeList.length > 0) {
setNodes(nodeList);
}
});
return unsub;
}, [subscribe]);
const stale = lastTs && Date.now() - lastTs > 10000;
const errorCount = nodes.filter(n => n.level === 2).length;
const warnCount = nodes.filter(n => n.level === 1).length;
return (
<div className="space-y-4">
{/* Summary banner */}
{nodes.length > 0 && (
<div className={`flex items-center gap-3 rounded-lg border p-3 ${
errorCount > 0 ? 'bg-red-950 border-red-800' :
warnCount > 0 ? 'bg-amber-950 border-amber-800' :
'bg-green-950 border-green-800'
}`}>
<div className={`w-3 h-3 rounded-full ${
errorCount > 0 ? 'bg-red-400 animate-pulse' :
warnCount > 0 ? 'bg-amber-400 animate-pulse' : 'bg-green-400'
}`} />
<div className="text-sm font-bold">
{errorCount > 0 ? `${errorCount} ERROR${errorCount > 1 ? 'S' : ''}` :
warnCount > 0 ? `${warnCount} WARNING${warnCount > 1 ? 'S' : ''}` :
'All systems nominal'}
</div>
<div className="ml-auto text-xs text-gray-500">{nodes.length} nodes</div>
{stale && <div className="text-red-400 text-xs">STALE</div>}
</div>
)}
{/* Temperatures */}
{(resources.cpuTemp != null || resources.gpuTemp != null) && (
<div className="grid grid-cols-2 gap-3">
<TempGauge label="CPU TEMP" tempC={resources.cpuTemp} />
<TempGauge label="GPU TEMP" tempC={resources.gpuTemp} />
</div>
)}
{/* Resource bars */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4 space-y-3">
<div className="text-cyan-700 text-xs font-bold tracking-widest">SYSTEM RESOURCES</div>
<ResourceBar label="RAM" used={resources.ramUsed} total={resources.ramTotal} unit="GB" />
<ResourceBar label="GPU MEM" used={resources.gpuUsed} total={resources.gpuTotal} unit="MB" />
<ResourceBar label="DISK" used={resources.diskUsed} total={resources.diskTotal} unit="GB" warnPct={85} />
{resources.ramUsed == null && resources.gpuUsed == null && resources.diskUsed == null && (
<div className="text-gray-600 text-xs">
Waiting for resource metrics from /diagnostics
<div className="mt-1 text-gray-700">Expected keys: cpu_temp_c, gpu_temp_c, ram_used_mb, disk_used_gb</div>
</div>
)}
</div>
{/* Node list */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
<div className="flex items-center justify-between mb-3">
<div className="text-cyan-700 text-xs font-bold tracking-widest">ROS2 NODE HEALTH</div>
<div className="text-gray-600 text-xs">{nodes.length} statuses</div>
</div>
{nodes.length === 0 ? (
<div className="text-gray-600 text-xs text-center py-4 border border-dashed border-gray-800 rounded">
Waiting for /diagnostics
</div>
) : (
<div className="space-y-1.5 max-h-80 overflow-y-auto">
{/* Sort: errors first, then warns, then OK */}
{[...nodes]
.sort((a, b) => (b.level ?? 0) - (a.level ?? 0))
.map((n, i) => <NodeRow key={i} status={n} />)
}
</div>
)}
</div>
</div>
);
}