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>
197 lines
8.2 KiB
JavaScript
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>
|
|
);
|
|
}
|