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
+53 -8
View File
@@ -1873,32 +1873,66 @@ router.post('/:id/tools/capture', requireWrite, async (req: Request, res: Respon
}); });
// POST /api/devices/:id/tools/btest — bandwidth test via RouterOS // POST /api/devices/:id/tools/btest — bandwidth test via RouterOS
// If target_device_id is provided: automatically enable/disable btest server on the target device.
// If only address is provided: manual mode — user must ensure the btest server is already running.
router.post('/:id/tools/btest', requireWrite, async (req: Request, res: Response) => { router.post('/:id/tools/btest', requireWrite, async (req: Request, res: Response) => {
const { address, direction = 'both', duration = 5, protocol = 'tcp' } = req.body as { const { target_device_id, address, direction = 'both', duration = 5, protocol = 'tcp' } = req.body as {
address?: string; direction?: string; duration?: number; protocol?: string; target_device_id?: number; address?: string; direction?: string; duration?: number; protocol?: string;
}; };
if (!address) return res.status(400).json({ error: 'address is required' }); if (!target_device_id && !address) {
return res.status(400).json({ error: 'target_device_id or address is required' });
}
const testSec = Math.min(Math.max(1, Number(duration) || 5), 30); const testSec = Math.min(Math.max(1, Number(duration) || 5), 30);
const device = await getToolDevice(req.params.id); const device = await getToolDevice(req.params.id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
// Resolve target: either a managed device (auto server management) or a manual IP
let targetAddress = address ?? '';
let targetUser: string | undefined;
let targetPassword: string | undefined;
let serverClient: RouterOSClient | null = null;
if (target_device_id) {
const target = await getToolDevice(String(target_device_id));
if (!target) return res.status(404).json({ error: 'Target device not found' });
targetAddress = target.ip_address;
targetUser = target.api_username;
targetPassword = decrypt(target.api_password_encrypted);
serverClient = new RouterOSClient(
target.ip_address, target.api_port, target.api_username, targetPassword, 15_000, 20_000
);
}
const client = new RouterOSClient( const client = new RouterOSClient(
device.ip_address, device.api_port, device.api_username, device.ip_address, device.api_port, device.api_username,
decrypt(device.api_password_encrypted), decrypt(device.api_password_encrypted),
15_000, 15_000,
(testSec + 10) * 1000 (testSec + 15) * 1000
); );
try { try {
// Enable bandwidth-test server on target device before running test
if (serverClient) {
await serverClient.connect();
await serverClient.execute('/tool/bandwidth-server/set', { enabled: 'yes', authenticate: 'yes' });
serverClient.disconnect();
}
await client.connect(); await client.connect();
const rows = await client.executeStreaming('/tool/bandwidth-test', { const btestParams: Record<string, string> = {
address, address: targetAddress,
direction: ['receive', 'transmit', 'both'].includes(direction) ? direction : 'both', direction: ['receive', 'transmit', 'both'].includes(direction) ? direction : 'both',
duration: String(testSec), duration: String(testSec),
protocol: ['tcp', 'udp'].includes(protocol) ? protocol : 'tcp', protocol: ['tcp', 'udp'].includes(protocol) ? protocol : 'tcp',
}, (testSec + 10) * 1000); };
// When testing against a managed device, authenticate with its RouterOS credentials
if (targetUser) btestParams['user'] = targetUser;
if (targetPassword) btestParams['password'] = targetPassword;
const rows = await client.executeStreaming('/tool/bandwidth-test', btestParams, (testSec + 15) * 1000);
client.disconnect();
// Return the final summary row (last row that has tx/rx total averages)
const lastRow = rows.filter((r) => r['tx-total-average'] !== undefined || r['rx-total-average'] !== undefined).at(-1); const lastRow = rows.filter((r) => r['tx-total-average'] !== undefined || r['rx-total-average'] !== undefined).at(-1);
const toMbps = (v: string | undefined) => v ? Math.round(parseInt(v) / 1_000_000) : 0; const toMbps = (v: string | undefined) => v ? Math.round(parseInt(v) / 1_000_000) : 0;
@@ -1908,12 +1942,23 @@ router.post('/:id/tools/btest', requireWrite, async (req: Request, res: Response
direction, direction,
protocol, protocol,
duration: testSec, duration: testSec,
target_ip: targetAddress,
raw: lastRow ?? rows.at(-1) ?? null, raw: lastRow ?? rows.at(-1) ?? null,
}); });
} catch (err) { } catch (err) {
return res.status(500).json({ error: (err as Error).message }); return res.status(500).json({ error: (err as Error).message });
} finally { } finally {
client.disconnect(); client.disconnect();
// Always disable btest server on target when done (success or failure)
if (serverClient) {
try {
await serverClient.connect();
await serverClient.execute('/tool/bandwidth-server/set', { enabled: 'no' });
serverClient.disconnect();
} catch {
// Best-effort — don't mask the original error
}
}
} }
}); });
@@ -473,19 +473,35 @@ function PacketCaptureTool({ deviceId, interfaces }: { deviceId: number; interfa
// ─── Bandwidth Test ─────────────────────────────────────────────────────────── // ─── Bandwidth Test ───────────────────────────────────────────────────────────
function BandwidthTestTool({ deviceId }: { deviceId: number }) { 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 [direction, setDirection] = useState('both');
const [duration, setDuration] = useState('5'); const [duration, setDuration] = useState('5');
const [protocol, setProtocol] = useState('tcp'); const [protocol, setProtocol] = useState('tcp');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); 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 () => { const run = async () => {
if (!address) return; if (!canRun) return;
setLoading(true); setError(''); setResult(null); setLoading(true); setError(''); setResult(null);
try { 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); setResult(data);
} catch (e: unknown) { } catch (e: unknown) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error; const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
@@ -498,17 +514,49 @@ function BandwidthTestTool({ deviceId }: { deviceId: number }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-gray-500 dark:text-slate-400"> <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. Measures throughput between this device (client) and a target (server). When targeting a managed
The target must have the bandwidth-test server enabled. device, the bandwidth-test server is automatically enabled and disabled for you.
</p> </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"> <div className="flex flex-wrap gap-3">
<input {mode === 'managed' ? (
className="input flex-1 min-w-48" <select className="input flex-1 min-w-48" value={targetDeviceId} onChange={(e) => setTargetDeviceId(e.target.value)}>
placeholder="Target IP address" <option value="">— Select target device —</option>
value={address} {targetDevices.map((d: { id: number; name: string; ip_address: string; status?: string }) => (
onChange={(e) => setAddress(e.target.value)} <option key={d.id} value={d.id}>
onKeyDown={(e) => e.key === 'Enter' && run()} {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)}> <select className="input w-32" value={direction} onChange={(e) => setDirection(e.target.value)}>
<option value="both">Both</option> <option value="both">Both</option>
<option value="receive">Receive</option> <option value="receive">Receive</option>
@@ -523,6 +571,13 @@ function BandwidthTestTool({ deviceId }: { deviceId: number }) {
</select> </select>
<RunButton loading={loading} onClick={run} label="Test" /> <RunButton loading={loading} onClick={run} label="Test" />
</div> </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 && ( {loading && (
<p className="text-sm text-gray-500 dark:text-slate-400"> <p className="text-sm text-gray-500 dark:text-slate-400">
Running {duration}s bandwidth test — please wait… Running {duration}s bandwidth test — please wait…
@@ -534,13 +589,13 @@ function BandwidthTestTool({ deviceId }: { deviceId: number }) {
{(result.direction === 'both' || result.direction === 'transmit') && ( {(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="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-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> </div>
)} )}
{(result.direction === 'both' || result.direction === 'receive') && ( {(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="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-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>
)} )}
<div className="col-span-2 text-xs text-gray-500 dark:text-slate-400 text-center"> <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 }), 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 }) => capture: (id: number, body: { interface?: string; filter_ip?: string; duration?: number }) =>
api.post(`/devices/${id}/tools/capture`, body, { timeout: 120_000, responseType: 'blob' }), api.post(`/devices/${id}/tools/capture`, body, { timeout: 120_000, responseType: 'blob' }),
btest: (id: number, body: { address: string; direction?: string; duration?: number; protocol?: string }) => 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; raw: Record<string, string> | null }>( 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: 60_000 } `/devices/${id}/tools/btest`, body, { timeout: 75_000 }
), ),
}; };