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>
This commit is contained in:
2GT-Rich
2026-08-16 21:04:29 -07:00
co-authored by Claude Opus 4.8
parent 518063e64f
commit 118dd9ee8f
7 changed files with 203 additions and 15 deletions
@@ -6,6 +6,7 @@ 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;
@@ -20,6 +21,9 @@ export default function VlansTab({ deviceId, deviceName, deviceType, onGoToPorts
}) {
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],
@@ -58,7 +62,7 @@ export default function VlansTab({ deviceId, deviceName, deviceType, onGoToPorts
// ── Mutations ──
const addMutation = useMutation({
mutationFn: () => {
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, {
@@ -66,6 +70,7 @@ export default function VlansTab({ deviceId, deviceName, deviceType, onGoToPorts
vlan_id: parseInt(addForm.vlan_id, 10),
tagged_ports: tagged,
untagged_ports: untagged,
...(confirm ? { confirm_lockout: true } : {}),
});
},
onSuccess: () => {
@@ -73,41 +78,55 @@ export default function VlansTab({ deviceId, deviceName, deviceType, onGoToPorts
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: () => {
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: number) => devicesApi.deleteVlan(deviceId, vlanDbId),
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) => {
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');
},
@@ -257,7 +276,7 @@ export default function VlansTab({ deviceId, deviceName, deviceType, onGoToPorts
)}
<div className="flex gap-2">
<button
onClick={() => addMutation.mutate()}
onClick={() => addMutation.mutate(false)}
disabled={!addForm.vlan_id || !addForm.bridge || addMutation.isPending}
className="btn-primary flex items-center gap-2 text-sm"
>
@@ -317,7 +336,7 @@ export default function VlansTab({ deviceId, deviceName, deviceType, onGoToPorts
<div className="flex gap-2">
<button
onClick={() => updateMutation.mutate()}
onClick={() => updateMutation.mutate(false)}
disabled={updateMutation.isPending}
className="btn-primary flex items-center gap-2 text-sm"
>
@@ -416,7 +435,7 @@ export default function VlansTab({ deviceId, deviceName, deviceType, onGoToPorts
<button
onClick={() => {
if (confirm(`Remove VLAN ${vlan.vlan_id} from ${vlan.bridge}?`)) {
deleteMutation.mutate(vlan.id);
deleteMutation.mutate({ vlanDbId: vlan.id });
}
}}
disabled={deleteMutation.isPending}
@@ -434,6 +453,16 @@ export default function VlansTab({ deviceId, deviceName, deviceType, onGoToPorts
</table>
)}
</div>
{lockout && (
<LockoutVerdictDialog
verdict={lockout.verdict}
confirmPhrase={deviceName || 'confirm'}
pending={addMutation.isPending || updateMutation.isPending || deleteMutation.isPending}
onConfirm={lockout.retry}
onCancel={() => setLockout(null)}
/>
)}
</div>
);
}