@takodotid/circuit 0.19.0 → 0.21.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 (54) hide show
  1. package/README.md +2 -2
  2. package/dist/chunks/index-n4zpd25n.js +936 -0
  3. package/dist/chunks/{index-dczpxzzm.js → index-v8zgr7wm.js} +33 -14
  4. package/dist/chunks/{new-7n8qvtjd.js → new-ny89s7y7.js} +4 -6
  5. package/dist/chunks/server-zapfzhv4.js +244 -0
  6. package/dist/cli/index.js +855 -803
  7. package/dist/index.js +20 -21
  8. package/dist/presets/index.js +20 -21
  9. package/dist/types/adapters/devices/derive.d.ts +2 -0
  10. package/dist/types/core/across.d.ts +4 -0
  11. package/dist/types/core/addr.d.ts +10 -0
  12. package/dist/types/core/addresses.d.ts +46 -0
  13. package/dist/types/core/files.d.ts +6 -0
  14. package/dist/types/core/graph.d.ts +31 -1
  15. package/dist/types/core/schema-docs.d.ts +16 -0
  16. package/package.json +7 -7
  17. package/src/adapters/devices/derive.ts +32 -0
  18. package/src/adapters/devices/raisecom-ros/index.ts +23 -3
  19. package/src/adapters/devices/routeros/index.ts +10 -0
  20. package/src/adapters/devices/vrp/index.ts +10 -1
  21. package/src/cli/graph.ts +1 -15
  22. package/src/cli/index.ts +154 -20
  23. package/src/cli/rename.ts +129 -0
  24. package/src/core/across.ts +322 -0
  25. package/src/core/addr.ts +29 -0
  26. package/src/core/addresses.ts +316 -0
  27. package/src/core/files.ts +32 -0
  28. package/src/core/graph.ts +27 -6
  29. package/src/core/schema-docs.ts +166 -0
  30. package/src/core/validate.ts +2 -52
  31. package/src/studio/data.ts +255 -0
  32. package/src/studio/git.ts +119 -0
  33. package/src/studio/public/app.js +165 -0
  34. package/src/studio/public/compare.js +243 -0
  35. package/src/studio/public/diff.js +97 -0
  36. package/src/studio/public/dom.js +108 -0
  37. package/src/studio/public/evaluate.js +648 -0
  38. package/src/studio/public/export.js +122 -0
  39. package/src/studio/public/favicon.svg +12 -0
  40. package/src/studio/public/graph.js +983 -0
  41. package/src/studio/public/index.html +71 -0
  42. package/src/studio/public/net.js +128 -0
  43. package/src/studio/public/special-gothic-latin-ext.woff2 +0 -0
  44. package/src/studio/public/special-gothic-latin.woff2 +0 -0
  45. package/src/studio/public/special-gothic-license.txt +93 -0
  46. package/src/studio/public/style.css +1334 -0
  47. package/src/studio/public/tables.js +1223 -0
  48. package/src/studio/public/tools.js +578 -0
  49. package/src/studio/server.ts +219 -0
  50. package/templates/project/AGENTS.md +2 -0
  51. package/dist/chunks/index-fq57hya4.js +0 -20
  52. package/dist/chunks/studio-femhydvd.js +0 -764
  53. package/src/cli/studio-page.ts +0 -876
  54. package/src/cli/studio.ts +0 -126
@@ -0,0 +1,1223 @@
1
+ // @ts-check
2
+ /// <reference lib="dom" />
3
+ // Studio's tables, for reading the config without reading TypeScript: every device and everything it holds, and the network's VLANs, addresses, BGP sessions and cables.
4
+
5
+ import { fetchJson } from "./compare.js";
6
+ import { diffLines } from "./diff.js";
7
+ import { go, h, link } from "./dom.js";
8
+ import { csvOf, save } from "./export.js";
9
+ import { currentLayer, LAYER_NAMES, lightVlan, selectDevice } from "./graph.js";
10
+
11
+ /** @typedef {import("../../schema").Device} Device */
12
+ /** @typedef {import("../data").StudioData} StudioData */
13
+ /** @typedef {import("./dom.js").Child} Child */
14
+ /** A column named after a field shows the name as code. A table with `csv` can be saved under that name. @typedef {{ head: { label: string, title?: string, field?: boolean }[], rows: { cells: Child[], muted?: boolean, id?: string }[], csv?: string }} Table */
15
+ /** @typedef {{ id: string, title: string, has: (device: Device) => boolean, render: (device: Device) => Child[] }} Section */
16
+
17
+ /** What the tables show, set each time they are drawn. @type {StudioData} */
18
+ let data;
19
+
20
+ /**
21
+ * @param {unknown} value
22
+ * @returns {value is Record<string, unknown>}
23
+ */
24
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
25
+
26
+ /** @param {unknown} value */
27
+ const isSecret = (value) =>
28
+ isObject(value) && Object.keys(value).length === 1 && (typeof value.secret === "string" || typeof value.secret_file === "string");
29
+
30
+ /** @param {Record<string, unknown>} value */
31
+ const secretText = (value) => (typeof value.secret === "string" ? `secret("${value.secret}")` : `secretFile("${value.secret_file}")`);
32
+
33
+ /**
34
+ * What a field is, from the schema's comments: looked up in these types, and the types they take fields from.
35
+ *
36
+ * @param {readonly string[]} types
37
+ * @param {string} field
38
+ * @returns {string | undefined}
39
+ */
40
+ function describe(types, field) {
41
+ const seen = new Set();
42
+ const queue = [...types];
43
+
44
+ for (let name = queue.shift(); name !== undefined; name = queue.shift()) {
45
+ if (seen.has(name)) continue;
46
+ seen.add(name);
47
+
48
+ const type = data.docs[name];
49
+ if (!type) continue;
50
+
51
+ const found = type.fields[field] ?? Object.entries(type.fields).find(([path]) => path.endsWith(`.${field}`))?.[1];
52
+ if (found?.description) return found.description;
53
+
54
+ queue.push(...type.includes);
55
+ }
56
+
57
+ return undefined;
58
+ }
59
+
60
+ /**
61
+ * A value in one line of text.
62
+ *
63
+ * @param {unknown} value
64
+ * @returns {string}
65
+ */
66
+ function compact(value) {
67
+ if (value === undefined || value === null) return "";
68
+ if (isSecret(value)) return secretText(/** @type {Record<string, unknown>} */ (value));
69
+ if (Array.isArray(value)) return value.map(compact).join(value.every((item) => !isObject(item)) ? ", " : " / ");
70
+
71
+ // A field written as `{}`, such as `ssh: {}`, is on with its defaults.
72
+ if (isObject(value) && !Object.keys(value).length) return "on";
73
+ if (isObject(value)) {
74
+ return Object.entries(value)
75
+ .map(([key, inner]) => (inner === true ? key : `${key} ${compact(inner)}`))
76
+ .join(", ");
77
+ }
78
+
79
+ return String(value);
80
+ }
81
+
82
+ /**
83
+ * @param {Table} table
84
+ * @returns {Node}
85
+ */
86
+ export function table({ head, rows, csv }) {
87
+ /** @param {Table["rows"][number]} row */
88
+ const attributesOf = (row) => ({ ...(row.muted ? { class: "muted" } : {}), ...(row.id ? { id: row.id } : {}) });
89
+
90
+ const element = h(
91
+ "table",
92
+ { class: "data" },
93
+ h(
94
+ "thead",
95
+ {},
96
+ h(
97
+ "tr",
98
+ {},
99
+ ...head.map((column) =>
100
+ h("th", column.title ? { title: column.title } : {}, column.field ? h("code", {}, column.label) : column.label)
101
+ )
102
+ )
103
+ ),
104
+ h("tbody", {}, ...rows.map((row) => h("tr", attributesOf(row), ...row.cells.map((cell) => h("td", {}, cell)))))
105
+ );
106
+
107
+ if (!csv) return element;
108
+
109
+ const download = link("Save as CSV", () => save(`${csv}.csv`, new Blob([csvOf(element)], { type: "text/csv" })));
110
+ return h("div", { class: "table-block" }, h("p", { class: "table-actions" }, download), element);
111
+ }
112
+
113
+ /**
114
+ * The fields any of the items hold, in the order first seen, with a chosen few first.
115
+ *
116
+ * @param {Record<string, unknown>[]} items
117
+ * @param {readonly string[]} [first]
118
+ */
119
+ function columnsOf(items, first = []) {
120
+ const seen = new Set(first.filter((key) => items.some((item) => key in item)));
121
+ for (const item of items) for (const key of Object.keys(item)) seen.add(key);
122
+ return [...seen];
123
+ }
124
+
125
+ /**
126
+ * Named items, such as ports or neighbors, as a table: a row per name, a column per field.
127
+ *
128
+ * @param {object} record
129
+ * @param {readonly string[]} types
130
+ * @param {string} [nameLabel]
131
+ * @param {readonly string[]} [first]
132
+ * @returns {Node}
133
+ */
134
+ function recordTable(record, types, nameLabel = "Name", first = []) {
135
+ const entries = Object.entries(record).filter(([, value]) => value !== undefined);
136
+ if (!entries.length) return empty("None.");
137
+
138
+ /** @type {Record<string, unknown>[]} */
139
+ const items = entries.map(([, value]) => (isObject(value) && !isSecret(value) ? value : { value }));
140
+ const columns = columnsOf(items, first);
141
+
142
+ return table({
143
+ head: [{ label: nameLabel }, ...columns.map((column) => ({ label: column, title: describe(types, column), field: true }))],
144
+ rows: entries.map(([name], index) => ({
145
+ cells: [h("strong", {}, name), ...columns.map((column) => compact(items[index]?.[column]))],
146
+ })),
147
+ });
148
+ }
149
+
150
+ /**
151
+ * A list of items, such as rules or routes, as a table, in order.
152
+ *
153
+ * @param {readonly unknown[]} list
154
+ * @param {readonly string[]} types
155
+ * @param {readonly string[]} [first]
156
+ * @returns {Node}
157
+ */
158
+ function listTable(list, types, first = []) {
159
+ if (!list.length) return empty("None.");
160
+
161
+ const items = list.map((value) => (isObject(value) ? value : { value }));
162
+ const columns = columnsOf(items, first);
163
+
164
+ return table({
165
+ head: [{ label: "#" }, ...columns.map((column) => ({ label: column, title: describe(types, column), field: true }))],
166
+ rows: items.map((item, index) => ({ cells: [String(index + 1), ...columns.map((column) => compact(item[column]))] })),
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Settings as a table of field and value. A value that holds more is shown inside, the same way.
172
+ *
173
+ * @param {object} object
174
+ * @param {readonly string[]} types
175
+ * @returns {Node}
176
+ */
177
+ function settings(object, types) {
178
+ const entries = Object.entries(object).filter(([, value]) => value !== undefined);
179
+ if (!entries.length) return empty("None.");
180
+
181
+ /**
182
+ * @param {unknown} value
183
+ * @returns {Child}
184
+ */
185
+ const valueOf = (value) => {
186
+ if (isObject(value) && !Object.keys(value).length) return compact(value);
187
+ if (isObject(value) && !isSecret(value)) {
188
+ const allObjects = Object.values(value).every((inner) => isObject(inner) && !isSecret(inner));
189
+ return allObjects ? recordTable(value, types) : settings(value, types);
190
+ }
191
+
192
+ if (Array.isArray(value) && value.some(isObject)) return listTable(value, types);
193
+ return compact(value);
194
+ };
195
+
196
+ return h(
197
+ "table",
198
+ { class: "data settings" },
199
+ h(
200
+ "tbody",
201
+ {},
202
+ ...entries.map(([key, value]) => {
203
+ const title = describe(types, key);
204
+ return h("tr", {}, h("th", title ? { title } : {}, h("code", {}, key)), h("td", {}, valueOf(value)));
205
+ })
206
+ )
207
+ );
208
+ }
209
+
210
+ /**
211
+ * @param {string} text
212
+ * @param {"h2" | "h3" | "h4"} [level]
213
+ */
214
+ export const heading = (text, level = "h3") => h(level, {}, text);
215
+
216
+ /** @param {string} text */
217
+ export const empty = (text) => h("p", { class: "muted" }, text);
218
+
219
+ /**
220
+ * A muted paragraph of several parts, such as text with a command in it.
221
+ *
222
+ * @param {...import("./dom.js").Child} parts
223
+ */
224
+ export const prose = (...parts) => h("p", { class: "muted" }, ...parts);
225
+
226
+ /** @param {string} name */
227
+ export const deviceLink = (name) => link(name, () => go(`devices/${encodeURIComponent(name)}`));
228
+
229
+ /** @param {string} name */
230
+ const deviceRoute = (name) => `devices/${encodeURIComponent(name)}`;
231
+
232
+ /**
233
+ * @param {string} device
234
+ * @returns {Child[]}
235
+ */
236
+ function findingBadges(device) {
237
+ const findings = data.graph.findings.filter((finding) => finding.device === device);
238
+ const errors = findings.filter((finding) => finding.level === "error").length;
239
+ const warnings = findings.length - errors;
240
+
241
+ return [
242
+ errors ? h("span", { class: "badge error" }, `${errors} ${errors === 1 ? "error" : "errors"}`) : undefined,
243
+ warnings ? h("span", { class: "badge warning" }, `${warnings} ${warnings === 1 ? "warning" : "warnings"}`) : undefined,
244
+ ];
245
+ }
246
+
247
+ /** @type {Record<string, string>} */
248
+ const SNAPSHOT_TEXT = {
249
+ matches: "matches its snapshot",
250
+ differs: "differs from its snapshot",
251
+ "no snapshot": "no snapshot yet",
252
+ "cannot plan": "cannot be planned",
253
+ };
254
+
255
+ /**
256
+ * How a port is used, for its square on the faceplate.
257
+ *
258
+ * @param {Record<string, unknown> | undefined} port
259
+ * @returns {"shut" | "lag" | "access" | "trunk" | "routed" | "plain"}
260
+ */
261
+ function portUse(port) {
262
+ if (!port) return "shut";
263
+ if (port.lag) return "lag";
264
+ if (port.access_vlan) return "access";
265
+ if (port.trunk_vlans) return "trunk";
266
+ if (port.addresses) return "routed";
267
+ return "plain";
268
+ }
269
+
270
+ /** @type {Record<ReturnType<typeof portUse>, string>} */
271
+ const PORT_USES = {
272
+ shut: "shut down",
273
+ lag: "in a LAG",
274
+ access: "access",
275
+ trunk: "trunk",
276
+ routed: "routed",
277
+ plain: "up, nothing on it",
278
+ };
279
+
280
+ /**
281
+ * The device's ports as its front panel: one square per port, by speed, colored by how the port is used. A square points to its row.
282
+ *
283
+ * @param {Device} device
284
+ * @param {string[]} ports
285
+ * @param {Record<string, Record<string, unknown> | undefined>} declared
286
+ */
287
+ function faceplate(device, ports, declared) {
288
+ /** @type {Map<string, string[]>} */
289
+ const bySpeed = new Map();
290
+ for (const name of ports) {
291
+ const speed = name.split("-")[0] ?? name;
292
+ bySpeed.set(speed, [...(bySpeed.get(speed) ?? []), name]);
293
+ }
294
+
295
+ const panel = h("div", { class: "faceplate", role: "group", "aria-label": `${device.name}'s ports` });
296
+
297
+ for (const [speed, names] of bySpeed) {
298
+ const squares = h("div", { class: "ports" });
299
+
300
+ for (const name of names) {
301
+ const port = declared[name];
302
+ const use = portUse(port);
303
+ const far = /** @type {{ device: string, port: string } | undefined} */ (port?.link);
304
+ const label = [name, PORT_USES[use], compact(port?.description), far ? `to ${far.device} ${far.port}` : ""]
305
+ .filter(Boolean)
306
+ .join(", ");
307
+
308
+ const square = h(
309
+ "button",
310
+ { class: `port ${use}${far ? " linked" : ""}`, title: label, "aria-label": label },
311
+ name.split("-")[1] ?? name
312
+ );
313
+ square.addEventListener("click", () => {
314
+ const row = document.getElementById(`port-${name}`);
315
+ row?.scrollIntoView({ block: "center", behavior: "smooth" });
316
+ row?.classList.add("flash");
317
+ setTimeout(() => row?.classList.remove("flash"), 1600);
318
+ });
319
+ squares.append(square);
320
+ }
321
+
322
+ panel.append(h("div", { class: "speed" }, h("span", { class: "speed-name" }, speed), squares));
323
+ }
324
+
325
+ const legend = h(
326
+ "ul",
327
+ { class: "faceplate-legend" },
328
+ ...Object.entries(PORT_USES).map(([use, text]) => h("li", {}, h("span", { class: `port ${use}` }), text)),
329
+ h("li", {}, h("span", { class: "port plain linked" }), "linked to another device")
330
+ );
331
+
332
+ return h("div", {}, panel, legend);
333
+ }
334
+
335
+ /**
336
+ * Two texts compared line by line: what is the same is folded away, except a few lines around each change.
337
+ *
338
+ * @param {string} before
339
+ * @param {string} after
340
+ * @returns {Child}
341
+ */
342
+ export function lineDiff(before, after) {
343
+ const lines = diffLines(before.trimEnd().split("\n"), after.trimEnd().split("\n"));
344
+ if (!lines) return empty("They differ in too many lines to compare one by one. The plan shows what would be sent.");
345
+
346
+ const changed = lines.filter((line) => line.kind !== "same").length;
347
+ if (!changed) return empty("They are the same, line for line.");
348
+
349
+ const context = 3;
350
+ const near = lines.map((_, index) =>
351
+ lines.slice(Math.max(0, index - context), index + context + 1).some((line) => line.kind !== "same")
352
+ );
353
+
354
+ const code = h("pre", { class: "code diff" });
355
+ let folded = 0;
356
+
357
+ const fold = () => {
358
+ if (folded) code.append(h("span", { class: "fold" }, `${folded} ${folded === 1 ? "line" : "lines"} the same\n`));
359
+ folded = 0;
360
+ };
361
+
362
+ for (const [index, line] of lines.entries()) {
363
+ if (!near[index]) {
364
+ folded++;
365
+ continue;
366
+ }
367
+
368
+ fold();
369
+ const mark = line.kind === "added" ? "+ " : line.kind === "removed" ? "- " : " ";
370
+ code.append(h("span", { class: line.kind }, `${mark}${line.text}\n`));
371
+ }
372
+
373
+ fold();
374
+ return h("div", {}, empty(`${changed} ${changed === 1 ? "line differs" : "lines differ"}.`), code);
375
+ }
376
+
377
+ /** The fields the Ports table has columns of its own for. */
378
+ const PORT_FIELDS = ["description", "access_vlan", "trunk_vlans", "native_vlan", "addresses", "lag", "link"];
379
+
380
+ /**
381
+ * How a port is switched. A LAG member carries what its LAG carries.
382
+ *
383
+ * @param {Device} device
384
+ * @param {Record<string, unknown>} port
385
+ * @returns {string}
386
+ */
387
+ function switching(device, port) {
388
+ const lag = typeof port.lag === "string" ? device.interfaces?.[port.lag] : undefined;
389
+ if (lag) {
390
+ const carried = switching(device, { ...lag, lag: undefined });
391
+ return carried ? `${carried}, on LAG ${port.lag}` : "";
392
+ }
393
+
394
+ if (port.access_vlan) return `access ${port.access_vlan}`;
395
+ if (Array.isArray(port.trunk_vlans))
396
+ return `trunk ${port.trunk_vlans.join(", ")}${port.native_vlan ? `, native ${port.native_vlan}` : ""}`;
397
+ return "";
398
+ }
399
+
400
+ // A device's sections. Each lists the schema types its fields come from, for their descriptions.
401
+ /** @type {Section[]} */
402
+ const SECTIONS = [
403
+ {
404
+ id: "overview",
405
+ title: "Overview",
406
+ has: () => true,
407
+ render(device) {
408
+ const findings = data.graph.findings.filter((finding) => finding.device === device.name);
409
+ const drift = data.graph.drift[device.name];
410
+
411
+ const { platform, model, connection, system, hardware_offload, lldp, stp, vrfs } = device;
412
+ const shown = { platform, model, connection, hardware_offload, lldp, stp, vrfs, system };
413
+ const differs = drift?.status === "differs";
414
+
415
+ return [
416
+ findings.length ? h("h3", {}, "Found by ", h("code", {}, "circuit validate")) : undefined,
417
+ findings.length
418
+ ? h(
419
+ "ul",
420
+ { class: "findings" },
421
+ ...findings.map((finding) =>
422
+ h("li", {}, h("span", { class: `badge ${finding.level}` }, finding.level), finding.message)
423
+ )
424
+ )
425
+ : undefined,
426
+ heading("Against its snapshot"),
427
+ h(
428
+ "p",
429
+ {},
430
+ drift ? (SNAPSHOT_TEXT[drift.status] ?? "") : "not compared",
431
+ differs ? " · " : "",
432
+ differs ? link("see the plan", () => go(`${deviceRoute(device.name)}/plan`)) : undefined
433
+ ),
434
+ heading("Device"),
435
+ settings(shown, ["Device", "Connection", "System", "Vrf"]),
436
+ ];
437
+ },
438
+ },
439
+ {
440
+ id: "ports",
441
+ title: "Ports",
442
+ has: () => true,
443
+ render(device) {
444
+ const declared = /** @type {Record<string, Record<string, unknown> | undefined>} */ (device.ports ?? {});
445
+ const all = [...new Set([...(data.ports[device.name] ?? []), ...Object.keys(declared)])];
446
+ const types = ["Port", "Routed", "Switched", "Link", "PortStp", "Vrrp", "Exchange"];
447
+
448
+ const rows = all.map((name) => {
449
+ const port = declared[name];
450
+ const id = `port-${name}`;
451
+ if (!port) return { id, cells: [name, "not declared, so shut down", "", "", "", "", ""], muted: true };
452
+
453
+ const far = /** @type {{ device: string, port: string } | undefined} */ (port.link);
454
+ const other = Object.fromEntries(Object.entries(port).filter(([key]) => !PORT_FIELDS.includes(key)));
455
+
456
+ return {
457
+ id,
458
+ cells: [
459
+ h("strong", {}, name),
460
+ compact(port.description),
461
+ switching(device, port),
462
+ compact(port.addresses),
463
+ compact(port.lag),
464
+ far ? h("span", {}, deviceLink(far.device), ` ${far.port}`) : "",
465
+ Object.keys(other).length ? compact(other) : "",
466
+ ],
467
+ };
468
+ });
469
+
470
+ /**
471
+ * @param {string} label
472
+ * @param {string} [field]
473
+ */
474
+ const column = (label, field) => ({ label, title: field ? describe(types, field) : undefined });
475
+ const used = Object.keys(declared).length;
476
+
477
+ return [
478
+ h("p", { class: "muted" }, `${used} of ${all.length} ports declared. A port the config does not declare is shut down.`),
479
+ faceplate(device, all, declared),
480
+ table({
481
+ csv: `${device.name}-ports`,
482
+ head: [
483
+ column("Port"),
484
+ column("Description", "description"),
485
+ column("VLANs", "trunk_vlans"),
486
+ column("Addresses", "addresses"),
487
+ column("LAG", "lag"),
488
+ column("Link", "link"),
489
+ column("Other"),
490
+ ],
491
+ rows,
492
+ }),
493
+ ];
494
+ },
495
+ },
496
+ {
497
+ id: "interfaces",
498
+ title: "Interfaces",
499
+ has: (device) => Object.keys(device.interfaces ?? {}).length > 0,
500
+ render: (device) => [
501
+ recordTable(
502
+ device.interfaces ?? {},
503
+ [
504
+ "VlanInterface",
505
+ "Loopback",
506
+ "Lag",
507
+ "Gre",
508
+ "Vxlan",
509
+ "WireGuard",
510
+ "Base",
511
+ "Routed",
512
+ "Switched",
513
+ "Vrrp",
514
+ "WireGuardPeer",
515
+ "Exchange",
516
+ ],
517
+ "Interface",
518
+ ["type", "description", "vlan", "addresses"]
519
+ ),
520
+ ],
521
+ },
522
+ {
523
+ id: "vlans",
524
+ title: "VLANs",
525
+ has: (device) => Object.keys(device.vlans ?? {}).length > 0,
526
+ render: (device) => [recordTable(device.vlans ?? {}, ["Vlan"], "VLAN", ["id", "description"])],
527
+ },
528
+ {
529
+ id: "routing",
530
+ title: "Routing",
531
+ has: (device) => device.routing !== undefined,
532
+ render(device) {
533
+ const routing = device.routing ?? {};
534
+ const types = ["Routing", "StaticRoute", "Bgp", "Neighbor", "NeighborSettings", "Ospf", "OspfInterface", "RpkiServer"];
535
+ /** @type {Child[]} */
536
+ const parts = [];
537
+
538
+ if (routing.router_id) parts.push(settings({ router_id: routing.router_id }, types));
539
+ if (routing.static?.length)
540
+ parts.push(heading("Static routes"), listTable(routing.static, types, ["prefix", "via", "interface"]));
541
+
542
+ if (routing.bgp) {
543
+ const { asn, networks, groups, neighbors } = routing.bgp;
544
+ parts.push(heading("BGP"), settings({ asn, networks }, types));
545
+ if (groups) parts.push(heading("BGP groups"), recordTable(groups, types, "Group"));
546
+ if (neighbors)
547
+ parts.push(
548
+ heading("BGP neighbors"),
549
+ recordTable(neighbors, types, "Neighbor", ["address", "group", "remote_as", "role"])
550
+ );
551
+ }
552
+
553
+ if (routing.ospf) {
554
+ const { families, redistribute } = routing.ospf;
555
+ parts.push(heading("OSPF"));
556
+ if (families || redistribute) parts.push(settings({ families, redistribute }, types));
557
+ for (const [area, { interfaces }] of Object.entries(routing.ospf.areas)) {
558
+ parts.push(heading(`Area ${area}`, "h4"), recordTable(interfaces, types, "Interface"));
559
+ }
560
+ }
561
+
562
+ if (routing.rpki?.length) parts.push(heading("RPKI"), listTable(routing.rpki, types));
563
+ return parts;
564
+ },
565
+ },
566
+ {
567
+ id: "policies",
568
+ title: "Policies",
569
+ has: (device) => Object.keys({ ...device.policies, ...device.prefix_sets }).length > 0,
570
+ render(device) {
571
+ const types = ["PolicyRule", "Match", "Sets", "PrefixSet"];
572
+ /** @type {Child[]} */
573
+ const parts = [];
574
+
575
+ for (const [name, rules] of Object.entries(device.policies ?? {})) {
576
+ parts.push(heading(`Policy ${name}`), listTable(rules, types, ["description", "match", "set", "action"]));
577
+ }
578
+
579
+ if (device.prefix_sets) parts.push(heading("Prefix sets"), recordTable(device.prefix_sets, types, "Prefix set"));
580
+ return parts;
581
+ },
582
+ },
583
+ {
584
+ id: "firewall",
585
+ title: "Firewall",
586
+ has: (device) => device.firewall !== undefined || Object.keys(device.acls ?? {}).length > 0,
587
+ render(device) {
588
+ const types = ["Firewall", "Chain", "FilterRule", "FilterMatch", "NatRule", "AclRule"];
589
+ const firewall = device.firewall;
590
+ /** @type {Child[]} */
591
+ const parts = [];
592
+
593
+ if (firewall?.address_sets) {
594
+ parts.push(
595
+ heading("Address sets"),
596
+ table({
597
+ head: [{ label: "Set" }, { label: "Members" }],
598
+ rows: Object.entries(firewall.address_sets).map(([name, members]) => ({
599
+ cells: [h("strong", {}, name), compact(members)],
600
+ })),
601
+ })
602
+ );
603
+ }
604
+
605
+ // A filter chain has a default and rules; a NAT chain is its rules.
606
+ for (const [chain, settingsOfChain] of Object.entries(firewall?.filter ?? {})) {
607
+ if (!settingsOfChain) continue;
608
+ const fallback = settingsOfChain.default ? `, default ${settingsOfChain.default}` : "";
609
+ parts.push(
610
+ heading(`Filter ${chain}${fallback}`),
611
+ listTable(settingsOfChain.rules ?? [], types, ["description", "match", "action"])
612
+ );
613
+ }
614
+
615
+ for (const [chain, rules] of Object.entries(firewall?.nat ?? {})) {
616
+ if (rules) parts.push(heading(`NAT ${chain}`), listTable(rules, types, ["description", "match", "action"]));
617
+ }
618
+
619
+ const { helpers, untracked } = firewall ?? {};
620
+ if (helpers || untracked) parts.push(heading("Other"), settings({ helpers, untracked }, types));
621
+
622
+ for (const [name, rules] of Object.entries(device.acls ?? {})) {
623
+ parts.push(heading(`ACL ${name}`), listTable(rules, types, ["description", "match", "action"]));
624
+ }
625
+
626
+ return parts;
627
+ },
628
+ },
629
+ {
630
+ id: "management",
631
+ title: "Management",
632
+ has: () => true,
633
+ render(device) {
634
+ const types = ["Management", "Service", "SnmpUser", "User", "Certificate"];
635
+
636
+ // A certificate is shown by its size; it is public, but a page of PEM helps no one here.
637
+ const certificates = Object.fromEntries(
638
+ Object.entries(device.certificates ?? {}).map(([name, certificate]) => [
639
+ name,
640
+ { ...certificate, certificate: `PEM, ${certificate.certificate.trim().split("\n").length} lines` },
641
+ ])
642
+ );
643
+ const hasCertificates = Object.keys(certificates).length > 0;
644
+
645
+ return [
646
+ heading("Users"),
647
+ recordTable(device.users ?? {}, types, "User", ["role", "description"]),
648
+ heading("Ways in"),
649
+ settings(device.management ?? {}, types),
650
+ hasCertificates ? heading("Certificates") : undefined,
651
+ hasCertificates ? recordTable(certificates, types, "Certificate") : undefined,
652
+ ];
653
+ },
654
+ },
655
+ {
656
+ id: "services",
657
+ title: "Services",
658
+ has: (device) => Boolean(device.dhcp || device.dhcp_relay || device.flow_export),
659
+ render(device) {
660
+ const types = ["DhcpServer", "DhcpRelay", "FlowExport"];
661
+
662
+ return [
663
+ device.dhcp ? heading("DHCP servers") : undefined,
664
+ device.dhcp ? recordTable(device.dhcp, types, "Server", ["interface", "network", "gateway", "pool"]) : undefined,
665
+ device.dhcp_relay ? heading("DHCP relays") : undefined,
666
+ device.dhcp_relay ? recordTable(device.dhcp_relay, types, "Relay") : undefined,
667
+ device.flow_export ? heading("Flow export") : undefined,
668
+ device.flow_export ? settings(device.flow_export, types) : undefined,
669
+ ];
670
+ },
671
+ },
672
+ {
673
+ id: "config",
674
+ title: "Config",
675
+ has: () => true,
676
+ render: (device) => [
677
+ prose(
678
+ "What the device should run, in its vendor's commands, as ",
679
+ h("code", {}, "circuit build"),
680
+ " prints it. Secrets are markers, never values."
681
+ ),
682
+ h("pre", { class: "code" }, data.rendered[device.name] ?? ""),
683
+ ],
684
+ },
685
+ {
686
+ id: "running",
687
+ title: "Running",
688
+ has: () => true,
689
+ render(device) {
690
+ const snapshot = data.snapshots[device.name];
691
+ if (snapshot === undefined)
692
+ return [prose("No snapshot yet, so nothing to compare with: run ", h("code", {}, "circuit snapshot"), ".")];
693
+
694
+ return [
695
+ prose(
696
+ "What the device ran when Circuit last read it, ",
697
+ h("span", { class: "removed" }, "marked -"),
698
+ ", beside what the config would have it run, ",
699
+ h("span", { class: "added" }, "marked +"),
700
+ ". Lines can differ only in order or form; the plan shows what would be sent."
701
+ ),
702
+ lineDiff(snapshot, data.rendered[device.name] ?? ""),
703
+ ];
704
+ },
705
+ },
706
+ {
707
+ id: "history",
708
+ title: "History",
709
+ has: () => true,
710
+ render(device) {
711
+ const area = h("div", {}, empty("Reading the history…"));
712
+ const files = data.files[device.name] ?? {};
713
+
714
+ void fetchJson(`history?device=${encodeURIComponent(device.name)}`).then((found) => {
715
+ area.replaceChildren();
716
+ if (!Array.isArray(found)) return area.append(empty(`No history: ${"error" in found ? found.error : "git has none"}.`));
717
+ if (!found.length) return area.append(empty("No commit has changed it yet."));
718
+
719
+ area.append(
720
+ prose(
721
+ "The commits that changed ",
722
+ files.source ? h("code", {}, files.source) : "its file",
723
+ files.snapshot ? h("span", {}, " or its snapshot, ", h("code", {}, files.snapshot)) : "",
724
+ ", newest first."
725
+ ),
726
+ table({
727
+ csv: `${device.name}-history`,
728
+ head: ["When", "Who", "What", "Commit", ""].map((label) => ({ label })),
729
+ rows: /** @type {import("../git").Commit[]} */ (found).map((commit) => ({
730
+ cells: [
731
+ new Date(commit.date).toLocaleString(),
732
+ commit.author,
733
+ commit.subject,
734
+ h("code", {}, commit.short),
735
+ link("compare with now", () => go(`compare/${commit.commit}`)),
736
+ ],
737
+ })),
738
+ })
739
+ );
740
+ });
741
+
742
+ return [area];
743
+ },
744
+ },
745
+ {
746
+ id: "plan",
747
+ title: "Plan",
748
+ has: () => true,
749
+ render(device) {
750
+ const drift = data.graph.drift[device.name];
751
+ if (!drift || drift.status === "no snapshot")
752
+ return [prose("No snapshot yet, so nothing to compare with: run ", h("code", {}, "circuit snapshot"), ".")];
753
+ if (drift.status === "matches")
754
+ return [prose("The device matches its last snapshot: ", h("code", {}, "circuit diff"), " would send nothing.")];
755
+
756
+ return [
757
+ drift.status === "differs"
758
+ ? prose("What ", h("code", {}, "circuit diff"), " would send, against the last snapshot:")
759
+ : empty("It could not be planned:"),
760
+ h("pre", { class: "code" }, drift.lines.join("\n")),
761
+ ];
762
+ },
763
+ },
764
+ ];
765
+
766
+ /** @param {HTMLElement} container */
767
+ function devicesView(container) {
768
+ container.append(
769
+ heading("Devices", "h2"),
770
+ table({
771
+ csv: "devices",
772
+ head: ["Name", "Platform", "Model", "Reached at", "Ports declared", "Snapshot", "Found"].map((label) => ({ label })),
773
+ rows: data.devices.map((device) => {
774
+ const badges = findingBadges(device.name);
775
+
776
+ return {
777
+ cells: [
778
+ h("strong", {}, deviceLink(device.name)),
779
+ device.platform,
780
+ device.model,
781
+ device.connection.host,
782
+ `${Object.keys(device.ports ?? {}).length} of ${data.ports[device.name]?.length ?? "?"}`,
783
+ SNAPSHOT_TEXT[data.graph.drift[device.name]?.status ?? ""] ?? "",
784
+ badges.some(Boolean) ? h("span", {}, ...badges) : h("span", { class: "muted" }, "nothing"),
785
+ ],
786
+ };
787
+ }),
788
+ })
789
+ );
790
+ }
791
+
792
+ /**
793
+ * @param {string} name
794
+ * @param {string | undefined} sectionId
795
+ * @param {HTMLElement} container
796
+ */
797
+ function deviceView(name, sectionId, container) {
798
+ const device = data.devices.find((candidate) => candidate.name === name);
799
+ if (!device) {
800
+ container.append(
801
+ heading("No such device", "h2"),
802
+ empty(`The config has no device ${name}.`),
803
+ link("All devices", () => go("devices"))
804
+ );
805
+ return;
806
+ }
807
+
808
+ const sections = SECTIONS.filter((section) => section.has(device));
809
+ const section = sections.find((candidate) => candidate.id === sectionId) ?? SECTIONS[0];
810
+ if (!section) return;
811
+
812
+ const tabs = h("nav", { class: "tabs", "aria-label": "Sections" });
813
+ for (const candidate of sections) {
814
+ const button = h("button", { "aria-pressed": String(candidate === section) }, candidate.title);
815
+ button.addEventListener("click", () => go(`${deviceRoute(device.name)}/${candidate.id}`));
816
+ tabs.append(button);
817
+ }
818
+
819
+ container.append(
820
+ h(
821
+ "p",
822
+ { class: "crumbs" },
823
+ link("Devices", () => go("devices")),
824
+ " / "
825
+ ),
826
+ h("h2", {}, device.name, " ", ...findingBadges(device.name)),
827
+ h(
828
+ "p",
829
+ { class: "muted" },
830
+ `${device.platform} ${device.model}, reached at ${device.connection.host} · `,
831
+ link("show in the graph", () => {
832
+ selectDevice(device.name);
833
+ go(`graph/${currentLayer()}`);
834
+ })
835
+ ),
836
+ tabs,
837
+ h("div", { class: "section" }, ...section.render(device))
838
+ );
839
+ }
840
+
841
+ /** @param {HTMLElement} container */
842
+ function vlansView(container) {
843
+ /** @type {Map<number, { names: Set<string>, declared: string[], gateways: string[], ports: string[] }>} */
844
+ const byId = new Map();
845
+
846
+ for (const device of data.devices) {
847
+ for (const [name, vlan] of Object.entries(device.vlans ?? {})) {
848
+ const row = byId.get(vlan.id) ?? { names: new Set(), declared: [], gateways: [], ports: [] };
849
+ row.names.add(name);
850
+ row.declared.push(device.name);
851
+
852
+ for (const [iface, settingsOfInterface] of Object.entries(device.interfaces ?? {})) {
853
+ if (settingsOfInterface.type === "vlan" && settingsOfInterface.vlan === name) {
854
+ row.gateways.push(`${device.name} ${iface} ${compact(settingsOfInterface.addresses)}`.trim());
855
+ }
856
+ }
857
+
858
+ const carriers = /** @type {(Record<string, unknown> | undefined)[]} */ (
859
+ Object.values({ ...device.ports, ...device.interfaces })
860
+ );
861
+ const access = carriers.filter((carrier) => carrier?.access_vlan === name).length;
862
+ const trunk = carriers.filter((carrier) => Array.isArray(carrier?.trunk_vlans) && carrier.trunk_vlans.includes(name)).length;
863
+ const counts = [access ? `${access} access` : "", trunk ? `${trunk} trunk` : ""].filter(Boolean).join(", ");
864
+ if (counts) row.ports.push(`${device.name}: ${counts}`);
865
+
866
+ byId.set(vlan.id, row);
867
+ }
868
+ }
869
+
870
+ const rows = [...byId].sort(([a], [b]) => a - b);
871
+
872
+ container.append(
873
+ heading("VLANs", "h2"),
874
+ empty("Every VLAN any device declares. One id under two names is worth a look."),
875
+ rows.length
876
+ ? table({
877
+ csv: "vlans",
878
+ head: ["VLAN", "Name", "Declared on", "Gateway", "Ports", ""].map((label) => ({ label })),
879
+ rows: rows.map(([id, row]) => ({
880
+ cells: [
881
+ h("strong", {}, String(id)),
882
+ [...row.names].join(", "),
883
+ h("span", {}, ...row.declared.flatMap((name, index) => [index ? ", " : "", deviceLink(name)])),
884
+ row.gateways.join("; "),
885
+ row.ports.join("; "),
886
+ link("cables in the graph", () => {
887
+ lightVlan(id);
888
+ go("graph/l2");
889
+ }),
890
+ ],
891
+ })),
892
+ })
893
+ : empty("No device declares a VLAN.")
894
+ );
895
+ }
896
+
897
+ /** @param {HTMLElement} container */
898
+ function addressesView(container) {
899
+ const { spaces, elsewhere } = data.addresses;
900
+ container.append(
901
+ heading("Addresses", "h2"),
902
+ empty("Every address the config uses, by prefix, and what is still free, as circuit addresses prints it.")
903
+ );
904
+
905
+ /**
906
+ * @param {typeof elsewhere.segments} segments
907
+ * @param {typeof elsewhere.blocks} blocks
908
+ */
909
+ const segmentsAndBlocks = (segments, blocks) => {
910
+ for (const segment of segments) {
911
+ const next = segment.next ? `, next ${segment.next}` : "";
912
+ container.append(
913
+ heading(`${segment.prefix}, ${segment.free} free${next}`, "h4"),
914
+ table({
915
+ head: [{ label: "Address" }, { label: "Used by" }],
916
+ rows: segment.uses.map((use) => ({ cells: [use.address, use.what] })),
917
+ })
918
+ );
919
+ }
920
+
921
+ if (blocks.length) {
922
+ container.append(
923
+ heading("Blocks", "h4"),
924
+ table({
925
+ head: [{ label: "Prefix" }, { label: "Used by" }],
926
+ rows: blocks.map((block) => ({ cells: [block.prefix, block.what] })),
927
+ })
928
+ );
929
+ }
930
+ };
931
+
932
+ for (const space of spaces) {
933
+ container.append(heading(`${space.prefix}, ${space.owner}`));
934
+ segmentsAndBlocks(space.segments, space.blocks);
935
+ container.append(h("p", {}, h("strong", {}, "Free: "), space.free.join(", ") || "none"));
936
+ }
937
+
938
+ if (elsewhere.segments.length || elsewhere.blocks.length) {
939
+ container.append(heading("Outside the space the network originates"));
940
+ segmentsAndBlocks(elsewhere.segments, elsewhere.blocks);
941
+ }
942
+
943
+ if (!spaces.length && !elsewhere.segments.length && !elsewhere.blocks.length) container.append(empty("The config holds no addresses."));
944
+ }
945
+
946
+ /** @param {HTMLElement} container */
947
+ function sessionsView(container) {
948
+ const ours = new Set(data.devices.map((device) => device.name));
949
+
950
+ container.append(
951
+ heading("BGP sessions", "h2"),
952
+ empty("Every BGP neighbor, with its group's settings filled in. A far end that is one of our devices links to it."),
953
+ data.sessions.length
954
+ ? table({
955
+ csv: "bgp-sessions",
956
+ head: ["Device", "Neighbor", "Address", "Remote AS", "Role", "Import", "Export", "Far end", ""].map((label) => ({
957
+ label,
958
+ })),
959
+ rows: data.sessions.map((session) => ({
960
+ cells: [
961
+ deviceLink(session.device),
962
+ h("strong", {}, session.name),
963
+ session.address,
964
+ session.remoteAs ? String(session.remoteAs) : "",
965
+ session.role ?? "",
966
+ session.import ?? "imports nothing",
967
+ session.export ?? "exports nothing",
968
+ session.far && ours.has(session.far) ? deviceLink(session.far) : (session.far ?? ""),
969
+ session.oneSided ? h("span", { class: "badge warning" }, "no session back") : "",
970
+ ],
971
+ })),
972
+ })
973
+ : empty("No device runs BGP.")
974
+ );
975
+ }
976
+
977
+ /** @param {HTMLElement} container */
978
+ function cablesView(container) {
979
+ container.append(
980
+ heading("Cables", "h2"),
981
+ empty("Every cable a port's link names, once, from the end that names it first."),
982
+ data.cables.length
983
+ ? table({
984
+ csv: "cables",
985
+ head: ["Device", "Port", "Far device", "Far port", "LAG", "VLANs", "Description", ""].map((label) => ({ label })),
986
+ rows: data.cables.map((cable) => ({
987
+ cells: [
988
+ deviceLink(cable.device),
989
+ cable.port,
990
+ deviceLink(cable.farDevice),
991
+ cable.farPort,
992
+ [cable.lag, cable.farLag].filter(Boolean).join(" to "),
993
+ cable.vlans.join(", "),
994
+ cable.description ?? "",
995
+ cable.oneSided ? h("span", { class: "badge warning" }, "not linked back") : "",
996
+ ],
997
+ })),
998
+ })
999
+ : empty("No port names a link.")
1000
+ );
1001
+ }
1002
+
1003
+ /**
1004
+ * Everything that needs a look, in one place: what validate found, what only one end declares, and devices unlike their snapshot.
1005
+ *
1006
+ * @param {HTMLElement} container
1007
+ */
1008
+ function problemsView(container) {
1009
+ const { graph } = data;
1010
+ const nameOf = (/** @type {string} */ id) => graph.nodes.find((node) => node.id === id)?.label ?? id;
1011
+
1012
+ const errors = graph.findings.filter((finding) => finding.level === "error");
1013
+ const warnings = graph.findings.filter((finding) => finding.level === "warning");
1014
+ const oneSided = graph.edges.filter((edge) => edge.oneSided);
1015
+ const drifted = data.devices.filter((device) => graph.drift[device.name]?.status === "differs");
1016
+ const unplanned = data.devices.filter((device) => graph.drift[device.name]?.status === "cannot plan");
1017
+ const unsnapped = data.devices.filter((device) => graph.drift[device.name]?.status === "no snapshot");
1018
+
1019
+ container.append(
1020
+ heading("Problems", "h2"),
1021
+ empty("Everything that needs a look. Studio finds these from the files and the last snapshots alone.")
1022
+ );
1023
+
1024
+ if (!errors.length && !warnings.length && !oneSided.length && !drifted.length && !unplanned.length) {
1025
+ container.append(empty("Nothing: every check passes, both ends declare everything, and every device with a snapshot matches it."));
1026
+ }
1027
+
1028
+ /**
1029
+ * @param {string} title
1030
+ * @param {import("../../core/define").Finding[]} findings
1031
+ */
1032
+ const findingTable = (title, findings) => {
1033
+ if (!findings.length) return;
1034
+
1035
+ container.append(
1036
+ h("h3", {}, title, " found by ", h("code", {}, "circuit validate")),
1037
+ table({
1038
+ csv: title.toLowerCase(),
1039
+ head: ["Device", "Message"].map((label) => ({ label })),
1040
+ rows: findings.map((finding) => ({
1041
+ cells: [finding.device ? deviceLink(finding.device) : "the network", finding.message],
1042
+ })),
1043
+ })
1044
+ );
1045
+ };
1046
+
1047
+ findingTable("Errors", errors);
1048
+ findingTable("Warnings", warnings);
1049
+
1050
+ if (oneSided.length) {
1051
+ container.append(
1052
+ heading("Declared by one end only"),
1053
+ table({
1054
+ csv: "one-sided",
1055
+ head: ["Layer", "What", "From", "To", ""].map((label) => ({ label })),
1056
+ rows: oneSided.map((edge) => ({
1057
+ cells: [
1058
+ LAYER_NAMES[edge.layer],
1059
+ edge.label,
1060
+ nameOf(edge.from),
1061
+ nameOf(edge.to),
1062
+ link("in the graph", () => go(`graph/${edge.layer}`)),
1063
+ ],
1064
+ })),
1065
+ })
1066
+ );
1067
+ }
1068
+
1069
+ /**
1070
+ * @param {string} title
1071
+ * @param {Device[]} devices
1072
+ * @param {string} section
1073
+ * @param {string} action
1074
+ */
1075
+ const deviceList = (title, devices, section, action) => {
1076
+ if (!devices.length) return;
1077
+
1078
+ container.append(
1079
+ heading(title),
1080
+ h(
1081
+ "ul",
1082
+ { class: "findings" },
1083
+ ...devices.map((device) =>
1084
+ h(
1085
+ "li",
1086
+ {},
1087
+ deviceLink(device.name),
1088
+ " · ",
1089
+ link(action, () => go(`${deviceRoute(device.name)}/${section}`))
1090
+ )
1091
+ )
1092
+ )
1093
+ );
1094
+ };
1095
+
1096
+ deviceList("Unlike their last snapshot", drifted, "plan", "see the plan");
1097
+ deviceList("Could not be planned against their snapshot", unplanned, "plan", "see why");
1098
+
1099
+ if (unsnapped.length) {
1100
+ container.append(
1101
+ heading("No snapshot yet"),
1102
+ prose(
1103
+ unsnapped.map((device) => device.name).join(", "),
1104
+ ". Run ",
1105
+ h("code", {}, "circuit snapshot"),
1106
+ " to compare them with what they run."
1107
+ )
1108
+ );
1109
+ }
1110
+ }
1111
+
1112
+ /** How many problems there are, for the top bar. */
1113
+ export function problemCount(/** @type {StudioData} */ next) {
1114
+ const drifted = Object.values(next.graph.drift).filter((drift) => drift.status === "differs" || drift.status === "cannot plan").length;
1115
+ return next.graph.findings.length + next.graph.edges.filter((edge) => edge.oneSided).length + drifted;
1116
+ }
1117
+
1118
+ /** Which section shows each part of a device, for search results. @type {Record<string, string>} */
1119
+ const SECTION_OF = {
1120
+ ports: "ports",
1121
+ interfaces: "interfaces",
1122
+ vlans: "vlans",
1123
+ routing: "routing",
1124
+ policies: "policies",
1125
+ prefix_sets: "policies",
1126
+ firewall: "firewall",
1127
+ acls: "firewall",
1128
+ users: "management",
1129
+ management: "management",
1130
+ certificates: "management",
1131
+ dhcp: "services",
1132
+ dhcp_relay: "services",
1133
+ flow_export: "services",
1134
+ };
1135
+
1136
+ /**
1137
+ * @param {string} query
1138
+ * @param {HTMLElement} container
1139
+ */
1140
+ function searchView(query, container) {
1141
+ const needle = query.toLowerCase();
1142
+ /** @type {{ device: string, section: string, path: string, text: string }[]} */
1143
+ const results = [];
1144
+
1145
+ /**
1146
+ * @param {string} device
1147
+ * @param {string[]} path
1148
+ * @param {string} text
1149
+ */
1150
+ const check = (device, path, text) => {
1151
+ if (!text.toLowerCase().includes(needle) || path.at(-1) === "certificate") return;
1152
+ results.push({ device, section: SECTION_OF[path[0] ?? ""] ?? "overview", path: path.join(" › "), text });
1153
+ };
1154
+
1155
+ /**
1156
+ * @param {string} device
1157
+ * @param {string[]} path
1158
+ * @param {unknown} value
1159
+ */
1160
+ const walk = (device, path, value) => {
1161
+ if (results.length >= 200) return;
1162
+
1163
+ if (isSecret(value)) return check(device, path, secretText(/** @type {Record<string, unknown>} */ (value)));
1164
+ if (Array.isArray(value)) return value.forEach((item, index) => walk(device, [...path, String(index + 1)], item));
1165
+ if (isObject(value)) {
1166
+ for (const [key, inner] of Object.entries(value)) {
1167
+ // A name a device declares, such as a port or a VLAN, is a match too.
1168
+ if (path.length && key.toLowerCase().includes(needle)) check(device, [...path, key], key);
1169
+ walk(device, [...path, key], inner);
1170
+ }
1171
+ return;
1172
+ }
1173
+
1174
+ if (value !== undefined && value !== null) check(device, path, String(value));
1175
+ };
1176
+
1177
+ for (const device of data.devices) walk(device.name, [], device);
1178
+
1179
+ container.append(heading(`Search: ${query}`, "h2"));
1180
+
1181
+ if (!results.length) {
1182
+ container.append(empty("Nothing in the config matches."));
1183
+ return;
1184
+ }
1185
+
1186
+ container.append(
1187
+ empty(results.length >= 200 ? "The first 200 matches." : `${results.length} ${results.length === 1 ? "match" : "matches"}.`),
1188
+ table({
1189
+ csv: "search",
1190
+ head: ["Device", "Where", "Value"].map((label) => ({ label })),
1191
+ rows: results.map((result) => ({
1192
+ cells: [
1193
+ deviceLink(result.device),
1194
+ link(result.path, () => go(`${deviceRoute(result.device)}/${result.section}`)),
1195
+ result.text.length > 120 ? `${result.text.slice(0, 120)}…` : result.text,
1196
+ ],
1197
+ })),
1198
+ })
1199
+ );
1200
+ }
1201
+
1202
+ /**
1203
+ * Draw a place in Studio's tables, such as `["devices", "edge-01", "ports"]`, into a container.
1204
+ *
1205
+ * @param {string[]} route
1206
+ * @param {HTMLElement} container
1207
+ * @param {StudioData} next
1208
+ */
1209
+ export function showTables(route, container, next) {
1210
+ data = next;
1211
+ container.replaceChildren();
1212
+
1213
+ const [view, first, second] = route;
1214
+
1215
+ if (view === "devices" && first) deviceView(first, second, container);
1216
+ else if (view === "devices") devicesView(container);
1217
+ else if (view === "vlans") vlansView(container);
1218
+ else if (view === "addresses") addressesView(container);
1219
+ else if (view === "sessions") sessionsView(container);
1220
+ else if (view === "cables") cablesView(container);
1221
+ else if (view === "problems") problemsView(container);
1222
+ else if (view === "search") searchView(first ?? "", container);
1223
+ }