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
// 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) => {
const { address, direction = 'both', duration = 5, protocol = 'tcp' } = req.body as {
address?: string; direction?: string; duration?: number; protocol?: string;
const { target_device_id, address, direction = 'both', duration = 5, protocol = 'tcp' } = req.body as {
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 device = await getToolDevice(req.params.id);
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(
device.ip_address, device.api_port, device.api_username,
decrypt(device.api_password_encrypted),
15_000,
(testSec + 10) * 1000
(testSec + 15) * 1000
);
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();
const rows = await client.executeStreaming('/tool/bandwidth-test', {
address,
const btestParams: Record<string, string> = {
address: targetAddress,
direction: ['receive', 'transmit', 'both'].includes(direction) ? direction : 'both',
duration: String(testSec),
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 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,
protocol,
duration: testSec,
target_ip: targetAddress,
raw: lastRow ?? rows.at(-1) ?? null,
});
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
} finally {
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 ───────────────────────────────────────────────────────────
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 }
),
};