import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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'; import { LockoutVerdictDialog, lockoutVerdictOf, type LockoutVerdict } from '../ChangeGuardDialog'; interface PortForm { tagged_ports: string; untagged_ports: string; } export default function VlansTab({ deviceId, deviceName, deviceType, onGoToPorts }: { deviceId: number; deviceName?: string; deviceType?: string; onGoToPorts?: (bridgeName: string) => void; }) { const queryClient = useQueryClient(); const canWrite = useCanWrite(); // A predicted lockout comes back as 409 with a verdict; hold it plus the retry // that re-sends the same change with confirm_lockout once the user accepts. const [lockout, setLockout] = useState<{ verdict: LockoutVerdict; retry: () => void } | null>(null); const { data: vlans = [], isLoading, refetch, isFetching } = useQuery({ queryKey: ['vlans', deviceId], queryFn: () => devicesApi.getVlans(deviceId).then((r) => r.data), refetchInterval: 60_000, }); const { data: portsData } = useQuery({ queryKey: ['ports', deviceId], queryFn: () => devicesApi.getPorts(deviceId).then((r) => r.data), }); const ports: SwitchPort[] = portsData?.ports ?? []; // Find bridges where vlan-filtering is not enabled (stored as 'yes' from RouterOS or 'true' after toggle) const bridgesWithoutFiltering = ports.filter( (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: '' }); const [addError, setAddError] = useState(''); // ── Edit form ── const [editingVlan, setEditingVlan] = useState(null); const [editForm, setEditForm] = useState({ tagged_ports: '', untagged_ports: '' }); const [editError, setEditError] = useState(''); // ── Delete error ── const [deleteError, setDeleteError] = useState(''); const bridgeOptions = [...new Set((vlans as Vlan[]).map((v) => v.bridge).filter(Boolean))]; // ── Mutations ── const addMutation = useMutation({ mutationFn: (confirm: boolean) => { const tagged = addForm.tagged_ports.split(',').map((s) => s.trim()).filter(Boolean); const untagged = addForm.untagged_ports.split(',').map((s) => s.trim()).filter(Boolean); return devicesApi.addVlan(deviceId, { bridge: addForm.bridge, vlan_id: parseInt(addForm.vlan_id, 10), tagged_ports: tagged, untagged_ports: untagged, ...(confirm ? { confirm_lockout: true } : {}), }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] }); setAddForm({ vlan_id: '', bridge: bridgeOptions[0] || '', tagged_ports: '', untagged_ports: '' }); setShowAdd(false); setAddError(''); setLockout(null); }, onError: (err: unknown) => { const verdict = lockoutVerdictOf(err); if (verdict) { setLockout({ verdict, retry: () => addMutation.mutate(true) }); return; } const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setAddError(msg || 'Failed to add VLAN'); }, }); const updateMutation = useMutation({ mutationFn: (confirm: boolean) => { if (!editingVlan) throw new Error('No VLAN selected'); const tagged = editForm.tagged_ports.split(',').map((s) => s.trim()).filter(Boolean); const untagged = editForm.untagged_ports.split(',').map((s) => s.trim()).filter(Boolean); return devicesApi.updateVlan(deviceId, editingVlan.id, { tagged_ports: tagged, untagged_ports: untagged, ...(confirm ? { confirm_lockout: true } : {}), }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] }); setEditingVlan(null); setEditError(''); setLockout(null); }, onError: (err: unknown) => { const verdict = lockoutVerdictOf(err); if (verdict) { setLockout({ verdict, retry: () => updateMutation.mutate(true) }); return; } const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setEditError(msg || 'Failed to update VLAN'); }, }); const deleteMutation = useMutation({ mutationFn: ({ vlanDbId, confirm = false }: { vlanDbId: number; confirm?: boolean }) => devicesApi.deleteVlan(deviceId, vlanDbId, confirm), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] }); setDeleteError(''); setLockout(null); }, onError: (err: unknown, vars) => { const verdict = lockoutVerdictOf(err); if (verdict) { setLockout({ verdict, retry: () => deleteMutation.mutate({ vlanDbId: vars.vlanDbId, confirm: true }) }); return; } const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setDeleteError(msg || 'Failed to delete VLAN'); }, }); const openEdit = (vlan: Vlan) => { setEditingVlan(vlan); setEditError(''); setShowAdd(false); setEditForm({ tagged_ports: (vlan.tagged_ports ?? []).join(', '), untagged_ports: (vlan.untagged_ports ?? []).join(', '), }); }; if (isLoading) return
Loading...
; return (
{/* VLAN Filtering warning */} {bridgesWithoutFiltering.length > 0 && (
VLAN Filtering is not enabled on the Bridge interface {bridgesWithoutFiltering.length > 1 ? ` (${bridgesWithoutFiltering.map((b) => b.name).join(', ')})` : ` (${bridgesWithoutFiltering[0].name})`}. {' '}VLAN functionality is disabled — tagging and untagging rules will not be enforced.{' '} {onGoToPorts && ( )}
)} {/* Toolbar */}
{deleteError && ( {deleteError} )}
{canWrite && deviceType === 'switch' && ( )} {canWrite && ( )}
{/* Add VLAN form */} {canWrite && showAdd && (

New Bridge VLAN

setAddForm((f) => ({ ...f, vlan_id: e.target.value }))} min={1} max={4094} placeholder="10" />
{bridgeOptions.length > 0 ? ( ) : ( setAddForm((f) => ({ ...f, bridge: e.target.value }))} placeholder="bridge1" /> )}
setAddForm((f) => ({ ...f, tagged_ports: e.target.value }))} placeholder="ether1, ether2" />

Comma-separated

setAddForm((f) => ({ ...f, untagged_ports: e.target.value }))} placeholder="ether3" />

Comma-separated

{addError && (
{addError}
)}
)} {/* Edit VLAN panel */} {canWrite && editingVlan && (

Edit VLAN{' '} {editingVlan.vlan_id} {editingVlan.bridge && ( on {editingVlan.bridge} )}

setEditForm((f) => ({ ...f, tagged_ports: e.target.value }))} placeholder="ether1, ether2" autoFocus />

Comma-separated interface names

setEditForm((f) => ({ ...f, untagged_ports: e.target.value }))} placeholder="ether3" />

Comma-separated interface names

{editError && (
{editError}
)}
)} {showCopyModal && ( { setShowCopyModal(false); queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] }); }} /> )} {/* VLANs table */}
{(vlans as Vlan[]).length === 0 ? (
No VLANs configured on this device, or device not synced yet.
) : ( {canWrite && {(vlans as Vlan[]).map((vlan) => ( {canWrite && ( )} ))}
VLAN ID Name Bridge Tagged Ports Untagged Ports}
{vlan.vlan_id} {vlan.name || `VLAN ${vlan.vlan_id}`} {vlan.bridge || '—'}
{(vlan.tagged_ports ?? []).map((p) => ( {p} ))} {!(vlan.tagged_ports?.length) && '—'}
{(vlan.untagged_ports ?? []).map((p) => ( {p} ))} {!(vlan.untagged_ports?.length) && '—'}
)}
{lockout && ( setLockout(null)} /> )}
); }