From 118dd9ee8f8b4c93ca63d7a2da9253214a1e4a3f Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:04:29 -0700 Subject: [PATCH] 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 --- README.md | 2 +- backend/package.json | 2 +- frontend/package.json | 2 +- frontend/src/components/ChangeGuardDialog.tsx | 158 +++++++++++++++++- .../src/components/device-detail/VlansTab.tsx | 43 ++++- frontend/src/services/api.ts | 9 +- frontend/src/version.ts | 2 +- 7 files changed, 203 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d625125..ca1f856 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface. -![Version](https://img.shields.io/badge/version-0.19.0_Beta-blue) +![Version](https://img.shields.io/badge/version-0.19.1_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) diff --git a/backend/package.json b/backend/package.json index ac22a04..85d8c15 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.19.0-beta", + "version": "0.19.1-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/frontend/package.json b/frontend/package.json index 0a554b3..e169697 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.19.0-beta", + "version": "0.19.1-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/ChangeGuardDialog.tsx b/frontend/src/components/ChangeGuardDialog.tsx index ef39e49..f98bb14 100644 --- a/frontend/src/components/ChangeGuardDialog.tsx +++ b/frontend/src/components/ChangeGuardDialog.tsx @@ -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 ( +
+
+
+ {critical + ? + : } +
+

+ {critical ? 'This will disconnect the device' : 'This leaves management fragile'} +

+

{verdict.headline}

+
+
+ + {verdict.violations.map((v) => ( +
+

{v.title}

+

{v.detail}

+
+ ))} + + {verdict.path.hops.length > 0 && ( +
+
+ + How the manager reaches this device +
+
    + {verdict.path.hops.map((h, i) => ( +
  • + {h.kind}{' '} + {h.name} +
    {h.reason}
    +
  • + ))} +
+
+ )} + + {verdict.warnings.length > 0 && ( +
    + {verdict.warnings.map((w, i) => ( +
  • • {w}
  • + ))} +
+ )} + + {critical && ( +
+ + setTyped(e.target.value)} + placeholder={confirmPhrase} + autoComplete="off" + /> +

+ The change will still run under auto-revert: if the device stops responding, it + restores itself and comes back. +

+
+ )} + +
+ + +
+
+
+ ); +} + /** Shape returned by guarded endpoints so callers can report the outcome. */ export interface GuardResult { protected: boolean; diff --git a/frontend/src/components/device-detail/VlansTab.tsx b/frontend/src/components/device-detail/VlansTab.tsx index e6cd860..e628a86 100644 --- a/frontend/src/components/device-detail/VlansTab.tsx +++ b/frontend/src/components/device-detail/VlansTab.tsx @@ -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 )}
+ + {lockout && ( + setLockout(null)} + /> + )} ); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b2cad56..2d6eed5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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`), diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 123eb8d..20e04e4 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.19.0 Beta'; +export const APP_VERSION = 'v0.19.1 Beta';