@takodotid/circuit 0.18.5 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +18 -0
  2. package/dist/chunks/index-jtzfw679.js +936 -0
  3. package/dist/chunks/{index-ex1wcst7.js → index-xm9nma2t.js} +20 -1
  4. package/dist/chunks/server-5shmsmse.js +246 -0
  5. package/dist/cli/index.js +701 -106
  6. package/dist/index.js +1 -1
  7. package/dist/presets/index.js +1 -1
  8. package/dist/types/adapters/devices/derive.d.ts +2 -0
  9. package/dist/types/core/across.d.ts +4 -0
  10. package/dist/types/core/addr.d.ts +10 -0
  11. package/dist/types/core/addresses.d.ts +46 -0
  12. package/dist/types/core/files.d.ts +6 -0
  13. package/dist/types/core/graph.d.ts +74 -0
  14. package/dist/types/core/schema-docs.d.ts +16 -0
  15. package/package.json +1 -1
  16. package/src/adapters/devices/derive.ts +32 -0
  17. package/src/adapters/devices/raisecom-ros/index.ts +23 -3
  18. package/src/adapters/devices/routeros/index.ts +10 -0
  19. package/src/adapters/devices/vrp/index.ts +10 -1
  20. package/src/cli/graph.ts +89 -0
  21. package/src/cli/index.ts +205 -2
  22. package/src/cli/rename.ts +129 -0
  23. package/src/core/across.ts +322 -0
  24. package/src/core/addr.ts +29 -0
  25. package/src/core/addresses.ts +316 -0
  26. package/src/core/files.ts +30 -0
  27. package/src/core/graph.ts +634 -0
  28. package/src/core/schema-docs.ts +166 -0
  29. package/src/core/validate.ts +2 -52
  30. package/src/studio/data.ts +255 -0
  31. package/src/studio/git.ts +119 -0
  32. package/src/studio/public/app.js +147 -0
  33. package/src/studio/public/compare.js +243 -0
  34. package/src/studio/public/diff.js +97 -0
  35. package/src/studio/public/dom.js +108 -0
  36. package/src/studio/public/evaluate.js +648 -0
  37. package/src/studio/public/export.js +122 -0
  38. package/src/studio/public/favicon.svg +6 -0
  39. package/src/studio/public/graph.js +914 -0
  40. package/src/studio/public/index.html +67 -0
  41. package/src/studio/public/net.js +128 -0
  42. package/src/studio/public/style.css +693 -0
  43. package/src/studio/public/tables.js +1223 -0
  44. package/src/studio/public/tools.js +578 -0
  45. package/src/studio/server.ts +218 -0
  46. package/templates/project/AGENTS.md +12 -9
  47. package/templates/project/README.md +1 -0
@@ -0,0 +1,914 @@
1
+ // @ts-check
2
+ /// <reference lib="dom" />
3
+ // The graph view: a force layout of every node, drawn one layer at a time, with panning, zooming, dragging, and a side panel for what is selected.
4
+
5
+ import { byId, go, h, link, s, store, svgById } from "./dom.js";
6
+ import { changeKey } from "./evaluate.js";
7
+ import { pngOf, save, svgOf } from "./export.js";
8
+
9
+ /** @typedef {import("../../core/graph").GraphNode} GraphNode */
10
+ /** @typedef {import("../../core/graph").GraphEdge} GraphEdge */
11
+ /** @typedef {import("../../core/graph").Layer} Layer */
12
+ /** @typedef {import("../../core/graph").CarriedVlan} CarriedVlan */
13
+ /** @typedef {import("../../core/graph").Annotated} Annotated */
14
+ /** @typedef {{ x: number, y: number }} Point */
15
+ /** An edge as drawn: one of the graph's, or several between the same two nodes drawn as one, or one a comparison shows is gone. @typedef {GraphEdge & { members?: DrawnEdge[], gone?: true }} DrawnEdge */
16
+ /** @typedef {{ node: string } | { edge: DrawnEdge } | undefined} Selection */
17
+
18
+ /** @type {Record<Layer, string>} */
19
+ export const LAYER_NAMES = {
20
+ l1: "Cables",
21
+ l2: "VLANs",
22
+ l3: "Addresses",
23
+ bgp: "BGP",
24
+ ospf: "OSPF",
25
+ tunnels: "Tunnels",
26
+ };
27
+
28
+ /** @type {Record<Layer, string>} */
29
+ const LAYER_HELP = {
30
+ l1: "Each cable a port's link names. A LAG's cables are one line.",
31
+ l2: "The VLANs each cable carries. Pick a VLAN to light up every cable that carries it.",
32
+ l3: "Prefixes two or more devices hold an address in, and point-to-point addresses. Loopbacks and prefixes only one device holds are left out.",
33
+ bgp: "BGP sessions: between our devices, matched by the address each names, and to every AS outside.",
34
+ ospf: "OSPF adjacencies: devices with OSPF in one area on one prefix, neither side passive.",
35
+ tunnels: "GRE and VXLAN tunnels matched by their endpoints, and WireGuard peers.",
36
+ };
37
+
38
+ export const LAYERS = /** @type {Layer[]} */ (Object.keys(LAYER_NAMES));
39
+
40
+ /**
41
+ * @param {string | undefined} name
42
+ * @returns {name is Layer}
43
+ */
44
+ export const isLayer = (name) => LAYERS.includes(/** @type {Layer} */ (name));
45
+
46
+ // The parts of index.html this view draws into.
47
+ const graphView = byId("graph-view");
48
+ const layersNav = byId("layers");
49
+ const svg = svgById("canvas");
50
+ const viewport = svgById("viewport");
51
+ const edgeLayer = svgById("edges");
52
+ const nodeLayer = svgById("nodes");
53
+ const panel = byId("panel");
54
+
55
+ // State.
56
+ /** @type {Annotated | undefined} */
57
+ let data;
58
+ /** @type {Layer} */
59
+ let layer = LAYERS.find((name) => name === store.get("layer")) ?? "l1";
60
+ /** The graph has been drawn while visible at least once; until then it is fitted when shown. */
61
+ let drawn = false;
62
+ /** @type {Selection} */
63
+ let selection;
64
+ /** @type {string | undefined} */
65
+ let hovered;
66
+ /** @type {number | undefined} */
67
+ let vlanFocus;
68
+ /** A VLAN to light up once the VLAN layer is shown, asked for from the tables. @type {number | undefined} */
69
+ let pendingVlan;
70
+ /** Cables to light up as one path, asked for from the tools, and those lit now. @type {GraphEdge[] | undefined} */
71
+ let pendingPath;
72
+ /** @type {Set<string> | undefined} */
73
+ let pathFocus;
74
+ /** A comparison with another commit: its name, the edges it adds, and those gone. @type {{ name: string, added: Set<string>, removed: GraphEdge[] } | undefined} */
75
+ let comparison;
76
+
77
+ /** @param {GraphEdge} edge */
78
+ const edgeKey = (edge) => `${edge.layer} ${edge.from} ${edge.to} ${edge.label}`;
79
+ let view = { x: 0, y: 0, k: 1 };
80
+
81
+ const saved = /** @type {Record<string, [number, number]>} */ (store.get("positions") ?? {});
82
+ /** @type {Map<string, Point>} */
83
+ const positions = new Map(Object.entries(saved).map(([id, [x, y]]) => [id, { x, y }]));
84
+
85
+ /** @type {Map<string, SVGGElement>} */
86
+ const nodeElements = new Map();
87
+ /** @type {{ edge: DrawnEdge, group: SVGGElement, path: SVGPathElement, hit: SVGPathElement, label: SVGTextElement, offset: number }[]} */
88
+ const edgeElements = [];
89
+
90
+ function savePositions() {
91
+ store.set("positions", Object.fromEntries([...positions].map(([id, point]) => [id, [Math.round(point.x), Math.round(point.y)]])));
92
+ }
93
+
94
+ /**
95
+ * A number from 0 to 1 that is always the same for the same text, so a layout comes out the same each time.
96
+ *
97
+ * @param {string} text
98
+ */
99
+ function hash(text) {
100
+ let value = 2166136261;
101
+ for (const char of text) value = Math.imul(value ^ char.charCodeAt(0), 16777619);
102
+ return (value >>> 0) / 4294967296;
103
+ }
104
+
105
+ /**
106
+ * Place nodes with a force layout: every node pushes every other away, and each edge pulls its ends together. A node placed before keeps its place unless `fresh`, so the drawing does not jump when the config changes.
107
+ * Every layer is laid out together, so a device stays where it is when the layer changes.
108
+ *
109
+ * @param {GraphNode[]} nodes
110
+ * @param {GraphEdge[]} edges
111
+ * @param {boolean} fresh
112
+ */
113
+ function layout(nodes, edges, fresh) {
114
+ if (fresh) positions.clear();
115
+
116
+ const fixed = new Set(nodes.filter((node) => positions.has(node.id)).map((node) => node.id));
117
+ /** @type {Map<string, Set<string>>} */
118
+ const neighbors = new Map();
119
+
120
+ for (const edge of edges) {
121
+ if (edge.from === edge.to) continue;
122
+ if (!neighbors.has(edge.from)) neighbors.set(edge.from, new Set());
123
+ if (!neighbors.has(edge.to)) neighbors.set(edge.to, new Set());
124
+ neighbors.get(edge.from)?.add(edge.to);
125
+ neighbors.get(edge.to)?.add(edge.from);
126
+ }
127
+
128
+ // A new node starts beside a neighbor already placed, or on a circle.
129
+ const radius = 60 * Math.sqrt(nodes.length + 1);
130
+
131
+ for (const node of nodes) {
132
+ if (positions.has(node.id)) continue;
133
+
134
+ const angle = hash(node.id) * Math.PI * 2;
135
+ const placed = [...(neighbors.get(node.id) ?? [])].map((id) => positions.get(id)).find(Boolean);
136
+ const distance = placed ? 90 : radius;
137
+ const origin = placed ?? { x: 0, y: 0 };
138
+
139
+ positions.set(node.id, { x: origin.x + Math.cos(angle) * distance, y: origin.y + Math.sin(angle) * distance });
140
+ }
141
+
142
+ const moving = nodes.filter((node) => !fixed.has(node.id));
143
+ if (!moving.length) return;
144
+
145
+ const pairs = [...neighbors].flatMap(([from, tos]) => [...tos].filter((to) => from < to).map((to) => [from, to]));
146
+ const ideal = 230;
147
+ const rounds = fixed.size ? 200 : 500;
148
+
149
+ /** @param {string} id */
150
+ const at = (id) => /** @type {Point} */ (positions.get(id));
151
+
152
+ for (let round = 0; round < rounds; round++) {
153
+ const heat = 40 * (1 - round / rounds) + 1;
154
+ const push = new Map(nodes.map((node) => [node.id, { x: 0, y: 0 }]));
155
+ /** @param {string} id */
156
+ const pushOf = (id) => /** @type {Point} */ (push.get(id));
157
+
158
+ for (const [index, a] of nodes.entries()) {
159
+ for (const b of nodes.slice(index + 1)) {
160
+ let dx = at(a.id).x - at(b.id).x;
161
+ let dy = at(a.id).y - at(b.id).y;
162
+
163
+ // Two nodes in one place are pulled apart in a direction of their own.
164
+ if (dx === 0 && dy === 0) {
165
+ dx = hash(a.id + b.id) - 0.5;
166
+ dy = hash(b.id + a.id) - 0.5;
167
+ }
168
+
169
+ // Nodes far apart no longer push, so parts that are not connected stay near each other.
170
+ const distance = Math.max(Math.hypot(dx, dy), 1);
171
+ if (distance > ideal * 3) continue;
172
+
173
+ const force = (ideal * ideal) / distance;
174
+ pushOf(a.id).x += (dx / distance) * force;
175
+ pushOf(a.id).y += (dy / distance) * force;
176
+ pushOf(b.id).x -= (dx / distance) * force;
177
+ pushOf(b.id).y -= (dy / distance) * force;
178
+ }
179
+ }
180
+
181
+ for (const [from, to] of pairs) {
182
+ if (!from || !to || !positions.has(from) || !positions.has(to)) continue;
183
+
184
+ const dx = at(from).x - at(to).x;
185
+ const dy = at(from).y - at(to).y;
186
+ const distance = Math.max(Math.hypot(dx, dy), 1);
187
+ const force = (distance * distance) / ideal;
188
+ pushOf(from).x -= (dx / distance) * force;
189
+ pushOf(from).y -= (dy / distance) * force;
190
+ pushOf(to).x += (dx / distance) * force;
191
+ pushOf(to).y += (dy / distance) * force;
192
+ }
193
+
194
+ for (const node of moving) {
195
+ const point = at(node.id);
196
+ const force = pushOf(node.id);
197
+
198
+ // A pull to the middle keeps parts that are not connected from drifting away.
199
+ force.x -= point.x * 0.15;
200
+ force.y -= point.y * 0.15;
201
+
202
+ const length = Math.max(Math.hypot(force.x, force.y), 1);
203
+ const step = Math.min(length, heat);
204
+ point.x += (force.x / length) * step;
205
+ point.y += (force.y / length) * step;
206
+ }
207
+ }
208
+
209
+ savePositions();
210
+ }
211
+
212
+ // What is drawn.
213
+ /** @returns {DrawnEdge[]} */
214
+ const visibleEdges = () => {
215
+ const current = data?.edges.filter((edge) => edge.layer === layer) ?? [];
216
+ if (!comparison) return current;
217
+
218
+ // An edge the comparison says is gone is drawn when both its ends are still drawn.
219
+ const nodes = new Set(data?.nodes.map((node) => node.id));
220
+ const gone = comparison.removed.filter((edge) => edge.layer === layer && nodes.has(edge.from) && nodes.has(edge.to));
221
+ return [...current, ...gone.map((edge) => ({ ...edge, gone: /** @type {const} */ (true) }))];
222
+ };
223
+
224
+ /** @returns {GraphNode[]} */
225
+ function visibleNodes() {
226
+ const ends = new Set(visibleEdges().flatMap((edge) => [edge.from, edge.to]));
227
+ return data?.nodes.filter((node) => node.kind === "device" || ends.has(node.id)) ?? [];
228
+ }
229
+
230
+ /** @param {string} id */
231
+ const nodeById = (id) => data?.nodes.find((node) => node.id === id);
232
+
233
+ /**
234
+ * @param {GraphNode} node
235
+ * @returns {"error" | "warning" | undefined}
236
+ */
237
+ function markOf(node) {
238
+ if (!data || node.kind !== "device") return undefined;
239
+
240
+ const findings = data.findings.filter((finding) => finding.device === node.label);
241
+ if (findings.some((finding) => finding.level === "error")) return "error";
242
+
243
+ const drift = data.drift[node.label]?.status;
244
+ if (findings.length || drift === "differs" || drift === "cannot plan") return "warning";
245
+
246
+ return undefined;
247
+ }
248
+
249
+ /**
250
+ * @param {GraphEdge} edge
251
+ * @param {number} offset
252
+ * @returns {{ d: string, label: Point }}
253
+ */
254
+ function curve(edge, offset) {
255
+ const a = positions.get(edge.from) ?? { x: 0, y: 0 };
256
+ const b = positions.get(edge.to) ?? { x: 0, y: 0 };
257
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
258
+
259
+ const length = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 1);
260
+ const normal = { x: -(b.y - a.y) / length, y: (b.x - a.x) / length };
261
+ const control = { x: mid.x + normal.x * offset * 2, y: mid.y + normal.y * offset * 2 };
262
+
263
+ return {
264
+ d: `M${a.x},${a.y} Q${control.x},${control.y} ${b.x},${b.y}`,
265
+ label: { x: mid.x + normal.x * offset, y: mid.y + normal.y * offset },
266
+ };
267
+ }
268
+
269
+ /** Move what is drawn to where the nodes are now, without drawing it again. */
270
+ function place() {
271
+ viewport.setAttribute("transform", `translate(${view.x},${view.y}) scale(${view.k})`);
272
+ svg.classList.toggle("far", view.k < 0.4);
273
+
274
+ for (const [id, group] of nodeElements) {
275
+ const point = positions.get(id) ?? { x: 0, y: 0 };
276
+ group.setAttribute("transform", `translate(${point.x},${point.y})`);
277
+ }
278
+
279
+ for (const { edge, path, hit, label, offset } of edgeElements) {
280
+ const { d, label: middle } = curve(edge, offset);
281
+ path.setAttribute("d", d);
282
+ hit.setAttribute("d", d);
283
+ label.setAttribute("x", String(middle.x));
284
+ label.setAttribute("y", String(middle.y));
285
+ }
286
+ }
287
+
288
+ /** Past this many edges between the same two nodes, they are drawn as one, which lists them all when selected. */
289
+ const MOST_APART = 3;
290
+
291
+ /** @type {Record<Layer, string>} */
292
+ const MANY = { l1: "cables", l2: "cables", l3: "shared prefixes", bgp: "sessions", ospf: "adjacencies", tunnels: "tunnels" };
293
+
294
+ /**
295
+ * The edges to draw: each on its own, or, past a few between the same two nodes, one for all of them.
296
+ *
297
+ * @param {DrawnEdge[]} edges
298
+ * @returns {DrawnEdge[]}
299
+ */
300
+ function bundled(edges) {
301
+ /** @type {Map<string, DrawnEdge[]>} */
302
+ const byPair = new Map();
303
+ for (const edge of edges) {
304
+ const pair = `${[edge.from, edge.to].sort().join("|")}${edge.gone ? " gone" : ""}`;
305
+ byPair.set(pair, [...(byPair.get(pair) ?? []), edge]);
306
+ }
307
+
308
+ return [...byPair.values()].flatMap((members) => {
309
+ const [first] = members;
310
+ if (!first || members.length <= MOST_APART) return members;
311
+
312
+ /** @type {Map<number, import("../../core/graph").CarriedVlan>} */
313
+ const vlans = new Map();
314
+ for (const member of members) for (const vlan of member.vlans ?? []) vlans.set(vlan.id, vlan);
315
+
316
+ return [
317
+ {
318
+ layer: first.layer,
319
+ from: first.from,
320
+ to: first.to,
321
+ label: `${members.length} ${MANY[first.layer]}`,
322
+ detail: members.flatMap((member) => [member.label, ...member.detail.map((line) => ` ${line}`)]),
323
+ ...(members.some((member) => member.oneSided) ? { oneSided: /** @type {const} */ (true) } : {}),
324
+ ...(vlans.size ? { vlans: [...vlans.values()].sort((a, b) => a.id - b.id) } : {}),
325
+ ...(first.gone ? { gone: /** @type {const} */ (true) } : {}),
326
+ members,
327
+ },
328
+ ];
329
+ });
330
+ }
331
+
332
+ function draw() {
333
+ edgeLayer.replaceChildren();
334
+ nodeLayer.replaceChildren();
335
+ nodeElements.clear();
336
+ edgeElements.length = 0;
337
+
338
+ const edges = bundled(visibleEdges());
339
+
340
+ // Edges between the same two nodes bend apart, so each stays readable.
341
+ /** @type {Map<string, number>} */
342
+ const between = new Map();
343
+ /** @param {DrawnEdge} edge */
344
+ const pairOf = (edge) => [edge.from, edge.to].sort().join("|");
345
+ for (const edge of edges) between.set(pairOf(edge), (between.get(pairOf(edge)) ?? 0) + 1);
346
+ /** @type {Map<string, number>} */
347
+ const seen = new Map();
348
+
349
+ for (const edge of edges) {
350
+ const pair = pairOf(edge);
351
+ const index = seen.get(pair) ?? 0;
352
+ seen.set(pair, index + 1);
353
+
354
+ // Bend in one direction whichever way round the edge was written.
355
+ const sign = edge.from < edge.to ? 1 : -1;
356
+ const offset = (index - ((between.get(pair) ?? 1) - 1) / 2) * 34 * sign;
357
+
358
+ const added = comparison && [edge, ...(edge.members ?? [])].some((one) => !one.gone && comparison?.added.has(changeKey(one)));
359
+ const change = edge.gone ? " gone" : added ? " added" : "";
360
+ const group = s("g", { class: `edge${edge.oneSided ? " one-sided" : ""}${change}` });
361
+ const hit = s("path", { class: "hit" });
362
+ const path = s("path");
363
+ const label = s("text", { "text-anchor": "middle", "dominant-baseline": "central" });
364
+ label.textContent = edge.label;
365
+
366
+ group.append(path, hit, label);
367
+
368
+ // Selected on press: the canvas captures the pointer for panning, so no click would reach the edge.
369
+ group.addEventListener("pointerdown", (event) => {
370
+ event.stopPropagation();
371
+ select({ edge });
372
+ });
373
+
374
+ edgeLayer.append(group);
375
+ edgeElements.push({ edge, group, path, hit, label, offset });
376
+ }
377
+
378
+ for (const node of visibleNodes()) {
379
+ const group = s("g", { class: `node ${node.kind} ${markOf(node) ?? ""}`, tabindex: 0 });
380
+ const rect = s("rect", { rx: node.kind === "segment" ? 14 : 8 });
381
+ group.append(rect);
382
+
383
+ const lines = node.kind === "device" ? [node.label, node.detail[0] ?? ""] : [node.label];
384
+ const texts = lines.map((line, index) => {
385
+ const text = s("text", { class: index === 0 ? "name" : "sub", y: lines.length === 1 ? 0 : index === 0 ? -8 : 9 });
386
+ text.textContent = line;
387
+ group.append(text);
388
+ return text;
389
+ });
390
+
391
+ nodeLayer.append(group);
392
+
393
+ // Each box fits its text, so it is measured once drawn.
394
+ const width = Math.max(...texts.map((text) => text.getComputedTextLength())) + 24;
395
+ const height = lines.length === 1 ? 28 : 44;
396
+ rect.setAttribute("x", String(-width / 2));
397
+ rect.setAttribute("y", String(-height / 2));
398
+ rect.setAttribute("width", String(width));
399
+ rect.setAttribute("height", String(height));
400
+
401
+ group.addEventListener("pointerdown", (event) => startDrag(event, node.id));
402
+ group.addEventListener("pointerenter", () => {
403
+ hovered = node.id;
404
+ emphasize();
405
+ });
406
+ group.addEventListener("pointerleave", () => {
407
+ hovered = undefined;
408
+ emphasize();
409
+ });
410
+ group.addEventListener("keydown", (event) => event.key === "Enter" && select({ node: node.id }));
411
+
412
+ nodeElements.set(node.id, group);
413
+ }
414
+
415
+ place();
416
+ emphasize();
417
+ }
418
+
419
+ /** Light up what the pointer is on, or what is selected, and dim the rest. */
420
+ function emphasize() {
421
+ const focus = hovered ?? (selection && "node" in selection ? selection.node : undefined);
422
+ const selectedEdge = selection && "edge" in selection ? selection.edge : undefined;
423
+
424
+ /** @param {GraphEdge} edge */
425
+ const touching = (edge) => edge.from === focus || edge.to === focus;
426
+ /** @param {GraphEdge} edge */
427
+ const carries = (edge) => edge.vlans?.some((vlan) => vlan.id === vlanFocus) ?? false;
428
+
429
+ /** @type {Set<GraphEdge> | undefined} */
430
+ let litEdges;
431
+ if (focus) litEdges = new Set(edgeElements.filter(({ edge }) => touching(edge)).map(({ edge }) => edge));
432
+ else if (pathFocus)
433
+ litEdges = new Set(
434
+ edgeElements
435
+ .filter(({ edge }) => [edge, ...(edge.members ?? [])].some((one) => pathFocus?.has(edgeKey(one))))
436
+ .map(({ edge }) => edge)
437
+ );
438
+ else if (vlanFocus !== undefined && layer === "l2")
439
+ litEdges = new Set(edgeElements.filter(({ edge }) => carries(edge)).map(({ edge }) => edge));
440
+ else if (selectedEdge) litEdges = new Set([selectedEdge]);
441
+
442
+ const litNodes = litEdges ? new Set([...litEdges].flatMap((edge) => [edge.from, edge.to])) : undefined;
443
+ if (focus) litNodes?.add(focus);
444
+
445
+ for (const { edge, group } of edgeElements) {
446
+ group.classList.toggle("lit", Boolean(litEdges?.has(edge)) && !selectedEdge);
447
+ group.classList.toggle("selected", edge === selectedEdge);
448
+ group.classList.toggle("dim", litEdges !== undefined && !litEdges.has(edge));
449
+ }
450
+
451
+ for (const [id, group] of nodeElements) {
452
+ group.classList.toggle("selected", Boolean(selection && "node" in selection && selection.node === id));
453
+ group.classList.toggle("dim", litNodes !== undefined && !litNodes.has(id));
454
+ }
455
+ }
456
+
457
+ // Panning, zooming and dragging.
458
+ /** @type {{ id?: string, start: Point, origin: Point, moved: boolean } | undefined} */
459
+ let drag;
460
+
461
+ /**
462
+ * @param {{ clientX: number, clientY: number }} event
463
+ * @returns {Point}
464
+ */
465
+ const toWorld = (event) => {
466
+ const box = svg.getBoundingClientRect();
467
+ return { x: (event.clientX - box.left - view.x) / view.k, y: (event.clientY - box.top - view.y) / view.k };
468
+ };
469
+
470
+ /**
471
+ * @param {PointerEvent} event
472
+ * @param {string} [id]
473
+ */
474
+ function startDrag(event, id) {
475
+ if (event.button !== 0) return;
476
+ event.stopPropagation();
477
+
478
+ svg.setPointerCapture(event.pointerId);
479
+ const point = id ? positions.get(id) : undefined;
480
+ const origin = point ? { ...point } : { x: view.x, y: view.y };
481
+ drag = { id, start: { x: event.clientX, y: event.clientY }, origin, moved: false };
482
+ if (!id) svg.classList.add("panning");
483
+ }
484
+
485
+ svg.addEventListener("pointerdown", (event) => startDrag(event));
486
+
487
+ svg.addEventListener("pointermove", (event) => {
488
+ if (!drag) return;
489
+
490
+ const dx = event.clientX - drag.start.x;
491
+ const dy = event.clientY - drag.start.y;
492
+ if (Math.hypot(dx, dy) > 3) drag.moved = true;
493
+ if (!drag.moved) return;
494
+
495
+ if (drag.id) positions.set(drag.id, { x: drag.origin.x + dx / view.k, y: drag.origin.y + dy / view.k });
496
+ else view = { ...view, x: drag.origin.x + dx, y: drag.origin.y + dy };
497
+
498
+ place();
499
+ });
500
+
501
+ svg.addEventListener("pointerup", () => {
502
+ if (!drag) return;
503
+
504
+ const ended = drag;
505
+ drag = undefined;
506
+ svg.classList.remove("panning");
507
+
508
+ if (ended.moved && ended.id) savePositions();
509
+ if (ended.moved) return;
510
+
511
+ // A press without a move is a click: on a node it selects the node, and on an empty spot it clears the selection.
512
+ select(ended.id ? { node: ended.id } : undefined);
513
+ });
514
+
515
+ svg.addEventListener(
516
+ "wheel",
517
+ (event) => {
518
+ event.preventDefault();
519
+
520
+ const point = toWorld(event);
521
+ const k = Math.min(Math.max(view.k * Math.exp(-event.deltaY * 0.0015), 0.15), 4);
522
+ const box = svg.getBoundingClientRect();
523
+
524
+ view = { k, x: event.clientX - box.left - point.x * k, y: event.clientY - box.top - point.y * k };
525
+ place();
526
+ },
527
+ { passive: false }
528
+ );
529
+
530
+ function fit() {
531
+ const points = visibleNodes().flatMap((node) => {
532
+ const point = positions.get(node.id);
533
+ return point ? [point] : [];
534
+ });
535
+ if (!points.length) return;
536
+
537
+ const box = svg.getBoundingClientRect();
538
+ const xs = points.map((point) => point.x);
539
+ const ys = points.map((point) => point.y);
540
+ const width = Math.max(...xs) - Math.min(...xs) + 240;
541
+ const height = Math.max(...ys) - Math.min(...ys) + 160;
542
+ const k = Math.min(Math.max(Math.min(box.width / width, box.height / height), 0.15), 1.5);
543
+
544
+ const centre = { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
545
+ view = { k, x: box.width / 2 - centre.x * k, y: box.height / 2 - centre.y * k };
546
+ place();
547
+ }
548
+
549
+ byId("fit").addEventListener("click", fit);
550
+
551
+ byId("lay-out").addEventListener("click", () => {
552
+ if (!data) return;
553
+ layout(data.nodes, data.edges, true);
554
+ draw();
555
+ fit();
556
+ });
557
+
558
+ window.addEventListener("resize", () => place());
559
+
560
+ // Saving the layer shown, as it looks.
561
+ byId("save-svg").addEventListener("click", () => {
562
+ if (!(viewport instanceof SVGGraphicsElement)) return;
563
+ save(`circuit-${layer}.svg`, new Blob([svgOf(svg, viewport).text], { type: "image/svg+xml" }));
564
+ });
565
+
566
+ byId("save-png").addEventListener("click", async () => {
567
+ if (!(viewport instanceof SVGGraphicsElement)) return;
568
+ save(`circuit-${layer}.png`, await pngOf(svgOf(svg, viewport)));
569
+ });
570
+
571
+ // The side panel.
572
+ /** @param {Selection} next */
573
+ function select(next) {
574
+ selection = next;
575
+ pathFocus = undefined;
576
+ emphasize();
577
+ showPanel();
578
+ }
579
+
580
+ /**
581
+ * @param {Annotated["findings"]} findings
582
+ * @param {boolean} withDevice
583
+ */
584
+ function findingList(findings, withDevice) {
585
+ return h(
586
+ "ul",
587
+ {},
588
+ ...findings.map((finding) =>
589
+ h(
590
+ "li",
591
+ {},
592
+ h("span", { class: `badge ${finding.level}` }, finding.level),
593
+ withDevice && finding.device ? link(finding.device, () => select({ node: `device:${finding.device}` })) : undefined,
594
+ withDevice && finding.device ? ": " : undefined,
595
+ finding.message
596
+ )
597
+ )
598
+ );
599
+ }
600
+
601
+ /** @param {CarriedVlan[]} vlans */
602
+ function vlanChips(vlans) {
603
+ const chips = h("div", { class: "chips" });
604
+
605
+ for (const vlan of vlans) {
606
+ const button = h("button", { "aria-pressed": String(vlan.id === vlanFocus) }, `${vlan.name} ${vlan.id}`);
607
+ button.addEventListener("click", () => {
608
+ vlanFocus = vlanFocus === vlan.id ? undefined : vlan.id;
609
+ selection = undefined;
610
+ emphasize();
611
+ showPanel();
612
+ });
613
+ chips.append(button);
614
+ }
615
+
616
+ return chips;
617
+ }
618
+
619
+ function legend() {
620
+ /**
621
+ * @param {boolean} dashed
622
+ * @param {string} color
623
+ */
624
+ const sample = (dashed, color) => {
625
+ const icon = s("svg", { width: 34, height: 10 });
626
+ icon.append(s("line", { x1: 2, y1: 5, x2: 32, y2: 5, stroke: color, "stroke-width": 2, "stroke-dasharray": dashed ? "6 4" : "" }));
627
+ return icon;
628
+ };
629
+
630
+ /** @param {string} style */
631
+ const box = (style) => {
632
+ const icon = s("svg", { width: 34, height: 16 });
633
+ icon.append(s("rect", { x: 2, y: 2, width: 30, height: 12, rx: 3, fill: "none", "stroke-width": 2, style }));
634
+ return icon;
635
+ };
636
+
637
+ return h(
638
+ "ul",
639
+ { class: "legend" },
640
+ h("li", {}, sample(false, "var(--edge)"), "both ends declare it"),
641
+ h("li", {}, sample(true, "var(--warning)"), "only one end declares it"),
642
+ h("li", {}, box("stroke: var(--error)"), "a device with an error"),
643
+ h("li", {}, box("stroke: var(--warning)"), "a warning, or unlike its snapshot"),
644
+ h("li", {}, box("stroke: var(--line); stroke-dasharray: 5 3"), "outside the network")
645
+ );
646
+ }
647
+
648
+ function showPanel() {
649
+ panel.replaceChildren();
650
+ if (!data) return;
651
+
652
+ if (selection && "node" in selection) {
653
+ const node = nodeById(selection.node);
654
+ if (node) return showNode(node);
655
+ }
656
+
657
+ if (selection && "edge" in selection) return showEdge(selection.edge);
658
+
659
+ const drift = data.drift;
660
+ const devices = data.nodes.filter((node) => node.kind === "device");
661
+ const drifted = devices.filter((node) => drift[node.label]?.status === "differs");
662
+ const unsnapped = devices.filter((node) => drift[node.label]?.status === "no snapshot");
663
+
664
+ panel.append(h("h2", {}, LAYER_NAMES[layer]), h("p", { class: "muted" }, LAYER_HELP[layer]));
665
+
666
+ if (comparison) {
667
+ panel.append(
668
+ h("h3", {}, "Compared with ", comparison.name),
669
+ h(
670
+ "p",
671
+ {},
672
+ h("span", { class: "added" }, "Green lines"),
673
+ " are new, ",
674
+ h("span", { class: "removed" }, "red dashed lines"),
675
+ " are gone. ",
676
+ link("Stop comparing", () => {
677
+ comparison = undefined;
678
+ draw();
679
+ showPanel();
680
+ })
681
+ )
682
+ );
683
+ }
684
+
685
+ if (layer === "l2") {
686
+ /** @type {Map<number, CarriedVlan>} */
687
+ const vlans = new Map();
688
+ for (const edge of visibleEdges()) for (const vlan of edge.vlans ?? []) vlans.set(vlan.id, vlan);
689
+
690
+ const sorted = [...vlans.values()].sort((a, b) => a.id - b.id);
691
+ if (sorted.length) panel.append(h("h3", {}, "VLANs"), vlanChips(sorted));
692
+ }
693
+
694
+ if (data.findings.length) panel.append(h("h3", {}, "Found by ", h("code", {}, "circuit validate")), findingList(data.findings, true));
695
+
696
+ if (drifted.length) {
697
+ panel.append(
698
+ h("h3", {}, "Unlike their last snapshot"),
699
+ h(
700
+ "ul",
701
+ {},
702
+ ...drifted.map((node) =>
703
+ h(
704
+ "li",
705
+ {},
706
+ link(node.label, () => select({ node: node.id }))
707
+ )
708
+ )
709
+ )
710
+ );
711
+ }
712
+
713
+ if (unsnapped.length) {
714
+ panel.append(h("h3", {}, "No snapshot yet"), h("p", { class: "muted" }, unsnapped.map((node) => node.label).join(", ")));
715
+ }
716
+
717
+ panel.append(h("h3", {}, "Legend"), legend());
718
+ }
719
+
720
+ /** @param {GraphNode} node */
721
+ function showNode(node) {
722
+ const kinds = { device: "device", segment: "shared segment", outside: "outside the network" };
723
+ panel.append(h("h2", {}, node.label), h("div", { class: "kind" }, kinds[node.kind]));
724
+
725
+ if (node.kind === "device")
726
+ panel.append(
727
+ h(
728
+ "p",
729
+ {},
730
+ link("Everything it holds", () => go(`devices/${encodeURIComponent(node.label)}`))
731
+ )
732
+ );
733
+ if (node.detail.length) panel.append(h("h3", {}, "Details"), h("pre", {}, node.detail.join("\n")));
734
+
735
+ if (node.kind === "device" && data) {
736
+ const findings = data.findings.filter((finding) => finding.device === node.label);
737
+ if (findings.length) panel.append(h("h3", {}, "Found by ", h("code", {}, "circuit validate")), findingList(findings, false));
738
+
739
+ const drift = data.drift[node.label];
740
+ if (drift) {
741
+ const notes = {
742
+ matches: ["Matches its last snapshot."],
743
+ differs: ["Differs from its last snapshot. This is what ", h("code", {}, "circuit diff"), " would send:"],
744
+ "no snapshot": ["No snapshot yet: run ", h("code", {}, "circuit snapshot"), "."],
745
+ "cannot plan": ["Could not be planned against its snapshot:"],
746
+ };
747
+
748
+ panel.append(h("h3", {}, "Against its snapshot"), h("p", {}, ...notes[drift.status]));
749
+ if (drift.lines.length) panel.append(h("pre", {}, drift.lines.join("\n")));
750
+ }
751
+ }
752
+
753
+ const edges = visibleEdges().filter((edge) => edge.from === node.id || edge.to === node.id);
754
+
755
+ panel.append(h("h3", {}, `${LAYER_NAMES[layer]} here`));
756
+ if (!edges.length) panel.append(h("p", { class: "muted" }, "None on this layer."));
757
+
758
+ panel.append(
759
+ h(
760
+ "ul",
761
+ {},
762
+ ...edges.map((edge) => {
763
+ const other = nodeById(edge.from === node.id ? edge.to : edge.from);
764
+ return h(
765
+ "li",
766
+ {},
767
+ link(edge.label, () => select({ edge })),
768
+ h("span", { class: "muted" }, ` with ${other?.label ?? "?"}`)
769
+ );
770
+ })
771
+ )
772
+ );
773
+ }
774
+
775
+ /** @param {GraphEdge} edge */
776
+ function showEdge(edge) {
777
+ const from = nodeById(edge.from);
778
+ const to = nodeById(edge.to);
779
+
780
+ panel.append(
781
+ h("h2", {}, edge.label),
782
+ h(
783
+ "div",
784
+ { class: "kind" },
785
+ `${LAYER_NAMES[edge.layer]}, between `,
786
+ link(from?.label ?? edge.from, () => select({ node: edge.from })),
787
+ " and ",
788
+ link(to?.label ?? edge.to, () => select({ node: edge.to }))
789
+ )
790
+ );
791
+
792
+ if (edge.oneSided) panel.append(h("p", {}, h("span", { class: "badge warning" }, "one end"), "Only one end declares it."));
793
+ if (edge.detail.length) panel.append(h("h3", {}, "Details"), h("pre", {}, edge.detail.join("\n")));
794
+ if (edge.vlans?.length) panel.append(h("h3", {}, "VLANs"), vlanChips(edge.vlans));
795
+ }
796
+
797
+ function showLayerButtons() {
798
+ layersNav.replaceChildren();
799
+
800
+ for (const [index, name] of LAYERS.entries()) {
801
+ const count = data?.edges.filter((edge) => edge.layer === name).length ?? 0;
802
+ const button = h(
803
+ "button",
804
+ { "aria-pressed": String(name === layer), title: `${LAYER_HELP[name]} Key ${index + 1}.` },
805
+ LAYER_NAMES[name],
806
+ h("span", { class: "count" }, String(count))
807
+ );
808
+ button.addEventListener("click", () => go(`graph/${name}`));
809
+ layersNav.append(button);
810
+ }
811
+ }
812
+
813
+ document.addEventListener("keydown", (event) => {
814
+ if (event.target instanceof HTMLInputElement || graphView.hidden) return;
815
+
816
+ if (event.key === "Escape") select(undefined);
817
+
818
+ // Keys 1 to 6 pick a layer.
819
+ const chosen = LAYERS[Number(event.key) - 1];
820
+ if (chosen) go(`graph/${chosen}`);
821
+ });
822
+
823
+ /** The layer shown last, or the first. */
824
+ export const currentLayer = () => layer;
825
+
826
+ /**
827
+ * Take a new graph, when the config changed. Nodes keep their places, and a selection whose node or edge is gone is dropped.
828
+ *
829
+ * @param {Annotated} next
830
+ */
831
+ export function setGraph(next) {
832
+ data = next;
833
+
834
+ if (selection && "node" in selection && !nodeById(selection.node)) selection = undefined;
835
+ if (selection && "edge" in selection) {
836
+ const { layer: was, from, to, label } = selection.edge;
837
+ const same = data.edges.find((edge) => edge.layer === was && edge.from === from && edge.to === to && edge.label === label);
838
+ selection = same ? { edge: same } : undefined;
839
+ }
840
+
841
+ layout(data.nodes, data.edges, false);
842
+ }
843
+
844
+ /**
845
+ * Draw a layer, now that the graph is visible: its labels are measured as they are drawn.
846
+ *
847
+ * @param {Layer} next
848
+ */
849
+ export function showLayer(next) {
850
+ if (next !== layer) {
851
+ if (selection && "edge" in selection) selection = undefined;
852
+ vlanFocus = undefined;
853
+ pathFocus = undefined;
854
+ }
855
+
856
+ if (pendingVlan !== undefined) {
857
+ vlanFocus = pendingVlan;
858
+ pendingVlan = undefined;
859
+ }
860
+
861
+ if (pendingPath) {
862
+ pathFocus = new Set(pendingPath.map(edgeKey));
863
+ pendingPath = undefined;
864
+ }
865
+
866
+ layer = next;
867
+ store.set("layer", layer);
868
+
869
+ showLayerButtons();
870
+ draw();
871
+ showPanel();
872
+
873
+ if (!drawn && data) fit();
874
+ drawn = data !== undefined;
875
+ }
876
+
877
+ /**
878
+ * Select a device, for when the graph is shown next.
879
+ *
880
+ * @param {string} name
881
+ */
882
+ export function selectDevice(name) {
883
+ selection = { node: `device:${name}` };
884
+ }
885
+
886
+ /**
887
+ * Show a comparison with another commit on the graph: new edges lit, edges gone drawn dashed.
888
+ *
889
+ * @param {{ name: string, added: GraphEdge[], removed: GraphEdge[] }} next
890
+ */
891
+ export function setComparison(next) {
892
+ comparison = { name: next.name, added: new Set(next.added.map(changeKey)), removed: next.removed };
893
+ selection = undefined;
894
+ }
895
+
896
+ /**
897
+ * Light up these cables as one path, for when their layer is shown next.
898
+ *
899
+ * @param {GraphEdge[]} path
900
+ */
901
+ export function lightPath(path) {
902
+ pendingPath = path;
903
+ selection = undefined;
904
+ }
905
+
906
+ /**
907
+ * Light up every cable carrying a VLAN, for when the VLAN layer is shown next.
908
+ *
909
+ * @param {number} id
910
+ */
911
+ export function lightVlan(id) {
912
+ pendingVlan = id;
913
+ selection = undefined;
914
+ }