@isothrum/editor 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/PUBLISHING.md +93 -0
  4. package/README.md +106 -2
  5. package/desktop-editor/editor.css +11 -0
  6. package/desktop-editor/index.html +14 -0
  7. package/desktop-editor/main.tsx +26 -0
  8. package/dist/desktop/editor.css +1 -0
  9. package/dist/desktop/editor.js +181 -0
  10. package/dist/desktop/fonts/geist-cyrillic-ext-wght-normal-RO72NYA2.woff2 +0 -0
  11. package/dist/desktop/fonts/geist-cyrillic-wght-normal-CK2XY27K.woff2 +0 -0
  12. package/dist/desktop/fonts/geist-latin-ext-wght-normal-XEQ56JUF.woff2 +0 -0
  13. package/dist/desktop/fonts/geist-latin-wght-normal-T72VVCUW.woff2 +0 -0
  14. package/dist/desktop/fonts/geist-mono-cyrillic-ext-wght-normal-QXKVIKFD.woff2 +0 -0
  15. package/dist/desktop/fonts/geist-mono-cyrillic-wght-normal-GOIAQAGU.woff2 +0 -0
  16. package/dist/desktop/fonts/geist-mono-latin-ext-wght-normal-HU3ZAWN6.woff2 +0 -0
  17. package/dist/desktop/fonts/geist-mono-latin-wght-normal-CJNQKXUG.woff2 +0 -0
  18. package/dist/desktop/fonts/geist-mono-symbols2-wght-normal-WZJJVLQO.woff2 +0 -0
  19. package/dist/desktop/fonts/geist-mono-vietnamese-wght-normal-KW5NM6YV.woff2 +0 -0
  20. package/dist/desktop/fonts/geist-vietnamese-wght-normal-Y2R7JZKV.woff2 +0 -0
  21. package/dist/desktop/fonts/shantell-sans-cyrillic-ext-wght-normal-ILPQWRJJ.woff2 +0 -0
  22. package/dist/desktop/fonts/shantell-sans-cyrillic-wght-normal-RKC6NDPM.woff2 +0 -0
  23. package/dist/desktop/fonts/shantell-sans-latin-ext-wght-normal-SNKHG3LM.woff2 +0 -0
  24. package/dist/desktop/fonts/shantell-sans-latin-wght-normal-7CHHGC3N.woff2 +0 -0
  25. package/dist/desktop/fonts/shantell-sans-vietnamese-wght-normal-IPFAQSBA.woff2 +0 -0
  26. package/dist/desktop/index.html +14 -0
  27. package/dist/desktop/styles.css +2 -0
  28. package/dist/desktop/wasm/isothrum_wasm.d.ts +60 -0
  29. package/dist/desktop/wasm/isothrum_wasm.js +312 -0
  30. package/dist/desktop/wasm/isothrum_wasm_bg.wasm +0 -0
  31. package/dist/index.js +7059 -0
  32. package/dist/index.js.map +7 -0
  33. package/dist/model.js +745 -0
  34. package/dist/model.js.map +7 -0
  35. package/dist/types/components/tool/canvas/cable-edge.d.ts +4 -0
  36. package/dist/types/components/tool/canvas/contexts.d.ts +12 -0
  37. package/dist/types/components/tool/canvas/device-icon.d.ts +17 -0
  38. package/dist/types/components/tool/canvas/inline-text.d.ts +15 -0
  39. package/dist/types/components/tool/canvas/lan-node.d.ts +8 -0
  40. package/dist/types/components/tool/canvas/machine-addresses.d.ts +14 -0
  41. package/dist/types/components/tool/canvas/machine-node.d.ts +4 -0
  42. package/dist/types/components/tool/canvas/note-node.d.ts +7 -0
  43. package/dist/types/components/tool/canvas/port-led.d.ts +10 -0
  44. package/dist/types/components/tool/canvas/router-node.d.ts +8 -0
  45. package/dist/types/components/tool/canvas/sketch.d.ts +50 -0
  46. package/dist/types/components/tool/canvas/status-dot.d.ts +5 -0
  47. package/dist/types/components/tool/canvas/switch-node.d.ts +8 -0
  48. package/dist/types/components/tool/canvas/use-measured-ports.d.ts +7 -0
  49. package/dist/types/components/tool/context-menus.d.ts +5 -0
  50. package/dist/types/components/tool/device-block.d.ts +12 -0
  51. package/dist/types/components/tool/editor-menu.d.ts +28 -0
  52. package/dist/types/components/tool/export/copy-button.d.ts +9 -0
  53. package/dist/types/components/tool/export/export-panel.d.ts +17 -0
  54. package/dist/types/components/tool/export/target-list.d.ts +15 -0
  55. package/dist/types/components/tool/floating-window.d.ts +13 -0
  56. package/dist/types/components/tool/flow-canvas.d.ts +6 -0
  57. package/dist/types/components/tool/hooks/use-cabling.d.ts +23 -0
  58. package/dist/types/components/tool/hooks/use-captures.d.ts +9 -0
  59. package/dist/types/components/tool/hooks/use-clipboard.d.ts +12 -0
  60. package/dist/types/components/tool/hooks/use-context-menus.d.ts +25 -0
  61. package/dist/types/components/tool/hooks/use-design.d.ts +10 -0
  62. package/dist/types/components/tool/hooks/use-desktop.d.ts +50 -0
  63. package/dist/types/components/tool/hooks/use-devices.d.ts +17 -0
  64. package/dist/types/components/tool/hooks/use-downloads.d.ts +9 -0
  65. package/dist/types/components/tool/hooks/use-editor.d.ts +609 -0
  66. package/dist/types/components/tool/hooks/use-engine.d.ts +9 -0
  67. package/dist/types/components/tool/hooks/use-history.d.ts +9 -0
  68. package/dist/types/components/tool/hooks/use-lab-actions.d.ts +25 -0
  69. package/dist/types/components/tool/hooks/use-lab-run.d.ts +25 -0
  70. package/dist/types/components/tool/hooks/use-lab.d.ts +18 -0
  71. package/dist/types/components/tool/hooks/use-lan-carry.d.ts +8 -0
  72. package/dist/types/components/tool/hooks/use-machine-actions.d.ts +26 -0
  73. package/dist/types/components/tool/hooks/use-placement.d.ts +27 -0
  74. package/dist/types/components/tool/hooks/use-project.d.ts +25 -0
  75. package/dist/types/components/tool/hooks/use-projects.d.ts +34 -0
  76. package/dist/types/components/tool/hooks/use-shared-link.d.ts +23 -0
  77. package/dist/types/components/tool/hooks/use-toast.d.ts +2 -0
  78. package/dist/types/components/tool/hooks/use-tools.d.ts +17 -0
  79. package/dist/types/components/tool/lab-canvas.d.ts +18 -0
  80. package/dist/types/components/tool/overlays.d.ts +5 -0
  81. package/dist/types/components/tool/projects-list.d.ts +9 -0
  82. package/dist/types/components/tool/text.d.ts +418 -0
  83. package/dist/types/components/tool/toolbar.d.ts +5 -0
  84. package/dist/types/components/tool/top-bars.d.ts +10 -0
  85. package/dist/types/components/tool/windows/conditions-fields.d.ts +6 -0
  86. package/dist/types/components/tool/windows/editor-windows.d.ts +5 -0
  87. package/dist/types/components/tool/windows/fields.d.ts +4 -0
  88. package/dist/types/components/tool/windows/lab-status.d.ts +6 -0
  89. package/dist/types/components/tool/windows/lab-windows.d.ts +13 -0
  90. package/dist/types/components/tool/windows/link-controls.d.ts +21 -0
  91. package/dist/types/components/tool/windows/machine-controls.d.ts +16 -0
  92. package/dist/types/components/tool/windows/machine-inspector.d.ts +11 -0
  93. package/dist/types/components/tool/windows/runs-fields.d.ts +18 -0
  94. package/dist/types/components/tool/windows/selected-windows.d.ts +21 -0
  95. package/dist/types/components/ui/button.d.ts +8 -0
  96. package/dist/types/components/ui/dropdown-menu.d.ts +21 -0
  97. package/dist/types/index.d.ts +7 -0
  98. package/dist/types/lib/auth.d.ts +38 -0
  99. package/dist/types/lib/brand.d.ts +19 -0
  100. package/dist/types/lib/i18n.d.ts +19 -0
  101. package/dist/types/lib/tool/blocks.d.ts +35 -0
  102. package/dist/types/lib/tool/cables.d.ts +48 -0
  103. package/dist/types/lib/tool/clone.d.ts +12 -0
  104. package/dist/types/lib/tool/conditions.d.ts +31 -0
  105. package/dist/types/lib/tool/create.d.ts +22 -0
  106. package/dist/types/lib/tool/desktop.d.ts +63 -0
  107. package/dist/types/lib/tool/draft.d.ts +20 -0
  108. package/dist/types/lib/tool/engine.d.ts +33 -0
  109. package/dist/types/lib/tool/examples.d.ts +11 -0
  110. package/dist/types/lib/tool/export-targets.d.ts +21 -0
  111. package/dist/types/lib/tool/facing.d.ts +9 -0
  112. package/dist/types/lib/tool/geometry.d.ts +36 -0
  113. package/dist/types/lib/tool/gns3-cables.d.ts +17 -0
  114. package/dist/types/lib/tool/gns3-import.d.ts +15 -0
  115. package/dist/types/lib/tool/gns3-parts.d.ts +25 -0
  116. package/dist/types/lib/tool/issues.d.ts +12 -0
  117. package/dist/types/lib/tool/layout.d.ts +52 -0
  118. package/dist/types/lib/tool/live.d.ts +21 -0
  119. package/dist/types/lib/tool/logos.d.ts +7 -0
  120. package/dist/types/lib/tool/model.d.ts +173 -0
  121. package/dist/types/lib/tool/os.d.ts +20 -0
  122. package/dist/types/lib/tool/pins.d.ts +5 -0
  123. package/dist/types/lib/tool/placement.d.ts +21 -0
  124. package/dist/types/lib/tool/ports.d.ts +21 -0
  125. package/dist/types/lib/tool/presets.d.ts +21 -0
  126. package/dist/types/lib/tool/projects-api.d.ts +37 -0
  127. package/dist/types/lib/tool/router-ports.d.ts +22 -0
  128. package/dist/types/lib/tool/selection.d.ts +19 -0
  129. package/dist/types/lib/tool/storage.d.ts +25 -0
  130. package/dist/types/lib/tool/switch-edit.d.ts +17 -0
  131. package/dist/types/lib/tool/text/de.d.ts +2 -0
  132. package/dist/types/lib/tool/text/en.d.ts +429 -0
  133. package/dist/types/lib/tool/text/es.d.ts +2 -0
  134. package/dist/types/lib/tool/text/fr.d.ts +2 -0
  135. package/dist/types/lib/tool/text/index.d.ts +419 -0
  136. package/dist/types/lib/tool/topology.d.ts +2 -0
  137. package/dist/types/lib/tool/vlans.d.ts +24 -0
  138. package/dist/types/lib/utils.d.ts +2 -0
  139. package/dist/types/model.d.ts +13 -0
  140. package/package.json +88 -4
  141. package/scripts/build-desktop-editor.mjs +43 -0
  142. package/scripts/build-lib.mjs +38 -0
  143. package/src/components/tool/canvas/cable-edge.tsx +112 -0
  144. package/src/components/tool/canvas/contexts.ts +18 -0
  145. package/src/components/tool/canvas/device-icon.tsx +105 -0
  146. package/src/components/tool/canvas/inline-text.tsx +69 -0
  147. package/src/components/tool/canvas/lan-node.tsx +148 -0
  148. package/src/components/tool/canvas/machine-addresses.tsx +117 -0
  149. package/src/components/tool/canvas/machine-node.tsx +114 -0
  150. package/src/components/tool/canvas/note-node.tsx +109 -0
  151. package/src/components/tool/canvas/port-led.tsx +21 -0
  152. package/src/components/tool/canvas/router-node.tsx +114 -0
  153. package/src/components/tool/canvas/sketch.tsx +130 -0
  154. package/src/components/tool/canvas/status-dot.tsx +29 -0
  155. package/src/components/tool/canvas/switch-node.tsx +143 -0
  156. package/src/components/tool/canvas/use-measured-ports.ts +25 -0
  157. package/src/components/tool/context-menus.tsx +142 -0
  158. package/src/components/tool/device-block.tsx +107 -0
  159. package/src/components/tool/editor-menu.tsx +191 -0
  160. package/src/components/tool/export/copy-button.tsx +45 -0
  161. package/src/components/tool/export/export-panel.tsx +127 -0
  162. package/src/components/tool/export/target-list.tsx +87 -0
  163. package/src/components/tool/floating-window.tsx +69 -0
  164. package/src/components/tool/flow-canvas.tsx +138 -0
  165. package/src/components/tool/hooks/use-cabling.ts +66 -0
  166. package/src/components/tool/hooks/use-captures.ts +24 -0
  167. package/src/components/tool/hooks/use-clipboard.ts +54 -0
  168. package/src/components/tool/hooks/use-context-menus.ts +37 -0
  169. package/src/components/tool/hooks/use-design.ts +24 -0
  170. package/src/components/tool/hooks/use-desktop.ts +100 -0
  171. package/src/components/tool/hooks/use-devices.ts +58 -0
  172. package/src/components/tool/hooks/use-downloads.ts +42 -0
  173. package/src/components/tool/hooks/use-editor.ts +114 -0
  174. package/src/components/tool/hooks/use-engine.ts +29 -0
  175. package/src/components/tool/hooks/use-history.ts +107 -0
  176. package/src/components/tool/hooks/use-lab-actions.ts +57 -0
  177. package/src/components/tool/hooks/use-lab-run.ts +61 -0
  178. package/src/components/tool/hooks/use-lab.ts +32 -0
  179. package/src/components/tool/hooks/use-lan-carry.ts +43 -0
  180. package/src/components/tool/hooks/use-machine-actions.ts +57 -0
  181. package/src/components/tool/hooks/use-placement.ts +80 -0
  182. package/src/components/tool/hooks/use-project.ts +112 -0
  183. package/src/components/tool/hooks/use-projects.ts +85 -0
  184. package/src/components/tool/hooks/use-shared-link.ts +64 -0
  185. package/src/components/tool/hooks/use-toast.ts +16 -0
  186. package/src/components/tool/hooks/use-tools.ts +60 -0
  187. package/src/components/tool/lab-canvas.tsx +96 -0
  188. package/src/components/tool/overlays.tsx +104 -0
  189. package/src/components/tool/projects-list.tsx +96 -0
  190. package/src/components/tool/text.ts +9 -0
  191. package/src/components/tool/toolbar.tsx +106 -0
  192. package/src/components/tool/top-bars.tsx +90 -0
  193. package/src/components/tool/windows/conditions-fields.tsx +43 -0
  194. package/src/components/tool/windows/editor-windows.tsx +73 -0
  195. package/src/components/tool/windows/fields.ts +6 -0
  196. package/src/components/tool/windows/lab-status.tsx +68 -0
  197. package/src/components/tool/windows/lab-windows.tsx +130 -0
  198. package/src/components/tool/windows/link-controls.tsx +69 -0
  199. package/src/components/tool/windows/machine-controls.tsx +116 -0
  200. package/src/components/tool/windows/machine-inspector.tsx +83 -0
  201. package/src/components/tool/windows/runs-fields.tsx +127 -0
  202. package/src/components/tool/windows/selected-windows.tsx +147 -0
  203. package/src/components/ui/button.tsx +59 -0
  204. package/src/components/ui/dropdown-menu.tsx +185 -0
  205. package/src/index.ts +21 -0
  206. package/src/lib/auth.tsx +43 -0
  207. package/src/lib/brand.tsx +46 -0
  208. package/src/lib/i18n.ts +53 -0
  209. package/src/lib/tool/blocks.test.ts +64 -0
  210. package/src/lib/tool/blocks.ts +78 -0
  211. package/src/lib/tool/cable-wiring.test.ts +74 -0
  212. package/src/lib/tool/cables.test.ts +75 -0
  213. package/src/lib/tool/cables.ts +215 -0
  214. package/src/lib/tool/clone.test.ts +54 -0
  215. package/src/lib/tool/clone.ts +48 -0
  216. package/src/lib/tool/conditions.test.ts +170 -0
  217. package/src/lib/tool/conditions.ts +132 -0
  218. package/src/lib/tool/create.test.ts +67 -0
  219. package/src/lib/tool/create.ts +83 -0
  220. package/src/lib/tool/desktop.test.ts +59 -0
  221. package/src/lib/tool/desktop.ts +99 -0
  222. package/src/lib/tool/draft.test.ts +150 -0
  223. package/src/lib/tool/draft.ts +109 -0
  224. package/src/lib/tool/engine.test.ts +39 -0
  225. package/src/lib/tool/engine.ts +58 -0
  226. package/src/lib/tool/examples.test.ts +35 -0
  227. package/src/lib/tool/examples.ts +71 -0
  228. package/src/lib/tool/export-targets.test.ts +47 -0
  229. package/src/lib/tool/export-targets.ts +84 -0
  230. package/src/lib/tool/facing.test.ts +72 -0
  231. package/src/lib/tool/facing.ts +100 -0
  232. package/src/lib/tool/geometry.test.ts +80 -0
  233. package/src/lib/tool/geometry.ts +109 -0
  234. package/src/lib/tool/gns3-cables.test.ts +47 -0
  235. package/src/lib/tool/gns3-cables.ts +81 -0
  236. package/src/lib/tool/gns3-import.test.ts +102 -0
  237. package/src/lib/tool/gns3-import.ts +198 -0
  238. package/src/lib/tool/gns3-layout.test.ts +62 -0
  239. package/src/lib/tool/gns3-parts.test.ts +47 -0
  240. package/src/lib/tool/gns3-parts.ts +68 -0
  241. package/src/lib/tool/issues.test.ts +35 -0
  242. package/src/lib/tool/issues.ts +60 -0
  243. package/src/lib/tool/layout.test.ts +128 -0
  244. package/src/lib/tool/layout.ts +229 -0
  245. package/src/lib/tool/live.test.ts +46 -0
  246. package/src/lib/tool/live.ts +49 -0
  247. package/src/lib/tool/logos.test.ts +30 -0
  248. package/src/lib/tool/logos.ts +99 -0
  249. package/src/lib/tool/model.test.ts +128 -0
  250. package/src/lib/tool/model.ts +238 -0
  251. package/src/lib/tool/os.test.ts +45 -0
  252. package/src/lib/tool/os.ts +57 -0
  253. package/src/lib/tool/parity.test.ts +145 -0
  254. package/src/lib/tool/pins.test.ts +24 -0
  255. package/src/lib/tool/pins.ts +11 -0
  256. package/src/lib/tool/placement.test.ts +79 -0
  257. package/src/lib/tool/placement.ts +60 -0
  258. package/src/lib/tool/ports.test.ts +39 -0
  259. package/src/lib/tool/ports.ts +48 -0
  260. package/src/lib/tool/presets.test.ts +50 -0
  261. package/src/lib/tool/presets.ts +77 -0
  262. package/src/lib/tool/projects-api.test.ts +59 -0
  263. package/src/lib/tool/projects-api.ts +47 -0
  264. package/src/lib/tool/router-ports.test.ts +63 -0
  265. package/src/lib/tool/router-ports.ts +88 -0
  266. package/src/lib/tool/selection.test.ts +44 -0
  267. package/src/lib/tool/selection.ts +34 -0
  268. package/src/lib/tool/storage.test.ts +123 -0
  269. package/src/lib/tool/storage.ts +127 -0
  270. package/src/lib/tool/switch-edit.test.ts +49 -0
  271. package/src/lib/tool/switch-edit.ts +38 -0
  272. package/src/lib/tool/text/de.ts +358 -0
  273. package/src/lib/tool/text/en.ts +371 -0
  274. package/src/lib/tool/text/es.ts +358 -0
  275. package/src/lib/tool/text/fr.ts +358 -0
  276. package/src/lib/tool/text/index.ts +21 -0
  277. package/src/lib/tool/text/text.test.ts +33 -0
  278. package/src/lib/tool/topology.test.ts +108 -0
  279. package/src/lib/tool/topology.ts +116 -0
  280. package/src/lib/tool/vlans.test.ts +64 -0
  281. package/src/lib/tool/vlans.ts +79 -0
  282. package/src/lib/utils.ts +6 -0
  283. package/src/model.ts +13 -0
  284. package/src/test/arbitrary.ts +41 -0
  285. package/src/test/engine.ts +48 -0
  286. package/src/test/lab.ts +101 -0
  287. package/styles/globals.css +258 -0
  288. package/styles/theme.css +192 -0
  289. package/tsconfig.build.json +12 -0
  290. package/tsconfig.json +22 -0
  291. package/wasm/isothrum_wasm.d.ts +60 -0
  292. package/wasm/isothrum_wasm.js +312 -0
  293. package/wasm/isothrum_wasm_bg.wasm +0 -0
@@ -0,0 +1,79 @@
1
+ import type { NodeChange } from "@xyflow/react";
2
+ import { describe, expect, test } from "vitest";
3
+ import type { LabNode } from "./model";
4
+ import { paletteSpot, placeAppliance, placeMachine, placeSwitch, snapMoves } from "./placement";
5
+ import { PRESETS, type Preset } from "./presets";
6
+ import { autoRouter, lan, machine, switchNode } from "@/test/lab";
7
+
8
+ const preset = (id: string): Preset => PRESETS.find((p) => p.id === id) ?? PRESETS[0];
9
+ /** The 400 by 300 office LAN at the origin, selected. */
10
+ const office = { ...lan("office"), selected: true };
11
+ const far = { x: 1000, y: 1000 };
12
+
13
+ test("paletteSpot staggers devices placed from the palette around the view's center", () => {
14
+ expect(paletteSpot({ x: 500, y: 400 }, [])).toEqual({ x: 444, y: 360 });
15
+ expect(paletteSpot({ x: 500, y: 400 }, Array.from({ length: 6 }, (_, i) => machine(String(i))))).toEqual({ x: 468, y: 384 });
16
+ });
17
+
18
+ describe("placeAppliance", () => {
19
+ test("lands on a LAN's border when placed near it, the rest unselected", () => {
20
+ const placed = placeAppliance(preset("router"), { x: 330, y: 100 }, false, [office], [office]);
21
+ expect(placed.map((n) => [n.type, n.selected])).toEqual([
22
+ ["lan", false],
23
+ ["machine", true],
24
+ ]);
25
+ expect(placed[1].position).toEqual({ x: 340, y: 100 });
26
+ });
27
+
28
+ test("comes with a LAN of its own under it, away from any LAN", () => {
29
+ const [newLan, , router] = placeAppliance(preset("router"), far, false, [office], [office]);
30
+ expect(newLan.type === "lan" && newLan.data).toMatchObject({ name: "lan1", cidr: "10.10.2.0/24" });
31
+ expect(newLan.position.y).toBe(router.position.y + 58);
32
+ });
33
+
34
+ test("centers a dropped router you configure on the point, as a machine would be", () => {
35
+ const placed = placeAppliance(preset("router-frr"), far, true, [], []);
36
+ expect(placed.at(-1)?.position).toEqual({ x: 976, y: 1021 });
37
+ });
38
+ });
39
+
40
+ test("placeMachine adds the machine selected, the rest unselected", () => {
41
+ const placed = placeMachine(preset("web"), { x: 5, y: 5 }, [{ ...machine("old"), selected: true }]);
42
+ expect(placed.map((n) => [n.type !== "note" && n.data.name, n.selected])).toEqual([
43
+ ["old", false],
44
+ ["web1", true],
45
+ ]);
46
+ });
47
+
48
+ describe("placeSwitch", () => {
49
+ test("lands on a LAN's border when placed near it", () => {
50
+ const placed = placeSwitch({ x: 217, y: 293 }, [office], [office]);
51
+ expect(placed).toHaveLength(2);
52
+ expect(placed[1].position).toEqual({ x: 47, y: 263 });
53
+ });
54
+
55
+ test("comes with its first VLAN's LAN hanging under it, away from any LAN", () => {
56
+ const [vlanLan] = placeSwitch(far, [], []);
57
+ expect(vlanLan).toMatchObject({ type: "lan", width: 600, height: 300, data: { name: "vlan10", cidr: "10.10.10.0/24" } });
58
+ });
59
+ });
60
+
61
+ describe("snapMoves", () => {
62
+ const nodes: LabNode[] = [office, autoRouter("r"), switchNode("sw"), machine("web")];
63
+ const move = (id: string, position = { x: 330, y: 100 }): NodeChange<LabNode> => ({ type: "position", id, position });
64
+
65
+ test("routers and firewalls dragged near a LAN's border land on it", () => {
66
+ expect(snapMoves([move("r")], nodes, [office])[0]).toEqual(move("r", { x: 340, y: 100 }));
67
+ });
68
+
69
+ test("switches land on it too, by their own size", () => {
70
+ expect(snapMoves([move("sw", { x: 47, y: 250 })], nodes, [office])[0]).toEqual(move("sw", { x: 47, y: 263 }));
71
+ });
72
+
73
+ test("other machines, and other changes, are left as they are", () => {
74
+ const changes: NodeChange<LabNode>[] = [move("web"), { type: "select", id: "r", selected: true }];
75
+ const snapped = snapMoves(changes, nodes, [office]);
76
+ expect(snapped[0]).toBe(changes[0]);
77
+ expect(snapped[1]).toBe(changes[1]);
78
+ });
79
+ });
@@ -0,0 +1,60 @@
1
+ import type { NodeChange, XYPosition } from "@xyflow/react";
2
+ import { lanBelow, newMachine, newSwitch } from "./create";
3
+ import { snapToLanEdge } from "./geometry";
4
+ import { MACHINE_H, MACHINE_W, ROUTER_H, ROUTER_W, SWITCH_H, SWITCH_W, isAppliance, isRealRouter, isSwitch, sizeOf, unselected, type LabNode, type LanNode } from "./model";
5
+ import type { Preset } from "./presets";
6
+ import { vlanName } from "./vlans";
7
+
8
+ /** Placing devices: where they land, and what comes with them. */
9
+
10
+ /** A device placed from the palette (no point given): in the middle of the view, staggered so machines added in a row don't stack exactly. */
11
+ export function paletteSpot(viewCenter: XYPosition, nodes: LabNode[]): XYPosition {
12
+ const step = (nodes.length % 5) * 24;
13
+ return { x: viewCenter.x - 56 + step, y: viewCenter.y - 40 + step };
14
+ }
15
+
16
+ /**
17
+ * The nodes after placing a router or firewall at `position`: near a LAN's border it lands on
18
+ * it, as when dragged; away from any LAN it comes with its own LAN under it (it is that LAN's
19
+ * gateway). `dropped`: placed at a point (a click, a drop), centered there like a machine.
20
+ */
21
+ export function placeAppliance(preset: Preset, position: XYPosition, dropped: boolean, nodes: LabNode[], lans: LanNode[]): LabNode[] {
22
+ const real = isRealRouter({ id: "", type: "machine", position, data: { ...preset.data, name: "" } });
23
+ const size = real ? { w: ROUTER_W, h: ROUTER_H } : { w: MACHINE_W, h: MACHINE_H };
24
+ const at = dropped ? { x: position.x + MACHINE_W / 2 - size.w / 2, y: position.y + MACHINE_H / 2 - size.h / 2 } : position;
25
+ const snapped = snapToLanEdge(at, lans, size);
26
+ const m = { ...newMachine(preset, snapped, nodes), selected: true };
27
+ if (snapped !== at) return [...unselected(nodes), m];
28
+ const lan = lanBelow({ x: snapped.x + size.w / 2, y: snapped.y + size.h / 2 }, [...nodes, m]);
29
+ return [lan, ...unselected(nodes), m];
30
+ }
31
+
32
+ /** The nodes after placing a machine at `position`, selected. */
33
+ export function placeMachine(preset: Preset, position: XYPosition, nodes: LabNode[]): LabNode[] {
34
+ return [...unselected(nodes), { ...newMachine(preset, position, nodes), selected: true }];
35
+ }
36
+
37
+ /**
38
+ * The nodes after placing a switch at `at`: on a LAN's edge when near one; away from any LAN it
39
+ * comes with its LAN, its first VLAN, hanging under it.
40
+ */
41
+ export function placeSwitch(at: XYPosition, nodes: LabNode[], lans: LanNode[]): LabNode[] {
42
+ const sw = newSwitch(at, nodes);
43
+ const snapped = snapToLanEdge(sw.position, lans, { w: SWITCH_W, h: SWITCH_H });
44
+ const placed = { ...sw, position: snapped, selected: true };
45
+ if (snapped !== sw.position) return [...unselected(nodes), placed];
46
+ const first = sw.data.vlans[0];
47
+ const lan = lanBelow({ x: sw.position.x + SWITCH_W / 2, y: sw.position.y + SWITCH_H / 2 }, nodes, { name: vlanName(first.id), cidr: first.cidr }, { w: 600, h: 300 });
48
+ return [lan, ...unselected(nodes), placed];
49
+ }
50
+
51
+ /** Routers, firewalls and switches are magnetic: dragged near a LAN's border, they land on it. */
52
+ export function snapMoves(changes: NodeChange<LabNode>[], nodes: LabNode[], lans: LanNode[]): NodeChange<LabNode>[] {
53
+ return changes.map((c) => {
54
+ if (c.type !== "position" || !c.position) return c;
55
+ const n = nodes.find((x) => x.id === c.id);
56
+ if (n && isAppliance(n)) return { ...c, position: snapToLanEdge(c.position, lans, sizeOf(n)) };
57
+ if (n && isSwitch(n)) return { ...c, position: snapToLanEdge(c.position, lans, { w: SWITCH_W, h: SWITCH_H }) };
58
+ return c;
59
+ });
60
+ }
@@ -0,0 +1,39 @@
1
+ import fc from "fast-check";
2
+ import { describe, expect, test } from "vitest";
3
+ import { PORT_PROTOCOLS, portsText, readPorts, type Service } from "./ports";
4
+
5
+ describe("readPorts", () => {
6
+ test("reads each port, with what it speaks when it isn't TCP", () => {
7
+ expect(readPorts("80, 443/https,53/udp , 22/tcp")).toEqual({
8
+ services: [{ port: 80 }, { port: 443, protocol: "https" }, { port: 53, protocol: "udp" }, { port: 22 }],
9
+ bad: [],
10
+ });
11
+ });
12
+
13
+ test("reports each entry Isoloom couldn't take, and keeps the others", () => {
14
+ expect(readPorts("80, http, 0, 65536, 80.5, 53/UDP, 25/smtp, 22")).toEqual({
15
+ services: [{ port: 80 }, { port: 22 }],
16
+ bad: ["http", "0", "65536", "80.5", "53/UDP", "25/smtp"],
17
+ });
18
+ });
19
+
20
+ test("an empty field is no ports and no problem", () => {
21
+ expect(readPorts(" , ")).toEqual({ services: [], bad: [] });
22
+ });
23
+ });
24
+
25
+ describe("portsText", () => {
26
+ test("writes TCP as the number, and the rest with their protocol", () => {
27
+ expect(portsText([{ port: 80 }, { port: 22, protocol: "tcp" }, { port: 53, protocol: "udp" }])).toBe("80, 22, 53/udp");
28
+ });
29
+
30
+ test("is read back as the same ports", () => {
31
+ const service: fc.Arbitrary<Service> = fc.record({ port: fc.integer({ min: 1, max: 65535 }), protocol: fc.constantFrom(...PORT_PROTOCOLS) });
32
+ fc.assert(
33
+ fc.property(fc.array(service, { maxLength: 6 }), (services) => {
34
+ const normal = services.map((s) => (s.protocol === "tcp" ? { port: s.port } : s));
35
+ expect(readPorts(portsText(services))).toEqual({ services: normal, bad: [] });
36
+ }),
37
+ );
38
+ });
39
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A machine's ports field (`80, 443/https, 53/udp`): each entry a port, 1 to 65535, and what it
3
+ * speaks when it isn't plain TCP: Isoloom's port `protocol`. An entry Isoloom couldn't take is
4
+ * kept out of the topology and reported (see `canvasIssues`), never guessed.
5
+ */
6
+
7
+ /** What a port speaks: TCP (the default), UDP, or TCP that speaks HTTP, HTTPS or TLS. */
8
+ export const PORT_PROTOCOLS = ["tcp", "udp", "http", "https", "tls"] as const;
9
+ type PortProtocol = (typeof PORT_PROTOCOLS)[number];
10
+
11
+ /** A port as the engine's topology takes it (`protocol` left out for TCP). */
12
+ export interface Service {
13
+ port: number;
14
+ protocol?: PortProtocol;
15
+ }
16
+
17
+ const ENTRY = /^(\d{1,5})(?:\/([a-z]+))?$/;
18
+
19
+ const isProtocol = (p: string): p is PortProtocol => PORT_PROTOCOLS.some((x) => x === p);
20
+
21
+ /** One entry as a port, or null when Isoloom couldn't take it. */
22
+ function service(entry: string): Service | null {
23
+ const m = ENTRY.exec(entry);
24
+ if (!m) return null;
25
+ // An entry without its protocol is TCP.
26
+ const [, digits, protocol = "tcp"] = m;
27
+ const port = Number(digits);
28
+ if (port < 1 || port > 65535) return null;
29
+ if (protocol === "tcp") return { port };
30
+ return isProtocol(protocol) ? { port, protocol } : null;
31
+ }
32
+
33
+ /** The ports field read: its ports, and each entry it couldn't read. */
34
+ export function readPorts(text: string): { services: Service[]; bad: string[] } {
35
+ const services: Service[] = [];
36
+ const bad: string[] = [];
37
+ for (const entry of text.split(",").map((e) => e.trim()).filter(Boolean)) {
38
+ const s = service(entry);
39
+ if (s) services.push(s);
40
+ else bad.push(entry);
41
+ }
42
+ return { services, bad };
43
+ }
44
+
45
+ /** Ports as the field writes them: `80, 53/udp`. */
46
+ export function portsText(services: readonly Service[]): string {
47
+ return services.map((s) => (s.protocol && s.protocol !== "tcp" ? `${String(s.port)}/${s.protocol}` : String(s.port))).join(", ");
48
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { PRESETS, deviceFrom, presetForKey, runAs, shortcutOf } from "./presets";
3
+ import { machine } from "@/test/lab";
4
+
5
+ describe("number keys", () => {
6
+ test("keys 1 to 9 then 0 pick the first ten devices, in toolbar order", () => {
7
+ expect(PRESETS.slice(0, 10).map((p) => shortcutOf(p.id))).toEqual(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]);
8
+ expect(presetForKey("1")?.id).toBe("router");
9
+ expect(presetForKey("0")?.id).toBe(PRESETS[9].id);
10
+ });
11
+
12
+ test("devices after the tenth, unknown devices and other keys have no shortcut", () => {
13
+ expect(shortcutOf(PRESETS[10].id)).toBeNull();
14
+ expect(shortcutOf("nope")).toBeNull();
15
+ expect(presetForKey("a")).toBeUndefined();
16
+ expect(presetForKey("10")).toBeUndefined();
17
+ });
18
+ });
19
+
20
+ describe("deviceFrom", () => {
21
+ test("saves a machine as a device of mine, named after it, without its fixed addresses", () => {
22
+ const device = deviceFrom(machine("m", { name: "web2", pins: { office: 20 } }).data);
23
+ expect(device).toMatchObject({ group: "mine", label: "web2", prefix: "web" });
24
+ expect(device.id).toMatch(/^my-/);
25
+ expect(device.data.pins).toBeUndefined();
26
+ expect("name" in device.data).toBe(false);
27
+ });
28
+
29
+ test("prefixes a device whose name is only digits 'device'", () => {
30
+ expect(deviceFrom(machine("m", { name: "42" }).data).prefix).toBe("device");
31
+ });
32
+ });
33
+
34
+ describe("runAs", () => {
35
+ test("a VM keeps its OS, Debian 12 by default", () => {
36
+ expect(runAs(machine("m", { os: "kali@rolling" }).data, "vm")).toEqual({ kind: "vm", os: "kali@rolling" });
37
+ expect(runAs(machine("m").data, "vm")).toEqual({ kind: "vm", os: "debian@12" });
38
+ });
39
+
40
+ test("a container keeps its image: FRR for a router, Alpine otherwise", () => {
41
+ expect(runAs(machine("m", { image: "" }).data, "container")).toEqual({ kind: "container", image: "alpine:3.20" });
42
+ expect(runAs(machine("m", { image: "", icon: "router" }).data, "container")).toEqual({ kind: "container", image: "frrouting/frr:v8.5.2" });
43
+ expect(runAs(machine("m").data, "container")).toEqual({ kind: "container", image: "nginx:1.27-alpine" });
44
+ });
45
+
46
+ test("an automatic router takes only its kind, and an unknown kind changes nothing", () => {
47
+ expect(runAs(machine("m").data, "router")).toEqual({ kind: "router" });
48
+ expect(runAs(machine("m").data, "pod")).toBeNull();
49
+ });
50
+ });
@@ -0,0 +1,77 @@
1
+ import { uid, type MachineData } from "./model";
2
+ import { DEFAULT_OS } from "./os";
3
+
4
+ /** The devices the toolbar places, and the ones saved from the canvas. */
5
+
6
+ export interface Preset {
7
+ id: string;
8
+ /** Toolbar groups: network appliances first, then containers, then VMs, then saved devices. */
9
+ group: "network" | "container" | "vm" | "mine";
10
+ /** A saved device's name. The built-in devices are named in the editor's text, by their id. */
11
+ label?: string;
12
+ /** Prefix of the generated machine name (`router1`, `router2`...). */
13
+ prefix: string;
14
+ data: Omit<MachineData, "name">;
15
+ }
16
+
17
+ const base = { image: "", os: "", provision: "", ports: "", access: false };
18
+
19
+ /** A built-in device: `id` in `group`, named `<prefix><n>`, with `data` over the defaults. */
20
+ const preset = (id: string, group: Preset["group"], prefix: string, data: Pick<MachineData, "kind" | "icon"> & Partial<MachineData>): Preset => ({
21
+ id,
22
+ group,
23
+ prefix,
24
+ data: { ...base, ...data },
25
+ });
26
+
27
+ export const PRESETS: Preset[] = [
28
+ preset("router", "network", "router", { kind: "router", icon: "router" }),
29
+ preset("router-frr", "network", "frr", { kind: "container", icon: "router", image: "frrouting/frr:v8.5.2" }),
30
+ preset("firewall", "network", "fw", { kind: "container", icon: "shield", image: "alpine:3.20" }),
31
+ preset("container", "container", "box", { kind: "container", icon: "container", image: "alpine:3.20" }),
32
+ preset("web", "container", "web", { kind: "container", icon: "server", image: "nginx:1.27-alpine", ports: "80" }),
33
+ preset("database", "container", "db", { kind: "container", icon: "server", image: "postgres:16-alpine", ports: "5432" }),
34
+ preset("linux-vm", "vm", "vm", { kind: "vm", icon: "monitor", os: DEFAULT_OS }),
35
+ preset("ubuntu-vm", "vm", "ubuntu", { kind: "vm", icon: "monitor", os: "ubuntu@24.04" }),
36
+ preset("kali-vm", "vm", "kali", { kind: "vm", icon: "monitor", os: "kali@rolling" }),
37
+ preset("windows-vm", "vm", "win", { kind: "vm", icon: "monitor", os: "windows-server@2022" }),
38
+ preset("workstation", "vm", "user", { kind: "vm", icon: "laptop", os: DEFAULT_OS, access: true }),
39
+ // Last, so the number keys above keep their devices.
40
+ preset("cisco-iol", "network", "r", { kind: "container", icon: "router", image: "vrnetlab/cisco_iol:17.12.01", appliance: "cisco-iol" }),
41
+ preset("cisco-iol-l2", "network", "sw", { kind: "container", icon: "router", image: "vrnetlab/cisco_iol:L2-17.12.01", appliance: "cisco-iol-l2" }),
42
+ // A Linux desktop with a VNC server (GNS3's desktop appliances): its screen opens from the
43
+ // desktop app (right-click, Screen). VNC password: vncpassword.
44
+ preset("desktop", "container", "desk", { kind: "container", icon: "monitor", image: "consol/debian-xfce-vnc:latest" }),
45
+ ];
46
+
47
+ /** Keys 1–9 then 0 pick the devices, in toolbar order. */
48
+ export function shortcutOf(presetId: string): string | null {
49
+ const i = PRESETS.findIndex((p) => p.id === presetId);
50
+ return i < 0 || i > 9 ? null : String((i + 1) % 10);
51
+ }
52
+
53
+ /** The device a number key picks: 1–9 the first nine, 0 the tenth. */
54
+ export function presetForKey(key: string): Preset | undefined {
55
+ return /^[0-9]$/.test(key) ? PRESETS[(Number(key) + 9) % 10] : undefined;
56
+ }
57
+
58
+ /** A device from a machine on the canvas: everything but its name and fixed addresses. */
59
+ export function deviceFrom(m: MachineData): Preset {
60
+ const { name, ...rest } = m;
61
+ const data: Omit<MachineData, "name"> = { ...rest, pins: undefined };
62
+ return {
63
+ id: uid("my"),
64
+ group: "mine",
65
+ label: name,
66
+ prefix: name.replace(/\d+$/, "") || "device",
67
+ data,
68
+ };
69
+ }
70
+
71
+ /** What changes when a machine is set to run as `kind`: a VM keeps its OS (Debian 12 by default), a container its image (FRR for a router, Alpine otherwise). */
72
+ export function runAs(m: MachineData, kind: string): Partial<MachineData> | null {
73
+ if (kind === "vm") return { kind, os: m.os || DEFAULT_OS };
74
+ if (kind === "router") return { kind };
75
+ if (kind === "container") return { kind, image: m.image || (m.icon === "router" ? "frrouting/frr:v8.5.2" : "alpine:3.20") };
76
+ return null;
77
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { deleteProject, getVersion, listProjects, projectFromLink, saveProject } from "./projects-api";
3
+
4
+ /** Answers every request with `response`; the calls it gets. */
5
+ function useServer(response: () => Response) {
6
+ const server = vi.fn<typeof fetch>(() => Promise.resolve(response()));
7
+ vi.stubGlobal("fetch", server);
8
+ return server;
9
+ }
10
+
11
+ const draft = { name: "lab", nodes: [], edges: [] };
12
+
13
+ describe("the project store", () => {
14
+ test("lists the user's projects", async () => {
15
+ const server = useServer(() => Response.json([{ id: "p1", name: "lab", updatedAt: "2026-10-15T00:00:00.000Z" }]));
16
+ await expect(listProjects()).resolves.toEqual([{ id: "p1", name: "lab", updatedAt: "2026-10-15T00:00:00.000Z" }]);
17
+ expect(server).toHaveBeenCalledWith("/api/projects", { method: "GET", headers: { "content-type": "application/json" }, body: undefined });
18
+ });
19
+
20
+ test("saves a new project with POST, and over one with PUT", async () => {
21
+ const server = useServer(() => Response.json({ id: "p1", name: "lab", updatedAt: "" }));
22
+ await saveProject(null, { draft, isoloomYml: null });
23
+ await saveProject("p1", { draft, isoloomYml: "x" });
24
+ expect(server.mock.calls.map(([url, init]) => [url, init?.method, init?.body])).toEqual([
25
+ ["/api/projects", "POST", JSON.stringify({ draft, isoloomYml: null })],
26
+ ["/api/projects/p1", "PUT", JSON.stringify({ draft, isoloomYml: "x" })],
27
+ ]);
28
+ });
29
+
30
+ test("reads an earlier version by its number", async () => {
31
+ const server = useServer(() => Response.json({ draft }));
32
+ await expect(getVersion("p1", 3)).resolves.toEqual({ draft });
33
+ expect(server.mock.calls[0][0]).toBe("/api/projects/p1/versions/3");
34
+ });
35
+
36
+ test("gives nothing for a deletion the server answers with no content", async () => {
37
+ useServer(() => new Response(null, { status: 204 }));
38
+ await expect(deleteProject("p1")).resolves.toBeNull();
39
+ });
40
+
41
+ test("fails with the server's reason, or its status when it gives none", async () => {
42
+ useServer(() => Response.json({ error: "sign in first" }, { status: 401 }));
43
+ await expect(listProjects()).rejects.toThrow("sign in first");
44
+ useServer(() => new Response("oops", { status: 500 }));
45
+ await expect(listProjects()).rejects.toThrow("500");
46
+ });
47
+ });
48
+
49
+ describe("projectFromLink", () => {
50
+ test("reads the project a link names", async () => {
51
+ useServer(() => Response.json({ id: "p1", name: "lab", draft }));
52
+ await expect(projectFromLink("p1")).resolves.toEqual({ id: "p1", name: "lab", draft });
53
+ });
54
+
55
+ test("is nothing when the project can't be read", async () => {
56
+ useServer(() => new Response(null, { status: 404 }));
57
+ await expect(projectFromLink("p1")).resolves.toBeNull();
58
+ });
59
+ });
@@ -0,0 +1,47 @@
1
+ import type { Draft } from "./model";
2
+
3
+ /**
4
+ * The site's project store (`/api/projects`), for a signed-in user: their projects, each with
5
+ * its earlier versions. Errors carry the server's message, or the HTTP status.
6
+ */
7
+
8
+ export interface ProjectSummary {
9
+ id: string;
10
+ name: string;
11
+ updatedAt: string;
12
+ }
13
+ export interface ProjectVersion {
14
+ id: number;
15
+ savedAt: string;
16
+ }
17
+
18
+ /** The server's reason in an error response (`{ error }`), or the status when it gives none. */
19
+ async function failure(r: Response): Promise<Error> {
20
+ const body = (await r.json().catch(() => null)) as { error?: unknown } | null;
21
+ const reason = body?.error;
22
+ return new Error(typeof reason === "string" ? reason : String(r.status));
23
+ }
24
+
25
+ async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
26
+ const r = await fetch(path, { method, headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
27
+ if (!r.ok) throw await failure(r);
28
+ return (r.status === 204 ? null : await r.json()) as T;
29
+ }
30
+
31
+ export const listProjects = () => api<ProjectSummary[]>("GET", "/api/projects");
32
+ export const getProject = (id: string) => api<{ id: string; name: string; draft: Draft }>("GET", `/api/projects/${id}`);
33
+ export const deleteProject = (id: string) => api("DELETE", `/api/projects/${id}`);
34
+ export const listVersions = (id: string) => api<ProjectVersion[]>(`GET`, `/api/projects/${id}/versions`);
35
+ export const getVersion = (id: string, version: number) => api<{ draft: Draft }>("GET", `/api/projects/${id}/versions/${String(version)}`);
36
+
37
+ /** Saves the lab as a new project (POST), or as the next version of `id` (PUT). */
38
+ export function saveProject(id: string | null, body: { name?: string; draft: Draft; isoloomYml: string | null }): Promise<ProjectSummary> {
39
+ if (id) return api<ProjectSummary>("PUT", `/api/projects/${id}`, body);
40
+ return api<ProjectSummary>("POST", "/api/projects", body);
41
+ }
42
+
43
+ /** A project by the id in a link (#project=…), or null when it can't be read. */
44
+ export async function projectFromLink(id: string): Promise<{ id: string; name: string; draft: unknown } | null> {
45
+ const r = await fetch(`/api/projects/${id}`);
46
+ return r.ok ? ((await r.json()) as { id: string; name: string; draft: unknown }) : null;
47
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { routerPortsOf } from "./router-ports";
3
+ import { autoRouter, cable, lan, machine, realRouter, switchNode } from "@/test/lab";
4
+ import { trunkStripes } from "./vlans";
5
+
6
+ const office = lan("office");
7
+ const away = { x: 2000, y: 2000 };
8
+
9
+ describe("a router's cabled ports", () => {
10
+ const sw1 = switchNode("sw1", {}, { x: 3000, y: 0 });
11
+ const nodes = [office, sw1, realRouter("frr", away), machine("web", {}, away)];
12
+ const edges = [
13
+ cable("c1", "frr", "sw1", { sourceHandle: "eth0", targetHandle: "trunk" }),
14
+ cable("c2", "frr", "office", { sourceHandle: "eth1" }),
15
+ cable("c3", "web", "frr", { targetHandle: "eth2" }),
16
+ ];
17
+ const addresses = { frr: [["office", "10.10.1.1"], ["vlan10", "10.10.10.1"], ["link-0", "10.0.0.1"]] as [string, string][] };
18
+ const ports = routerPortsOf(nodes, edges, [office], addresses).frr;
19
+
20
+ test("lists each port's network and address, in port order, a LAN blue and a link gray", () => {
21
+ expect(ports.interfaces.slice(0, 2)).toEqual([
22
+ { name: "eth1", network: "office", ip: "10.10.1.1", color: "#74c0fc" },
23
+ { name: "eth2", network: "link-0", ip: "10.0.0.1", color: "#adb5bd" },
24
+ ]);
25
+ });
26
+
27
+ test("a port on a switch's trunk has one sub-interface per VLAN, named after the switch's LAN", () => {
28
+ expect(ports.interfaces.slice(2)).toEqual([
29
+ { name: "sw1.10", network: "vlan10", ip: "10.10.10.1", color: "#2f9e44" },
30
+ { name: "sw1.20", network: "vlan20", ip: undefined, color: "#1971c2" },
31
+ ]);
32
+ });
33
+
34
+ test("colors each port by its network, a trunk striped with its VLANs", () => {
35
+ expect(ports.colors).toEqual({ eth0: trunkStripes(sw1.data.vlans), eth1: "#74c0fc", eth2: "#adb5bd" });
36
+ });
37
+ });
38
+
39
+ describe("other routers", () => {
40
+ test("a Cisco appliance's interfaces are its OS's, in the order of its addresses", () => {
41
+ const iol = realRouter("r1", away, { appliance: "cisco-iol" });
42
+ const ports = routerPortsOf([iol], [], [], { r1: [["office", "10.10.1.1"], ["dmz", "10.10.2.1"]] }).r1;
43
+ expect(ports.interfaces.map((i) => [i.name, i.network])).toEqual([
44
+ ["Ethernet0/1", "office"],
45
+ ["Ethernet0/2", "dmz"],
46
+ ]);
47
+ });
48
+
49
+ test("on a LAN's border, the LAN is on the first port no cable uses", () => {
50
+ const edge = realRouter("frr", { x: 316, y: 113 });
51
+ const alone = routerPortsOf([office, edge], [], [office], {}).frr;
52
+ const cabled = routerPortsOf([office, edge, machine("web", {}, away)], [cable("c", "frr", "web", { sourceHandle: "eth0" })], [office], {}).frr;
53
+ expect(alone.interfaces.map((i) => [i.name, i.network])).toEqual([["eth0", "office"]]);
54
+ expect(cabled.interfaces.map((i) => [i.name, i.network])).toEqual([
55
+ ["eth0", "link-0"],
56
+ ["eth1", "office"],
57
+ ]);
58
+ });
59
+
60
+ test("an automatic router has no ports to show", () => {
61
+ expect(routerPortsOf([autoRouter("r")], [], [], {})).toEqual({});
62
+ });
63
+ });
@@ -0,0 +1,88 @@
1
+ import { networkOfCable } from "./cables";
2
+ import { lanUnder } from "./geometry";
3
+ import { applianceInterface, isRealRouter, isSwitch, routerPort, type CableEdge, type LabNode, type LanNode, type MachineNode } from "./model";
4
+ import { isTrunkPort, switchLans, trunkInterface, trunkStripes, vlanColor, vlanName, vlanOf } from "./vlans";
5
+
6
+ /**
7
+ * A real router's interfaces: each port's color (striped when it is a trunk), and the list of
8
+ * interfaces with the network and address on each; a trunk port has one sub-interface per VLAN
9
+ * (eth0.10, eth0.20), as router-on-a-stick.
10
+ */
11
+ export interface RouterPorts {
12
+ colors: Partial<Record<string, string>>;
13
+ interfaces: { name: string; network: string; ip?: string; color: string }[];
14
+ }
15
+
16
+ /** Each machine's addresses (from the engine), by machine name: [network, IPv4][]. */
17
+ export type Addresses = Partial<Record<string, [string, string][]>>;
18
+
19
+ interface Wiring {
20
+ nodes: LabNode[];
21
+ edges: CableEdge[];
22
+ lans: LanNode[];
23
+ /** A network's color: a VLAN's own, a LAN's blue, gray for a point-to-point cable. */
24
+ colorOf: (net: string) => string;
25
+ /** A switch's LAN name, by its id. */
26
+ lanOfSwitch: (id: string) => string;
27
+ }
28
+
29
+ /** One router's interfaces: its cabled ports, an appliance's own names, the LAN it sits on. */
30
+ function portsOf(w: Wiring, r: MachineNode, ips: [string, string][]): RouterPorts {
31
+ const ipOn = (net: string) => ips.find(([n]) => n === net)?.[1];
32
+ const view: RouterPorts = { colors: {}, interfaces: [] };
33
+ const add = (name: string, net: string) => view.interfaces.push({ name, network: net, ip: ipOn(net), color: w.colorOf(net) });
34
+ for (const e of w.edges) cabledPort(w, r, e, view, add);
35
+ // Cisco IOL: Isoloom numbers its interfaces in the order of its networks (Ethernet0/0 is
36
+ // management), which is the order of its addresses.
37
+ const kind = r.data.appliance;
38
+ if (kind) view.interfaces = ips.map(([net, ip], k) => ({ name: applianceInterface(kind, k + 1), network: net, ip, color: w.colorOf(net) }));
39
+ // On a LAN's edge, the LAN is on the first port no cable uses.
40
+ const under = lanUnder(r, w.lans);
41
+ if (under) {
42
+ let i = 0;
43
+ while (view.colors[routerPort(i)] && i < 63) i++;
44
+ add(routerPort(i), under.lan.data.name);
45
+ view.colors[routerPort(i)] = w.colorOf(under.lan.data.name);
46
+ }
47
+ view.interfaces.sort((x, y) => x.name.localeCompare(y.name, undefined, { numeric: true }));
48
+ return view;
49
+ }
50
+
51
+ /**
52
+ * The port cable `e` plugs into on router `r`, if it does: on a switch's trunk, one sub-interface
53
+ * per VLAN, named as Isoloom names them (after the switch's LAN: `sw1.10`), and the port striped
54
+ * with their colors; otherwise the cable's network.
55
+ */
56
+ function cabledPort(w: Wiring, r: MachineNode, e: CableEdge, view: RouterPorts, add: (name: string, net: string) => void): void {
57
+ const mine = e.source === r.id ? e.sourceHandle : e.target === r.id ? e.targetHandle : null;
58
+ if (!mine) return;
59
+ const other = w.nodes.find((n) => n.id === (e.source === r.id ? e.target : e.source));
60
+ const otherHandle = e.source === r.id ? e.targetHandle : e.sourceHandle;
61
+ if (other && isSwitch(other) && isTrunkPort(other, otherHandle)) {
62
+ const trunk = trunkInterface(w.lanOfSwitch(other.id));
63
+ other.data.vlans.forEach((v) => {
64
+ add(`${trunk}.${String(v.id)}`, vlanName(v.id));
65
+ });
66
+ view.colors[mine] = trunkStripes(other.data.vlans);
67
+ return;
68
+ }
69
+ const net = networkOfCable(e.id, w.nodes, w.edges);
70
+ if (!net) return;
71
+ add(mine, net);
72
+ view.colors[mine] = w.colorOf(net);
73
+ }
74
+
75
+ /**
76
+ * Each real router's interfaces, by node id: the network on each port, its address there, and a
77
+ * color. On a LAN's edge, the LAN is on eth0 (or the first port no cable uses).
78
+ */
79
+ export function routerPortsOf(nodes: LabNode[], edges: CableEdge[], lans: LanNode[], addresses: Addresses): Record<string, RouterPorts> {
80
+ const lanNames = new Set(lans.map((l) => l.data.name));
81
+ const colorOf = (net: string) => {
82
+ const v = vlanOf(net);
83
+ if (v !== null) return vlanColor(v);
84
+ return lanNames.has(net) ? "#74c0fc" : "#adb5bd";
85
+ };
86
+ const w: Wiring = { nodes, edges, lans, colorOf, lanOfSwitch: switchLans(nodes, edges) };
87
+ return Object.fromEntries(nodes.filter(isRealRouter).map((r) => [r.id, portsOf(w, r, addresses[r.data.name] ?? [])]));
88
+ }