@takodotid/circuit 0.43.0 → 1.0.0-rc.1

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 (257) 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 +67 -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 +348 -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/plan.ts +132 -44
  63. package/src/platforms/fortios/render.ts +69 -62
  64. package/src/platforms/fortios/routing.ts +89 -58
  65. package/src/platforms/fortios/vdoms.ts +47 -16
  66. package/src/platforms/junos/TRAPS.md +2 -2
  67. package/src/platforms/junos/bgp.ts +63 -42
  68. package/src/platforms/junos/index.ts +122 -89
  69. package/src/platforms/junos/render.ts +139 -104
  70. package/src/platforms/platform.ts +10 -8
  71. package/src/platforms/raisecom-ros/TRAPS.md +1 -1
  72. package/src/platforms/raisecom-ros/index.ts +89 -70
  73. package/src/platforms/raisecom-ros/render.ts +49 -45
  74. package/src/platforms/routeros/TRAPS.md +3 -3
  75. package/src/platforms/routeros/index.ts +121 -60
  76. package/src/platforms/routeros/render.ts +214 -195
  77. package/src/platforms/vrp/TRAPS.md +1 -1
  78. package/src/platforms/vrp/index.ts +74 -50
  79. package/src/platforms/vrp/render.ts +67 -56
  80. package/src/project/project.ts +725 -0
  81. package/src/studio/app/env.d.ts +9 -0
  82. package/src/studio/app/index.html +18 -0
  83. package/src/studio/app/src/App.vue +95 -0
  84. package/src/studio/app/src/components/AddressesView.vue +38 -0
  85. package/src/studio/app/src/components/CablesView.vue +29 -0
  86. package/src/studio/app/src/components/CompareView.vue +160 -0
  87. package/src/studio/app/src/components/DataTable.vue +49 -0
  88. package/src/studio/app/src/components/DeviceView.vue +237 -0
  89. package/src/studio/app/src/components/DevicesView.vue +32 -0
  90. package/src/studio/app/src/components/Faceplate.vue +75 -0
  91. package/src/studio/app/src/components/GraphView.vue +282 -0
  92. package/src/studio/app/src/components/LineDiff.vue +24 -0
  93. package/src/studio/app/src/components/ProblemsView.vue +42 -0
  94. package/src/studio/app/src/components/SearchView.vue +72 -0
  95. package/src/studio/app/src/components/SecretsView.vue +112 -0
  96. package/src/studio/app/src/components/SessionsView.vue +35 -0
  97. package/src/studio/app/src/components/Settings.vue +95 -0
  98. package/src/studio/app/src/components/ToolsView.vue +118 -0
  99. package/src/studio/app/src/components/VlansView.vue +50 -0
  100. package/src/studio/app/src/lib/csv.ts +19 -0
  101. package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
  102. package/src/studio/app/src/lib/format.ts +38 -0
  103. package/src/studio/app/src/lib/net.ts +68 -0
  104. package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
  105. package/src/studio/app/src/lib/store.ts +71 -0
  106. package/src/studio/app/src/lib/tools.ts +217 -0
  107. package/src/studio/app/src/main.ts +9 -0
  108. package/src/studio/{public → app/src}/style.css +14 -4
  109. package/src/studio/app/tsconfig.json +17 -0
  110. package/src/studio/app/vite.config.ts +16 -0
  111. package/src/studio/assets.generated.ts +3 -0
  112. package/src/studio/data.ts +96 -188
  113. package/src/studio/git.ts +27 -43
  114. package/src/studio/server.ts +196 -107
  115. package/src/studio/types.ts +141 -0
  116. package/src/transport/console.ts +3 -3
  117. package/src/transport/ssh.ts +1 -1
  118. package/dist/chunks/index-3b343xva.js +0 -177
  119. package/dist/chunks/index-6wb41q67.js +0 -62
  120. package/dist/chunks/index-n7w3tg5y.js +0 -149
  121. package/dist/chunks/index-yzw86rc8.js +0 -921
  122. package/dist/chunks/new-w3zys5y4.js +0 -172
  123. package/dist/chunks/server-cxr9ynxc.js +0 -259
  124. package/dist/cli/index.js +0 -12872
  125. package/dist/helpers/index.js +0 -881
  126. package/dist/index.js +0 -28
  127. package/dist/types/core/address-space.d.ts +0 -62
  128. package/dist/types/core/address.d.ts +0 -44
  129. package/dist/types/core/cabling.d.ts +0 -41
  130. package/dist/types/core/cross-checks.d.ts +0 -28
  131. package/dist/types/core/define.d.ts +0 -79
  132. package/dist/types/core/project-files.d.ts +0 -12
  133. package/dist/types/core/schema-docs.d.ts +0 -25
  134. package/dist/types/core/secrets.d.ts +0 -62
  135. package/dist/types/core/topology.d.ts +0 -96
  136. package/dist/types/core/utils.d.ts +0 -10
  137. package/dist/types/core/validator.d.ts +0 -23
  138. package/dist/types/helpers/bgp.d.ts +0 -195
  139. package/dist/types/helpers/cables.d.ts +0 -69
  140. package/dist/types/helpers/checks.d.ts +0 -36
  141. package/dist/types/helpers/exchange.d.ts +0 -55
  142. package/dist/types/helpers/filters.d.ts +0 -51
  143. package/dist/types/helpers/index.d.ts +0 -14
  144. package/dist/types/helpers/route-server.d.ts +0 -52
  145. package/dist/types/helpers/upstreams.d.ts +0 -192
  146. package/dist/types/index.d.ts +0 -10
  147. package/dist/types/integrations/librenms.d.ts +0 -60
  148. package/dist/types/integrations/netbox.d.ts +0 -89
  149. package/dist/types/integrations/peeringdb.d.ts +0 -24
  150. package/dist/types/integrations/prometheus.d.ts +0 -24
  151. package/dist/types/integrations/registries.d.ts +0 -8
  152. package/dist/types/platforms/bgp-wait.d.ts +0 -24
  153. package/dist/types/platforms/catalog.d.ts +0 -109
  154. package/dist/types/platforms/derive.d.ts +0 -17
  155. package/dist/types/platforms/fortios/firewall.d.ts +0 -47
  156. package/dist/types/platforms/fortios/index.d.ts +0 -140
  157. package/dist/types/platforms/fortios/models.d.ts +0 -14
  158. package/dist/types/platforms/fortios/names.d.ts +0 -52
  159. package/dist/types/platforms/fortios/plan.d.ts +0 -104
  160. package/dist/types/platforms/fortios/render.d.ts +0 -77
  161. package/dist/types/platforms/fortios/routing.d.ts +0 -56
  162. package/dist/types/platforms/fortios/tree.d.ts +0 -37
  163. package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
  164. package/dist/types/platforms/index.d.ts +0 -13
  165. package/dist/types/platforms/junos/bgp.d.ts +0 -44
  166. package/dist/types/platforms/junos/index.d.ts +0 -114
  167. package/dist/types/platforms/junos/models.d.ts +0 -14
  168. package/dist/types/platforms/junos/render.d.ts +0 -154
  169. package/dist/types/platforms/line-diff.d.ts +0 -67
  170. package/dist/types/platforms/platform.d.ts +0 -135
  171. package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
  172. package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
  173. package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
  174. package/dist/types/platforms/routeros/export.d.ts +0 -59
  175. package/dist/types/platforms/routeros/index.d.ts +0 -116
  176. package/dist/types/platforms/routeros/models.d.ts +0 -18
  177. package/dist/types/platforms/routeros/plan.d.ts +0 -58
  178. package/dist/types/platforms/routeros/render.d.ts +0 -250
  179. package/dist/types/platforms/vrp/index.d.ts +0 -83
  180. package/dist/types/platforms/vrp/models.d.ts +0 -12
  181. package/dist/types/platforms/vrp/render.d.ts +0 -96
  182. package/dist/types/schema/common.d.ts +0 -30
  183. package/dist/types/schema/device.d.ts +0 -126
  184. package/dist/types/schema/firewall.d.ts +0 -131
  185. package/dist/types/schema/index.d.ts +0 -8
  186. package/dist/types/schema/interfaces.d.ts +0 -205
  187. package/dist/types/schema/policy.d.ts +0 -107
  188. package/dist/types/schema/routing.d.ts +0 -161
  189. package/dist/types/schema/system.d.ts +0 -198
  190. package/dist/types/schema/time-zones.d.ts +0 -2
  191. package/dist/types/transport/console.d.ts +0 -115
  192. package/dist/types/transport/path-check.d.ts +0 -32
  193. package/dist/types/transport/ssh.d.ts +0 -27
  194. package/src/cli/graph-format.ts +0 -91
  195. package/src/cli/new.ts +0 -234
  196. package/src/core/address-space.ts +0 -320
  197. package/src/core/cross-checks.ts +0 -369
  198. package/src/core/define.ts +0 -124
  199. package/src/core/project-files.ts +0 -35
  200. package/src/core/schema-docs.ts +0 -174
  201. package/src/core/validator.ts +0 -508
  202. package/src/helpers/bgp.ts +0 -473
  203. package/src/helpers/cables.ts +0 -74
  204. package/src/helpers/checks.ts +0 -201
  205. package/src/helpers/exchange.ts +0 -123
  206. package/src/helpers/filters.ts +0 -107
  207. package/src/helpers/index.ts +0 -25
  208. package/src/helpers/route-server.ts +0 -212
  209. package/src/helpers/upstreams.ts +0 -369
  210. package/src/index.ts +0 -13
  211. package/src/integrations/librenms.ts +0 -291
  212. package/src/integrations/netbox.ts +0 -620
  213. package/src/integrations/peeringdb.ts +0 -173
  214. package/src/integrations/prometheus.ts +0 -90
  215. package/src/schema/common.ts +0 -41
  216. package/src/schema/device.ts +0 -145
  217. package/src/schema/firewall.ts +0 -140
  218. package/src/schema/index.ts +0 -8
  219. package/src/schema/policy.ts +0 -115
  220. package/src/schema/routing.ts +0 -173
  221. package/src/schema/system.ts +0 -203
  222. package/src/studio/public/app.js +0 -186
  223. package/src/studio/public/compare.js +0 -250
  224. package/src/studio/public/dom.js +0 -138
  225. package/src/studio/public/evaluate.js +0 -687
  226. package/src/studio/public/export.js +0 -130
  227. package/src/studio/public/graph.js +0 -1541
  228. package/src/studio/public/index.html +0 -83
  229. package/src/studio/public/net.js +0 -134
  230. package/src/studio/public/tables.js +0 -1289
  231. package/src/studio/public/tools.js +0 -610
  232. package/templates/colocation/checks.ts +0 -53
  233. package/templates/colocation/circuit.config.ts +0 -9
  234. package/templates/colocation/edge-01.ts +0 -133
  235. package/templates/colocation/routing.ts +0 -13
  236. package/templates/colocation/tenancy.ts +0 -76
  237. package/templates/colocation/tenants/acme.ts +0 -19
  238. package/templates/colocation/tenants/index.ts +0 -6
  239. package/templates/colocation/tor-01.ts +0 -33
  240. package/templates/edge-router/checks.ts +0 -20
  241. package/templates/edge-router/circuit.config.ts +0 -8
  242. package/templates/edge-router/edge-01.ts +0 -110
  243. package/templates/edge-router/routing.ts +0 -13
  244. package/templates/project/AGENTS.md +0 -51
  245. package/templates/project/CLAUDE.md +0 -1
  246. package/templates/project/README.md +0 -16
  247. package/templates/project/gitignore +0 -5
  248. package/templates/project/tsconfig.json +0 -13
  249. package/templates/single-site/checks.ts +0 -26
  250. package/templates/single-site/circuit.config.ts +0 -8
  251. package/templates/single-site/router.ts +0 -86
  252. package/templates/single-site/site.ts +0 -18
  253. package/templates/single-site/switch.ts +0 -34
  254. /package/src/studio/{public → app/public}/favicon.svg +0 -0
  255. /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
  256. /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
  257. /package/src/studio/{public → app/public}/special-gothic-license.txt +0 -0
@@ -0,0 +1,35 @@
1
+ <!-- Every BGP session of the project, with what it takes from its group, and what is at its far end. -->
2
+ <script setup lang="ts">
3
+ import { computed } from "vue";
4
+ import type { StudioData } from "../../../types";
5
+ import DataTable, { type Row } from "./DataTable.vue";
6
+
7
+ const props = defineProps<{ data: StudioData }>();
8
+ const devices = computed(() => new Set(props.data.devices.map((device) => device.name)));
9
+
10
+ const rows = computed<Row[]>(() =>
11
+ props.data.sessions.map((session) => ({
12
+ cells: [
13
+ { device: session.device },
14
+ session.name,
15
+ session.address,
16
+ session.remoteAs ? `AS${session.remoteAs}` : "",
17
+ session.role,
18
+ session.group,
19
+ session.import,
20
+ session.export,
21
+ session.far && devices.value.has(session.far) ? { device: session.far } : session.far,
22
+ session.oneSided ? "no session back" : "",
23
+ ],
24
+ }))
25
+ );
26
+ </script>
27
+
28
+ <template>
29
+ <h2>BGP sessions</h2>
30
+ <DataTable
31
+ :head="['Device', 'Neighbor', 'Address', 'Remote AS', 'Role', 'Group', 'Import', 'Export', 'Far end', 'Note']"
32
+ :rows="rows"
33
+ csv="bgp-sessions"
34
+ />
35
+ </template>
@@ -0,0 +1,95 @@
1
+ <!-- Settings as a table of field and value. A value that holds named items, or a list of items, is a table inside; any other is one line of text. -->
2
+ <script setup lang="ts">
3
+ import { computed } from "vue";
4
+ import { Format } from "../lib/format";
5
+
6
+ const props = defineProps<{ value: unknown }>();
7
+
8
+ type Shown =
9
+ | { kind: "text"; text: string }
10
+ | { kind: "settings"; value: Record<string, unknown> }
11
+ | { kind: "record"; head: string[]; rows: { name: string; cells: unknown[] }[] }
12
+ | { kind: "list"; head: string[]; rows: unknown[][] };
13
+
14
+ /** The fields any of the items hold, in the order first seen. */
15
+ const columnsOf = (items: Record<string, unknown>[]) => [...new Set(items.flatMap((item) => Object.keys(item)))];
16
+
17
+ function shown(value: unknown): Shown {
18
+ if (Format.isObject(value) && Object.keys(value).length && !Format.isSecret(value)) {
19
+ const inner = Object.entries(value).filter(([, item]) => item !== undefined);
20
+ const allObjects = inner.every(([, item]) => Format.isObject(item) && !Format.isSecret(item));
21
+ if (!allObjects) return { kind: "settings", value };
22
+
23
+ const items = inner.map(([, item]) => item as Record<string, unknown>);
24
+ const head = columnsOf(items);
25
+ return { kind: "record", head, rows: inner.map(([name], index) => ({ name, cells: head.map((column) => items[index]![column]) })) };
26
+ }
27
+
28
+ if (Array.isArray(value) && value.some((item) => Format.isObject(item) && !Format.isSecret(item))) {
29
+ const items = value.map((item) => (Format.isObject(item) ? item : { value: item }));
30
+ const head = columnsOf(items);
31
+ return { kind: "list", head, rows: items.map((item) => head.map((column) => item[column])) };
32
+ }
33
+
34
+ return { kind: "text", text: Format.compact(value) };
35
+ }
36
+
37
+ const entries = computed(() =>
38
+ Format.isObject(props.value)
39
+ ? Object.entries(props.value)
40
+ .filter(([, value]) => value !== undefined)
41
+ .map(([key, value]) => ({ key, item: shown(value) }))
42
+ : []
43
+ );
44
+ </script>
45
+
46
+ <template>
47
+ <p v-if="!entries.length" class="muted">None.</p>
48
+ <table v-else class="data settings">
49
+ <tbody>
50
+ <tr v-for="{ key, item } in entries" :key="key">
51
+ <th>
52
+ <code>{{ key }}</code>
53
+ </th>
54
+ <td>
55
+ <template v-if="item.kind === 'text'">{{ item.text }}</template>
56
+ <Settings v-else-if="item.kind === 'settings'" :value="item.value" />
57
+ <table v-else-if="item.kind === 'record'" class="data">
58
+ <thead>
59
+ <tr>
60
+ <th>Name</th>
61
+ <th v-for="column in item.head" :key="column">
62
+ <code>{{ column }}</code>
63
+ </th>
64
+ </tr>
65
+ </thead>
66
+ <tbody>
67
+ <tr v-for="row in item.rows" :key="row.name">
68
+ <td>
69
+ <strong>{{ row.name }}</strong>
70
+ </td>
71
+ <td v-for="(cell, at) in row.cells" :key="at">{{ Format.compact(cell) }}</td>
72
+ </tr>
73
+ </tbody>
74
+ </table>
75
+ <table v-else class="data">
76
+ <thead>
77
+ <tr>
78
+ <th>#</th>
79
+ <th v-for="column in item.head" :key="column">
80
+ <code>{{ column }}</code>
81
+ </th>
82
+ </tr>
83
+ </thead>
84
+ <tbody>
85
+ <tr v-for="(row, index) in item.rows" :key="index">
86
+ <td>{{ index + 1 }}</td>
87
+ <td v-for="(cell, at) in row" :key="at">{{ Format.compact(cell) }}</td>
88
+ </tr>
89
+ </tbody>
90
+ </table>
91
+ </td>
92
+ </tr>
93
+ </tbody>
94
+ </table>
95
+ </template>
@@ -0,0 +1,118 @@
1
+ <!-- Studio's tools: trace an address through the project, and find the cables that carry a VLAN between two devices. -->
2
+ <script setup lang="ts">
3
+ import { computed, ref } from "vue";
4
+ import type { StudioData } from "../../../types";
5
+ import { go, store } from "../lib/store";
6
+ import { Tools } from "../lib/tools";
7
+ import DataTable from "./DataTable.vue";
8
+
9
+ const props = defineProps<{ data: StudioData }>();
10
+
11
+ const tool = computed(() => (store.route.parts[0] === "vlan" ? "vlan" : "trace"));
12
+
13
+ // A trace has its own address, such as `#tools/trace/192.0.2.5`, to bookmark or send.
14
+ const typed = ref(store.route.parts[0] === "trace" ? (store.route.parts[1] ?? "") : "");
15
+ const traced = computed(() => (tool.value === "trace" && store.route.parts[1] ? Tools.trace(props.data, store.route.parts[1]) : undefined));
16
+ const traceFailed = computed(() => tool.value === "trace" && Boolean(store.route.parts[1]) && !traced.value);
17
+
18
+ const vlans = computed(() => [...new Set(props.data.devices.flatMap((device) => Object.keys(device.switching?.vlans ?? {})))].sort());
19
+ const devices = computed(() => props.data.devices.map((device) => device.name));
20
+ const vlan = ref("");
21
+ const from = ref("");
22
+ const to = ref("");
23
+ const asked = ref(false);
24
+ const path = computed(() =>
25
+ asked.value && vlan.value && from.value && to.value ? Tools.vlanPath(props.data, vlan.value, from.value, to.value) : undefined
26
+ );
27
+ </script>
28
+
29
+ <template>
30
+ <h2>Tools</h2>
31
+ <div class="tabs">
32
+ <button :aria-pressed="tool === 'trace'" @click="go('tools/trace')">Trace an address</button>
33
+ <button :aria-pressed="tool === 'vlan'" @click="go('tools/vlan')">VLAN path</button>
34
+ </div>
35
+
36
+ <template v-if="tool === 'trace'">
37
+ <p class="muted">
38
+ Which device holds an address, how each device would reach it from what the project says, and every rule that names it. A route
39
+ learned from BGP or OSPF is not in the project, so a device with no route here may still reach it.
40
+ </p>
41
+ <form class="tool-form" @submit.prevent="go(`tools/trace/${typed.trim()}`)">
42
+ <label class="field">Address <input v-model="typed" placeholder="192.0.2.5" /></label>
43
+ <button class="primary" type="submit">Trace</button>
44
+ </form>
45
+ <p v-if="traceFailed" class="verdict reject">That is not one address, such as 192.0.2.5 or 2001:db8::5.</p>
46
+ <template v-if="traced">
47
+ <h3>Held by</h3>
48
+ <DataTable
49
+ :head="['Device', 'On', 'Address']"
50
+ :rows="traced.held.map((entry) => ({ cells: [{ device: entry.device }, entry.holder, entry.address] }))"
51
+ />
52
+ <h3>How each device reaches it</h3>
53
+ <DataTable
54
+ :head="['Device', 'Table', 'How', 'Prefix', 'Via', 'Through', 'Note']"
55
+ :rows="
56
+ traced.routes.map((route) => ({
57
+ muted: route.kind === 'none',
58
+ cells: [
59
+ { device: route.device },
60
+ route.table,
61
+ route.kind === 'none' ? 'no route in the project' : route.kind,
62
+ route.prefix,
63
+ route.via,
64
+ route.through,
65
+ route.note,
66
+ ],
67
+ }))
68
+ "
69
+ csv="trace"
70
+ />
71
+ <h3>Rules that name it</h3>
72
+ <DataTable
73
+ :head="['Device', 'Where', 'Rule', 'Matches its', 'Then', 'Description']"
74
+ :rows="
75
+ traced.rules.map((rule) => ({
76
+ cells: [{ device: rule.device }, rule.where, rule.rule, rule.side, rule.then, rule.description],
77
+ }))
78
+ "
79
+ />
80
+ </template>
81
+ </template>
82
+
83
+ <template v-else>
84
+ <p class="muted">
85
+ The fewest cables that carry a VLAN from one device to another, by each port's VLANs and links, at both ends of each cable.
86
+ </p>
87
+ <form class="tool-form" @submit.prevent="asked = true">
88
+ <label class="field"
89
+ >VLAN
90
+ <select v-model="vlan" @change="asked = false">
91
+ <option v-for="name in vlans" :key="name">{{ name }}</option>
92
+ </select>
93
+ </label>
94
+ <label class="field"
95
+ >From
96
+ <select v-model="from" @change="asked = false">
97
+ <option v-for="name in devices" :key="name">{{ name }}</option>
98
+ </select>
99
+ </label>
100
+ <label class="field"
101
+ >To
102
+ <select v-model="to" @change="asked = false">
103
+ <option v-for="name in devices" :key="name">{{ name }}</option>
104
+ </select>
105
+ </label>
106
+ <button class="primary" type="submit">Find the path</button>
107
+ </form>
108
+ <template v-if="asked && vlan && from && to">
109
+ <p v-if="!path" class="verdict reject">No path of cables carries {{ vlan }} from {{ from }} to {{ to }}.</p>
110
+ <p v-else-if="!path.length" class="muted">{{ from }} and {{ to }} are one device.</p>
111
+ <DataTable
112
+ v-else
113
+ :head="['From', 'Port', 'To', 'Port']"
114
+ :rows="path.map((hop) => ({ cells: [{ device: hop.device }, hop.port, { device: hop.farDevice }, hop.farPort] }))"
115
+ />
116
+ </template>
117
+ </template>
118
+ </template>
@@ -0,0 +1,50 @@
1
+ <!-- Every VLAN of the project: its ID on each device, what it is for, and the ports that carry it. -->
2
+ <script setup lang="ts">
3
+ import { computed } from "vue";
4
+ import type { StudioData } from "../../../types";
5
+ import DataTable, { type Row } from "./DataTable.vue";
6
+
7
+ const props = defineProps<{ data: StudioData }>();
8
+
9
+ const rows = computed<Row[]>(() => {
10
+ const byName = new Map<string, { ids: Set<number>; descriptions: Set<string>; devices: string[]; ports: string[] }>();
11
+
12
+ for (const device of props.data.devices) {
13
+ for (const [name, vlan] of Object.entries(device.switching?.vlans ?? {})) {
14
+ if (!vlan) continue;
15
+ const entry = byName.get(name) ?? { ids: new Set(), descriptions: new Set(), devices: [], ports: [] };
16
+ entry.ids.add(vlan.id);
17
+ if (vlan.description) entry.descriptions.add(vlan.description);
18
+ entry.devices.push(device.name);
19
+ byName.set(name, entry);
20
+ }
21
+
22
+ // A port carries what its LAG carries.
23
+ const holders = [
24
+ ...Object.entries(device.interfaces?.ethernet ?? {}).filter(([, port]) => port && !port.in_lag),
25
+ ...Object.entries(device.interfaces?.lag ?? {}),
26
+ ];
27
+ for (const [holder, settings] of holders) {
28
+ const carried = [settings?.access_vlan, settings?.native_vlan, ...(settings?.trunk_vlans ?? [])];
29
+ for (const name of new Set(carried)) if (name) byName.get(name)?.ports.push(`${device.name} ${holder}`);
30
+ }
31
+ }
32
+
33
+ return [...byName]
34
+ .sort(([, a], [, b]) => Math.min(...a.ids) - Math.min(...b.ids))
35
+ .map(([name, entry]) => ({
36
+ cells: [
37
+ name,
38
+ [...entry.ids].join(", ") + (entry.ids.size > 1 ? " (differs by device)" : ""),
39
+ [...entry.descriptions].join(" / "),
40
+ entry.devices.join(", "),
41
+ entry.ports.length,
42
+ ],
43
+ }));
44
+ });
45
+ </script>
46
+
47
+ <template>
48
+ <h2>VLANs</h2>
49
+ <DataTable :head="['VLAN', 'ID', 'Description', 'Devices', 'Ports and LAGs carrying it']" :rows="rows" csv="vlans" />
50
+ </template>
@@ -0,0 +1,19 @@
1
+ // Saving what Studio shows: a table as CSV, and the graph as an image.
2
+
3
+ export class Export {
4
+ /** A table's rows as CSV, each cell quoted when it needs to be. */
5
+ static csv(head: readonly string[], rows: readonly (readonly string[])[]): string {
6
+ const cell = (text: string) => (/[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text);
7
+ return [head, ...rows].map((row) => row.map(cell).join(",")).join("\n") + "\n";
8
+ }
9
+
10
+ /** Hand a file to the browser to save. */
11
+ static save(name: string, blob: Blob): void {
12
+ const url = URL.createObjectURL(blob);
13
+ const link = document.createElement("a");
14
+ link.href = url;
15
+ link.download = name;
16
+ link.click();
17
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
18
+ }
19
+ }
@@ -1,37 +1,24 @@
1
- // @ts-check
2
1
  // A line diff, after Myers' "An O(ND) Difference Algorithm": the fewest lines added and removed that turn one text into another.
3
2
 
4
- /** @typedef {{ kind: "same" | "removed" | "added", text: string }} DiffLine */
3
+ export type DiffLine = { kind: "same" | "removed" | "added"; text: string };
5
4
 
6
5
  export class Diff {
7
6
  /** Past this many differences, two texts are too far apart to show line by line. */
8
- static #MOST_DIFFERENCES = 4000;
9
-
10
- /**
11
- * The lines of `before` and `after` in order, each marked as kept, removed or added. Undefined when they differ in more than a few thousand lines.
12
- *
13
- * @param {readonly string[]} before
14
- * @param {readonly string[]} after
15
- * @returns {DiffLine[] | undefined}
16
- */
17
- static lines(before, after) {
7
+ private static readonly MOST_DIFFERENCES = 4000;
8
+
9
+ /** The lines of `before` and `after` in order, each marked as kept, removed or added. Undefined when they differ in more than a few thousand lines. */
10
+ static lines(before: readonly string[], after: readonly string[]): DiffLine[] | undefined {
18
11
  const n = before.length;
19
12
  const m = after.length;
20
- const limit = Math.min(n + m, Diff.#MOST_DIFFERENCES);
13
+ const limit = Math.min(n + m, Diff.MOST_DIFFERENCES);
21
14
  const offset = limit + 1;
22
15
 
23
16
  // How far along `before` each diagonal reaches, and a copy of that for each number of differences, to walk back through.
24
17
  const reach = new Int32Array(2 * offset + 1);
25
- /** @type {Int32Array[]} */
26
- const trace = [];
18
+ const trace: Int32Array[] = [];
27
19
 
28
- /**
29
- * Where diagonal k reached after d differences.
30
- *
31
- * @param {number} d
32
- * @param {number} k
33
- */
34
- const reached = (d, k) => /** @type {Int32Array} */ (trace[d])[k + d] ?? 0;
20
+ /** Where diagonal k reached after d differences. */
21
+ const reached = (d: number, k: number) => trace[d]![k + d] ?? 0;
35
22
 
36
23
  for (let d = 0; d <= limit; d++) {
37
24
  for (let k = -d; k <= d; k += 2) {
@@ -48,7 +35,7 @@ export class Diff {
48
35
 
49
36
  if (x >= n && y >= m) {
50
37
  trace.push(reach.slice(offset - d, offset + d + 1));
51
- return Diff.#walkBack(before, after, trace, reached);
38
+ return Diff.walkBack(before, after, trace, reached);
52
39
  }
53
40
  }
54
41
 
@@ -58,18 +45,14 @@ export class Diff {
58
45
  return undefined;
59
46
  }
60
47
 
61
- /**
62
- * The edits, found by walking back from the end through what each step reached.
63
- *
64
- * @param {readonly string[]} before
65
- * @param {readonly string[]} after
66
- * @param {Int32Array[]} trace
67
- * @param {(d: number, k: number) => number} reached
68
- * @returns {DiffLine[]}
69
- */
70
- static #walkBack(before, after, trace, reached) {
71
- /** @type {DiffLine[]} */
72
- const lines = [];
48
+ /** The edits, found by walking back from the end through what each step reached. */
49
+ private static walkBack(
50
+ before: readonly string[],
51
+ after: readonly string[],
52
+ trace: Int32Array[],
53
+ reached: (d: number, k: number) => number
54
+ ): DiffLine[] {
55
+ const lines: DiffLine[] = [];
73
56
  let x = before.length;
74
57
  let y = after.length;
75
58
 
@@ -96,4 +79,26 @@ export class Diff {
96
79
 
97
80
  return lines.reverse();
98
81
  }
82
+
83
+ /** What to show of a diff: each changed line with a few lines around it, and runs of the same folded into a count. */
84
+ static shown(lines: readonly DiffLine[], context = 3): ({ kind: "fold"; count: number } | DiffLine)[] {
85
+ const near = lines.map((_, index) =>
86
+ lines.slice(Math.max(0, index - context), index + context + 1).some((line) => line.kind !== "same")
87
+ );
88
+ const shown: ({ kind: "fold"; count: number } | DiffLine)[] = [];
89
+ let folded = 0;
90
+
91
+ for (const [index, line] of lines.entries()) {
92
+ if (!near[index]) {
93
+ folded++;
94
+ continue;
95
+ }
96
+ if (folded) shown.push({ kind: "fold", count: folded });
97
+ folded = 0;
98
+ shown.push(line);
99
+ }
100
+ if (folded) shown.push({ kind: "fold", count: folded });
101
+
102
+ return shown;
103
+ }
99
104
  }
@@ -0,0 +1,38 @@
1
+ // A value of the model as text, the way Studio's tables show it.
2
+
3
+ export class Format {
4
+ static isObject(value: unknown): value is Record<string, unknown> {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+
8
+ /** `secret("NAME")` or `secretFile("path")` in the model: shown as the project writes it, never as a value. */
9
+ static isSecret(value: unknown): value is { secret: string } | { secret_file: string } {
10
+ return (
11
+ Format.isObject(value) &&
12
+ Object.keys(value).length === 1 &&
13
+ (typeof value.secret === "string" || typeof value.secret_file === "string")
14
+ );
15
+ }
16
+
17
+ /** A value in one line of text. */
18
+ static compact(value: unknown): string {
19
+ if (value === undefined || value === null) return "";
20
+ if (Format.isSecret(value)) return "secret" in value ? `secret("${value.secret}")` : `secretFile("${value.secret_file}")`;
21
+ if (Array.isArray(value)) return value.map(Format.compact).join(value.every((item) => !Format.isObject(item)) ? ", " : " / ");
22
+
23
+ // A field written as `{}`, such as `ssh: {}`, is on with its defaults.
24
+ if (Format.isObject(value) && !Object.keys(value).length) return "on";
25
+ if (Format.isObject(value)) {
26
+ return Object.entries(value)
27
+ .map(([key, inner]) => (inner === true ? key : `${key} ${Format.compact(inner)}`))
28
+ .join(", ");
29
+ }
30
+
31
+ return String(value);
32
+ }
33
+
34
+ /** "1 error", "2 errors". */
35
+ static count(count: number, one: string, many = `${one}s`): string {
36
+ return `${count} ${count === 1 ? one : many}`;
37
+ }
38
+ }
@@ -0,0 +1,68 @@
1
+ // Address arithmetic for the browser, for both families, as src/core/address.ts does it for Circuit: addresses as numbers, so IPv4 and IPv6 share one path.
2
+
3
+ export type Family = "ipv4" | "ipv6";
4
+ export type Range = { family: Family; first: bigint; last: bigint; length: number };
5
+
6
+ export class Net {
7
+ private static bits(family: Family): number {
8
+ return family === "ipv4" ? 32 : 128;
9
+ }
10
+
11
+ /** An address as a number, or undefined when the text is not one. */
12
+ static parseAddress(text: string): { family: Family; value: bigint } | undefined {
13
+ if (!text.includes(":")) {
14
+ const octets = text.split(".");
15
+ if (octets.length !== 4 || !octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255)) return undefined;
16
+ return { family: "ipv4", value: octets.reduce((value, octet) => (value << 8n) | BigInt(octet), 0n) };
17
+ }
18
+
19
+ const [before, after, extra] = text.split("::");
20
+ if (extra !== undefined || before === undefined) return undefined;
21
+
22
+ const groupsOf = (part: string | undefined) => (part ? part.split(":") : []);
23
+ const leading = groupsOf(before);
24
+ const trailing = groupsOf(after);
25
+
26
+ // `::` stands for as many zero groups as make eight.
27
+ const zeros = after === undefined ? 0 : 8 - leading.length - trailing.length;
28
+ if (zeros < 0) return undefined;
29
+ const groups = [...leading, ...Array<string>(zeros).fill("0"), ...trailing];
30
+ if (groups.length !== 8 || !groups.every((group) => /^[0-9a-fA-F]{1,4}$/.test(group))) return undefined;
31
+
32
+ return { family: "ipv6", value: groups.reduce((value, group) => (value << 16n) | BigInt(parseInt(group, 16)), 0n) };
33
+ }
34
+
35
+ /** A prefix, or a lone address as the prefix of just itself, as the range of numbers it covers. Undefined when the text is neither. */
36
+ static parseRange(text: string): Range | undefined {
37
+ const [addressText, lengthText] = text.split("/");
38
+ const address = Net.parseAddress(addressText ?? "");
39
+ if (!address) return undefined;
40
+
41
+ const bits = Net.bits(address.family);
42
+ const length = lengthText === undefined ? bits : Number(lengthText);
43
+ if (!Number.isInteger(length) || length < 0 || length > bits) return undefined;
44
+
45
+ const hostBits = (1n << BigInt(bits - length)) - 1n;
46
+ const first = address.value & ~hostBits & ((1n << BigInt(bits)) - 1n);
47
+ return { family: address.family, first, last: first | hostBits, length };
48
+ }
49
+
50
+ /** Whether a prefix, or an address, holds an address or a whole other prefix. */
51
+ static covers(outer: string, inner: string): boolean {
52
+ const a = Net.parseRange(outer);
53
+ const b = Net.parseRange(inner);
54
+ return Boolean(a && b && a.family === b.family && a.first <= b.first && b.last <= a.last);
55
+ }
56
+
57
+ /** The same address, however it is written. */
58
+ static sameAddress(a: string, b: string): boolean {
59
+ const x = Net.parseAddress(a.split("/")[0] ?? "");
60
+ const y = Net.parseAddress(b.split("/")[0] ?? "");
61
+ return Boolean(x && y && x.family === y.family && x.value === y.value);
62
+ }
63
+
64
+ /** How long a prefix is, or the family's whole length for a lone address. */
65
+ static lengthOf(text: string): number {
66
+ return Net.parseRange(text)?.length ?? 0;
67
+ }
68
+ }
@@ -0,0 +1,35 @@
1
+ // Decrypting a secrets file's value in the browser, as Circuit encrypts it: `encrypted:` and the base64 of the version, salt, IV, ciphertext and tag, AES-256-GCM with a key made from the password by scrypt, and the secret's name bound to it. The password a person types is used here and never leaves the page.
2
+
3
+ import { gcm } from "@noble/ciphers/aes";
4
+ import { scryptAsync } from "@noble/hashes/scrypt";
5
+
6
+ export class SecretsCrypto {
7
+ private static readonly PREFIX = "encrypted:";
8
+
9
+ /** One key per salt, so a file's values open with one scrypt run. */
10
+ private readonly keys = new Map<string, Promise<Uint8Array>>();
11
+
12
+ constructor(private readonly password: string) {}
13
+
14
+ private static partsOf(value: string) {
15
+ const bytes = Uint8Array.from(atob(value.slice(SecretsCrypto.PREFIX.length)), (char) => char.charCodeAt(0));
16
+ if (bytes[0] !== 1 || bytes.length < 1 + 16 + 12 + 16) throw new Error("not a value Circuit encrypted");
17
+ return { salt: bytes.subarray(1, 17), iv: bytes.subarray(17, 29), sealed: bytes.subarray(29) };
18
+ }
19
+
20
+ private key(salt: Uint8Array): Promise<Uint8Array> {
21
+ const id = [...salt].join(",");
22
+ let key = this.keys.get(id);
23
+ if (!key) this.keys.set(id, (key = scryptAsync(this.password, salt, { N: 2 ** 17, r: 8, p: 1, dkLen: 32 })));
24
+ return key;
25
+ }
26
+
27
+ /** One value, decrypted. Throws when the password does not open it. */
28
+ async decrypt(name: string, value: string): Promise<string> {
29
+ if (!value.startsWith(SecretsCrypto.PREFIX)) return value;
30
+
31
+ const { salt, iv, sealed } = SecretsCrypto.partsOf(value);
32
+ const plain = gcm(await this.key(salt), iv, new TextEncoder().encode(name)).decrypt(sealed);
33
+ return new TextDecoder().decode(plain);
34
+ }
35
+ }
@@ -0,0 +1,71 @@
1
+ // What the page knows: what the server gathered last, where the reader is, and whether the server is still there. The server tells the page when the project changed, and the page reads it again.
2
+
3
+ import { reactive } from "vue";
4
+ import type { StudioData } from "../../../types";
5
+
6
+ export type Route = { view: string; parts: string[] };
7
+
8
+ function parseRoute(): Route {
9
+ const parts = location.hash.slice(1).split("/").map(decodeURIComponent).filter(Boolean);
10
+ return { view: parts[0] ?? "graph", parts: parts.slice(1) };
11
+ }
12
+
13
+ export const store = reactive({
14
+ data: undefined as StudioData | undefined,
15
+ /** What stopped the server reading the project, shown over the page. */
16
+ error: "",
17
+ live: "connecting" as "connecting" | "live" | "offline",
18
+ /** The project was read again just now. */
19
+ beat: false,
20
+ route: parseRoute(),
21
+ });
22
+
23
+ /** Go to a place in the page, such as `devices/edge-01/ports`. */
24
+ export function go(path: string): void {
25
+ location.hash = path
26
+ .split("/")
27
+ .map((part) => encodeURIComponent(part))
28
+ .join("/");
29
+ }
30
+
31
+ addEventListener("hashchange", () => {
32
+ store.route = parseRoute();
33
+ });
34
+
35
+ /** A request to the server. A request that reaches a device takes Studio's header and a POST. */
36
+ export async function api<T>(path: string, reachesDevice = false): Promise<T> {
37
+ const response = await fetch(path, reachesDevice ? { method: "POST", headers: { "x-circuit-studio": "1" } } : {});
38
+ if (!response.ok) throw new Error(`the server answered ${response.status}`);
39
+
40
+ const body = (await response.json()) as T | { error: string };
41
+ if (body && typeof body === "object" && "error" in body && typeof body.error === "string") throw new Error(body.error);
42
+ return body as T;
43
+ }
44
+
45
+ async function load(): Promise<void> {
46
+ try {
47
+ const body = (await (await fetch("/api/data")).json()) as StudioData | { error: string };
48
+ if ("error" in body) {
49
+ store.error = body.error;
50
+ return;
51
+ }
52
+ store.data = body;
53
+ store.error = "";
54
+ } catch (error) {
55
+ store.error = (error as Error).message;
56
+ }
57
+ }
58
+
59
+ /** Read what the server gathered, and again each time it says the project changed. */
60
+ export function connect(): void {
61
+ void load();
62
+
63
+ const events = new EventSource("/api/events");
64
+ events.onopen = () => (store.live = "live");
65
+ events.onerror = () => (store.live = "offline");
66
+ events.onmessage = async () => {
67
+ await load();
68
+ store.beat = true;
69
+ setTimeout(() => (store.beat = false), 1200);
70
+ };
71
+ }