feat(safety): wire the lockout verdict into every guarded UI surface (v0.19.2-beta)

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 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-16 21:30:17 -07:00
co-authored by Claude Opus 4.8
parent 118dd9ee8f
commit d67698bac9
10 changed files with 198 additions and 49 deletions
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.19.1-beta",
"version": "0.19.2-beta",
"private": true,
"type": "module",
"scripts": {
@@ -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<string, string>) =>
@@ -158,7 +176,7 @@ function RoutesSubTab({ deviceId }: { deviceId: number }) {
</div>
{addError && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{addError}</div>}
<div className="flex gap-2">
<button onClick={() => addMutation.mutate()} disabled={!form.dst_address || !form.gateway || addMutation.isPending} className="btn-primary flex items-center gap-2 text-sm">
<button onClick={() => addMutation.mutate(false)} disabled={!form.dst_address || !form.gateway || addMutation.isPending} className="btn-primary flex items-center gap-2 text-sm">
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add Route
</button>
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
@@ -206,7 +224,7 @@ function RoutesSubTab({ deviceId }: { deviceId: number }) {
<td className="px-4 py-2.5">
{!dynamic && routeId && (
<button
onClick={() => { if (confirm(`Remove route to ${r['dst-address']}?`)) deleteMutation.mutate(routeId); }}
onClick={() => { if (confirm(`Remove route to ${r['dst-address']}?`)) deleteMutation.mutate({ routeId }); }}
disabled={deleteMutation.isPending}
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
>
@@ -222,6 +240,16 @@ function RoutesSubTab({ deviceId }: { deviceId: number }) {
</table>
)}
</div>
{lockout && (
<LockoutVerdictDialog
verdict={lockout.verdict}
confirmPhrase={deviceName || 'confirm'}
pending={addMutation.isPending || deleteMutation.isPending}
onConfirm={lockout.retry}
onCancel={() => setLockout(null)}
/>
)}
</div>
);
}
@@ -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<SubTab>('Routes');
return (
@@ -706,7 +734,7 @@ export default function RoutingTab({ deviceId }: { deviceId: number }) {
</div>
{/* Sub-tab content */}
{activeSubTab === 'Routes' && <RoutesSubTab deviceId={deviceId} />}
{activeSubTab === 'Routes' && <RoutesSubTab deviceId={deviceId} deviceName={deviceName} />}
{activeSubTab === 'OSPF' && <OspfSubTab deviceId={deviceId} />}
{activeSubTab === 'BGP' && <BgpSubTab deviceId={deviceId} />}
{activeSubTab === 'Route Filters' && <RouteFiltersSubTab deviceId={deviceId} />}
@@ -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<string, string> & { '.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 }) {
</div>
</>
)}
{serviceError && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-600 dark:text-red-400">
{serviceError}
</div>
)}
{lockout && (
<LockoutVerdictDialog
verdict={lockout.verdict}
confirmPhrase={deviceName || 'confirm'}
pending={toggleSvc.isPending}
onConfirm={lockout.retry}
onCancel={() => setLockout(null)}
/>
)}
</div>
);
}
@@ -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) {
<button
onClick={() => {
if (confirm(`Remove ${ip.address} from ${ip.interface}?`)) {
removeIpMutation.mutate(ip['.id']);
removeIpMutation.mutate({ id: ip['.id'] });
}
}}
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
@@ -511,7 +531,7 @@ export default function SystemConfigTab({ deviceId, device }: Props) {
</select>
</div>
<button
onClick={() => addIpMutation.mutate()}
onClick={() => addIpMutation.mutate(false)}
disabled={!newIp.address || !newIp.interface || addIpMutation.isPending}
className="btn-primary flex items-center gap-2"
>
@@ -722,6 +742,16 @@ export default function SystemConfigTab({ deviceId, device }: Props) {
)}
</div>
</div>
{lockout && (
<LockoutVerdictDialog
verdict={lockout.verdict}
confirmPhrase={device.name}
pending={addIpMutation.isPending || removeIpMutation.isPending}
onConfirm={lockout.retry}
onCancel={() => setLockout(null)}
/>
)}
</div>
);
}
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, X, Check, AlertCircle, Activity, Cpu, Link2, Trash2, Plus, Network, Users, Wifi, ShieldCheck, AlertTriangle } from 'lucide-react';
import { devicesApi, metricsApi } from '../../services/api';
import { useCanWrite } from '../../hooks/useCanWrite';
import ChangeGuardDialog, { guardOutcomeMessage, type GuardResult } from '../ChangeGuardDialog';
import ChangeGuardDialog, { guardOutcomeMessage, LockoutVerdictDialog, lockoutVerdictOf, type GuardResult, type LockoutVerdict } from '../ChangeGuardDialog';
import type { SwitchPort, Vlan, TrafficPoint, PortMonitorData, PortClient } from '../../types';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
@@ -15,6 +15,7 @@ import clsx from 'clsx';
interface Props {
deviceId: number;
deviceName?: string;
autoOpenBridge?: string;
onBridgeOpened?: () => void;
}
@@ -586,7 +587,7 @@ function PortClientsCard({ deviceId, portName }: { deviceId: number; portName: s
);
}
export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOpened }: Props) {
export default function SwitchPortDiagram({ deviceId, deviceName, autoOpenBridge, onBridgeOpened }: Props) {
const queryClient = useQueryClient();
const canWrite = useCanWrite();
const [selectedPorts, setSelectedPorts] = useState<Set<string>>(new Set());
@@ -599,6 +600,8 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
const [editingBond, setEditingBond] = useState<SwitchPort | null>(null);
const [confirmDestroyBond, setConfirmDestroyBond] = useState<string | null>(null);
const [guardNotice, setGuardNotice] = useState<{ tone: 'ok' | 'warn'; text: string } | null>(null);
// Predicted lockout (409 + verdict) and the retry that re-sends with confirm_lockout.
const [lockout, setLockout] = useState<{ verdict: LockoutVerdict; retry: () => void } | null>(null);
const [editForm, setEditForm] = useState<PortEditForm>({
disabled: false,
comment: '',
@@ -655,6 +658,7 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
if (bridgePort) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setBridgeError('');
setLockout(null);
setEditingBridge(bridgePort);
onBridgeOpened?.();
}
@@ -674,27 +678,39 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
});
const updateVlanMutation = useMutation({
mutationFn: ({ name, data }: { name: string; data: { pvid?: number; tagged_vlans?: number[]; untagged_vlans?: number[] } }) =>
devicesApi.configurePortVlan(deviceId, name, data),
mutationFn: ({ name, data, confirm }: { name: string; data: { pvid?: number; tagged_vlans?: number[]; untagged_vlans?: number[] }; confirm?: boolean }) =>
devicesApi.configurePortVlan(deviceId, name, confirm ? { ...data, confirm_lockout: true } : data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
setLockout(null);
},
onError: (err: unknown) => {
onError: (err: unknown, vars) => {
const verdict = lockoutVerdictOf(err);
if (verdict) {
setLockout({ verdict, retry: () => updateVlanMutation.mutate({ ...vars, confirm: true }) });
return;
}
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setSaveError(msg || 'Failed to apply VLAN config');
},
});
const createBondMutation = useMutation({
mutationFn: (d: { name: string; mode: string; slaves: string[]; lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number }) =>
mutationFn: (d: { name: string; mode: string; slaves: string[]; lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number; confirm_lockout?: boolean }) =>
devicesApi.createBond(deviceId, d),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
setShowCreateTrunkModal(false);
setSelectedPorts(new Set());
setBondError('');
setLockout(null);
},
onError: (err: unknown) => {
onError: (err: unknown, vars) => {
const verdict = lockoutVerdictOf(err);
if (verdict) {
setLockout({ verdict, retry: () => createBondMutation.mutate({ ...vars, confirm_lockout: true }) });
return;
}
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setBondError(msg || 'Failed to create bond');
},
@@ -707,6 +723,7 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
setEditingBond(null);
setBondError('');
setLockout(null);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
@@ -715,12 +732,14 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
});
const deleteBondMutation = useMutation({
mutationFn: (name: string) => devicesApi.deleteBond(deviceId, name),
mutationFn: ({ name, confirm = false }: { name: string; confirm?: boolean }) =>
devicesApi.deleteBond(deviceId, name, confirm),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
setEditingBond(null);
setConfirmDestroyBond(null);
setBondError('');
setLockout(null);
// Report what the safety net did — especially "the device is restoring itself".
const notice = guardOutcomeMessage((res?.data as { guard?: GuardResult })?.guard);
if (notice) {
@@ -728,25 +747,39 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
if (notice.tone === 'ok') setTimeout(() => setGuardNotice(null), 6000);
}
},
onError: (err: unknown) => {
onError: (err: unknown, vars) => {
const verdict = lockoutVerdictOf(err);
if (verdict) {
setLockout({ verdict, retry: () => deleteBondMutation.mutate({ name: vars.name, confirm: true }) });
return;
}
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setBondError(msg || 'Failed to delete bond');
},
});
const setBridgeVlanFilteringMutation = useMutation({
mutationFn: ({ name, enabled }: { name: string; enabled: boolean }) =>
devicesApi.setBridgeVlanFiltering(deviceId, name, enabled),
mutationFn: ({ name, enabled, confirm = false }: { name: string; enabled: boolean; confirm?: boolean }) =>
devicesApi.setBridgeVlanFiltering(deviceId, name, enabled, confirm),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
setBridgeError('');
setLockout(null);
// Update editingBridge locally so the toggle reflects the new state immediately
setEditingBridge((b) => b ? {
...b,
config_json: { ...(b.config_json ?? {}), 'vlan-filtering': setBridgeVlanFilteringMutation.variables?.enabled ? 'true' : 'false' },
} : null);
},
onError: (err: unknown) => {
onError: (err: unknown, vars) => {
const verdict = lockoutVerdictOf(err);
if (verdict) {
setLockout({
verdict,
retry: () => setBridgeVlanFilteringMutation.mutate({ ...vars, confirm: true }),
});
return;
}
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setBridgeError(msg || 'Failed to update VLAN filtering');
},
@@ -802,6 +835,7 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
const openEditPanel = (port: SwitchPort) => {
if (isBridge(port)) {
setBridgeError('');
setLockout(null);
setEditingBridge(port);
return;
}
@@ -816,6 +850,7 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
members: slaves,
});
setBondError('');
setLockout(null);
setEditingBond(port);
return;
}
@@ -993,6 +1028,7 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
onClick={() => {
setCreateTrunkForm({ name: `bond${bondPorts.length + 1}`, mode: '802.3ad', lacp_rate: 'slow', transmit_hash_policy: '', mtu: '', min_links: '' });
setBondError('');
setLockout(null);
setShowCreateTrunkModal(true);
}}
>
@@ -1923,11 +1959,24 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
}
confirmLabel="Destroy bond"
pending={deleteBondMutation.isPending}
onConfirm={() => deleteBondMutation.mutate(confirmDestroyBond)}
onConfirm={() => deleteBondMutation.mutate({ name: confirmDestroyBond })}
onCancel={() => setConfirmDestroyBond(null)}
/>
)}
{lockout && (
<LockoutVerdictDialog
verdict={lockout.verdict}
confirmPhrase={deviceName || 'confirm'}
pending={
updateVlanMutation.isPending || createBondMutation.isPending
|| deleteBondMutation.isPending || setBridgeVlanFilteringMutation.isPending
}
onConfirm={lockout.retry}
onCancel={() => setLockout(null)}
/>
)}
{guardNotice && (
<div
className={clsx(
+3 -2
View File
@@ -370,6 +370,7 @@ export default function DeviceDetailPage() {
{activeTab === 'ports' && (
<SwitchPortDiagram
deviceId={deviceId}
deviceName={device?.name}
autoOpenBridge={autoOpenBridge ?? undefined}
onBridgeOpened={() => setAutoOpenBridge(null)}
/>
@@ -382,9 +383,9 @@ export default function DeviceDetailPage() {
onGoToPorts={(bridgeName) => { setAutoOpenBridge(bridgeName); setActiveTab('ports'); }}
/>
)}
{activeTab === 'routing' && <RoutingTab deviceId={deviceId} />}
{activeTab === 'routing' && <RoutingTab deviceId={deviceId} deviceName={device.name} />}
{activeTab === 'firewall' && <FirewallTab deviceId={deviceId} />}
{activeTab === 'security' && <SecurityTab deviceId={deviceId} />}
{activeTab === 'security' && <SecurityTab deviceId={deviceId} deviceName={device.name} />}
{activeTab === 'queues' && <QueuesTab deviceId={deviceId} />}
{activeTab === 'connections' && <ConnectionsTab deviceId={deviceId} />}
{activeTab === 'config' && <SystemConfigTab deviceId={deviceId} device={device} />}
+19 -13
View File
@@ -217,7 +217,7 @@ export const devicesApi = {
deleteFirewallRule: (id: number, ruleId: string) =>
api.delete(`/devices/${id}/firewall/${encodeURIComponent(ruleId)}`),
getResources: (id: number) => api.get<Record<string, string>>(`/devices/${id}/resources`),
configurePortVlan: (id: number, name: string, data: PortVlanConfig) =>
configurePortVlan: (id: number, name: string, data: PortVlanConfig & { confirm_lockout?: boolean }) =>
api.put(`/devices/${id}/ports/${encodeURIComponent(name)}/vlan`, data),
getSystemConfig: (id: number) => api.get<SystemConfig>(`/devices/${id}/system-config`),
updateSystemConfig: (id: number, data: Partial<{
@@ -229,10 +229,11 @@ export const devicesApi = {
dns_allow_remote: boolean;
}>) => api.put(`/devices/${id}/system-config`, data),
getIpAddresses: (id: number) => api.get<IpAddress[]>(`/devices/${id}/ip-addresses`),
addIpAddress: (id: number, data: { address: string; interface: string }) =>
addIpAddress: (id: number, data: { address: string; interface: string; confirm_lockout?: boolean }) =>
api.post<IpAddress[]>(`/devices/${id}/ip-addresses`, data),
removeIpAddress: (id: number, addrId: string) =>
api.delete(`/devices/${id}/ip-addresses/${encodeURIComponent(addrId)}`),
removeIpAddress: (id: number, addrId: string, confirmLockout = false) =>
api.delete(`/devices/${id}/ip-addresses/${encodeURIComponent(addrId)}`,
confirmLockout ? { data: { confirm_lockout: true } } : undefined),
checkUpdate: (id: number) =>
api.post<Record<string, string>>(`/devices/${id}/check-update`),
checkRouterboard: (id: number) =>
@@ -244,10 +245,11 @@ export const devicesApi = {
getClock: (id: number) => api.get<{ date: string; time: string; timezone: string }>(`/devices/${id}/clock`),
setClock: (id: number, data: { date?: string; time?: string; timezone?: string }) =>
api.put(`/devices/${id}/clock`, data),
addRoute: (id: number, data: { dst_address: string; gateway: string; distance?: number; comment?: string }) =>
addRoute: (id: number, data: { dst_address: string; gateway: string; distance?: number; comment?: string; confirm_lockout?: boolean }) =>
api.post(`/devices/${id}/routing`, data),
deleteRoute: (id: number, routeId: string) =>
api.delete(`/devices/${id}/routing/${encodeURIComponent(routeId)}`),
deleteRoute: (id: number, routeId: string, confirmLockout = false) =>
api.delete(`/devices/${id}/routing/${encodeURIComponent(routeId)}`,
confirmLockout ? { data: { confirm_lockout: true } } : undefined),
// 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 }) =>
@@ -266,15 +268,18 @@ export const devicesApi = {
createBond: (id: number, data: {
name: string; mode: string; slaves: string[];
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
confirm_lockout?: boolean;
}) => api.post(`/devices/${id}/bonds`, data),
updateBond: (id: number, bondName: string, data: {
mode: string; slaves: string[];
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
}) => api.put(`/devices/${id}/bonds/${encodeURIComponent(bondName)}`, data),
deleteBond: (id: number, bondName: string) =>
api.delete(`/devices/${id}/bonds/${encodeURIComponent(bondName)}`),
setBridgeVlanFiltering: (id: number, bridgeName: string, enabled: boolean) =>
api.put(`/devices/${id}/bridge/${encodeURIComponent(bridgeName)}/vlan-filtering`, { enabled }),
deleteBond: (id: number, bondName: string, confirmLockout = false) =>
api.delete(`/devices/${id}/bonds/${encodeURIComponent(bondName)}`,
confirmLockout ? { data: { confirm_lockout: true } } : undefined),
setBridgeVlanFiltering: (id: number, bridgeName: string, enabled: boolean, confirmLockout = false) =>
api.put(`/devices/${id}/bridge/${encodeURIComponent(bridgeName)}/vlan-filtering`,
{ enabled, ...(confirmLockout ? { confirm_lockout: true } : {}) }),
getNat: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/nat`),
addNatRule: (id: number, data: Record<string, unknown>) =>
api.post<Record<string, string>[]>(`/devices/${id}/nat`, data),
@@ -310,8 +315,9 @@ export const devicesApi = {
api.delete(`/devices/${id}/queues/${encodeURIComponent(queueId)}`),
// IP services + security posture
getServices: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/services`),
setServiceDisabled: (id: number, serviceId: string, disabled: boolean) =>
api.put<Record<string, string>[]>(`/devices/${id}/services/${encodeURIComponent(serviceId)}`, { disabled }),
setServiceDisabled: (id: number, serviceId: string, disabled: boolean, confirmLockout = false) =>
api.put<Record<string, string>[]>(`/devices/${id}/services/${encodeURIComponent(serviceId)}`,
{ disabled, ...(confirmLockout ? { confirm_lockout: true } : {}) }),
getSecurityPosture: (id: number) =>
api.get<{ score: number; checks: SecurityCheck[] }>(`/devices/${id}/security-posture`),
patchLocation: (id: number, data: {
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.19.1 Beta';
export const APP_VERSION = 'v0.19.2 Beta';