Draw a scenario, from the declaration and from the hypervisor
`mesh-lab diagram` renders a scenario as draw.io, from either source, through one layout — so a difference between what was asked for and what exists is a difference you can see. The shape says what a resource is and is fixed per kind. The badges say what is true about that particular one and come entirely from metadata: translation, forwardability, mapping expiry, refuses-inbound, container-or-VM, running. The interesting properties of a network are exactly the ones with no visual consequence — a translated address looks identical to an untranslated one. For the live picture to be a record rather than a restatement, raise now writes down what it applied: a segment's kind, ranges and MTU on the link; a gateway's translation, forwardability and expiry on the gateway; inbound: deny on the machine. Every behavioural tag is written AFTER the thing works, never at creation — a failed raise leaves wreckage standing on purpose, and a picture of that wreckage must not badge translation the router never got. The pairing earned itself immediately: drawn side by side, every virtual machine held no addresses. A container's interface carries the device's name and a VM names its own, so joining them by name silently dropped one whole class of machine. Fixed by joining on MAC. Also brings tests under the typecheck gate, which caught integration timeouts being passed as a 4th argument and therefore ignored entirely.
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* 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 } from "./model.ts";
|
||||
|
||||
const LANE_MIN_HEIGHT = 170;
|
||||
const LANE_GAP = 80;
|
||||
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, ">")
|
||||
.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 <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 }[] = [];
|
||||
|
||||
// Lanes, ordered by depth: public first, then each level of private network below it.
|
||||
const lanes = [...diagram.segments].sort(
|
||||
(a, b) => a.depth - b.depth || a.name.localeCompare(b.name),
|
||||
);
|
||||
// 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) {
|
||||
const first = machine.attachments[0]?.segment;
|
||||
if (first) occupants.set(first, (occupants.get(first) ?? 0) + 1);
|
||||
}
|
||||
const laneHeight = (name: string): number => {
|
||||
const rows = Math.max(1, Math.ceil((occupants.get(name) ?? 0) / SLOTS_PER_ROW));
|
||||
return Math.max(LANE_MIN_HEIGHT, 45 + rows * (NODE_HEIGHT + 45));
|
||||
};
|
||||
|
||||
const laneY = new Map<string, number>();
|
||||
const laneH = new Map<string, number>();
|
||||
let cursor = 80;
|
||||
|
||||
lanes.forEach((segment) => {
|
||||
const y = cursor;
|
||||
const h = laneHeight(segment.name);
|
||||
cursor = y + h + LANE_GAP;
|
||||
laneY.set(segment.name, y);
|
||||
laneH.set(segment.name, h);
|
||||
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: LANE_X,
|
||||
y,
|
||||
w: LANE_WIDTH,
|
||||
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.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 {
|
||||
const first = machine.attachments[0]!;
|
||||
const spans =
|
||||
machine.attachments.filter((a) => laneY.has(a.segment)).length > 1;
|
||||
|
||||
const slot = perLane.get(first.segment) ?? 0;
|
||||
perLane.set(first.segment, 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.
|
||||
const column = slot % SLOTS_PER_ROW;
|
||||
const row = Math.floor(slot / SLOTS_PER_ROW);
|
||||
x = LANE_X + 40 + column * SLOT_WIDTH;
|
||||
if (spans) {
|
||||
// A gateway straddles, so it sits in the gap below the highest lane it joins —
|
||||
// which is what makes at-a-glance reading of "this is the way in" work.
|
||||
const top = machine.attachments
|
||||
.map((a) => a.segment)
|
||||
.filter((name) => laneY.has(name))
|
||||
.reduce((best, name) => ((laneY.get(name) ?? 0) < (laneY.get(best) ?? 0) ? name : best));
|
||||
y = (laneY.get(top) ?? 80) + (laneH.get(top) ?? LANE_MIN_HEIGHT) + LANE_GAP / 2 - NODE_HEIGHT / 2;
|
||||
} else {
|
||||
y = (laneY.get(first.segment) ?? 80) + 45 + row * (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, right to left, so the first fact stated is nearest the
|
||||
// resource's own corner and the row grows away from the label underneath it.
|
||||
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 - BADGE - 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,
|
||||
});
|
||||
}
|
||||
|
||||
for (const attachment of machine.attachments) {
|
||||
if (!laneY.has(attachment.segment)) 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>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user