Merge pull request 'feat(webui): robot status dashboard header (#269)' (#273) from sl-webui/issue-269-status-header into main
This commit is contained in:
commit
0776003dd3
@ -61,6 +61,9 @@ import { NetworkPanel } from './components/NetworkPanel.jsx';
|
|||||||
// Waypoint editor (issue #261)
|
// Waypoint editor (issue #261)
|
||||||
import { WaypointEditor } from './components/WaypointEditor.jsx';
|
import { WaypointEditor } from './components/WaypointEditor.jsx';
|
||||||
|
|
||||||
|
// Status header (issue #269)
|
||||||
|
import { StatusHeader } from './components/StatusHeader.jsx';
|
||||||
|
|
||||||
const TAB_GROUPS = [
|
const TAB_GROUPS = [
|
||||||
{
|
{
|
||||||
label: 'SOCIAL',
|
label: 'SOCIAL',
|
||||||
@ -197,6 +200,9 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* ── Status Header ── */}
|
||||||
|
<StatusHeader subscribe={subscribe} />
|
||||||
|
|
||||||
{/* ── Tab Navigation ── */}
|
{/* ── Tab Navigation ── */}
|
||||||
<nav className="bg-[#070712] border-b border-cyan-950 shrink-0 overflow-x-auto">
|
<nav className="bg-[#070712] border-b border-cyan-950 shrink-0 overflow-x-auto">
|
||||||
<div className="flex min-w-max">
|
<div className="flex min-w-max">
|
||||||
|
|||||||
260
ui/social-bot/src/components/StatusHeader.jsx
Normal file
260
ui/social-bot/src/components/StatusHeader.jsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* StatusHeader.jsx — Persistent status bar with robot health indicators
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Battery percentage and status indicator
|
||||||
|
* - WiFi signal strength (RSSI)
|
||||||
|
* - Motor status (running/stopped/error)
|
||||||
|
* - Emergency state indicator (active/clear)
|
||||||
|
* - System uptime
|
||||||
|
* - Current operational mode (idle/navigation/social/docking)
|
||||||
|
* - Real-time updates from ROS topics
|
||||||
|
* - Always visible at top of dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function StatusHeader({ subscribe }) {
|
||||||
|
const [batteryPercent, setBatteryPercent] = useState(null);
|
||||||
|
const [batteryVoltage, setBatteryVoltage] = useState(null);
|
||||||
|
const [wifiRssi, setWifiRssi] = useState(null);
|
||||||
|
const [wifiQuality, setWifiQuality] = useState('unknown');
|
||||||
|
const [motorStatus, setMotorStatus] = useState('idle');
|
||||||
|
const [motorCurrent, setMotorCurrent] = useState(null);
|
||||||
|
const [emergencyActive, setEmergencyActive] = useState(false);
|
||||||
|
const [uptime, setUptime] = useState(0);
|
||||||
|
const [currentMode, setCurrentMode] = useState('idle');
|
||||||
|
const [connected, setConnected] = useState(true);
|
||||||
|
|
||||||
|
// Battery subscriber
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubBattery = subscribe(
|
||||||
|
'/saltybot/battery',
|
||||||
|
'sensor_msgs/BatteryState',
|
||||||
|
(msg) => {
|
||||||
|
try {
|
||||||
|
setBatteryPercent(Math.round(msg.percentage * 100));
|
||||||
|
setBatteryVoltage(msg.voltage?.toFixed(1));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing battery data:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return unsubBattery;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
// WiFi RSSI subscriber
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubWifi = subscribe(
|
||||||
|
'/saltybot/wifi_rssi',
|
||||||
|
'std_msgs/Float32',
|
||||||
|
(msg) => {
|
||||||
|
try {
|
||||||
|
const rssi = Math.round(msg.data);
|
||||||
|
setWifiRssi(rssi);
|
||||||
|
|
||||||
|
if (rssi > -50) setWifiQuality('excellent');
|
||||||
|
else if (rssi > -60) setWifiQuality('good');
|
||||||
|
else if (rssi > -70) setWifiQuality('fair');
|
||||||
|
else if (rssi > -80) setWifiQuality('weak');
|
||||||
|
else setWifiQuality('poor');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing WiFi data:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return unsubWifi;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
// Motor status subscriber
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubMotor = subscribe(
|
||||||
|
'/saltybot/motor_status',
|
||||||
|
'std_msgs/String',
|
||||||
|
(msg) => {
|
||||||
|
try {
|
||||||
|
const status = msg.data?.toLowerCase() || 'unknown';
|
||||||
|
setMotorStatus(status);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing motor status:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return unsubMotor;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
// Motor current subscriber
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubCurrent = subscribe(
|
||||||
|
'/saltybot/motor_current',
|
||||||
|
'std_msgs/Float32',
|
||||||
|
(msg) => {
|
||||||
|
try {
|
||||||
|
setMotorCurrent(Math.round(msg.data * 100) / 100);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing motor current:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return unsubCurrent;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
// Emergency subscriber
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubEmergency = subscribe(
|
||||||
|
'/saltybot/emergency',
|
||||||
|
'std_msgs/Bool',
|
||||||
|
(msg) => {
|
||||||
|
try {
|
||||||
|
setEmergencyActive(msg.data === true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing emergency status:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return unsubEmergency;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
// Uptime tracking
|
||||||
|
useEffect(() => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
const hours = Math.floor(elapsed / 3600);
|
||||||
|
const minutes = Math.floor((elapsed % 3600) / 60);
|
||||||
|
setUptime(`${hours}h ${minutes}m`);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Current mode subscriber
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubMode = subscribe(
|
||||||
|
'/saltybot/current_mode',
|
||||||
|
'std_msgs/String',
|
||||||
|
(msg) => {
|
||||||
|
try {
|
||||||
|
const mode = msg.data?.toLowerCase() || 'idle';
|
||||||
|
setCurrentMode(mode);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing mode:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return unsubMode;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
// Connection status
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setConnected(batteryPercent !== null);
|
||||||
|
}, 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [batteryPercent]);
|
||||||
|
|
||||||
|
const getBatteryColor = () => {
|
||||||
|
if (batteryPercent === null) return 'text-gray-600';
|
||||||
|
if (batteryPercent > 60) return 'text-green-400';
|
||||||
|
if (batteryPercent > 30) return 'text-amber-400';
|
||||||
|
return 'text-red-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWifiColor = () => {
|
||||||
|
if (wifiRssi === null) return 'text-gray-600';
|
||||||
|
if (wifiQuality === 'excellent' || wifiQuality === 'good') return 'text-green-400';
|
||||||
|
if (wifiQuality === 'fair') return 'text-amber-400';
|
||||||
|
return 'text-red-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMotorColor = () => {
|
||||||
|
if (motorStatus === 'running') return 'text-green-400';
|
||||||
|
if (motorStatus === 'idle') return 'text-gray-500';
|
||||||
|
return 'text-red-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getModeColor = () => {
|
||||||
|
switch (currentMode) {
|
||||||
|
case 'navigation':
|
||||||
|
return 'text-cyan-400';
|
||||||
|
case 'social':
|
||||||
|
return 'text-purple-400';
|
||||||
|
case 'docking':
|
||||||
|
return 'text-blue-400';
|
||||||
|
default:
|
||||||
|
return 'text-gray-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-[#0a0a0f] border-b border-cyan-950/50 h-14 shrink-0 gap-4">
|
||||||
|
{/* Connection status */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-green-400' : 'bg-red-500'}`} />
|
||||||
|
<span className="text-xs text-gray-600">
|
||||||
|
{connected ? 'CONNECTED' : 'DISCONNECTED'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Battery */}
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-gray-900 border border-gray-800">
|
||||||
|
<span className={`text-xs font-bold ${getBatteryColor()}`}>🔋</span>
|
||||||
|
<span className={`text-xs font-mono ${getBatteryColor()}`}>
|
||||||
|
{batteryPercent !== null ? `${batteryPercent}%` : '—'}
|
||||||
|
</span>
|
||||||
|
{batteryVoltage && (
|
||||||
|
<span className="text-xs text-gray-600">{batteryVoltage}V</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WiFi */}
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-gray-900 border border-gray-800">
|
||||||
|
<span className={`text-xs font-bold ${getWifiColor()}`}>📡</span>
|
||||||
|
<span className={`text-xs font-mono ${getWifiColor()}`}>
|
||||||
|
{wifiRssi !== null ? `${wifiRssi}dBm` : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-600 capitalize">{wifiQuality}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Motors */}
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-gray-900 border border-gray-800">
|
||||||
|
<span className={`text-xs font-bold ${getMotorColor()}`}>⚙️</span>
|
||||||
|
<span className={`text-xs font-mono capitalize ${getMotorColor()}`}>
|
||||||
|
{motorStatus}
|
||||||
|
</span>
|
||||||
|
{motorCurrent !== null && (
|
||||||
|
<span className="text-xs text-gray-600">{motorCurrent}A</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emergency */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1.5 px-2 py-1 rounded border ${
|
||||||
|
emergencyActive
|
||||||
|
? 'bg-red-950 border-red-700'
|
||||||
|
: 'bg-gray-900 border-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={emergencyActive ? 'text-red-400 text-xs' : 'text-gray-600 text-xs'}>
|
||||||
|
{emergencyActive ? '🚨 EMERGENCY' : '✓ Safe'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uptime */}
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-gray-900 border border-gray-800">
|
||||||
|
<span className="text-xs text-gray-600">⏱️</span>
|
||||||
|
<span className="text-xs font-mono text-gray-500">{uptime}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Mode */}
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-gray-900 border border-gray-800">
|
||||||
|
<span className="text-xs text-gray-600">Mode:</span>
|
||||||
|
<span className={`text-xs font-bold capitalize ${getModeColor()}`}>
|
||||||
|
{currentMode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { StatusHeader };
|
||||||
Loading…
x
Reference in New Issue
Block a user