Files
mesh-lab/src/diagram/drawio.ts
T
jschoubben 54d417fdb2 Group each public network with what is behind it
The drawings were confusing, and looking at them showed why: a single stack
ordered by depth put a private network far from the public one it sits behind,
so a gateway's link to the outside ran the full height of the picture through
three networks it had nothing to do with — and two such links overlapped, so
they read as one wire.

Now each public network is followed by everything behind it, depth first. Every
gateway is adjacent to the network it serves, every link is a short stub, and
"behind" is shown by INDENTATION rather than by a line to follow. Gaps are sized
to what they hold, so a gap with no gateway in it takes no room. Transit is not
on a boundary — it reaches every public network at once — so it is stated once
at the top instead of drawing a line to each.

Both sources now order by name rather than by the order the source yielded. The
hypervisor cannot know declaration order, and two pictures laid out differently
cannot be compared, which is the whole point of having both.

Fixed while testing: the gap size and the box placement each decided separately
which network a gateway sat above, and disagreed — reserving the gap above one
sibling while drawing the box above the other, which landed a gateway on top of
a machine in an unrelated network. Both now read one map.

Five new tests, run across every scenario: no link crosses a network it does not
touch, no box is drawn inside a network it is not on, a network behind another
is indented inside it, a public network is not split apart by another group, and
both sources lay the same topology out identically.
2026-08-24 23:27:11 +02:00

456 lines
19 KiB
TypeScript

/**
* Render a diagram as draw.io XML.
*
* draw.io rather than a rendered image because the output is **editable**: an automatic
* layout of a network is usually 90% right and needs a human nudge, and a picture nobody
* can adjust gets regenerated rather than corrected.
*
* Laid out as lanes rather than by a generic graph algorithm. A network topology has a
* natural vertical order — public at the top, each private network below the one it sits
* behind — and a force-directed layout throws that away, producing a picture that is
* correct and unreadable.
*
* Two kinds of symbol, on purpose:
*
* - the **shape** says what a resource is, and is fixed per kind — a server is always the
* server shape, a gateway always the router shape;
* - the **badges** say what is true about that particular one, and come entirely from
* metadata: translation, forwardability, mapping expiry, whether it refuses inbound.
*
* Which matters because the interesting properties of a network are exactly the ones with
* no visual consequence. An address that is translated looks identical to one that is not.
*
* Shapes come from draw.io's bundled network library, so the file needs no external images
* and renders anywhere draw.io opens. Badges use core mxGraph primitives rather than icon
* shapes: a stencil name that turns out not to exist renders as an empty box, and a badge
* that silently disappears is worse than a plain one that does not.
*/
import type { Diagram, DiagramMachine, DiagramSegment } from "./model.ts";
const LANE_MIN_HEIGHT = 170;
const LANE_EMPTY_HEIGHT = 62;
/** Depth becomes indentation, which is how "behind" is shown without drawing a line. */
const INDENT = 40;
/** A gap holding nothing needs no room; one between top-level groups needs a little. */
const TIGHT_GAP = 24;
const GROUP_GAP = 50;
// Wide enough that a gateway placed on a boundary sits BETWEEN the lanes rather than on
// top of one — an earlier value let a router's box cover the lane's own name and ranges.
const LANE_GAP = 110;
const LANE_X = 40;
const LANE_WIDTH = 980;
const NODE_WIDTH = 150;
const NODE_HEIGHT = 60;
const SLOT_WIDTH = NODE_WIDTH + 60;
const SLOTS_PER_ROW = Math.max(1, Math.floor((LANE_WIDTH - 60) / SLOT_WIDTH));
const BADGE = 18;
const STYLE = {
publicLane:
"rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;dashed=1;" +
"verticalAlign=top;align=left;spacingLeft=10;spacingTop=4;fontSize=11;fontStyle=1",
privateLane:
"rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;dashed=1;" +
"verticalAlign=top;align=left;spacingLeft=10;spacingTop=4;fontSize=11;fontStyle=1",
machine:
"sketch=0;html=1;verticalLabelPosition=bottom;verticalAlign=top;align=center;" +
"shape=mxgraph.networks.server;fillColor=#ffffff;strokeColor=#333333;fontSize=10",
router:
"sketch=0;html=1;verticalLabelPosition=bottom;verticalAlign=top;align=center;" +
"shape=mxgraph.networks.router;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10",
transit:
"sketch=0;html=1;verticalLabelPosition=bottom;verticalAlign=top;align=center;" +
"shape=mxgraph.networks.cloud;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10",
link: "edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=none;strokeColor=#666666",
note: "text;html=1;align=left;verticalAlign=top;fontSize=9;fontColor=#666666",
badge:
"ellipse;whiteSpace=wrap;html=1;fontSize=9;fontStyle=1;fontColor=#ffffff;" +
"verticalAlign=middle;align=center;spacing=0",
};
/** A metadata fact, rendered as a mark on the resource it is a fact about. */
interface Badge {
code: string;
fill: string;
stroke: string;
/** The full sentence, shown on hover — the code alone would be a private language. */
tip: string;
}
const COLOUR = {
amber: { fill: "#d79b00", stroke: "#b07000" },
red: { fill: "#b85450", stroke: "#8c3a37" },
green: { fill: "#82b366", stroke: "#5b8047" },
blue: { fill: "#6c8ebf", stroke: "#4b6a94" },
grey: { fill: "#9e9e9e", stroke: "#757575" },
};
/**
* Turn a machine's recorded facts into marks.
*
* Absence is deliberately not a badge. A gateway that does not translate gets no NAT mark
* rather than a struck-through one, because a diagram that badges every negative is a
* diagram nobody reads.
*/
function badgesFor(machine: DiagramMachine): { badges: Badge[]; remaining: string[] } {
const badges: Badge[] = [];
const remaining: string[] = [];
if (machine.status) {
const running = machine.status.toLowerCase() === "running";
badges.push({
code: running ? "▶" : "■",
...(running ? COLOUR.green : COLOUR.grey),
tip: `status: ${machine.status}`,
});
}
for (const note of machine.notes) {
const nat = /^NAT (.+)$/.exec(note);
const ttl = /^mappings expire (\d+)s$/.exec(note);
if (nat) {
badges.push({ code: "N", ...COLOUR.amber, tip: `translates ${nat[1]} — addresses behind it are not seen outside` });
} else if (note === "NOT forwardable") {
badges.push({ code: "F", ...COLOUR.red, tip: "no port forwarding — nothing behind this gateway is reachable from outside" });
} else if (note === "forwardable") {
badges.push({ code: "F", ...COLOUR.green, tip: "port forwarding available" });
} else if (ttl) {
badges.push({ code: "T", ...COLOUR.blue, tip: `mappings expire after ${ttl[1]}s of no traffic` });
} else if (note === "refuses inbound") {
badges.push({ code: "D", ...COLOUR.red, tip: "refuses inbound connections it did not start" });
} else if (note === "container") {
badges.push({ code: "C", ...COLOUR.grey, tip: "container — scenery, nothing under test runs here" });
} else if (note === "virtual machine") {
badges.push({ code: "VM", ...COLOUR.blue, tip: "virtual machine — its own kernel" });
} else {
remaining.push(note);
}
}
return { badges, remaining };
}
function escapeXml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function shapeFor(machine: DiagramMachine): string {
return machine.kind === "router"
? STYLE.router
: machine.kind === "transit"
? STYLE.transit
: STYLE.machine;
}
interface Cell {
id: string;
value: string;
style: string;
x: number;
y: number;
w: number;
h: number;
/** Rendered as a wrapping <object>, which is how draw.io carries a tooltip. */
tip?: string;
}
export function toDrawio(diagram: Diagram): string {
const cells: Cell[] = [];
const edges: { id: string; source: string; target: string }[] = [];
/**
* Order: each public network, then everything behind it, depth first.
*
* A single stack sorted by depth put a private network far from the public one it sits
* behind, so a gateway's link to the outside ran the height of the picture and crossed
* networks it had nothing to do with — two such links overlapped and read as one wire.
* Grouping makes every gateway adjacent to both lanes it joins, and every link short.
*/
const byName = new Map(diagram.segments.map((s) => [s.name, s]));
const childrenOf = new Map<string, string[]>();
for (const segment of diagram.segments) {
if (!segment.behind) continue;
childrenOf.set(segment.behind, [...(childrenOf.get(segment.behind) ?? []), segment.name]);
}
const lanes: DiagramSegment[] = [];
const placed = new Set<string>();
const walk = (name: string): void => {
const segment = byName.get(name);
if (!segment || placed.has(name)) return;
placed.add(name);
lanes.push(segment);
for (const child of [...(childrenOf.get(name) ?? [])].sort()) walk(child);
};
// By name, not by the order the source happened to yield them. The hypervisor cannot know
// declaration order, so ordering by it would give the two pictures different shapes and
// make the comparison they exist for impossible to read.
const byNameOrder = [...diagram.segments].sort((a, b) => a.name.localeCompare(b.name));
for (const segment of byNameOrder) if (segment.kind === "public") walk(segment.name);
for (const segment of byNameOrder) walk(segment.name); // isolated ones
const laneX = (segment: DiagramSegment) => LANE_X + segment.depth * INDENT;
const laneW = (segment: DiagramSegment) => LANE_WIDTH - segment.depth * INDENT;
const slotsIn = (segment: DiagramSegment) =>
Math.max(1, Math.floor((laneW(segment) - 60) / SLOT_WIDTH));
// A lane is sized to what it holds. Fixed heights meant a lane with enough machines to
// wrap onto a second row drew that row outside the box it was supposed to be inside.
const occupants = new Map<string, number>();
for (const machine of diagram.machines) {
// Only a machine that sits INSIDE a lane occupies it. A gateway is counted against the
// segment it faces but drawn in the gap, so counting it here left a segment holding no
// machines at full height with nothing in it.
if (machine.attachments.length !== 1) continue;
const only = machine.attachments[0]?.segment;
if (only) occupants.set(only, (occupants.get(only) ?? 0) + 1);
}
const laneHeight = (segment: DiagramSegment): number => {
const rows = Math.ceil((occupants.get(segment.name) ?? 0) / slotsIn(segment));
// A segment whose only occupants are the gateways in the gaps beside it holds nothing
// itself, so it collapses to its own name and ranges. Left at full height it padded a
// layered scenario with empty boxes and pushed the interesting rows apart.
if (rows === 0) return LANE_EMPTY_HEIGHT;
return Math.max(LANE_MIN_HEIGHT, 45 + rows * (NODE_HEIGHT + 45));
};
/**
* Which lane each gateway is drawn above — decided ONCE, by position in the lane order,
* and used both to size the gap and to place the box.
*
* Two rules deciding this separately is what put a gateway inside an unrelated network:
* the gap was reserved above one sibling while the box was drawn above the other, so it
* overflowed into the lane above and landed on top of a machine.
*/
const laneIndex = new Map(lanes.map((segment, index) => [segment.name, index]));
const servedTop = new Map<DiagramMachine, string>();
for (const machine of diagram.machines) {
if (machine.kind === "transit" || machine.attachments.length < 2) continue;
const served = machine.attachments
.slice(1)
.map((a) => a.segment)
.filter((n) => laneIndex.has(n));
if (served.length === 0) continue;
servedTop.set(
machine,
served.reduce((best, n) => ((laneIndex.get(n) ?? 0) < (laneIndex.get(best) ?? 0) ? n : best), served[0]!),
);
}
const gatewayAbove = new Set(servedTop.values());
const transit = diagram.machines.find((m) => m.kind === "transit");
const laneY = new Map<string, number>();
const laneH = new Map<string, number>();
const laneOf = new Map<string, DiagramSegment>();
let cursor = transit ? 80 + NODE_HEIGHT + GROUP_GAP : 80;
lanes.forEach((segment, index) => {
const y = cursor;
const h = laneHeight(segment);
laneY.set(segment.name, y);
laneH.set(segment.name, h);
laneOf.set(segment.name, segment);
const next = lanes[index + 1];
cursor =
y + h +
(!next ? 0 : gatewayAbove.has(next.name) ? LANE_GAP : next.depth === 0 ? GROUP_GAP : TIGHT_GAP);
const facts = [
segment.cidr.join(" "),
segment.mtu ? `MTU ${segment.mtu}` : "",
segment.behind ? `behind ${segment.behind}` : segment.kind === "public" ? "public" : "isolated",
].filter(Boolean);
cells.push({
id: `lane-${segment.name}`,
value: `<b>${segment.name}</b><br/><font style="font-size:10px">${facts.join(" · ")}</font>`,
style: segment.kind === "public" ? STYLE.publicLane : STYLE.privateLane,
x: laneX(segment),
y,
w: laneW(segment),
h,
});
});
// Cell ids are numbered, not named. A declared machine is free to be called `gw-home`,
// which is also what the gateway serving `home` is called — two cells sharing an id makes
// a file draw.io opens with one of them missing, silently.
const idOf = new Map<DiagramMachine, string>();
diagram.machines.forEach((machine, index) => idOf.set(machine, `m${index}`));
// A machine sits inside its first lane. A router straddles, so it is placed between the
// lanes it joins — which is what makes the picture readable at a glance.
const perLane = new Map<string, number>();
let detachedSlot = 0;
for (const machine of diagram.machines) {
const id = idOf.get(machine)!;
const { badges, remaining } = badgesFor(machine);
let x: number;
let y = 80;
if (machine.kind === "transit") {
// Transit is not on a boundary — it reaches every public network at once, so a line
// to each would cross everything between. Stated once, at the top, where it applies.
x = LANE_X;
y = 80;
} else if (machine.attachments.length === 0) {
// Detached: parked to the side, because it genuinely is nowhere.
x = LANE_X + LANE_WIDTH + 60;
y = 80 + detachedSlot * (NODE_HEIGHT + 50);
detachedSlot++;
} else if (machine.attachments.filter((a) => laneY.has(a.segment)).length > 1) {
// A gateway sits in the gap directly above the lane it SERVES, indented to that lane,
// so it reads as the door into it rather than a box floating nearby. Its first
// attachment is the segment it faces outward on; the rest are the ones it serves.
const top = servedTop.get(machine) ?? machine.attachments[1]?.segment ?? "";
const segment = laneOf.get(top);
x = (segment ? laneX(segment) : LANE_X) + 40;
y = (laneY.get(top) ?? 80) - LANE_GAP / 2 - NODE_HEIGHT / 2;
} else {
const only = machine.attachments[0]!.segment;
const segment = laneOf.get(only);
const per = segment ? slotsIn(segment) : SLOTS_PER_ROW;
const slot = perLane.get(only) ?? 0;
perLane.set(only, slot + 1);
// Wrap onto a second row rather than running off the end of the lane. An earlier
// version placed slot 5 outside the box it was supposed to be inside.
x = (segment ? laneX(segment) : LANE_X) + 40 + (slot % per) * SLOT_WIDTH;
y = (laneY.get(only) ?? 80) + 45 + Math.floor(slot / per) * (NODE_HEIGHT + 45);
}
const addresses = machine.attachments
.flatMap((a) => a.addresses)
.slice(0, 3)
.join("<br/>");
const label =
`<b>${machine.name}</b>` +
(addresses ? `<br/><font style="font-size:9px">${addresses}</font>` : "");
cells.push({
id,
value: label,
style: shapeFor(machine),
x,
y,
w: NODE_WIDTH,
h: NODE_HEIGHT,
tip: machine.attachments
.map((a) => `${a.segment}${a.addresses.length ? `: ${a.addresses.join(", ")}` : ""}`)
.join(" · "),
});
// Badges sit along the top edge and read left to right in the order the facts are
// stated, which is the order the declaration states them in.
const badgeRun = badges.length * BADGE + Math.max(0, badges.length - 1) * 3;
badges.forEach((badge, index) => {
cells.push({
id: `${id}-b${index}`,
value: badge.code,
style: `${STYLE.badge};fillColor=${badge.fill};strokeColor=${badge.stroke}`,
x: x + NODE_WIDTH - badgeRun + index * (BADGE + 3),
y: y - BADGE / 2,
w: BADGE,
h: BADGE,
tip: badge.tip,
});
});
if (remaining.length > 0) {
cells.push({
id: `${id}-n`,
value: remaining.join("<br/>"),
style: STYLE.note,
x,
y: y + NODE_HEIGHT + 4,
w: NODE_WIDTH + 60,
h: 13 * remaining.length,
});
}
// A link is drawn only where it is short. With the grouping above a gateway is always
// adjacent to both lanes it joins; anything else would be a line crossing networks it
// does not touch, and a lane already says "behind X" in words.
if (machine.kind === "transit") continue;
for (const attachment of machine.attachments) {
const ly = laneY.get(attachment.segment);
const lh = laneH.get(attachment.segment);
if (ly === undefined || lh === undefined) continue;
const above = ly + lh <= y && y - (ly + lh) <= LANE_GAP;
const below = y + NODE_HEIGHT <= ly && ly - (y + NODE_HEIGHT) <= LANE_GAP;
const within = y >= ly && y + NODE_HEIGHT <= ly + lh;
if (!above && !below && !within) continue;
edges.push({ id: `${id}-e-${attachment.segment}`, source: id, target: `lane-${attachment.segment}` });
}
}
const header =
`<b>${diagram.title}</b> — ${diagram.source === "live" ? "as raised" : "as declared"}`;
cells.unshift({ id: "title", value: header, style: STYLE.note + ";fontSize=14", x: LANE_X, y: 30, w: 600, h: 24 });
const legend = [
"<b>badges</b> — read from metadata, not from the file that asked for it",
"N translates · F forwarding (green yes, red no) · T mappings expire",
"D refuses inbound · C container · VM virtual machine · ▶ running",
].join("<br/>");
cells.push({
id: "legend",
value: legend,
style: STYLE.note,
x: LANE_X,
y: cursor + 10,
w: LANE_WIDTH,
h: 48,
});
// A cell with a tooltip has to be wrapped in <object> — draw.io reads `tooltip` from the
// wrapper, never from mxCell itself, and putting it on the mxCell loses it without error.
const vertex = (c: Cell): string => {
const geometry =
` <mxGeometry x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" as="geometry" />\n`;
if (c.tip) {
return (
` <object id="${c.id}" label="${escapeXml(c.value)}" tooltip="${escapeXml(c.tip)}">\n` +
` <mxCell style="${c.style}" vertex="1" parent="1">\n` +
` ${geometry}` +
` </mxCell>\n` +
` </object>`
);
}
// The label is HTML and this is an XML attribute, so the whole thing is escaped here —
// exactly once. Escaping the pieces and concatenating raw tags is what made the first
// file unparseable.
return (
` <mxCell id="${c.id}" value="${escapeXml(c.value)}" style="${c.style}" vertex="1" parent="1">\n` +
geometry +
` </mxCell>`
);
};
const body = [
...cells.map(vertex),
...edges.map(
(e) =>
` <mxCell id="${e.id}" style="${STYLE.link}" edge="1" parent="1" source="${e.source}" target="${e.target}">\n` +
` <mxGeometry relative="1" as="geometry" />\n` +
` </mxCell>`,
),
].join("\n");
return `<mxfile host="mesh-lab">
<diagram name="${escapeXml(diagram.title)}">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" page="1" pageWidth="1169" pageHeight="826">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
${body}
</root>
</mxGraphModel>
</diagram>
</mxfile>
`;
}