sl-webui da3ee19688
Some checks failed
social-bot integration tests / Lint (flake8 + pep257) (pull_request) Failing after 2s
social-bot integration tests / Core integration tests (mock sensors, no GPU) (pull_request) Has been skipped
social-bot integration tests / Latency profiling (GPU, Orin) (pull_request) Has been cancelled
feat(webui): settings & configuration panel (Issue #160)
- 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>
2026-03-02 10:26:42 -05:00

453 lines
23 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.

/**
* SettingsPanel.jsx — System configuration dashboard.
*
* Sub-views: PID | Sensors | Network | Firmware | Diagnostics | Backup
*
* Props:
* subscribe, callService — from useRosbridge
* connected — rosbridge connection status
* wsUrl — current rosbridge WebSocket URL
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import {
useSettings, PID_PARAMS, SENSOR_PARAMS, PID_NODE,
simulateStepResponse, validatePID,
} from '../hooks/useSettings.js';
const VIEWS = ['PID', 'Sensors', 'Network', 'Firmware', 'Diagnostics', 'Backup'];
function ValidationBadges({ warnings }) {
if (!warnings?.length) return (
<div className="flex items-center gap-1.5 text-xs text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 inline-block"/>
All gains within safe bounds
</div>
);
return (
<div className="space-y-1">
{warnings.map((w, i) => (
<div key={i} className={`flex items-start gap-2 text-xs rounded px-2 py-1 border ${
w.level === 'error'
? 'bg-red-950 border-red-800 text-red-400'
: 'bg-amber-950 border-amber-800 text-amber-300'
}`}>
<span className="shrink-0">{w.level === 'error' ? '✕' : '⚠'}</span>
{w.msg}
</div>
))}
</div>
);
}
function ApplyResult({ result }) {
if (!result) return null;
return (
<div className={`text-xs rounded px-2 py-1 border ${
result.ok
? 'bg-green-950 border-green-800 text-green-400'
: 'bg-red-950 border-red-800 text-red-400'
}`}>{result.ok ? '✓ ' : '✕ '}{result.msg}</div>
);
}
function StepResponseCanvas({ kp, ki, kd }) {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { top: 12, right: 16, bottom: 24, left: 40 };
const CW = W - PAD.left - PAD.right;
const CH = H - PAD.top - PAD.bottom;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#050510';
ctx.fillRect(0, 0, W, H);
const data = simulateStepResponse(kp, ki, kd);
if (!data.length) return;
const tail = data.slice(-20);
const maxTail = Math.max(...tail.map(d => Math.abs(d.theta)));
const finalMax = Math.max(...data.map(d => Math.abs(d.theta)));
const isUnstable = finalMax > 45;
const isOscillating = !isUnstable && maxTail > 1.0;
const lineColor = isUnstable ? '#ef4444' : isOscillating ? '#f59e0b' : '#22c55e';
const yMax = Math.min(90, Math.max(10, finalMax * 1.2));
const yMin = -Math.min(5, yMax * 0.1);
const tx = (t) => PAD.left + (t / 2.4) * CW;
const ty = (v) => PAD.top + CH - ((v - yMin) / (yMax - yMin)) * CH;
ctx.strokeStyle = '#0d1b2a'; ctx.lineWidth = 1;
for (let v = -10; v <= 90; v += 5) {
if (v < yMin || v > yMax) continue;
ctx.beginPath(); ctx.moveTo(PAD.left, ty(v)); ctx.lineTo(PAD.left + CW, ty(v)); ctx.stroke();
}
for (let t = 0; t <= 2.4; t += 0.4) {
ctx.beginPath(); ctx.moveTo(tx(t), PAD.top); ctx.lineTo(tx(t), PAD.top + CH); ctx.stroke();
}
ctx.strokeStyle = lineColor; ctx.lineWidth = 2;
ctx.shadowBlur = isUnstable ? 0 : 4; ctx.shadowColor = lineColor;
ctx.beginPath();
data.forEach((d, i) => {
const x = tx(d.t), y = ty(Math.max(yMin, Math.min(yMax, d.theta)));
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke(); ctx.shadowBlur = 0;
ctx.fillStyle = '#4b5563'; ctx.font = '8px monospace'; ctx.textAlign = 'right';
[0, 5, 10, 20, 45, 90].filter(v => v >= yMin && v <= yMax).forEach(v => {
ctx.fillText(`${v}°`, PAD.left - 3, ty(v) + 3);
});
ctx.textAlign = 'center';
[0, 0.5, 1.0, 1.5, 2.0, 2.4].forEach(t => {
ctx.fillText(`${t.toFixed(1)}`, tx(t), PAD.top + CH + 14);
});
const label = isUnstable ? 'UNSTABLE' : isOscillating ? 'OSCILLATING' : 'STABLE';
ctx.fillStyle = lineColor; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'right';
ctx.fillText(label, PAD.left + CW, PAD.top + 10);
}, [kp, ki, kd]);
return (
<canvas ref={canvasRef} width={380} height={140}
className="w-full block rounded bg-gray-950 border border-gray-800" />
);
}
function GainSlider({ param, value, onChange }) {
const pct = ((value - param.min) / (param.max - param.min)) * 100;
return (
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500">{param.label}</span>
<input type="number"
className="w-16 text-right bg-gray-900 border border-gray-700 rounded px-1 py-0.5 text-xs text-cyan-200 focus:outline-none focus:border-cyan-700"
value={typeof value === 'boolean' ? value : Number(value).toFixed(param.step < 0.1 ? 3 : param.step < 1 ? 2 : 1)}
step={param.step} min={param.min} max={param.max}
onChange={e => onChange(param.key, param.type === 'bool' ? e.target.checked : parseFloat(e.target.value))}
/>
</div>
{param.type !== 'bool' && (
<div className="relative h-1.5 bg-gray-800 rounded overflow-hidden">
<div className="absolute h-full rounded" style={{ width: `${pct}%`, background: '#06b6d4' }} />
<input type="range" className="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
min={param.min} max={param.max} step={param.step} value={value}
onChange={e => onChange(param.key, parseFloat(e.target.value))} />
</div>
)}
</div>
);
}
function PIDView({ gains, setGains, applyPIDGains, applying, applyResult, connected }) {
const [activeClass, setActiveClass] = useState('empty');
const warnings = validatePID(gains);
const classKeys = {
empty: ['kp_empty','ki_empty','kd_empty'],
light: ['kp_light','ki_light','kd_light'],
heavy: ['kp_heavy','ki_heavy','kd_heavy'],
};
const overrideKeys = ['override_enabled','override_kp','override_ki','override_kd'];
const clampKeys = ['kp_min','kp_max','ki_min','ki_max','kd_min','kd_max'];
const miscKeys = ['control_rate','balance_setpoint_rad'];
const handleChange = (key, val) => setGains(g => ({ ...g, [key]: val }));
const previewKp = gains.override_enabled ? gains.override_kp : gains[`kp_${activeClass}`] ?? 15;
const previewKi = gains.override_enabled ? gains.override_ki : gains[`ki_${activeClass}`] ?? 0.5;
const previewKd = gains.override_enabled ? gains.override_kd : gains[`kd_${activeClass}`] ?? 1.5;
const paramsByKey = Object.fromEntries(PID_PARAMS.map(p => [p.key, p]));
return (
<div className="space-y-4">
<div className="flex items-center gap-2 flex-wrap">
<div className="text-cyan-700 text-xs font-bold tracking-widest">PID GAIN EDITOR</div>
<span className={`text-xs px-1.5 py-0.5 rounded border ml-auto ${connected ? 'text-green-400 border-green-800' : 'text-gray-600 border-gray-700'}`}>
{connected ? 'LIVE' : 'OFFLINE'}
</span>
</div>
<div className="space-y-1">
<div className="text-gray-600 text-xs">Simulated step response ({gains.override_enabled ? 'override' : activeClass} gains · 5° disturbance)</div>
<StepResponseCanvas kp={previewKp} ki={previewKi} kd={previewKd} />
</div>
<div className="flex gap-0.5 border-b border-gray-800">
{Object.keys(classKeys).map(c => (
<button key={c} onClick={() => setActiveClass(c)}
className={`px-3 py-1.5 text-xs font-bold tracking-wide border-b-2 transition-colors ${activeClass===c?'border-cyan-500 text-cyan-300':'border-transparent text-gray-600 hover:text-gray-300'}`}
>{c.toUpperCase()}</button>
))}
{[['override','border-amber-500 text-amber-300'],['clamp','border-purple-500 text-purple-300'],['misc','border-gray-400 text-gray-200']].map(([id, activeStyle]) => (
<button key={id} onClick={() => setActiveClass(id)}
className={`px-3 py-1.5 text-xs font-bold tracking-wide border-b-2 transition-colors ${activeClass===id?activeStyle:'border-transparent text-gray-600 hover:text-gray-300'}`}
>{id.toUpperCase()}</button>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{(activeClass==='override'?overrideKeys:activeClass==='clamp'?clampKeys:activeClass==='misc'?miscKeys:classKeys[activeClass]).map(key => {
const p = paramsByKey[key]; if (!p) return null;
return <GainSlider key={key} param={p} value={gains[key]??p.default} onChange={handleChange} />;
})}
</div>
<ValidationBadges warnings={warnings} />
<div className="flex gap-2 items-center flex-wrap">
<button onClick={() => applyPIDGains()} disabled={applying||warnings.some(w=>w.level==='error')}
className="px-4 py-1.5 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-xs font-bold disabled:opacity-40">
{applying?'Applying…':connected?'Apply to Robot':'Save Locally'}
</button>
{warnings.some(w=>w.level==='error')&&<span className="text-red-500 text-xs">Fix errors before applying</span>}
<ApplyResult result={applyResult} />
</div>
</div>
);
}
function SensorsView({ sensors, setSensors, applySensorParams, applying, applyResult, connected }) {
const handleChange = (key, val) => setSensors(s => ({ ...s, [key]: val }));
const grouped = {};
SENSOR_PARAMS.forEach(p => { if (!grouped[p.node]) grouped[p.node] = []; grouped[p.node].push(p); });
return (
<div className="space-y-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest">SENSOR CONFIGURATION</div>
{Object.entries(grouped).map(([node, params]) => (
<div key={node} className="bg-gray-950 border border-gray-800 rounded-lg p-3 space-y-3">
<div className="text-gray-500 text-xs font-bold font-mono">{node}</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{params.map(p => p.type === 'bool' ? (
<label key={p.key} className="flex items-center gap-2 text-xs cursor-pointer">
<div onClick={() => handleChange(p.key, !sensors[p.key])}
className={`w-8 h-4 rounded-full relative cursor-pointer transition-colors ${sensors[p.key]?'bg-cyan-700':'bg-gray-700'}`}>
<span className={`absolute top-0.5 w-3 h-3 rounded-full bg-white transition-all ${sensors[p.key]?'left-4':'left-0.5'}`}/>
</div>
<span className="text-gray-400">{p.label}</span>
</label>
) : (
<GainSlider key={p.key} param={p} value={sensors[p.key]??p.default} onChange={handleChange} />
))}
</div>
</div>
))}
<div className="flex gap-2 items-center flex-wrap">
<button onClick={() => applySensorParams()} disabled={applying}
className="px-4 py-1.5 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-xs font-bold disabled:opacity-40">
{applying?'Applying…':connected?'Apply to Robot':'Save Locally'}
</button>
<ApplyResult result={applyResult} />
</div>
</div>
);
}
function NetworkView({ wsUrl, connected }) {
const [ddsDomain, setDdsDomain] = useState(() => parseInt(localStorage.getItem('saltybot_dds_domain')||'0',10));
const [saved, setSaved] = useState(false);
const urlObj = (() => { try { return new URL(wsUrl); } catch { return null; } })();
const saveDDS = () => { localStorage.setItem('saltybot_dds_domain', String(ddsDomain)); setSaved(true); setTimeout(()=>setSaved(false),2000); };
return (
<div className="space-y-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest">NETWORK SETTINGS</div>
<div className="bg-gray-950 border border-gray-800 rounded-lg p-3 space-y-2">
<div className="text-gray-500 text-xs font-bold">ROSBRIDGE WEBSOCKET</div>
<div className="grid grid-cols-2 gap-2 text-xs">
{[['URL', wsUrl, 'text-cyan-300 font-mono truncate'], ['Host', urlObj?.hostname??'—', 'text-gray-300 font-mono'],
['Port', urlObj?.port??'9090', 'text-gray-300 font-mono'],
['Status', connected?'CONNECTED':'DISCONNECTED', connected?'text-green-400':'text-red-400']
].map(([k, v, cls]) => (<><div key={k} className="text-gray-600">{k}</div><div className={cls}>{v}</div></>))}
</div>
</div>
<div className="bg-gray-950 border border-gray-800 rounded-lg p-3 space-y-3">
<div className="text-gray-500 text-xs font-bold">ROS2 DDS DOMAIN</div>
<div className="flex items-center gap-3">
<label className="text-gray-500 text-xs w-24">Domain ID</label>
<input type="number" min="0" max="232"
className="w-20 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 focus:outline-none focus:border-cyan-700"
value={ddsDomain} onChange={e=>setDdsDomain(parseInt(e.target.value)||0)} />
<button onClick={saveDDS} className="px-3 py-1 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-xs">Save</button>
{saved && <span className="text-green-400 text-xs">Saved</span>}
</div>
<div className="text-gray-700 text-xs">Set ROS_DOMAIN_ID on the robot to match. Range 0232.</div>
</div>
<div className="bg-gray-950 border border-gray-800 rounded-lg p-3 space-y-1 text-xs text-gray-600">
<div className="text-gray-500 font-bold text-xs">REFERENCE PORTS</div>
<div>Web dashboard: <code className="text-gray-400">8080</code></div>
<div>Rosbridge: <code className="text-gray-400">ws://&lt;host&gt;:9090</code></div>
<div>RTSP: <code className="text-gray-400">rtsp://&lt;host&gt;:8554/panoramic</code></div>
<div>MJPEG: <code className="text-gray-400">http://&lt;host&gt;:8080/stream?topic=/camera/panoramic/compressed</code></div>
</div>
</div>
);
}
function FirmwareView({ firmwareInfo, startFirmwareWatch, connected }) {
useEffect(() => { if (!connected) return; const unsub = startFirmwareWatch(); return unsub; }, [connected, startFirmwareWatch]);
const formatUptime = (s) => { if (!s) return '—'; return `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`; };
const rows = [
['STM32 Firmware', firmwareInfo?.stm32Version ?? '—'],
['Jetson SW', firmwareInfo?.jetsonVersion ?? '—'],
['Last OTA Update',firmwareInfo?.lastOtaDate ?? '—'],
['Hostname', firmwareInfo?.hostname ?? '—'],
['ROS Distro', firmwareInfo?.rosDistro ?? '—'],
['Uptime', formatUptime(firmwareInfo?.uptimeS)],
];
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="text-cyan-700 text-xs font-bold tracking-widest">FIRMWARE INFO</div>
{!connected && <span className="text-gray-600 text-xs">(connect to robot to fetch live info)</span>}
</div>
<div className="bg-gray-950 border border-gray-800 rounded-lg overflow-hidden">
{rows.map(([label, value], i) => (
<div key={label} className={`flex items-center px-4 py-2.5 text-xs ${i%2===0?'bg-gray-950':'bg-[#070712]'}`}>
<span className="text-gray-500 w-36 shrink-0">{label}</span>
<span className={`font-mono ${value==='—'?'text-gray-700':'text-cyan-300'}`}>{value}</span>
</div>
))}
</div>
{!firmwareInfo && connected && (
<div className="text-amber-700 text-xs border border-amber-900 rounded p-2">
Waiting for /diagnostics Ensure firmware diagnostics keys (stm32_fw_version etc.) are published.
</div>
)}
</div>
);
}
function DiagnosticsView({ exportDiagnosticsBundle, subscribe, connected }) {
const [diagData, setDiagData] = useState(null);
const [balanceData, setBalanceData] = useState(null);
const [exporting, setExporting] = useState(false);
useEffect(() => {
if (!connected || !subscribe) return;
const u1 = subscribe('/diagnostics', 'diagnostic_msgs/DiagnosticArray', msg => setDiagData(msg));
const u2 = subscribe('/saltybot/balance_state', 'std_msgs/String', msg => {
try { setBalanceData(JSON.parse(msg.data)); } catch {}
});
return () => { u1?.(); u2?.(); };
}, [connected, subscribe]);
const errorCount = (diagData?.status??[]).filter(s=>s.level>=2).length;
const warnCount = (diagData?.status??[]).filter(s=>s.level===1).length;
const handleExport = () => {
setExporting(true);
exportDiagnosticsBundle(balanceData?{'live':{balanceState:balanceData}}:{}, {});
setTimeout(()=>setExporting(false), 1000);
};
return (
<div className="space-y-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest">DIAGNOSTICS EXPORT</div>
<div className="grid grid-cols-3 gap-3">
{[['ERRORS',errorCount,errorCount?'text-red-400':'text-gray-600'],['WARNINGS',warnCount,warnCount?'text-amber-400':'text-gray-600'],['NODES',diagData?.status?.length??0,'text-gray-400']].map(([l,c,cl])=>(
<div key={l} className="bg-gray-950 border border-gray-800 rounded p-3 text-center">
<div className={`text-2xl font-bold ${cl}`}>{c}</div>
<div className="text-gray-600 text-xs mt-0.5">{l}</div>
</div>
))}
</div>
<div className="bg-gray-950 border border-gray-800 rounded-lg p-4 space-y-3">
<div className="text-gray-400 text-sm font-bold">Download Diagnostics Bundle</div>
<div className="text-gray-600 text-xs">Bundle: PID gains, sensor settings, firmware info, live /diagnostics, balance state, timestamp.</div>
<button onClick={handleExport} disabled={exporting}
className="px-4 py-2 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-xs font-bold disabled:opacity-40">
{exporting?'Preparing…':'Download JSON Bundle'}
</button>
<div className="text-gray-700 text-xs">
For rosbag: <code className="text-gray-600">ros2 bag record -o saltybot_diag /diagnostics /saltybot/balance_state /odom</code>
</div>
</div>
{diagData?.status?.length>0 && (
<div className="space-y-1 max-h-64 overflow-y-auto">
{[...diagData.status].sort((a,b)=>(b.level??0)-(a.level??0)).map((s,i)=>(
<div key={i} className={`flex items-center gap-2 px-3 py-1.5 rounded border text-xs ${s.level>=2?'bg-red-950 border-red-800':s.level===1?'bg-amber-950 border-amber-800':'bg-gray-950 border-gray-800'}`}>
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${s.level>=2?'bg-red-400':s.level===1?'bg-amber-400':'bg-green-400'}`}/>
<span className="text-gray-300 font-bold truncate flex-1">{s.name}</span>
<span className={s.level>=2?'text-red-400':s.level===1?'text-amber-400':'text-gray-600'}>{s.message||(s.level===0?'OK':`L${s.level}`)}</span>
</div>
))}
</div>
)}
</div>
);
}
function BackupView({ exportSettingsJSON, importSettingsJSON }) {
const [importText, setImportText] = useState('');
const [showImport, setShowImport] = useState(false);
const [msg, setMsg] = useState(null);
const showMsg = (text, ok=true) => { setMsg({text,ok}); setTimeout(()=>setMsg(null),4000); };
const handleExport = () => {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([exportSettingsJSON()],{type:'application/json'}));
a.download = `saltybot-settings-${new Date().toISOString().slice(0,10)}.json`;
a.click(); showMsg('Settings exported');
};
const handleImport = () => {
try { importSettingsJSON(importText); setImportText(''); setShowImport(false); showMsg('Settings imported'); }
catch(e) { showMsg('Import failed: '+e.message, false); }
};
return (
<div className="space-y-4 max-w-lg">
<div className="text-cyan-700 text-xs font-bold tracking-widest">BACKUP & RESTORE</div>
<div className="bg-gray-950 border border-gray-800 rounded-lg p-4 space-y-3">
<div className="text-gray-400 text-sm font-bold">Export Settings</div>
<div className="text-gray-600 text-xs">Saves PID gains, sensor config and UI preferences to JSON.</div>
<button onClick={handleExport} className="px-4 py-2 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-xs font-bold">Export JSON</button>
</div>
<div className="bg-gray-950 border border-gray-800 rounded-lg p-4 space-y-3">
<div className="text-gray-400 text-sm font-bold">Restore Settings</div>
<div className="text-gray-600 text-xs">Load a previously exported settings JSON. Click Apply after import to push to the robot.</div>
<button onClick={() => setShowImport(s=>!s)} className="px-4 py-2 rounded border border-gray-700 text-gray-400 hover:text-gray-200 text-xs">
{showImport?'Cancel':'Import JSON…'}
</button>
{showImport && (
<div className="space-y-2">
<textarea rows={8} className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-xs text-gray-200 focus:outline-none font-mono"
placeholder="Paste exported settings JSON here…" value={importText} onChange={e=>setImportText(e.target.value)} />
<button disabled={!importText.trim()} onClick={handleImport}
className="px-3 py-1 rounded bg-cyan-950 border border-cyan-700 text-cyan-300 hover:bg-cyan-900 text-xs disabled:opacity-40">Restore</button>
</div>
)}
</div>
{msg && (
<div className={`text-xs rounded px-2 py-1 border ${msg.ok?'bg-green-950 border-green-800 text-green-400':'bg-red-950 border-red-800 text-red-400'}`}>{msg.text}</div>
)}
<div className="bg-gray-950 border border-gray-800 rounded-lg p-3 text-xs text-gray-600">
Settings stored in localStorage key <code className="text-gray-500">saltybot_settings_v1</code>. Export to persist across browsers.
</div>
</div>
);
}
export function SettingsPanel({ subscribe, callService, connected = false, wsUrl = '' }) {
const [view, setView] = useState('PID');
const settings = useSettings({ callService, subscribe });
return (
<div className="space-y-4">
<div className="flex gap-0.5 border-b border-gray-800 overflow-x-auto">
{VIEWS.map(v => (
<button key={v} onClick={() => setView(v)}
className={`px-3 py-2 text-xs font-bold tracking-wider whitespace-nowrap border-b-2 transition-colors ${
view===v ? 'border-cyan-500 text-cyan-300' : 'border-transparent text-gray-600 hover:text-gray-300'
}`}>{v.toUpperCase()}</button>
))}
</div>
{view==='PID' && <PIDView gains={settings.gains} setGains={settings.setGains} applyPIDGains={settings.applyPIDGains} applying={settings.applying} applyResult={settings.applyResult} connected={connected} />}
{view==='Sensors' && <SensorsView sensors={settings.sensors} setSensors={settings.setSensors} applySensorParams={settings.applySensorParams} applying={settings.applying} applyResult={settings.applyResult} connected={connected} />}
{view==='Network' && <NetworkView wsUrl={wsUrl} connected={connected} />}
{view==='Firmware' && <FirmwareView firmwareInfo={settings.firmwareInfo} startFirmwareWatch={settings.startFirmwareWatch} connected={connected} />}
{view==='Diagnostics' && <DiagnosticsView exportDiagnosticsBundle={settings.exportDiagnosticsBundle} subscribe={subscribe} connected={connected} />}
{view==='Backup' && <BackupView exportSettingsJSON={settings.exportSettingsJSON} importSettingsJSON={settings.importSettingsJSON} />}
</div>
);
}