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
@@ -0,0 +1,911 @@
import { useState, useMemo, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, ChevronRight, Copy, AlertTriangle, Check, RefreshCw, Info } from 'lucide-react';
import { devicesApi } from '../../services/api';
import type { Device, Vlan, SwitchPort } from '../../types';
import clsx from 'clsx';
// ─── Types ────────────────────────────────────────────────────────────────────
type Step = 1 | 2 | 3;
type PortAssignment = 'none' | 'tagged' | 'untagged';
type ConflictChoice = 'skip' | 'overwrite';
type OpStatus = 'add' | 'identical' | 'conflict';
interface AnalyzedOp {
sourceVlan: Vlan;
status: OpStatus;
targetBridge: string;
bridgeExistsOnTarget: boolean;
effectiveTagged: string[];
effectiveUntagged: string[];
existingVlan?: Vlan;
conflictChoice: ConflictChoice;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function arraysEqualAsSet(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sa = new Set(a);
return b.every((x) => sa.has(x));
}
function naturalSort(a: string, b: string): number {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StepIndicator({ step }: { step: Step }) {
const labels = ['Select VLANs', 'Assign Ports', 'Confirm'];
return (
<div className="flex items-center gap-0 px-5 py-3 border-b border-gray-200 dark:border-slate-700 shrink-0">
{labels.map((label, i) => (
<div key={i} className="flex items-center">
<span className={clsx(
'flex items-center gap-1.5 text-xs font-medium',
step === i + 1 ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-slate-500'
)}>
<span className={clsx(
'w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0',
step > i + 1
? 'bg-blue-500 text-white'
: step === i + 1
? 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-400'
: 'bg-gray-100 text-gray-400 dark:bg-slate-700 dark:text-slate-500'
)}>
{step > i + 1 ? '✓' : i + 1}
</span>
{label}
</span>
{i < 2 && (
<ChevronRight className="w-3 h-3 mx-2 text-gray-300 dark:text-slate-600 shrink-0" />
)}
</div>
))}
</div>
);
}
function StatusBadge({ op }: { op: AnalyzedOp }) {
if (op.status === 'add') {
return (
<span className="px-2 py-0.5 rounded text-xs font-semibold bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
NEW
</span>
);
}
if (op.status === 'identical') {
return (
<span className="px-2 py-0.5 rounded text-xs font-semibold bg-gray-100 text-gray-500 dark:bg-slate-700 dark:text-slate-400">
IDENTICAL
</span>
);
}
return op.conflictChoice === 'overwrite' ? (
<span className="px-2 py-0.5 rounded text-xs font-semibold bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
OVERWRITE
</span>
) : (
<span className="px-2 py-0.5 rounded text-xs font-semibold bg-gray-100 text-gray-500 dark:bg-slate-700 dark:text-slate-400">
SKIP
</span>
);
}
// Interactive port chip — cycles None → Tagged → Untagged → None on click
function PortChip({
name,
assignment,
onClick,
}: {
name: string;
assignment: PortAssignment;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
title={
assignment === 'none'
? 'Click to set Tagged'
: assignment === 'tagged'
? 'Click to set Untagged'
: 'Click to remove'
}
className={clsx(
'inline-flex items-center gap-1 px-2 py-1 rounded font-mono text-xs transition-all select-none',
assignment === 'none' &&
'bg-gray-100 text-gray-400 dark:bg-slate-800 dark:text-slate-500 hover:bg-gray-200 dark:hover:bg-slate-700',
assignment === 'tagged' &&
'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 ring-1 ring-blue-400 dark:ring-blue-500',
assignment === 'untagged' &&
'bg-slate-200 text-slate-700 dark:bg-slate-600 dark:text-slate-200 ring-1 ring-slate-400 dark:ring-slate-500'
)}
>
{name}
{assignment === 'tagged' && (
<span className="text-[9px] font-bold leading-none bg-blue-500 text-white rounded px-0.5">T</span>
)}
{assignment === 'untagged' && (
<span className="text-[9px] font-bold leading-none bg-slate-500 text-white rounded px-0.5">U</span>
)}
</button>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function CopyVlanModal({
deviceId,
deviceName,
onClose,
}: {
deviceId: number;
deviceName: string;
onClose: () => void;
}) {
const queryClient = useQueryClient();
// ── Step state ──
const [step, setStep] = useState<Step>(1);
// ── Step 1 state ──
const [sourceDeviceId, setSourceDeviceId] = useState<number | null>(null);
const [selectedVlanIds, setSelectedVlanIds] = useState<Set<number>>(new Set());
// ── Step 2 user assignments ──
// portAssignments[vlan_id][portName] = 'tagged' | 'untagged' | 'none'
const [portAssignments, setPortAssignments] = useState<Record<number, Record<string, PortAssignment>>>({});
const [bridgeOverrides, setBridgeOverrides] = useState<Record<number, string>>({});
const [conflictChoices, setConflictChoices] = useState<Record<number, ConflictChoice>>({});
// ── Step 3 results ──
const [applyResults, setApplyResults] = useState<
{ vlan_id: number; action: string; success: boolean; error?: string }[] | null
>(null);
// ── Data fetching ──────────────────────────────────────────────────────────
const { data: allDevices = [] } = useQuery({
queryKey: ['devices'],
queryFn: () => devicesApi.list().then((r) => r.data),
});
const switchDevices = (allDevices as Device[]).filter(
(d) => d.device_type === 'switch' && d.id !== deviceId
);
const { data: sourceVlans = [], isLoading: loadingSource } = useQuery({
queryKey: ['vlans', sourceDeviceId],
queryFn: () => devicesApi.getVlans(sourceDeviceId!).then((r) => r.data),
enabled: sourceDeviceId != null,
});
const { data: targetVlans = [] } = useQuery({
queryKey: ['vlans', deviceId],
queryFn: () => devicesApi.getVlans(deviceId).then((r) => r.data),
});
const { data: targetPortsData } = useQuery({
queryKey: ['ports', deviceId],
queryFn: () => devicesApi.getPorts(deviceId).then((r) => r.data),
});
const targetPorts: SwitchPort[] = targetPortsData?.ports ?? [];
const targetBridgeNames = useMemo(
() => targetPorts.filter((p) => p.type === 'bridge').map((p) => p.name).sort(naturalSort),
[targetPorts]
);
// Ports available for assignment: everything except VLAN-type interfaces
const assignablePorts = useMemo(
() =>
targetPorts
.filter((p) => p.type !== 'vlan')
.map((p) => p.name)
.sort(naturalSort),
[targetPorts]
);
// ── Side-effects ───────────────────────────────────────────────────────────
useEffect(() => {
setSelectedVlanIds(new Set());
setBridgeOverrides({});
setConflictChoices({});
setPortAssignments({});
}, [sourceDeviceId]);
// Auto-select all except VLAN 1 when source VLANs load
useEffect(() => {
if ((sourceVlans as Vlan[]).length > 0) {
setSelectedVlanIds(
new Set((sourceVlans as Vlan[]).filter((v) => v.vlan_id !== 1).map((v) => v.vlan_id))
);
}
}, [sourceVlans]);
// ── Port assignment helpers ────────────────────────────────────────────────
const cyclePort = (vlanId: number, portName: string) => {
setPortAssignments((prev) => {
const current: PortAssignment = prev[vlanId]?.[portName] ?? 'none';
const next: PortAssignment =
current === 'none' ? 'tagged' : current === 'tagged' ? 'untagged' : 'none';
return { ...prev, [vlanId]: { ...(prev[vlanId] ?? {}), [portName]: next } };
});
};
const setPortDirect = (vlanId: number, portName: string, value: PortAssignment) => {
setPortAssignments((prev) => ({
...prev,
[vlanId]: { ...(prev[vlanId] ?? {}), [portName]: value },
}));
};
const getAssignment = (vlanId: number, portName: string): PortAssignment =>
portAssignments[vlanId]?.[portName] ?? 'none';
const getEffective = (vlanId: number) => {
const entries = Object.entries(portAssignments[vlanId] ?? {});
return {
tagged: entries.filter(([, v]) => v === 'tagged').map(([k]) => k).sort(naturalSort),
untagged: entries.filter(([, v]) => v === 'untagged').map(([k]) => k).sort(naturalSort),
};
};
// ── Analysis (memoized) ────────────────────────────────────────────────────
const analyzedOps = useMemo((): AnalyzedOp[] => {
return (sourceVlans as Vlan[])
.filter((sv) => selectedVlanIds.has(sv.vlan_id))
.map((sv) => {
const sourceBridge = sv.bridge ?? '';
const targetBridge =
bridgeOverrides[sv.vlan_id] ??
(targetBridgeNames.includes(sourceBridge)
? sourceBridge
: targetBridgeNames[0] ?? sourceBridge);
const bridgeExistsOnTarget = targetBridgeNames.includes(targetBridge);
const { tagged: effectiveTagged, untagged: effectiveUntagged } = getEffective(sv.vlan_id);
const existingVlan = (targetVlans as Vlan[]).find((v) => v.vlan_id === sv.vlan_id);
let status: OpStatus;
if (!existingVlan) {
status = 'add';
} else {
const isIdentical =
existingVlan.bridge === targetBridge &&
arraysEqualAsSet(existingVlan.tagged_ports ?? [], effectiveTagged) &&
arraysEqualAsSet(existingVlan.untagged_ports ?? [], effectiveUntagged);
status = isIdentical ? 'identical' : 'conflict';
}
return {
sourceVlan: sv,
status,
targetBridge,
bridgeExistsOnTarget,
effectiveTagged,
effectiveUntagged,
existingVlan,
conflictChoice: conflictChoices[sv.vlan_id] ?? 'skip',
};
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceVlans, selectedVlanIds, targetVlans, targetBridgeNames, bridgeOverrides, conflictChoices, portAssignments]);
const toApply = analyzedOps.filter(
(op) => op.status === 'add' || (op.status === 'conflict' && op.conflictChoice === 'overwrite')
);
const hasVlan1Selected = selectedVlanIds.has(1);
// ── Mutation ───────────────────────────────────────────────────────────────
const applyMutation = useMutation({
mutationFn: () =>
devicesApi.copyVlans(
deviceId,
toApply.map((op) => ({
action: op.status === 'add' ? 'add' : 'update',
vlan_id: op.sourceVlan.vlan_id,
bridge: op.targetBridge,
tagged_ports: op.effectiveTagged,
untagged_ports: op.effectiveUntagged,
}))
),
onSuccess: (res) => {
setApplyResults(res.data.results);
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
},
});
// ── Toggle helpers ─────────────────────────────────────────────────────────
const toggleVlan = (vlanId: number) =>
setSelectedVlanIds((prev) => {
const next = new Set(prev);
next.has(vlanId) ? next.delete(vlanId) : next.add(vlanId);
return next;
});
const selectAll = () =>
setSelectedVlanIds(new Set((sourceVlans as Vlan[]).map((v) => v.vlan_id)));
const selectNone = () => setSelectedVlanIds(new Set());
// ── Step 1 ─────────────────────────────────────────────────────────────────
const renderStep1 = () => (
<div className="space-y-4">
<div>
<label className="label">Source switch</label>
<select
className="input"
value={sourceDeviceId ?? ''}
onChange={(e) => setSourceDeviceId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">Select a switch…</option>
{switchDevices.map((d) => (
<option key={d.id} value={d.id}>
{d.name} ({d.ip_address}){d.status !== 'online' ? ' — offline' : ''}
</option>
))}
</select>
{switchDevices.length === 0 && (
<p className="text-xs text-gray-400 mt-1.5">
No other switches found. Add another switch device first.
</p>
)}
</div>
{sourceDeviceId != null && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-slate-300">
{loadingSource
? 'Loading VLANs…'
: `VLANs on ${switchDevices.find((d) => d.id === sourceDeviceId)?.name ?? 'source'}`}
</span>
{!loadingSource && (sourceVlans as Vlan[]).length > 0 && (
<div className="flex gap-2">
<button onClick={selectAll} className="text-xs text-blue-500 hover:text-blue-700 dark:hover:text-blue-300">
Select all
</button>
<span className="text-gray-300 dark:text-slate-600">·</span>
<button onClick={selectNone} className="text-xs text-blue-500 hover:text-blue-700 dark:hover:text-blue-300">
Deselect all
</button>
</div>
)}
</div>
{loadingSource && (
<div className="flex items-center gap-2 text-sm text-gray-400 py-4">
<RefreshCw className="w-4 h-4 animate-spin" /> Loading…
</div>
)}
{!loadingSource && (sourceVlans as Vlan[]).length === 0 && (
<p className="text-sm text-gray-400 py-3">No VLANs found on this device.</p>
)}
{!loadingSource && (sourceVlans as Vlan[]).length > 0 && (
<div className="rounded-lg border border-gray-200 dark:border-slate-700 divide-y divide-gray-100 dark:divide-slate-700 overflow-hidden">
{(sourceVlans as Vlan[]).map((vlan) => {
const isVlan1 = vlan.vlan_id === 1;
const checked = selectedVlanIds.has(vlan.vlan_id);
return (
<label
key={vlan.vlan_id}
className={clsx(
'flex items-start gap-3 px-3 py-2.5 cursor-pointer transition-colors select-none',
checked
? 'bg-blue-50 dark:bg-blue-900/10'
: 'hover:bg-gray-50 dark:hover:bg-slate-700/30'
)}
>
<input
type="checkbox"
className="mt-0.5 shrink-0"
checked={checked}
onChange={() => toggleVlan(vlan.vlan_id)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-bold text-sm text-blue-600 dark:text-blue-400">
VLAN {vlan.vlan_id}
</span>
{vlan.name && (
<span className="text-sm text-gray-700 dark:text-slate-300">{vlan.name}</span>
)}
{vlan.bridge && (
<span className="text-xs text-gray-400 font-mono">{vlan.bridge}</span>
)}
{isVlan1 && (
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3 h-3" /> management risk
</span>
)}
</div>
<p className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">
Port assignments will be configured in the next step
</p>
</div>
</label>
);
})}
</div>
)}
{hasVlan1Selected && (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 px-3 py-2.5 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>
<strong>VLAN 1 is selected.</strong> This is often the default management VLAN.
Take care when assigning ports to avoid losing device reachability.
</span>
</div>
)}
</div>
)}
</div>
);
// ── Step 2 ─────────────────────────────────────────────────────────────────
const renderStep2 = () => (
<div className="space-y-4">
<div className="flex items-start gap-2 text-sm text-gray-500 dark:text-slate-400">
<Info className="w-4 h-4 shrink-0 mt-0.5 text-blue-400" />
<span>
For each VLAN, choose which interfaces on <strong>{deviceName}</strong> should participate and how.
Click a port to cycle: <span className="font-medium text-gray-600 dark:text-slate-300">None</span>
{' → '}
<span className="font-medium text-blue-600 dark:text-blue-400">Tagged</span>
{' → '}
<span className="font-medium text-slate-600 dark:text-slate-300">Untagged</span>
{' → '}
<span className="font-medium text-gray-400">None</span>.
You can also leave all ports unassigned and configure them later.
</span>
</div>
{assignablePorts.length === 0 && (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 px-3 py-2.5 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>
No interfaces detected on this device yet. Run a sync first so ports can be listed here.
You can still proceed and configure ports manually in the VLAN editor afterward.
</span>
</div>
)}
{analyzedOps.map((op) => {
const { tagged: effTagged, untagged: effUntagged } = getEffective(op.sourceVlan.vlan_id);
const isSkipped = op.status === 'identical' || (op.status === 'conflict' && op.conflictChoice === 'skip');
return (
<div
key={op.sourceVlan.vlan_id}
className={clsx(
'rounded-lg border p-4 space-y-3',
op.status === 'add' && 'border-green-200 dark:border-green-800/60',
op.status === 'conflict' && 'border-amber-200 dark:border-amber-700/50',
op.status === 'identical' && 'border-gray-200 dark:border-slate-700 opacity-60'
)}
>
{/* Header */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<span className="font-mono font-bold text-blue-600 dark:text-blue-400">
VLAN {op.sourceVlan.vlan_id}
</span>
{op.sourceVlan.name && (
<span className="text-sm text-gray-700 dark:text-slate-300">{op.sourceVlan.name}</span>
)}
</div>
<StatusBadge op={op} />
</div>
{op.status === 'identical' && (
<p className="text-xs text-gray-400 dark:text-slate-500">
Your port assignment matches what's already on this device — nothing to change.
</p>
)}
{/* Conflict notice + resolution */}
{op.status === 'conflict' && (
<div className="rounded-md border border-amber-200 dark:border-amber-700/40 bg-amber-50/50 dark:bg-amber-900/10 px-3 py-2.5 space-y-2">
<div className="text-xs text-amber-700 dark:text-amber-400">
<span className="font-medium">Already on this device</span>
{' — '}
tagged: {op.existingVlan?.tagged_ports?.join(', ') || '—'}
{' · '}
untagged: {op.existingVlan?.untagged_ports?.join(', ') || '—'}
{op.existingVlan?.bridge && ` · ${op.existingVlan.bridge}`}
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-gray-500 dark:text-slate-400">Resolution:</span>
<label className="flex items-center gap-1.5 cursor-pointer text-sm">
<input
type="radio"
name={`conflict-${op.sourceVlan.vlan_id}`}
checked={op.conflictChoice === 'skip'}
onChange={() =>
setConflictChoices((prev) => ({ ...prev, [op.sourceVlan.vlan_id]: 'skip' }))
}
/>
Skip
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-sm">
<input
type="radio"
name={`conflict-${op.sourceVlan.vlan_id}`}
checked={op.conflictChoice === 'overwrite'}
onChange={() =>
setConflictChoices((prev) => ({ ...prev, [op.sourceVlan.vlan_id]: 'overwrite' }))
}
/>
Overwrite with my assignments below
</label>
</div>
</div>
)}
{/* Bridge picker */}
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-500 dark:text-slate-400 text-xs w-12 shrink-0">Bridge</span>
{targetBridgeNames.length > 0 ? (
<select
className="input py-1 text-xs font-mono max-w-[180px]"
value={op.targetBridge}
disabled={isSkipped}
onChange={(e) =>
setBridgeOverrides((prev) => ({
...prev,
[op.sourceVlan.vlan_id]: e.target.value,
}))
}
>
{targetBridgeNames.map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
) : (
<input
className="input py-1 text-xs font-mono max-w-[180px]"
value={op.targetBridge}
placeholder="bridge1"
disabled={isSkipped}
onChange={(e) =>
setBridgeOverrides((prev) => ({
...prev,
[op.sourceVlan.vlan_id]: e.target.value,
}))
}
/>
)}
{op.bridgeExistsOnTarget ? (
<Check className="w-3.5 h-3.5 text-green-500 shrink-0" />
) : (
<span className="text-xs text-amber-500 flex items-center gap-1">
<AlertTriangle className="w-3.5 h-3.5" /> not found on target
</span>
)}
</div>
{/* Port assignment */}
<div className={clsx('space-y-2', isSkipped && 'opacity-40 pointer-events-none')}>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500 dark:text-slate-400">Interfaces</span>
{assignablePorts.length > 0 && (
<button
type="button"
onClick={() => {
// Reset all ports for this VLAN to none
setPortAssignments((prev) => ({ ...prev, [op.sourceVlan.vlan_id]: {} }));
}}
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
>
Clear all
</button>
)}
</div>
{assignablePorts.length === 0 ? (
<p className="text-xs text-gray-400 italic">
No interfaces detected — sync the device first.
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{assignablePorts.map((portName) => (
<PortChip
key={portName}
name={portName}
assignment={getAssignment(op.sourceVlan.vlan_id, portName)}
onClick={() => cyclePort(op.sourceVlan.vlan_id, portName)}
/>
))}
</div>
)}
{/* Live summary of current assignment */}
{(effTagged.length > 0 || effUntagged.length > 0) && (
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs pt-1 border-t border-gray-100 dark:border-slate-700/50">
{effTagged.length > 0 && (
<span className="text-gray-500 dark:text-slate-400">
<span className="font-medium text-blue-600 dark:text-blue-400">Tagged:</span>{' '}
{effTagged.join(', ')}
</span>
)}
{effUntagged.length > 0 && (
<span className="text-gray-500 dark:text-slate-400">
<span className="font-medium text-slate-600 dark:text-slate-300">Untagged:</span>{' '}
{effUntagged.join(', ')}
</span>
)}
</div>
)}
{effTagged.length === 0 && effUntagged.length === 0 && assignablePorts.length > 0 && (
<p className="text-xs text-gray-400 dark:text-slate-500 italic pt-1">
No ports assigned — VLAN will be created with no port memberships.
</p>
)}
</div>
</div>
);
})}
</div>
);
// ── Step 3 ─────────────────────────────────────────────────────────────────
const renderStep3 = () => {
if (applyResults) {
const succeeded = applyResults.filter((r) => r.success);
const failed = applyResults.filter((r) => !r.success);
return (
<div className="space-y-3">
{succeeded.length > 0 && (
<div className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/10 px-4 py-3 space-y-1">
<p className="text-sm font-medium text-green-700 dark:text-green-400">
{succeeded.length} VLAN{succeeded.length !== 1 ? 's' : ''} applied successfully
</p>
{succeeded.map((r) => (
<div key={r.vlan_id} className="flex items-center gap-2 text-sm text-green-600 dark:text-green-500">
<Check className="w-3.5 h-3.5 shrink-0" />
<span>VLAN {r.vlan_id} — {r.action === 'add' ? 'added' : 'updated'}</span>
</div>
))}
</div>
)}
{failed.length > 0 && (
<div className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/10 px-4 py-3 space-y-1">
<p className="text-sm font-medium text-red-700 dark:text-red-400">
{failed.length} VLAN{failed.length !== 1 ? 's' : ''} failed
</p>
{failed.map((r) => (
<div key={r.vlan_id} className="text-sm text-red-600 dark:text-red-400">
<span className="font-mono">VLAN {r.vlan_id}</span>
{r.error && <span className="ml-2 text-xs opacity-75">{r.error}</span>}
</div>
))}
</div>
)}
</div>
);
}
const skipped = analyzedOps.filter(
(op) =>
op.status === 'identical' ||
(op.status === 'conflict' && op.conflictChoice === 'skip')
);
return (
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-slate-400">
{toApply.length > 0 ? (
<>
<strong>{toApply.length}</strong> VLAN{toApply.length !== 1 ? 's' : ''} will be applied to{' '}
<strong>{deviceName}</strong>.
{skipped.length > 0 && (
<> <strong>{skipped.length}</strong> will be skipped.</>
)}
</>
) : (
<>No VLANs will be applied — all selected VLANs are either identical or set to skip.</>
)}
</p>
<div className="rounded-lg border border-gray-200 dark:border-slate-700 divide-y divide-gray-100 dark:divide-slate-700 overflow-hidden">
{analyzedOps.map((op) => {
const willApply =
op.status === 'add' ||
(op.status === 'conflict' && op.conflictChoice === 'overwrite');
return (
<div key={op.sourceVlan.vlan_id} className="px-4 py-3 flex items-start gap-3">
<div
className={clsx(
'mt-1 w-2 h-2 rounded-full shrink-0',
willApply ? 'bg-green-500' : 'bg-gray-300 dark:bg-slate-600'
)}
/>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-bold text-sm text-blue-600 dark:text-blue-400">
VLAN {op.sourceVlan.vlan_id}
</span>
{op.sourceVlan.name && (
<span className="text-sm text-gray-600 dark:text-slate-400">
{op.sourceVlan.name}
</span>
)}
<StatusBadge op={op} />
<span className="text-xs text-gray-400 font-mono">{op.targetBridge}</span>
</div>
{willApply && (
<div className="flex flex-wrap gap-1">
{op.effectiveTagged.map((p) => (
<span
key={p}
className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 text-xs rounded font-mono"
>
{p} T
</span>
))}
{op.effectiveUntagged.map((p) => (
<span
key={p}
className="px-1.5 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 text-xs rounded font-mono"
>
{p} U
</span>
))}
{op.effectiveTagged.length === 0 && op.effectiveUntagged.length === 0 && (
<span className="text-xs text-gray-400 italic">no port assignments</span>
)}
</div>
)}
{op.status === 'conflict' && op.conflictChoice === 'skip' && (
<p className="text-xs text-gray-400">Conflict — you chose to skip</p>
)}
{op.status === 'identical' && (
<p className="text-xs text-gray-400">Already configured identically — skipped</p>
)}
</div>
</div>
);
})}
</div>
{applyMutation.isError && (
<div className="text-sm text-red-500">
Failed to apply:{' '}
{(applyMutation.error as { message?: string })?.message ?? 'Unknown error'}
</div>
)}
</div>
);
};
// ── Footer ─────────────────────────────────────────────────────────────────
const renderFooter = () => {
if (step === 1) {
return (
<>
<button onClick={onClose} className="btn-secondary text-sm">
Cancel
</button>
<button
className="btn-primary flex items-center gap-2 text-sm"
disabled={selectedVlanIds.size === 0 || sourceDeviceId == null}
onClick={() => setStep(2)}
>
Assign Ports <ChevronRight className="w-4 h-4" />
</button>
</>
);
}
if (step === 2) {
return (
<>
<button
onClick={() => setStep(1)}
className="btn-secondary flex items-center gap-2 text-sm"
>
<ChevronRight className="w-4 h-4 rotate-180" /> Back
</button>
<button
className="btn-primary flex items-center gap-2 text-sm"
onClick={() => setStep(3)}
>
Review plan <ChevronRight className="w-4 h-4" />
</button>
</>
);
}
// step === 3
if (applyResults) {
return (
<div className="w-full flex justify-end">
<button onClick={onClose} className="btn-primary text-sm">
Close
</button>
</div>
);
}
return (
<>
<button
onClick={() => setStep(2)}
className="btn-secondary flex items-center gap-2 text-sm"
disabled={applyMutation.isPending}
>
<ChevronRight className="w-4 h-4 rotate-180" /> Back
</button>
<button
className="btn-primary flex items-center gap-2 text-sm"
disabled={toApply.length === 0 || applyMutation.isPending}
onClick={() => applyMutation.mutate()}
>
{applyMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
{applyMutation.isPending
? 'Applying…'
: `Apply ${toApply.length} VLAN${toApply.length !== 1 ? 's' : ''}`}
</button>
</>
);
};
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="card w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700 shrink-0">
<div className="flex items-center gap-2">
<Copy className="w-4 h-4 text-blue-500" />
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
Copy VLANs from Another Switch
</h2>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-slate-300 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<StepIndicator step={step} />
{/* Scrollable body */}
<div className="overflow-y-auto flex-1 p-5">
{step === 1 && renderStep1()}
{step === 2 && renderStep2()}
{step === 3 && renderStep3()}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 py-4 border-t border-gray-200 dark:border-slate-700 shrink-0 bg-gray-50 dark:bg-slate-800/50">
{renderFooter()}
</div>
</div>
</div>
);
}
@@ -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 ? (