/** * 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 (
| handleSort('name')} > Node Name {sortKey === 'name' && (sortAscending ? '▲' : '▼')} | handleSort('status')} > Status {sortKey === 'status' && (sortAscending ? '▲' : '▼')} | handleSort('uptime')} > Uptime (s) {sortKey === 'uptime' && (sortAscending ? '▲' : '▼')} | Last Seen |
|---|---|---|---|
| {node.name} |
{node.status.toUpperCase()}
|
{node.uptime} | {node.lastSeen} |