sl-webui 201dea4c01 feat(webui): robot status dashboard header bar (Issue #269)
Persistent top bar showing real-time robot health indicators:
- Battery percentage and voltage (color-coded: green >60%, amber 30-60%, red <30%)
- WiFi signal strength (RSSI dBm with quality assessment)
- Motor status and current draw in Amperes
- Emergency state indicator (red highlight when active)
- System uptime in hours and minutes
- Current operational mode (idle/nav/social/docking)
- Connection status indicator

Component subscribes to relevant ROS topics and displays in compact
flex layout matching dashboard dark theme.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-02 14:19:03 -05:00

261 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 };