From d67698bac9b8f7f7af0e0f83d38f3f2b8eaffc8b Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:30:17 -0700 Subject: [PATCH] feat(safety): wire the lockout verdict into every guarded UI surface (v0.19.2-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verdict dialog only reached the VLANs tab, so a predicted lockout on the other guarded paths still surfaced as a generic "failed" message with the reasoning lost. Every surface that can push a guarded change now explains the refusal: - SwitchPortDiagram — port VLAN (PVID / tagged / untagged, the shape of the real incident), bond create and destroy, and the bridge vlan-filtering toggle. - RoutingTab — static route add and delete. - SystemConfigTab — IP address add and remove. - SecurityTab — /ip/service enable/disable. This route also has an older hard refusal that returns 409 with a reason but no verdict; that text is now shown instead of a generic error, where previously it was swallowed entirely. Supporting changes: confirm_lockout threaded through the affected API methods (DELETE routes send it as a request body), and deviceName threaded down to SwitchPortDiagram, RoutingTab and SecurityTab so the typed confirmation can ask for the actual device name. Verified the shipped bundle contains the dialog and the typed-confirm prompt. Full CI preflight green; 152 backend tests pass. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- backend/package.json | 2 +- frontend/package.json | 2 +- .../components/device-detail/RoutingTab.tsx | 46 +++++++++--- .../components/device-detail/SecurityTab.tsx | 41 +++++++++- .../device-detail/SystemConfigTab.tsx | 40 ++++++++-- .../components/ports/SwitchPortDiagram.tsx | 75 +++++++++++++++---- frontend/src/pages/DeviceDetailPage.tsx | 5 +- frontend/src/services/api.ts | 32 ++++---- frontend/src/version.ts | 2 +- 10 files changed, 198 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index ca1f856..13bcc1e 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.1_Beta-blue) +![Version](https://img.shields.io/badge/version-0.19.2_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 85d8c15..ca67b94 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.19.1-beta", + "version": "0.19.2-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/frontend/package.json b/frontend/package.json index e169697..df62089 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.19.1-beta", + "version": "0.19.2-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/device-detail/RoutingTab.tsx b/frontend/src/components/device-detail/RoutingTab.tsx index f0e9ffb..43b75b6 100644 --- a/frontend/src/components/device-detail/RoutingTab.tsx +++ b/frontend/src/components/device-detail/RoutingTab.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { LockoutVerdictDialog, lockoutVerdictOf, type LockoutVerdict } from '../ChangeGuardDialog'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Trash2, AlertCircle, RefreshCw } from 'lucide-react'; import { devicesApi } from '../../services/api'; @@ -77,7 +78,7 @@ function SectionHeader({ title, onRefresh, isFetching }: { title: string; onRefr } // ─── Routes sub-tab (original) ──────────────────────────────────────────────── -function RoutesSubTab({ deviceId }: { deviceId: number }) { +function RoutesSubTab({ deviceId, deviceName }: { deviceId: number; deviceName?: string }) { const queryClient = useQueryClient(); const canWrite = useCanWrite(); @@ -90,30 +91,47 @@ function RoutesSubTab({ deviceId }: { deviceId: number }) { const [form, setForm] = useState({ dst_address: '', gateway: '', distance: '1', comment: '' }); const [addError, setAddError] = useState(''); const [deleteError, setDeleteError] = useState(''); + // 409 + verdict from the lockout predictor, with the retry that confirms it. + const [lockout, setLockout] = useState<{ verdict: LockoutVerdict; retry: () => void } | null>(null); const addMutation = useMutation({ - mutationFn: () => devicesApi.addRoute(deviceId, { + mutationFn: (confirm: boolean) => devicesApi.addRoute(deviceId, { dst_address: form.dst_address, gateway: form.gateway, distance: form.distance ? parseInt(form.distance, 10) : undefined, comment: form.comment || undefined, + ...(confirm ? { confirm_lockout: true } : {}), }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing', deviceId] }); setForm({ dst_address: '', gateway: '', distance: '1', comment: '' }); setShowAdd(false); setAddError(''); + setLockout(null); + }, + onError: (err: unknown) => { + const verdict = lockoutVerdictOf(err); + if (verdict) { setLockout({ verdict, retry: () => addMutation.mutate(true) }); return; } + setAddError(errMsg(err)); }, - onError: (err: unknown) => setAddError(errMsg(err)), }); const deleteMutation = useMutation({ - mutationFn: (routeId: string) => devicesApi.deleteRoute(deviceId, routeId), + mutationFn: ({ routeId, confirm = false }: { routeId: string; confirm?: boolean }) => + devicesApi.deleteRoute(deviceId, routeId, confirm), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing', deviceId] }); setDeleteError(''); + setLockout(null); + }, + onError: (err: unknown, vars) => { + const verdict = lockoutVerdictOf(err); + if (verdict) { + setLockout({ verdict, retry: () => deleteMutation.mutate({ routeId: vars.routeId, confirm: true }) }); + return; + } + setDeleteError(errMsg(err)); }, - onError: (err: unknown) => setDeleteError(errMsg(err)), }); const isDynamic = (r: Record) => @@ -158,7 +176,7 @@ function RoutesSubTab({ deviceId }: { deviceId: number }) { {addError &&
{addError}
}
- @@ -206,7 +224,7 @@ function RoutesSubTab({ deviceId }: { deviceId: number }) { {!dynamic && routeId && (
+ + {lockout && ( + setLockout(null)} + /> + )} ); } @@ -682,7 +710,7 @@ function TablesSubTab({ deviceId }: { deviceId: number }) { } // ─── Main RoutingTab with sub-tabs ──────────────────────────────────────────── -export default function RoutingTab({ deviceId }: { deviceId: number }) { +export default function RoutingTab({ deviceId, deviceName }: { deviceId: number; deviceName?: string }) { const [activeSubTab, setActiveSubTab] = useState('Routes'); return ( @@ -706,7 +734,7 @@ export default function RoutingTab({ deviceId }: { deviceId: number }) { {/* Sub-tab content */} - {activeSubTab === 'Routes' && } + {activeSubTab === 'Routes' && } {activeSubTab === 'OSPF' && } {activeSubTab === 'BGP' && } {activeSubTab === 'Route Filters' && } diff --git a/frontend/src/components/device-detail/SecurityTab.tsx b/frontend/src/components/device-detail/SecurityTab.tsx index 547c56b..a763ae1 100644 --- a/frontend/src/components/device-detail/SecurityTab.tsx +++ b/frontend/src/components/device-detail/SecurityTab.tsx @@ -1,8 +1,10 @@ +import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { ShieldCheck, ShieldAlert, RefreshCw, Check, AlertTriangle, Lock } from 'lucide-react'; import { devicesApi } from '../../services/api'; import type { SecurityCheck } from '../../services/api'; import { useCanWrite } from '../../hooks/useCanWrite'; +import { LockoutVerdictDialog, lockoutVerdictOf, type LockoutVerdict } from '../ChangeGuardDialog'; import clsx from 'clsx'; type Row = Record & { '.id': string }; @@ -20,9 +22,11 @@ function scoreColor(score: number) { return 'text-red-600 dark:text-red-400'; } -export default function SecurityTab({ deviceId }: { deviceId: number }) { +export default function SecurityTab({ deviceId, deviceName }: { deviceId: number; deviceName?: string }) { const qc = useQueryClient(); const canWrite = useCanWrite(); + const [serviceError, setServiceError] = useState(''); + const [lockout, setLockout] = useState<{ verdict: LockoutVerdict; retry: () => void } | null>(null); const { data: posture, isLoading, refetch, isFetching } = useQuery({ queryKey: ['security-posture', deviceId], @@ -47,8 +51,23 @@ export default function SecurityTab({ deviceId }: { deviceId: number }) { qc.invalidateQueries({ queryKey: ['services', deviceId] }); }; const toggleSvc = useMutation({ - mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) => devicesApi.setServiceDisabled(deviceId, id, disabled), - onSuccess: invalidate, + mutationFn: ({ id, disabled, confirm = false }: { id: string; disabled: boolean; confirm?: boolean }) => + devicesApi.setServiceDisabled(deviceId, id, disabled, confirm), + onSuccess: () => { invalidate(); setLockout(null); }, + onError: (err: unknown, vars) => { + const verdict = lockoutVerdictOf(err); + if (verdict) { + setLockout({ + verdict, + retry: () => toggleSvc.mutate({ id: vars.id, disabled: vars.disabled, confirm: true }), + }); + return; + } + // The management service has its own hard refusal (409 with a reason but no + // verdict) — surface that text rather than a generic failure. + const r = (err as { response?: { data?: { reason?: string; error?: string } } })?.response?.data; + setServiceError(r?.reason || r?.error || 'Failed to update service'); + }, }); const checks: SecurityCheck[] = posture?.checks ?? []; @@ -164,6 +183,22 @@ export default function SecurityTab({ deviceId }: { deviceId: number }) { )} + + {serviceError && ( +
+ {serviceError} +
+ )} + + {lockout && ( + setLockout(null)} + /> + )} ); } diff --git a/frontend/src/components/device-detail/SystemConfigTab.tsx b/frontend/src/components/device-detail/SystemConfigTab.tsx index 3a4141d..c5a64ec 100644 --- a/frontend/src/components/device-detail/SystemConfigTab.tsx +++ b/frontend/src/components/device-detail/SystemConfigTab.tsx @@ -4,6 +4,7 @@ import { RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server, Cpu, FileText, } from 'lucide-react'; import { devicesApi } from '../../services/api'; +import { LockoutVerdictDialog, lockoutVerdictOf, type LockoutVerdict } from '../ChangeGuardDialog'; import type { Device, IpAddress, Interface } from '../../types'; import clsx from 'clsx'; import { useCanWrite } from '../../hooks/useCanWrite'; @@ -98,23 +99,42 @@ export default function SystemConfigTab({ deviceId, device }: Props) { const [newIp, setNewIp] = useState({ address: '', interface: '' }); const [ipError, setIpError] = useState(''); + // Predicted lockout (409 + verdict) plus the retry that confirms it. + const [lockout, setLockout] = useState<{ verdict: LockoutVerdict; retry: () => void } | null>(null); const addIpMutation = useMutation({ - mutationFn: () => devicesApi.addIpAddress(deviceId, newIp), + mutationFn: (confirm: boolean) => + devicesApi.addIpAddress(deviceId, confirm ? { ...newIp, confirm_lockout: true } : newIp), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] }); setNewIp({ address: '', interface: '' }); setIpError(''); + setLockout(null); }, onError: (err: unknown) => { + const verdict = lockoutVerdictOf(err); + if (verdict) { setLockout({ verdict, retry: () => addIpMutation.mutate(true) }); return; } const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setIpError(msg || 'Failed to add IP address'); }, }); const removeIpMutation = useMutation({ - mutationFn: (id: string) => devicesApi.removeIpAddress(deviceId, id), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] }), + mutationFn: ({ id, confirm = false }: { id: string; confirm?: boolean }) => + devicesApi.removeIpAddress(deviceId, id, confirm), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] }); + setLockout(null); + }, + onError: (err: unknown, vars) => { + const verdict = lockoutVerdictOf(err); + if (verdict) { + setLockout({ verdict, retry: () => removeIpMutation.mutate({ id: vars.id, confirm: true }) }); + return; + } + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + setIpError(msg || 'Failed to remove IP address'); + }, }); // ─── RouterOS update check / install ───────────────────────────────────── @@ -471,7 +491,7 @@ export default function SystemConfigTab({ deviceId, device }: Props) {