/** * 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, ">") .replace(/"/g, """); } 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 , 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(); 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(); 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(); 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(); 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(); const laneH = new Map(); const laneOf = new Map(); 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: `${segment.name}
${facts.join(" · ")}`, 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(); 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(); 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("
"); const label = `${machine.name}` + (addresses ? `
${addresses}` : ""); 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("
"), 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 = `${diagram.title} — ${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 = [ "badges — 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("
"); 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 — 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 = ` \n`; if (c.tip) { return ( ` \n` + ` \n` + ` ${geometry}` + ` \n` + ` ` ); } // 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 ( ` \n` + geometry + ` ` ); }; const body = [ ...cells.map(vertex), ...edges.map( (e) => ` \n` + ` \n` + ` `, ), ].join("\n"); return ` ${body} `; }