@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,35 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { exampleLab, vlanExample } from "./examples";
3
+ import { canvasIssues } from "./issues";
4
+ import { compile, issueText, sw1Networks } from "@/test/lab";
5
+
6
+ const networksOf = (t: ReturnType<typeof compile>) => Object.fromEntries(Object.entries(t.nodes).map(([n, m]) => [n, m.networks]));
7
+
8
+ describe("the starting lab", () => {
9
+ const t = compile(exampleLab().nodes, exampleLab().edges, exampleLab().name);
10
+
11
+ test("puts the workstation on the office LAN, the servers on theirs, and the router on both", () => {
12
+ expect(networksOf(t)).toEqual({ user: { office: null }, router: { office: null, servers: null }, web: { servers: null }, db: { servers: null } });
13
+ });
14
+
15
+ test("has the workstation as access machine and the servers' services", () => {
16
+ expect(t.nodes.user.access).toBe(true);
17
+ expect([t.nodes.web.services, t.nodes.db.services]).toEqual([[{ port: 80 }], [{ port: 5432 }]]);
18
+ });
19
+
20
+ test("has nothing to fix", () => {
21
+ expect(canvasIssues(exampleLab().nodes, issueText)).toEqual([]);
22
+ });
23
+ });
24
+
25
+ describe("the VLAN lab", () => {
26
+ const t = compile(vlanExample().nodes, vlanExample().edges);
27
+
28
+ test("puts the PCs on vlan10, the server on vlan20 and the router on the trunk", () => {
29
+ expect(networksOf(t)).toEqual({ router: { vlan10: null, vlan20: null }, pc1: { vlan10: null }, pc2: { vlan10: null }, srv: { vlan20: null } });
30
+ });
31
+
32
+ test("nests both VLANs under the switch", () => {
33
+ expect(t.networks).toEqual(sw1Networks);
34
+ });
35
+ });
@@ -0,0 +1,71 @@
1
+ import { twoVlans } from "./create";
2
+ import { DEFAULT_OS } from "./os";
3
+ import { LAN_BEHAVIOR, MACHINE_H, MACHINE_W, type CableEdge, type Draft, type LabNode, type LanData, type LanNode, type MachineData, type MachineNode } from "./model";
4
+
5
+ /** The labs the menu loads: one to start from, and one with VLANs. */
6
+
7
+ const base = { image: "", os: "", provision: "", ports: "", access: false };
8
+
9
+ const machine = (id: string, position: { x: number; y: number }, data: MachineData): MachineNode => ({ id, type: "machine", position, data });
10
+
11
+ const lan = (id: string, area: { x: number; y: number; w: number; h: number }, data: LanData): LanNode => ({
12
+ ...LAN_BEHAVIOR,
13
+ id,
14
+ type: "lan",
15
+ position: { x: area.x, y: area.y },
16
+ width: area.w,
17
+ height: area.h,
18
+ data,
19
+ });
20
+
21
+ /** A cable from `source` to `target`, its handles and port labels as [source's, target's]. */
22
+ const cable = (id: string, [source, target]: [string, string], [sh, th]: [string, string], [aPort, bPort]: [string, string]): CableEdge => ({
23
+ id,
24
+ type: "cable",
25
+ source,
26
+ sourceHandle: sh,
27
+ target,
28
+ targetHandle: th,
29
+ data: { aPort, bPort },
30
+ });
31
+
32
+ /**
33
+ * A starting lab: an office LAN with a workstation, a servers LAN with a web server and a
34
+ * database, and a router cabled to both LANs.
35
+ */
36
+ export function exampleLab(): Draft {
37
+ const nodes: LabNode[] = [
38
+ // LANs first, so they are drawn under the machines.
39
+ lan("l-office", { x: -40, y: 40, w: 280, h: 260 }, { name: "office", cidr: "10.10.1.0/24", internet: true }),
40
+ lan("l-servers", { x: 560, y: -80, w: 300, h: 500 }, { name: "servers", cidr: "10.10.2.0/24", internet: true }),
41
+ machine("m-user", { x: 40, y: 110 }, { ...base, name: "user", kind: "vm", icon: "laptop", os: DEFAULT_OS, access: true }),
42
+ // On the office LAN's right edge: a two-network appliance, office on one side, its port out.
43
+ machine("m-router", { x: 240 - MACHINE_W / 2, y: 170 - MACHINE_H / 2 }, { ...base, name: "router", kind: "router", icon: "router" }),
44
+ machine("m-web", { x: 650, y: 0 }, { ...base, name: "web", kind: "container", icon: "server", image: "nginx:1.27-alpine", ports: "80" }),
45
+ machine("m-db", { x: 650, y: 240 }, { ...base, name: "db", kind: "vm", icon: "monitor", os: DEFAULT_OS, provision: "provision/db.sh", ports: "5432" }),
46
+ ];
47
+ const edges = [cable("c-1", ["m-router", "l-servers"], ["port", "left"], ["eth1", ""])];
48
+ return { name: "office-lab", nodes, edges };
49
+ }
50
+
51
+ /**
52
+ * The VLAN example: a switch with vlan10 and vlan20, two PCs on vlan10, a server on vlan20, and a
53
+ * router on the trunk (router-on-a-stick: one address on each VLAN).
54
+ */
55
+ export function vlanExample(): Draft {
56
+ const nodes: LabNode[] = [
57
+ { id: "s-sw1", type: "switch", position: { x: 40, y: 0 }, data: { ...twoVlans(), name: "sw1" } },
58
+ machine("m-router", { x: 180, y: -230 }, { ...base, name: "router", kind: "router", icon: "router" }),
59
+ machine("m-pc1", { x: -140, y: 220 }, { ...base, name: "pc1", kind: "vm", icon: "laptop", os: DEFAULT_OS, access: true }),
60
+ machine("m-pc2", { x: 40, y: 220 }, { ...base, name: "pc2", kind: "container", icon: "container", image: "alpine:3.20" }),
61
+ machine("m-srv", { x: 300, y: 220 }, { ...base, name: "srv", kind: "container", icon: "server", image: "nginx:1.27-alpine", ports: "80" }),
62
+ ];
63
+ const onSwitch = (id: string, m: string, mh: string, port: string, labels: [string, string]) => cable(id, [m, "s-sw1"], [mh, port], labels);
64
+ const edges = [
65
+ onSwitch("c-1", "m-router", "port", "trunk", ["eth0", "T"]),
66
+ onSwitch("c-2", "m-pc1", "top", "p1", ["eth0", "1"]),
67
+ onSwitch("c-3", "m-pc2", "top", "p2", ["eth0", "2"]),
68
+ onSwitch("c-4", "m-srv", "top", "p5", ["eth0", "5"]),
69
+ ];
70
+ return { name: "vlan-lab", nodes, edges };
71
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { groupByTarget } from "./export-targets";
3
+
4
+ const text = { containersAndVms: "containers and VMs", oneVmPerMachine: "one VM per machine", dockerOnOneVm: "Docker on one VM" };
5
+ const file = (path: string) => ({ path, contents: "" });
6
+ const target = (dir: string) => groupByTarget([file(`.isoloom/${dir}/main.tf`)], text)[0];
7
+
8
+ describe("groupByTarget", () => {
9
+ test("groups files by target, subfolders with their target", () => {
10
+ const groups = groupByTarget([file(".isoloom/docker/compose.yml"), file(".isoloom/docker/checks/web.sh"), file(".isoloom/vagrant/Vagrantfile")], text);
11
+ expect(groups.map((g) => [g.dir, g.files.length])).toEqual([
12
+ ["docker", 2],
13
+ ["vagrant", 1],
14
+ ]);
15
+ });
16
+
17
+ test("names Docker Compose, with the command that starts it and the tool's own", () => {
18
+ expect(target("docker")).toMatchObject({ name: "Docker Compose", isoloom: "isoloom run docker", run: "docker compose -f .isoloom/docker/compose.yml up -d" });
19
+ });
20
+
21
+ test("names the Vagrant outputs by what they run", () => {
22
+ expect([target("vagrant").name, target("hybrid").name, target("docker-vm").name]).toEqual([
23
+ "Vagrant (one VM per machine)",
24
+ "Vagrant (containers and VMs)",
25
+ "Vagrant (Docker on one VM)",
26
+ ]);
27
+ });
28
+
29
+ test("a cloud output is one folder per cloud, run with Terraform", () => {
30
+ expect(target("cloud-docker/aws")).toMatchObject({ name: "AWS (Docker on one VM)", isoloom: "isoloom run cloud-docker --cloud aws" });
31
+ expect(target("cloud-vm/gcp")).toMatchObject({ name: "Google Cloud (one VM per machine)", run: "cd .isoloom/cloud-vm/gcp && terraform init && terraform apply" });
32
+ });
33
+
34
+ test("docker-vm's Proxmox module is its own output, with no Isoloom command", () => {
35
+ expect(target("docker-vm/proxmox")).toMatchObject({ name: "Proxmox (Docker on one VM)", isoloom: null });
36
+ });
37
+
38
+ test("Proxmox and Kubernetes have their commands", () => {
39
+ expect(target("proxmox")).toMatchObject({ name: "Proxmox (one VM per machine)", isoloom: "isoloom run proxmox" });
40
+ expect(target("kubernetes").isoloom).toBe("isoloom run kubernetes");
41
+ });
42
+
43
+ test("an unknown cloud keeps its folder's name, an unknown target its folder and no command", () => {
44
+ expect(target("cloud-vm/mars").name).toBe("mars (one VM per machine)");
45
+ expect(target("mystery")).toMatchObject({ name: "mystery", isoloom: null, run: "" });
46
+ });
47
+ });
@@ -0,0 +1,84 @@
1
+ import type { GeneratedFile } from "./engine";
2
+
3
+ /**
4
+ * The Isoloom outputs in a lab's zip, as the Export window lists them: how each is named for
5
+ * people, the one Isoloom command that starts it (from the unzipped folder), and the same thing
6
+ * with the tool itself.
7
+ */
8
+
9
+ /** The words the names take (the editor's text). */
10
+ export interface TargetText {
11
+ containersAndVms: string;
12
+ oneVmPerMachine: string;
13
+ dockerOnOneVm: string;
14
+ }
15
+
16
+ export interface Target {
17
+ dir: string;
18
+ files: GeneratedFile[];
19
+ name: string;
20
+ isoloom: string | null;
21
+ run: string;
22
+ }
23
+
24
+ const CLOUDS: Partial<Record<string, string>> = {
25
+ aws: "AWS",
26
+ azure: "Azure",
27
+ gcp: "Google Cloud",
28
+ digitalocean: "DigitalOcean",
29
+ linode: "Linode",
30
+ oci: "Oracle Cloud",
31
+ };
32
+
33
+ type Described = Pick<Target, "name" | "isoloom" | "run">;
34
+
35
+ /** A cloud output (one folder per cloud): Docker on one VM, or one VM per machine. */
36
+ function cloudTarget(target: string, cloud: string | undefined, terraform: string, t: TargetText): Described {
37
+ const where = CLOUDS[cloud ?? ""] ?? String(cloud);
38
+ if (target === "cloud-docker") return { name: `${where} (${t.dockerOnOneVm})`, isoloom: `isoloom run cloud-docker --cloud ${String(cloud)}`, run: terraform };
39
+ return { name: `${where} (${t.oneVmPerMachine})`, isoloom: `isoloom run cloud-vm --cloud ${String(cloud)}`, run: terraform };
40
+ }
41
+
42
+ function describe(dir: string, t: TargetText): Described {
43
+ const [target, cloud] = dir.split("/") as [string, string | undefined];
44
+ const terraform = `cd .isoloom/${dir} && terraform init && terraform apply`;
45
+ switch (target) {
46
+ case "docker":
47
+ return { name: "Docker Compose", isoloom: "isoloom run docker", run: "docker compose -f .isoloom/docker/compose.yml up -d" };
48
+ case "hybrid":
49
+ return { name: `Vagrant (${t.containersAndVms})`, isoloom: "isoloom run hybrid", run: "cd .isoloom/hybrid && vagrant up" };
50
+ case "vagrant":
51
+ return { name: `Vagrant (${t.oneVmPerMachine})`, isoloom: "isoloom run vagrant", run: "cd .isoloom/vagrant && vagrant up" };
52
+ case "docker-vm":
53
+ return cloud === "proxmox"
54
+ ? { name: `Proxmox (${t.dockerOnOneVm})`, isoloom: null, run: terraform }
55
+ : { name: `Vagrant (${t.dockerOnOneVm})`, isoloom: "isoloom run docker-vm", run: "cd .isoloom/docker-vm && vagrant up" };
56
+ case "proxmox":
57
+ return { name: `Proxmox (${t.oneVmPerMachine})`, isoloom: "isoloom run proxmox", run: terraform };
58
+ case "kubernetes":
59
+ return { name: "Kubernetes", isoloom: "isoloom run kubernetes", run: "kubectl kustomize --load-restrictor LoadRestrictionsNone .isoloom/kubernetes | kubectl apply -f -" };
60
+ case "cloud-docker":
61
+ case "cloud-vm":
62
+ return cloudTarget(target, cloud, terraform, t);
63
+ default:
64
+ return { name: dir, isoloom: null, run: "" };
65
+ }
66
+ }
67
+
68
+ /** The folder a generated file belongs to: `<target>`, or `<target>/<cloud>` for a cloud output and docker-vm's Proxmox module. */
69
+ function folderOf(path: string): string {
70
+ const parts = path.replace(/^\.isoloom\//, "").split("/");
71
+ // Every other subfolder (checks/, …) belongs to its target.
72
+ const deeper = parts.length > 2 && (parts[0] === "cloud-docker" || parts[0] === "cloud-vm" || (parts[0] === "docker-vm" && parts[1] === "proxmox"));
73
+ return deeper ? `${parts[0]}/${parts[1]}` : parts[0];
74
+ }
75
+
76
+ /** Generated files grouped by where they run: `.isoloom/<target>[/<cloud>]/…`. */
77
+ export function groupByTarget(files: GeneratedFile[], t: TargetText): Target[] {
78
+ const groups = new Map<string, GeneratedFile[]>();
79
+ for (const f of files) {
80
+ const dir = folderOf(f.path);
81
+ groups.set(dir, [...(groups.get(dir) ?? []), f]);
82
+ }
83
+ return [...groups.entries()].map(([dir, fs]) => ({ dir, files: fs, ...describe(dir, t) }));
84
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { facingSides, shownCables } from "./facing";
3
+ import { autoRouter, cable, lan, machine, switchNode } from "@/test/lab";
4
+
5
+ const office = lan("office");
6
+ const vlanArea = lan("v20", { name: "vlan20" }, { x: 2000 });
7
+ /** On the office LAN's right border. */
8
+ const r = autoRouter("r", { x: 340, y: 92 });
9
+ const web = machine("web", {}, { x: 140, y: 92 });
10
+ const dmz = machine("dmz", {}, { x: 600, y: 92 });
11
+ const cli = machine("cli", {}, { x: 140, y: 500 });
12
+ const sw = switchNode("sw", {}, { x: 0, y: 500 });
13
+ const pc = machine("pc", {}, { x: 0, y: 300 });
14
+ const nodes = [office, vlanArea, r, web, dmz, cli, sw, pc];
15
+ const lans = [office, vlanArea];
16
+ const edges = [
17
+ cable("toInside", "r", "web", { sourceHandle: "port" }),
18
+ cable("toOutside", "r", "dmz", { sourceHandle: "port" }),
19
+ cable("onSwitch", "pc", "sw", { sourceHandle: "top", targetHandle: "p1" }),
20
+ cable("toLan", "cli", "office", { sourceHandle: "top", targetHandle: "left" }),
21
+ cable("toVlanArea", "cli", "v20"),
22
+ ];
23
+ const shown = (id: string) => shownCables(nodes, edges, lans).find((e) => e.id === id);
24
+
25
+ describe("facingSides", () => {
26
+ test("an appliance on a LAN's border faces out of it", () => {
27
+ expect(facingSides(nodes, edges, lans).r).toBe("right");
28
+ });
29
+
30
+ test("an appliance elsewhere faces down when most of its cables lead below it", () => {
31
+ const fw = machine("fw", { icon: "shield" }, { x: 1000, y: 0 });
32
+ const below = [machine("b1", {}, { x: 900, y: 200 }), machine("b2", {}, { x: 1100, y: 200 })];
33
+ const sides = facingSides([fw, ...below], [cable("c1", "fw", "b1"), cable("c2", "b2", "fw")], []);
34
+ expect(sides.fw).toBe("bottom");
35
+ });
36
+
37
+ test("an appliance with its cables above keeps its default side", () => {
38
+ const fw = machine("fw", { icon: "shield" }, { x: 1000, y: 500 });
39
+ expect(facingSides([fw, pc], [cable("c", "fw", "pc")], []).fw).toBeUndefined();
40
+ });
41
+
42
+ test("a switch's port faces up when the device on it is above the switch", () => {
43
+ expect(facingSides(nodes, edges, lans)["sw:p1"]).toBe("top");
44
+ });
45
+ });
46
+
47
+ describe("shownCables", () => {
48
+ test("an appliance on a LAN's border shows its inside port toward that LAN's machines, its outside port otherwise", () => {
49
+ expect([shown("toInside")?.sourceHandle, shown("toOutside")?.sourceHandle]).toEqual(["lan", "port"]);
50
+ });
51
+
52
+ test("a machine's end leaves from its top or bottom, whichever faces the other end", () => {
53
+ expect([shown("onSwitch")?.sourceHandle, shown("toLan")?.sourceHandle]).toEqual(["bottom", "top"]);
54
+ });
55
+
56
+ test("a switch keeps the port the cable is plugged into", () => {
57
+ expect(shown("onSwitch")?.targetHandle).toBe("p1");
58
+ });
59
+
60
+ test("a LAN's end is on its side facing the other end", () => {
61
+ expect(shown("toLan")?.targetHandle).toBe("bottom");
62
+ });
63
+
64
+ test("a cable on a VLAN takes the VLAN's color; one on a plain LAN none", () => {
65
+ expect([shown("onSwitch")?.data?.color, shown("toVlanArea")?.data?.color, shown("toLan")?.data?.color]).toEqual(["#2f9e44", "#1971c2", undefined]);
66
+ });
67
+
68
+ test("a cable to a node that is gone is shown as it is", () => {
69
+ const loose = cable("loose", "gone", "web");
70
+ expect(shownCables(nodes, [loose], lans)[0]).toBe(loose);
71
+ });
72
+ });
@@ -0,0 +1,100 @@
1
+ import { networkOfCable } from "./cables";
2
+ import { insideLan, lanUnder, sideOnLan } from "./geometry";
3
+ import { centerOf, isAppliance, isMachine, isRealRouter, isSwitch, type CableEdge, type LabNode, type LanNode, type Side } from "./model";
4
+ import { vlanColor, vlanOf } from "./vlans";
5
+
6
+ /** Which way ports face and where cables attach: worked out from where things are on the canvas. */
7
+
8
+ /** A node's vertical middle, by its measured height (its top when it hasn't been measured). */
9
+ const middleY = (n: LabNode) => n.position.y + (n.height ?? n.measured?.height ?? 0) / 2;
10
+
11
+ /** The nodes at the other end of `n`'s cables. */
12
+ function peersOf(n: LabNode, nodes: LabNode[], edges: CableEdge[]): LabNode[] {
13
+ return edges
14
+ .filter((e) => e.source === n.id || e.target === n.id)
15
+ .flatMap((e) => nodes.find((x) => x.id === (e.source === n.id ? e.target : e.source)) ?? []);
16
+ }
17
+
18
+ /**
19
+ * Which way each appliance's single port faces, by node id. On a LAN's edge: out of the LAN.
20
+ * Elsewhere: toward its cables (down when they mostly lead below it), so they don't cross the box.
21
+ */
22
+ function applianceSides(nodes: LabNode[], edges: CableEdge[], lans: LanNode[], out: Record<string, Side>): void {
23
+ for (const n of nodes) {
24
+ if (!isAppliance(n)) continue;
25
+ const side = sideOnLan(n, lans);
26
+ if (side) {
27
+ out[n.id] = side;
28
+ continue;
29
+ }
30
+ const peers = peersOf(n, nodes, edges);
31
+ const below = peers.filter((x) => middleY(x) > n.position.y + 58).length;
32
+ if (peers.length && below > peers.length / 2) out[n.id] = "bottom";
33
+ }
34
+ }
35
+
36
+ /** A switch's (or a real router's) port faces up, `<id>:<port>`, when the device on it is above. */
37
+ function portSides(nodes: LabNode[], edges: CableEdge[], out: Record<string, Side>): void {
38
+ for (const e of edges) {
39
+ for (const [end, handle, other] of [
40
+ [e.source, e.sourceHandle, e.target],
41
+ [e.target, e.targetHandle, e.source],
42
+ ] as const) {
43
+ const sw = nodes.find((x) => x.id === end);
44
+ const peer = nodes.find((x) => x.id === other);
45
+ if (sw && peer && (isSwitch(sw) || isRealRouter(sw)) && handle && middleY(peer) < sw.position.y) out[`${sw.id}:${handle}`] = "top";
46
+ }
47
+ }
48
+ }
49
+
50
+ /** The sides ports face: an appliance's by its id, a switch's or router's ports by `<id>:<port>`. */
51
+ export function facingSides(nodes: LabNode[], edges: CableEdge[], lans: LanNode[]): Record<string, Side> {
52
+ const out: Record<string, Side> = {};
53
+ applianceSides(nodes, edges, lans, out);
54
+ portSides(nodes, edges, out);
55
+ return out;
56
+ }
57
+
58
+ /** The side of a LAN another node lies toward, relative to the LAN's own proportions. */
59
+ function lanSideToward(lan: LabNode, other: LabNode): Side {
60
+ const a = centerOf(lan);
61
+ const b = centerOf(other);
62
+ const dx = (b.x - a.x) / (lan.width ?? lan.measured?.width ?? 1);
63
+ const dy = (b.y - a.y) / (lan.height ?? lan.measured?.height ?? 1);
64
+ if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? "right" : "left";
65
+ return dy > 0 ? "bottom" : "top";
66
+ }
67
+
68
+ /**
69
+ * Where a cable attaches to `n`, going to `other`. A switch keeps the port the cable is plugged
70
+ * into, and a real router its port. An appliance on a LAN's edge: its inside port toward machines
71
+ * in that LAN, the outside one otherwise. A machine: its top or bottom, whichever faces the
72
+ * other end. A LAN: its side facing the other end.
73
+ */
74
+ function handleFor(n: LabNode, handle: string | null | undefined, other: LabNode, lans: LanNode[]): string | null | undefined {
75
+ if (isSwitch(n) || isRealRouter(n)) return handle;
76
+ if (isAppliance(n)) {
77
+ const under = lanUnder(n, lans);
78
+ return under && insideLan(centerOf(other), under.lan) ? "lan" : "port";
79
+ }
80
+ if (isMachine(n)) return centerOf(other).y < centerOf(n).y ? "top" : "bottom";
81
+ return lanSideToward(n, other);
82
+ }
83
+
84
+ /**
85
+ * The cables as drawn: each end attached where things are now (not where the cable was first
86
+ * drawn), and a cable on a VLAN (a switch access port, a VLAN area, a router port on it) in its
87
+ * color.
88
+ */
89
+ export function shownCables(nodes: LabNode[], edges: CableEdge[], lans: LanNode[]): CableEdge[] {
90
+ const byId = new Map(nodes.map((n) => [n.id, n]));
91
+ return edges.map((e) => {
92
+ const a = byId.get(e.source);
93
+ const b = byId.get(e.target);
94
+ if (!a || !b) return e;
95
+ const net = networkOfCable(e.id, nodes, edges);
96
+ const vlan = net ? vlanOf(net) : null;
97
+ const data = e.data && vlan !== null ? { ...e.data, color: vlanColor(vlan) } : e.data;
98
+ return { ...e, data, sourceHandle: handleFor(a, e.sourceHandle, b, lans), targetHandle: handleFor(b, e.targetHandle, a, lans) };
99
+ });
100
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { insideLan, lanRect, lanUnder, lansAround, sideOnLan, snapToLanEdge, within } from "./geometry";
3
+ import { MACHINE_H, MACHINE_W } from "./model";
4
+ import { autoRouter, lan, machine } from "@/test/lab";
5
+
6
+ /** The 400 by 300 LAN at the origin. */
7
+ const office = lan("office");
8
+ /** The position that centers a machine's box on `p`. */
9
+ const centeredOn = (p: { x: number; y: number }) => ({ x: p.x - MACHINE_W / 2, y: p.y - MACHINE_H / 2 });
10
+
11
+ describe("areas", () => {
12
+ test("a LAN covers its position and size", () => {
13
+ expect(lanRect(lan("l", {}, { x: 5, y: 6, w: 10, h: 20 }))).toEqual({ x: 5, y: 6, w: 10, h: 20 });
14
+ });
15
+
16
+ test("within includes the borders, and the slack around them", () => {
17
+ const r = { x: 0, y: 0, w: 10, h: 10 };
18
+ expect([within(r, { x: 10, y: 0 }), within(r, { x: 12, y: 5 }), within(r, { x: 12, y: 5 }, 2)]).toEqual([true, false, true]);
19
+ });
20
+
21
+ test("insideLan excludes the border", () => {
22
+ expect(insideLan({ x: 200, y: 150 }, office)).toBe(true);
23
+ expect(insideLan({ x: 0, y: 150 }, office)).toBe(false);
24
+ });
25
+ });
26
+
27
+ describe("snapToLanEdge", () => {
28
+ test("centers an appliance on the nearest border within reach", () => {
29
+ expect(snapToLanEdge(centeredOn({ x: 10, y: 150 }), [office])).toEqual(centeredOn({ x: 0, y: 150 }));
30
+ expect(snapToLanEdge(centeredOn({ x: 200, y: 290 }), [office])).toEqual(centeredOn({ x: 200, y: 300 }));
31
+ });
32
+
33
+ test("snaps to the closer of two LANs", () => {
34
+ const servers = lan("servers", {}, { x: 500, w: 400 });
35
+ expect(snapToLanEdge(centeredOn({ x: 470, y: 100 }), [office, servers])).toEqual(centeredOn({ x: 500, y: 100 }));
36
+ });
37
+
38
+ test("leaves an appliance where it is when no border is within 36", () => {
39
+ const far = centeredOn({ x: -37, y: 150 });
40
+ expect(snapToLanEdge(far, [office])).toBe(far);
41
+ });
42
+
43
+ test("goes by the size it is given", () => {
44
+ expect(snapToLanEdge({ x: -100, y: 100 }, [office], { w: 180, h: 100 })).toEqual({ x: -90, y: 100 });
45
+ });
46
+ });
47
+
48
+ describe("appliances on a LAN's border", () => {
49
+ test("sideOnLan names the border the appliance's center is on", () => {
50
+ expect(sideOnLan(autoRouter("r", centeredOn({ x: 400, y: 100 })), [office])).toBe("right");
51
+ expect(sideOnLan(autoRouter("r", centeredOn({ x: 100, y: 0 })), [office])).toBe("top");
52
+ expect(sideOnLan(autoRouter("r", centeredOn({ x: 100, y: 100 })), [office])).toBeNull();
53
+ });
54
+
55
+ test("lanUnder gives the LAN and the side", () => {
56
+ const servers = lan("servers", {}, { x: 600 });
57
+ const under = lanUnder(autoRouter("r", centeredOn({ x: 600, y: 100 })), [office, servers]);
58
+ expect(under?.lan.id).toBe("servers");
59
+ expect(under?.side).toBe("left");
60
+ });
61
+ });
62
+
63
+ describe("lansAround", () => {
64
+ test("a machine is on every LAN its center lies in", () => {
65
+ const inner = lan("inner", {}, { x: 100, y: 100, w: 100, h: 100 });
66
+ const m = machine("m", {}, centeredOn({ x: 150, y: 150 }));
67
+ expect(lansAround(m, [office, inner, m]).map((l) => l.id)).toEqual(["office", "inner"]);
68
+ });
69
+
70
+ test("a hidden LAN takes no machine by position", () => {
71
+ const m = machine("m", {}, centeredOn({ x: 150, y: 150 }));
72
+ expect(lansAround(m, [{ ...office, data: { ...office.data, hidden: true } }, m])).toEqual([]);
73
+ });
74
+
75
+ test("a hidden LAN still takes the appliance on its edge", () => {
76
+ const hidden = { ...office, data: { ...office.data, hidden: true } };
77
+ const r = autoRouter("r", centeredOn({ x: 400, y: 100 }));
78
+ expect(lansAround(r, [hidden, r]).map((l) => l.id)).toEqual(["office"]);
79
+ });
80
+ });
@@ -0,0 +1,109 @@
1
+ import type { XYPosition } from "@xyflow/react";
2
+ import { LAN_H, LAN_W, MACHINE_H, MACHINE_W, isAppliance, isLan, sizeOf, type LabNode, type LanNode, type MachineNode, type Side } from "./model";
3
+
4
+ /** Where things sit on the canvas: LAN areas, their borders, and what snaps onto them. */
5
+
6
+ /** How close (canvas units) an appliance's center must come to a LAN's border to snap onto it. */
7
+ const SNAP = 36;
8
+ /** Exactly on the border, give or take rounding. */
9
+ const ON = 0.5;
10
+
11
+ interface Rect {
12
+ x: number;
13
+ y: number;
14
+ w: number;
15
+ h: number;
16
+ }
17
+
18
+ /** The area a LAN covers on the canvas. */
19
+ export function lanRect(l: LanNode): Rect {
20
+ return { x: l.position.x, y: l.position.y, w: l.width ?? LAN_W, h: l.height ?? LAN_H };
21
+ }
22
+
23
+ /** Whether `p` lies within `r`, borders included, give or take `slack`. */
24
+ export function within(r: Rect, p: XYPosition, slack = 0): boolean {
25
+ return p.x >= r.x - slack && p.x <= r.x + r.w + slack && p.y >= r.y - slack && p.y <= r.y + r.h + slack;
26
+ }
27
+
28
+ /** A point on one of a rectangle's borders, and how far `p` is from it. */
29
+ interface BorderPoint {
30
+ d: number;
31
+ x: number;
32
+ y: number;
33
+ }
34
+
35
+ /** The points of `r`'s borders facing `p` (top, bottom, left, right), where `p` is level with them. */
36
+ function borderPoints(r: Rect, p: XYPosition): BorderPoint[] {
37
+ const alongX = p.x >= r.x && p.x <= r.x + r.w;
38
+ const alongY = p.y >= r.y && p.y <= r.y + r.h;
39
+ const out: BorderPoint[] = [];
40
+ if (alongX) out.push({ d: Math.abs(p.y - r.y), x: p.x, y: r.y }, { d: Math.abs(p.y - (r.y + r.h)), x: p.x, y: r.y + r.h });
41
+ if (alongY) out.push({ d: Math.abs(p.x - r.x), x: r.x, y: p.y }, { d: Math.abs(p.x - (r.x + r.w)), x: r.x + r.w, y: p.y });
42
+ return out;
43
+ }
44
+
45
+ /**
46
+ * Where an appliance at `position` should go: centered on the nearest LAN border within reach
47
+ * (half inside, half outside), or where it is when no border is near.
48
+ */
49
+ export function snapToLanEdge(position: XYPosition, lans: LanNode[], size: { w: number; h: number } = { w: MACHINE_W, h: MACHINE_H }): XYPosition {
50
+ const center = { x: position.x + size.w / 2, y: position.y + size.h / 2 };
51
+ let best: BorderPoint | null = null;
52
+ for (const l of lans) {
53
+ for (const c of borderPoints(lanRect(l), center)) if (c.d <= SNAP && (!best || c.d < best.d)) best = c;
54
+ }
55
+ return best ? { x: best.x - size.w / 2, y: best.y - size.h / 2 } : position;
56
+ }
57
+
58
+ /** The side of `r` that `p` lies on, if any (top, bottom, left, right, in that order). */
59
+ function sideOf(r: Rect, p: XYPosition): Side | null {
60
+ const alongX = p.x >= r.x && p.x <= r.x + r.w;
61
+ const alongY = p.y >= r.y && p.y <= r.y + r.h;
62
+ if (alongX && Math.abs(p.y - r.y) < ON) return "top";
63
+ if (alongX && Math.abs(p.y - (r.y + r.h)) < ON) return "bottom";
64
+ if (alongY && Math.abs(p.x - r.x) < ON) return "left";
65
+ if (alongY && Math.abs(p.x - (r.x + r.w)) < ON) return "right";
66
+ return null;
67
+ }
68
+
69
+ /** A node's center, by the size it is drawn at (see `sizeOf`). */
70
+ function drawnCenter(m: LabNode): XYPosition {
71
+ const { w, h } = sizeOf(m);
72
+ return { x: m.position.x + w / 2, y: m.position.y + h / 2 };
73
+ }
74
+
75
+ /** The LAN border an appliance sits on, if any: the side of the LAN, so its port faces out. */
76
+ export function sideOnLan(m: MachineNode, lans: LanNode[]): Side | null {
77
+ const c = drawnCenter(m);
78
+ for (const l of lans) {
79
+ const side = sideOf(lanRect(l), c);
80
+ if (side) return side;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /** The LAN an appliance sits on the border of, and which border: its inside and outside. */
86
+ export function lanUnder(m: MachineNode, lans: LanNode[]): { lan: LanNode; side: Side } | null {
87
+ for (const l of lans) {
88
+ const side = sideOnLan(m, [l]);
89
+ if (side) return { lan: l, side };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /** Whether a point is inside a LAN's area (not on its border). */
95
+ export function insideLan(p: XYPosition, l: LanNode): boolean {
96
+ const r = lanRect(l);
97
+ return p.x > r.x + ON && p.x < r.x + r.w - ON && p.y > r.y + ON && p.y < r.y + r.h - ON;
98
+ }
99
+
100
+ /**
101
+ * The LANs a machine sits in: its center is inside the area (or on its border). A hidden LAN's
102
+ * area isn't drawn, so it doesn't take machines by position, except routers and firewalls on
103
+ * its edge; machines join it through its switch's ports or a cable.
104
+ */
105
+ export function lansAround(m: MachineNode, nodes: LabNode[]): LanNode[] {
106
+ const c = drawnCenter(m);
107
+ const onEdge = isAppliance(m) ? lanUnder(m, nodes.filter(isLan))?.lan.id : undefined;
108
+ return nodes.filter(isLan).filter((l) => (!l.data.hidden || l.id === onEdge) && within(lanRect(l), c));
109
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { type Gns3Wiring, gns3Cables } from "./gns3-cables";
3
+ import { autoRouter, lan, machine } from "@/test/lab";
4
+
5
+ /** A GNS3 LAN where its switch was: 260 by 160 at the origin. */
6
+ const office = lan("office", {}, { w: 260, h: 160 });
7
+ const above = machine("above", {}, { x: 100, y: -300 });
8
+ const left = machine("left", {}, { x: -600, y: 0 });
9
+ const router = autoRouter("router", { x: 600, y: 600 });
10
+ /** The wiring of `links` (GNS3 ids: s1 the switch, the machines by their own ids). */
11
+ const wiring = (links: [string, string][]): Gns3Wiring => ({
12
+ links,
13
+ machines: new Map([above, left, router].map((m) => [m.id, m])),
14
+ lanOf: new Map([["s1", office]]),
15
+ nodes: [office, above, left, router],
16
+ lanSize: { w: 260, h: 160 },
17
+ });
18
+ const ends = (links: [string, string][]) => gns3Cables(wiring(links)).map((e) => [e.source, e.sourceHandle, e.target, e.targetHandle]);
19
+
20
+ describe("gns3Cables", () => {
21
+ test("cables a machine to its switch's LAN, on the sides that face each other", () => {
22
+ expect(ends([["above", "s1"], ["s1", "left"]])).toEqual([
23
+ ["above", "bottom", "office", "top"],
24
+ ["left", "bottom", "office", "left"],
25
+ ]);
26
+ });
27
+
28
+ test("cables a router from its single port", () => {
29
+ expect(ends([["router", "s1"]])[0][1]).toBe("port");
30
+ });
31
+
32
+ test("cables two machines to each other, each end on its next free port", () => {
33
+ const [c] = gns3Cables(wiring([["above", "left"]]));
34
+ expect(c).toMatchObject({ source: "above", target: "left", data: { aPort: "eth0", bPort: "eth0" } });
35
+ });
36
+
37
+ test("cables a machine to a LAN once, however many links join them", () => {
38
+ expect(ends([["above", "s1"], ["s1", "above"]])).toHaveLength(1);
39
+ });
40
+
41
+ test("hides a LAN drawn over a machine it isn't cabled to", () => {
42
+ const covered = machine("covered", {}, { x: 50, y: 20 });
43
+ const w = { ...wiring([]), machines: new Map([["covered", covered]]), nodes: [{ ...office, data: { ...office.data } }, covered] };
44
+ gns3Cables(w);
45
+ expect(w.nodes[0].type === "lan" && w.nodes[0].data.hidden).toBe(true);
46
+ });
47
+ });