feat(webui): diagnostics panel — system health overview with alerts (Issue #340) #343
@ -79,6 +79,9 @@ import { NodeList } from './components/NodeList.jsx';
|
|||||||
// Gamepad teleoperation (issue #319)
|
// Gamepad teleoperation (issue #319)
|
||||||
import { Teleop } from './components/Teleop.jsx';
|
import { Teleop } from './components/Teleop.jsx';
|
||||||
|
|
||||||
|
// System diagnostics (issue #340)
|
||||||
|
import { Diagnostics } from './components/Diagnostics.jsx';
|
||||||
|
|
||||||
const TAB_GROUPS = [
|
const TAB_GROUPS = [
|
||||||
{
|
{
|
||||||
label: 'SOCIAL',
|
label: 'SOCIAL',
|
||||||
@ -127,6 +130,7 @@ const TAB_GROUPS = [
|
|||||||
label: 'MONITORING',
|
label: 'MONITORING',
|
||||||
color: 'text-yellow-600',
|
color: 'text-yellow-600',
|
||||||
tabs: [
|
tabs: [
|
||||||
|
{ id: 'diagnostics', label: 'Diagnostics' },
|
||||||
{ id: 'eventlog', label: 'Events' },
|
{ id: 'eventlog', label: 'Events' },
|
||||||
{ id: 'bandwidth', label: 'Bandwidth' },
|
{ id: 'bandwidth', label: 'Bandwidth' },
|
||||||
{ id: 'nodes', label: 'Nodes' },
|
{ id: 'nodes', label: 'Nodes' },
|
||||||
@ -281,6 +285,8 @@ export default function App() {
|
|||||||
{activeTab === 'fleet' && <FleetPanel />}
|
{activeTab === 'fleet' && <FleetPanel />}
|
||||||
{activeTab === 'missions' && <MissionPlanner />}
|
{activeTab === 'missions' && <MissionPlanner />}
|
||||||
|
|
||||||
|
{activeTab === 'diagnostics' && <Diagnostics subscribe={subscribe} />}
|
||||||
|
|
||||||
{activeTab === 'eventlog' && <EventLog subscribe={subscribe} />}
|
{activeTab === 'eventlog' && <EventLog subscribe={subscribe} />}
|
||||||
|
|
||||||
{activeTab === 'bandwidth' && <BandwidthMonitor />}
|
{activeTab === 'bandwidth' && <BandwidthMonitor />}
|
||||||
|
|||||||
308
ui/social-bot/src/components/Diagnostics.jsx
Normal file
308
ui/social-bot/src/components/Diagnostics.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user