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:
commit
5156100197
@ -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 />}
|
||||
|
||||
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