@takodotid/circuit 0.19.0 → 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 (49) hide show
  1. package/README.md +2 -2
  2. package/dist/chunks/index-jtzfw679.js +936 -0
  3. package/dist/chunks/{index-dczpxzzm.js → index-xm9nma2t.js} +20 -1
  4. package/dist/chunks/server-5shmsmse.js +246 -0
  5. package/dist/cli/index.js +544 -487
  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 +31 -1
  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 +1 -15
  21. package/src/cli/index.ts +154 -20
  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 +27 -6
  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 +2 -0
  47. package/dist/chunks/studio-femhydvd.js +0 -764
  48. package/src/cli/studio-page.ts +0 -876
  49. package/src/cli/studio.ts +0 -126
@@ -1,764 +0,0 @@
1
- import"./index-fq57hya4.js";
2
-
3
- // src/cli/studio.ts
4
- import { execFile, spawn } from "node:child_process";
5
- import { watch } from "node:fs";
6
- import { createServer } from "node:http";
7
-
8
- // src/cli/studio-page.ts
9
- var STYLE = `
10
- :root {
11
- --bg: #f6f7f9;
12
- --surface: #ffffff;
13
- --line: #d7dbe0;
14
- --text: #1d2228;
15
- --muted: #5d6670;
16
- --edge: #8a939c;
17
- --accent: #2f6fdb;
18
- --error: #d1242f;
19
- --warning: #b07800;
20
- --segment: #e9eef6;
21
- --font: system-ui, -apple-system, "Segoe UI", sans-serif;
22
- --mono: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
23
- }
24
- @media (prefers-color-scheme: dark) {
25
- :root {
26
- --bg: #14171b;
27
- --surface: #1c2026;
28
- --line: #333a42;
29
- --text: #e4e8ec;
30
- --muted: #98a2ad;
31
- --edge: #6c7680;
32
- --accent: #6aa0ff;
33
- --error: #ff6b6b;
34
- --warning: #e3b341;
35
- --segment: #232a33;
36
- }
37
- }
38
- * { box-sizing: border-box; }
39
- html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font: 14px/1.45 var(--font); }
40
- body { display: flex; flex-direction: column; }
41
- button { font: inherit; color: inherit; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; cursor: pointer; }
42
- button:hover { border-color: var(--accent); }
43
- button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
44
- header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; padding: 10px 16px; background: var(--surface); border-bottom: 1px solid var(--line); }
45
- header .brand { font-weight: 600; }
46
- header nav, header .actions { display: flex; flex-wrap: wrap; gap: 6px; }
47
- header .count { opacity: 0.7; margin-left: 4px; font-size: 12px; }
48
- header .summary { color: var(--muted); font-size: 13px; }
49
- header .live { margin-left: auto; color: var(--muted); font-size: 12px; }
50
- header .live::before { content: ""; display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--warning); margin-right: 6px; }
51
- header .live.on::before { background: #2da44e; }
52
- .banner { margin: 0; padding: 10px 16px; background: var(--error); color: #fff; white-space: pre-wrap; font: 12px/1.4 var(--mono); max-height: 30vh; overflow: auto; }
53
- main { flex: 1; display: flex; min-height: 0; }
54
- svg.canvas { flex: 1; min-width: 0; height: 100%; cursor: grab; touch-action: none; user-select: none; }
55
- svg.canvas.panning { cursor: grabbing; }
56
- aside { width: 340px; overflow: auto; padding: 14px 16px; background: var(--surface); border-left: 1px solid var(--line); }
57
- aside h2 { font-size: 15px; margin: 0 0 4px; overflow-wrap: anywhere; }
58
- aside h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); margin: 18px 0 6px; }
59
- aside ul { list-style: none; margin: 0; padding: 0; }
60
- aside li { padding: 3px 0; overflow-wrap: anywhere; }
61
- aside .kind { color: var(--muted); font-size: 12px; }
62
- aside pre { margin: 0; font: 12px/1.45 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bg); padding: 8px; border-radius: 6px; }
63
- aside a { color: var(--accent); cursor: pointer; text-decoration: none; }
64
- aside a:hover { text-decoration: underline; }
65
- aside .muted { color: var(--muted); }
66
- .badge { display: inline-block; font-size: 11px; font-weight: 600; padding: 0 6px; border-radius: 4px; margin-right: 6px; color: #fff; }
67
- .badge.error { background: var(--error); }
68
- .badge.warning { background: var(--warning); }
69
- .chips { display: flex; flex-wrap: wrap; gap: 6px; }
70
- .chips button { padding: 2px 8px; font-size: 12px; }
71
- .legend li { display: flex; align-items: center; gap: 8px; }
72
- .legend svg { flex: none; }
73
- .edge path { fill: none; stroke: var(--edge); stroke-width: 1.6; }
74
- .edge.one-sided path:not(.hit) { stroke: var(--warning); stroke-dasharray: 6 4; }
75
- .edge text { font-size: 11px; fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: 4px; stroke-linejoin: round; }
76
- .edge .hit { stroke: transparent; stroke-width: 12; cursor: pointer; }
77
- .edge.selected path:not(.hit), .edge.lit path:not(.hit) { stroke: var(--accent); stroke-width: 2.6; }
78
- .edge.selected text, .edge.lit text { fill: var(--text); }
79
- .node { cursor: pointer; }
80
- .node rect { fill: var(--surface); stroke: var(--line); stroke-width: 1.5; }
81
- .node.segment rect { fill: var(--segment); }
82
- .node.outside rect { stroke-dasharray: 5 3; }
83
- .node.error rect { stroke: var(--error); stroke-width: 3; }
84
- .node.warning rect { stroke: var(--warning); stroke-width: 3; }
85
- .node.selected rect { stroke: var(--accent); stroke-width: 3; }
86
- .node text { fill: var(--text); font-size: 12px; text-anchor: middle; dominant-baseline: central; pointer-events: none; }
87
- .node text.name { font-weight: 600; font-size: 13px; }
88
- .node text.sub { fill: var(--muted); font-size: 11px; }
89
- .node.segment text, .node.outside text { font-family: var(--mono); font-size: 11px; }
90
- .dim { opacity: 0.18; }
91
- svg.far .edge text { display: none; }
92
- @media (max-width: 760px) {
93
- main { flex-direction: column; }
94
- aside { width: auto; height: 40%; border-left: 0; border-top: 1px solid var(--line); }
95
- }
96
- `;
97
- function page() {
98
- const LAYER_NAMES = {
99
- l1: "Cables",
100
- l2: "VLANs",
101
- l3: "Addresses",
102
- bgp: "BGP",
103
- ospf: "OSPF",
104
- tunnels: "Tunnels"
105
- };
106
- const LAYER_HELP = {
107
- l1: "Each cable a port's link names. A LAG's cables are one line.",
108
- l2: "The VLANs each cable carries. Pick a VLAN to light up every cable that carries it.",
109
- 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.",
110
- bgp: "BGP sessions: between our devices, matched by the address each names, and to every AS outside.",
111
- ospf: "OSPF adjacencies: devices with OSPF in one area on one prefix, neither side passive.",
112
- tunnels: "GRE and VXLAN tunnels matched by their endpoints, and WireGuard peers."
113
- };
114
- const LAYERS = Object.keys(LAYER_NAMES);
115
- const SVG = "http://www.w3.org/2000/svg";
116
- const store = {
117
- get(key) {
118
- try {
119
- const value = localStorage.getItem(`circuit-studio:${key}`);
120
- return value === null ? undefined : JSON.parse(value);
121
- } catch {
122
- return;
123
- }
124
- },
125
- set(key, value) {
126
- try {
127
- localStorage.setItem(`circuit-studio:${key}`, JSON.stringify(value));
128
- } catch {}
129
- }
130
- };
131
- function h(tag, attributes = {}, ...children) {
132
- const element = document.createElement(tag);
133
- for (const [name, value] of Object.entries(attributes))
134
- element.setAttribute(name, value);
135
- for (const child of children)
136
- if (child !== undefined && child !== false)
137
- element.append(child);
138
- return element;
139
- }
140
- function s(tag, attributes = {}) {
141
- const element = document.createElementNS(SVG, tag);
142
- for (const [name, value] of Object.entries(attributes))
143
- element.setAttribute(name, String(value));
144
- return element;
145
- }
146
- function link(text, action) {
147
- const anchor = h("a", { role: "button", tabindex: "0" }, text);
148
- anchor.addEventListener("click", action);
149
- anchor.addEventListener("keydown", (event) => event.key === "Enter" && action());
150
- return anchor;
151
- }
152
- const layerButtons = new Map;
153
- const nav = h("nav", { "aria-label": "Layers" });
154
- const summary = h("span", { class: "summary" });
155
- const live = h("span", { class: "live" }, "connecting");
156
- const fitButton = h("button", { title: "Fit the drawing to the window" }, "Fit");
157
- const layoutButton = h("button", { title: "Forget where nodes were moved, and lay them out again" }, "Lay out again");
158
- const banner = h("pre", { class: "banner", hidden: "" });
159
- const svg = s("svg", { class: "canvas", role: "img", "aria-label": "The network" });
160
- const viewport = s("g");
161
- const edgeLayer = s("g");
162
- const nodeLayer = s("g");
163
- viewport.append(edgeLayer, nodeLayer);
164
- svg.append(viewport);
165
- const panel = h("aside");
166
- document.body.append(h("header", {}, h("span", { class: "brand" }, "Circuit studio"), nav, h("div", { class: "actions" }, fitButton, layoutButton), summary, live), banner, h("main", {}, svg, panel));
167
- let data;
168
- const requested = [location.hash.slice(1), store.get("layer")].find((name) => LAYERS.includes(name));
169
- let layer = requested ?? "l1";
170
- let selection;
171
- let hovered;
172
- let vlanFocus;
173
- let view = { x: 0, y: 0, k: 1 };
174
- const saved = store.get("positions") ?? {};
175
- const positions = new Map(Object.entries(saved).map(([id, [x, y]]) => [id, { x, y }]));
176
- const nodeElements = new Map;
177
- const edgeElements = [];
178
- function savePositions() {
179
- store.set("positions", Object.fromEntries([...positions].map(([id, point]) => [id, [Math.round(point.x), Math.round(point.y)]])));
180
- }
181
- function hash(text) {
182
- let value = 2166136261;
183
- for (const char of text)
184
- value = Math.imul(value ^ char.charCodeAt(0), 16777619);
185
- return (value >>> 0) / 4294967296;
186
- }
187
- function layout(nodes, edges, fresh) {
188
- if (fresh)
189
- positions.clear();
190
- const fixed = new Set(nodes.filter((node) => positions.has(node.id)).map((node) => node.id));
191
- const neighbors = new Map;
192
- for (const edge of edges) {
193
- if (edge.from === edge.to)
194
- continue;
195
- if (!neighbors.has(edge.from))
196
- neighbors.set(edge.from, new Set);
197
- if (!neighbors.has(edge.to))
198
- neighbors.set(edge.to, new Set);
199
- neighbors.get(edge.from).add(edge.to);
200
- neighbors.get(edge.to).add(edge.from);
201
- }
202
- const radius = 60 * Math.sqrt(nodes.length + 1);
203
- for (const node of nodes) {
204
- if (positions.has(node.id))
205
- continue;
206
- const angle = hash(node.id) * Math.PI * 2;
207
- const placed = [...neighbors.get(node.id) ?? []].map((id) => positions.get(id)).find(Boolean);
208
- const distance = placed ? 90 : radius;
209
- const origin = placed ?? { x: 0, y: 0 };
210
- positions.set(node.id, { x: origin.x + Math.cos(angle) * distance, y: origin.y + Math.sin(angle) * distance });
211
- }
212
- const moving = nodes.filter((node) => !fixed.has(node.id));
213
- if (!moving.length)
214
- return;
215
- const pairs = [...neighbors].flatMap(([from, tos]) => [...tos].filter((to) => from < to).map((to) => [from, to]));
216
- const ideal = 230;
217
- const rounds = fixed.size ? 200 : 500;
218
- for (let round = 0;round < rounds; round++) {
219
- const heat = 40 * (1 - round / rounds) + 1;
220
- const push = new Map(nodes.map((node) => [node.id, { x: 0, y: 0 }]));
221
- for (const [index, a] of nodes.entries()) {
222
- for (const b of nodes.slice(index + 1)) {
223
- const pa = positions.get(a.id);
224
- const pb = positions.get(b.id);
225
- let dx = pa.x - pb.x;
226
- let dy = pa.y - pb.y;
227
- if (dx === 0 && dy === 0) {
228
- dx = hash(a.id + b.id) - 0.5;
229
- dy = hash(b.id + a.id) - 0.5;
230
- }
231
- const distance = Math.max(Math.hypot(dx, dy), 1);
232
- if (distance > ideal * 3)
233
- continue;
234
- const force = ideal * ideal / distance;
235
- push.get(a.id).x += dx / distance * force;
236
- push.get(a.id).y += dy / distance * force;
237
- push.get(b.id).x -= dx / distance * force;
238
- push.get(b.id).y -= dy / distance * force;
239
- }
240
- }
241
- for (const [from, to] of pairs) {
242
- const pa = positions.get(from);
243
- const pb = positions.get(to);
244
- if (!pa || !pb)
245
- continue;
246
- const dx = pa.x - pb.x;
247
- const dy = pa.y - pb.y;
248
- const distance = Math.max(Math.hypot(dx, dy), 1);
249
- const force = distance * distance / ideal;
250
- push.get(from).x -= dx / distance * force;
251
- push.get(from).y -= dy / distance * force;
252
- push.get(to).x += dx / distance * force;
253
- push.get(to).y += dy / distance * force;
254
- }
255
- for (const node of moving) {
256
- const point = positions.get(node.id);
257
- const force = push.get(node.id);
258
- force.x -= point.x * 0.15;
259
- force.y -= point.y * 0.15;
260
- const length = Math.max(Math.hypot(force.x, force.y), 1);
261
- const step = Math.min(length, heat);
262
- point.x += force.x / length * step;
263
- point.y += force.y / length * step;
264
- }
265
- }
266
- savePositions();
267
- }
268
- const visibleEdges = () => data?.edges.filter((edge) => edge.layer === layer) ?? [];
269
- function visibleNodes() {
270
- const ends = new Set(visibleEdges().flatMap((edge) => [edge.from, edge.to]));
271
- return data?.nodes.filter((node) => node.kind === "device" || ends.has(node.id)) ?? [];
272
- }
273
- const nodeById = (id) => data?.nodes.find((node) => node.id === id);
274
- function markOf(node) {
275
- if (!data || node.kind !== "device")
276
- return;
277
- const findings = data.findings.filter((finding) => finding.device === node.label);
278
- if (findings.some((finding) => finding.level === "error"))
279
- return "error";
280
- const drift = data.drift[node.label]?.status;
281
- if (findings.length || drift === "differs" || drift === "cannot plan")
282
- return "warning";
283
- return;
284
- }
285
- function curve(edge, offset) {
286
- const a = positions.get(edge.from) ?? { x: 0, y: 0 };
287
- const b = positions.get(edge.to) ?? { x: 0, y: 0 };
288
- const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
289
- const length = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 1);
290
- const normal = { x: -(b.y - a.y) / length, y: (b.x - a.x) / length };
291
- const control = { x: mid.x + normal.x * offset * 2, y: mid.y + normal.y * offset * 2 };
292
- return {
293
- d: `M${a.x},${a.y} Q${control.x},${control.y} ${b.x},${b.y}`,
294
- label: { x: mid.x + normal.x * offset, y: mid.y + normal.y * offset }
295
- };
296
- }
297
- function place() {
298
- viewport.setAttribute("transform", `translate(${view.x},${view.y}) scale(${view.k})`);
299
- svg.classList.toggle("far", view.k < 0.4);
300
- for (const [id, group] of nodeElements) {
301
- const point = positions.get(id);
302
- group.setAttribute("transform", `translate(${point.x},${point.y})`);
303
- }
304
- for (const { edge, path, hit, label, offset } of edgeElements) {
305
- const { d, label: at } = curve(edge, offset);
306
- path.setAttribute("d", d);
307
- hit.setAttribute("d", d);
308
- label.setAttribute("x", String(at.x));
309
- label.setAttribute("y", String(at.y));
310
- }
311
- }
312
- function draw() {
313
- edgeLayer.replaceChildren();
314
- nodeLayer.replaceChildren();
315
- nodeElements.clear();
316
- edgeElements.length = 0;
317
- const edges = visibleEdges();
318
- const between = new Map;
319
- const pairOf = (edge) => [edge.from, edge.to].sort().join("|");
320
- for (const edge of edges)
321
- between.set(pairOf(edge), (between.get(pairOf(edge)) ?? 0) + 1);
322
- const seen = new Map;
323
- for (const edge of edges) {
324
- const pair = pairOf(edge);
325
- const index = seen.get(pair) ?? 0;
326
- seen.set(pair, index + 1);
327
- const sign = edge.from < edge.to ? 1 : -1;
328
- const offset = (index - ((between.get(pair) ?? 1) - 1) / 2) * 34 * sign;
329
- const group = s("g", { class: `edge${edge.oneSided ? " one-sided" : ""}` });
330
- const hit = s("path", { class: "hit" });
331
- const path = s("path");
332
- const label = s("text", { "text-anchor": "middle", "dominant-baseline": "central" });
333
- label.textContent = edge.label;
334
- group.append(path, hit, label);
335
- group.addEventListener("pointerdown", (event) => {
336
- event.stopPropagation();
337
- select({ edge });
338
- });
339
- edgeLayer.append(group);
340
- edgeElements.push({ edge, group, path, hit, label, offset });
341
- }
342
- for (const node of visibleNodes()) {
343
- const group = s("g", { class: `node ${node.kind} ${markOf(node) ?? ""}`, tabindex: 0 });
344
- const rect = s("rect", { rx: node.kind === "segment" ? 14 : 8 });
345
- group.append(rect);
346
- const lines = node.kind === "device" ? [node.label, node.detail[0] ?? ""] : [node.label];
347
- const texts = lines.map((line, index) => {
348
- const text = s("text", { class: index === 0 ? "name" : "sub", y: lines.length === 1 ? 0 : index === 0 ? -8 : 9 });
349
- text.textContent = line;
350
- group.append(text);
351
- return text;
352
- });
353
- nodeLayer.append(group);
354
- const width = Math.max(...texts.map((text) => text.getComputedTextLength())) + 24;
355
- const height = lines.length === 1 ? 28 : 44;
356
- rect.setAttribute("x", String(-width / 2));
357
- rect.setAttribute("y", String(-height / 2));
358
- rect.setAttribute("width", String(width));
359
- rect.setAttribute("height", String(height));
360
- group.addEventListener("pointerdown", (event) => startDrag(event, node.id));
361
- group.addEventListener("pointerenter", () => {
362
- hovered = node.id;
363
- emphasize();
364
- });
365
- group.addEventListener("pointerleave", () => {
366
- hovered = undefined;
367
- emphasize();
368
- });
369
- group.addEventListener("keydown", (event) => event.key === "Enter" && select({ node: node.id }));
370
- nodeElements.set(node.id, group);
371
- }
372
- place();
373
- emphasize();
374
- }
375
- function emphasize() {
376
- const focus = hovered ?? (selection && "node" in selection ? selection.node : undefined);
377
- const selectedEdge = selection && "edge" in selection ? selection.edge : undefined;
378
- const touching = (edge) => edge.from === focus || edge.to === focus;
379
- const carries = (edge) => edge.vlans?.some((vlan) => vlan.id === vlanFocus) ?? false;
380
- let litEdges;
381
- if (focus)
382
- litEdges = new Set(edgeElements.filter(({ edge }) => touching(edge)).map(({ edge }) => edge));
383
- else if (vlanFocus !== undefined && layer === "l2")
384
- litEdges = new Set(edgeElements.filter(({ edge }) => carries(edge)).map(({ edge }) => edge));
385
- else if (selectedEdge)
386
- litEdges = new Set([selectedEdge]);
387
- const litNodes = litEdges ? new Set([...litEdges].flatMap((edge) => [edge.from, edge.to])) : undefined;
388
- if (focus)
389
- litNodes?.add(focus);
390
- for (const { edge, group } of edgeElements) {
391
- group.classList.toggle("lit", Boolean(litEdges?.has(edge)) && !selectedEdge);
392
- group.classList.toggle("selected", edge === selectedEdge);
393
- group.classList.toggle("dim", Boolean(litEdges) && !litEdges.has(edge));
394
- }
395
- for (const [id, group] of nodeElements) {
396
- group.classList.toggle("selected", Boolean(selection && "node" in selection && selection.node === id));
397
- group.classList.toggle("dim", Boolean(litNodes) && !litNodes.has(id));
398
- }
399
- }
400
- let drag;
401
- const toWorld = (event) => {
402
- const box = svg.getBoundingClientRect();
403
- return { x: (event.clientX - box.left - view.x) / view.k, y: (event.clientY - box.top - view.y) / view.k };
404
- };
405
- function startDrag(event, id) {
406
- if (event.button !== 0)
407
- return;
408
- event.stopPropagation();
409
- svg.setPointerCapture(event.pointerId);
410
- const origin = id ? { ...positions.get(id) } : { x: view.x, y: view.y };
411
- drag = { id, start: { x: event.clientX, y: event.clientY }, origin, moved: false };
412
- if (!id)
413
- svg.classList.add("panning");
414
- }
415
- svg.addEventListener("pointerdown", (event) => startDrag(event));
416
- svg.addEventListener("pointermove", (event) => {
417
- if (!drag)
418
- return;
419
- const dx = event.clientX - drag.start.x;
420
- const dy = event.clientY - drag.start.y;
421
- if (Math.hypot(dx, dy) > 3)
422
- drag.moved = true;
423
- if (!drag.moved)
424
- return;
425
- if (drag.id)
426
- positions.set(drag.id, { x: drag.origin.x + dx / view.k, y: drag.origin.y + dy / view.k });
427
- else
428
- view = { ...view, x: drag.origin.x + dx, y: drag.origin.y + dy };
429
- place();
430
- });
431
- svg.addEventListener("pointerup", () => {
432
- if (!drag)
433
- return;
434
- const ended = drag;
435
- drag = undefined;
436
- svg.classList.remove("panning");
437
- if (ended.moved && ended.id)
438
- savePositions();
439
- if (ended.moved)
440
- return;
441
- select(ended.id ? { node: ended.id } : undefined);
442
- });
443
- svg.addEventListener("wheel", (event) => {
444
- event.preventDefault();
445
- const at = toWorld(event);
446
- const k = Math.min(Math.max(view.k * Math.exp(-event.deltaY * 0.0015), 0.15), 4);
447
- const box = svg.getBoundingClientRect();
448
- view = { k, x: event.clientX - box.left - at.x * k, y: event.clientY - box.top - at.y * k };
449
- place();
450
- }, { passive: false });
451
- function fit() {
452
- const points = visibleNodes().map((node) => positions.get(node.id)).filter((point) => Boolean(point));
453
- if (!points.length)
454
- return;
455
- const box = svg.getBoundingClientRect();
456
- const xs = points.map((point) => point.x);
457
- const ys = points.map((point) => point.y);
458
- const width = Math.max(...xs) - Math.min(...xs) + 240;
459
- const height = Math.max(...ys) - Math.min(...ys) + 160;
460
- const k = Math.min(Math.max(Math.min(box.width / width, box.height / height), 0.15), 1.5);
461
- const centre = { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
462
- view = { k, x: box.width / 2 - centre.x * k, y: box.height / 2 - centre.y * k };
463
- place();
464
- }
465
- fitButton.addEventListener("click", fit);
466
- layoutButton.addEventListener("click", () => {
467
- if (!data)
468
- return;
469
- layout(data.nodes, data.edges, true);
470
- draw();
471
- fit();
472
- });
473
- function select(next) {
474
- selection = next;
475
- emphasize();
476
- showPanel();
477
- }
478
- function findingList(findings, withDevice) {
479
- return h("ul", {}, ...findings.map((finding) => h("li", {}, h("span", { class: `badge ${finding.level}` }, finding.level), withDevice && finding.device ? link(finding.device, () => select({ node: `device:${finding.device}` })) : undefined, withDevice && finding.device ? ": " : undefined, finding.message)));
480
- }
481
- function vlanChips(vlans) {
482
- const chips = h("div", { class: "chips" });
483
- for (const vlan of vlans) {
484
- const button = h("button", { "aria-pressed": String(vlan.id === vlanFocus) }, `${vlan.name} ${vlan.id}`);
485
- button.addEventListener("click", () => {
486
- vlanFocus = vlanFocus === vlan.id ? undefined : vlan.id;
487
- selection = undefined;
488
- emphasize();
489
- showPanel();
490
- });
491
- chips.append(button);
492
- }
493
- return chips;
494
- }
495
- function legend() {
496
- const sample = (dashed, color) => {
497
- const icon = s("svg", { width: 34, height: 10 });
498
- icon.append(s("line", { x1: 2, y1: 5, x2: 32, y2: 5, stroke: color, "stroke-width": 2, "stroke-dasharray": dashed ? "6 4" : "" }));
499
- return icon;
500
- };
501
- const box = (style) => {
502
- const icon = s("svg", { width: 34, height: 16 });
503
- icon.append(s("rect", { x: 2, y: 2, width: 30, height: 12, rx: 3, fill: "none", "stroke-width": 2, style }));
504
- return icon;
505
- };
506
- return h("ul", { class: "legend" }, h("li", {}, sample(false, "var(--edge)"), "both ends declare it"), h("li", {}, sample(true, "var(--warning)"), "only one end declares it"), h("li", {}, box("stroke: var(--error)"), "a device with an error"), h("li", {}, box("stroke: var(--warning)"), "a warning, or unlike its snapshot"), h("li", {}, box("stroke: var(--line); stroke-dasharray: 5 3"), "outside the network"));
507
- }
508
- function showPanel() {
509
- panel.replaceChildren();
510
- if (!data)
511
- return;
512
- if (selection && "node" in selection) {
513
- const node = nodeById(selection.node);
514
- if (node)
515
- return showNode(node);
516
- }
517
- if (selection && "edge" in selection)
518
- return showEdge(selection.edge);
519
- const devices = data.nodes.filter((node) => node.kind === "device");
520
- const drifted = devices.filter((node) => data.drift[node.label]?.status === "differs");
521
- const unsnapped = devices.filter((node) => data.drift[node.label]?.status === "no snapshot");
522
- panel.append(h("h2", {}, LAYER_NAMES[layer]), h("p", { class: "muted" }, LAYER_HELP[layer]));
523
- if (layer === "l2") {
524
- const vlans = new Map;
525
- for (const edge of visibleEdges())
526
- for (const vlan of edge.vlans ?? [])
527
- vlans.set(vlan.id, vlan);
528
- const sorted = [...vlans.values()].sort((a, b) => a.id - b.id);
529
- if (sorted.length)
530
- panel.append(h("h3", {}, "VLANs"), vlanChips(sorted));
531
- }
532
- if (data.findings.length)
533
- panel.append(h("h3", {}, "Found by validate"), findingList(data.findings, true));
534
- if (drifted.length) {
535
- panel.append(h("h3", {}, "Unlike their last snapshot"), h("ul", {}, ...drifted.map((node) => h("li", {}, link(node.label, () => select({ node: node.id }))))));
536
- }
537
- if (unsnapped.length) {
538
- panel.append(h("h3", {}, "No snapshot yet"), h("p", { class: "muted" }, unsnapped.map((node) => node.label).join(", ")));
539
- }
540
- panel.append(h("h3", {}, "Legend"), legend());
541
- }
542
- function showNode(node) {
543
- const kinds = { device: "device", segment: "shared segment", outside: "outside the network" };
544
- panel.append(h("h2", {}, node.label), h("div", { class: "kind" }, kinds[node.kind]));
545
- if (node.detail.length)
546
- panel.append(h("h3", {}, "Details"), h("pre", {}, node.detail.join(`
547
- `)));
548
- if (node.kind === "device" && data) {
549
- const findings = data.findings.filter((finding) => finding.device === node.label);
550
- if (findings.length)
551
- panel.append(h("h3", {}, "Found by validate"), findingList(findings, false));
552
- const drift = data.drift[node.label];
553
- if (drift) {
554
- const notes = {
555
- matches: "Matches its last snapshot.",
556
- differs: "Differs from its last snapshot. This is what diff would send:",
557
- "no snapshot": "No snapshot yet: run circuit snapshot.",
558
- "cannot plan": "Could not be planned against its snapshot:"
559
- };
560
- panel.append(h("h3", {}, "Against its snapshot"), h("p", {}, notes[drift.status]));
561
- if (drift.lines.length)
562
- panel.append(h("pre", {}, drift.lines.join(`
563
- `)));
564
- }
565
- }
566
- const edges = visibleEdges().filter((edge) => edge.from === node.id || edge.to === node.id);
567
- panel.append(h("h3", {}, `${LAYER_NAMES[layer]} here`));
568
- if (!edges.length)
569
- panel.append(h("p", { class: "muted" }, "None on this layer."));
570
- panel.append(h("ul", {}, ...edges.map((edge) => {
571
- const other = nodeById(edge.from === node.id ? edge.to : edge.from);
572
- return h("li", {}, link(edge.label, () => select({ edge })), h("span", { class: "muted" }, ` with ${other?.label ?? "?"}`));
573
- })));
574
- }
575
- function showEdge(edge) {
576
- const from = nodeById(edge.from);
577
- const to = nodeById(edge.to);
578
- panel.append(h("h2", {}, edge.label), h("div", { class: "kind" }, `${LAYER_NAMES[edge.layer]}, between `, link(from?.label ?? edge.from, () => select({ node: edge.from })), " and ", link(to?.label ?? edge.to, () => select({ node: edge.to }))));
579
- if (edge.oneSided)
580
- panel.append(h("p", {}, h("span", { class: "badge warning" }, "one end"), "Only one end declares it."));
581
- if (edge.detail.length)
582
- panel.append(h("h3", {}, "Details"), h("pre", {}, edge.detail.join(`
583
- `)));
584
- if (edge.vlans?.length)
585
- panel.append(h("h3", {}, "VLANs"), vlanChips(edge.vlans));
586
- }
587
- function showHeader() {
588
- nav.replaceChildren();
589
- layerButtons.clear();
590
- for (const [index, name] of LAYERS.entries()) {
591
- const count = data?.edges.filter((edge) => edge.layer === name).length ?? 0;
592
- const button = h("button", { "aria-pressed": String(name === layer), title: `${LAYER_HELP[name]} Key ${index + 1}.` }, LAYER_NAMES[name], h("span", { class: "count" }, String(count)));
593
- button.addEventListener("click", () => chooseLayer(name));
594
- nav.append(button);
595
- layerButtons.set(name, button);
596
- }
597
- if (!data)
598
- return;
599
- const devices = data.nodes.filter((node) => node.kind === "device").length;
600
- const errors = data.findings.filter((finding) => finding.level === "error").length;
601
- const warnings = data.findings.length - errors;
602
- const drifted = Object.values(data.drift).filter((drift) => drift.status === "differs").length;
603
- summary.textContent = [
604
- `${devices} ${devices === 1 ? "device" : "devices"}`,
605
- `${errors} ${errors === 1 ? "error" : "errors"}`,
606
- `${warnings} ${warnings === 1 ? "warning" : "warnings"}`,
607
- `${drifted} unlike the snapshot`
608
- ].join(" · ");
609
- }
610
- function chooseLayer(next) {
611
- layer = next;
612
- store.set("layer", layer);
613
- history.replaceState(null, "", `#${layer}`);
614
- if (selection && "edge" in selection)
615
- selection = undefined;
616
- vlanFocus = undefined;
617
- showHeader();
618
- draw();
619
- showPanel();
620
- }
621
- document.addEventListener("keydown", (event) => {
622
- if (event.target instanceof HTMLInputElement)
623
- return;
624
- if (event.key === "Escape")
625
- select(undefined);
626
- const chosen = LAYERS[Number(event.key) - 1];
627
- if (chosen)
628
- chooseLayer(chosen);
629
- });
630
- async function load() {
631
- let next;
632
- try {
633
- next = await (await fetch("/graph", { cache: "no-store" })).json();
634
- } catch (error) {
635
- next = { error: `could not reach circuit studio: ${error.message}` };
636
- }
637
- if (next.error !== undefined) {
638
- banner.textContent = `The config could not be read, so the drawing is the last one that could:
639
- ${next.error}`;
640
- banner.hidden = false;
641
- return;
642
- }
643
- banner.hidden = true;
644
- const first = !data;
645
- data = next;
646
- if (selection && "node" in selection && !nodeById(selection.node))
647
- selection = undefined;
648
- if (selection && "edge" in selection) {
649
- const { layer: was, from, to, label } = selection.edge;
650
- const same = data.edges.find((edge) => edge.layer === was && edge.from === from && edge.to === to && edge.label === label);
651
- selection = same ? { edge: same } : undefined;
652
- }
653
- layout(data.nodes, data.edges, false);
654
- showHeader();
655
- draw();
656
- showPanel();
657
- if (first)
658
- fit();
659
- }
660
- const events = new EventSource("/events");
661
- events.addEventListener("open", () => {
662
- live.textContent = "watching the config";
663
- live.classList.add("on");
664
- });
665
- events.addEventListener("error", () => {
666
- live.textContent = "not connected: is circuit studio still running?";
667
- live.classList.remove("on");
668
- });
669
- events.addEventListener("message", () => void load());
670
- window.addEventListener("resize", () => place());
671
- load();
672
- }
673
-
674
- // src/cli/studio.ts
675
- var HTML = `<!doctype html>
676
- <html lang="en">
677
- <head>
678
- <meta charset="utf-8">
679
- <meta name="viewport" content="width=device-width, initial-scale=1">
680
- <title>Circuit studio</title>
681
- <style>${STYLE}</style>
682
- </head>
683
- <body>
684
- <script>(${page.toString()})()</script>
685
- </body>
686
- </html>
687
- `;
688
- var IGNORED = /(^|[\\/])(node_modules|\.git)([\\/]|$)/;
689
- function makeGraph(configPath) {
690
- const cli = process.argv[1];
691
- const args = [...process.execArgv, cli, "graph", "--format=json", "--layer=all", `--config=${configPath}`];
692
- return new Promise((done) => {
693
- execFile(process.execPath, args, { maxBuffer: 256 * 1024 * 1024 }, (error, stdout, stderr) => {
694
- if (!error)
695
- return done(stdout);
696
- const message = (stderr || error.message).trim();
697
- done(JSON.stringify({ error: message }));
698
- });
699
- });
700
- }
701
- function openBrowser(url) {
702
- const [command, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
703
- const child = spawn(command, args, { stdio: "ignore", detached: true });
704
- child.on("error", () => {});
705
- child.unref();
706
- }
707
- async function studio(options) {
708
- const { configPath, root, port } = options;
709
- let graph = await makeGraph(configPath);
710
- const listeners = new Set;
711
- const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
712
- const server = createServer((request, response) => {
713
- if (!allowedHosts.has(request.headers.host ?? "")) {
714
- response.writeHead(403).end();
715
- return;
716
- }
717
- if (request.url === "/") {
718
- response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(HTML);
719
- return;
720
- }
721
- if (request.url === "/graph") {
722
- response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(graph);
723
- return;
724
- }
725
- if (request.url === "/events") {
726
- response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
727
- response.write(`: connected
728
-
729
- `);
730
- listeners.add(response);
731
- request.on("close", () => listeners.delete(response));
732
- return;
733
- }
734
- response.writeHead(404).end();
735
- });
736
- let timer;
737
- watch(root, { recursive: true }, (_event, filename) => {
738
- if (filename && IGNORED.test(filename))
739
- return;
740
- clearTimeout(timer);
741
- timer = setTimeout(async () => {
742
- graph = await makeGraph(configPath);
743
- for (const listener of listeners)
744
- listener.write(`data: changed
745
-
746
- `);
747
- console.log(`${new Date().toLocaleTimeString()} ${filename ?? "a file"} changed, graph made again`);
748
- }, 300);
749
- });
750
- server.on("error", (error) => {
751
- const message = error.code === "EADDRINUSE" ? `port ${port} is in use; choose another with --port=N` : error.message;
752
- console.error(message);
753
- process.exit(1);
754
- });
755
- server.listen(port, "127.0.0.1", () => {
756
- const url = `http://127.0.0.1:${port}`;
757
- console.log(`Circuit studio at ${url}, watching ${root}. Stop it with Ctrl+C.`);
758
- if (options.open)
759
- openBrowser(url);
760
- });
761
- }
762
- export {
763
- studio
764
- };