@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
@@ -0,0 +1,32 @@
1
+ <!-- Every device of the project, with where it is, what it runs on, and what validate finds on 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
+ props.data.devices.map((device) => {
11
+ const findings = props.data.findings.filter((finding) => finding.device === device.name);
12
+ const errors = findings.filter((finding) => finding.level === "error").length;
13
+ return {
14
+ cells: [
15
+ { device: device.name },
16
+ device.site,
17
+ device.platform,
18
+ device.model,
19
+ device.connection.host ?? "console only",
20
+ errors || "",
21
+ findings.length - errors || "",
22
+ props.data.files[device.name],
23
+ ],
24
+ };
25
+ })
26
+ );
27
+ </script>
28
+
29
+ <template>
30
+ <h2>Devices</h2>
31
+ <DataTable :head="['Device', 'Site', 'Platform', 'Model', 'Reached at', 'Errors', 'Warnings', 'File']" :rows="rows" csv="devices" />
32
+ </template>
@@ -0,0 +1,75 @@
1
+ <!-- A device's front panel: one square per port of its model, by speed, filled by how the port is used. A square scrolls to the port's row. -->
2
+ <script setup lang="ts">
3
+ import { computed } from "vue";
4
+ import type { Device, Port } from "../../../../model";
5
+
6
+ const props = defineProps<{ device: Device; ports: string[] }>();
7
+
8
+ type Use = "shut" | "lag" | "access" | "trunk" | "routed" | "plain";
9
+
10
+ const USES: Record<Use, string> = {
11
+ shut: "shut down",
12
+ lag: "in a LAG",
13
+ access: "access",
14
+ trunk: "trunk",
15
+ routed: "routed",
16
+ plain: "up, nothing on it",
17
+ };
18
+
19
+ const useOf = (port: Port | undefined): Use => {
20
+ if (!port) return "shut";
21
+ if (port.in_lag) return "lag";
22
+ if (port.access_vlan) return "access";
23
+ if (port.trunk_vlans) return "trunk";
24
+ if (port.addresses) return "routed";
25
+ return "plain";
26
+ };
27
+
28
+ const bySpeed = computed(() => {
29
+ const groups = new Map<string, { name: string; use: Use; linked: boolean; label: string }[]>();
30
+
31
+ for (const name of props.ports) {
32
+ const port = props.device.interfaces?.ethernet?.[name];
33
+ const use = useOf(port);
34
+ const far = port?.link;
35
+ const label = [name, USES[use], port?.description, far ? `to ${far.device} ${far.port}` : ""].filter(Boolean).join(", ");
36
+ const speed = name.split("-")[0] ?? name;
37
+ groups.set(speed, [...(groups.get(speed) ?? []), { name, use, linked: Boolean(far), label }]);
38
+ }
39
+
40
+ return [...groups];
41
+ });
42
+
43
+ function show(name: string): void {
44
+ const row = document.getElementById(`port-${name}`);
45
+ row?.scrollIntoView({ block: "center", behavior: "smooth" });
46
+ row?.classList.add("flash");
47
+ setTimeout(() => row?.classList.remove("flash"), 1600);
48
+ }
49
+ </script>
50
+
51
+ <template>
52
+ <div v-if="ports.length">
53
+ <div class="faceplate" role="group" :aria-label="`${device.name}'s ports`">
54
+ <div v-for="[speed, squares] in bySpeed" :key="speed" class="speed">
55
+ <span class="speed-name">{{ speed }}</span>
56
+ <div class="ports">
57
+ <button
58
+ v-for="square in squares"
59
+ :key="square.name"
60
+ :class="['port', square.use, { linked: square.linked }]"
61
+ :title="square.label"
62
+ :aria-label="square.label"
63
+ @click="show(square.name)"
64
+ >
65
+ {{ square.name.split("-")[1] ?? square.name }}
66
+ </button>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ <ul class="faceplate-legend">
71
+ <li v-for="(text, use) in USES" :key="use"><span :class="['port', use]"></span>{{ text }}</li>
72
+ <li><span class="port plain linked"></span>linked to another device</li>
73
+ </ul>
74
+ </div>
75
+ </template>
@@ -0,0 +1,282 @@
1
+ <!-- The network drawn, one layer at a time: cables, VLANs, address segments, BGP, OSPF and tunnels. Drawn with Cytoscape; a node or line picked shows what it is in the panel. -->
2
+ <script setup lang="ts">
3
+ import cytoscape, { type Core, type ElementDefinition } from "cytoscape";
4
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
5
+ import type { GraphEdge, GraphNode, Layer } from "../../../../core/graph";
6
+ import type { StudioData } from "../../../types";
7
+ import { Export } from "../lib/csv";
8
+ import { go, store } from "../lib/store";
9
+
10
+ const props = defineProps<{ data: StudioData }>();
11
+
12
+ const LAYERS: { id: Layer; label: string; legend: string }[] = [
13
+ { id: "l1", label: "Cables", legend: "Each cable a port's link names. A LAG's cables are one line." },
14
+ { id: "l2", label: "VLANs", legend: "The VLANs each cable carries, from its port or LAG." },
15
+ {
16
+ id: "l3",
17
+ label: "Addresses",
18
+ legend: "Prefixes two devices hold addresses in. Three or more share a segment, drawn as its own box.",
19
+ },
20
+ { id: "bgp", label: "BGP", legend: "Sessions between our devices, matched by address, and to each AS outside." },
21
+ { id: "ospf", label: "OSPF", legend: "Interfaces in one area and one prefix, neither passive." },
22
+ { id: "tunnels", label: "Tunnels", legend: "GRE and VXLAN tunnels matched by their endpoints, and WireGuard peers." },
23
+ ];
24
+
25
+ const layer = computed<Layer>(() => (LAYERS.some((item) => item.id === store.route.parts[0]) ? (store.route.parts[0] as Layer) : "l1"));
26
+ const counts = computed(() =>
27
+ Object.fromEntries(LAYERS.map((item) => [item.id, props.data.graph.edges.filter((edge) => edge.layer === item.id).length]))
28
+ );
29
+
30
+ const container = ref<HTMLElement>();
31
+ const selected = ref<{ node?: GraphNode; edge?: GraphEdge }>({});
32
+ let graph: Core | undefined;
33
+
34
+ /** Where each node was on each layer, kept while the page is open, so a node moved stays moved. */
35
+ const placed = new Map<string, Record<string, { x: number; y: number }>>();
36
+
37
+ const css = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
38
+
39
+ function elements(): ElementDefinition[] {
40
+ const edges = props.data.graph.edges.filter((edge) => edge.layer === layer.value);
41
+ const ends = new Set(edges.flatMap((edge) => [edge.from, edge.to]));
42
+ const levelOf = (node: GraphNode) => {
43
+ const findings = props.data.findings.filter((finding) => `device:${finding.device}` === node.id);
44
+ return findings.some((finding) => finding.level === "error") ? "error" : findings.length ? "warning" : "";
45
+ };
46
+
47
+ return [
48
+ ...props.data.graph.nodes
49
+ .filter((node) => node.kind === "device" || ends.has(node.id))
50
+ .map((node) => ({
51
+ group: "nodes" as const,
52
+ // A box as wide as its name.
53
+ data: { id: node.id, label: node.label, kind: node.kind, level: levelOf(node), width: node.label.length * 8.5 + 28 },
54
+ })),
55
+ ...edges.map((edge, index) => ({
56
+ group: "edges" as const,
57
+ data: {
58
+ id: `edge-${index}`,
59
+ source: edge.from,
60
+ target: edge.to,
61
+ label: edge.label,
62
+ oneSided: edge.oneSided ? "yes" : "no",
63
+ index,
64
+ },
65
+ })),
66
+ ];
67
+ }
68
+
69
+ function draw(): void {
70
+ if (!graph) return;
71
+ const positions = placed.get(layer.value);
72
+
73
+ graph.elements().remove();
74
+ graph.add(elements());
75
+ selected.value = {};
76
+
77
+ if (positions && graph.nodes().every((node) => positions[node.id()] !== undefined)) {
78
+ graph.nodes().forEach((node) => {
79
+ node.position(positions[node.id()]!);
80
+ });
81
+ fit();
82
+ } else {
83
+ layOut();
84
+ }
85
+ }
86
+
87
+ /** Fit the drawing to the window, but never larger than its own size: a network of two devices stays readable. */
88
+ function fit(): void {
89
+ if (!graph) return;
90
+ graph.fit(undefined, 60);
91
+ if (graph.zoom() > 1.2) {
92
+ graph.zoom(1.2);
93
+ graph.center();
94
+ }
95
+ }
96
+
97
+ function layOut(): void {
98
+ // From a circle rather than random places, so the same network is drawn the same way each time.
99
+ graph?.layout({ name: "circle", animate: false, fit: false }).run();
100
+ // Boxes are wide, so they push hard and keep a gap, or they land on each other and on the lines' labels.
101
+ graph
102
+ ?.layout({
103
+ name: "cose",
104
+ animate: false,
105
+ fit: false,
106
+ randomize: false,
107
+ nodeRepulsion: () => 160000,
108
+ nodeOverlap: 40,
109
+ nodeDimensionsIncludeLabels: true,
110
+ idealEdgeLength: () => 55,
111
+ edgeElasticity: () => 200,
112
+ gravity: 2.5,
113
+ numIter: 3000,
114
+ componentSpacing: 140,
115
+ })
116
+ .run();
117
+ fit();
118
+ remember();
119
+ }
120
+
121
+ function remember(): void {
122
+ if (!graph) return;
123
+ placed.set(layer.value, Object.fromEntries(graph.nodes().map((node) => [node.id(), { ...node.position() }])));
124
+ }
125
+
126
+ function saveImage(): void {
127
+ if (!graph) return;
128
+ const png = graph.png({ output: "blob", full: true, scale: 2, bg: css("--bg") }) as unknown as Blob;
129
+ Export.save(`${props.data.project}-${layer.value}.png`, png);
130
+ }
131
+
132
+ onMounted(() => {
133
+ graph = cytoscape({
134
+ container: container.value,
135
+ minZoom: 0.2,
136
+ maxZoom: 3,
137
+ style: [
138
+ {
139
+ selector: "node",
140
+ style: {
141
+ shape: "round-rectangle",
142
+ width: "data(width)",
143
+ height: 34,
144
+ "background-color": css("--surface"),
145
+ "border-width": 2,
146
+ "border-color": css("--text"),
147
+ label: "data(label)",
148
+ color: css("--text"),
149
+ "font-family": css("--font"),
150
+ "font-size": 13,
151
+ "font-weight": 700,
152
+ "text-valign": "center",
153
+ "text-halign": "center",
154
+ },
155
+ },
156
+ {
157
+ selector: 'node[kind = "segment"]',
158
+ style: { "background-color": css("--yellow"), "border-color": css("--navy"), color: "#11141b" },
159
+ },
160
+ {
161
+ selector: 'node[kind = "outside"]',
162
+ style: { "border-style": "dashed", "border-color": css("--edge"), color: css("--muted") },
163
+ },
164
+ { selector: 'node[level = "warning"]', style: { "border-color": css("--warning"), "border-width": 3 } },
165
+ { selector: 'node[level = "error"]', style: { "border-color": css("--error"), "border-width": 3 } },
166
+ {
167
+ selector: "edge",
168
+ style: {
169
+ width: 2,
170
+ "line-color": css("--edge"),
171
+ "curve-style": "bezier",
172
+ label: "data(label)",
173
+ "font-size": 11,
174
+ color: css("--muted"),
175
+ "text-background-color": css("--bg"),
176
+ "text-background-opacity": 1,
177
+ "text-background-padding": "2px",
178
+ "font-family": css("--font"),
179
+ },
180
+ },
181
+ { selector: 'edge[oneSided = "yes"]', style: { "line-style": "dashed", "line-color": css("--warning") } },
182
+ {
183
+ selector: ":selected",
184
+ style: { "border-color": css("--cobalt"), "line-color": css("--cobalt"), "border-width": 4, width: 4 },
185
+ },
186
+ ],
187
+ });
188
+
189
+ graph.on("tap", "node", (event) => {
190
+ selected.value = { node: props.data.graph.nodes.find((node) => node.id === event.target.id()) };
191
+ });
192
+ graph.on("tap", "edge", (event) => {
193
+ const index = event.target.data("index") as number;
194
+ selected.value = { edge: props.data.graph.edges.filter((edge) => edge.layer === layer.value)[index] };
195
+ });
196
+ graph.on("tap", (event) => {
197
+ if (event.target === graph) selected.value = {};
198
+ });
199
+ graph.on("dbltap", 'node[kind = "device"]', (event) => go(`devices/${event.target.data("label")}`));
200
+ graph.on("dragfree", "node", remember);
201
+
202
+ draw();
203
+ });
204
+
205
+ onBeforeUnmount(() => graph?.destroy());
206
+ watch([layer, () => props.data], draw);
207
+
208
+ const nodeFindings = computed(() =>
209
+ props.data.findings.filter((finding) => selected.value.node && `device:${finding.device}` === selected.value.node.id)
210
+ );
211
+ </script>
212
+
213
+ <template>
214
+ <section class="view">
215
+ <div class="toolbar">
216
+ <nav aria-label="Layers">
217
+ <button v-for="item in LAYERS" :key="item.id" :aria-pressed="layer === item.id" @click="go(`graph/${item.id}`)">
218
+ {{ item.label }} <small>{{ counts[item.id] }}</small>
219
+ </button>
220
+ </nav>
221
+ <div class="actions">
222
+ <button title="Fit the drawing to the window" @click="fit">Fit</button>
223
+ <button title="Forget where nodes were moved on this layer, and lay it out again" @click="layOut">Lay out again</button>
224
+ <button title="Save this layer as a PNG image" @click="saveImage">Save image</button>
225
+ </div>
226
+ </div>
227
+ <div class="stage">
228
+ <div ref="container" class="canvas" role="img" aria-label="The network"></div>
229
+ <aside>
230
+ <template v-if="selected.node">
231
+ <h2>{{ selected.node.label }}</h2>
232
+ <p class="kind muted">
233
+ {{
234
+ selected.node.kind === "device"
235
+ ? "a device"
236
+ : selected.node.kind === "segment"
237
+ ? "a shared segment"
238
+ : "outside the network"
239
+ }}
240
+ </p>
241
+ <ul>
242
+ <li v-for="line in selected.node.detail" :key="line">{{ line }}</li>
243
+ </ul>
244
+ <ul v-if="nodeFindings.length" class="findings">
245
+ <li v-for="(finding, index) in nodeFindings" :key="index">
246
+ <span :class="['badge', finding.level]">{{ finding.level }}</span
247
+ >{{ finding.message }}
248
+ </li>
249
+ </ul>
250
+ <p v-if="selected.node.kind === 'device'">
251
+ <a @click="go(`devices/${selected.node.label}`)">Open {{ selected.node.label }}</a>
252
+ </p>
253
+ </template>
254
+ <template v-else-if="selected.edge">
255
+ <h2>{{ selected.edge.label }}</h2>
256
+ <p v-if="selected.edge.oneSided" class="badge warning">declared at one end only</p>
257
+ <ul>
258
+ <li v-for="line in selected.edge.detail" :key="line">{{ line }}</li>
259
+ </ul>
260
+ </template>
261
+ <template v-else>
262
+ <h2>{{ LAYERS.find((item) => item.id === layer)?.label }}</h2>
263
+ <p class="muted">{{ LAYERS.find((item) => item.id === layer)?.legend }}</p>
264
+ <ul class="legend">
265
+ <li>A dashed line is declared at one end only.</li>
266
+ <li>A dashed box is outside the network.</li>
267
+ <li>A box framed in orange or red has warnings or errors.</li>
268
+ <li>Pick a box or a line to see what it is. Double-click a device to open it.</li>
269
+ </ul>
270
+ </template>
271
+ </aside>
272
+ </div>
273
+ </section>
274
+ </template>
275
+
276
+ <style scoped>
277
+ .canvas {
278
+ flex: 1;
279
+ min-width: 0;
280
+ height: 100%;
281
+ }
282
+ </style>
@@ -0,0 +1,24 @@
1
+ <!-- Two texts compared line by line: what is the same is folded away, except a few lines around each change. -->
2
+ <script setup lang="ts">
3
+ import { computed } from "vue";
4
+ import { Diff } from "../lib/diff";
5
+ import { Format } from "../lib/format";
6
+
7
+ const props = defineProps<{ before: string; after: string }>();
8
+
9
+ const lines = computed(() => Diff.lines(props.before.trimEnd().split("\n"), props.after.trimEnd().split("\n")));
10
+ const changed = computed(() => lines.value?.filter((line) => line.kind !== "same").length ?? 0);
11
+ const shown = computed(() => (lines.value ? Diff.shown(lines.value) : []));
12
+ const mark = (kind: string) => (kind === "added" ? "+ " : kind === "removed" ? "- " : " ");
13
+ </script>
14
+
15
+ <template>
16
+ <p v-if="!lines" class="muted">They differ in too many lines to compare one by one.</p>
17
+ <p v-else-if="!changed" class="muted">They are the same, line for line.</p>
18
+ <div v-else>
19
+ <p class="muted">{{ Format.count(changed, "line differs", "lines differ") }}.</p>
20
+ <pre
21
+ class="code diff"
22
+ ><template v-for="(line, index) in shown" :key="index"><span v-if="line.kind === 'fold'" class="fold">{{ Format.count(line.count, "line") }} the same{{ "\n" }}</span><span v-else :class="line.kind">{{ mark(line.kind) }}{{ line.text }}{{ "\n" }}</span></template></pre>
23
+ </div>
24
+ </template>
@@ -0,0 +1,42 @@
1
+ <!-- What `circuit validate` finds, errors first, and what only one end declares: cables and BGP sessions with nothing back. -->
2
+ <script setup lang="ts">
3
+ import { computed } from "vue";
4
+ import type { StudioData } from "../../../types";
5
+ import { go } from "../lib/store";
6
+
7
+ const props = defineProps<{ data: StudioData }>();
8
+
9
+ const findings = computed(() => [...props.data.findings].sort((a, b) => (a.level === b.level ? 0 : a.level === "error" ? -1 : 1)));
10
+ const oneSidedCables = computed(() => props.data.cables.filter((cable) => cable.oneSided));
11
+ const oneSidedSessions = computed(() => props.data.sessions.filter((session) => session.oneSided));
12
+ </script>
13
+
14
+ <template>
15
+ <h2>Problems</h2>
16
+ <h3>Found by <code>circuit validate</code></h3>
17
+ <p v-if="!findings.length" class="muted">Nothing: every device validates.</p>
18
+ <ul v-else class="findings">
19
+ <li v-for="(finding, index) in findings" :key="index">
20
+ <span :class="['badge', finding.level]">{{ finding.level }}</span>
21
+ <a v-if="finding.device" @click="go(`devices/${finding.device}`)">{{ finding.device }}</a
22
+ ><template v-if="finding.device">: </template>{{ finding.message }}
23
+ </li>
24
+ </ul>
25
+
26
+ <h3>Declared at one end only</h3>
27
+ <p v-if="!oneSidedCables.length && !oneSidedSessions.length" class="muted">
28
+ Every cable and BGP session between devices is declared at both ends.
29
+ </p>
30
+ <ul v-else class="findings">
31
+ <li v-for="cable in oneSidedCables" :key="`${cable.device} ${cable.port}`">
32
+ <span class="badge warning">cable</span>
33
+ <a @click="go(`devices/${cable.device}`)">{{ cable.device }}</a> {{ cable.port }} links to {{ cable.farDevice }}
34
+ {{ cable.farPort }}, which does not link back
35
+ </li>
36
+ <li v-for="session in oneSidedSessions" :key="`${session.device} ${session.name}`">
37
+ <span class="badge warning">BGP</span>
38
+ <a @click="go(`devices/${session.device}`)">{{ session.device }}</a> {{ session.name }} reaches {{ session.far }}, which has no
39
+ session back
40
+ </li>
41
+ </ul>
42
+ </template>
@@ -0,0 +1,72 @@
1
+ <!-- What the search finds: devices, ports and interfaces by name or description, addresses, VLANs, BGP neighbors, and lines of each device's config. -->
2
+ <script setup lang="ts">
3
+ import { computed } from "vue";
4
+ import type { StudioData } from "../../../types";
5
+ import { Format } from "../lib/format";
6
+ import DataTable, { type Row } from "./DataTable.vue";
7
+
8
+ const props = defineProps<{ data: StudioData; query: string }>();
9
+
10
+ const found = computed<Row[]>(() => {
11
+ const query = props.query.toLowerCase();
12
+ if (!query) return [];
13
+ const has = (text: unknown) => typeof text === "string" && text.toLowerCase().includes(query);
14
+ const rows: Row[] = [];
15
+
16
+ for (const device of props.data.devices) {
17
+ if (has(device.name) || has(device.site) || has(device.model)) {
18
+ rows.push({ cells: [{ device: device.name }, "device", `${device.platform} ${device.model} at ${device.site}`] });
19
+ }
20
+
21
+ for (const [kind, group] of Object.entries(device.interfaces ?? {})) {
22
+ for (const [name, settings] of Object.entries((group ?? {}) as Record<string, { description?: string } | undefined>)) {
23
+ if (has(name) || has(settings?.description)) {
24
+ rows.push({
25
+ cells: [
26
+ { device: device.name },
27
+ kind === "ethernet" ? "port" : kind.replace("_", " "),
28
+ `${name}${settings?.description ? `: ${settings.description}` : ""}`,
29
+ ],
30
+ });
31
+ }
32
+ }
33
+ }
34
+
35
+ for (const [name, vlan] of Object.entries(device.switching?.vlans ?? {})) {
36
+ if (has(name) || has(vlan?.description) || has(String(vlan?.id)))
37
+ rows.push({ cells: [{ device: device.name }, "VLAN", `${name}, ID ${vlan?.id}`] });
38
+ }
39
+
40
+ for (const [name, neighbor] of Object.entries(device.routing?.bgp?.neighbors ?? {})) {
41
+ if (has(name) || has(neighbor?.description) || has(neighbor?.address)) {
42
+ rows.push({ cells: [{ device: device.name }, "BGP neighbor", `${name}, ${neighbor?.address}`] });
43
+ }
44
+ }
45
+
46
+ const lines = (props.data.rendered[device.name] ?? "").split("\n").filter(has);
47
+ if (lines.length) {
48
+ rows.push({
49
+ cells: [
50
+ { link: `devices/${device.name}/config`, label: device.name },
51
+ "config",
52
+ `${Format.count(lines.length, "line")}: ${lines
53
+ .slice(0, 3)
54
+ .map((line) => line.trim())
55
+ .join(" · ")}${lines.length > 3 ? " …" : ""}`,
56
+ ],
57
+ });
58
+ }
59
+ }
60
+
61
+ for (const row of props.data.addresses) {
62
+ if (has(row.address)) rows.push({ cells: [{ device: row.device }, "address", `${row.address} on ${row.holder}`] });
63
+ }
64
+
65
+ return rows;
66
+ });
67
+ </script>
68
+
69
+ <template>
70
+ <h2>Search: {{ query }}</h2>
71
+ <DataTable :head="['Device', 'What', 'Found']" :rows="found" />
72
+ </template>
@@ -0,0 +1,112 @@
1
+ <!-- The secrets, file by file: each name and how it is kept. To see the values, a person types the file's password; they are decrypted here, in the browser, and the password is never sent or kept. -->
2
+ <script setup lang="ts">
3
+ import { onMounted, reactive, ref } from "vue";
4
+ import type { SecretsFileListing, SecretsListing } from "../../../types";
5
+ import { SecretsCrypto } from "../lib/secrets-crypto";
6
+ import { api } from "../lib/store";
7
+
8
+ const listing = ref<SecretsListing>();
9
+ const loadError = ref("");
10
+ /** By file: the password being typed, why it failed, whether it is being tried, and the values it opened. */
11
+ const typed = reactive<Record<string, string>>({});
12
+ const failed = reactive<Record<string, string>>({});
13
+ const busy = reactive<Record<string, boolean>>({});
14
+ const shown = reactive<Record<string, Record<string, string>>>({});
15
+
16
+ onMounted(async () => {
17
+ try {
18
+ listing.value = await api<SecretsListing>("/api/secrets");
19
+ } catch (error) {
20
+ loadError.value = (error as Error).message;
21
+ }
22
+ });
23
+
24
+ const encryptedOf = (file: SecretsFileListing) => file.entries.filter((entry) => entry.kind === "encrypted");
25
+
26
+ /** Decrypt every encrypted value of a file with the typed password, then forget the password. */
27
+ async function reveal(file: SecretsFileListing): Promise<void> {
28
+ failed[file.file] = "";
29
+ const crypto = new SecretsCrypto(typed[file.file] ?? "");
30
+ typed[file.file] = "";
31
+ busy[file.file] = true;
32
+
33
+ try {
34
+ const values: Record<string, string> = {};
35
+ for (const entry of encryptedOf(file)) values[entry.name] = await crypto.decrypt(entry.name, entry.ciphertext!);
36
+ shown[file.file] = values;
37
+ } catch {
38
+ failed[file.file] = `That password does not open ${file.file}.`;
39
+ } finally {
40
+ busy[file.file] = false;
41
+ }
42
+ }
43
+
44
+ const KIND: Record<string, string> = {
45
+ encrypted: "encrypted",
46
+ "1password": "kept in 1Password",
47
+ empty: "empty",
48
+ "not encrypted": "NOT ENCRYPTED: run circuit secrets encrypt",
49
+ missing: "missing: the project needs it",
50
+ };
51
+ </script>
52
+
53
+ <template>
54
+ <h2>Secrets</h2>
55
+ <p class="muted">
56
+ Each secret, and how it is kept. Add or change one with <code>circuit secrets</code>. A value is never sent to this page in plain
57
+ text.
58
+ </p>
59
+ <p v-if="loadError" class="verdict reject">{{ loadError }}</p>
60
+
61
+ <section v-for="file in listing?.files ?? []" :key="file.file">
62
+ <h3>
63
+ <code>{{ file.file }}</code>
64
+ </h3>
65
+ <p class="muted">
66
+ {{ file.networks.length > 1 ? "Networks" : "Network" }} {{ file.networks.join(", ") }}. Its password:
67
+ {{ file.passwordLocation ?? "typed when Circuit asks" }}.
68
+ </p>
69
+
70
+ <form v-if="encryptedOf(file).length" class="tool-form" autocomplete="off" @submit.prevent="reveal(file)">
71
+ <label class="field"
72
+ >The password of {{ file.file }}
73
+ <input v-model="typed[file.file]" type="password" autocomplete="off" spellcheck="false" />
74
+ <small>Used here in the browser to decrypt, then forgotten. It is never sent anywhere or kept.</small>
75
+ </label>
76
+ <button class="primary" type="submit" :disabled="busy[file.file]">
77
+ {{ busy[file.file] ? "Opening…" : "Show the values" }}
78
+ </button>
79
+ <button v-if="shown[file.file]" type="button" @click="delete shown[file.file]">Hide them</button>
80
+ </form>
81
+ <p v-if="failed[file.file]" class="verdict reject">{{ failed[file.file] }}</p>
82
+
83
+ <p v-if="!file.entries.length" class="muted">No secrets yet.</p>
84
+ <table v-else class="data">
85
+ <thead>
86
+ <tr>
87
+ <th>Name</th>
88
+ <th>Kept</th>
89
+ <th>Value</th>
90
+ </tr>
91
+ </thead>
92
+ <tbody>
93
+ <tr v-for="entry in file.entries" :key="entry.name">
94
+ <td>
95
+ <code>{{ entry.name }}</code>
96
+ </td>
97
+ <td>
98
+ <span v-if="entry.kind === 'not encrypted' || entry.kind === 'missing'" class="badge error">{{
99
+ KIND[entry.kind]
100
+ }}</span>
101
+ <template v-else>{{ KIND[entry.kind] }}</template>
102
+ </td>
103
+ <td>
104
+ <template v-if="entry.onePassword">vault {{ entry.onePassword.vault }}, item {{ entry.onePassword.item }}</template>
105
+ <code v-else-if="shown[file.file]?.[entry.name] !== undefined">{{ shown[file.file]![entry.name] }}</code>
106
+ <span v-else class="muted">{{ entry.kind === "encrypted" ? "hidden" : "" }}</span>
107
+ </td>
108
+ </tr>
109
+ </tbody>
110
+ </table>
111
+ </section>
112
+ </template>