From c4be1c721de114ac7612e8a78c2e66b13f10306f Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 20 Apr 2026 23:47:29 -0400 Subject: [PATCH] fix(mazenet): auto-layout nets + deckies in a deterministic grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping more than one LAN near the same spot stacked the NetBox rectangles on top of each other, and multiple deckies in a LAN landed on identical per-LAN coordinates. Since canvas position persistence is deferred (localStorage pass), the stored x/y are not load-bearing — compute layout from the topology graph instead. adaptTopology now lays LANs out in a 3-col grid with the DMZ first and stacks deckies 2-wide inside their home LAN. New LAN palette drops append to the same grid, ignoring the raw drop point. --- decnet_web/src/components/MazeNET/MazeNET.tsx | 11 +++- .../src/components/MazeNET/useMazeApi.ts | 64 +++++++++++++------ 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index e4c5d2e1..701838ea 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -67,9 +67,14 @@ const MazeNET: React.FC = () => { 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); + // Append to the 3-col grid matching adaptTopology so new drops + // never land on top of existing LANs. The raw drop point is + // ignored — cleaner than trying to resolve collisions after. + 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); diff --git a/decnet_web/src/components/MazeNET/useMazeApi.ts b/decnet_web/src/components/MazeNET/useMazeApi.ts index 308c317a..fd85d448 100644 --- a/decnet_web/src/components/MazeNET/useMazeApi.ts +++ b/decnet_web/src/components/MazeNET/useMazeApi.ts @@ -62,35 +62,63 @@ export interface HydratedTopology { * 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) => ({ + // Auto-layout: DMZ pinned top-left, subnets flow in a grid to the right. + // We ignore lan.x/lan.y from the backend because canvas position + // persistence is deferred (handled via localStorage in a later pass). + // Computing layout from the graph keeps the canvas readable no matter + // how sloppy the original drop points were. + const NET_W = 300; + const NET_H = 240; + const GAP_X = 40; + const GAP_Y = 40; + const COLS = 3; + const dmzs = detail.lans.filter((l) => l.is_dmz); + const subnets = detail.lans.filter((l) => !l.is_dmz); + const ordered = [...dmzs, ...subnets]; + const nets: Net[] = ordered.map((lan, i) => ({ id: lan.id, label: lan.name.toUpperCase(), cidr: lan.subnet, kind: lan.is_dmz ? 'dmz' : 'subnet', - x: lan.x ?? 40 + (i % 3) * 320, - y: lan.y ?? 40 + Math.floor(i / 3) * 280, - w: 300, - h: 240, + x: GAP_X + (i % COLS) * (NET_W + GAP_X), + y: GAP_Y + Math.floor(i / COLS) * (NET_H + GAP_Y), + w: NET_W, + h: NET_H, })); + // Home LAN = first edge; a multi-homed gateway is drawn inside its + // home LAN, membership in others is expressed via the edge list. const firstLanFor = new Map(); for (const e of detail.edges) { if (!firstLanFor.has(e.decky_uuid)) firstLanFor.set(e.decky_uuid, e.lan_id); } - const nodes: MazeNode[] = detail.deckies.map((d, i): DeckyNode => ({ - kind: 'decky', - id: d.uuid, - netId: firstLanFor.get(d.uuid) ?? (nets[0]?.id ?? ''), - name: d.name, - 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, - y: d.y ?? 60 + Math.floor(i / 2) * 90, - ip: d.ip ?? undefined, - decky_config: d.decky_config ?? undefined, - })); + // Layout deckies in a 2-column grid inside their home LAN so two + // members never overlap regardless of backend x/y. Same reasoning as + // the LAN grid above. + const NODE_COL_W = 140; + const NODE_ROW_H = 82; + const NODE_X0 = 12; + const NODE_Y0 = 40; + const perNetIndex = new Map(); + const nodes: MazeNode[] = detail.deckies.map((d): DeckyNode => { + const homeNetId = firstLanFor.get(d.uuid) ?? (nets[0]?.id ?? ''); + const idx = perNetIndex.get(homeNetId) ?? 0; + perNetIndex.set(homeNetId, idx + 1); + return { + kind: 'decky', + id: d.uuid, + netId: homeNetId, + name: d.name, + 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: NODE_X0 + (idx % 2) * NODE_COL_W, + y: NODE_Y0 + Math.floor(idx / 2) * NODE_ROW_H, + ip: d.ip ?? undefined, + decky_config: d.decky_config ?? undefined, + }; + }); const byLan = new Map(); for (const e of detail.edges) {