/** * 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 (
{/* Summary */}
ROS NODE MONITOR
{sortedNodes.length} total nodes
{/* Node status summary */}
Alive {aliveCount}
Dead {deadCount}
{/* Node table */}
{sortedNodes.length === 0 ? (
Waiting for ROS activity
Nodes will appear as they publish to /rosout
) : ( {sortedNodes.map((node) => ( ))}
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}
)}
{/* Configuration info */}
Topic Source: /rosout (rcl_interfaces/Log)
Heartbeat Timeout: {HEARTBEAT_TIMEOUT / 1000}s
Status Check Interval: {POLL_INTERVAL / 1000}s
Note: Nodes detected via /rosout activity; inactive nodes marked as dead after timeout
); }