- useSettings.js: PID parameter catalogue, step-response simulation, ROS2 parameter apply via rcl_interfaces/srv/SetParameters, sensor param management, firmware info extraction from /diagnostics, diagnostics bundle export, JSON backup/restore, localStorage persist - SettingsPanel.jsx: 6-view panel (PID, Sensors, Network, Firmware, Diagnostics, Backup); StepResponseCanvas with stable/oscillating/ unstable colour-coding; GainSlider with range+number input; weight- class tabs (empty/light/heavy); parameter validation badges - App.jsx: CONFIG tab group (purple), settings tab render, FLEET_TABS set to gate ConnectionBar and footer for fleet/missions/settings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
232 lines
8.6 KiB
JavaScript
232 lines
8.6 KiB
JavaScript
/**
|
|
* 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
|
|
*
|
|
* Fleet tabs (issue #139):
|
|
* Fleet (self-contained via useFleet)
|
|
*
|
|
* Mission tabs (issue #145):
|
|
* Missions (waypoint editor, route builder, geofence, schedule, execute)
|
|
*/
|
|
|
|
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';
|
|
|
|
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', },
|
|
],
|
|
},
|
|
{
|
|
label: 'FLEET',
|
|
color: 'text-green-600',
|
|
tabs: [
|
|
{ id: 'fleet', label: 'Fleet' },
|
|
{ id: 'missions', label: 'Missions' },
|
|
],
|
|
},
|
|
{
|
|
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 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 === 'fleet' && <FleetPanel />}
|
|
{activeTab === 'missions' && <MissionPlanner />}
|
|
|
|
{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>
|
|
);
|
|
}
|