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:
co-authored by
Claude Opus 4.8
parent
518063e64f
commit
118dd9ee8f
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.19.0-beta",
|
||||
"version": "0.19.1-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertTriangle, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, RefreshCw, ShieldCheck, ShieldAlert, Route } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Confirmation for a change that could sever the manager's path to a device.
|
||||
@@ -76,6 +77,161 @@ export default function ChangeGuardDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Predicted-lockout verdict ────────────────────────────────────────────────
|
||||
|
||||
export interface VerdictViolation {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
severity: 'critical' | 'warning';
|
||||
}
|
||||
|
||||
export interface VerdictPath {
|
||||
mgmt_interface: string | null;
|
||||
bridge: string | null;
|
||||
mgmt_vlan_id: number | null;
|
||||
tagged_management: boolean;
|
||||
ingress_port: string | null;
|
||||
ingress_port_source: string;
|
||||
hops: Array<{ kind: string; name: string; reason: string }>;
|
||||
}
|
||||
|
||||
export interface LockoutVerdict {
|
||||
severity: 'safe' | 'warning' | 'critical';
|
||||
headline: string;
|
||||
violations: VerdictViolation[];
|
||||
warnings: string[];
|
||||
path: VerdictPath;
|
||||
}
|
||||
|
||||
/** Pull a 409 lockout verdict off a failed mutation, if the backend sent one. */
|
||||
export function lockoutVerdictOf(err: unknown): LockoutVerdict | null {
|
||||
const r = (err as { response?: { status?: number; data?: { lockout?: boolean; verdict?: LockoutVerdict } } })?.response;
|
||||
if (r?.status === 409 && r.data?.lockout && r.data.verdict) return r.data.verdict;
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface LockoutVerdictDialogProps {
|
||||
verdict: LockoutVerdict;
|
||||
/** Typed into the confirm box for a critical verdict — normally the device name. */
|
||||
confirmPhrase: string;
|
||||
pending?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explains a predicted lockout and, for a critical verdict, makes the user type the
|
||||
* device name before overriding. The reason chain is the point: a warning the user
|
||||
* can verify is one they'll act on, rather than click past.
|
||||
*/
|
||||
export function LockoutVerdictDialog({
|
||||
verdict, confirmPhrase, pending = false, onConfirm, onCancel,
|
||||
}: LockoutVerdictDialogProps) {
|
||||
const [typed, setTyped] = useState('');
|
||||
const critical = verdict.severity === 'critical';
|
||||
const canConfirm = !critical || typed.trim() === confirmPhrase;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-lg mx-4 p-6 space-y-4 max-h-[85vh] overflow-y-auto">
|
||||
<div className="flex items-start gap-3">
|
||||
{critical
|
||||
? <ShieldAlert className="w-6 h-6 text-red-500 flex-shrink-0" />
|
||||
: <AlertTriangle className="w-6 h-6 text-amber-500 flex-shrink-0" />}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||
{critical ? 'This will disconnect the device' : 'This leaves management fragile'}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-slate-300">{verdict.headline}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{verdict.violations.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className={
|
||||
v.severity === 'critical'
|
||||
? 'rounded-lg border border-red-300 bg-red-50 dark:bg-red-900/20 dark:border-red-800 p-3'
|
||||
: 'rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 p-3'
|
||||
}
|
||||
>
|
||||
<p className={`text-xs font-semibold uppercase tracking-wide ${
|
||||
v.severity === 'critical'
|
||||
? 'text-red-700 dark:text-red-400'
|
||||
: 'text-amber-700 dark:text-amber-400'
|
||||
}`}>{v.title}</p>
|
||||
<p className={`mt-1 text-sm ${
|
||||
v.severity === 'critical'
|
||||
? 'text-red-800 dark:text-red-300'
|
||||
: 'text-amber-800 dark:text-amber-300'
|
||||
}`}>{v.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{verdict.path.hops.length > 0 && (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<div className="flex items-center gap-1.5 mb-2 text-xs font-semibold text-gray-500 dark:text-slate-400">
|
||||
<Route className="w-3.5 h-3.5" />
|
||||
How the manager reaches this device
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{verdict.path.hops.map((h, i) => (
|
||||
<li key={i} className="text-xs text-gray-600 dark:text-slate-300">
|
||||
<span className="mono text-[11px] text-gray-400 dark:text-slate-500">{h.kind}</span>{' '}
|
||||
<span className="font-medium">{h.name}</span>
|
||||
<div className="text-gray-500 dark:text-slate-400">{h.reason}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verdict.warnings.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{verdict.warnings.map((w, i) => (
|
||||
<li key={i} className="text-xs text-gray-500 dark:text-slate-400">• {w}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{critical && (
|
||||
<div>
|
||||
<label className="label">
|
||||
Type <span className="mono font-semibold">{confirmPhrase}</span> to apply anyway
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
placeholder={confirmPhrase}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-slate-400">
|
||||
The change will still run under auto-revert: if the device stops responding, it
|
||||
restores itself and comes back.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button onClick={onCancel} className="btn-secondary" disabled={pending}>Cancel</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={pending || !canConfirm}
|
||||
className={`px-4 py-1.5 disabled:opacity-50 text-white text-sm font-medium rounded-lg flex items-center gap-2 ${
|
||||
critical ? 'bg-red-600 hover:bg-red-700' : 'bg-amber-600 hover:bg-amber-700'
|
||||
}`}
|
||||
>
|
||||
{pending && <RefreshCw className="w-4 h-4 animate-spin" />}
|
||||
Apply anyway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shape returned by guarded endpoints so callers can report the outcome. */
|
||||
export interface GuardResult {
|
||||
protected: boolean;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,11 +248,14 @@ export const devicesApi = {
|
||||
api.post(`/devices/${id}/routing`, data),
|
||||
deleteRoute: (id: number, routeId: string) =>
|
||||
api.delete(`/devices/${id}/routing/${encodeURIComponent(routeId)}`),
|
||||
addVlan: (id: number, data: { bridge: string; vlan_id: number; tagged_ports?: string[]; untagged_ports?: string[] }) =>
|
||||
// confirm_lockout overrides a predicted-lockout refusal (HTTP 409) after the user
|
||||
// has seen and accepted the verdict.
|
||||
addVlan: (id: number, data: { bridge: string; vlan_id: number; tagged_ports?: string[]; untagged_ports?: string[]; confirm_lockout?: boolean }) =>
|
||||
api.post(`/devices/${id}/vlans`, data),
|
||||
updateVlan: (id: number, vlanDbId: number, data: { tagged_ports: string[]; untagged_ports: string[] }) =>
|
||||
updateVlan: (id: number, vlanDbId: number, data: { tagged_ports: string[]; untagged_ports: string[]; confirm_lockout?: boolean }) =>
|
||||
api.put(`/devices/${id}/vlans/${vlanDbId}`, data),
|
||||
deleteVlan: (id: number, vlanDbId: number) => api.delete(`/devices/${id}/vlans/${vlanDbId}`),
|
||||
deleteVlan: (id: number, vlanDbId: number, confirmLockout = false) =>
|
||||
api.delete(`/devices/${id}/vlans/${vlanDbId}`, confirmLockout ? { data: { confirm_lockout: true } } : undefined),
|
||||
copyVlans: (id: number, operations: Array<{ action: 'add' | 'update'; vlan_id: number; bridge: string; tagged_ports: string[]; untagged_ports: string[] }>) =>
|
||||
api.post<{ results: Array<{ vlan_id: number; action: string; success: boolean; error?: string }>; vlans: Vlan[] }>(`/devices/${id}/vlans/copy`, { operations }),
|
||||
reboot: (id: number) => api.post<{ message: string }>(`/devices/${id}/reboot`),
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.19.0 Beta';
|
||||
export const APP_VERSION = 'v0.19.1 Beta';
|
||||
|
||||
Reference in New Issue
Block a user