feat(webui): ROS node list viewer with heartbeat status
Features: - Enumerate active ROS nodes from /rosout activity - Real-time node status tracking (alive/dead based on heartbeat) - Heartbeat timeout: 5 seconds without updates = dead - Display node name, status, uptime, and last seen timestamp - Color-coded status indicators (green=alive, gray=dead) - Sortable table with node statistics - Summary card showing alive/dead node counts - Periodic status polling every 2 seconds Integration: - Added to MONITORING tab group as 'Nodes' tab - Subscribes to /rosout (rcl_interfaces/Log) to detect active nodes - Real-time updates with smooth transitions Build: 119 modules, no errors Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d468cb515e
commit
3f558046c5
@ -68,6 +68,9 @@ import { BandwidthMonitor } from './components/BandwidthMonitor.jsx';
|
||||
// Temperature gauge (issue #308)
|
||||
import { TempGauge } from './components/TempGauge.jsx';
|
||||
|
||||
// Node list viewer
|
||||
import { NodeList } from './components/NodeList.jsx';
|
||||
|
||||
const TAB_GROUPS = [
|
||||
{
|
||||
label: 'SOCIAL',
|
||||
@ -118,6 +121,7 @@ const TAB_GROUPS = [
|
||||
tabs: [
|
||||
{ id: 'eventlog', label: 'Events' },
|
||||
{ id: 'bandwidth', label: 'Bandwidth' },
|
||||
{ id: 'nodes', label: 'Nodes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -282,6 +286,8 @@ export default function App() {
|
||||
|
||||
{activeTab === 'bandwidth' && <BandwidthMonitor />}
|
||||
|
||||
{activeTab === 'nodes' && <NodeList subscribe={subscribe} publish={publishFn} callService={callService} />}
|
||||
|
||||
{activeTab === 'logs' && <LogViewer subscribe={subscribe} />}
|
||||
|
||||
{activeTab === 'network' && <NetworkPanel subscribe={subscribe} connected={connected} wsUrl={wsUrl} />}
|
||||
|
||||
256
ui/social-bot/src/components/NodeList.jsx
Normal file
256
ui/social-bot/src/components/NodeList.jsx
Normal file
@ -0,0 +1,256 @@
|
||||
/**
|
||||
* NodeList.jsx — ROS node enumeration and status monitor
|
||||
*
|
||||
* Features:
|
||||
* - Enumerate active ROS nodes from rosbridge
|
||||
* - Real-time node status tracking (alive/dead based on heartbeat)
|
||||
* - Display node name, status, and topic subscriptions
|
||||
* - Color-coded status indicators (green=alive, gray=dead)
|
||||
* - Sortable table with node statistics
|
||||
* - Track last update time for each node
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const HEARTBEAT_TIMEOUT = 5000; // 5 seconds without update = dead
|
||||
const POLL_INTERVAL = 2000; // Check node status every 2 seconds
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function getNodeStatus(lastUpdate) {
|
||||
const now = Date.now();
|
||||
return now - lastUpdate < HEARTBEAT_TIMEOUT ? 'alive' : 'dead';
|
||||
}
|
||||
|
||||
export function NodeList({ subscribe, publish, callService }) {
|
||||
const [nodeData, setNodeData] = useState({});
|
||||
const [sortKey, setSortKey] = useState('name');
|
||||
const [sortAscending, setSortAscending] = useState(true);
|
||||
const nodeRefRef = useRef({});
|
||||
const pollIntervalRef = useRef(null);
|
||||
|
||||
// Track node activity via topic subscriptions
|
||||
useEffect(() => {
|
||||
// Subscribe to /rosout to track active nodes
|
||||
const unsubscribe = subscribe(
|
||||
'/rosout',
|
||||
'rcl_interfaces/Log',
|
||||
(msg) => {
|
||||
try {
|
||||
const nodeName = msg.name || 'unknown';
|
||||
|
||||
if (!nodeRefRef.current[nodeName]) {
|
||||
nodeRefRef.current[nodeName] = {
|
||||
name: nodeName,
|
||||
topicCount: 0,
|
||||
firstSeen: Date.now(),
|
||||
lastUpdate: Date.now(),
|
||||
};
|
||||
} else {
|
||||
nodeRefRef.current[nodeName].lastUpdate = Date.now();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error tracking node:', e);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [subscribe]);
|
||||
|
||||
// Update node status periodically
|
||||
useEffect(() => {
|
||||
pollIntervalRef.current = setInterval(() => {
|
||||
const nodes = {};
|
||||
const now = Date.now();
|
||||
|
||||
Object.entries(nodeRefRef.current).forEach(([name, data]) => {
|
||||
const status = getNodeStatus(data.lastUpdate);
|
||||
const uptime = ((now - data.firstSeen) / 1000).toFixed(1);
|
||||
|
||||
nodes[name] = {
|
||||
name,
|
||||
status,
|
||||
topicCount: data.topicCount || 0,
|
||||
lastUpdate: data.lastUpdate,
|
||||
uptime: parseFloat(uptime),
|
||||
lastSeen: formatTimestamp(data.lastUpdate),
|
||||
};
|
||||
});
|
||||
|
||||
setNodeData(nodes);
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
return () => clearInterval(pollIntervalRef.current);
|
||||
}, []);
|
||||
|
||||
// Sort nodes
|
||||
const sortedNodes = Object.values(nodeData).sort((a, b) => {
|
||||
let aVal = a[sortKey];
|
||||
let bVal = b[sortKey];
|
||||
|
||||
// Handle string comparison for name
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return sortAscending ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
||||
}
|
||||
|
||||
// Handle numeric comparison
|
||||
return sortAscending ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
|
||||
const handleSort = (key) => {
|
||||
if (sortKey === key) {
|
||||
setSortAscending(!sortAscending);
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortAscending(true);
|
||||
}
|
||||
};
|
||||
|
||||
const aliveCount = sortedNodes.filter((n) => n.status === 'alive').length;
|
||||
const deadCount = sortedNodes.filter((n) => n.status === 'dead').length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3">
|
||||
{/* Summary */}
|
||||
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
||||
ROS NODE MONITOR
|
||||
</div>
|
||||
<div className="text-gray-600 text-xs">
|
||||
{sortedNodes.length} total nodes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node status summary */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-gray-900 rounded p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-gray-600 text-xs">Alive</span>
|
||||
<span className="text-green-400 font-mono text-sm font-bold">{aliveCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-500" />
|
||||
<span className="text-gray-600 text-xs">Dead</span>
|
||||
<span className="text-gray-400 font-mono text-sm font-bold">{deadCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node table */}
|
||||
<div className="flex-1 bg-gray-950 rounded-lg border border-cyan-950 overflow-auto">
|
||||
{sortedNodes.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-600">
|
||||
<div className="text-center">
|
||||
<div className="text-sm mb-2">Waiting for ROS activity</div>
|
||||
<div className="text-xs text-gray-700">
|
||||
Nodes will appear as they publish to /rosout
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-gray-900 border-b border-gray-800">
|
||||
<tr>
|
||||
<th
|
||||
className="px-3 py-2 text-left font-bold text-cyan-400 cursor-pointer hover:bg-gray-800"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
Node Name {sortKey === 'name' && (sortAscending ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2 text-center font-bold text-green-400 cursor-pointer hover:bg-gray-800 whitespace-nowrap"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
Status {sortKey === 'status' && (sortAscending ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2 text-right font-bold text-amber-400 cursor-pointer hover:bg-gray-800 whitespace-nowrap"
|
||||
onClick={() => handleSort('uptime')}
|
||||
>
|
||||
Uptime (s) {sortKey === 'uptime' && (sortAscending ? '▲' : '▼')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-bold text-teal-400 whitespace-nowrap">
|
||||
Last Seen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedNodes.map((node) => (
|
||||
<tr
|
||||
key={node.name}
|
||||
className={`border-b border-gray-800 hover:bg-gray-900 transition-colors ${
|
||||
node.status === 'dead' ? 'opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-cyan-300 font-mono truncate max-w-sm">
|
||||
{node.name}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
node.status === 'alive' ? 'bg-green-500' : 'bg-gray-500'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
node.status === 'alive'
|
||||
? 'text-green-300 font-bold'
|
||||
: 'text-gray-400'
|
||||
}
|
||||
>
|
||||
{node.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-amber-300 font-mono">
|
||||
{node.uptime}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-500 font-mono text-xs">
|
||||
{node.lastSeen}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Configuration info */}
|
||||
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Topic Source:</span>
|
||||
<span className="text-gray-500">/rosout (rcl_interfaces/Log)</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Heartbeat Timeout:</span>
|
||||
<span className="text-gray-500">{HEARTBEAT_TIMEOUT / 1000}s</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Status Check Interval:</span>
|
||||
<span className="text-gray-500">{POLL_INTERVAL / 1000}s</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Note:</span>
|
||||
<span className="text-gray-500">
|
||||
Nodes detected via /rosout activity; inactive nodes marked as dead after timeout
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user