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}
+
+
+
+
+
+
+ {/* Node table */}
+
+ {sortedNodes.length === 0 ? (
+
+
+
Waiting for ROS activity
+
+ Nodes will appear as they publish to /rosout
+
+
+
+ ) : (
+
+
+
+ | handleSort('name')}
+ >
+ Node Name {sortKey === 'name' && (sortAscending ? '▲' : '▼')}
+ |
+ handleSort('status')}
+ >
+ Status {sortKey === 'status' && (sortAscending ? '▲' : '▼')}
+ |
+ handleSort('uptime')}
+ >
+ Uptime (s) {sortKey === 'uptime' && (sortAscending ? '▲' : '▼')}
+ |
+
+ Last Seen
+ |
+
+
+
+ {sortedNodes.map((node) => (
+
+ |
+ {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
+
+
+
+
+ );
+}