/** * 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 (
{colors.label} {formatTimestamp(event.timestamp)}
{event.message}
{event.details && (
{typeof event.details === 'string' ? ( event.details ) : (
{JSON.stringify(event.details, null, 2)}
)}
)}
); } 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 (
{/* Controls */}
EVENT LOG
{filteredEvents.length} of {events.length} events
{/* Filter buttons */}
{Object.entries(EVENT_COLORS).map(([typeKey, colors]) => ( ))}
{/* Event list */}
{filteredEvents.length > 0 ? ( <> {filteredEvents.map((event) => { const colors = EVENT_COLORS[event.type]; return (
setExpandedEventId(expandedEventId === event.id ? null : event.id) } className="cursor-pointer hover:opacity-80 transition-opacity" >
); })}
) : (
{events.length === 0 ? ( <>
No events yet
Waiting for events from emergency, docking, and diagnostics topics…
) : ( <>
No events match selected filter
{events.length} events available, adjust filters above
)}
)}
{/* Stats footer */}
Displaying {filteredEvents.length} / {events.length} events Max capacity: {MAX_EVENTS}
); }