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