feat(webui): topic bandwidth monitor (#287) #295
@ -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} />}
|
||||||
|
|||||||
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