From 57fecb80710955e3fd46bc81215732086c43ece3 Mon Sep 17 00:00:00 2001 From: anti Date: Thu, 30 Apr 2026 22:14:20 -0400 Subject: [PATCH] refactor(frontend): ApiError interface, tempIdSuffix rename, NET_GRID constants, extract onPaletteDrop handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApiError: defined once in utils/api.ts, replaces 9 ad-hoc anonymous casts across MazeNET, Inspector, DeckyFleet, SwarmHosts, Webhooks, PersonaGeneration, ServiceConfigFields, CanaryTokens. hex4 renamed to tempIdSuffix — the name now matches the comment that already explained its purpose. NET_GRID_{W,H,GAP,COLS} extracted from inline magic numbers to module-level constants in MazeNET.tsx. onPaletteDrop (130-line useCallback) split into three module-level handlers (_dropNetwork, _dropArchetype, _dropService); the callback becomes a 10-line router. --- decnet_web/src/components/CanaryTokens.tsx | 4 +- decnet_web/src/components/DeckyFleet.tsx | 12 +- .../src/components/MazeNET/Inspector.tsx | 9 +- decnet_web/src/components/MazeNET/MazeNET.tsx | 269 +++++++++--------- .../src/components/PersonaGeneration.tsx | 7 +- .../src/components/ServiceConfigFields.tsx | 4 +- decnet_web/src/components/SwarmHosts.tsx | 4 +- decnet_web/src/components/Webhooks.tsx | 7 +- decnet_web/src/utils/api.ts | 6 + 9 files changed, 169 insertions(+), 153 deletions(-) diff --git a/decnet_web/src/components/CanaryTokens.tsx b/decnet_web/src/components/CanaryTokens.tsx index 93528a3f..1f256efd 100644 --- a/decnet_web/src/components/CanaryTokens.tsx +++ b/decnet_web/src/components/CanaryTokens.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Plus, Upload, X, AlertTriangle, Search, Target, } from '../icons'; -import api from '../utils/api'; +import api, { type ApiError } from '../utils/api'; import { useEscapeKey } from '../hooks/useEscapeKey'; import { useFocusTrap } from '../hooks/useFocusTrap'; import CanaryTokenDrawer from './CanaryTokenDrawer'; @@ -32,7 +32,7 @@ const KIND_OPTIONS: Array<{ value: 'http' | 'dns' | 'aws_passive'; label: string ]; function extractError(err: unknown, fallback: string): string { - const e = err as { response?: { status?: number; data?: { detail?: string } } }; + const e = err as ApiError; if (e?.response?.data?.detail) return e.response.data.detail; if (e?.response?.status === 403) return 'Insufficient permissions (admin only).'; if (e?.response?.status === 401) return 'Session expired — please log in again.'; diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index 081e8b2b..18834467 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -4,7 +4,7 @@ import { RefreshCw, Server, Shield, Terminal, Plus, X, } from '../icons'; import { useEscapeKey } from '../hooks/useEscapeKey'; -import api from '../utils/api'; +import api, { type ApiError } from '../utils/api'; import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data'; import { useToast } from './Toasts/useToast'; import Modal from './Modal/Modal'; @@ -325,7 +325,7 @@ const DeckyCard: React.FC = ({ setTarpitMenuOpen(false); onTarpitResult(decky.name, true, `TARPIT ON · ${decky.name.toUpperCase()} · ${ports.join(',')} / ${tarpitDelayMs}ms`); } catch (err) { - const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Tarpit enable failed'; + const msg = (err as ApiError)?.response?.data?.detail ?? 'Tarpit enable failed'; onTarpitResult(decky.name, false, msg); } finally { setTarpitBusy(false); @@ -339,7 +339,7 @@ const DeckyCard: React.FC = ({ await api.delete(`/deckies/${encodeURIComponent(decky.name)}/tarpit`); onTarpitResult(decky.name, true, `TARPIT OFF · ${decky.name.toUpperCase()}`); } catch (err) { - const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Tarpit disable failed'; + const msg = (err as ApiError)?.response?.data?.detail ?? 'Tarpit disable failed'; onTarpitResult(decky.name, false, msg); } finally { setTarpitBusy(false); @@ -355,7 +355,7 @@ const DeckyCard: React.FC = ({ ); onServicesChanged(decky.name, data.services); } catch (err) { - const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail + const msg = (err as ApiError)?.response?.data?.detail ?? 'Remove failed.'; setOpError(msg); } finally { @@ -383,7 +383,7 @@ const DeckyCard: React.FC = ({ } catch (err) { // Re-raise so the modal can surface the error in its own status row. // Also mirror onto opError for the inline picker case. - const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail + const msg = (err as ApiError)?.response?.data?.detail ?? 'Add failed.'; setOpError(msg); throw err; @@ -1466,7 +1466,7 @@ const DeckyFleet: React.FC = ({ searchQuery = '' }) => { await fetchDeckies(deployMode?.mode); push({ text: `TORN DOWN · ${d.name.toUpperCase()}`, tone: 'matrix', icon: 'check-circle' }); } catch (err: unknown) { - const e = err as { response?: { data?: { detail?: string } } }; + const e = err as ApiError; push({ text: `TEARDOWN FAILED · ${e?.response?.data?.detail || d.name}`, tone: 'alert', diff --git a/decnet_web/src/components/MazeNET/Inspector.tsx b/decnet_web/src/components/MazeNET/Inspector.tsx index 81f9c1f7..b22d7801 100644 --- a/decnet_web/src/components/MazeNET/Inspector.tsx +++ b/decnet_web/src/components/MazeNET/Inspector.tsx @@ -6,6 +6,7 @@ import { import type { Net, MazeNode, Edge } from './types'; import { DEFAULT_SERVICES } from './data'; import ServiceConfigForm from '../ServiceConfigForm'; +import type { ApiError } from '../../utils/api'; export type Selection = | { type: 'net'; id: string } @@ -173,7 +174,7 @@ const Inspector: React.FC = ({ try { await onLiveRemoveService!(node.name, s); } catch (err) { - const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail + const msg = (err as ApiError)?.response?.data?.detail ?? 'Remove failed.'; setOpError(msg); } finally { @@ -314,7 +315,7 @@ const Inspector: React.FC = ({ try { await onToggleGateway(node.id, next); } catch (err) { - const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail + const msg = (err as ApiError)?.response?.data?.detail ?? 'Gateway toggle failed.'; setOpError(msg); } finally { @@ -352,7 +353,7 @@ const Inspector: React.FC = ({ try { await onLiveTarpitDisable!(node.name); } catch (err) { - const msg = (err as { response?: { data?: { detail?: string } } }) + const msg = (err as ApiError) ?.response?.data?.detail ?? 'Tarpit disable failed.'; setOpError(msg); } finally { @@ -406,7 +407,7 @@ const Inspector: React.FC = ({ await onLiveTarpitEnable!(node.name, ports, tarpitDelay); setTarpitOpen(false); } catch (err) { - const msg = (err as { response?: { data?: { detail?: string } } }) + const msg = (err as ApiError) ?.response?.data?.detail ?? 'Tarpit enable failed.'; setOpError(msg); } finally { diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index ecaea8c3..af2238b7 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -6,7 +6,7 @@ import { Plus, Trash2, Zap, Copy, Eye, ShieldAlert, GitMerge, Server, Mail, } from '../../icons'; import './MazeNET.css'; -import axios from '../../utils/api'; +import axios, { type ApiError } from '../../utils/api'; import { useSwarmHosts } from '../../hooks/useSwarmHosts'; import Palette from './Palette'; import Canvas from './Canvas'; @@ -29,13 +29,145 @@ import AddServiceConfigModal from '../AddServiceConfigModal'; /* Short unique suffix for default names — avoids the DB uniqueness * constraint regardless of delete/re-add sequencing on the client. */ -const hex4 = (): string => { +const tempIdSuffix = (): string => { const r = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID().replace(/-/g, '') : Math.random().toString(16).slice(2); return r.slice(0, 4); }; +const NET_GRID_W = 300; +const NET_GRID_H = 240; +const NET_GRID_GAP = 40; +const NET_GRID_COLS = 3; + +async function _dropNetwork( + drag: PaletteDrag, + topologyId: string, + nets: Net[], + api: ReturnType, + editor: ReturnType, + setNets: React.Dispatch>, + setNodes: React.Dispatch>, + flashErr: (err: unknown, fallback: string) => void, +): Promise { + const isDmz = drag.kind === 'network-dmz'; + if (isDmz && nets.some((n) => n.kind === 'dmz')) { + flashErr(null, 'topology already has a DMZ'); + return; + } + const i = nets.filter((n) => n.kind !== 'internet').length; + const x = NET_GRID_GAP + (i % NET_GRID_COLS) * (NET_GRID_W + NET_GRID_GAP); + const y = NET_GRID_GAP + Math.floor(i / NET_GRID_COLS) * (NET_GRID_H + NET_GRID_GAP); + const name = isDmz ? `dmz-${tempIdSuffix()}` : `subnet-${tempIdSuffix()}`; + try { + const subnet = await api.getNextSubnet().catch(() => undefined); + const lanRes = await editor.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) }); + if (lanRes.kind !== 'applied') { + const tempId = `pending-lan-${name}`; + setNets((p) => [...p, { + id: tempId, name, label: name.toUpperCase(), + cidr: subnet ?? '', kind: isDmz ? 'dmz' : 'subnet', + x, y, w: NET_GRID_W, h: NET_GRID_H, pending: true, + }]); + return; + } + const lan = lanRes.data; + setNets((p) => [...p, { + id: lan.id, name: lan.name, label: lan.name.toUpperCase(), cidr: lan.subnet, + kind: isDmz ? 'dmz' : 'subnet', x, y, w: NET_GRID_W, h: NET_GRID_H, + }]); + if (isDmz) { + const gwName = `dmz-gateway-${tempIdSuffix()}`; + const gwRes = await editor.addDeckyToLan( + topologyId, + { name: gwName, services: ['ssh'], x: 20, y: 40, + decky_config: { archetype: 'deaddeck', forwards_l3: true } }, + lan.id, lan.name, + { is_bridge: true, forwards_l3: true }, + ); + if (gwRes.kind !== 'applied') return; + const gw = gwRes.data; + setNodes((p) => [...p, { + kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name, + archetype: 'deaddeck', services: ['ssh'], status: 'idle', + x: 20, y: 40, decky_config: { forwards_l3: true }, + } as DeckyNode]); + } + } catch (err) { + flashErr(err, 'create network failed'); + } +} + +async function _dropArchetype( + drag: PaletteDrag, + world: { x: number; y: number }, + overNetId: string, + topologyId: string, + nets: Net[], + archetypes: Archetype[], + editor: ReturnType, + setNodes: React.Dispatch>, + flashErr: (err: unknown, fallback: string) => void, +): Promise { + const net = nets.find((n) => n.id === overNetId); + if (!net) return; + const arch = archetypes.find((a) => a.slug === drag.slug); + const dServices = drag.services ?? arch?.services ?? []; + const nx = Math.max(8, Math.round(world.x - net.x - 70)); + const ny = Math.max(28, Math.round(world.y - net.y - 24)); + const name = `decky-${tempIdSuffix()}`; + try { + const dRes = await editor.addDeckyToLan( + topologyId, + { name, services: dServices, x: nx, y: ny, decky_config: { archetype: drag.slug } }, + overNetId, net.name, + ); + if (dRes.kind !== 'applied') return; + const decky = dRes.data; + setNodes((p) => [...p, { + kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name, + archetype: drag.slug, services: dServices, status: 'idle', x: nx, y: ny, + } as DeckyNode]); + } catch (err) { + flashErr(err, 'create decky failed'); + } +} + +async function _dropService( + drag: PaletteDrag, + overNodeId: string, + topologyId: string, + nodes: MazeNode[], + topoStatus: string, + requestAddService: (name: string, slug: string) => void, + editor: ReturnType, + setNodes: React.Dispatch>, + flashErr: (err: unknown, fallback: string) => void, +): Promise { + const target = nodes.find((n) => n.id === overNodeId); + if (!target || target.kind !== 'decky') return; + if (target.services.includes(drag.slug)) return; + // Active/degraded topologies route through the live W3 endpoint — the + // design-time mutator queue would silently enqueue and the chip would never + // visibly land. Schema-driven services pop the config modal; empty-schema + // services auto-confirm and short-circuit. + if (topoStatus === 'active' || topoStatus === 'degraded') { + requestAddService(target.name, drag.slug); + return; + } + const nextServices = [...target.services, drag.slug]; + try { + const r = await editor.updateDecky(topologyId, overNodeId, target.name, { services: nextServices }); + if (r.kind !== 'applied') return; + setNodes((p) => p.map((n) => n.id === overNodeId && n.kind === 'decky' + ? { ...n, services: nextServices } + : n)); + } catch (err) { + flashErr(err, 'update services failed'); + } +} + const MazeNET: React.FC = () => { const api = useMazeApi(); const navigate = useNavigate(); @@ -105,8 +237,7 @@ const MazeNET: React.FC = () => { const editor = useTopologyEditor({ api, topoStatus, topoVersion }); const flashErr = useCallback((err: unknown, fallback: string) => { - const msg = (err as { response?: { data?: { detail?: string } }; message?: string }) - ?.response?.data?.detail ?? (err as Error)?.message ?? fallback; + const msg = (err as ApiError)?.response?.data?.detail ?? (err as ApiError)?.message ?? fallback; setActionErr(msg); setTimeout(() => setActionErr(null), 4000); }, []); @@ -196,128 +327,12 @@ const MazeNET: React.FC = () => { const onPaletteDrop = useCallback( async (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => { if (!topologyId) return; - if (drag.kind === 'network-subnet' || drag.kind === 'network-dmz') { - const isDmz = drag.kind === 'network-dmz'; - if (isDmz && nets.some((n) => n.kind === 'dmz')) { - flashErr(null, 'topology already has a DMZ'); - return; - } - // Append to the 3-col grid matching adaptTopology. Counting - // existing nets PLUS any pending placeholders (live-topology - // enqueued mutations that haven't echoed through SSE yet) - // keeps successive drops from stacking on the same cell. - const w = 300, h = 240; - const GAP = 40, COLS = 3; - const i = nets.filter((n) => n.kind !== 'internet').length; - const x = GAP + (i % COLS) * (w + GAP); - const y = GAP + Math.floor(i / COLS) * (h + GAP); - const name = isDmz ? `dmz-${hex4()}` : `subnet-${hex4()}`; - try { - const subnet = await api.getNextSubnet().catch(() => undefined); - const lanRes = await editor.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) }); - if (lanRes.kind !== 'applied') { - // Live topology: mutator will materialise the LAN. Drop - // a placeholder net so the grid index advances and the - // user gets an immediate visual ack. Real LAN arriving - // via SSE replaces the placeholder by id when its - // canonical id lands; until then, the temp id is unique. - const tempId = `pending-lan-${name}`; - setNets((p) => [...p, { - id: tempId, name, label: name.toUpperCase(), - cidr: subnet ?? '', kind: isDmz ? 'dmz' : 'subnet', - x, y, w, h, pending: true, - }]); - return; - } - const lan = lanRes.data; - const net: Net = { - id: lan.id, name: lan.name, label: lan.name.toUpperCase(), cidr: lan.subnet, - kind: isDmz ? 'dmz' : 'subnet', x, y, w, h, - }; - setNets((p) => [...p, net]); - - if (isDmz) { - const gwName = `dmz-gateway-${hex4()}`; - const gwRes = await editor.addDeckyToLan( - topologyId, - { name: gwName, services: ['ssh'], x: 20, y: 40, - decky_config: { archetype: 'deaddeck', forwards_l3: true } }, - lan.id, lan.name, - { is_bridge: true, forwards_l3: true }, - ); - if (gwRes.kind !== 'applied') return; - const gw = gwRes.data; - const gwNode: DeckyNode = { - kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name, - archetype: 'deaddeck', services: ['ssh'], status: 'idle', - x: 20, y: 40, decky_config: { forwards_l3: true }, - }; - setNodes((p) => [...p, gwNode]); - } - } catch (err) { - flashErr(err, 'create network failed'); - } - return; - } - - if (drag.kind === 'archetype') { - if (!overNetId) return; - const net = nets.find((n) => n.id === overNetId); - if (!net) return; - const arch = archetypes.find((a) => a.slug === drag.slug); - const archSlug = drag.slug; - const dServices = drag.services ?? arch?.services ?? []; - const nx = Math.max(8, Math.round(world.x - net.x - 70)); - const ny = Math.max(28, Math.round(world.y - net.y - 24)); - const name = `decky-${hex4()}`; - try { - const dRes = await editor.addDeckyToLan( - topologyId, - { name, services: dServices, x: nx, y: ny, - decky_config: { archetype: archSlug } }, - overNetId, net.name, - ); - if (dRes.kind !== 'applied') return; - const decky = dRes.data; - const node: DeckyNode = { - kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name, - archetype: archSlug, services: dServices, status: 'idle', x: nx, y: ny, - }; - setNodes((p) => [...p, node]); - } catch (err) { - flashErr(err, 'create decky failed'); - } - return; - } - - if (drag.kind === 'service') { - if (!overNodeId) return; - const target = nodes.find((n) => n.id === overNodeId); - if (!target || target.kind !== 'decky') return; - if (target.services.includes(drag.slug)) return; - // For active/degraded topologies, route through the live W3 - // endpoint — the design-time mutator queue would silently - // enqueue and the dropped chip would never visibly land - // (resulting in the "no way to APPLY" feedback). Funnel through - // requestAddService so the schema-driven config modal pops if - // the service has any user-tunable fields; empty-schema services - // auto-confirm and short-circuit, keeping drag fluency. - const live = topoStatus === 'active' || topoStatus === 'degraded'; - if (live) { - requestAddService(target.name, drag.slug); - return; - } - const nextServices = [...target.services, drag.slug]; - try { - const r = await editor.updateDecky(topologyId, overNodeId, target.name, { services: nextServices }); - if (r.kind !== 'applied') return; - setNodes((p) => p.map((n) => n.id === overNodeId && n.kind === 'decky' - ? { ...n, services: nextServices } - : n)); - } catch (err) { - flashErr(err, 'update services failed'); - } + await _dropNetwork(drag, topologyId, nets, api, editor, setNets, setNodes, flashErr); + } else if (drag.kind === 'archetype' && overNetId) { + await _dropArchetype(drag, world, overNetId, topologyId, nets, archetypes, editor, setNodes, flashErr); + } else if (drag.kind === 'service' && overNodeId) { + await _dropService(drag, overNodeId, topologyId, nodes, topoStatus, requestAddService, editor, setNodes, flashErr); } }, [api, archetypes, editor, flashErr, nets, nodes, topologyId, topoStatus, requestAddService], @@ -473,7 +488,7 @@ const MazeNET: React.FC = () => { const duplicateNode = async (id: string) => { const n = nodes.find((x) => x.id === id); if (!n || n.kind !== 'decky') return; - const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`; + const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${tempIdSuffix()}`; try { const parentNet = nets.find((net) => net.id === n.netId); const dRes = await editor.addDeckyToLan( @@ -596,7 +611,7 @@ const MazeNET: React.FC = () => { const archetypeSubmenu: MenuItem[] = archetypes.map((a) => ({ label: a.name, icon: , onClick: async () => { - const name = `decky-${hex4()}`; + const name = `decky-${tempIdSuffix()}`; try { const dRes = await editor.addDeckyToLan( topologyId, diff --git a/decnet_web/src/components/PersonaGeneration.tsx b/decnet_web/src/components/PersonaGeneration.tsx index 59e5a371..1bcf3d1c 100644 --- a/decnet_web/src/components/PersonaGeneration.tsx +++ b/decnet_web/src/components/PersonaGeneration.tsx @@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom'; import { Mail, Plus, Pencil, Trash2, Check, AlertTriangle, Upload, Download, Sparkles, } from '../icons'; -import api from '../utils/api'; +import api, { type ApiError } from '../utils/api'; import { useToast } from './Toasts/useToast'; import Modal from './Modal/Modal'; import './DeckyFleet.css'; @@ -53,10 +53,7 @@ const LATENCIES: ReplyLatency[] = ['fast', 'normal', 'slow']; type FilterKey = 'all' | Tone; function extractErrorDetail(err: unknown, fallback: string): string { - const e = err as { - response?: { status?: number; data?: { detail?: string } }; - message?: string; - }; + const e = err as ApiError; if (e?.response?.data?.detail) return e.response.data.detail; if (e?.response?.status === 403) return 'Insufficient permissions (admin only)'; if (e?.response?.status === 401) return 'Session expired — please log in again'; diff --git a/decnet_web/src/components/ServiceConfigFields.tsx b/decnet_web/src/components/ServiceConfigFields.tsx index 87dade5c..4339de86 100644 --- a/decnet_web/src/components/ServiceConfigFields.tsx +++ b/decnet_web/src/components/ServiceConfigFields.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import api from '../utils/api'; +import api, { type ApiError } from '../utils/api'; import './ServiceConfigForm.css'; export interface ServiceConfigFieldDTO { @@ -57,7 +57,7 @@ export function compactPayload( } export const fmtSchemaError = (err: unknown, fallback: string): string => - (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail + (err as ApiError)?.response?.data?.detail ?? fallback; interface Props { diff --git a/decnet_web/src/components/SwarmHosts.tsx b/decnet_web/src/components/SwarmHosts.tsx index 524a4643..e9d9b2b0 100644 --- a/decnet_web/src/components/SwarmHosts.tsx +++ b/decnet_web/src/components/SwarmHosts.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import api from '../utils/api'; +import api, { type ApiError } from '../utils/api'; import EmptyState from './EmptyState/EmptyState'; import Modal from './Modal/Modal'; import './Dashboard.css'; @@ -108,7 +108,7 @@ const EnrollmentWizard: React.FC = ({ open, onClose, onEn setResult(res.data); onEnrolled(); } catch (err: unknown) { - const e = err as { response?: { data?: { detail?: string } } }; + const e = err as ApiError; setError(e?.response?.data?.detail || 'Enrollment bundle creation failed'); } finally { setSubmitting(false); diff --git a/decnet_web/src/components/Webhooks.tsx b/decnet_web/src/components/Webhooks.tsx index 2f648e7f..75f4563d 100644 --- a/decnet_web/src/components/Webhooks.tsx +++ b/decnet_web/src/components/Webhooks.tsx @@ -3,7 +3,7 @@ import { Plus, Trash2, Pencil, Zap, AlertTriangle, Copy, X, Save, Check, Webhook as WebhookIcon, } from '../icons'; -import api from '../utils/api'; +import api, { type ApiError } from '../utils/api'; import { useToast } from './Toasts/useToast'; import './Dashboard.css'; import './Config.css'; @@ -54,10 +54,7 @@ const BLANK_FORM: FormState = { }; function extractErrorDetail(err: unknown, fallback: string): string { - const e = err as { - response?: { status?: number; data?: { detail?: string } }; - message?: string; - }; + const e = err as ApiError; if (e?.response?.data?.detail) return e.response.data.detail; if (e?.response?.status === 403) return 'Insufficient permissions (admin only)'; if (e?.response?.status === 401) return 'Session expired — please log in again'; diff --git a/decnet_web/src/utils/api.ts b/decnet_web/src/utils/api.ts index bc987a5f..f6239ce5 100644 --- a/decnet_web/src/utils/api.ts +++ b/decnet_web/src/utils/api.ts @@ -24,3 +24,9 @@ api.interceptors.response.use( ); export default api; + +/** Shape of an axios error response from the DECNET API. */ +export interface ApiError { + response?: { status?: number; data?: { detail?: string } }; + message?: string; +}