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) {