sl-webui aedb8771ad 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>
2026-03-02 11:04:53 -05:00

252 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* App.jsx — Saltybot Social + Telemetry + Fleet + Mission Dashboard root component.
*
* Social tabs (issue #107):
* Status | Faces | Conversation | Personality | Navigation
*
* Telemetry tabs (issue #126):
* IMU | Battery | Motors | Map | Control | Health | Cameras
*
* Fleet tabs (issue #139):
* Fleet (self-contained via useFleet)
*
* Mission tabs (issue #145):
* Missions (waypoint editor, route builder, geofence, schedule, execute)
*
* Camera viewer (issue #177):
* CSI × 4 + D435i RGB/depth + panoramic, detection overlays, recording
*/
import { useState, useCallback } from 'react';
import { useRosbridge } from './hooks/useRosbridge.js';
// Social panels
import { StatusPanel } from './components/StatusPanel.jsx';
import { FaceGallery } from './components/FaceGallery.jsx';
import { ConversationLog } from './components/ConversationLog.jsx';
import { PersonalityTuner } from './components/PersonalityTuner.jsx';
import { NavModeSelector } from './components/NavModeSelector.jsx';
// Telemetry panels
import { ImuPanel } from './components/ImuPanel.jsx';
import { BatteryPanel } from './components/BatteryPanel.jsx';
import { MotorPanel } from './components/MotorPanel.jsx';
import { MapViewer } from './components/MapViewer.jsx';
import { ControlMode } from './components/ControlMode.jsx';
import { SystemHealth } from './components/SystemHealth.jsx';
// Fleet panel (issue #139)
import { FleetPanel } from './components/FleetPanel.jsx';
// Mission planner (issue #145)
import { MissionPlanner } from './components/MissionPlanner.jsx';
// Settings panel (issue #160)
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',
color: 'text-cyan-600',
tabs: [
{ id: 'status', label: 'Status', },
{ id: 'faces', label: 'Faces', },
{ id: 'conversation', label: 'Convo', },
{ id: 'personality', label: 'Personality', },
{ id: 'navigation', label: 'Nav Mode', },
],
},
{
label: 'TELEMETRY',
color: 'text-amber-600',
tabs: [
{ id: 'imu', label: 'IMU', },
{ id: 'battery', label: 'Battery', },
{ id: 'motors', label: 'Motors', },
{ id: 'map', label: 'Map', },
{ id: 'control', label: 'Control', },
{ id: 'health', label: 'Health', },
{ id: 'cameras', label: 'Cameras', },
],
},
{
label: 'FLEET',
color: 'text-green-600',
tabs: [
{ id: 'fleet', label: 'Fleet' },
{ id: 'missions', label: 'Missions' },
],
},
{
label: 'MONITORING',
color: 'text-yellow-600',
tabs: [
{ id: 'eventlog', label: 'Events' },
],
},
{
label: 'CONFIG',
color: 'text-purple-600',
tabs: [
{ id: 'settings', label: 'Settings' },
],
},
];
const FLEET_TABS = new Set(['fleet', 'missions']);
const DEFAULT_WS_URL = 'ws://localhost:9090';
function ConnectionBar({ url, setUrl, connected, error }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(url);
const handleApply = () => { setUrl(draft); setEditing(false); };
return (
<div className="flex items-center gap-2 text-xs">
<div className={`w-2 h-2 rounded-full shrink-0 ${
connected ? 'bg-green-400' : error ? 'bg-red-500' : 'bg-gray-600'
}`} />
{editing ? (
<div className="flex items-center gap-1">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleApply();
if (e.key === 'Escape') setEditing(false);
}}
autoFocus
className="bg-gray-900 border border-cyan-800 rounded px-2 py-0.5 text-cyan-300 w-52 focus:outline-none"
/>
<button
onClick={handleApply}
className="px-2 py-0.5 rounded bg-cyan-950 border border-cyan-700 text-cyan-400 hover:bg-cyan-900 text-xs"
>
Connect
</button>
<button onClick={() => setEditing(false)} className="text-gray-600 hover:text-gray-400 px-1">
</button>
</div>
) : (
<button
onClick={() => { setDraft(url); setEditing(true); }}
className="text-gray-500 hover:text-cyan-400 transition-colors truncate max-w-52"
title={url}
>
{connected ? (
<span className="text-green-400">{url}</span>
) : error ? (
<span className="text-red-400"> {url}</span>
) : (
<span className="text-gray-500">{url}</span>
)}
</button>
)}
</div>
);
}
export default function App() {
const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL);
const [activeTab, setActiveTab] = useState('status');
const { connected, error, subscribe, publish, callService, setParam } = useRosbridge(wsUrl);
const publishFn = useCallback((name, type, data) => publish(name, type, data), [publish]);
return (
<div className="min-h-screen flex flex-col bg-[#050510] text-gray-300 font-mono">
{/* ── Top Bar ── */}
<header className="flex items-center justify-between px-4 py-2 bg-[#070712] border-b border-cyan-950 shrink-0 gap-2 flex-wrap">
<div className="flex items-center gap-3">
<span className="text-orange-500 font-bold tracking-widest text-sm"> SALTYBOT</span>
<span className="text-cyan-800 text-xs hidden sm:inline">DASHBOARD</span>
</div>
{!FLEET_TABS.has(activeTab) && (
<ConnectionBar url={wsUrl} setUrl={setWsUrl} connected={connected} error={error} />
)}
</header>
{/* ── Tab Navigation ── */}
<nav className="bg-[#070712] border-b border-cyan-950 shrink-0 overflow-x-auto">
<div className="flex min-w-max">
{TAB_GROUPS.map((group, gi) => (
<div key={group.label} className="flex items-stretch">
{gi > 0 && (
<div className="flex items-center px-1">
<div className="w-px h-4 bg-cyan-950" />
</div>
)}
<div className="flex items-center px-1.5 shrink-0">
<span className={`text-xs font-bold tracking-widest ${group.color} opacity-60`}>
{group.label}
</span>
</div>
{group.tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-3 py-2.5 text-xs font-bold tracking-wider whitespace-nowrap border-b-2 transition-colors ${
activeTab === tab.id
? 'border-cyan-500 text-cyan-300 bg-cyan-950 bg-opacity-30'
: 'border-transparent text-gray-600 hover:text-gray-300 hover:border-gray-700'
}`}
>
{tab.label.toUpperCase()}
</button>
))}
</div>
))}
</div>
</nav>
{/* ── Content ── */}
<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} />}
{activeTab === 'personality' && <PersonalityTuner subscribe={subscribe} setParam={setParam} />}
{activeTab === 'navigation' && <NavModeSelector subscribe={subscribe} publish={publishFn} />}
{activeTab === 'imu' && <ImuPanel subscribe={subscribe} />}
{activeTab === 'battery' && <BatteryPanel subscribe={subscribe} />}
{activeTab === 'motors' && <MotorPanel subscribe={subscribe} />}
{activeTab === 'map' && <MapViewer subscribe={subscribe} />}
{activeTab === 'control' && <ControlMode subscribe={subscribe} />}
{activeTab === 'health' && <SystemHealth subscribe={subscribe} />}
{activeTab === 'cameras' && <CameraViewer subscribe={subscribe} />}
{activeTab === 'fleet' && <FleetPanel />}
{activeTab === 'missions' && <MissionPlanner />}
{activeTab === 'eventlog' && <EventLog subscribe={subscribe} />}
{activeTab === 'settings' && <SettingsPanel subscribe={subscribe} callService={callService} connected={connected} wsUrl={wsUrl} />}
</main>
{/* ── Footer ── */}
<footer className="bg-[#070712] border-t border-cyan-950 px-4 py-1.5 flex items-center justify-between text-xs text-gray-700 shrink-0">
{!FLEET_TABS.has(activeTab) ? (
<>
<span>rosbridge: <code className="text-gray-600">{wsUrl}</code></span>
<span className={connected ? 'text-green-700' : 'text-red-900'}>
{connected ? 'CONNECTED' : 'DISCONNECTED'}
</span>
</>
) : (
<span className="text-green-800">FLEET MODE multi-robot</span>
)}
</footer>
</div>
);
}