feat(webui): topic bandwidth monitor (#287) #295
@ -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' && <EventLog subscribe={subscribe} />}
|
||||
|
||||
{activeTab === 'bandwidth' && <BandwidthMonitor />}
|
||||
|
||||
{activeTab === 'logs' && <LogViewer subscribe={subscribe} />}
|
||||
|
||||
{activeTab === 'network' && <NetworkPanel subscribe={subscribe} connected={connected} wsUrl={wsUrl} />}
|
||||
|
||||
291
ui/social-bot/src/components/BandwidthMonitor.jsx
Normal file
291
ui/social-bot/src/components/BandwidthMonitor.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user