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
@@ -1,17 +1,23 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, AlertCircle, RefreshCw, Pencil, Check, X, Network } from 'lucide-react';
import { Plus, Trash2, AlertCircle, RefreshCw, Pencil, Check, X, Network, Copy } from 'lucide-react';
import { devicesApi } from '../../services/api';
import type { Vlan, SwitchPort } from '../../types';
import clsx from 'clsx';
import { useCanWrite } from '../../hooks/useCanWrite';
import CopyVlanModal from './CopyVlanModal';
interface PortForm {
tagged_ports: string;
untagged_ports: string;
}
export default function VlansTab({ deviceId, onGoToPorts }: { deviceId: number; onGoToPorts?: (bridgeName: string) => void }) {
export default function VlansTab({ deviceId, deviceName, deviceType, onGoToPorts }: {
deviceId: number;
deviceName?: string;
deviceType?: string;
onGoToPorts?: (bridgeName: string) => void;
}) {
const queryClient = useQueryClient();
const canWrite = useCanWrite();
@@ -32,6 +38,9 @@ export default function VlansTab({ deviceId, onGoToPorts }: { deviceId: number;
(p) => p.type === 'bridge' && p.config_json?.['vlan-filtering'] !== 'yes' && p.config_json?.['vlan-filtering'] !== 'true'
);
// ── Copy VLAN modal ──
const [showCopyModal, setShowCopyModal] = useState(false);
// ── Add form ──
const [showAdd, setShowAdd] = useState(false);
const [addForm, setAddForm] = useState({ vlan_id: '', bridge: '', tagged_ports: '', untagged_ports: '' });
@@ -158,6 +167,16 @@ export default function VlansTab({ deviceId, onGoToPorts }: { deviceId: number;
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
Refresh
</button>
{canWrite && deviceType === 'switch' && (
<button
onClick={() => setShowCopyModal(true)}
className="btn-secondary flex items-center gap-2 text-sm"
title="Copy VLANs from another switch"
>
<Copy className="w-4 h-4" />
Copy from Switch
</button>
)}
{canWrite && (
<button
onClick={() => {
@@ -312,6 +331,17 @@ export default function VlansTab({ deviceId, onGoToPorts }: { deviceId: number;
</div>
)}
{showCopyModal && (
<CopyVlanModal
deviceId={deviceId}
deviceName={deviceName ?? `Device ${deviceId}`}
onClose={() => {
setShowCopyModal(false);
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
}}
/>
)}
{/* VLANs table */}
<div className="card overflow-hidden">
{(vlans as Vlan[]).length === 0 ? (