fix(mazenet): auto-layout nets + deckies in a deterministic grid

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.
This commit is contained in:
2026-04-20 23:47:29 -04:00
parent b261e8e5fa
commit c4be1c721d
2 changed files with 54 additions and 21 deletions

View File

@@ -67,9 +67,14 @@ const MazeNET: React.FC = () => {
flashErr(null, 'topology already has a DMZ'); flashErr(null, 'topology already has a DMZ');
return; return;
} }
const w = 320, h = 240; // Append to the 3-col grid matching adaptTopology so new drops
const x = Math.round(world.x - w / 2); // never land on top of existing LANs. The raw drop point is
const y = Math.round(world.y - h / 2); // 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()}`; const name = isDmz ? `dmz-${hex4()}` : `subnet-${hex4()}`;
try { try {
const subnet = await api.getNextSubnet().catch(() => undefined); const subnet = await api.getNextSubnet().catch(() => undefined);

View File

@@ -62,35 +62,63 @@ export interface HydratedTopology {
* placement. Decky-to-decky traffic edges are derived from * placement. Decky-to-decky traffic edges are derived from
* shared-LAN co-membership for visualization only. */ * shared-LAN co-membership for visualization only. */
export function adaptTopology(detail: TopologyDetail): HydratedTopology { 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, id: lan.id,
label: lan.name.toUpperCase(), label: lan.name.toUpperCase(),
cidr: lan.subnet, cidr: lan.subnet,
kind: lan.is_dmz ? 'dmz' : 'subnet', kind: lan.is_dmz ? 'dmz' : 'subnet',
x: lan.x ?? 40 + (i % 3) * 320, x: GAP_X + (i % COLS) * (NET_W + GAP_X),
y: lan.y ?? 40 + Math.floor(i / 3) * 280, y: GAP_Y + Math.floor(i / COLS) * (NET_H + GAP_Y),
w: 300, w: NET_W,
h: 240, 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<string, string>(); const firstLanFor = new Map<string, string>();
for (const e of detail.edges) { for (const e of detail.edges) {
if (!firstLanFor.has(e.decky_uuid)) firstLanFor.set(e.decky_uuid, e.lan_id); if (!firstLanFor.has(e.decky_uuid)) firstLanFor.set(e.decky_uuid, e.lan_id);
} }
const nodes: MazeNode[] = detail.deckies.map((d, i): DeckyNode => ({ // Layout deckies in a 2-column grid inside their home LAN so two
kind: 'decky', // members never overlap regardless of backend x/y. Same reasoning as
id: d.uuid, // the LAN grid above.
netId: firstLanFor.get(d.uuid) ?? (nets[0]?.id ?? ''), const NODE_COL_W = 140;
name: d.name, const NODE_ROW_H = 82;
archetype: (d.decky_config as { archetype?: string } | null)?.archetype ?? 'linux-server', const NODE_X0 = 12;
services: d.services, const NODE_Y0 = 40;
status: d.state === 'running' ? 'active' : d.state === 'failed' ? 'hot' : 'idle', const perNetIndex = new Map<string, number>();
x: d.x ?? 20 + (i % 2) * 160, const nodes: MazeNode[] = detail.deckies.map((d): DeckyNode => {
y: d.y ?? 60 + Math.floor(i / 2) * 90, const homeNetId = firstLanFor.get(d.uuid) ?? (nets[0]?.id ?? '');
ip: d.ip ?? undefined, const idx = perNetIndex.get(homeNetId) ?? 0;
decky_config: d.decky_config ?? undefined, 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<string, string[]>(); const byLan = new Map<string, string[]>();
for (const e of detail.edges) { for (const e of detail.edges) {