Files
MIKROTIK-RMM/frontend/src/components/device-detail/VlansTab.tsx
T
2GT-RichandClaude Opus 4.8 118dd9ee8f feat(safety): explain predicted lockouts in the UI with typed confirmation (v0.19.1-beta)
The backend already refused a predicted lockout with 409 + a verdict, but the
frontend surfaced it as a generic "failed" message, so the reasoning never reached
the user.

- LockoutVerdictDialog renders the verdict properly: each violated invariant with
  its plain-English consequence, and the resolved management path ("how the manager
  reaches this device") so the user can verify the claim rather than take it on
  trust. Critical verdicts require typing the device name to override; warnings
  need only an acknowledgement, per the agreed friction levels.
- lockoutVerdictOf() extracts the 409 payload, mirroring the existing lockoutOf()
  helper used by the firewall guard.
- VLAN add/update/delete now capture the verdict and re-send with confirm_lockout
  when the user accepts, instead of showing an error banner.

Verified end to end on the production switch: changing the ingress port's PVID off
the management VLAN returns severity=critical with the ingress-port-pvid violation
and the hop chain (192.168.0.40/24 -> bridge -> sfp28-1), and the shipped bundle
contains the new dialog. Full CI preflight green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:04:29 -07:00

469 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Vlan | null>(null);
const [editForm, setEditForm] = useState<PortForm>({ 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 <div className="text-center py-8 text-gray-400">Loading...</div>;
return (
<div className="space-y-4">
{/* VLAN Filtering warning */}
{bridgesWithoutFiltering.length > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 px-4 py-3">
<Network className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1 text-sm text-amber-800 dark:text-amber-300">
<span className="font-semibold">VLAN Filtering is not enabled</span> 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 && (
<button
onClick={() => onGoToPorts(bridgesWithoutFiltering[0].name)}
className="inline-flex items-center gap-1 font-semibold underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-200 transition-colors"
>
Enable VLAN Filtering on the Bridge interface →
</button>
)}
</div>
</div>
)}
{/* Toolbar */}
<div className="flex items-center justify-between">
<div>
{deleteError && (
<span className="text-sm text-red-500 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />{deleteError}
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => refetch()}
disabled={isFetching}
className="btn-secondary flex items-center gap-2 text-sm"
>
<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={() => {
setShowAdd(!showAdd);
setEditingVlan(null);
setAddError('');
setAddForm({ vlan_id: '', bridge: bridgeOptions[0] || '', tagged_ports: '', untagged_ports: '' });
}}
className="btn-primary flex items-center gap-2 text-sm"
>
<Plus className="w-4 h-4" />
Add VLAN
</button>
)}
</div>
</div>
{/* Add VLAN form */}
{canWrite && showAdd && (
<div className="card p-4 space-y-3">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">New Bridge VLAN</h4>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<label className="label">VLAN ID (1–4094)</label>
<input
type="number"
className="input"
value={addForm.vlan_id}
onChange={(e) => setAddForm((f) => ({ ...f, vlan_id: e.target.value }))}
min={1} max={4094}
placeholder="10"
/>
</div>
<div>
<label className="label">Bridge</label>
{bridgeOptions.length > 0 ? (
<select
className="input"
value={addForm.bridge}
onChange={(e) => setAddForm((f) => ({ ...f, bridge: e.target.value }))}
>
{bridgeOptions.map((b) => <option key={b} value={b}>{b}</option>)}
</select>
) : (
<input
className="input font-mono"
value={addForm.bridge}
onChange={(e) => setAddForm((f) => ({ ...f, bridge: e.target.value }))}
placeholder="bridge1"
/>
)}
</div>
<div>
<label className="label">Tagged Ports</label>
<input
className="input font-mono"
value={addForm.tagged_ports}
onChange={(e) => setAddForm((f) => ({ ...f, tagged_ports: e.target.value }))}
placeholder="ether1, ether2"
/>
<p className="text-xs text-gray-400 mt-1">Comma-separated</p>
</div>
<div>
<label className="label">Untagged Ports</label>
<input
className="input font-mono"
value={addForm.untagged_ports}
onChange={(e) => setAddForm((f) => ({ ...f, untagged_ports: e.target.value }))}
placeholder="ether3"
/>
<p className="text-xs text-gray-400 mt-1">Comma-separated</p>
</div>
</div>
{addError && (
<div className="flex items-center gap-2 text-sm text-red-500">
<AlertCircle className="w-4 h-4" /> {addError}
</div>
)}
<div className="flex gap-2">
<button
onClick={() => addMutation.mutate(false)}
disabled={!addForm.vlan_id || !addForm.bridge || addMutation.isPending}
className="btn-primary flex items-center gap-2 text-sm"
>
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
Add VLAN
</button>
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
</div>
</div>
)}
{/* Edit VLAN panel */}
{canWrite && editingVlan && (
<div className="card p-4 space-y-3 border-blue-300 dark:border-blue-600 border-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
Edit VLAN{' '}
<span className="font-mono text-blue-600 dark:text-blue-400">{editingVlan.vlan_id}</span>
{editingVlan.bridge && (
<span className="text-gray-400 dark:text-slate-500 font-normal"> on {editingVlan.bridge}</span>
)}
</h4>
<button onClick={() => setEditingVlan(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="label">Tagged Ports</label>
<input
className="input font-mono"
value={editForm.tagged_ports}
onChange={(e) => setEditForm((f) => ({ ...f, tagged_ports: e.target.value }))}
placeholder="ether1, ether2"
autoFocus
/>
<p className="text-xs text-gray-400 mt-1">Comma-separated interface names</p>
</div>
<div>
<label className="label">Untagged Ports</label>
<input
className="input font-mono"
value={editForm.untagged_ports}
onChange={(e) => setEditForm((f) => ({ ...f, untagged_ports: e.target.value }))}
placeholder="ether3"
/>
<p className="text-xs text-gray-400 mt-1">Comma-separated interface names</p>
</div>
</div>
{editError && (
<div className="flex items-center gap-2 text-sm text-red-500">
<AlertCircle className="w-4 h-4" /> {editError}
</div>
)}
<div className="flex gap-2">
<button
onClick={() => updateMutation.mutate(false)}
disabled={updateMutation.isPending}
className="btn-primary flex items-center gap-2 text-sm"
>
{updateMutation.isPending
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
: <Check className="w-3.5 h-3.5" />}
Save Changes
</button>
<button onClick={() => setEditingVlan(null)} className="btn-secondary text-sm">Cancel</button>
</div>
</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 ? (
<div className="text-center py-8 text-gray-400 dark:text-slate-500">
No VLANs configured on this device, or device not synced yet.
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
<th className="table-header px-4 py-2.5 text-left">VLAN ID</th>
<th className="table-header px-4 py-2.5 text-left">Name</th>
<th className="table-header px-4 py-2.5 text-left">Bridge</th>
<th className="table-header px-4 py-2.5 text-left">Tagged Ports</th>
<th className="table-header px-4 py-2.5 text-left">Untagged Ports</th>
{canWrite && <th className="table-header px-4 py-2.5 w-16" />}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
{(vlans as Vlan[]).map((vlan) => (
<tr
key={vlan.id}
className={clsx(
'hover:bg-gray-50 dark:hover:bg-slate-700/30',
editingVlan?.id === vlan.id && 'bg-blue-50 dark:bg-blue-900/10'
)}
>
<td className="px-4 py-2.5 font-mono font-bold text-blue-600 dark:text-blue-400">
{vlan.vlan_id}
</td>
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
{vlan.name || `VLAN ${vlan.vlan_id}`}
</td>
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">
{vlan.bridge || '—'}
</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{(vlan.tagged_ports ?? []).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}
</span>
))}
{!(vlan.tagged_ports?.length) && '—'}
</div>
</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{(vlan.untagged_ports ?? []).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}
</span>
))}
{!(vlan.untagged_ports?.length) && '—'}
</div>
</td>
{canWrite && (
<td className="px-4 py-2.5">
<div className="flex items-center gap-1">
<button
onClick={() => openEdit(vlan)}
className={clsx(
'p-1 rounded transition-colors',
editingVlan?.id === vlan.id
? 'text-blue-500 bg-blue-100 dark:bg-blue-900/30'
: 'text-gray-400 hover:text-blue-500 hover:bg-gray-100 dark:hover:bg-slate-700'
)}
title="Edit VLAN"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Remove VLAN ${vlan.vlan_id} from ${vlan.bridge}?`)) {
deleteMutation.mutate({ vlanDbId: vlan.id });
}
}}
disabled={deleteMutation.isPending}
className="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
title="Delete VLAN"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</div>
{lockout && (
<LockoutVerdictDialog
verdict={lockout.verdict}
confirmPhrase={deviceName || 'confirm'}
pending={addMutation.isPending || updateMutation.isPending || deleteMutation.isPending}
onConfirm={lockout.retry}
onCancel={() => setLockout(null)}
/>
)}
</div>
);
}