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:
co-authored by
Claude Opus 4.8
parent
118dd9ee8f
commit
d67698bac9
@@ -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.
|
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.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.19.1-beta",
|
"version": "0.19.2-beta",
|
||||||
"description": "Mikrotik Network Management Platform - Backend API",
|
"description": "Mikrotik Network Management Platform - Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.19.1-beta",
|
"version": "0.19.2-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { LockoutVerdictDialog, lockoutVerdictOf, type LockoutVerdict } from '../ChangeGuardDialog';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Trash2, AlertCircle, RefreshCw } from 'lucide-react';
|
import { Plus, Trash2, AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
import { devicesApi } from '../../services/api';
|
import { devicesApi } from '../../services/api';
|
||||||
@@ -77,7 +78,7 @@ function SectionHeader({ title, onRefresh, isFetching }: { title: string; onRefr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Routes sub-tab (original) ────────────────────────────────────────────────
|
// ─── Routes sub-tab (original) ────────────────────────────────────────────────
|
||||||
function RoutesSubTab({ deviceId }: { deviceId: number }) {
|
function RoutesSubTab({ deviceId, deviceName }: { deviceId: number; deviceName?: string }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const canWrite = useCanWrite();
|
const canWrite = useCanWrite();
|
||||||
|
|
||||||
@@ -90,30 +91,47 @@ function RoutesSubTab({ deviceId }: { deviceId: number }) {
|
|||||||
const [form, setForm] = useState({ dst_address: '', gateway: '', distance: '1', comment: '' });
|
const [form, setForm] = useState({ dst_address: '', gateway: '', distance: '1', comment: '' });
|
||||||
const [addError, setAddError] = useState('');
|
const [addError, setAddError] = useState('');
|
||||||
const [deleteError, setDeleteError] = 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({
|
const addMutation = useMutation({
|
||||||
mutationFn: () => devicesApi.addRoute(deviceId, {
|
mutationFn: (confirm: boolean) => devicesApi.addRoute(deviceId, {
|
||||||
dst_address: form.dst_address,
|
dst_address: form.dst_address,
|
||||||
gateway: form.gateway,
|
gateway: form.gateway,
|
||||||
distance: form.distance ? parseInt(form.distance, 10) : undefined,
|
distance: form.distance ? parseInt(form.distance, 10) : undefined,
|
||||||
comment: form.comment || undefined,
|
comment: form.comment || undefined,
|
||||||
|
...(confirm ? { confirm_lockout: true } : {}),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['routing', deviceId] });
|
queryClient.invalidateQueries({ queryKey: ['routing', deviceId] });
|
||||||
setForm({ dst_address: '', gateway: '', distance: '1', comment: '' });
|
setForm({ dst_address: '', gateway: '', distance: '1', comment: '' });
|
||||||
setShowAdd(false);
|
setShowAdd(false);
|
||||||
setAddError('');
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (routeId: string) => devicesApi.deleteRoute(deviceId, routeId),
|
mutationFn: ({ routeId, confirm = false }: { routeId: string; confirm?: boolean }) =>
|
||||||
|
devicesApi.deleteRoute(deviceId, routeId, confirm),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['routing', deviceId] });
|
queryClient.invalidateQueries({ queryKey: ['routing', deviceId] });
|
||||||
setDeleteError('');
|
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>) =>
|
const isDynamic = (r: Record<string, string>) =>
|
||||||
@@ -158,7 +176,7 @@ function RoutesSubTab({ deviceId }: { deviceId: number }) {
|
|||||||
</div>
|
</div>
|
||||||
{addError && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{addError}</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">
|
<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
|
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add Route
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</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">
|
<td className="px-4 py-2.5">
|
||||||
{!dynamic && routeId && (
|
{!dynamic && routeId && (
|
||||||
<button
|
<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}
|
disabled={deleteMutation.isPending}
|
||||||
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -222,6 +240,16 @@ function RoutesSubTab({ deviceId }: { deviceId: number }) {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{lockout && (
|
||||||
|
<LockoutVerdictDialog
|
||||||
|
verdict={lockout.verdict}
|
||||||
|
confirmPhrase={deviceName || 'confirm'}
|
||||||
|
pending={addMutation.isPending || deleteMutation.isPending}
|
||||||
|
onConfirm={lockout.retry}
|
||||||
|
onCancel={() => setLockout(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -682,7 +710,7 @@ function TablesSubTab({ deviceId }: { deviceId: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main RoutingTab with sub-tabs ────────────────────────────────────────────
|
// ─── 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');
|
const [activeSubTab, setActiveSubTab] = useState<SubTab>('Routes');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -706,7 +734,7 @@ export default function RoutingTab({ deviceId }: { deviceId: number }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub-tab content */}
|
{/* Sub-tab content */}
|
||||||
{activeSubTab === 'Routes' && <RoutesSubTab deviceId={deviceId} />}
|
{activeSubTab === 'Routes' && <RoutesSubTab deviceId={deviceId} deviceName={deviceName} />}
|
||||||
{activeSubTab === 'OSPF' && <OspfSubTab deviceId={deviceId} />}
|
{activeSubTab === 'OSPF' && <OspfSubTab deviceId={deviceId} />}
|
||||||
{activeSubTab === 'BGP' && <BgpSubTab deviceId={deviceId} />}
|
{activeSubTab === 'BGP' && <BgpSubTab deviceId={deviceId} />}
|
||||||
{activeSubTab === 'Route Filters' && <RouteFiltersSubTab 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ShieldCheck, ShieldAlert, RefreshCw, Check, AlertTriangle, Lock } from 'lucide-react';
|
import { ShieldCheck, ShieldAlert, RefreshCw, Check, AlertTriangle, Lock } from 'lucide-react';
|
||||||
import { devicesApi } from '../../services/api';
|
import { devicesApi } from '../../services/api';
|
||||||
import type { SecurityCheck } from '../../services/api';
|
import type { SecurityCheck } from '../../services/api';
|
||||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||||
|
import { LockoutVerdictDialog, lockoutVerdictOf, type LockoutVerdict } from '../ChangeGuardDialog';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
type Row = Record<string, string> & { '.id': string };
|
type Row = Record<string, string> & { '.id': string };
|
||||||
@@ -20,9 +22,11 @@ function scoreColor(score: number) {
|
|||||||
return 'text-red-600 dark:text-red-400';
|
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 qc = useQueryClient();
|
||||||
const canWrite = useCanWrite();
|
const canWrite = useCanWrite();
|
||||||
|
const [serviceError, setServiceError] = useState('');
|
||||||
|
const [lockout, setLockout] = useState<{ verdict: LockoutVerdict; retry: () => void } | null>(null);
|
||||||
|
|
||||||
const { data: posture, isLoading, refetch, isFetching } = useQuery({
|
const { data: posture, isLoading, refetch, isFetching } = useQuery({
|
||||||
queryKey: ['security-posture', deviceId],
|
queryKey: ['security-posture', deviceId],
|
||||||
@@ -47,8 +51,23 @@ export default function SecurityTab({ deviceId }: { deviceId: number }) {
|
|||||||
qc.invalidateQueries({ queryKey: ['services', deviceId] });
|
qc.invalidateQueries({ queryKey: ['services', deviceId] });
|
||||||
};
|
};
|
||||||
const toggleSvc = useMutation({
|
const toggleSvc = useMutation({
|
||||||
mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) => devicesApi.setServiceDisabled(deviceId, id, disabled),
|
mutationFn: ({ id, disabled, confirm = false }: { id: string; disabled: boolean; confirm?: boolean }) =>
|
||||||
onSuccess: invalidate,
|
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 ?? [];
|
const checks: SecurityCheck[] = posture?.checks ?? [];
|
||||||
@@ -164,6 +183,22 @@ export default function SecurityTab({ deviceId }: { deviceId: number }) {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server, Cpu, FileText,
|
RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server, Cpu, FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { devicesApi } from '../../services/api';
|
import { devicesApi } from '../../services/api';
|
||||||
|
import { LockoutVerdictDialog, lockoutVerdictOf, type LockoutVerdict } from '../ChangeGuardDialog';
|
||||||
import type { Device, IpAddress, Interface } from '../../types';
|
import type { Device, IpAddress, Interface } from '../../types';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||||
@@ -98,23 +99,42 @@ export default function SystemConfigTab({ deviceId, device }: Props) {
|
|||||||
|
|
||||||
const [newIp, setNewIp] = useState({ address: '', interface: '' });
|
const [newIp, setNewIp] = useState({ address: '', interface: '' });
|
||||||
const [ipError, setIpError] = useState('');
|
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({
|
const addIpMutation = useMutation({
|
||||||
mutationFn: () => devicesApi.addIpAddress(deviceId, newIp),
|
mutationFn: (confirm: boolean) =>
|
||||||
|
devicesApi.addIpAddress(deviceId, confirm ? { ...newIp, confirm_lockout: true } : newIp),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] });
|
queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] });
|
||||||
setNewIp({ address: '', interface: '' });
|
setNewIp({ address: '', interface: '' });
|
||||||
setIpError('');
|
setIpError('');
|
||||||
|
setLockout(null);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
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;
|
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||||
setIpError(msg || 'Failed to add IP address');
|
setIpError(msg || 'Failed to add IP address');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const removeIpMutation = useMutation({
|
const removeIpMutation = useMutation({
|
||||||
mutationFn: (id: string) => devicesApi.removeIpAddress(deviceId, id),
|
mutationFn: ({ id, confirm = false }: { id: string; confirm?: boolean }) =>
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] }),
|
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 ─────────────────────────────────────
|
// ─── RouterOS update check / install ─────────────────────────────────────
|
||||||
@@ -471,7 +491,7 @@ export default function SystemConfigTab({ deviceId, device }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(`Remove ${ip.address} from ${ip.interface}?`)) {
|
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"
|
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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => addIpMutation.mutate()}
|
onClick={() => addIpMutation.mutate(false)}
|
||||||
disabled={!newIp.address || !newIp.interface || addIpMutation.isPending}
|
disabled={!newIp.address || !newIp.interface || addIpMutation.isPending}
|
||||||
className="btn-primary flex items-center gap-2"
|
className="btn-primary flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -722,6 +742,16 @@ export default function SystemConfigTab({ deviceId, device }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{lockout && (
|
||||||
|
<LockoutVerdictDialog
|
||||||
|
verdict={lockout.verdict}
|
||||||
|
confirmPhrase={device.name}
|
||||||
|
pending={addIpMutation.isPending || removeIpMutation.isPending}
|
||||||
|
onConfirm={lockout.retry}
|
||||||
|
onCancel={() => setLockout(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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 { RefreshCw, X, Check, AlertCircle, Activity, Cpu, Link2, Trash2, Plus, Network, Users, Wifi, ShieldCheck, AlertTriangle } from 'lucide-react';
|
||||||
import { devicesApi, metricsApi } from '../../services/api';
|
import { devicesApi, metricsApi } from '../../services/api';
|
||||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
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 type { SwitchPort, Vlan, TrafficPoint, PortMonitorData, PortClient } from '../../types';
|
||||||
import {
|
import {
|
||||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
@@ -15,6 +15,7 @@ import clsx from 'clsx';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
deviceId: number;
|
deviceId: number;
|
||||||
|
deviceName?: string;
|
||||||
autoOpenBridge?: string;
|
autoOpenBridge?: string;
|
||||||
onBridgeOpened?: () => void;
|
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 queryClient = useQueryClient();
|
||||||
const canWrite = useCanWrite();
|
const canWrite = useCanWrite();
|
||||||
const [selectedPorts, setSelectedPorts] = useState<Set<string>>(new Set());
|
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 [editingBond, setEditingBond] = useState<SwitchPort | null>(null);
|
||||||
const [confirmDestroyBond, setConfirmDestroyBond] = useState<string | null>(null);
|
const [confirmDestroyBond, setConfirmDestroyBond] = useState<string | null>(null);
|
||||||
const [guardNotice, setGuardNotice] = useState<{ tone: 'ok' | 'warn'; text: 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>({
|
const [editForm, setEditForm] = useState<PortEditForm>({
|
||||||
disabled: false,
|
disabled: false,
|
||||||
comment: '',
|
comment: '',
|
||||||
@@ -655,6 +658,7 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
|
|||||||
if (bridgePort) {
|
if (bridgePort) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setBridgeError('');
|
setBridgeError('');
|
||||||
|
setLockout(null);
|
||||||
setEditingBridge(bridgePort);
|
setEditingBridge(bridgePort);
|
||||||
onBridgeOpened?.();
|
onBridgeOpened?.();
|
||||||
}
|
}
|
||||||
@@ -674,27 +678,39 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateVlanMutation = useMutation({
|
const updateVlanMutation = useMutation({
|
||||||
mutationFn: ({ name, data }: { name: string; data: { pvid?: number; tagged_vlans?: number[]; untagged_vlans?: number[] } }) =>
|
mutationFn: ({ name, data, confirm }: { name: string; data: { pvid?: number; tagged_vlans?: number[]; untagged_vlans?: number[] }; confirm?: boolean }) =>
|
||||||
devicesApi.configurePortVlan(deviceId, name, data),
|
devicesApi.configurePortVlan(deviceId, name, confirm ? { ...data, confirm_lockout: true } : data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
|
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;
|
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||||
setSaveError(msg || 'Failed to apply VLAN config');
|
setSaveError(msg || 'Failed to apply VLAN config');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createBondMutation = useMutation({
|
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),
|
devicesApi.createBond(deviceId, d),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
|
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
|
||||||
setShowCreateTrunkModal(false);
|
setShowCreateTrunkModal(false);
|
||||||
setSelectedPorts(new Set());
|
setSelectedPorts(new Set());
|
||||||
setBondError('');
|
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;
|
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||||
setBondError(msg || 'Failed to create bond');
|
setBondError(msg || 'Failed to create bond');
|
||||||
},
|
},
|
||||||
@@ -707,6 +723,7 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
|
|||||||
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
|
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
|
||||||
setEditingBond(null);
|
setEditingBond(null);
|
||||||
setBondError('');
|
setBondError('');
|
||||||
|
setLockout(null);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
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({
|
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) => {
|
onSuccess: (res) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
|
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
|
||||||
setEditingBond(null);
|
setEditingBond(null);
|
||||||
setConfirmDestroyBond(null);
|
setConfirmDestroyBond(null);
|
||||||
setBondError('');
|
setBondError('');
|
||||||
|
setLockout(null);
|
||||||
// Report what the safety net did — especially "the device is restoring itself".
|
// Report what the safety net did — especially "the device is restoring itself".
|
||||||
const notice = guardOutcomeMessage((res?.data as { guard?: GuardResult })?.guard);
|
const notice = guardOutcomeMessage((res?.data as { guard?: GuardResult })?.guard);
|
||||||
if (notice) {
|
if (notice) {
|
||||||
@@ -728,25 +747,39 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
|
|||||||
if (notice.tone === 'ok') setTimeout(() => setGuardNotice(null), 6000);
|
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;
|
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||||
setBondError(msg || 'Failed to delete bond');
|
setBondError(msg || 'Failed to delete bond');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const setBridgeVlanFilteringMutation = useMutation({
|
const setBridgeVlanFilteringMutation = useMutation({
|
||||||
mutationFn: ({ name, enabled }: { name: string; enabled: boolean }) =>
|
mutationFn: ({ name, enabled, confirm = false }: { name: string; enabled: boolean; confirm?: boolean }) =>
|
||||||
devicesApi.setBridgeVlanFiltering(deviceId, name, enabled),
|
devicesApi.setBridgeVlanFiltering(deviceId, name, enabled, confirm),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
|
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
|
||||||
setBridgeError('');
|
setBridgeError('');
|
||||||
|
setLockout(null);
|
||||||
// Update editingBridge locally so the toggle reflects the new state immediately
|
// Update editingBridge locally so the toggle reflects the new state immediately
|
||||||
setEditingBridge((b) => b ? {
|
setEditingBridge((b) => b ? {
|
||||||
...b,
|
...b,
|
||||||
config_json: { ...(b.config_json ?? {}), 'vlan-filtering': setBridgeVlanFilteringMutation.variables?.enabled ? 'true' : 'false' },
|
config_json: { ...(b.config_json ?? {}), 'vlan-filtering': setBridgeVlanFilteringMutation.variables?.enabled ? 'true' : 'false' },
|
||||||
} : null);
|
} : 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;
|
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||||
setBridgeError(msg || 'Failed to update VLAN filtering');
|
setBridgeError(msg || 'Failed to update VLAN filtering');
|
||||||
},
|
},
|
||||||
@@ -802,6 +835,7 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
|
|||||||
const openEditPanel = (port: SwitchPort) => {
|
const openEditPanel = (port: SwitchPort) => {
|
||||||
if (isBridge(port)) {
|
if (isBridge(port)) {
|
||||||
setBridgeError('');
|
setBridgeError('');
|
||||||
|
setLockout(null);
|
||||||
setEditingBridge(port);
|
setEditingBridge(port);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -816,6 +850,7 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
|
|||||||
members: slaves,
|
members: slaves,
|
||||||
});
|
});
|
||||||
setBondError('');
|
setBondError('');
|
||||||
|
setLockout(null);
|
||||||
setEditingBond(port);
|
setEditingBond(port);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -993,6 +1028,7 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCreateTrunkForm({ name: `bond${bondPorts.length + 1}`, mode: '802.3ad', lacp_rate: 'slow', transmit_hash_policy: '', mtu: '', min_links: '' });
|
setCreateTrunkForm({ name: `bond${bondPorts.length + 1}`, mode: '802.3ad', lacp_rate: 'slow', transmit_hash_policy: '', mtu: '', min_links: '' });
|
||||||
setBondError('');
|
setBondError('');
|
||||||
|
setLockout(null);
|
||||||
setShowCreateTrunkModal(true);
|
setShowCreateTrunkModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1923,11 +1959,24 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
|
|||||||
}
|
}
|
||||||
confirmLabel="Destroy bond"
|
confirmLabel="Destroy bond"
|
||||||
pending={deleteBondMutation.isPending}
|
pending={deleteBondMutation.isPending}
|
||||||
onConfirm={() => deleteBondMutation.mutate(confirmDestroyBond)}
|
onConfirm={() => deleteBondMutation.mutate({ name: confirmDestroyBond })}
|
||||||
onCancel={() => setConfirmDestroyBond(null)}
|
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 && (
|
{guardNotice && (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ export default function DeviceDetailPage() {
|
|||||||
{activeTab === 'ports' && (
|
{activeTab === 'ports' && (
|
||||||
<SwitchPortDiagram
|
<SwitchPortDiagram
|
||||||
deviceId={deviceId}
|
deviceId={deviceId}
|
||||||
|
deviceName={device?.name}
|
||||||
autoOpenBridge={autoOpenBridge ?? undefined}
|
autoOpenBridge={autoOpenBridge ?? undefined}
|
||||||
onBridgeOpened={() => setAutoOpenBridge(null)}
|
onBridgeOpened={() => setAutoOpenBridge(null)}
|
||||||
/>
|
/>
|
||||||
@@ -382,9 +383,9 @@ export default function DeviceDetailPage() {
|
|||||||
onGoToPorts={(bridgeName) => { setAutoOpenBridge(bridgeName); setActiveTab('ports'); }}
|
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 === 'firewall' && <FirewallTab deviceId={deviceId} />}
|
||||||
{activeTab === 'security' && <SecurityTab deviceId={deviceId} />}
|
{activeTab === 'security' && <SecurityTab deviceId={deviceId} deviceName={device.name} />}
|
||||||
{activeTab === 'queues' && <QueuesTab deviceId={deviceId} />}
|
{activeTab === 'queues' && <QueuesTab deviceId={deviceId} />}
|
||||||
{activeTab === 'connections' && <ConnectionsTab deviceId={deviceId} />}
|
{activeTab === 'connections' && <ConnectionsTab deviceId={deviceId} />}
|
||||||
{activeTab === 'config' && <SystemConfigTab deviceId={deviceId} device={device} />}
|
{activeTab === 'config' && <SystemConfigTab deviceId={deviceId} device={device} />}
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export const devicesApi = {
|
|||||||
deleteFirewallRule: (id: number, ruleId: string) =>
|
deleteFirewallRule: (id: number, ruleId: string) =>
|
||||||
api.delete(`/devices/${id}/firewall/${encodeURIComponent(ruleId)}`),
|
api.delete(`/devices/${id}/firewall/${encodeURIComponent(ruleId)}`),
|
||||||
getResources: (id: number) => api.get<Record<string, string>>(`/devices/${id}/resources`),
|
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),
|
api.put(`/devices/${id}/ports/${encodeURIComponent(name)}/vlan`, data),
|
||||||
getSystemConfig: (id: number) => api.get<SystemConfig>(`/devices/${id}/system-config`),
|
getSystemConfig: (id: number) => api.get<SystemConfig>(`/devices/${id}/system-config`),
|
||||||
updateSystemConfig: (id: number, data: Partial<{
|
updateSystemConfig: (id: number, data: Partial<{
|
||||||
@@ -229,10 +229,11 @@ export const devicesApi = {
|
|||||||
dns_allow_remote: boolean;
|
dns_allow_remote: boolean;
|
||||||
}>) => api.put(`/devices/${id}/system-config`, data),
|
}>) => api.put(`/devices/${id}/system-config`, data),
|
||||||
getIpAddresses: (id: number) => api.get<IpAddress[]>(`/devices/${id}/ip-addresses`),
|
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),
|
api.post<IpAddress[]>(`/devices/${id}/ip-addresses`, data),
|
||||||
removeIpAddress: (id: number, addrId: string) =>
|
removeIpAddress: (id: number, addrId: string, confirmLockout = false) =>
|
||||||
api.delete(`/devices/${id}/ip-addresses/${encodeURIComponent(addrId)}`),
|
api.delete(`/devices/${id}/ip-addresses/${encodeURIComponent(addrId)}`,
|
||||||
|
confirmLockout ? { data: { confirm_lockout: true } } : undefined),
|
||||||
checkUpdate: (id: number) =>
|
checkUpdate: (id: number) =>
|
||||||
api.post<Record<string, string>>(`/devices/${id}/check-update`),
|
api.post<Record<string, string>>(`/devices/${id}/check-update`),
|
||||||
checkRouterboard: (id: number) =>
|
checkRouterboard: (id: number) =>
|
||||||
@@ -244,10 +245,11 @@ export const devicesApi = {
|
|||||||
getClock: (id: number) => api.get<{ date: string; time: string; timezone: string }>(`/devices/${id}/clock`),
|
getClock: (id: number) => api.get<{ date: string; time: string; timezone: string }>(`/devices/${id}/clock`),
|
||||||
setClock: (id: number, data: { date?: string; time?: string; timezone?: string }) =>
|
setClock: (id: number, data: { date?: string; time?: string; timezone?: string }) =>
|
||||||
api.put(`/devices/${id}/clock`, data),
|
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),
|
api.post(`/devices/${id}/routing`, data),
|
||||||
deleteRoute: (id: number, routeId: string) =>
|
deleteRoute: (id: number, routeId: string, confirmLockout = false) =>
|
||||||
api.delete(`/devices/${id}/routing/${encodeURIComponent(routeId)}`),
|
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
|
// confirm_lockout overrides a predicted-lockout refusal (HTTP 409) after the user
|
||||||
// has seen and accepted the verdict.
|
// has seen and accepted the verdict.
|
||||||
addVlan: (id: number, data: { bridge: string; vlan_id: number; tagged_ports?: string[]; untagged_ports?: string[]; confirm_lockout?: boolean }) =>
|
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: {
|
createBond: (id: number, data: {
|
||||||
name: string; mode: string; slaves: string[];
|
name: string; mode: string; slaves: string[];
|
||||||
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
|
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
|
||||||
|
confirm_lockout?: boolean;
|
||||||
}) => api.post(`/devices/${id}/bonds`, data),
|
}) => api.post(`/devices/${id}/bonds`, data),
|
||||||
updateBond: (id: number, bondName: string, data: {
|
updateBond: (id: number, bondName: string, data: {
|
||||||
mode: string; slaves: string[];
|
mode: string; slaves: string[];
|
||||||
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
|
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
|
||||||
}) => api.put(`/devices/${id}/bonds/${encodeURIComponent(bondName)}`, data),
|
}) => api.put(`/devices/${id}/bonds/${encodeURIComponent(bondName)}`, data),
|
||||||
deleteBond: (id: number, bondName: string) =>
|
deleteBond: (id: number, bondName: string, confirmLockout = false) =>
|
||||||
api.delete(`/devices/${id}/bonds/${encodeURIComponent(bondName)}`),
|
api.delete(`/devices/${id}/bonds/${encodeURIComponent(bondName)}`,
|
||||||
setBridgeVlanFiltering: (id: number, bridgeName: string, enabled: boolean) =>
|
confirmLockout ? { data: { confirm_lockout: true } } : undefined),
|
||||||
api.put(`/devices/${id}/bridge/${encodeURIComponent(bridgeName)}/vlan-filtering`, { enabled }),
|
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`),
|
getNat: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/nat`),
|
||||||
addNatRule: (id: number, data: Record<string, unknown>) =>
|
addNatRule: (id: number, data: Record<string, unknown>) =>
|
||||||
api.post<Record<string, string>[]>(`/devices/${id}/nat`, data),
|
api.post<Record<string, string>[]>(`/devices/${id}/nat`, data),
|
||||||
@@ -310,8 +315,9 @@ export const devicesApi = {
|
|||||||
api.delete(`/devices/${id}/queues/${encodeURIComponent(queueId)}`),
|
api.delete(`/devices/${id}/queues/${encodeURIComponent(queueId)}`),
|
||||||
// IP services + security posture
|
// IP services + security posture
|
||||||
getServices: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/services`),
|
getServices: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/services`),
|
||||||
setServiceDisabled: (id: number, serviceId: string, disabled: boolean) =>
|
setServiceDisabled: (id: number, serviceId: string, disabled: boolean, confirmLockout = false) =>
|
||||||
api.put<Record<string, string>[]>(`/devices/${id}/services/${encodeURIComponent(serviceId)}`, { disabled }),
|
api.put<Record<string, string>[]>(`/devices/${id}/services/${encodeURIComponent(serviceId)}`,
|
||||||
|
{ disabled, ...(confirmLockout ? { confirm_lockout: true } : {}) }),
|
||||||
getSecurityPosture: (id: number) =>
|
getSecurityPosture: (id: number) =>
|
||||||
api.get<{ score: number; checks: SecurityCheck[] }>(`/devices/${id}/security-posture`),
|
api.get<{ score: number; checks: SecurityCheck[] }>(`/devices/${id}/security-posture`),
|
||||||
patchLocation: (id: number, data: {
|
patchLocation: (id: number, data: {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = 'v0.19.1 Beta';
|
export const APP_VERSION = 'v0.19.2 Beta';
|
||||||
|
|||||||
Reference in New Issue
Block a user