From aedb8771ad81688aa917fcdfa195dc2c46ac9b8a Mon Sep 17 00:00:00 2001 From: sl-webui Date: Mon, 2 Mar 2026 11:04:53 -0500 Subject: [PATCH] =?UTF-8?q?feat(webui):=20robot=20event=20log=20viewer=20?= =?UTF-8?q?=E2=80=94=20emergency/docking/diagnostics=20(Issue=20#192)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ui/social-bot/src/App.jsx | 14 +- ui/social-bot/src/components/EventLog.jsx | 290 ++++++++++++++++++++++ 2 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 ui/social-bot/src/components/EventLog.jsx diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx index e614f32..0ff8dfb 100644 --- a/ui/social-bot/src/App.jsx +++ b/ui/social-bot/src/App.jsx @@ -47,6 +47,9 @@ import { SettingsPanel } from './components/SettingsPanel.jsx'; // Camera viewer (issue #177) import { CameraViewer } from './components/CameraViewer.jsx'; +// Event log (issue #192) +import { EventLog } from './components/EventLog.jsx'; + const TAB_GROUPS = [ { label: 'SOCIAL', @@ -80,6 +83,13 @@ const TAB_GROUPS = [ { id: 'missions', label: 'Missions' }, ], }, + { + label: 'MONITORING', + color: 'text-yellow-600', + tabs: [ + { id: 'eventlog', label: 'Events' }, + ], + }, { label: 'CONFIG', color: 'text-purple-600', @@ -200,7 +210,7 @@ export default function App() { {/* ── Content ── */} -
+
{activeTab === 'status' && } {activeTab === 'faces' && } {activeTab === 'conversation' && } @@ -218,6 +228,8 @@ export default function App() { {activeTab === 'fleet' && } {activeTab === 'missions' && } + {activeTab === 'eventlog' && } + {activeTab === 'settings' && }
diff --git a/ui/social-bot/src/components/EventLog.jsx b/ui/social-bot/src/components/EventLog.jsx new file mode 100644 index 0000000..7854e3a --- /dev/null +++ b/ui/social-bot/src/components/EventLog.jsx @@ -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 ( +
+
+ + {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} +
+
+ ); +}