From d770eaa9cd3e7bad9b2a116af588931c82b12b38 Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 20 Apr 2026 23:07:52 -0400 Subject: [PATCH] fix(mazenet-ui): detect gateway via forwards_l3, drop host-mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway detection in the editor previously matched archetype === 'host-gateway' (a fictional archetype that never existed in decnet/archetypes.py). Switch to decky_config.forwards_l3 — the real runtime marker the composer already reads — so deletion guards, drag-pinning, context menu locking, and NodeCard DMZ-gateway styling all line up with what actually ships at deploy time. On DMZ palette drop, create the gateway with archetype=deaddeck, services=['ssh'], forwards_l3=true, and mark the edge is_bridge=true, forwards_l3=true. attachEdge now accepts those flags so callers can seed a real bridge attachment. --- decnet_web/src/components/MazeNET/MazeNET.tsx | 465 ++++++++++++++---- .../src/components/MazeNET/NodeCard.tsx | 6 +- .../src/components/MazeNET/useMazeApi.ts | 209 ++++++-- .../components/MazeNET/useMazeInteraction.ts | 93 +++- 4 files changed, 621 insertions(+), 152 deletions(-) diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index a0138e0f..c5cc2123 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -1,75 +1,266 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { PanelRightOpen, PanelRightClose, RotateCcw, UploadCloud } from 'lucide-react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { + PanelRightOpen, PanelRightClose, RotateCcw, UploadCloud, ArrowLeft, + Plus, Trash2, Zap, Copy, Eye, ShieldAlert, GitMerge, Server, +} from 'lucide-react'; import './MazeNET.css'; +import axios from '../../utils/api'; import Palette from './Palette'; import Canvas from './Canvas'; import Inspector from './Inspector'; import type { Selection } from './Inspector'; import ContextMenu, { type MenuItem } from './ContextMenu'; -import { DEFAULT_SERVICES, DEMO_NETS, DEMO_NODES, DEMO_EDGES } from './data'; -import type { ServiceDef } from './data'; -import type { Net, MazeNode, Edge, PendingChange } from './types'; +import { DEFAULT_SERVICES } from './data'; +import type { Archetype, ServiceDef } from './data'; +import type { Net, MazeNode, Edge, DeckyNode } from './types'; import { useMazeApi } from './useMazeApi'; -import { useMazeInteraction } from './useMazeInteraction'; +import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction'; +import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data'; + +/* Short unique suffix for default names — avoids the DB uniqueness + * constraint regardless of delete/re-add sequencing on the client. */ +const hex4 = (): 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 MazeNET: React.FC = () => { const api = useMazeApi(); + const navigate = useNavigate(); const [params] = useSearchParams(); - const topologyId = params.get('topology'); + const topologyId = params.get('topology') ?? ''; - const [nets, setNets] = useState(DEMO_NETS); - const [nodes, setNodes] = useState(DEMO_NODES); - const [edges, setEdges] = useState(DEMO_EDGES); - const [pending, setPending] = useState([]); + const [nets, setNets] = useState([]); + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + const [topoStatus, setTopoStatus] = useState('pending'); + const [topoName, setTopoName] = useState(''); + const [topoVersion, setTopoVersion] = useState(0); const [selection, setSelection] = useState(null); const [inspectorOpen, setInspectorOpen] = useState(true); const [services, setServices] = useState(DEFAULT_SERVICES); + const [archetypes, setArchetypes] = useState(DEFAULT_ARCHETYPES); const [loadErr, setLoadErr] = useState(null); + const [actionErr, setActionErr] = useState(null); + const [deploying, setDeploying] = useState(false); const canvasRef = useRef(null); - const applyChange = useCallback((pc: PendingChange) => { - setPending((p) => [...p, pc]); - if (pc.op === 'add_edge') { - const payload = pc.payload; - setEdges((prev) => prev.some((e) => e.id === payload.id) - ? prev - : [...prev, { id: payload.id, from: payload.from, to: payload.to, traffic: 'active' as const }]); - } + + 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; + setActionErr(msg); + setTimeout(() => setActionErr(null), 4000); }, []); - const interaction = useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef }); + + /* ── Palette drop — create LANs / deckies / services via REST ─── */ + 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; + } + const w = 320, h = 240; + const x = Math.round(world.x - w / 2); + const y = Math.round(world.y - h / 2); + const name = isDmz ? `dmz-${hex4()}` : `subnet-${hex4()}`; + try { + const subnet = await api.getNextSubnet().catch(() => undefined); + const lan = await api.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) }); + const net: Net = { + id: lan.id, 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 gw = await api.createDecky(topologyId, { + name: gwName, services: ['ssh'], x: 20, y: 40, + decky_config: { archetype: 'deaddeck', forwards_l3: true }, + }); + await api.attachEdge(topologyId, { + decky_uuid: gw.uuid, lan_id: lan.id, + is_bridge: true, forwards_l3: true, + }); + 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 decky = await api.createDecky(topologyId, { + name, services: dServices, x: nx, y: ny, + decky_config: { archetype: archSlug }, + }); + await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: overNetId }); + 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; + const nextServices = [...target.services, drag.slug]; + try { + await api.updateDecky(topologyId, overNodeId, { services: nextServices }); + setNodes((p) => p.map((n) => n.id === overNodeId && n.kind === 'decky' + ? { ...n, services: nextServices } + : n)); + } catch (err) { + flashErr(err, 'update services failed'); + } + } + }, + [api, archetypes, flashErr, nets, nodes, topologyId], + ); + + /* ── Cross-net reparent via node drag (detach + attach edge) ─── */ + const onReparent = useCallback(async (nodeId: string, fromNetId: string, toNetId: string) => { + if (!topologyId) return; + try { + const { data: detail } = await axios.get(`/topologies/${topologyId}`); + const existingEdge = (detail.edges ?? []).find( + (e: { decky_uuid: string; lan_id: string; id: string }) => + e.decky_uuid === nodeId && e.lan_id === fromNetId, + ); + if (existingEdge) await api.detachEdge(topologyId, existingEdge.id); + await api.attachEdge(topologyId, { decky_uuid: nodeId, lan_id: toNetId }); + } catch (err) { + flashErr(err, 'reparent failed'); + } + }, [api, flashErr, topologyId]); + + /* Port→port edges stay UI-only (backend edges are decky↔LAN). */ + const onAddEdge = useCallback((fromId: string, toId: string) => { + const id = `viz-${fromId}-${toId}-${Date.now()}`; + setEdges((prev) => prev.some((e) => (e.from === fromId && e.to === toId) || (e.from === toId && e.to === fromId)) + ? prev + : [...prev, { id, from: fromId, to: toId, traffic: 'active' as const }]); + }, []); + + const interaction = useMazeInteraction({ + nets, nodes, setNets, setNodes, canvasRef, + onPaletteDrop, onReparent, onAddEdge, + }); const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null); - const removeNet = (id: string) => { + const removeNet = async (id: string) => { const net = nets.find((n) => n.id === id); if (!net || net.kind === 'internet') return; - setNets((p) => p.filter((n) => n.id !== id)); - setNodes((p) => p.filter((n) => n.netId !== id)); - setEdges((p) => p.filter((e) => { - const a = nodes.find((x) => x.id === e.from)?.netId; - const b = nodes.find((x) => x.id === e.to)?.netId; - return a !== id && b !== id; - })); - applyChange({ op: 'remove_lan', payload: { id } }); - setSelection(null); + /* Cascade delete members first — backend will otherwise 400 on orphan risk. */ + const members = nodes.filter((n) => n.netId === id && n.kind === 'decky'); + try { + for (const m of members) await api.deleteDecky(topologyId, m.id); + await api.deleteLan(topologyId, id); + setNets((p) => p.filter((n) => n.id !== id)); + setNodes((p) => p.filter((n) => n.netId !== id)); + setEdges((p) => p.filter((e) => { + const a = nodes.find((x) => x.id === e.from)?.netId; + const b = nodes.find((x) => x.id === e.to)?.netId; + return a !== id && b !== id; + })); + setSelection(null); + } catch (err) { + flashErr(err, 'delete network failed'); + } }; - const removeNode = (id: string) => { + const removeNode = async (id: string) => { const node = nodes.find((n) => n.id === id); if (!node || node.kind === 'observed') return; - setNodes((p) => p.filter((n) => n.id !== id)); - setEdges((p) => p.filter((e) => e.from !== id && e.to !== id)); - applyChange({ op: 'remove_decky', payload: { nodeId: id } }); - setSelection(null); + if (node.kind === 'decky' && node.decky_config?.forwards_l3) return; + try { + await api.deleteDecky(topologyId, id); + setNodes((p) => p.filter((n) => n.id !== id)); + setEdges((p) => p.filter((e) => e.from !== id && e.to !== id)); + setSelection(null); + } catch (err) { + flashErr(err, 'delete decky failed'); + } }; const removeEdge = (id: string) => { + /* Viz-only edges: backend has no edge to delete here. */ setEdges((p) => p.filter((e) => e.id !== id)); - applyChange({ op: 'remove_edge', payload: { id } }); setSelection(null); }; + 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()}`; + try { + const decky = await api.createDecky(topologyId, { + name, services: [...n.services], x: n.x + 24, y: n.y + 24, + decky_config: { archetype: n.archetype }, + }); + await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: n.netId }); + const copy: DeckyNode = { + kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name, + archetype: n.archetype, services: [...n.services], status: 'idle', + x: n.x + 24, y: n.y + 24, + }; + setNodes((p) => [...p, copy]); + } catch (err) { + flashErr(err, 'duplicate failed'); + } + }; + + const addServiceToNode = async (id: string, slug: string) => { + const n = nodes.find((x) => x.id === id); + if (!n || n.kind !== 'decky' || n.services.includes(slug)) return; + const nextServices = [...n.services, slug]; + try { + await api.updateDecky(topologyId, id, { services: nextServices }); + setNodes((p) => p.map((x) => x.id === id && x.kind === 'decky' + ? { ...x, services: nextServices } : x)); + } catch (err) { + flashErr(err, 'add service failed'); + } + }; + + /* Force-mutate is a no-op against a pending topology (no live containers). + * Keep the menu item disabled for now; real hook lands with live-editing polish. */ + const forceMutate = (_id: string) => { + flashErr(null, 'force-mutate only applies to deployed topologies'); + }; + const onNodeContextMenu = (id: string) => (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -77,18 +268,38 @@ const MazeNET: React.FC = () => { if (!node) return; setSelection({ type: 'node', id }); const isObs = node.kind === 'observed'; + const isGateway = node.kind === 'decky' && !!node.decky_config?.forwards_l3; + const locked = isObs || isGateway; + const lockedTitle = isObs + ? 'observed entity — not a deployed decky' + : isGateway ? 'DMZ gateway — pinned to its DMZ network' : undefined; + const usedServices = node.kind === 'decky' ? new Set(node.services) : new Set(); + const serviceSubmenu: MenuItem[] = services + .filter((s) => !usedServices.has(s.slug)) + .slice(0, 16) + .map((s) => ({ + label: `${s.name} · ${s.proto.toUpperCase()}:${s.port}`, + disabled: isObs, + onClick: () => addServiceToNode(id, s.slug), + })); + if (serviceSubmenu.length === 0) { + serviceSubmenu.push({ label: '(no free services)', disabled: true }); + } + setCtxMenu({ x: e.clientX, y: e.clientY, items: [ - { label: 'INSPECT', onClick: () => setSelection({ type: 'node', id }) }, + { label: 'Add service…', icon: , disabled: isObs, + title: isObs ? 'observed entity — services fixed' : undefined, + submenu: serviceSubmenu }, + { label: 'Force mutate', icon: , disabled: isObs, + onClick: () => forceMutate(id) }, + { label: 'Duplicate decky', icon: , disabled: locked, + title: lockedTitle, onClick: () => duplicateNode(id) }, { separator: true, label: '' }, - { - label: 'DELETE NODE', - danger: true, - disabled: isObs, - title: isObs ? 'observed entity — not a deployed decky' : undefined, - onClick: () => removeNode(id), - }, + { label: 'Delete decky', icon: , danger: true, + disabled: locked, title: lockedTitle, + onClick: () => removeNode(id) }, ], }); }; @@ -99,18 +310,39 @@ const MazeNET: React.FC = () => { const net = nets.find((n) => n.id === id); if (!net) return; setSelection({ type: 'net', id }); + const archetypeSubmenu: MenuItem[] = archetypes.map((a) => ({ + label: a.name, icon: , + onClick: async () => { + const name = `decky-${hex4()}`; + try { + const decky = await api.createDecky(topologyId, { + name, services: [...a.services], x: 20, y: 40, + decky_config: { archetype: a.slug }, + }); + await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: id }); + const node: DeckyNode = { + kind: 'decky', id: decky.uuid, netId: id, name: decky.name, + archetype: a.slug, services: [...a.services], status: 'idle', + x: 20, y: 40, + }; + setNodes((p) => [...p, node]); + } catch (err) { + flashErr(err, 'create decky failed'); + } + }, + })); + setCtxMenu({ x: e.clientX, y: e.clientY, items: [ - { label: 'INSPECT', onClick: () => setSelection({ type: 'net', id }) }, + { label: 'Add decky…', icon: , submenu: archetypeSubmenu }, + { label: 'Inspect', icon: , onClick: () => setSelection({ type: 'net', id }) }, { separator: true, label: '' }, - { - label: 'DELETE NET', - danger: true, + { label: net.kind === 'dmz' ? 'Delete DMZ' : 'Delete network', + icon: , danger: true, disabled: net.kind === 'internet', title: net.kind === 'internet' ? 'internet zone cannot be removed' : undefined, - onClick: () => removeNet(id), - }, + onClick: () => removeNet(id) }, ], }); }; @@ -122,46 +354,79 @@ const MazeNET: React.FC = () => { setCtxMenu({ x: e.clientX, y: e.clientY, items: [ - { label: 'REMOVE EDGE', danger: true, onClick: () => removeEdge(id) }, + { label: 'Remove edge', icon: , danger: true, onClick: () => removeEdge(id) }, ], }); }; - /* Load service catalog from API (fall back to defaults if 401/offline). */ + const onCanvasContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + setCtxMenu({ + x: e.clientX, y: e.clientY, + items: [ + { label: 'Add subnet here', icon: , + onClick: () => { + const rect = canvasRef.current?.getBoundingClientRect(); + const wx = e.clientX - (rect?.left ?? 0) - interaction.pan.x; + const wy = e.clientY - (rect?.top ?? 0) - interaction.pan.y; + onPaletteDrop( + { kind: 'network-subnet', slug: 'subnet', label: 'SUBNET', clientX: e.clientX, clientY: e.clientY }, + { x: wx, y: wy }, null, null, + ); + }, + }, + { label: 'Add DMZ here', icon: , + onClick: () => { + const rect = canvasRef.current?.getBoundingClientRect(); + const wx = e.clientX - (rect?.left ?? 0) - interaction.pan.x; + const wy = e.clientY - (rect?.top ?? 0) - interaction.pan.y; + onPaletteDrop( + { kind: 'network-dmz', slug: 'dmz', label: 'DMZ', clientX: e.clientX, clientY: e.clientY }, + { x: wx, y: wy }, null, null, + ); + }, + }, + ], + }); + }; + + /* Load catalogs. */ useEffect(() => { let cancelled = false; api.getServices().then((s) => { if (!cancelled) setServices(s); }).catch(() => {}); + api.getArchetypes().then((a) => { if (!cancelled) setArchetypes(a); }).catch(() => {}); return () => { cancelled = true; }; }, [api]); - /* If ?topology= is present, hydrate from the real backend. */ - useEffect(() => { + /* Hydrate topology. Route guard in App.tsx ensures topologyId is set; + * if the id is bogus, surface a friendly error. */ + const refetch = useCallback(async () => { if (!topologyId) return; - let cancelled = false; - api.getTopology(topologyId) - .then((h) => { - if (cancelled) return; - setNets(h.nets); setNodes(h.nodes); setEdges(h.edges); - setSelection(null); - setLoadErr(null); - }) - .catch((err) => { - if (!cancelled) setLoadErr(err?.message ?? 'topology load failed'); - }); - return () => { cancelled = true; }; + try { + const h = await api.getTopology(topologyId); + setNets(h.nets); setNodes(h.nodes); setEdges(h.edges); + setTopoStatus(h.topology.status); + setTopoName(h.topology.name); + setTopoVersion(h.topology.version); + setLoadErr(null); + } catch (err) { + setLoadErr((err as Error)?.message ?? 'topology load failed'); + } }, [api, topologyId]); - const onReset = () => { - if (topologyId) { - api.getTopology(topologyId).then((h) => { - setNets(h.nets); setNodes(h.nodes); setEdges(h.edges); - }).catch(() => {}); - } else { - setNets(DEMO_NETS); setNodes(DEMO_NODES); setEdges(DEMO_EDGES); + useEffect(() => { refetch(); }, [refetch]); + + const onDeploy = async () => { + if (!topologyId) return; + setDeploying(true); + try { + await api.deployTopology(topologyId); + await refetch(); + } catch (err) { + flashErr(err, 'deploy failed'); + } finally { + setDeploying(false); } - setSelection(null); - setPending([]); - interaction.resetPan(); }; useEffect(() => { @@ -172,38 +437,38 @@ const MazeNET: React.FC = () => { return () => window.removeEventListener('keydown', onKey); }, []); + const canDeploy = topoStatus === 'pending' && nets.length > 0; + return (
-

MAZENET

+

MAZENET · {topoName || topologyId}

- {topologyId ? `TOPOLOGY ${topologyId} · ` : 'DEMO · '} - {nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS ·{' '} - {pending.length > 0 ? `${pending.length} UNCOMMITTED` : 'LIVE'} + {topoStatus.toUpperCase()} · v{topoVersion} ·{' '} + {nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS {loadErr && · {loadErr}} + {actionErr && · {actionErr}}
- - +
@@ -212,12 +477,13 @@ const MazeNET: React.FC = () => { className="maze-shell" style={{ gridTemplateColumns: inspectorOpen ? '240px 1fr 320px' : '240px 1fr' }} > - + { onNodeContextMenu={onNodeContextMenu} onNetContextMenu={onNetContextMenu} onEdgeContextMenu={onEdgeContextMenu} + onCanvasContextMenu={onCanvasContextMenu} /> {ctxMenu && ( setCtxMenu(null)} /> )} + {interaction.paletteDrag && ( +
+ {interaction.paletteDrag.label} +
+ )} {inspectorOpen && ( setInspectorOpen(false)} onDeleteNet={removeNet} onDeleteNode={removeNode} diff --git a/decnet_web/src/components/MazeNET/NodeCard.tsx b/decnet_web/src/components/MazeNET/NodeCard.tsx index f24a56b1..e4db0092 100644 --- a/decnet_web/src/components/MazeNET/NodeCard.tsx +++ b/decnet_web/src/components/MazeNET/NodeCard.tsx @@ -7,19 +7,23 @@ interface Props { absY: number; selected: boolean; dragging?: boolean; + deployed?: boolean; onSelect?: (id: string) => void; onMouseDown?: (id: string) => (e: React.MouseEvent) => void; onPortMouseDown?: (id: string) => (e: React.MouseEvent) => void; onContextMenu?: (id: string) => (e: React.MouseEvent) => void; } -const NodeCard: React.FC = ({ node, absX, absY, selected, dragging, onSelect, onMouseDown, onPortMouseDown, onContextMenu }) => { +const NodeCard: React.FC = ({ node, absX, absY, selected, dragging, deployed, onSelect, onMouseDown, onPortMouseDown, onContextMenu }) => { + const isDmzGateway = !!(node as { decky_config?: { forwards_l3?: boolean } }).decky_config?.forwards_l3; const classes = [ 'maze-node', node.kind === 'observed' ? 'observed' : '', node.status === 'hot' ? 'hot' : '', selected ? 'selected' : '', dragging ? 'dragging' : '', + deployed ? 'deployed' : '', + deployed && isDmzGateway ? 'dmz-gateway' : '', ].filter(Boolean).join(' '); const handleDown = (e: React.MouseEvent) => { diff --git a/decnet_web/src/components/MazeNET/useMazeApi.ts b/decnet_web/src/components/MazeNET/useMazeApi.ts index 10d884a5..308c317a 100644 --- a/decnet_web/src/components/MazeNET/useMazeApi.ts +++ b/decnet_web/src/components/MazeNET/useMazeApi.ts @@ -1,11 +1,12 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import api from '../../utils/api'; -import { DEFAULT_SERVICES } from './data'; -import type { ServiceDef } from './data'; -import type { Net, MazeNode, Edge, DeckyNode, PendingChange } from './types'; +import { ARCHETYPES as DEFAULT_ARCHETYPES, DEFAULT_SERVICES } from './data'; +import type { Archetype, ServiceDef } from './data'; +import type { Net, MazeNode, Edge, DeckyNode } from './types'; -interface LANRow { +export interface LANRow { id: string; + topology_id: string; name: string; subnet: string; is_dmz: boolean; @@ -13,8 +14,9 @@ interface LANRow { y?: number | null; } -interface DeckyRow { +export interface DeckyRow { uuid: string; + topology_id: string; name: string; services: string[]; decky_config?: Record | null; @@ -24,15 +26,16 @@ interface DeckyRow { y?: number | null; } -interface EdgeRow { +export interface EdgeRow { id: string; + topology_id: string; decky_uuid: string; lan_id: string; is_bridge: boolean; forwards_l3: boolean; } -interface TopologySummary { +export interface TopologySummary { id: string; name: string; mode: string; @@ -47,17 +50,17 @@ interface TopologyDetail { edges: EdgeRow[]; } -interface HydratedTopology { +export interface HydratedTopology { topology: TopologySummary; nets: Net[]; nodes: MazeNode[]; edges: Edge[]; } -/** Adapt the Phase-3 TopologyDetail wire shape to canvas entities. - * Backend edges are decky↔LAN membership (bipartite); we surface them - * as node-in-net placement. Decky-to-decky traffic edges are derived - * from shared-LAN co-membership for now (Step 4 may refine this). */ +/** Adapt the wire shape to canvas entities. Backend edges are + * decky↔LAN membership (bipartite); we surface them as node-in-net + * placement. Decky-to-decky traffic edges are derived from + * shared-LAN co-membership for visualization only. */ export function adaptTopology(detail: TopologyDetail): HydratedTopology { const nets: Net[] = detail.lans.map((lan, i) => ({ id: lan.id, @@ -70,7 +73,6 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology { h: 240, })); - /* A decky sits in the first LAN it attaches to. */ const firstLanFor = new Map(); for (const e of detail.edges) { if (!firstLanFor.has(e.decky_uuid)) firstLanFor.set(e.decky_uuid, e.lan_id); @@ -81,7 +83,7 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology { id: d.uuid, netId: firstLanFor.get(d.uuid) ?? (nets[0]?.id ?? ''), name: d.name, - archetype: 'linux-server', + archetype: (d.decky_config as { archetype?: string } | null)?.archetype ?? 'linux-server', services: d.services, status: d.state === 'running' ? 'active' : d.state === 'failed' ? 'hot' : 'idle', x: d.x ?? 20 + (i % 2) * 160, @@ -90,7 +92,6 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology { decky_config: d.decky_config ?? undefined, })); - /* Derive decky-to-decky edges from shared-LAN membership. */ const byLan = new Map(); for (const e of detail.edges) { const arr = byLan.get(e.lan_id) ?? []; @@ -118,21 +119,71 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology { return { topology: detail.topology, nets, nodes, edges }; } -export interface MazeApi { - listTopologies: () => Promise; - getTopology: (id: string) => Promise; - getServices: () => Promise; - getNextIp: (topologyId: string, lanId: string) => Promise; - getNextSubnet: (base: string) => Promise; - commit: (topologyId: string, changes: PendingChange[]) => Promise; +interface ArchetypeRow { + slug: string; + display_name: string; + description: string; + services: string[]; + preferred_distros: string[]; + nmap_os: string; } -export function useMazeApi(toast?: (msg: string) => void): MazeApi { +const NMAP_OS_TO_ICON: Record = { + linux: 'server', + windows: 'monitor', + embedded: 'cpu', +}; + +export interface CreateLanBody { + name: string; + is_dmz: boolean; + x: number; + y: number; + subnet?: string; +} + +export interface CreateDeckyBody { + name: string; + services: string[]; + x: number; + y: number; + decky_config?: Record; +} + +export interface MazeApi { + listTopologies: () => Promise; + createBlankTopology: (name: string) => Promise; + getTopology: (id: string) => Promise; + getServices: () => Promise; + getArchetypes: () => Promise; + getNextIp: (topologyId: string, lanId: string) => Promise; + getNextSubnet: (base?: string) => Promise; + + createLan: (topologyId: string, body: CreateLanBody) => Promise; + updateLan: (topologyId: string, lanId: string, patch: Partial) => Promise; + deleteLan: (topologyId: string, lanId: string) => Promise; + + createDecky: (topologyId: string, body: CreateDeckyBody) => Promise; + updateDecky: (topologyId: string, uuid: string, patch: Partial) => Promise; + deleteDecky: (topologyId: string, uuid: string) => Promise; + + attachEdge: (topologyId: string, body: { decky_uuid: string; lan_id: string; is_bridge?: boolean; forwards_l3?: boolean }) => Promise; + detachEdge: (topologyId: string, edgeId: string) => Promise; + + deployTopology: (topologyId: string) => Promise; +} + +export function useMazeApi(): MazeApi { const listTopologies = useCallback(async () => { const { data } = await api.get('/topologies/'); return (data?.data ?? []) as TopologySummary[]; }, []); + const createBlankTopology = useCallback(async (name: string): Promise => { + const { data } = await api.post('/topologies/blank', { name }); + return data; + }, []); + const getTopology = useCallback(async (id: string) => { const { data } = await api.get(`/topologies/${id}`); return adaptTopology(data); @@ -158,6 +209,21 @@ export function useMazeApi(toast?: (msg: string) => void): MazeApi { } }, []); + const getArchetypes = useCallback(async (): Promise => { + try { + const { data } = await api.get<{ archetypes: ArchetypeRow[] }>('/topologies/archetypes'); + const known = new Map(DEFAULT_ARCHETYPES.map((a) => [a.slug, a.icon])); + return data.archetypes.map((a) => ({ + slug: a.slug, + name: a.display_name, + services: a.services, + icon: known.get(a.slug) ?? NMAP_OS_TO_ICON[a.nmap_os] ?? 'server', + })); + } catch { + return DEFAULT_ARCHETYPES; + } + }, []); + const getNextIp = useCallback(async (topologyId: string, lanId: string) => { const { data } = await api.get<{ subnet: string; ip: string }>( `/topologies/${topologyId}/lans/${lanId}/next-ip`, @@ -165,7 +231,7 @@ export function useMazeApi(toast?: (msg: string) => void): MazeApi { return data.ip; }, []); - const getNextSubnet = useCallback(async (base: string) => { + const getNextSubnet = useCallback(async (base: string = '10.0') => { const { data } = await api.get<{ subnet: string }>( `/topologies/next-subnet`, { params: { base } }, @@ -173,14 +239,93 @@ export function useMazeApi(toast?: (msg: string) => void): MazeApi { return data.subnet; }, []); - const commit = useCallback( - async (_topologyId: string, changes: PendingChange[]) => { - /* Phase-3 Steps 3–5 land the real endpoints. For now, just surface. */ - console.log('[MazeNET] commit stub — pending changes:', changes); - toast?.(`commit stubbed (${changes.length} change${changes.length === 1 ? '' : 's'})`); + const createLan = useCallback( + async (topologyId: string, body: CreateLanBody): Promise => { + const { data } = await api.post(`/topologies/${topologyId}/lans`, body); + return data; }, - [toast], + [], ); - return { listTopologies, getTopology, getServices, getNextIp, getNextSubnet, commit }; + const updateLan = useCallback( + async (topologyId: string, lanId: string, patch: Partial): Promise => { + const { data } = await api.patch(`/topologies/${topologyId}/lans/${lanId}`, patch); + return data; + }, + [], + ); + + const deleteLan = useCallback( + async (topologyId: string, lanId: string): Promise => { + await api.delete(`/topologies/${topologyId}/lans/${lanId}`); + }, + [], + ); + + const createDecky = useCallback( + async (topologyId: string, body: CreateDeckyBody): Promise => { + const { data } = await api.post(`/topologies/${topologyId}/deckies`, body); + return data; + }, + [], + ); + + const updateDecky = useCallback( + async (topologyId: string, uuid: string, patch: Partial): Promise => { + const { data } = await api.patch( + `/topologies/${topologyId}/deckies/${uuid}`, + patch, + ); + return data; + }, + [], + ); + + const deleteDecky = useCallback( + async (topologyId: string, uuid: string): Promise => { + await api.delete(`/topologies/${topologyId}/deckies/${uuid}`); + }, + [], + ); + + const attachEdge = useCallback( + async (topologyId: string, body: { decky_uuid: string; lan_id: string; is_bridge?: boolean; forwards_l3?: boolean }): Promise => { + const { data } = await api.post(`/topologies/${topologyId}/edges`, body); + return data; + }, + [], + ); + + const detachEdge = useCallback( + async (topologyId: string, edgeId: string): Promise => { + await api.delete(`/topologies/${topologyId}/edges/${edgeId}`); + }, + [], + ); + + const deployTopology = useCallback( + async (topologyId: string): Promise => { + await api.post(`/topologies/${topologyId}/deploy`, {}); + }, + [], + ); + + return useMemo( + () => ({ + listTopologies, createBlankTopology, getTopology, getServices, getArchetypes, + getNextIp, getNextSubnet, + createLan, updateLan, deleteLan, + createDecky, updateDecky, deleteDecky, + attachEdge, detachEdge, + deployTopology, + }), + [ + listTopologies, createBlankTopology, getTopology, getServices, getArchetypes, + getNextIp, getNextSubnet, + createLan, updateLan, deleteLan, + createDecky, updateDecky, deleteDecky, + attachEdge, detachEdge, + deployTopology, + ], + ); } diff --git a/decnet_web/src/components/MazeNET/useMazeInteraction.ts b/decnet_web/src/components/MazeNET/useMazeInteraction.ts index 3e658f16..20a39ceb 100644 --- a/decnet_web/src/components/MazeNET/useMazeInteraction.ts +++ b/decnet_web/src/components/MazeNET/useMazeInteraction.ts @@ -1,8 +1,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { Net, MazeNode, PendingChange } from './types'; +import type { Net, MazeNode } from './types'; export type ResizeHandle = 'e' | 'w' | 'n' | 's' | 'ne' | 'nw' | 'se' | 'sw'; +export type PaletteDragKind = 'network-subnet' | 'network-dmz' | 'archetype' | 'service'; +export interface PaletteDrag { + kind: PaletteDragKind; + slug: string; + label: string; + services?: string[]; + clientX: number; + clientY: number; +} + type Drag = | null | { type: 'pan'; startX: number; startY: number; panX: number; panY: number } @@ -15,8 +25,11 @@ interface Args { nodes: MazeNode[]; setNets: React.Dispatch>; setNodes: React.Dispatch>; - applyChange: (pc: PendingChange) => void; canvasRef: React.RefObject; + onPaletteDrop?: (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => void; + /** Structural callbacks — only these hit the backend. */ + onReparent?: (nodeId: string, fromNetId: string, toNetId: string) => void; + onAddEdge?: (fromNodeId: string, toNodeId: string) => void; } interface EdgeDraw { @@ -26,13 +39,20 @@ interface EdgeDraw { hoverTarget: string | null; } -export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef }: Args) { +export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, onPaletteDrop, onReparent, onAddEdge }: Args) { const [pan, setPan] = useState({ x: 0, y: 0 }); const [drag, setDrag] = useState(null); const [dropTargetId, setDropTargetId] = useState(null); const [edgeDraw, setEdgeDraw] = useState(null); + const [paletteDrag, setPaletteDrag] = useState(null); const edgeDrawRef = useRef(null); + const paletteDragRef = useRef(null); useEffect(() => { edgeDrawRef.current = edgeDraw; }, [edgeDraw]); + useEffect(() => { paletteDragRef.current = paletteDrag; }, [paletteDrag]); + + const startPaletteDrag = useCallback((d: Omit, e: React.MouseEvent) => { + setPaletteDrag({ ...d, clientX: e.clientX, clientY: e.clientY }); + }, []); /* Refs to avoid re-binding global listeners on every state change. */ const netsRef = useRef(nets); @@ -109,6 +129,11 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange useEffect(() => { const onMove = (e: MouseEvent) => { + const pd = paletteDragRef.current; + if (pd) { + setPaletteDrag({ ...pd, clientX: e.clientX, clientY: e.clientY }); + return; + } const ed = edgeDrawRef.current; if (ed) { const o = canvasOriginRef.current(); @@ -121,7 +146,7 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange if (!parent) return false; const ax = parent.x + n.x; const ay = parent.y + n.y; - return wx >= ax - 8 && wx <= ax + 8 && wy >= ay + 14 && wy <= ay + 30; + return wx >= ax - 12 && wx <= ax + 140 && wy >= ay && wy <= ay + 80; }); setEdgeDraw({ ...ed, toX: wx, toY: wy, hoverTarget: hover?.id ?? null }); return; @@ -150,7 +175,8 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange const node = nodesRef.current.find((n) => n.id === d.id); if (!node) return; const isObserved = node.kind === 'observed'; - const targetNet = !isObserved ? netsRef.current.find((net) => { + const isPinned = node.kind === 'decky' && !!node.decky_config?.forwards_l3; + const targetNet = !isObserved && !isPinned ? netsRef.current.find((net) => { if (net.id === node.netId) return false; return w.x >= net.x && w.x <= net.x + net.w && w.y >= net.y && w.y <= net.y + net.h; }) : undefined; @@ -158,8 +184,10 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange const parent = netsRef.current.find((n) => n.id === node.netId); if (!parent) return; - const nx = Math.max(8, Math.round(w.x - d.offX - parent.x)); - const ny = Math.max(28, Math.round(w.y - d.offY - parent.y)); + const maxX = Math.max(8, parent.w - 148); + const maxY = Math.max(28, parent.h - 88); + const nx = Math.min(maxX, Math.max(8, Math.round(w.x - d.offX - parent.x))); + const ny = Math.min(maxY, Math.max(28, Math.round(w.y - d.offY - parent.y))); setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, x: nx, y: ny } : n)); return; } @@ -187,14 +215,39 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange } }; - const onUp = () => { + const onUp = (e: MouseEvent) => { + const pd = paletteDragRef.current; + if (pd) { + setPaletteDrag(null); + const o = canvasOriginRef.current(); + const p = panRef.current; + const wx = e.clientX - o.x - p.x; + const wy = e.clientY - o.y - p.y; + const rect = canvasRef.current?.getBoundingClientRect(); + const inside = rect + ? e.clientX >= rect.left && e.clientX <= rect.right + && e.clientY >= rect.top && e.clientY <= rect.bottom + : false; + if (!inside) return; + const overNet = netsRef.current.find( + (n) => wx >= n.x && wx <= n.x + n.w && wy >= n.y && wy <= n.y + n.h, + ); + const overNode = nodesRef.current.find((n) => { + const parent = netsRef.current.find((nn) => nn.id === n.netId); + if (!parent) return false; + const ax = parent.x + n.x; + const ay = parent.y + n.y; + return wx >= ax && wx <= ax + 140 && wy >= ay && wy <= ay + 80; + }); + onPaletteDrop?.(pd, { x: wx, y: wy }, overNet?.id ?? null, overNode?.id ?? null); + return; + } const ed = edgeDrawRef.current; if (ed) { if (ed.hoverTarget && ed.hoverTarget !== ed.fromId) { const target = nodesRef.current.find((n) => n.id === ed.hoverTarget); if (target && target.kind !== 'observed') { - const id = `e-${ed.fromId}-${ed.hoverTarget}-${Date.now()}`; - applyChange({ op: 'add_edge', payload: { id, from: ed.fromId, to: ed.hoverTarget } }); + onAddEdge?.(ed.fromId, ed.hoverTarget); } } setEdgeDraw(null); @@ -215,22 +268,12 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange const absY = parentOld.y + node.y; const relX = Math.max(8, absX - parentNew.x); const relY = Math.max(28, absY - parentNew.y); + const fromNetId = node.netId; setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, netId: target, x: relX, y: relY } : n)); - applyChange({ op: 'detach_decky', payload: { nodeId: d.id, netId: node.netId } }); - applyChange({ op: 'attach_decky', payload: { - nodeId: d.id, netId: target, archetype: node.archetype, name: node.name, - x: relX, y: relY, services: node.services, - }}); + onReparent?.(d.id, fromNetId, target); } - } else if (node && node.kind === 'decky') { - applyChange({ op: 'update_decky', payload: { nodeId: node.id, patch: { x: node.x, y: node.y } } }); } - } else if (d.type === 'net') { - const net = netsRef.current.find((n) => n.id === d.id); - if (net) applyChange({ op: 'update_lan', payload: { id: net.id, patch: { x: net.x, y: net.y } } }); - } else if (d.type === 'resize') { - const net = netsRef.current.find((n) => n.id === d.id); - if (net) applyChange({ op: 'update_lan', payload: { id: net.id, patch: { x: net.x, y: net.y, w: net.w, h: net.h } } }); + /* Intra-net moves and net/resize drags are cosmetic — never persisted. */ } setDropTargetId(null); @@ -243,7 +286,7 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; - }, [applyChange, setNets, setNodes, dropTargetId]); + }, [setNets, setNodes, dropTargetId, onPaletteDrop, onReparent, onAddEdge, canvasRef]); const resetPan = useCallback(() => setPan({ x: 0, y: 0 }), []); @@ -252,6 +295,8 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange dropTargetId, dragging: drag !== null, edgeDraw, + paletteDrag, + startPaletteDrag, onCanvasMouseDown, onNodeMouseDown, onNetMouseDown,