feat(webui): robot event log viewer — emergency/docking/diagnostics (Issue #192)

- Add EventLog component with real-time event streaming
- Color-coded events: red=emergency, blue=docking, cyan=diagnostics
- Filter by event type with toggle buttons
- Auto-scroll to latest event
- Timestamped event cards with details display
- Max 200 event history (FIFO)
- Add MONITORING tab group with Events tab to App.jsx
- Supports /saltybot/emergency, /saltybot/docking_status, /diagnostics topics

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
sl-webui 2026-03-02 11:04:53 -05:00
parent 6dbbbb9adc
commit aedb8771ad
2 changed files with 303 additions and 1 deletions

View File

@ -47,6 +47,9 @@ import { SettingsPanel } from './components/SettingsPanel.jsx';
// Camera viewer (issue #177) // Camera viewer (issue #177)
import { CameraViewer } from './components/CameraViewer.jsx'; import { CameraViewer } from './components/CameraViewer.jsx';
// Event log (issue #192)
import { EventLog } from './components/EventLog.jsx';
const TAB_GROUPS = [ const TAB_GROUPS = [
{ {
label: 'SOCIAL', label: 'SOCIAL',
@ -80,6 +83,13 @@ const TAB_GROUPS = [
{ id: 'missions', label: 'Missions' }, { id: 'missions', label: 'Missions' },
], ],
}, },
{
label: 'MONITORING',
color: 'text-yellow-600',
tabs: [
{ id: 'eventlog', label: 'Events' },
],
},
{ {
label: 'CONFIG', label: 'CONFIG',
color: 'text-purple-600', color: 'text-purple-600',
@ -200,7 +210,7 @@ export default function App() {
</nav> </nav>
{/* ── Content ── */} {/* ── Content ── */}
<main className="flex-1 overflow-y-auto p-4"> <main className={`flex-1 ${activeTab === 'eventlog' ? 'flex flex-col' : 'overflow-y-auto'} p-4`}>
{activeTab === 'status' && <StatusPanel subscribe={subscribe} />} {activeTab === 'status' && <StatusPanel subscribe={subscribe} />}
{activeTab === 'faces' && <FaceGallery subscribe={subscribe} callService={callService} />} {activeTab === 'faces' && <FaceGallery subscribe={subscribe} callService={callService} />}
{activeTab === 'conversation' && <ConversationLog subscribe={subscribe} />} {activeTab === 'conversation' && <ConversationLog subscribe={subscribe} />}
@ -218,6 +228,8 @@ export default function App() {
{activeTab === 'fleet' && <FleetPanel />} {activeTab === 'fleet' && <FleetPanel />}
{activeTab === 'missions' && <MissionPlanner />} {activeTab === 'missions' && <MissionPlanner />}
{activeTab === 'eventlog' && <EventLog subscribe={subscribe} />}
{activeTab === 'settings' && <SettingsPanel subscribe={subscribe} callService={callService} connected={connected} wsUrl={wsUrl} />} {activeTab === 'settings' && <SettingsPanel subscribe={subscribe} callService={callService} connected={connected} wsUrl={wsUrl} />}
</main> </main>

View File

@ -0,0 +1,290 @@
/**
* EventLog.jsx Robot event log viewer
*
* Displays timestamped, color-coded event cards from:
* /saltybot/emergency (emergency events)
* /saltybot/docking_status (docking state changes)
* /diagnostics (system diagnostics)
*
* Features:
* - Real-time event streaming
* - Color-coded by event type (red=emergency, blue=docking, cyan=diagnostics)
* - Filter by type
* - Auto-scroll to latest event
* - Configurable max event history (default 200)
*/
import { useEffect, useRef, useState } from 'react';
const EVENT_TYPES = {
EMERGENCY: 'emergency',
DOCKING: 'docking',
DIAGNOSTIC: 'diagnostic',
};
const EVENT_COLORS = {
emergency: { bg: 'bg-red-950', border: 'border-red-800', text: 'text-red-400', label: 'Emergency' },
docking: { bg: 'bg-blue-950', border: 'border-blue-800', text: 'text-blue-400', label: 'Docking' },
diagnostic: { bg: 'bg-cyan-950', border: 'border-cyan-800', text: 'text-cyan-400', label: 'Diagnostic' },
};
const MAX_EVENTS = 200;
function formatTimestamp(ts) {
const date = new Date(ts);
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function EventCard({ event, colors }) {
return (
<div className={`rounded border ${colors.border} ${colors.bg} p-3 text-sm space-y-1`}>
<div className="flex justify-between items-start gap-2">
<span className={`font-bold tracking-widest text-xs ${colors.text}`}>
{colors.label}
</span>
<span className="text-gray-600 text-xs flex-shrink-0">
{formatTimestamp(event.timestamp)}
</span>
</div>
<div className="text-gray-300 break-words">
{event.message}
</div>
{event.details && (
<div className="text-gray-500 text-xs font-mono pt-1 border-t border-gray-800">
{typeof event.details === 'string' ? (
event.details
) : (
<pre className="overflow-x-auto">{JSON.stringify(event.details, null, 2)}</pre>
)}
</div>
)}
</div>
);
}
export function EventLog({ subscribe }) {
const [events, setEvents] = useState([]);
const [selectedTypes, setSelectedTypes] = useState(new Set(Object.values(EVENT_TYPES)));
const [expandedEventId, setExpandedEventId] = useState(null);
const scrollRef = useRef(null);
const eventIdRef = useRef(0);
// Auto-scroll to bottom when new events arrive
useEffect(() => {
if (scrollRef.current && events.length > 0) {
setTimeout(() => {
scrollRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, 0);
}
}, [events.length]);
// Subscribe to emergency events
useEffect(() => {
const unsub = subscribe(
'/saltybot/emergency',
'std_msgs/String',
(msg) => {
try {
const data = typeof msg.data === 'string' ? JSON.parse(msg.data) : msg.data;
setEvents((prev) => [
...prev,
{
id: ++eventIdRef.current,
type: EVENT_TYPES.EMERGENCY,
timestamp: Date.now(),
message: data.message || data.status || JSON.stringify(data),
details: data,
},
].slice(-MAX_EVENTS));
} catch (e) {
setEvents((prev) => [
...prev,
{
id: ++eventIdRef.current,
type: EVENT_TYPES.EMERGENCY,
timestamp: Date.now(),
message: msg.data || 'Unknown emergency event',
details: null,
},
].slice(-MAX_EVENTS));
}
}
);
return unsub;
}, [subscribe]);
// Subscribe to docking status
useEffect(() => {
const unsub = subscribe(
'/saltybot/docking_status',
'std_msgs/String',
(msg) => {
try {
const data = typeof msg.data === 'string' ? JSON.parse(msg.data) : msg.data;
const statusMsg = data.status || data.state || data.message || JSON.stringify(data);
setEvents((prev) => [
...prev,
{
id: ++eventIdRef.current,
type: EVENT_TYPES.DOCKING,
timestamp: Date.now(),
message: `Docking Status: ${statusMsg}`,
details: data,
},
].slice(-MAX_EVENTS));
} catch (e) {
setEvents((prev) => [
...prev,
{
id: ++eventIdRef.current,
type: EVENT_TYPES.DOCKING,
timestamp: Date.now(),
message: `Docking Status: ${msg.data || 'Unknown'}`,
details: null,
},
].slice(-MAX_EVENTS));
}
}
);
return unsub;
}, [subscribe]);
// Subscribe to diagnostics
useEffect(() => {
const unsub = subscribe(
'/diagnostics',
'diagnostic_msgs/DiagnosticArray',
(msg) => {
try {
for (const status of msg.status ?? []) {
if (status.level > 0) {
// Only log warnings and errors
const kv = {};
for (const pair of status.values ?? []) {
kv[pair.key] = pair.value;
}
setEvents((prev) => [
...prev,
{
id: ++eventIdRef.current,
type: EVENT_TYPES.DIAGNOSTIC,
timestamp: Date.now(),
message: `${status.name}: ${status.message}`,
details: kv,
},
].slice(-MAX_EVENTS));
}
}
} catch (e) {
// Ignore parsing errors
}
}
);
return unsub;
}, [subscribe]);
const filteredEvents = events.filter((event) => selectedTypes.has(event.type));
const toggleEventType = (type) => {
setSelectedTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
const clearEvents = () => {
setEvents([]);
eventIdRef.current = 0;
};
return (
<div className="flex flex-col h-full space-y-3">
{/* Controls */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4 space-y-3">
<div className="flex justify-between items-center flex-wrap gap-2">
<div className="text-cyan-700 text-xs font-bold tracking-widest">
EVENT LOG
</div>
<div className="text-gray-600 text-xs">
{filteredEvents.length} of {events.length} events
</div>
</div>
{/* Filter buttons */}
<div className="flex gap-2 flex-wrap">
{Object.entries(EVENT_COLORS).map(([typeKey, colors]) => (
<button
key={typeKey}
onClick={() => toggleEventType(typeKey)}
className={`px-3 py-1.5 text-xs font-bold tracking-widest rounded border transition-colors ${
selectedTypes.has(typeKey)
? `${colors.bg} ${colors.border} ${colors.text}`
: 'bg-gray-900 border-gray-800 text-gray-600 hover:text-gray-400'
}`}
>
{colors.label}
</button>
))}
<button
onClick={clearEvents}
className="ml-auto px-3 py-1.5 text-xs font-bold tracking-widest rounded border border-gray-800 text-gray-600 hover:text-red-400 hover:border-red-800 transition-colors"
>
CLEAR
</button>
</div>
</div>
{/* Event list */}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{filteredEvents.length > 0 ? (
<>
{filteredEvents.map((event) => {
const colors = EVENT_COLORS[event.type];
return (
<div
key={event.id}
onClick={() =>
setExpandedEventId(expandedEventId === event.id ? null : event.id)
}
className="cursor-pointer hover:opacity-80 transition-opacity"
>
<EventCard event={event} colors={colors} />
</div>
);
})}
<div ref={scrollRef} />
</>
) : (
<div className="text-center py-12 text-gray-600 text-sm">
{events.length === 0 ? (
<>
<div>No events yet</div>
<div className="text-xs text-gray-700 mt-2">
Waiting for events from emergency, docking, and diagnostics topics
</div>
</>
) : (
<>
<div>No events match selected filter</div>
<div className="text-xs text-gray-700 mt-2">
{events.length} events available, adjust filters above
</div>
</>
)}
</div>
)}
</div>
{/* Stats footer */}
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 flex justify-between">
<span>Displaying {filteredEvents.length} / {events.length} events</span>
<span className="text-gray-700">Max capacity: {MAX_EVENTS}</span>
</div>
</div>
);
}