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.
-
+



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
)}
);
}
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';