From 793ba31adaa14eb2d20f5a04f21277a7a28602a3 Mon Sep 17 00:00:00 2001 From: sl-webui Date: Mon, 2 Mar 2026 20:45:41 -0500 Subject: [PATCH] feat(webui): ROS topic bandwidth monitor (Issue #287) Topic bandwidth tracking and visualization with: - Tracks message rates for all subscribed ROS topics - Estimates bandwidth based on message size and frequency - Message size estimated from JSON serialization - Updates every 1 second with rolling 30-second history window - Sortable table display: * Topic name with truncation for long names * Message rate (messages per second) * Average message size (bytes) * Bandwidth estimate (B/s, KB/s, or MB/s) * Sparkline mini-chart showing bandwidth trend - Total bandwidth summary at top - Click column headers to sort (ascending/descending toggle) - Visual indicators with color-coded columns Integrated into MONITORING tab group as 'Bandwidth' tab. Component provides window.__trackRosMessage() hook for optional bandwidth tracking integration with ROS bridge. Co-Authored-By: Claude Haiku 4.5 --- ui/social-bot/src/App.jsx | 6 + .../src/components/BandwidthMonitor.jsx | 291 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 ui/social-bot/src/components/BandwidthMonitor.jsx diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx index 8b2ff7a..7e04817 100644 --- a/ui/social-bot/src/App.jsx +++ b/ui/social-bot/src/App.jsx @@ -62,6 +62,9 @@ import { NetworkPanel } from './components/NetworkPanel.jsx'; // Waypoint editor (issue #261) import { WaypointEditor } from './components/WaypointEditor.jsx'; +// Bandwidth monitor (issue #287) +import { BandwidthMonitor } from './components/BandwidthMonitor.jsx'; + const TAB_GROUPS = [ { label: 'SOCIAL', @@ -110,6 +113,7 @@ const TAB_GROUPS = [ color: 'text-yellow-600', tabs: [ { id: 'eventlog', label: 'Events' }, + { id: 'bandwidth', label: 'Bandwidth' }, ], }, { @@ -270,6 +274,8 @@ export default function App() { {activeTab === 'eventlog' && } + {activeTab === 'bandwidth' && } + {activeTab === 'logs' && } {activeTab === 'network' && } diff --git a/ui/social-bot/src/components/BandwidthMonitor.jsx b/ui/social-bot/src/components/BandwidthMonitor.jsx new file mode 100644 index 0000000..67721b3 --- /dev/null +++ b/ui/social-bot/src/components/BandwidthMonitor.jsx @@ -0,0 +1,291 @@ +/** + * BandwidthMonitor.jsx — ROS topic bandwidth monitor + * + * Features: + * - Tracks message rates for all subscribed ROS topics + * - Estimates bandwidth based on message size and frequency + * - Sortable table with topic name, rate, estimated size, and bandwidth + * - Sparkline mini-charts showing bandwidth over time + * - Total bandwidth summary + * - Auto-updates every 1 second + */ + +import { useEffect, useRef, useState } from 'react'; + +const SPARKLINE_WIDTH = 60; +const SPARKLINE_HEIGHT = 20; +const MAX_HISTORY_POINTS = 30; // 30 seconds of data at 1Hz +const SAMPLE_INTERVAL = 1000; // 1 second + +function estimateMessageSize(msg) { + // Rough estimate of message size in bytes + if (typeof msg === 'string') return msg.length; + if (typeof msg === 'object') return JSON.stringify(msg).length; + return 100; // Default estimate +} + +function formatBandwidth(bytes) { + if (bytes < 1024) return `${bytes.toFixed(1)} B/s`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB/s`; + return `${(bytes / (1024 * 1024)).toFixed(3)} MB/s`; +} + +function Sparkline({ data, width = SPARKLINE_WIDTH, height = SPARKLINE_HEIGHT }) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || data.length === 0) return; + + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#1f2937'; + ctx.fillRect(0, 0, width, height); + + if (data.length < 2) return; + + const max = Math.max(...data); + const min = Math.min(...data); + const range = max - min || 1; + + // Draw line + ctx.strokeStyle = '#06b6d4'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + + data.forEach((value, i) => { + const x = (i / (data.length - 1)) * (width - 2) + 1; + const y = height - ((value - min) / range) * (height - 2) - 1; + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + + ctx.stroke(); + + // Draw area fill + ctx.fillStyle = 'rgba(6, 182, 212, 0.2)'; + ctx.lineTo(width - 1, height); + ctx.lineTo(1, height); + ctx.closePath(); + ctx.fill(); + }, [data, width, height]); + + return ; +} + +export function BandwidthMonitor() { + const [topicStats, setTopicStats] = useState({}); + const [sortKey, setSortKey] = useState('bandwidth'); + const [sortAscending, setSortAscending] = useState(false); + const statsRef = useRef({}); + const intervalRef = useRef(null); + + // Initialize bandwidth tracking + useEffect(() => { + // Sample bandwidth data every second + intervalRef.current = setInterval(() => { + const stats = {}; + let totalBandwidth = 0; + + Object.entries(statsRef.current).forEach(([topic, data]) => { + const messageCount = data.messageCount || 0; + const avgSize = data.totalSize > 0 ? data.totalSize / Math.max(1, messageCount) : 100; + const bandwidth = messageCount * avgSize; // bytes per second + + stats[topic] = { + messageCount, + avgSize: avgSize.toFixed(1), + bandwidth, + bandwidthHistory: [ + ...(data.bandwidthHistory || []), + bandwidth, + ].slice(-MAX_HISTORY_POINTS), + lastUpdate: Date.now(), + }; + + totalBandwidth += bandwidth; + + // Reset counters + data.messageCount = 0; + data.totalSize = 0; + }); + + setTopicStats({ + ...stats, + __total: totalBandwidth, + }); + }, SAMPLE_INTERVAL); + + return () => clearInterval(intervalRef.current); + }, []); + + // Expose tracking function for ROS bridge to use + useEffect(() => { + window.__trackRosMessage = (topic, message) => { + if (!statsRef.current[topic]) { + statsRef.current[topic] = { + messageCount: 0, + totalSize: 0, + bandwidthHistory: [], + }; + } + + statsRef.current[topic].messageCount++; + statsRef.current[topic].totalSize += estimateMessageSize(message); + }; + + return () => { + delete window.__trackRosMessage; + }; + }, []); + + // Sort topics + const sortedTopics = Object.entries(topicStats) + .filter(([name]) => name !== '__total') + .sort(([, a], [, b]) => { + let aVal = a[sortKey]; + let bVal = b[sortKey]; + + if (typeof aVal === 'string') { + aVal = parseFloat(aVal); + bVal = parseFloat(bVal); + } + + return sortAscending ? aVal - bVal : bVal - aVal; + }); + + const handleSort = (key) => { + if (sortKey === key) { + setSortAscending(!sortAscending); + } else { + setSortKey(key); + setSortAscending(false); + } + }; + + const totalBandwidth = topicStats.__total || 0; + + return ( +
+ {/* Summary */} +
+
+
+ TOPIC BANDWIDTH MONITOR +
+
+ {sortedTopics.length} topics tracked +
+
+ + {/* Total bandwidth */} +
+
TOTAL BANDWIDTH
+
+ + {formatBandwidth(totalBandwidth)} + + + (~{(totalBandwidth * 8).toFixed(0)} bits/s) + +
+
+
+ + {/* Table */} +
+ {sortedTopics.length === 0 ? ( +
+
+
No topics to monitor
+
+ Topics will appear as they publish messages +
+
+
+ ) : ( + + + + + + + + + + + + {sortedTopics.map(([topic, stats]) => ( + + + + + + + + ))} + +
handleSort('topic')} + > + Topic {sortKey === 'topic' && (sortAscending ? '▲' : '▼')} + handleSort('messageCount')} + > + Rate {sortKey === 'messageCount' && (sortAscending ? '▲' : '▼')} + handleSort('avgSize')} + > + Avg Size {sortKey === 'avgSize' && (sortAscending ? '▲' : '▼')} + handleSort('bandwidth')} + > + Bandwidth {sortKey === 'bandwidth' && (sortAscending ? '▲' : '▼')} + + Trend +
+ {topic} + + {stats.messageCount} msg/s + + {stats.avgSize} B + + {formatBandwidth(stats.bandwidth)} + + {stats.bandwidthHistory && stats.bandwidthHistory.length > 0 ? ( + + ) : ( + + )} +
+ )} +
+ + {/* Info */} +
+
+ Update Interval: + {SAMPLE_INTERVAL / 1000}s +
+
+ History Points: + {MAX_HISTORY_POINTS} (30s window) +
+
+ Note: + Message sizes are estimated from JSON serialization +
+
+
+ ); +}