From b928f5d9320f8b14c79e24b661bc0befef5ebfc9 Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 20 Apr 2026 19:16:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(web/mazenet):=20render=20canvas=20?= =?UTF-8?q?=E2=80=94=20net=20boxes,=20node=20cards,=20bezier=20edges,=20to?= =?UTF-8?q?pology=20loader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- decnet_web/src/components/MazeNET/Canvas.tsx | 140 ++++++++++++++++++ decnet_web/src/components/MazeNET/MazeNET.tsx | 59 +++++--- decnet_web/src/components/MazeNET/NetBox.tsx | 69 +++++++++ .../src/components/MazeNET/NodeCard.tsx | 46 ++++++ 4 files changed, 296 insertions(+), 18 deletions(-) create mode 100644 decnet_web/src/components/MazeNET/Canvas.tsx create mode 100644 decnet_web/src/components/MazeNET/NetBox.tsx create mode 100644 decnet_web/src/components/MazeNET/NodeCard.tsx diff --git a/decnet_web/src/components/MazeNET/Canvas.tsx b/decnet_web/src/components/MazeNET/Canvas.tsx new file mode 100644 index 00000000..4fdb1159 --- /dev/null +++ b/decnet_web/src/components/MazeNET/Canvas.tsx @@ -0,0 +1,140 @@ +import React, { useMemo } from 'react'; +import NetBox from './NetBox'; +import NodeCard from './NodeCard'; +import type { Net, MazeNode, Edge } from './types'; +import type { Selection } from './Inspector'; + +interface Props { + nets: Net[]; + nodes: MazeNode[]; + edges: Edge[]; + selection: Selection; + setSelection: (s: Selection) => void; + pan?: { x: number; y: number }; +} + +const NODE_W = 140; +const NODE_HEAD_H = 22; + +const Canvas: React.FC = ({ nets, nodes, edges, selection, setSelection, pan = { x: 0, y: 0 } }) => { + const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]); + + const absPos = (node: MazeNode) => { + const net = netById.get(node.netId); + return { x: (net?.x ?? 0) + node.x, y: (net?.y ?? 0) + node.y }; + }; + + /* nets touched by any edge */ + const activeNetIds = useMemo(() => { + const nodeNet = new Map(nodes.map((n) => [n.id, n.netId])); + const ids = new Set(); + for (const e of edges) { + const a = nodeNet.get(e.from); const b = nodeNet.get(e.to); + if (a) ids.add(a); if (b) ids.add(b); + } + return ids; + }, [nodes, edges]); + + const selNetId = selection?.type === 'net' ? selection.id : null; + const selNodeId = selection?.type === 'node' ? selection.id : null; + const selEdgeId = selection?.type === 'edge' ? selection.id : null; + + return ( +
{ if (e.target === e.currentTarget) setSelection(null); }} + > +
+ + + + + + + + +
+ +
+ + + + + + + + + + + + + {edges.map((e) => { + const from = nodes.find((n) => n.id === e.from); + const to = nodes.find((n) => n.id === e.to); + if (!from || !to) return null; + const a = absPos(from); const b = absPos(to); + const x1 = a.x + NODE_W, y1 = a.y + NODE_HEAD_H; + const x2 = b.x, y2 = b.y + NODE_HEAD_H; + const cx = (x1 + x2) / 2; + const d = `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`; + const klass = e.traffic === 'hot' ? 'hot' : e.traffic === 'active' ? 'active' : ''; + const marker = e.traffic === 'hot' ? 'arrow-alert' : e.traffic === 'active' ? 'arrow-violet' : 'arrow-matrix'; + const isSel = e.id === selEdgeId; + return ( + { ev.stopPropagation(); setSelection({ type: 'edge', id: e.id }); }}> + + + {e.label && ( + + {e.label} + + )} + + ); + })} + + +
+ {nets.map((net) => { + const inactive = net.kind !== 'internet' && !activeNetIds.has(net.id); + return ( + setSelection({ type: 'net', id })} + /> + ); + })} + {nodes.map((n) => { + const p = absPos(n); + return ( + setSelection({ type: 'node', id })} + /> + ); + })} +
+
+ +
+
HOT
+
ACTIVE
+
IDLE
+
+
+ ); +}; + +export default Canvas; diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index 0b1cfe7d..4a3249d7 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -1,7 +1,9 @@ import React, { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { PanelRightOpen, PanelRightClose, RotateCcw, UploadCloud } from 'lucide-react'; import './MazeNET.css'; import Palette from './Palette'; +import Canvas from './Canvas'; import Inspector from './Inspector'; import type { Selection } from './Inspector'; import { DEFAULT_SERVICES, DEMO_NETS, DEMO_NODES, DEMO_EDGES } from './data'; @@ -11,6 +13,8 @@ import { useMazeApi } from './useMazeApi'; const MazeNET: React.FC = () => { const api = useMazeApi(); + const [params] = useSearchParams(); + const topologyId = params.get('topology'); const [nets, setNets] = useState(DEMO_NETS); const [nodes, setNodes] = useState(DEMO_NODES); @@ -19,15 +23,40 @@ const MazeNET: React.FC = () => { const [selection, setSelection] = useState(null); const [inspectorOpen, setInspectorOpen] = useState(true); const [services, setServices] = useState(DEFAULT_SERVICES); + const [loadErr, setLoadErr] = useState(null); + /* Load service catalog from API (fall back to defaults if 401/offline). */ useEffect(() => { let cancelled = false; api.getServices().then((s) => { if (!cancelled) setServices(s); }).catch(() => {}); return () => { cancelled = true; }; }, [api]); + /* If ?topology= is present, hydrate from the real backend. */ + useEffect(() => { + 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; }; + }, [api, topologyId]); + const onReset = () => { - setNets(DEMO_NETS); setNodes(DEMO_NODES); setEdges(DEMO_EDGES); + 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); + } setSelection(null); }; @@ -37,8 +66,10 @@ const MazeNET: React.FC = () => {

MAZENET

- NETWORK OF NETWORKS · {nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS ·{' '} + {topologyId ? `TOPOLOGY ${topologyId} · ` : 'DEMO · '} + {nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS ·{' '} {pending.length > 0 ? `${pending.length} UNCOMMITTED` : 'LIVE'} + {loadErr && · {loadErr}}
@@ -58,7 +89,7 @@ const MazeNET: React.FC = () => { type="button" className="maze-btn" disabled={pending.length === 0} - onClick={() => api.commit('', pending)} + onClick={() => api.commit(topologyId ?? '', pending)} > COMMIT {pending.length > 0 ? `(${pending.length})` : ''} @@ -70,21 +101,13 @@ const MazeNET: React.FC = () => { style={{ gridTemplateColumns: inspectorOpen ? '240px 1fr 320px' : '240px 1fr' }} > - -
-
- - - - - - - - -
-
CANVAS COMES ONLINE IN STEP 4
-
- + {inspectorOpen && ( void; + children?: React.ReactNode; +} + +const NetBox: React.FC = ({ net, selected, dropTarget, inactive, onSelect, children }) => { + const classes = [ + 'maze-net-box', + net.kind === 'internet' ? 'internet' : '', + selected ? 'selected' : '', + dropTarget ? 'drop-target' : '', + inactive ? 'inactive' : '', + ].filter(Boolean).join(' '); + + const Icon = net.kind === 'internet' ? Globe : GitMerge; + const resizable = net.kind !== 'internet'; + + return ( +
{ + if (e.target === e.currentTarget) { e.stopPropagation(); onSelect?.(net.id); } + }} + > +
{ e.stopPropagation(); onSelect?.(net.id); }} + > +
+ + {net.label} + {inactive && ( + + INACTIVE + + )} +
+ {net.cidr} +
+ {resizable && ( + <> +
+
+
+
+
+
+
+
+ + )} + {children} +
+ ); +}; + +export default NetBox; diff --git a/decnet_web/src/components/MazeNET/NodeCard.tsx b/decnet_web/src/components/MazeNET/NodeCard.tsx new file mode 100644 index 00000000..07f9e1e3 --- /dev/null +++ b/decnet_web/src/components/MazeNET/NodeCard.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import type { MazeNode } from './types'; + +interface Props { + node: MazeNode; + absX: number; + absY: number; + selected: boolean; + onSelect?: (id: string) => void; +} + +const NodeCard: React.FC = ({ node, absX, absY, selected, onSelect }) => { + const classes = [ + 'maze-node', + node.kind === 'observed' ? 'observed' : '', + node.status === 'hot' ? 'hot' : '', + selected ? 'selected' : '', + ].filter(Boolean).join(' '); + + return ( +
{ e.stopPropagation(); onSelect?.(node.id); }} + > +
{node.name}
+
{node.archetype.toUpperCase()}
+ {node.services.length > 0 && ( +
+ {node.services.map((s) => ( + + {s} + + ))} +
+ )} + {node.kind === 'decky' && <> + + + } + {node.kind === 'observed' && } +
+ ); +}; + +export default NodeCard;