/**
* 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}
);
}