@takodotid/circuit 0.43.1 → 1.0.0-rc.2

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 (256) hide show
  1. package/README.md +115 -37
  2. package/package.json +21 -58
  3. package/src/checks/across.ts +402 -0
  4. package/src/checks/built-in.ts +256 -0
  5. package/src/checks/device.ts +436 -0
  6. package/src/checks/index.ts +53 -0
  7. package/src/cli/advisories.ts +108 -0
  8. package/src/cli/app.ts +71 -0
  9. package/src/cli/editor-types.generated.ts +30 -0
  10. package/src/cli/editor-types.ts +69 -0
  11. package/src/cli/format.ts +124 -0
  12. package/src/cli/git-hooks.ts +79 -0
  13. package/src/cli/index.ts +350 -1170
  14. package/src/cli/migrate.ts +554 -0
  15. package/src/cli/project-commands.ts +656 -0
  16. package/src/cli/releases.ts +374 -0
  17. package/src/cli/remember.ts +339 -0
  18. package/src/cli/rename.ts +27 -33
  19. package/src/cli/secrets-password.ts +60 -0
  20. package/src/cli/secrets.ts +258 -0
  21. package/src/cli/setup.ts +300 -0
  22. package/src/cli/signing.ts +23 -0
  23. package/src/cli/text-editor.ts +268 -0
  24. package/src/cli/ui.ts +298 -0
  25. package/src/cli/version.ts +2 -0
  26. package/src/cli/versions.ts +158 -0
  27. package/src/config/address.ts +102 -0
  28. package/src/config/circuit.d.ts +30 -0
  29. package/src/config/common.ts +65 -0
  30. package/src/config/communities.ts +479 -0
  31. package/src/config/define.ts +684 -0
  32. package/src/config/device.ts +164 -0
  33. package/src/config/firewall.ts +150 -0
  34. package/src/config/index.ts +22 -0
  35. package/src/{schema → config}/interfaces.ts +121 -85
  36. package/src/config/keys.ts +434 -0
  37. package/src/config/management.ts +61 -0
  38. package/src/config/merge.ts +335 -0
  39. package/src/config/models.ts +70 -0
  40. package/src/config/monitoring.ts +64 -0
  41. package/src/config/network.ts +180 -0
  42. package/src/config/presets.ts +149 -0
  43. package/src/config/routing.ts +401 -0
  44. package/src/config/services.ts +51 -0
  45. package/src/config/switching.ts +84 -0
  46. package/src/config/system.ts +103 -0
  47. package/src/{schema → config}/time-zones.ts +1 -1
  48. package/src/core/address.ts +1 -1
  49. package/src/core/cabling.ts +8 -43
  50. package/src/core/graph.ts +40 -0
  51. package/src/{integrations → core}/registries.ts +5 -3
  52. package/src/core/secrets-file.ts +539 -0
  53. package/src/core/secrets.ts +83 -64
  54. package/src/core/topology.ts +103 -146
  55. package/src/core/utils.ts +12 -1
  56. package/src/model.ts +83 -0
  57. package/src/platforms/derive.ts +64 -37
  58. package/src/platforms/finding.ts +9 -0
  59. package/src/platforms/fortios/firewall.ts +76 -64
  60. package/src/platforms/fortios/index.ts +189 -125
  61. package/src/platforms/fortios/names.ts +51 -18
  62. package/src/platforms/fortios/render.ts +69 -62
  63. package/src/platforms/fortios/routing.ts +89 -58
  64. package/src/platforms/fortios/vdoms.ts +47 -16
  65. package/src/platforms/junos/TRAPS.md +2 -2
  66. package/src/platforms/junos/bgp.ts +63 -42
  67. package/src/platforms/junos/index.ts +122 -89
  68. package/src/platforms/junos/render.ts +139 -104
  69. package/src/platforms/platform.ts +10 -8
  70. package/src/platforms/raisecom-ros/TRAPS.md +1 -1
  71. package/src/platforms/raisecom-ros/index.ts +89 -70
  72. package/src/platforms/raisecom-ros/render.ts +49 -45
  73. package/src/platforms/routeros/TRAPS.md +3 -3
  74. package/src/platforms/routeros/index.ts +121 -60
  75. package/src/platforms/routeros/render.ts +214 -195
  76. package/src/platforms/vrp/TRAPS.md +1 -1
  77. package/src/platforms/vrp/index.ts +74 -50
  78. package/src/platforms/vrp/render.ts +67 -56
  79. package/src/project/project.ts +725 -0
  80. package/src/studio/app/env.d.ts +9 -0
  81. package/src/studio/app/index.html +18 -0
  82. package/src/studio/app/src/App.vue +95 -0
  83. package/src/studio/app/src/components/AddressesView.vue +38 -0
  84. package/src/studio/app/src/components/CablesView.vue +29 -0
  85. package/src/studio/app/src/components/CompareView.vue +160 -0
  86. package/src/studio/app/src/components/DataTable.vue +49 -0
  87. package/src/studio/app/src/components/DeviceView.vue +237 -0
  88. package/src/studio/app/src/components/DevicesView.vue +32 -0
  89. package/src/studio/app/src/components/Faceplate.vue +75 -0
  90. package/src/studio/app/src/components/GraphView.vue +282 -0
  91. package/src/studio/app/src/components/LineDiff.vue +24 -0
  92. package/src/studio/app/src/components/ProblemsView.vue +42 -0
  93. package/src/studio/app/src/components/SearchView.vue +72 -0
  94. package/src/studio/app/src/components/SecretsView.vue +112 -0
  95. package/src/studio/app/src/components/SessionsView.vue +35 -0
  96. package/src/studio/app/src/components/Settings.vue +95 -0
  97. package/src/studio/app/src/components/ToolsView.vue +118 -0
  98. package/src/studio/app/src/components/VlansView.vue +50 -0
  99. package/src/studio/app/src/lib/csv.ts +19 -0
  100. package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
  101. package/src/studio/app/src/lib/format.ts +38 -0
  102. package/src/studio/app/src/lib/net.ts +68 -0
  103. package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
  104. package/src/studio/app/src/lib/store.ts +71 -0
  105. package/src/studio/app/src/lib/tools.ts +217 -0
  106. package/src/studio/app/src/main.ts +9 -0
  107. package/src/studio/{public → app/src}/style.css +14 -4
  108. package/src/studio/app/tsconfig.json +17 -0
  109. package/src/studio/app/vite.config.ts +16 -0
  110. package/src/studio/assets.generated.ts +3 -0
  111. package/src/studio/data.ts +96 -188
  112. package/src/studio/git.ts +27 -43
  113. package/src/studio/server.ts +196 -107
  114. package/src/studio/types.ts +141 -0
  115. package/src/transport/console.ts +3 -3
  116. package/src/transport/ssh.ts +1 -1
  117. package/dist/chunks/index-3b343xva.js +0 -177
  118. package/dist/chunks/index-6wb41q67.js +0 -62
  119. package/dist/chunks/index-n7w3tg5y.js +0 -149
  120. package/dist/chunks/index-yzw86rc8.js +0 -921
  121. package/dist/chunks/new-w3zys5y4.js +0 -172
  122. package/dist/chunks/server-cxr9ynxc.js +0 -259
  123. package/dist/cli/index.js +0 -12922
  124. package/dist/helpers/index.js +0 -881
  125. package/dist/index.js +0 -28
  126. package/dist/types/core/address-space.d.ts +0 -62
  127. package/dist/types/core/address.d.ts +0 -44
  128. package/dist/types/core/cabling.d.ts +0 -41
  129. package/dist/types/core/cross-checks.d.ts +0 -28
  130. package/dist/types/core/define.d.ts +0 -79
  131. package/dist/types/core/project-files.d.ts +0 -12
  132. package/dist/types/core/schema-docs.d.ts +0 -25
  133. package/dist/types/core/secrets.d.ts +0 -62
  134. package/dist/types/core/topology.d.ts +0 -96
  135. package/dist/types/core/utils.d.ts +0 -10
  136. package/dist/types/core/validator.d.ts +0 -23
  137. package/dist/types/helpers/bgp.d.ts +0 -195
  138. package/dist/types/helpers/cables.d.ts +0 -69
  139. package/dist/types/helpers/checks.d.ts +0 -36
  140. package/dist/types/helpers/exchange.d.ts +0 -55
  141. package/dist/types/helpers/filters.d.ts +0 -51
  142. package/dist/types/helpers/index.d.ts +0 -14
  143. package/dist/types/helpers/route-server.d.ts +0 -52
  144. package/dist/types/helpers/upstreams.d.ts +0 -192
  145. package/dist/types/index.d.ts +0 -10
  146. package/dist/types/integrations/librenms.d.ts +0 -60
  147. package/dist/types/integrations/netbox.d.ts +0 -89
  148. package/dist/types/integrations/peeringdb.d.ts +0 -24
  149. package/dist/types/integrations/prometheus.d.ts +0 -24
  150. package/dist/types/integrations/registries.d.ts +0 -8
  151. package/dist/types/platforms/bgp-wait.d.ts +0 -24
  152. package/dist/types/platforms/catalog.d.ts +0 -109
  153. package/dist/types/platforms/derive.d.ts +0 -17
  154. package/dist/types/platforms/fortios/firewall.d.ts +0 -47
  155. package/dist/types/platforms/fortios/index.d.ts +0 -140
  156. package/dist/types/platforms/fortios/models.d.ts +0 -14
  157. package/dist/types/platforms/fortios/names.d.ts +0 -52
  158. package/dist/types/platforms/fortios/plan.d.ts +0 -114
  159. package/dist/types/platforms/fortios/render.d.ts +0 -77
  160. package/dist/types/platforms/fortios/routing.d.ts +0 -56
  161. package/dist/types/platforms/fortios/tree.d.ts +0 -37
  162. package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
  163. package/dist/types/platforms/index.d.ts +0 -13
  164. package/dist/types/platforms/junos/bgp.d.ts +0 -44
  165. package/dist/types/platforms/junos/index.d.ts +0 -114
  166. package/dist/types/platforms/junos/models.d.ts +0 -14
  167. package/dist/types/platforms/junos/render.d.ts +0 -154
  168. package/dist/types/platforms/line-diff.d.ts +0 -67
  169. package/dist/types/platforms/platform.d.ts +0 -135
  170. package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
  171. package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
  172. package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
  173. package/dist/types/platforms/routeros/export.d.ts +0 -59
  174. package/dist/types/platforms/routeros/index.d.ts +0 -116
  175. package/dist/types/platforms/routeros/models.d.ts +0 -18
  176. package/dist/types/platforms/routeros/plan.d.ts +0 -58
  177. package/dist/types/platforms/routeros/render.d.ts +0 -250
  178. package/dist/types/platforms/vrp/index.d.ts +0 -83
  179. package/dist/types/platforms/vrp/models.d.ts +0 -12
  180. package/dist/types/platforms/vrp/render.d.ts +0 -96
  181. package/dist/types/schema/common.d.ts +0 -30
  182. package/dist/types/schema/device.d.ts +0 -126
  183. package/dist/types/schema/firewall.d.ts +0 -131
  184. package/dist/types/schema/index.d.ts +0 -8
  185. package/dist/types/schema/interfaces.d.ts +0 -205
  186. package/dist/types/schema/policy.d.ts +0 -107
  187. package/dist/types/schema/routing.d.ts +0 -161
  188. package/dist/types/schema/system.d.ts +0 -198
  189. package/dist/types/schema/time-zones.d.ts +0 -2
  190. package/dist/types/transport/console.d.ts +0 -115
  191. package/dist/types/transport/path-check.d.ts +0 -32
  192. package/dist/types/transport/ssh.d.ts +0 -27
  193. package/src/cli/graph-format.ts +0 -91
  194. package/src/cli/new.ts +0 -234
  195. package/src/core/address-space.ts +0 -320
  196. package/src/core/cross-checks.ts +0 -369
  197. package/src/core/define.ts +0 -124
  198. package/src/core/project-files.ts +0 -35
  199. package/src/core/schema-docs.ts +0 -174
  200. package/src/core/validator.ts +0 -508
  201. package/src/helpers/bgp.ts +0 -473
  202. package/src/helpers/cables.ts +0 -74
  203. package/src/helpers/checks.ts +0 -201
  204. package/src/helpers/exchange.ts +0 -123
  205. package/src/helpers/filters.ts +0 -107
  206. package/src/helpers/index.ts +0 -25
  207. package/src/helpers/route-server.ts +0 -212
  208. package/src/helpers/upstreams.ts +0 -369
  209. package/src/index.ts +0 -13
  210. package/src/integrations/librenms.ts +0 -291
  211. package/src/integrations/netbox.ts +0 -620
  212. package/src/integrations/peeringdb.ts +0 -173
  213. package/src/integrations/prometheus.ts +0 -90
  214. package/src/schema/common.ts +0 -41
  215. package/src/schema/device.ts +0 -145
  216. package/src/schema/firewall.ts +0 -140
  217. package/src/schema/index.ts +0 -8
  218. package/src/schema/policy.ts +0 -115
  219. package/src/schema/routing.ts +0 -173
  220. package/src/schema/system.ts +0 -203
  221. package/src/studio/public/app.js +0 -186
  222. package/src/studio/public/compare.js +0 -250
  223. package/src/studio/public/dom.js +0 -138
  224. package/src/studio/public/evaluate.js +0 -687
  225. package/src/studio/public/export.js +0 -130
  226. package/src/studio/public/graph.js +0 -1541
  227. package/src/studio/public/index.html +0 -83
  228. package/src/studio/public/net.js +0 -134
  229. package/src/studio/public/tables.js +0 -1289
  230. package/src/studio/public/tools.js +0 -610
  231. package/templates/colocation/checks.ts +0 -53
  232. package/templates/colocation/circuit.config.ts +0 -9
  233. package/templates/colocation/edge-01.ts +0 -133
  234. package/templates/colocation/routing.ts +0 -13
  235. package/templates/colocation/tenancy.ts +0 -76
  236. package/templates/colocation/tenants/acme.ts +0 -19
  237. package/templates/colocation/tenants/index.ts +0 -6
  238. package/templates/colocation/tor-01.ts +0 -33
  239. package/templates/edge-router/checks.ts +0 -20
  240. package/templates/edge-router/circuit.config.ts +0 -8
  241. package/templates/edge-router/edge-01.ts +0 -110
  242. package/templates/edge-router/routing.ts +0 -13
  243. package/templates/project/AGENTS.md +0 -51
  244. package/templates/project/CLAUDE.md +0 -1
  245. package/templates/project/README.md +0 -16
  246. package/templates/project/gitignore +0 -5
  247. package/templates/project/tsconfig.json +0 -13
  248. package/templates/single-site/checks.ts +0 -26
  249. package/templates/single-site/circuit.config.ts +0 -8
  250. package/templates/single-site/router.ts +0 -86
  251. package/templates/single-site/site.ts +0 -18
  252. package/templates/single-site/switch.ts +0 -34
  253. /package/src/studio/{public → app/public}/favicon.svg +0 -0
  254. /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
  255. /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
  256. /package/src/studio/{public → app/public}/special-gothic-license.txt +0 -0
@@ -1,1289 +0,0 @@
1
- // @ts-check
2
- /// <reference lib="dom" />
3
- // Studio's tables, for reading the config without reading TypeScript: every device and everything it holds, and the network's VLANs, addresses, BGP sessions and cables.
4
-
5
- import { 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
-
11
- /** @typedef {import("../../schema").Device} Device */
12
- /** @typedef {import("../data").StudioData} StudioData */
13
- /** @typedef {import("./dom.js").Child} Child */
14
- /** A column named after a field shows the name as code. A table with `csv` can be saved under that name. @typedef {{ head: { label: string, title?: string, field?: boolean }[], rows: { cells: Child[], muted?: boolean, id?: string }[], csv?: string }} Table */
15
- /** @typedef {{ id: string, title: string, has: (device: Device) => boolean, render: (device: Device) => Child[] }} Section */
16
- /** How a port is used. @typedef {"shut" | "lag" | "access" | "trunk" | "routed" | "plain"} PortUse */
17
-
18
- export class Tables {
19
- /** What the tables show, set each time they are drawn. @type {StudioData} */
20
- static #data;
21
-
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
- }
29
-
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
- }
43
-
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];
54
-
55
- for (let name = queue.shift(); name !== undefined; name = queue.shift()) {
56
- if (seen.has(name)) continue;
57
- seen.add(name);
58
-
59
- const type = Tables.#data.docs[name];
60
- if (!type) continue;
61
-
62
- const found = type.fields[field] ?? Object.entries(type.fields).find(([path]) => path.endsWith(`.${field}`))?.[1];
63
- if (found?.description) return found.description;
64
-
65
- queue.push(...type.includes);
66
- }
67
-
68
- return undefined;
69
- }
70
-
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
- }
89
-
90
- return String(value);
91
- }
92
-
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",
106
- {},
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
- )
117
- )
118
- ),
119
- Dom.h("tbody", {}, ...rows.map((row) => Dom.h("tr", attributesOf(row), ...row.cells.map((cell) => Dom.h("td", {}, cell)))))
120
- );
121
-
122
- if (!csv) return element;
123
-
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
- }
127
-
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
- }
139
-
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
- }
167
-
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.");
178
-
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
- }
187
-
188
- /**
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}
194
- */
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
- }
209
-
210
- if (Array.isArray(value) && value.some(Tables.#isObject)) return Tables.#listTable(value, types);
211
- return Tables.#compact(value);
212
- };
213
-
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
- }
227
-
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
- }
235
-
236
- /** @param {string} text */
237
- static empty(text) {
238
- return Dom.h("p", { class: "muted" }, text);
239
- }
240
-
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);
248
- }
249
-
250
- /** @param {string} name */
251
- static deviceLink(name) {
252
- return Dom.link(name, () => Dom.go(`devices/${encodeURIComponent(name)}`));
253
- }
254
-
255
- /** @param {string} name */
256
- static #deviceRoute(name) {
257
- return `devices/${encodeURIComponent(name)}`;
258
- }
259
-
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
- }
274
-
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));
351
- }
352
-
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);
361
- }
362
-
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.");
373
-
374
- const changed = lines.filter((line) => line.kind !== "same").length;
375
- if (!changed) return Tables.empty("They are the same, line for line.");
376
-
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
- );
381
-
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`));
399
- }
400
-
401
- fold();
402
- return Dom.h("div", {}, Tables.empty(`${changed} ${changed === 1 ? "line differs" : "lines differ"}.`), code);
403
- }
404
-
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"];
407
-
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
- }
421
-
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
- }
427
-
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
- )
452
- )
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"]
551
- ),
552
- ],
553
- },
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
- }
584
-
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
- }
593
-
594
- if (routing.rpki?.length) parts.push(Tables.heading("RPKI"), Tables.#listTable(routing.rpki, types));
595
- return parts;
596
- },
597
- },
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 ?? {})) {
608
- parts.push(
609
- Tables.heading(`Policy ${name}`),
610
- Tables.#listTable(rules, types, ["description", "match", "set", "action"])
611
- );
612
- }
613
-
614
- if (device.prefix_sets)
615
- parts.push(Tables.heading("Prefix sets"), Tables.#recordTable(device.prefix_sets, types, "Prefix set"));
616
- return parts;
617
- },
618
- },
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
- }
640
-
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
- }
650
-
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
- }
655
-
656
- const { helpers, untracked } = firewall ?? {};
657
- if (helpers || untracked) parts.push(Tables.heading("Other"), Tables.#settings({ helpers, untracked }, types));
658
-
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
- }
662
-
663
- return parts;
664
- },
665
- },
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
- },
691
- },
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
- },
710
- },
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."
720
- ),
721
- Dom.h("pre", { class: "code" }, Tables.#data.rendered[device.name] ?? ""),
722
- ],
723
- },
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."
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
- });
781
-
782
- return [area];
783
- },
784
- },
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
- },
805
- },
806
- ];
807
-
808
- /** @param {HTMLElement} container */
809
- static #devicesView(container) {
810
- container.append(
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
- })
831
- );
832
- }
833
-
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
- }
849
-
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;
853
-
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
- }
860
-
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
- }
882
-
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
- }
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);
911
- }
912
- }
913
-
914
- const rows = [...byId].sort(([a], [b]) => a - b);
915
-
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
- );
939
- }
940
-
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
- );
948
-
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
- }
964
-
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
- };
975
-
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"));
980
- }
981
-
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
- }
986
-
987
- if (!spaces.length && !elsewhere.segments.length && !elsewhere.blocks.length)
988
- container.append(Tables.empty("The config holds no addresses."));
989
- }
990
-
991
- /** @param {HTMLElement} container */
992
- static #sessionsView(container) {
993
- const ours = new Set(Tables.#data.devices.map((device) => device.name));
994
-
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
- }
1021
-
1022
- /** @param {HTMLElement} container */
1023
- static #cablesView(container) {
1024
- // A carrier column only when a cable runs through one.
1025
- const carried = Tables.#data.cables.some((cable) => cable.through);
1026
-
1027
- container.append(
1028
- Tables.heading("Cables", "h2"),
1029
- Tables.empty("Every cable a port's link names, once, from the end that names it first."),
1030
- Tables.#data.cables.length
1031
- ? Tables.of({
1032
- csv: "cables",
1033
- head: [
1034
- "Device",
1035
- "Port",
1036
- "Far device",
1037
- "Far port",
1038
- ...(carried ? ["Through"] : []),
1039
- "LAG",
1040
- "VLANs",
1041
- "Description",
1042
- "",
1043
- ].map((label) => ({ label })),
1044
- rows: Tables.#data.cables.map((cable) => ({
1045
- cells: [
1046
- Tables.deviceLink(cable.device),
1047
- cable.port,
1048
- Tables.deviceLink(cable.farDevice),
1049
- cable.farPort,
1050
- ...(carried ? [cable.through ?? ""] : []),
1051
- [cable.lag, cable.farLag].filter(Boolean).join(" to "),
1052
- cable.vlans.join(", "),
1053
- cable.description ?? "",
1054
- cable.oneSided ? Dom.h("span", { class: "badge warning" }, "not linked back") : "",
1055
- ],
1056
- })),
1057
- })
1058
- : Tables.empty("No port names a link.")
1059
- );
1060
- }
1061
-
1062
- /**
1063
- * Everything that needs a look, in one place: what validate found, what only one end declares, and devices unlike their snapshot.
1064
- *
1065
- * @param {HTMLElement} container
1066
- */
1067
- static #problemsView(container) {
1068
- const { graph } = Tables.#data;
1069
- const nameOf = (/** @type {string} */ id) => graph.nodes.find((node) => node.id === id)?.label ?? id;
1070
-
1071
- const errors = graph.findings.filter((finding) => finding.level === "error");
1072
- const warnings = graph.findings.filter((finding) => finding.level === "warning");
1073
- const oneSided = graph.edges.filter((edge) => edge.oneSided);
1074
- const drifted = Tables.#data.devices.filter((device) => graph.drift[device.name]?.status === "differs");
1075
- const unplanned = Tables.#data.devices.filter((device) => graph.drift[device.name]?.status === "cannot plan");
1076
- const unsnapped = Tables.#data.devices.filter((device) => graph.drift[device.name]?.status === "no snapshot");
1077
-
1078
- container.append(
1079
- Tables.heading("Problems", "h2"),
1080
- Tables.empty("Everything that needs a look. Studio finds these from the files and the last snapshots alone.")
1081
- );
1082
-
1083
- if (!errors.length && !warnings.length && !oneSided.length && !drifted.length && !unplanned.length) {
1084
- container.append(
1085
- Tables.empty("Nothing: every check passes, both ends declare everything, and every device with a snapshot matches it.")
1086
- );
1087
- }
1088
-
1089
- /**
1090
- * @param {string} title
1091
- * @param {import("../../core/define").Finding[]} findings
1092
- */
1093
- const findingTable = (title, findings) => {
1094
- if (!findings.length) return;
1095
-
1096
- container.append(
1097
- Dom.h("h3", {}, title, " found by ", Dom.h("code", {}, "circuit validate")),
1098
- Tables.of({
1099
- csv: title.toLowerCase(),
1100
- head: ["Device", "Message"].map((label) => ({ label })),
1101
- rows: findings.map((finding) => ({
1102
- cells: [finding.device ? Tables.deviceLink(finding.device) : "the network", finding.message],
1103
- })),
1104
- })
1105
- );
1106
- };
1107
-
1108
- findingTable("Errors", errors);
1109
- findingTable("Warnings", warnings);
1110
-
1111
- if (oneSided.length) {
1112
- container.append(
1113
- Tables.heading("Declared by one end only"),
1114
- Tables.of({
1115
- csv: "one-sided",
1116
- head: ["Layer", "What", "From", "To", ""].map((label) => ({ label })),
1117
- rows: oneSided.map((edge) => ({
1118
- cells: [
1119
- Graph.LAYER_NAMES[edge.layer],
1120
- edge.label,
1121
- nameOf(edge.from),
1122
- nameOf(edge.to),
1123
- Dom.link("in the graph", () => Dom.go(`graph/${edge.layer}`)),
1124
- ],
1125
- })),
1126
- })
1127
- );
1128
- }
1129
-
1130
- /**
1131
- * @param {string} title
1132
- * @param {Device[]} devices
1133
- * @param {string} section
1134
- * @param {string} action
1135
- */
1136
- const deviceList = (title, devices, section, action) => {
1137
- if (!devices.length) return;
1138
-
1139
- container.append(
1140
- Tables.heading(title),
1141
- Dom.h(
1142
- "ul",
1143
- { class: "findings" },
1144
- ...devices.map((device) =>
1145
- Dom.h(
1146
- "li",
1147
- {},
1148
- Tables.deviceLink(device.name),
1149
- " · ",
1150
- Dom.link(action, () => Dom.go(`${Tables.#deviceRoute(device.name)}/${section}`))
1151
- )
1152
- )
1153
- )
1154
- );
1155
- };
1156
-
1157
- deviceList("Unlike their last snapshot", drifted, "plan", "see the plan");
1158
- deviceList("Could not be planned against their snapshot", unplanned, "plan", "see why");
1159
-
1160
- if (unsnapped.length) {
1161
- container.append(
1162
- Tables.heading("No snapshot yet"),
1163
- Tables.prose(
1164
- unsnapped.map((device) => device.name).join(", "),
1165
- ". Run ",
1166
- Dom.h("code", {}, "circuit snapshot"),
1167
- " to compare them with what they run."
1168
- )
1169
- );
1170
- }
1171
- }
1172
-
1173
- /** How many problems there are, for the top bar. */
1174
- static problemCount(/** @type {StudioData} */ next) {
1175
- const drifted = Object.values(next.graph.drift).filter(
1176
- (drift) => drift.status === "differs" || drift.status === "cannot plan"
1177
- ).length;
1178
- return next.graph.findings.length + next.graph.edges.filter((edge) => edge.oneSided).length + drifted;
1179
- }
1180
-
1181
- /** Which section shows each part of a device, for search results. @type {Record<string, string>} */
1182
- static #SECTION_OF = {
1183
- ports: "ports",
1184
- interfaces: "interfaces",
1185
- vlans: "vlans",
1186
- routing: "routing",
1187
- policies: "policies",
1188
- prefix_sets: "policies",
1189
- firewall: "firewall",
1190
- acls: "firewall",
1191
- users: "management",
1192
- management: "management",
1193
- certificates: "management",
1194
- dhcp: "services",
1195
- dhcp_relay: "services",
1196
- flow_export: "services",
1197
- };
1198
-
1199
- /**
1200
- * @param {string} query
1201
- * @param {HTMLElement} container
1202
- */
1203
- static #searchView(query, container) {
1204
- const needle = query.toLowerCase();
1205
- /** @type {{ device: string, section: string, path: string, text: string }[]} */
1206
- const results = [];
1207
-
1208
- /**
1209
- * @param {string} device
1210
- * @param {string[]} path
1211
- * @param {string} text
1212
- */
1213
- const check = (device, path, text) => {
1214
- if (!text.toLowerCase().includes(needle) || path.at(-1) === "certificate") return;
1215
- results.push({ device, section: Tables.#SECTION_OF[path[0] ?? ""] ?? "overview", path: path.join(" › "), text });
1216
- };
1217
-
1218
- /**
1219
- * @param {string} device
1220
- * @param {string[]} path
1221
- * @param {unknown} value
1222
- */
1223
- const walk = (device, path, value) => {
1224
- if (results.length >= 200) return;
1225
-
1226
- if (Tables.#isSecret(value)) return check(device, path, Tables.#secretText(/** @type {Record<string, unknown>} */ (value)));
1227
- if (Array.isArray(value)) return value.forEach((item, index) => walk(device, [...path, String(index + 1)], item));
1228
- if (Tables.#isObject(value)) {
1229
- for (const [key, inner] of Object.entries(value)) {
1230
- // A name a device declares, such as a port or a VLAN, is a match too.
1231
- if (path.length && key.toLowerCase().includes(needle)) check(device, [...path, key], key);
1232
- walk(device, [...path, key], inner);
1233
- }
1234
- return;
1235
- }
1236
-
1237
- if (value !== undefined && value !== null) check(device, path, String(value));
1238
- };
1239
-
1240
- for (const device of Tables.#data.devices) walk(device.name, [], device);
1241
-
1242
- container.append(Tables.heading(`Search: ${query}`, "h2"));
1243
-
1244
- if (!results.length) {
1245
- container.append(Tables.empty("Nothing in the config matches."));
1246
- return;
1247
- }
1248
-
1249
- container.append(
1250
- Tables.empty(
1251
- results.length >= 200 ? "The first 200 matches." : `${results.length} ${results.length === 1 ? "match" : "matches"}.`
1252
- ),
1253
- Tables.of({
1254
- csv: "search",
1255
- head: ["Device", "Where", "Value"].map((label) => ({ label })),
1256
- rows: results.map((result) => ({
1257
- cells: [
1258
- Tables.deviceLink(result.device),
1259
- Dom.link(result.path, () => Dom.go(`${Tables.#deviceRoute(result.device)}/${result.section}`)),
1260
- result.text.length > 120 ? `${result.text.slice(0, 120)}…` : result.text,
1261
- ],
1262
- })),
1263
- })
1264
- );
1265
- }
1266
-
1267
- /**
1268
- * Draw a place in Studio's tables, such as `["devices", "edge-01", "ports"]`, into a container.
1269
- *
1270
- * @param {string[]} route
1271
- * @param {HTMLElement} container
1272
- * @param {StudioData} next
1273
- */
1274
- static show(route, container, next) {
1275
- Tables.#data = next;
1276
- container.replaceChildren();
1277
-
1278
- const [view, first, second] = route;
1279
-
1280
- if (view === "devices" && first) Tables.#deviceView(first, second, container);
1281
- else if (view === "devices") Tables.#devicesView(container);
1282
- else if (view === "vlans") Tables.#vlansView(container);
1283
- else if (view === "addresses") Tables.#addressesView(container);
1284
- else if (view === "sessions") Tables.#sessionsView(container);
1285
- else if (view === "cables") Tables.#cablesView(container);
1286
- else if (view === "problems") Tables.#problemsView(container);
1287
- else if (view === "search") Tables.#searchView(first ?? "", container);
1288
- }
1289
- }