@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,578 @@
1
+ // @ts-check
2
+ /// <reference lib="dom" />
3
+ // Studio's tools, for finding out why something does or does not reach where it should: trace an address, find the VLAN path between two devices, test a packet against a firewall, and test a route against a route policy.
4
+
5
+ import { go, h, link } from "./dom.js";
6
+ import { filterPacket, routeThrough, trace, vlanPath } from "./evaluate.js";
7
+ import { lightPath } from "./graph.js";
8
+ import { deviceLink, empty, heading, prose, table } from "./tables.js";
9
+
10
+ /** @typedef {import("../data").StudioData} StudioData */
11
+ /** @typedef {import("../../schema").Device} Device */
12
+ /** @typedef {import("./dom.js").Child} Child */
13
+ /** @typedef {import("./evaluate.js").Check} Check */
14
+
15
+ /** What Studio shows, set each time a tool is drawn. @type {StudioData} */
16
+ let data;
17
+
18
+ /** What was typed into each tool, kept while the page is open, so a tool drawn again shows its last answer. */
19
+ const typed = {
20
+ vlan: { vlan: "", from: "", to: "" },
21
+ firewall: {
22
+ device: "",
23
+ chain: "input",
24
+ protocol: "tcp",
25
+ src: "",
26
+ dst: "",
27
+ srcPort: "",
28
+ dstPort: "",
29
+ inInterface: "",
30
+ outInterface: "",
31
+ state: "new",
32
+ flags: /** @type {string[]} */ ([]),
33
+ },
34
+ policy: {
35
+ device: "",
36
+ policy: "",
37
+ prefix: "",
38
+ asPathLength: "",
39
+ rpki: "",
40
+ communities: "",
41
+ largeCommunities: "",
42
+ inRegistrySets: /** @type {string[]} */ ([]),
43
+ },
44
+ };
45
+
46
+ const TOOLS = [
47
+ ["trace", "Trace an address"],
48
+ ["vlan", "VLAN path"],
49
+ ["firewall", "Test a firewall"],
50
+ ["policy", "Test a route policy"],
51
+ ];
52
+
53
+ /**
54
+ * A labelled field.
55
+ *
56
+ * @param {string} label
57
+ * @param {HTMLElement} control
58
+ * @param {string} [hint]
59
+ */
60
+ const field = (label, control, hint) =>
61
+ h("label", { class: "field" }, h("span", {}, label), control, hint ? h("small", { class: "muted" }, hint) : undefined);
62
+
63
+ /**
64
+ * A text box that keeps what is typed in `store[key]`.
65
+ *
66
+ * @param {Record<string, unknown>} store
67
+ * @param {string} key
68
+ * @param {string} [placeholder]
69
+ */
70
+ function textBox(store, key, placeholder = "") {
71
+ const input = h("input", { type: "text", value: String(store[key] ?? ""), placeholder, spellcheck: "false" });
72
+ input.addEventListener("input", () => (store[key] = input.value));
73
+ return input;
74
+ }
75
+
76
+ /**
77
+ * A choice that keeps what is chosen in `store[key]`.
78
+ *
79
+ * @param {Record<string, unknown>} store
80
+ * @param {string} key
81
+ * @param {[string, string][]} options value and text
82
+ * @param {() => void} [changed]
83
+ */
84
+ function choice(store, key, options, changed) {
85
+ const select = h(
86
+ "select",
87
+ {},
88
+ ...options.map(([value, text]) => h("option", { value, ...(value === store[key] ? { selected: "" } : {}) }, text))
89
+ );
90
+ if (!options.some(([value]) => value === store[key])) store[key] = options[0]?.[0] ?? "";
91
+ select.value = String(store[key]);
92
+ select.addEventListener("change", () => {
93
+ store[key] = select.value;
94
+ changed?.();
95
+ });
96
+ return select;
97
+ }
98
+
99
+ /**
100
+ * What a check found, in words.
101
+ *
102
+ * @param {Check} check
103
+ */
104
+ const verdictOf = (check) =>
105
+ check.answer === "yes"
106
+ ? "matches"
107
+ : check.answer === "no"
108
+ ? `does not match: ${check.why.join("; ")}`
109
+ : `cannot tell without ${check.why.join(", ")}`;
110
+
111
+ /**
112
+ * @param {string} text
113
+ * @param {() => void} action
114
+ */
115
+ function button(text, action) {
116
+ const element = h("button", { type: "submit", class: "primary" }, text);
117
+ element.addEventListener("click", (event) => {
118
+ event.preventDefault();
119
+ action();
120
+ });
121
+ return element;
122
+ }
123
+
124
+ // Trace an address.
125
+
126
+ /**
127
+ * @param {string | undefined} asked
128
+ * @param {HTMLElement} container
129
+ */
130
+ function traceTool(asked, container) {
131
+ const store = { address: asked ?? "" };
132
+ const input = textBox(store, "address", "10.0.10.5");
133
+
134
+ container.append(
135
+ prose(
136
+ "Where an address is in the network, how each device would reach it from what the files say, and every firewall rule and ACL that names it."
137
+ ),
138
+ h(
139
+ "form",
140
+ { class: "tool-form" },
141
+ field("Address", input),
142
+ button("Trace", () => store.address.trim() && go(`tools/trace/${encodeURIComponent(store.address.trim())}`))
143
+ )
144
+ );
145
+
146
+ if (!asked) return;
147
+
148
+ const found = trace(data, asked);
149
+ if (!found) {
150
+ container.append(empty(`${asked} is not an address, such as 10.0.10.5 or 2001:db8::1.`));
151
+ return;
152
+ }
153
+
154
+ const holder = found.held.length
155
+ ? h(
156
+ "span",
157
+ {},
158
+ "It is held by ",
159
+ ...found.held.flatMap((held, index) => [index ? ", " : "", deviceLink(held.device), ` ${held.where}`]),
160
+ "."
161
+ )
162
+ : "No device holds it.";
163
+ const where = found.segment
164
+ ? `It is in ${found.segment.prefix}${found.space ? `, inside ${found.space.prefix}, ${found.space.owner}` : ""}.`
165
+ : found.block
166
+ ? `It is in ${found.block.prefix}: ${found.block.what}.`
167
+ : found.space
168
+ ? `It is in ${found.space.prefix}, ${found.space.owner}, where nothing uses it yet.`
169
+ : "No segment or block of the network holds it.";
170
+
171
+ container.append(heading(asked, "h2"), h("p", {}, holder, " ", where));
172
+
173
+ if (found.uses.length) {
174
+ container.append(
175
+ heading("Used by"),
176
+ table({
177
+ head: [{ label: "Address" }, { label: "Used by" }],
178
+ rows: found.uses.map((use) => ({ cells: [use.address, use.what] })),
179
+ })
180
+ );
181
+ }
182
+
183
+ const kinds = {
184
+ local: "it is its own address",
185
+ connected: "an address of its own is in the same prefix",
186
+ static: "a static route",
187
+ none: "no route in the files",
188
+ };
189
+
190
+ container.append(
191
+ heading("How each device reaches it"),
192
+ prose("Routes a device learns from BGP or OSPF are not in the files, so a device with no route here may still reach it."),
193
+ table({
194
+ csv: `trace-${asked}`,
195
+ head: ["Device", "Table", "By", "Prefix", "Next hop or interface", "Note"].map((label) => ({ label })),
196
+ rows: found.routes.map((route) => ({
197
+ muted: route.kind === "none",
198
+ cells: [
199
+ deviceLink(route.device),
200
+ route.table,
201
+ kinds[route.kind],
202
+ route.prefix ?? "",
203
+ [route.via, route.through].filter(Boolean).join(", "),
204
+ route.note ?? "",
205
+ ],
206
+ })),
207
+ })
208
+ );
209
+
210
+ container.append(heading("Rules that name it"));
211
+ container.append(
212
+ found.rules.length
213
+ ? table({
214
+ head: ["Device", "Where", "Rule", "Description", "Action", "Names it as"].map((label) => ({ label })),
215
+ rows: found.rules.map((rule) => ({
216
+ cells: [deviceLink(rule.device), rule.where, String(rule.rule), rule.description ?? "", rule.action, rule.side],
217
+ })),
218
+ })
219
+ : empty("No firewall rule or ACL names it, by address, prefix or address set.")
220
+ );
221
+ }
222
+
223
+ // The VLAN path between two devices.
224
+
225
+ /** @param {HTMLElement} container */
226
+ function vlanTool(container) {
227
+ const store = typed.vlan;
228
+ const vlans = new Map();
229
+ for (const edge of data.graph.edges) for (const vlan of edge.vlans ?? []) vlans.set(vlan.id, vlan.name);
230
+
231
+ const devices = data.devices.map((device) => /** @type {[string, string]} */ ([device.name, device.name]));
232
+ const vlanChoices = [...vlans]
233
+ .sort(([a], [b]) => a - b)
234
+ .map(([id, name]) => /** @type {[string, string]} */ ([String(id), `${name} (${id})`]));
235
+
236
+ const result = h("div");
237
+ const find = () => {
238
+ result.replaceChildren();
239
+
240
+ const path = vlanPath(data, Number(store.vlan), store.from, store.to);
241
+ if (store.from === store.to) return result.append(empty("Pick two different devices."));
242
+ if (!path) return result.append(empty(`No cables carry VLAN ${store.vlan} from ${store.from} to ${store.to}.`));
243
+
244
+ const cableOf = (/** @type {import("../../core/graph").GraphEdge} */ edge) =>
245
+ data.graph.edges.find(
246
+ (other) =>
247
+ other.layer === "l1" &&
248
+ ((other.from === edge.from && other.to === edge.to) || (other.from === edge.to && other.to === edge.from))
249
+ )?.label ?? "";
250
+ const nameOf = (/** @type {string} */ id) => id.replace(/^device:/, "");
251
+
252
+ result.append(
253
+ h(
254
+ "p",
255
+ {},
256
+ `${path.length} ${path.length === 1 ? "cable" : "cables"} carry it from ${store.from} to ${store.to}. `,
257
+ link("Show in the graph", () => {
258
+ lightPath(path);
259
+ go("graph/l2");
260
+ })
261
+ ),
262
+ table({
263
+ head: ["#", "From", "To", "Cable"].map((label) => ({ label })),
264
+ rows: path.map((edge, index) => ({
265
+ cells: [String(index + 1), deviceLink(nameOf(edge.from)), deviceLink(nameOf(edge.to)), cableOf(edge)],
266
+ })),
267
+ })
268
+ );
269
+ };
270
+
271
+ if (!vlanChoices.length) return container.append(empty("No cable carries a VLAN."));
272
+
273
+ container.append(
274
+ prose("The fewest cables that carry a VLAN from one device to another, from the ports' VLANs and links."),
275
+ h(
276
+ "form",
277
+ { class: "tool-form" },
278
+ field("VLAN", choice(store, "vlan", vlanChoices)),
279
+ field("From", choice(store, "from", devices)),
280
+ field("To", choice(store, "to", devices)),
281
+ button("Find the path", find)
282
+ ),
283
+ result
284
+ );
285
+ }
286
+
287
+ // Test a firewall.
288
+
289
+ /** @param {HTMLElement} container */
290
+ function firewallTool(container) {
291
+ const store = typed.firewall;
292
+ const devices = data.devices.filter((device) => Object.values(device.firewall?.filter ?? {}).some(Boolean));
293
+ if (!devices.length) return container.append(empty("No device has a firewall filter."));
294
+
295
+ const result = h("div");
296
+ const form = h("form", { class: "tool-form" });
297
+
298
+ const draw = () => {
299
+ form.replaceChildren();
300
+ const device = devices.find((candidate) => candidate.name === store.device) ?? /** @type {Device} */ (devices[0]);
301
+ const chains = /** @type {[string, string][]} */ (Object.keys(device.firewall?.filter ?? {}).map((chain) => [chain, chain]));
302
+ const names = /** @type {[string, string][]} */ ([
303
+ ["", "not given"],
304
+ ...Object.keys({ ...device.ports, ...device.interfaces }).map((name) => [name, name]),
305
+ ]);
306
+ const protocols = /** @type {[string, string][]} */ (
307
+ ["tcp", "udp", "icmp", "gre", "esp", "ah", "ospf", "vrrp"].map((name) => [name, name])
308
+ );
309
+ const states = /** @type {[string, string][]} */ (
310
+ ["new", "established", "related", "untracked", "invalid"].map((name) => [name, name])
311
+ );
312
+
313
+ const flags = h("span", { class: "flags" });
314
+ for (const flag of ["syn", "ack", "fin", "rst", "psh", "urg"]) {
315
+ const box = h("input", { type: "checkbox", ...(store.flags.includes(flag) ? { checked: "" } : {}) });
316
+ box.addEventListener(
317
+ "change",
318
+ () => (store.flags = box.checked ? [...store.flags, flag] : store.flags.filter((other) => other !== flag))
319
+ );
320
+ flags.append(h("label", { class: "flag" }, box, flag));
321
+ }
322
+
323
+ form.append(
324
+ field(
325
+ "Device",
326
+ choice(
327
+ store,
328
+ "device",
329
+ devices.map((candidate) => [candidate.name, candidate.name]),
330
+ draw
331
+ )
332
+ ),
333
+ field("Chain", choice(store, "chain", chains)),
334
+ field("Protocol", choice(store, "protocol", protocols)),
335
+ field("Source", textBox(store, "src", "203.0.113.9")),
336
+ field("Destination", textBox(store, "dst", "192.0.2.1")),
337
+ field("Source port", textBox(store, "srcPort")),
338
+ field("Destination port", textBox(store, "dstPort", "22")),
339
+ field("Arrives on", choice(store, "inInterface", names)),
340
+ field("Leaves through", choice(store, "outInterface", names)),
341
+ field("State", choice(store, "state", states)),
342
+ field("TCP flags", flags, "Leave all unticked when not known."),
343
+ button("Test", test)
344
+ );
345
+ };
346
+
347
+ const test = () => {
348
+ result.replaceChildren();
349
+ const device = devices.find((candidate) => candidate.name === store.device) ?? /** @type {Device} */ (devices[0]);
350
+ const port = (/** @type {string} */ text) => (text.trim() === "" ? undefined : Number(text));
351
+
352
+ const outcome = filterPacket(device, /** @type {"input" | "forward" | "output"} */ (store.chain), {
353
+ protocol: store.protocol,
354
+ src: store.src.trim() || undefined,
355
+ dst: store.dst.trim() || undefined,
356
+ srcPort: port(store.srcPort),
357
+ dstPort: port(store.dstPort),
358
+ inInterface: store.inInterface || undefined,
359
+ outInterface: store.outInterface || undefined,
360
+ state: store.state,
361
+ flags: store.flags.length ? store.flags : undefined,
362
+ });
363
+
364
+ const decided = outcome.verdict
365
+ ? h("p", { class: `verdict ${outcome.verdict}` }, `${outcome.verdict}, by ${outcome.by}.`)
366
+ : h("p", { class: "verdict unknown" }, `Cannot tell: ${outcome.by} needs more about the packet.`);
367
+ const reached = outcome.steps.length;
368
+ const rules = device.firewall?.filter?.[/** @type {"input" | "forward" | "output"} */ (store.chain)]?.rules ?? [];
369
+
370
+ result.append(
371
+ decided,
372
+ table({
373
+ head: ["Rule", "Description", "Action", "This packet"].map((label) => ({ label })),
374
+ rows: rules.map((rule, index) => {
375
+ const step = outcome.steps[index];
376
+ return {
377
+ muted: index >= reached,
378
+ cells: [String(index + 1), rule.description ?? "", rule.action, step ? verdictOf(step.check) : "not reached"],
379
+ };
380
+ }),
381
+ })
382
+ );
383
+ };
384
+
385
+ draw();
386
+ container.append(
387
+ prose("Which rule of a device's filter chain a packet meets, first match first, as the schema describes each condition."),
388
+ form,
389
+ result
390
+ );
391
+ }
392
+
393
+ // Test a route policy.
394
+
395
+ /** @param {HTMLElement} container */
396
+ function policyTool(container) {
397
+ const store = typed.policy;
398
+ const devices = data.devices.filter((device) => Object.keys(device.policies ?? {}).length);
399
+ if (!devices.length) return container.append(empty("No device has a route policy."));
400
+
401
+ const result = h("div");
402
+ const form = h("form", { class: "tool-form" });
403
+
404
+ const draw = () => {
405
+ form.replaceChildren();
406
+ const device = devices.find((candidate) => candidate.name === store.device) ?? /** @type {Device} */ (devices[0]);
407
+ const neighbors = Object.entries(device.routing?.bgp?.neighbors ?? {});
408
+ const groups = device.routing?.bgp?.groups ?? {};
409
+
410
+ // A policy is listed with the sessions that use it, so it can be picked by neighbor.
411
+ const usedBy = (/** @type {string} */ policy) =>
412
+ neighbors.flatMap(([name, neighbor]) => {
413
+ const group = neighbor.group ? groups[neighbor.group] : undefined;
414
+ return [
415
+ (neighbor.import ?? group?.import) === policy ? `import from ${name}` : "",
416
+ (neighbor.export ?? group?.export) === policy ? `export to ${name}` : "",
417
+ ].filter(Boolean);
418
+ });
419
+
420
+ const policies = /** @type {[string, string][]} */ (
421
+ Object.keys(device.policies ?? {}).map((name) => {
422
+ const uses = usedBy(name);
423
+ return [name, uses.length ? `${name}: ${uses.join(", ")}` : name];
424
+ })
425
+ );
426
+
427
+ const registrySets = Object.entries(device.prefix_sets ?? {}).filter(([, set]) => "source" in set);
428
+ const sets = h("span", { class: "flags" });
429
+ for (const [name] of registrySets) {
430
+ const box = h("input", { type: "checkbox", ...(store.inRegistrySets.includes(name) ? { checked: "" } : {}) });
431
+ box.addEventListener(
432
+ "change",
433
+ () =>
434
+ (store.inRegistrySets = box.checked
435
+ ? [...store.inRegistrySets, name]
436
+ : store.inRegistrySets.filter((other) => other !== name))
437
+ );
438
+ sets.append(h("label", { class: "flag" }, box, name));
439
+ }
440
+
441
+ form.append(
442
+ field(
443
+ "Device",
444
+ choice(
445
+ store,
446
+ "device",
447
+ devices.map((candidate) => [candidate.name, candidate.name]),
448
+ draw
449
+ )
450
+ ),
451
+ field("Policy", choice(store, "policy", policies)),
452
+ field("Prefix", textBox(store, "prefix", "198.51.100.0/24")),
453
+ field("AS path length", textBox(store, "asPathLength"), "As the route arrives."),
454
+ field(
455
+ "RPKI",
456
+ choice(store, "rpki", [
457
+ ["", "not known"],
458
+ ["valid", "valid"],
459
+ ["invalid", "invalid"],
460
+ ["not-found", "not found"],
461
+ ])
462
+ ),
463
+ field("Communities", textBox(store, "communities", "64500:100"), "Separated by spaces or commas."),
464
+ field("Large communities", textBox(store, "largeCommunities", "64500:1:1")),
465
+ ...(registrySets.length
466
+ ? [field("In prefix sets from a registry", sets, "Their prefixes are fetched by circuit refresh and are not in the files.")]
467
+ : []),
468
+ button("Test", test)
469
+ );
470
+ };
471
+
472
+ const test = () => {
473
+ result.replaceChildren();
474
+ const device = devices.find((candidate) => candidate.name === store.device) ?? /** @type {Device} */ (devices[0]);
475
+ const list = (/** @type {string} */ text) => text.split(/[\s,]+/).filter(Boolean);
476
+
477
+ if (!store.prefix.trim()) return result.append(empty("Give the route's prefix, such as 198.51.100.0/24."));
478
+
479
+ const outcome = routeThrough(device, store.policy, {
480
+ prefix: store.prefix.trim(),
481
+ asPathLength: store.asPathLength.trim() === "" ? undefined : Number(store.asPathLength),
482
+ rpki: store.rpki || undefined,
483
+ communities: list(store.communities),
484
+ largeCommunities: list(store.largeCommunities),
485
+ inRegistrySets: store.inRegistrySets,
486
+ });
487
+
488
+ const decided =
489
+ outcome.decision === "unknown"
490
+ ? h(
491
+ "p",
492
+ { class: "verdict unknown" },
493
+ `Cannot tell: rule ${outcome.steps.at(-1)?.rule} of ${outcome.steps.at(-1)?.policy} needs more about the route.`
494
+ )
495
+ : h(
496
+ "p",
497
+ { class: `verdict ${outcome.decision}` },
498
+ `${outcome.decision}ed${outcome.byDefault ? ", since no rule accepted it" : ""}.`
499
+ );
500
+
501
+ const { attributes } = outcome;
502
+ const final = [
503
+ attributes.localPref !== undefined ? `local preference ${attributes.localPref}` : "",
504
+ attributes.med !== undefined ? `MED ${attributes.med}` : "",
505
+ attributes.prepend ? `our AS prepended ${attributes.prepend} ${attributes.prepend === 1 ? "time" : "times"}` : "",
506
+ `communities ${attributes.communities.join(", ") || "none"}`,
507
+ `large communities ${attributes.largeCommunities.join(", ") || "none"}`,
508
+ attributes.nextHop ? `next hop ${attributes.nextHop}` : "",
509
+ attributes.preferredSource ? `preferred source ${attributes.preferredSource}` : "",
510
+ attributes.blackhole ? "blackholed" : "",
511
+ ].filter(Boolean);
512
+
513
+ result.append(
514
+ decided,
515
+ table({
516
+ head: ["Policy", "Rule", "Description", "This route", "Changes", "Decision"].map((label) => ({ label })),
517
+ rows: outcome.steps.map((step) => ({
518
+ cells: [
519
+ step.policy,
520
+ String(step.rule),
521
+ step.description ?? (step.call ? `calls ${step.call}` : ""),
522
+ verdictOf(step.check),
523
+ step.changed.join("; "),
524
+ step.decision ?? "",
525
+ ],
526
+ })),
527
+ }),
528
+ ...(outcome.decision === "accept" ? [h("p", {}, "The route leaves with ", final.join(", "), ".")] : [])
529
+ );
530
+ };
531
+
532
+ draw();
533
+ container.append(
534
+ prose(
535
+ "What a device's route policy does to a route: rule by rule, what matches, what changes, and the decision. A prefix set holds a route whose prefix is one of its own or inside one."
536
+ ),
537
+ form,
538
+ result
539
+ );
540
+ }
541
+
542
+ /**
543
+ * Draw a tool, such as `["tools", "trace", "10.0.10.5"]`, into a container.
544
+ *
545
+ * @param {string[]} route
546
+ * @param {HTMLElement} container
547
+ * @param {StudioData} next
548
+ */
549
+ export function showTools(route, container, next) {
550
+ data = next;
551
+ container.replaceChildren();
552
+
553
+ const [, asked = "trace", argument] = route;
554
+ const tool = TOOLS.find(([id]) => id === asked) ?? TOOLS[0];
555
+ if (!tool) return;
556
+
557
+ const tabs = h("nav", { class: "tabs", "aria-label": "Tools" });
558
+ for (const [id, title] of TOOLS) {
559
+ const tab = h("button", { "aria-pressed": String(id === tool[0]) }, title ?? id ?? "");
560
+ tab.addEventListener("click", () => go(`tools/${id}`));
561
+ tabs.append(tab);
562
+ }
563
+
564
+ const body = h("div", { class: "section" });
565
+ container.append(
566
+ heading("Tools", "h2"),
567
+ prose(
568
+ "They read the files as the schema describes each field. A device can still behave otherwise; these say what the config means."
569
+ ),
570
+ tabs,
571
+ body
572
+ );
573
+
574
+ if (tool[0] === "trace") traceTool(argument, body);
575
+ else if (tool[0] === "vlan") vlanTool(body);
576
+ else if (tool[0] === "firewall") firewallTool(body);
577
+ else policyTool(body);
578
+ }