Merge pull request 'feat(webui): topic bandwidth monitor (#287)' (#295) from sl-webui/issue-287-bandwidth into main

This commit is contained in:
sl-jetson 2026-03-02 21:05:45 -05:00
commit accda32c7a
2 changed files with 297 additions and 0 deletions

View File

@ -62,6 +62,9 @@ import { NetworkPanel } from './components/NetworkPanel.jsx';
// Waypoint editor (issue #261) // Waypoint editor (issue #261)
import { WaypointEditor } from './components/WaypointEditor.jsx'; import { WaypointEditor } from './components/WaypointEditor.jsx';
// Bandwidth monitor (issue #287)
import { BandwidthMonitor } from './components/BandwidthMonitor.jsx';
const TAB_GROUPS = [ const TAB_GROUPS = [
{ {
label: 'SOCIAL', label: 'SOCIAL',
@ -110,6 +113,7 @@ const TAB_GROUPS = [
color: 'text-yellow-600', color: 'text-yellow-600',
tabs: [ tabs: [
{ id: 'eventlog', label: 'Events' }, { id: 'eventlog', label: 'Events' },
{ id: 'bandwidth', label: 'Bandwidth' },
], ],
}, },
{ {
@ -270,6 +274,8 @@ export default function App() {
{activeTab === 'eventlog' && <EventLog subscribe={subscribe} />} {activeTab === 'eventlog' && <EventLog subscribe={subscribe} />}
{activeTab === 'bandwidth' && <BandwidthMonitor />}
{activeTab === 'logs' && <LogViewer subscribe={subscribe} />} {activeTab === 'logs' && <LogViewer subscribe={subscribe} />}
{activeTab === 'network' && <NetworkPanel subscribe={subscribe} connected={connected} wsUrl={wsUrl} />} {activeTab === 'network' && <NetworkPanel subscribe={subscribe} connected={connected} wsUrl={wsUrl} />}

View File

@ -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 <canvas ref={canvasRef} width={width} height={height} className="inline-block" />;
}
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 (
<div className="flex flex-col h-full space-y-3">
{/* Summary */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-2">
<div className="flex justify-between items-center">
<div className="text-cyan-700 text-xs font-bold tracking-widest">
TOPIC BANDWIDTH MONITOR
</div>
<div className="text-gray-600 text-xs">
{sortedTopics.length} topics tracked
</div>
</div>
{/* Total bandwidth */}
<div className="bg-gray-900 rounded border border-cyan-900 p-2 space-y-1">
<div className="text-gray-600 text-xs">TOTAL BANDWIDTH</div>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-mono text-cyan-400">
{formatBandwidth(totalBandwidth)}
</span>
<span className="text-xs text-gray-700">
(~{(totalBandwidth * 8).toFixed(0)} bits/s)
</span>
</div>
</div>
</div>
{/* Table */}
<div className="flex-1 bg-gray-950 rounded-lg border border-cyan-950 overflow-auto">
{sortedTopics.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-600">
<div className="text-center">
<div className="text-sm mb-2">No topics to monitor</div>
<div className="text-xs text-gray-700">
Topics will appear as they publish messages
</div>
</div>
</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-gray-900 border-b border-gray-800">
<tr>
<th
className="px-3 py-2 text-left font-bold text-cyan-400 cursor-pointer hover:bg-gray-800"
onClick={() => handleSort('topic')}
>
Topic {sortKey === 'topic' && (sortAscending ? '▲' : '▼')}
</th>
<th
className="px-3 py-2 text-right font-bold text-amber-400 cursor-pointer hover:bg-gray-800 whitespace-nowrap"
onClick={() => handleSort('messageCount')}
>
Rate {sortKey === 'messageCount' && (sortAscending ? '▲' : '▼')}
</th>
<th
className="px-3 py-2 text-right font-bold text-green-400 cursor-pointer hover:bg-gray-800 whitespace-nowrap"
onClick={() => handleSort('avgSize')}
>
Avg Size {sortKey === 'avgSize' && (sortAscending ? '▲' : '▼')}
</th>
<th
className="px-3 py-2 text-right font-bold text-yellow-400 cursor-pointer hover:bg-gray-800 whitespace-nowrap"
onClick={() => handleSort('bandwidth')}
>
Bandwidth {sortKey === 'bandwidth' && (sortAscending ? '▲' : '▼')}
</th>
<th className="px-3 py-2 text-center font-bold text-teal-400 whitespace-nowrap">
Trend
</th>
</tr>
</thead>
<tbody>
{sortedTopics.map(([topic, stats]) => (
<tr
key={topic}
className="border-b border-gray-800 hover:bg-gray-900 transition-colors"
>
<td className="px-3 py-2 text-cyan-300 font-mono truncate max-w-xs">
{topic}
</td>
<td className="px-3 py-2 text-right text-amber-300 font-mono">
{stats.messageCount} msg/s
</td>
<td className="px-3 py-2 text-right text-green-300 font-mono">
{stats.avgSize} B
</td>
<td className="px-3 py-2 text-right text-yellow-300 font-mono">
{formatBandwidth(stats.bandwidth)}
</td>
<td className="px-3 py-2 text-center">
{stats.bandwidthHistory && stats.bandwidthHistory.length > 0 ? (
<Sparkline data={stats.bandwidthHistory} />
) : (
<span className="text-gray-600 text-xs"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Info */}
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
<div className="flex justify-between">
<span>Update Interval:</span>
<span className="text-gray-500">{SAMPLE_INTERVAL / 1000}s</span>
</div>
<div className="flex justify-between">
<span>History Points:</span>
<span className="text-gray-500">{MAX_HISTORY_POINTS} (30s window)</span>
</div>
<div className="flex justify-between">
<span>Note:</span>
<span className="text-gray-500">Message sizes are estimated from JSON serialization</span>
</div>
</div>
</div>
);
}