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 && ( + + )} + + {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';