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