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:
parent
6dbbbb9adc
commit
aedb8771ad
@ -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() {
|
||||
</nav>
|
||||
|
||||
{/* ── 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 === 'faces' && <FaceGallery subscribe={subscribe} callService={callService} />}
|
||||
{activeTab === 'conversation' && <ConversationLog subscribe={subscribe} />}
|
||||
@ -218,6 +228,8 @@ export default function App() {
|
||||
{activeTab === 'fleet' && <FleetPanel />}
|
||||
{activeTab === 'missions' && <MissionPlanner />}
|
||||
|
||||
{activeTab === 'eventlog' && <EventLog subscribe={subscribe} />}
|
||||
|
||||
{activeTab === 'settings' && <SettingsPanel subscribe={subscribe} callService={callService} connected={connected} wsUrl={wsUrl} />}
|
||||
</main>
|
||||
|
||||
|
||||
290
ui/social-bot/src/components/EventLog.jsx
Normal file
290
ui/social-bot/src/components/EventLog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user