Merge pull request 'feat(webui): diagnostics panel — system health overview with alerts (Issue #340)' (#343) from sl-webui/issue-340-diagnostics into main

This commit is contained in:
sl-jetson 2026-03-03 12:41:48 -05:00
commit 5156100197
2 changed files with 314 additions and 0 deletions

View File

@ -79,6 +79,9 @@ import { NodeList } from './components/NodeList.jsx';
// Gamepad teleoperation (issue #319)
import { Teleop } from './components/Teleop.jsx';
// System diagnostics (issue #340)
import { Diagnostics } from './components/Diagnostics.jsx';
const TAB_GROUPS = [
{
label: 'SOCIAL',
@ -127,6 +130,7 @@ const TAB_GROUPS = [
label: 'MONITORING',
color: 'text-yellow-600',
tabs: [
{ id: 'diagnostics', label: 'Diagnostics' },
{ id: 'eventlog', label: 'Events' },
{ id: 'bandwidth', label: 'Bandwidth' },
{ id: 'nodes', label: 'Nodes' },
@ -281,6 +285,8 @@ export default function App() {
{activeTab === 'fleet' && <FleetPanel />}
{activeTab === 'missions' && <MissionPlanner />}
{activeTab === 'diagnostics' && <Diagnostics subscribe={subscribe} />}
{activeTab === 'eventlog' && <EventLog subscribe={subscribe} />}
{activeTab === 'bandwidth' && <BandwidthMonitor />}

View File

@ -0,0 +1,308 @@
/**
* Diagnostics.jsx System diagnostics panel with hardware status monitoring
*
* Features:
* - Subscribes to /diagnostics (diagnostic_msgs/DiagnosticArray)
* - Hardware status cards per subsystem (color-coded health)
* - Real-time error and warning counts
* - Diagnostic status timeline
* - Error/warning history with timestamps
* - Aggregated system health summary
* - Status indicators: OK (green), WARNING (yellow), ERROR (red), STALE (gray)
*/
import { useEffect, useRef, useState } from 'react';
const MAX_HISTORY = 100; // Keep last 100 diagnostic messages
const STATUS_COLORS = {
0: { bg: 'bg-green-950', border: 'border-green-800', text: 'text-green-400', label: 'OK' },
1: { bg: 'bg-yellow-950', border: 'border-yellow-800', text: 'text-yellow-400', label: 'WARN' },
2: { bg: 'bg-red-950', border: 'border-red-800', text: 'text-red-400', label: 'ERROR' },
3: { bg: 'bg-gray-900', border: 'border-gray-700', text: 'text-gray-400', label: 'STALE' },
};
function getStatusColor(level) {
return STATUS_COLORS[level] || STATUS_COLORS[3];
}
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
function DiagnosticCard({ diagnostic, expanded, onToggle }) {
const color = getStatusColor(diagnostic.level);
return (
<div
className={`rounded-lg border p-3 space-y-2 cursor-pointer transition-all ${
color.bg
} ${color.border} ${expanded ? 'ring-2 ring-offset-2 ring-cyan-500' : ''}`}
onClick={onToggle}
>
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-xs font-bold text-gray-400 truncate">{diagnostic.name}</div>
<div className={`text-sm font-mono font-bold ${color.text}`}>
{diagnostic.message}
</div>
</div>
<div
className={`px-2 py-1 rounded text-xs font-bold whitespace-nowrap flex-shrink-0 ${
color.bg
} ${color.border} border ${color.text}`}
>
{color.label}
</div>
</div>
{/* Expanded details */}
{expanded && (
<div className="border-t border-gray-700 pt-2 space-y-2 text-xs">
{diagnostic.values && diagnostic.values.length > 0 && (
<div className="space-y-1">
<div className="text-gray-500 font-bold">KEY VALUES:</div>
{diagnostic.values.slice(0, 5).map((val, i) => (
<div key={i} className="flex justify-between gap-2 text-gray-400">
<span className="font-mono truncate">{val.key}:</span>
<span className="font-mono text-gray-300 truncate">{val.value}</span>
</div>
))}
{diagnostic.values.length > 5 && (
<div className="text-gray-500">+{diagnostic.values.length - 5} more values</div>
)}
</div>
)}
{diagnostic.hardware_id && (
<div className="text-gray-500">
<span className="font-bold">Hardware:</span> {diagnostic.hardware_id}
</div>
)}
</div>
)}
</div>
);
}
function HealthTimeline({ statusHistory, maxEntries = 20 }) {
const recentHistory = statusHistory.slice(-maxEntries);
return (
<div className="space-y-2">
<div className="text-xs font-bold text-gray-400 tracking-widest">TIMELINE</div>
<div className="space-y-1">
{recentHistory.map((entry, i) => {
const color = getStatusColor(entry.level);
return (
<div key={i} className="flex items-center gap-2">
<div className="text-xs text-gray-500 font-mono whitespace-nowrap">
{formatTimestamp(entry.timestamp)}
</div>
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${color.text}`} />
<div className="text-xs text-gray-400 truncate">
{entry.name}: {entry.message}
</div>
</div>
);
})}
</div>
</div>
);
}
export function Diagnostics({ subscribe }) {
const [diagnostics, setDiagnostics] = useState({});
const [statusHistory, setStatusHistory] = useState([]);
const [expandedDiags, setExpandedDiags] = useState(new Set());
const diagRef = useRef({});
// Subscribe to diagnostics
useEffect(() => {
const unsubscribe = subscribe(
'/diagnostics',
'diagnostic_msgs/DiagnosticArray',
(msg) => {
try {
const diags = {};
const now = Date.now();
// Process each diagnostic status
(msg.status || []).forEach((status) => {
const name = status.name || 'unknown';
diags[name] = {
name,
level: status.level,
message: status.message || '',
hardware_id: status.hardware_id || '',
values: status.values || [],
timestamp: now,
};
// Add to history
setStatusHistory((prev) => [
...prev,
{
name,
level: status.level,
message: status.message || '',
timestamp: now,
},
].slice(-MAX_HISTORY));
});
setDiagnostics(diags);
diagRef.current = diags;
} catch (e) {
console.error('Error parsing diagnostics:', e);
}
}
);
return unsubscribe;
}, [subscribe]);
// Calculate statistics
const stats = {
total: Object.keys(diagnostics).length,
ok: Object.values(diagnostics).filter((d) => d.level === 0).length,
warning: Object.values(diagnostics).filter((d) => d.level === 1).length,
error: Object.values(diagnostics).filter((d) => d.level === 2).length,
stale: Object.values(diagnostics).filter((d) => d.level === 3).length,
};
const overallHealth =
stats.error > 0 ? 2 : stats.warning > 0 ? 1 : stats.stale > 0 ? 3 : 0;
const overallColor = getStatusColor(overallHealth);
const sortedDiags = Object.values(diagnostics).sort((a, b) => {
// Sort by level (errors first), then by name
if (a.level !== b.level) return b.level - a.level;
return a.name.localeCompare(b.name);
});
const toggleExpanded = (name) => {
const updated = new Set(expandedDiags);
if (updated.has(name)) {
updated.delete(name);
} else {
updated.add(name);
}
setExpandedDiags(updated);
};
return (
<div className="flex flex-col h-full space-y-3">
{/* Health Summary */}
<div className={`rounded-lg border p-3 space-y-3 ${overallColor.bg} ${overallColor.border}`}>
<div className="flex justify-between items-center">
<div className={`text-xs font-bold tracking-widest ${overallColor.text}`}>
SYSTEM HEALTH
</div>
<div className={`text-xs font-bold px-3 py-1 rounded ${overallColor.text}`}>
{overallColor.label}
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-5 gap-2">
<div className="bg-gray-900 rounded p-2">
<div className="text-gray-600 text-xs">TOTAL</div>
<div className="text-lg font-mono text-cyan-300 font-bold">{stats.total}</div>
</div>
<div className="bg-green-950 rounded p-2">
<div className="text-gray-600 text-xs">OK</div>
<div className="text-lg font-mono text-green-400 font-bold">{stats.ok}</div>
</div>
<div className="bg-yellow-950 rounded p-2">
<div className="text-gray-600 text-xs">WARN</div>
<div className="text-lg font-mono text-yellow-400 font-bold">{stats.warning}</div>
</div>
<div className="bg-red-950 rounded p-2">
<div className="text-gray-600 text-xs">ERROR</div>
<div className="text-lg font-mono text-red-400 font-bold">{stats.error}</div>
</div>
<div className="bg-gray-900 rounded p-2">
<div className="text-gray-600 text-xs">STALE</div>
<div className="text-lg font-mono text-gray-400 font-bold">{stats.stale}</div>
</div>
</div>
</div>
{/* Diagnostics Grid */}
<div className="flex-1 overflow-y-auto space-y-2">
{sortedDiags.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-600">
<div className="text-center">
<div className="text-sm mb-2">Waiting for diagnostics</div>
<div className="text-xs text-gray-700">
Messages from /diagnostics will appear here
</div>
</div>
</div>
) : (
sortedDiags.map((diag) => (
<DiagnosticCard
key={diag.name}
diagnostic={diag}
expanded={expandedDiags.has(diag.name)}
onToggle={() => toggleExpanded(diag.name)}
/>
))
)}
</div>
{/* Timeline and Info */}
<div className="grid grid-cols-2 gap-2">
{/* Timeline */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3">
<HealthTimeline statusHistory={statusHistory} maxEntries={10} />
</div>
{/* Legend */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-2">
<div className="text-xs font-bold text-gray-400 tracking-widest mb-2">STATUS LEGEND</div>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-gray-400">OK System nominal</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-yellow-500" />
<span className="text-gray-400">WARN Check required</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-gray-400">ERROR Immediate action</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-gray-500" />
<span className="text-gray-400">STALE No recent data</span>
</div>
</div>
</div>
</div>
{/* Topic Info */}
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
<div className="flex justify-between">
<span>Topic:</span>
<span className="text-gray-500">/diagnostics (diagnostic_msgs/DiagnosticArray)</span>
</div>
<div className="flex justify-between">
<span>Status Levels:</span>
<span className="text-gray-500">0=OK, 1=WARN, 2=ERROR, 3=STALE</span>
</div>
<div className="flex justify-between">
<span>History Limit:</span>
<span className="text-gray-500">{MAX_HISTORY} events</span>
</div>
</div>
</div>
);
}