diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx index e6aafdb..650772a 100644 --- a/ui/social-bot/src/App.jsx +++ b/ui/social-bot/src/App.jsx @@ -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' && } + {activeTab === 'nodes' && } + {activeTab === 'logs' && } {activeTab === 'network' && } diff --git a/ui/social-bot/src/components/NodeList.jsx b/ui/social-bot/src/components/NodeList.jsx new file mode 100644 index 0000000..71b4920 --- /dev/null +++ b/ui/social-bot/src/components/NodeList.jsx @@ -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 ( +
+ {/* 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 + +
+
+
+ ); +}