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
- 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>
453 lines
23 KiB
JavaScript
453 lines
23 KiB
JavaScript
/**
|
||
* 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 0–232.</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://<host>:9090</code></div>
|
||
<div>RTSP: <code className="text-gray-400">rtsp://<host>:8554/panoramic</code></div>
|
||
<div>MJPEG: <code className="text-gray-400">http://<host>: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>
|
||
);
|
||
}
|