feat: Copy VLANs wizard + CPU display fix for hardware switches (v0.11.0 Beta)

Add 'Copy VLANs from Another Switch' — a 3-step modal available on the
VLANs tab of any switch device. Step 1 selects a source switch and which
VLAN IDs to copy (VLAN 1 deselected by default with a management risk
warning). Step 2 lets the user assign interfaces on the target device
per VLAN: clicking a port chip cycles it through None → Tagged → Untagged
→ None, with a live summary and bridge picker per VLAN. Conflict detection
identifies VLANs already present on the target and offers per-VLAN
Skip/Overwrite with the port grid disabling itself when Skip is active.
Step 3 shows the full plan before a single apply call executes all
operations over one device connection and reports per-VLAN results.

Also fix CPU load display on the Devices page for hardware switches that
report 0% CPU via ASIC offloading — the previous filter (v > 0) silently
dropped all valid zero readings; now filters on field presence instead.
This commit is contained in:
2GT-Rich
2026-05-15 14:43:53 -07:00
parent 17e989da99
commit d3aa72c488
10 changed files with 1003 additions and 7 deletions
+49
View File
@@ -1364,6 +1364,55 @@ router.delete('/:id/vlans/:vlanDbId', requireWrite, async (req: Request, res: Re
}
});
// POST /api/devices/:id/vlans/copy (bulk copy VLANs from another switch)
router.post('/:id/vlans/copy', requireWrite, async (req: Request, res: Response) => {
const deviceRow = await queryOne<any>(
`SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`,
[req.params.id]
);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
const { operations } = req.body as {
operations: Array<{
action: 'add' | 'update';
vlan_id: number;
bridge: string;
tagged_ports: string[];
untagged_ports: string[];
}>;
};
if (!Array.isArray(operations) || operations.length === 0) {
return res.status(400).json({ error: 'operations array is required and must not be empty' });
}
const collector = new DeviceCollector(deviceRow);
const results: Array<{ vlan_id: number; action: string; success: boolean; error?: string }> = [];
try {
await collector.connect();
for (const op of operations) {
try {
if (op.action === 'add') {
await collector.addBridgeVlan(op.bridge, op.vlan_id, op.tagged_ports, op.untagged_ports);
} else {
await collector.updateBridgeVlan(op.bridge, op.vlan_id, op.tagged_ports, op.untagged_ports);
}
results.push({ vlan_id: op.vlan_id, action: op.action, success: true });
} catch (err) {
results.push({ vlan_id: op.vlan_id, action: op.action, success: false, error: err instanceof Error ? err.message : String(err) });
}
}
await collector.collectVlans();
const vlans = await query(`SELECT * FROM vlans WHERE device_id = $1 ORDER BY vlan_id ASC`, [req.params.id]);
return res.json({ results, vlans });
} finally {
collector.disconnect();
}
});
// ─── Bond (LAG / LACP) routes ────────────────────────────────────────────────
// POST /api/devices/:id/bonds