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:
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user