feat: bandwidth test auto-manages server on target managed device

Backend: accept target_device_id — connect to target, enable btest
server, run test from source using target credentials, disable server
in finally (even on failure). Manual IP mode preserved as fallback.

Frontend: device picker dropdown (all managed devices except self) as
default mode; toggle to Manual IP for non-managed targets with a warning
that the server must be pre-enabled. Results show target IP in labels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-05-16 16:42:24 -07:00
co-authored by Claude Sonnet 4.6
parent a1788238f8
commit 8d24b0b998
3 changed files with 126 additions and 26 deletions
@@ -473,19 +473,35 @@ function PacketCaptureTool({ deviceId, interfaces }: { deviceId: number; interfa
// ─── Bandwidth Test ───────────────────────────────────────────────────────────
function BandwidthTestTool({ deviceId }: { deviceId: number }) {
const [address, setAddress] = useState('');
// 'managed' = pick a device from inventory; 'manual' = type an IP (server must be pre-enabled)
const [mode, setMode] = useState<'managed' | 'manual'>('managed');
const [targetDeviceId, setTargetDeviceId] = useState('');
const [manualAddress, setManualAddress] = useState('');
const [direction, setDirection] = useState('both');
const [duration, setDuration] = useState('5');
const [protocol, setProtocol] = useState('tcp');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<{ tx_mbps: number; rx_mbps: number; direction: string; protocol: string; duration: number } | null>(null);
const [result, setResult] = useState<{ tx_mbps: number; rx_mbps: number; direction: string; protocol: string; duration: number; target_ip: string } | null>(null);
const { data: allDevices = [] } = useQuery({
queryKey: ['devices'],
queryFn: () => devicesApi.list().then((r) => r.data),
});
// Exclude the current device from the target list
const targetDevices = allDevices.filter((d: { id: number; status?: string }) => d.id !== deviceId);
const canRun = mode === 'managed' ? !!targetDeviceId : !!manualAddress.trim();
const run = async () => {
if (!address) return;
if (!canRun) return;
setLoading(true); setError(''); setResult(null);
try {
const { data } = await deviceToolsApi.btest(deviceId, { address, direction, duration: parseInt(duration), protocol });
const body = mode === 'managed'
? { target_device_id: parseInt(targetDeviceId), direction, duration: parseInt(duration), protocol }
: { address: manualAddress.trim(), direction, duration: parseInt(duration), protocol };
const { data } = await deviceToolsApi.btest(deviceId, body);
setResult(data);
} catch (e: unknown) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
@@ -498,17 +514,49 @@ function BandwidthTestTool({ deviceId }: { deviceId: number }) {
return (
<div className="space-y-4">
<p className="text-xs text-gray-500 dark:text-slate-400">
Runs RouterOS /tool/bandwidth-test to measure throughput between this device and a target IP.
The target must have the bandwidth-test server enabled.
Measures throughput between this device (client) and a target (server). When targeting a managed
device, the bandwidth-test server is automatically enabled and disabled for you.
</p>
{/* Mode toggle */}
<div className="flex gap-2">
<button
onClick={() => setMode('managed')}
className={clsx('px-3 py-1 rounded text-xs font-medium border transition-colors', mode === 'managed'
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 dark:border-slate-600 text-gray-600 dark:text-slate-300 hover:border-blue-400')}
>
Managed device
</button>
<button
onClick={() => setMode('manual')}
className={clsx('px-3 py-1 rounded text-xs font-medium border transition-colors', mode === 'manual'
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 dark:border-slate-600 text-gray-600 dark:text-slate-300 hover:border-blue-400')}
>
Manual IP
</button>
</div>
<div className="flex flex-wrap gap-3">
<input
className="input flex-1 min-w-48"
placeholder="Target IP address"
value={address}
onChange={(e) => setAddress(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && run()}
/>
{mode === 'managed' ? (
<select className="input flex-1 min-w-48" value={targetDeviceId} onChange={(e) => setTargetDeviceId(e.target.value)}>
<option value="">— Select target device —</option>
{targetDevices.map((d: { id: number; name: string; ip_address: string; status?: string }) => (
<option key={d.id} value={d.id}>
{d.name} ({d.ip_address}){d.status === 'offline' ? ' — offline' : ''}
</option>
))}
</select>
) : (
<input
className="input flex-1 min-w-48"
placeholder="Target IP address"
value={manualAddress}
onChange={(e) => setManualAddress(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && run()}
/>
)}
<select className="input w-32" value={direction} onChange={(e) => setDirection(e.target.value)}>
<option value="both">Both</option>
<option value="receive">Receive</option>
@@ -523,6 +571,13 @@ function BandwidthTestTool({ deviceId }: { deviceId: number }) {
</select>
<RunButton loading={loading} onClick={run} label="Test" />
</div>
{mode === 'manual' && (
<p className="text-xs text-amber-600 dark:text-amber-400">
Manual mode: ensure the bandwidth-test server is running on the target device before starting.
</p>
)}
{loading && (
<p className="text-sm text-gray-500 dark:text-slate-400">
Running {duration}s bandwidth test — please wait…
@@ -534,13 +589,13 @@ function BandwidthTestTool({ deviceId }: { deviceId: number }) {
{(result.direction === 'both' || result.direction === 'transmit') && (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg text-center">
<div className="text-2xl font-bold text-blue-700 dark:text-blue-300">{result.tx_mbps} Mbps</div>
<div className="text-xs text-blue-500 dark:text-blue-400 mt-1">TX (Upload)</div>
<div className="text-xs text-blue-500 dark:text-blue-400 mt-1">TX (Upload to {result.target_ip})</div>
</div>
)}
{(result.direction === 'both' || result.direction === 'receive') && (
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg text-center">
<div className="text-2xl font-bold text-green-700 dark:text-green-300">{result.rx_mbps} Mbps</div>
<div className="text-xs text-green-500 dark:text-green-400 mt-1">RX (Download)</div>
<div className="text-xs text-green-500 dark:text-green-400 mt-1">RX (Download from {result.target_ip})</div>
</div>
)}
<div className="col-span-2 text-xs text-gray-500 dark:text-slate-400 text-center">
+3 -3
View File
@@ -337,9 +337,9 @@ export const deviceToolsApi = {
api.post<{ success: boolean; message: string }>(`/devices/${id}/tools/wol`, body, { timeout: TOOL_TIMEOUT }),
capture: (id: number, body: { interface?: string; filter_ip?: string; duration?: number }) =>
api.post(`/devices/${id}/tools/capture`, body, { timeout: 120_000, responseType: 'blob' }),
btest: (id: number, body: { address: string; direction?: string; duration?: number; protocol?: string }) =>
api.post<{ tx_mbps: number; rx_mbps: number; direction: string; protocol: string; duration: number; raw: Record<string, string> | null }>(
`/devices/${id}/tools/btest`, body, { timeout: 60_000 }
btest: (id: number, body: { target_device_id?: number; address?: string; direction?: string; duration?: number; protocol?: string }) =>
api.post<{ tx_mbps: number; rx_mbps: number; direction: string; protocol: string; duration: number; target_ip: string; raw: Record<string, string> | null }>(
`/devices/${id}/tools/btest`, body, { timeout: 75_000 }
),
};