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:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user