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
+
+
+
+ ) : (
+
+
+
+ | 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
+ |
+
+
+
+ {sortedTopics.map(([topic, stats]) => (
+
+ |
+ {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
+
+
+
+ );
+}