refactor(frontend): ApiError interface, tempIdSuffix rename, NET_GRID constants, extract onPaletteDrop handlers
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.
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Plus, Upload, X, AlertTriangle, Search, Target,
|
Plus, Upload, X, AlertTriangle, Search, Target,
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import api from '../utils/api';
|
import api, { type ApiError } from '../utils/api';
|
||||||
import { useEscapeKey } from '../hooks/useEscapeKey';
|
import { useEscapeKey } from '../hooks/useEscapeKey';
|
||||||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||||
import CanaryTokenDrawer from './CanaryTokenDrawer';
|
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 {
|
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?.data?.detail) return e.response.data.detail;
|
||||||
if (e?.response?.status === 403) return 'Insufficient permissions (admin only).';
|
if (e?.response?.status === 403) return 'Insufficient permissions (admin only).';
|
||||||
if (e?.response?.status === 401) return 'Session expired — please log in again.';
|
if (e?.response?.status === 401) return 'Session expired — please log in again.';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
RefreshCw, Server, Shield, Terminal, Plus, X,
|
RefreshCw, Server, Shield, Terminal, Plus, X,
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import { useEscapeKey } from '../hooks/useEscapeKey';
|
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 { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data';
|
||||||
import { useToast } from './Toasts/useToast';
|
import { useToast } from './Toasts/useToast';
|
||||||
import Modal from './Modal/Modal';
|
import Modal from './Modal/Modal';
|
||||||
@@ -325,7 +325,7 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
|||||||
setTarpitMenuOpen(false);
|
setTarpitMenuOpen(false);
|
||||||
onTarpitResult(decky.name, true, `TARPIT ON · ${decky.name.toUpperCase()} · ${ports.join(',')} / ${tarpitDelayMs}ms`);
|
onTarpitResult(decky.name, true, `TARPIT ON · ${decky.name.toUpperCase()} · ${ports.join(',')} / ${tarpitDelayMs}ms`);
|
||||||
} catch (err) {
|
} 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);
|
onTarpitResult(decky.name, false, msg);
|
||||||
} finally {
|
} finally {
|
||||||
setTarpitBusy(false);
|
setTarpitBusy(false);
|
||||||
@@ -339,7 +339,7 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
|||||||
await api.delete(`/deckies/${encodeURIComponent(decky.name)}/tarpit`);
|
await api.delete(`/deckies/${encodeURIComponent(decky.name)}/tarpit`);
|
||||||
onTarpitResult(decky.name, true, `TARPIT OFF · ${decky.name.toUpperCase()}`);
|
onTarpitResult(decky.name, true, `TARPIT OFF · ${decky.name.toUpperCase()}`);
|
||||||
} catch (err) {
|
} 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);
|
onTarpitResult(decky.name, false, msg);
|
||||||
} finally {
|
} finally {
|
||||||
setTarpitBusy(false);
|
setTarpitBusy(false);
|
||||||
@@ -355,7 +355,7 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
|||||||
);
|
);
|
||||||
onServicesChanged(decky.name, data.services);
|
onServicesChanged(decky.name, data.services);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
const msg = (err as ApiError)?.response?.data?.detail
|
||||||
?? 'Remove failed.';
|
?? 'Remove failed.';
|
||||||
setOpError(msg);
|
setOpError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -383,7 +383,7 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Re-raise so the modal can surface the error in its own status row.
|
// Re-raise so the modal can surface the error in its own status row.
|
||||||
// Also mirror onto opError for the inline picker case.
|
// 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.';
|
?? 'Add failed.';
|
||||||
setOpError(msg);
|
setOpError(msg);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -1466,7 +1466,7 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
|||||||
await fetchDeckies(deployMode?.mode);
|
await fetchDeckies(deployMode?.mode);
|
||||||
push({ text: `TORN DOWN · ${d.name.toUpperCase()}`, tone: 'matrix', icon: 'check-circle' });
|
push({ text: `TORN DOWN · ${d.name.toUpperCase()}`, tone: 'matrix', icon: 'check-circle' });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const e = err as { response?: { data?: { detail?: string } } };
|
const e = err as ApiError;
|
||||||
push({
|
push({
|
||||||
text: `TEARDOWN FAILED · ${e?.response?.data?.detail || d.name}`,
|
text: `TEARDOWN FAILED · ${e?.response?.data?.detail || d.name}`,
|
||||||
tone: 'alert',
|
tone: 'alert',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import type { Net, MazeNode, Edge } from './types';
|
import type { Net, MazeNode, Edge } from './types';
|
||||||
import { DEFAULT_SERVICES } from './data';
|
import { DEFAULT_SERVICES } from './data';
|
||||||
import ServiceConfigForm from '../ServiceConfigForm';
|
import ServiceConfigForm from '../ServiceConfigForm';
|
||||||
|
import type { ApiError } from '../../utils/api';
|
||||||
|
|
||||||
export type Selection =
|
export type Selection =
|
||||||
| { type: 'net'; id: string }
|
| { type: 'net'; id: string }
|
||||||
@@ -173,7 +174,7 @@ const Inspector: React.FC<Props> = ({
|
|||||||
try {
|
try {
|
||||||
await onLiveRemoveService!(node.name, s);
|
await onLiveRemoveService!(node.name, s);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
const msg = (err as ApiError)?.response?.data?.detail
|
||||||
?? 'Remove failed.';
|
?? 'Remove failed.';
|
||||||
setOpError(msg);
|
setOpError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -314,7 +315,7 @@ const Inspector: React.FC<Props> = ({
|
|||||||
try {
|
try {
|
||||||
await onToggleGateway(node.id, next);
|
await onToggleGateway(node.id, next);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
const msg = (err as ApiError)?.response?.data?.detail
|
||||||
?? 'Gateway toggle failed.';
|
?? 'Gateway toggle failed.';
|
||||||
setOpError(msg);
|
setOpError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -352,7 +353,7 @@ const Inspector: React.FC<Props> = ({
|
|||||||
try {
|
try {
|
||||||
await onLiveTarpitDisable!(node.name);
|
await onLiveTarpitDisable!(node.name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = (err as { response?: { data?: { detail?: string } } })
|
const msg = (err as ApiError)
|
||||||
?.response?.data?.detail ?? 'Tarpit disable failed.';
|
?.response?.data?.detail ?? 'Tarpit disable failed.';
|
||||||
setOpError(msg);
|
setOpError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -406,7 +407,7 @@ const Inspector: React.FC<Props> = ({
|
|||||||
await onLiveTarpitEnable!(node.name, ports, tarpitDelay);
|
await onLiveTarpitEnable!(node.name, ports, tarpitDelay);
|
||||||
setTarpitOpen(false);
|
setTarpitOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = (err as { response?: { data?: { detail?: string } } })
|
const msg = (err as ApiError)
|
||||||
?.response?.data?.detail ?? 'Tarpit enable failed.';
|
?.response?.data?.detail ?? 'Tarpit enable failed.';
|
||||||
setOpError(msg);
|
setOpError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Plus, Trash2, Zap, Copy, Eye, ShieldAlert, GitMerge, Server, Mail,
|
Plus, Trash2, Zap, Copy, Eye, ShieldAlert, GitMerge, Server, Mail,
|
||||||
} from '../../icons';
|
} from '../../icons';
|
||||||
import './MazeNET.css';
|
import './MazeNET.css';
|
||||||
import axios from '../../utils/api';
|
import axios, { type ApiError } from '../../utils/api';
|
||||||
import { useSwarmHosts } from '../../hooks/useSwarmHosts';
|
import { useSwarmHosts } from '../../hooks/useSwarmHosts';
|
||||||
import Palette from './Palette';
|
import Palette from './Palette';
|
||||||
import Canvas from './Canvas';
|
import Canvas from './Canvas';
|
||||||
@@ -29,13 +29,145 @@ import AddServiceConfigModal from '../AddServiceConfigModal';
|
|||||||
|
|
||||||
/* Short unique suffix for default names — avoids the DB uniqueness
|
/* Short unique suffix for default names — avoids the DB uniqueness
|
||||||
* constraint regardless of delete/re-add sequencing on the client. */
|
* constraint regardless of delete/re-add sequencing on the client. */
|
||||||
const hex4 = (): string => {
|
const tempIdSuffix = (): string => {
|
||||||
const r = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
const r = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
? crypto.randomUUID().replace(/-/g, '')
|
? crypto.randomUUID().replace(/-/g, '')
|
||||||
: Math.random().toString(16).slice(2);
|
: Math.random().toString(16).slice(2);
|
||||||
return r.slice(0, 4);
|
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<typeof useMazeApi>,
|
||||||
|
editor: ReturnType<typeof useTopologyEditor>,
|
||||||
|
setNets: React.Dispatch<React.SetStateAction<Net[]>>,
|
||||||
|
setNodes: React.Dispatch<React.SetStateAction<MazeNode[]>>,
|
||||||
|
flashErr: (err: unknown, fallback: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
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<typeof useTopologyEditor>,
|
||||||
|
setNodes: React.Dispatch<React.SetStateAction<MazeNode[]>>,
|
||||||
|
flashErr: (err: unknown, fallback: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
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<typeof useTopologyEditor>,
|
||||||
|
setNodes: React.Dispatch<React.SetStateAction<MazeNode[]>>,
|
||||||
|
flashErr: (err: unknown, fallback: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
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 MazeNET: React.FC = () => {
|
||||||
const api = useMazeApi();
|
const api = useMazeApi();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -105,8 +237,7 @@ const MazeNET: React.FC = () => {
|
|||||||
const editor = useTopologyEditor({ api, topoStatus, topoVersion });
|
const editor = useTopologyEditor({ api, topoStatus, topoVersion });
|
||||||
|
|
||||||
const flashErr = useCallback((err: unknown, fallback: string) => {
|
const flashErr = useCallback((err: unknown, fallback: string) => {
|
||||||
const msg = (err as { response?: { data?: { detail?: string } }; message?: string })
|
const msg = (err as ApiError)?.response?.data?.detail ?? (err as ApiError)?.message ?? fallback;
|
||||||
?.response?.data?.detail ?? (err as Error)?.message ?? fallback;
|
|
||||||
setActionErr(msg);
|
setActionErr(msg);
|
||||||
setTimeout(() => setActionErr(null), 4000);
|
setTimeout(() => setActionErr(null), 4000);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -196,128 +327,12 @@ const MazeNET: React.FC = () => {
|
|||||||
const onPaletteDrop = useCallback(
|
const onPaletteDrop = useCallback(
|
||||||
async (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => {
|
async (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => {
|
||||||
if (!topologyId) return;
|
if (!topologyId) return;
|
||||||
|
|
||||||
if (drag.kind === 'network-subnet' || drag.kind === 'network-dmz') {
|
if (drag.kind === 'network-subnet' || drag.kind === 'network-dmz') {
|
||||||
const isDmz = drag.kind === 'network-dmz';
|
await _dropNetwork(drag, topologyId, nets, api, editor, setNets, setNodes, flashErr);
|
||||||
if (isDmz && nets.some((n) => n.kind === 'dmz')) {
|
} else if (drag.kind === 'archetype' && overNetId) {
|
||||||
flashErr(null, 'topology already has a DMZ');
|
await _dropArchetype(drag, world, overNetId, topologyId, nets, archetypes, editor, setNodes, flashErr);
|
||||||
return;
|
} else if (drag.kind === 'service' && overNodeId) {
|
||||||
}
|
await _dropService(drag, overNodeId, topologyId, nodes, topoStatus, requestAddService, editor, setNodes, flashErr);
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, archetypes, editor, flashErr, nets, nodes, topologyId, topoStatus, requestAddService],
|
[api, archetypes, editor, flashErr, nets, nodes, topologyId, topoStatus, requestAddService],
|
||||||
@@ -473,7 +488,7 @@ const MazeNET: React.FC = () => {
|
|||||||
const duplicateNode = async (id: string) => {
|
const duplicateNode = async (id: string) => {
|
||||||
const n = nodes.find((x) => x.id === id);
|
const n = nodes.find((x) => x.id === id);
|
||||||
if (!n || n.kind !== 'decky') return;
|
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 {
|
try {
|
||||||
const parentNet = nets.find((net) => net.id === n.netId);
|
const parentNet = nets.find((net) => net.id === n.netId);
|
||||||
const dRes = await editor.addDeckyToLan(
|
const dRes = await editor.addDeckyToLan(
|
||||||
@@ -596,7 +611,7 @@ const MazeNET: React.FC = () => {
|
|||||||
const archetypeSubmenu: MenuItem[] = archetypes.map((a) => ({
|
const archetypeSubmenu: MenuItem[] = archetypes.map((a) => ({
|
||||||
label: a.name, icon: <Server size={12} />,
|
label: a.name, icon: <Server size={12} />,
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const name = `decky-${hex4()}`;
|
const name = `decky-${tempIdSuffix()}`;
|
||||||
try {
|
try {
|
||||||
const dRes = await editor.addDeckyToLan(
|
const dRes = await editor.addDeckyToLan(
|
||||||
topologyId,
|
topologyId,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Mail, Plus, Pencil, Trash2, Check, AlertTriangle, Upload, Download, Sparkles,
|
Mail, Plus, Pencil, Trash2, Check, AlertTriangle, Upload, Download, Sparkles,
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import api from '../utils/api';
|
import api, { type ApiError } from '../utils/api';
|
||||||
import { useToast } from './Toasts/useToast';
|
import { useToast } from './Toasts/useToast';
|
||||||
import Modal from './Modal/Modal';
|
import Modal from './Modal/Modal';
|
||||||
import './DeckyFleet.css';
|
import './DeckyFleet.css';
|
||||||
@@ -53,10 +53,7 @@ const LATENCIES: ReplyLatency[] = ['fast', 'normal', 'slow'];
|
|||||||
type FilterKey = 'all' | Tone;
|
type FilterKey = 'all' | Tone;
|
||||||
|
|
||||||
function extractErrorDetail(err: unknown, fallback: string): string {
|
function extractErrorDetail(err: unknown, fallback: string): string {
|
||||||
const e = err as {
|
const e = err as ApiError;
|
||||||
response?: { status?: number; data?: { detail?: string } };
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
if (e?.response?.data?.detail) return e.response.data.detail;
|
if (e?.response?.data?.detail) return e.response.data.detail;
|
||||||
if (e?.response?.status === 403) return 'Insufficient permissions (admin only)';
|
if (e?.response?.status === 403) return 'Insufficient permissions (admin only)';
|
||||||
if (e?.response?.status === 401) return 'Session expired — please log in again';
|
if (e?.response?.status === 401) return 'Session expired — please log in again';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import api from '../utils/api';
|
import api, { type ApiError } from '../utils/api';
|
||||||
import './ServiceConfigForm.css';
|
import './ServiceConfigForm.css';
|
||||||
|
|
||||||
export interface ServiceConfigFieldDTO {
|
export interface ServiceConfigFieldDTO {
|
||||||
@@ -57,7 +57,7 @@ export function compactPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const fmtSchemaError = (err: unknown, fallback: string): string =>
|
export const fmtSchemaError = (err: unknown, fallback: string): string =>
|
||||||
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
(err as ApiError)?.response?.data?.detail
|
||||||
?? fallback;
|
?? fallback;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
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 EmptyState from './EmptyState/EmptyState';
|
||||||
import Modal from './Modal/Modal';
|
import Modal from './Modal/Modal';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
@@ -108,7 +108,7 @@ const EnrollmentWizard: React.FC<EnrollmentWizardProps> = ({ open, onClose, onEn
|
|||||||
setResult(res.data);
|
setResult(res.data);
|
||||||
onEnrolled();
|
onEnrolled();
|
||||||
} catch (err: unknown) {
|
} 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');
|
setError(e?.response?.data?.detail || 'Enrollment bundle creation failed');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Plus, Trash2, Pencil, Zap, AlertTriangle, Copy, X, Save,
|
Plus, Trash2, Pencil, Zap, AlertTriangle, Copy, X, Save,
|
||||||
Check, Webhook as WebhookIcon,
|
Check, Webhook as WebhookIcon,
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import api from '../utils/api';
|
import api, { type ApiError } from '../utils/api';
|
||||||
import { useToast } from './Toasts/useToast';
|
import { useToast } from './Toasts/useToast';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
import './Config.css';
|
import './Config.css';
|
||||||
@@ -54,10 +54,7 @@ const BLANK_FORM: FormState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function extractErrorDetail(err: unknown, fallback: string): string {
|
function extractErrorDetail(err: unknown, fallback: string): string {
|
||||||
const e = err as {
|
const e = err as ApiError;
|
||||||
response?: { status?: number; data?: { detail?: string } };
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
if (e?.response?.data?.detail) return e.response.data.detail;
|
if (e?.response?.data?.detail) return e.response.data.detail;
|
||||||
if (e?.response?.status === 403) return 'Insufficient permissions (admin only)';
|
if (e?.response?.status === 403) return 'Insufficient permissions (admin only)';
|
||||||
if (e?.response?.status === 401) return 'Session expired — please log in again';
|
if (e?.response?.status === 401) return 'Session expired — please log in again';
|
||||||
|
|||||||
@@ -24,3 +24,9 @@ api.interceptors.response.use(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|
||||||
|
/** Shape of an axios error response from the DECNET API. */
|
||||||
|
export interface ApiError {
|
||||||
|
response?: { status?: number; data?: { detail?: string } };
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user