@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,81 @@
1
+ import { nextPort } from "./cables";
2
+ import { lanRect, within } from "./geometry";
3
+ import { centerOf, isLan, uid, type CableEdge, type LabNode, type LanNode, type MachineNode, type Side } from "./model";
4
+
5
+ /** A GNS3 project's links as cables: machine to machine, or machine to the LAN its switch became. */
6
+
7
+ export interface Gns3Wiring {
8
+ links: [string, string][];
9
+ /** The machines and the LANs, by GNS3 node id. */
10
+ machines: Map<string, MachineNode>;
11
+ lanOf: Map<string, LanNode>;
12
+ /** Every node of the lab so far (the LANs among them). */
13
+ nodes: LabNode[];
14
+ /** A GNS3 LAN's size: which side of it faces a machine goes by its proportions. */
15
+ lanSize: { w: number; h: number };
16
+ }
17
+
18
+ const machineSide = (m: MachineNode, other: LabNode) => {
19
+ if (m.data.icon === "router" || m.data.icon === "shield") return "port";
20
+ return centerOf(other).y < centerOf(m).y ? "top" : "bottom";
21
+ };
22
+
23
+ function lanSide(w: Gns3Wiring, lan: LanNode, m: MachineNode): Side {
24
+ const d = { x: centerOf(m).x - centerOf(lan).x, y: centerOf(m).y - centerOf(lan).y };
25
+ if (Math.abs(d.x) / w.lanSize.w > Math.abs(d.y) / w.lanSize.h) return d.x < 0 ? "left" : "right";
26
+ return d.y < 0 ? "top" : "bottom";
27
+ }
28
+
29
+ function machineCable(a: MachineNode, b: MachineNode, edges: CableEdge[]): CableEdge {
30
+ return {
31
+ id: uid("c"),
32
+ type: "cable",
33
+ source: a.id,
34
+ sourceHandle: machineSide(a, b),
35
+ target: b.id,
36
+ targetHandle: machineSide(b, a),
37
+ data: { aPort: nextPort(a.id, edges), bPort: nextPort(b.id, edges) },
38
+ };
39
+ }
40
+
41
+ function lanCable(w: Gns3Wiring, m: MachineNode, lan: LanNode, edges: CableEdge[]): CableEdge {
42
+ return {
43
+ id: uid("c"),
44
+ type: "cable",
45
+ source: m.id,
46
+ sourceHandle: machineSide(m, lan),
47
+ target: lan.id,
48
+ targetHandle: lanSide(w, lan, m),
49
+ data: { aPort: nextPort(m.id, edges), bPort: "" },
50
+ };
51
+ }
52
+
53
+ /**
54
+ * A LAN drawn over a machine it isn't cabled to would put that machine on it: its area is hidden
55
+ * (its label and cables stay).
56
+ */
57
+ function hideCovering(w: Gns3Wiring, linked: Set<string>): void {
58
+ for (const lan of w.nodes.filter(isLan)) {
59
+ const covers = [...w.machines.values()].some((m) => within(lanRect(lan), centerOf(m)) && !linked.has(`${m.id}/${lan.id}`));
60
+ if (covers) lan.data = { ...lan.data, hidden: true };
61
+ }
62
+ }
63
+
64
+ /** The cables of the project's links (each machine–LAN pair once); hides the LANs that would mislead. */
65
+ export function gns3Cables(w: Gns3Wiring): CableEdge[] {
66
+ const edges: CableEdge[] = [];
67
+ const linked = new Set<string>();
68
+ for (const [a, b] of w.links) {
69
+ const ma = w.machines.get(a);
70
+ const mb = w.machines.get(b);
71
+ const lan = w.lanOf.get(b) ?? w.lanOf.get(a);
72
+ const m = ma ?? mb;
73
+ if (ma && mb) edges.push(machineCable(ma, mb, edges));
74
+ else if (m && lan && !linked.has(`${m.id}/${lan.id}`)) {
75
+ linked.add(`${m.id}/${lan.id}`);
76
+ edges.push(lanCable(w, m, lan, edges));
77
+ }
78
+ }
79
+ hideCovering(w, linked);
80
+ return edges;
81
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { fromGns3 } from "./gns3-import";
3
+ import type { Gns3Text } from "./gns3-parts";
4
+ import { nodeName, type LabNode, type MachineData } from "./model";
5
+
6
+ const text: Gns3Text = {
7
+ notGns3: "not gns3",
8
+ noTopology: "no topology",
9
+ cisco: (node, kind) => `cisco ${node} ${kind}`,
10
+ firewall: (node) => `firewall ${node}`,
11
+ noEquivalent: (node, type) => `skipped ${node} ${type}`,
12
+ noNodes: "no nodes",
13
+ };
14
+
15
+ /** A GNS3 node: its id, type and name, at the origin unless `extra` says otherwise. */
16
+ const g3 = (node_id: string, node_type: string, name: string = node_id, extra: object = {}) => ({ node_id, node_type, name, x: 0, y: 0, ...extra });
17
+ /** A .gns3 project of `nodes` and two-ended `links`. */
18
+ const project = (nodes: object[], links: string[][] = [], more: object = {}) =>
19
+ JSON.stringify({ name: "My GNS3 Lab", type: "topology", topology: { nodes, links: links.map((ends) => ({ nodes: ends.map((node_id) => ({ node_id })) })), ...more } });
20
+ const open = (nodes: object[], links: string[][] = [], more: object = {}) => fromGns3(project(nodes, links, more), text);
21
+ const names = (nodes: LabNode[]) => nodes.map(nodeName);
22
+ /** The machine named `name`'s data. */
23
+ const machineData = (nodes: LabNode[], name: string): MachineData | undefined => nodes.find((n) => n.type === "machine" && n.data.name === name)?.data as MachineData | undefined;
24
+
25
+ describe("what isn't a GNS3 project", () => {
26
+ test("a file that isn't JSON", () => {
27
+ expect(() => fromGns3("not json", text)).toThrow("not gns3");
28
+ });
29
+
30
+ test("JSON with no topology", () => {
31
+ expect(() => fromGns3(JSON.stringify({ name: "x" }), text)).toThrow("no topology");
32
+ });
33
+
34
+ test("a project with no nodes opens empty, with a warning", () => {
35
+ expect(open([])).toEqual({ lab: { name: "my-gns3-lab", nodes: [], edges: [] }, warnings: ["no nodes"] });
36
+ });
37
+ });
38
+
39
+ describe("switches become LANs", () => {
40
+ test("a switch is a LAN without the internet, the machines cabled to it on its edge", () => {
41
+ const { lab } = open([g3("s1", "ethernet_switch", "Switch1"), g3("d1", "docker", "web", { x: 100, y: 100, properties: { image: "nginx:latest" } })], [["s1", "d1"]]);
42
+ expect(lab.nodes[0]).toMatchObject({ type: "lan", data: { name: "switch1", cidr: "10.10.1.0/24", internet: false } });
43
+ expect(lab.edges).toEqual([expect.objectContaining({ source: lab.nodes[1].id, target: lab.nodes[0].id, data: { aPort: "eth0", bPort: "" } })]);
44
+ });
45
+
46
+ test("switches cabled together are one LAN, named after the first; a NAT on it gives it the internet", () => {
47
+ const { lab } = open([g3("nat", "nat", "NAT1"), g3("s1", "ethernet_switch", "Switch1"), g3("s2", "ethernet_hub", "Switch 2"), g3("s3", "ethernet_switch")], [["s1", "s2"], ["nat", "s1"]]);
48
+ expect(lab.nodes.map((n) => n.type === "lan" && [n.data.name, n.data.cidr, n.data.internet])).toEqual([
49
+ ["switch1", "10.10.1.0/24", true],
50
+ ["s3", "10.10.2.0/24", false],
51
+ ]);
52
+ });
53
+
54
+ test("an Open vSwitch container is a switch too", () => {
55
+ expect(open([g3("o", "docker", "OpenvSwitch", { properties: { image: "gns3/openvswitch" } })]).lab.nodes[0].type).toBe("lan");
56
+ });
57
+ });
58
+
59
+ describe("what each node becomes", () => {
60
+ test("Cisco routers become automatic routers, with a warning that their configuration stays behind", () => {
61
+ const { lab, warnings } = open([g3("r1", "dynamips", "R1"), g3("r2", "iou", "R_2")]);
62
+ expect(names(lab.nodes)).toEqual(["r1", "r-2"]);
63
+ expect(machineData(lab.nodes, "r1")).toMatchObject({ kind: "router", icon: "router" });
64
+ expect(warnings).toEqual(["cisco R1 Dynamips", "cisco R_2 IOU"]);
65
+ });
66
+
67
+ test("a router appliance becomes an automatic router, silently", () => {
68
+ const { lab, warnings } = open([g3("v", "qemu", "edge", { properties: { hda_disk_image: "vyos.img" } })]);
69
+ expect(machineData(lab.nodes, "edge")).toMatchObject({ kind: "router", icon: "router" });
70
+ expect(warnings).toEqual([]);
71
+ });
72
+
73
+ test("a firewall becomes an automatic router with a shield, with a warning", () => {
74
+ const { lab, warnings } = open([g3("f", "qemu", "pfSense", { properties: { hda_disk_image: "pfsense.qcow2" } })]);
75
+ expect(machineData(lab.nodes, "pfsense")).toMatchObject({ kind: "router", icon: "shield" });
76
+ expect(warnings).toEqual(["firewall pfSense"]);
77
+ });
78
+
79
+ test("QEMU, VirtualBox and VMware nodes become VMs, the OS guessed, each with its provisioning script", () => {
80
+ const nodes = [
81
+ g3("q", "qemu", "Win10", { properties: { hda_disk_image: "win10.qcow2" } }),
82
+ g3("v", "virtualbox", "srv", { properties: { vmname: "Ubuntu 20.04" } }),
83
+ g3("w", "vmware", "kali linux"),
84
+ ];
85
+ const { lab } = open(nodes);
86
+ expect(lab.nodes.map((n) => n.type === "machine" && [n.data.name, n.data.kind, n.data.os, n.data.provision])).toEqual([
87
+ ["win10", "vm", "windows@10", "provision/win10.ps1"],
88
+ ["srv", "vm", "ubuntu@20.04", "provision/srv.sh"],
89
+ ["kali-linux", "vm", "kali@rolling", "provision/kali-linux.sh"],
90
+ ]);
91
+ });
92
+
93
+ test("VPCS becomes a small Alpine container, and a Docker node without an image runs Alpine", () => {
94
+ const { lab } = open([g3("p", "vpcs", "PC1"), g3("d", "docker", "box", { properties: {} })]);
95
+ expect(machineData(lab.nodes, "pc1")).toMatchObject({ kind: "container", icon: "laptop", image: "alpine:3.20" });
96
+ expect(machineData(lab.nodes, "box")).toMatchObject({ kind: "container", icon: "container", image: "alpine:3.20" });
97
+ });
98
+
99
+ test("a node with no equivalent is left out, with a warning", () => {
100
+ expect(open([g3("x", "cloud_unknown", "Thing")])).toMatchObject({ lab: { nodes: [] }, warnings: ["skipped Thing cloud_unknown"] });
101
+ });
102
+ });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Opening a GNS3 project (.gns3, the JSON GNS3 saves) as a lab. GNS3 draws switches as boxes
3
+ * that machines plug into; here they become LANs, the machines cabled to their edge. Docker
4
+ * nodes keep their image, QEMU/VirtualBox/VMware nodes become VMs (the OS guessed from the
5
+ * image), VPCS becomes a small container, Cisco routers (Dynamips, IOU) automatic routers, a NAT
6
+ * or cloud gives its LAN the internet, and text drawings become notes. What can't come across
7
+ * (an IOS configuration, a node type with no equivalent) is reported, not silently dropped.
8
+ */
9
+
10
+ import { migrateEdges } from "./cables";
11
+ import { gns3Cables } from "./gns3-cables";
12
+ import { drawingNote, guessOs, type G3Drawing, type Gns3Text } from "./gns3-parts";
13
+ import { LAN_BEHAVIOR, MACHINE_H, MACHINE_W, cleanName, uid, type Draft, type LabNode, type LanNode, type MachineData, type MachineNode, type NoteNode } from "./model";
14
+
15
+ interface G3Node {
16
+ node_id: string;
17
+ name?: string;
18
+ node_type?: string;
19
+ x?: number;
20
+ y?: number;
21
+ properties?: Record<string, unknown>;
22
+ }
23
+ interface G3Link {
24
+ nodes?: { node_id: string }[];
25
+ }
26
+ interface G3Project {
27
+ name?: string;
28
+ type?: string;
29
+ topology?: { nodes?: G3Node[]; links?: G3Link[]; drawings?: G3Drawing[] };
30
+ }
31
+
32
+ /** GNS3 places small icons; Isothrum's machines are about twice as big, so positions spread out. */
33
+ const SCALE = 2;
34
+ const LAN_W = 260;
35
+ const LAN_H = 160;
36
+
37
+ const SWITCHES = new Set(["ethernet_switch", "ethernet_hub", "atm_switch", "frame_relay_switch"]);
38
+ const INTERNET = new Set(["nat", "cloud"]);
39
+ const CISCO = new Set(["dynamips", "iou"]);
40
+ const VMS = new Set(["qemu", "virtualbox", "vmware"]);
41
+
42
+ /** Appliances recognised by their image or name: network devices rather than hosts. */
43
+ const ROUTER_HINT = /vyos|csr|vios|xrv|routeros|mikrotik|frr|cisco|juniper|vsrx|vmx|veos|arista|nokia|sros|openwrt/i;
44
+ const FIREWALL_HINT = /fortigate|pfsense|opnsense|asav|paloalto|pan-os|checkpoint|sophos/i;
45
+ const SWITCH_HINT = /openvswitch|ovs|cumulus|nxos|iosvl2|vios_l2/i;
46
+
47
+ const str = (v: unknown) => (typeof v === "string" ? v : "");
48
+
49
+ /** What a GNS3 node becomes: part of a LAN (a switch, or the internet), or a machine. */
50
+ function kindOf(n: G3Node): "switch" | "internet" | "machine" {
51
+ const t = n.node_type ?? "";
52
+ const look = `${n.name ?? ""} ${str(n.properties?.image)} ${str(n.properties?.hda_disk_image)}`;
53
+ if (SWITCHES.has(t) || ((t === "docker" || VMS.has(t)) && SWITCH_HINT.test(look))) return "switch";
54
+ if (INTERNET.has(t)) return "internet";
55
+ return "machine";
56
+ }
57
+
58
+ /** An import in progress: the project's nodes, the lab's nodes so far, the names taken. */
59
+ interface Import {
60
+ g3nodes: G3Node[];
61
+ links: [string, string][];
62
+ nodes: LabNode[];
63
+ names: Set<string>;
64
+ warnings: string[];
65
+ text: Gns3Text;
66
+ }
67
+
68
+ /** The project's nodes and its two-ended links, or why the file isn't a GNS3 project. */
69
+ function parse(source: string, text: Gns3Text): { project: G3Project; g3nodes: G3Node[]; links: [string, string][] } {
70
+ let project: G3Project;
71
+ try {
72
+ project = JSON.parse(source) as G3Project;
73
+ } catch {
74
+ throw new Error(text.notGns3);
75
+ }
76
+ const g3nodes = project.topology?.nodes;
77
+ if (!Array.isArray(g3nodes)) throw new Error(text.noTopology);
78
+ const links = (project.topology?.links ?? [])
79
+ .map((l) => (l.nodes ?? []).map((e) => e.node_id))
80
+ .flatMap((ends): [string, string][] => (ends.length === 2 ? [[ends[0], ends[1]]] : []));
81
+ return { project, g3nodes, links };
82
+ }
83
+
84
+ /** A free name from GNS3's (spaces and underscores as dashes), `-2`, `-3`… when it is taken. */
85
+ function nameFor(imp: Import, raw: string, fallback: string): string {
86
+ const base = cleanName(raw.replace(/[\s_]+/g, "-")).replace(/^-+|-+$/g, "") || fallback;
87
+ let name = base;
88
+ for (let i = 2; imp.names.has(name); i++) name = `${base}-${String(i)}`;
89
+ imp.names.add(name);
90
+ return name;
91
+ }
92
+
93
+ const center = (n: G3Node) => ({ x: (n.x ?? 0) * SCALE + 30, y: (n.y ?? 0) * SCALE + 30 });
94
+
95
+ /** Switches cabled together are one LAN; a NAT or cloud cabled to a switch gives it the internet. */
96
+ function switchGroups(imp: Import): (id: string) => string | null {
97
+ const parent = new Map<string, string>();
98
+ const find = (id: string): string => {
99
+ const up = parent.get(id);
100
+ return up === undefined || up === id ? id : find(up);
101
+ };
102
+ for (const n of imp.g3nodes) if (kindOf(n) !== "machine") parent.set(n.node_id, n.node_id);
103
+ for (const [a, b] of imp.links) if (parent.has(a) && parent.has(b)) parent.set(find(a), find(b));
104
+ return (id) => (parent.has(id) ? find(id) : null);
105
+ }
106
+
107
+ /** One LAN per group of switches (and NAT/cloud), where its first member was. */
108
+ function importLans(imp: Import): Map<string, LanNode> {
109
+ const groupOf = switchGroups(imp);
110
+ const lanOf = new Map<string, LanNode>();
111
+ let third = 1;
112
+ for (const n of imp.g3nodes) {
113
+ const root = groupOf(n.node_id);
114
+ if (root === null) continue;
115
+ let lan = lanOf.get(root);
116
+ if (!lan) {
117
+ const members = imp.g3nodes.filter((m) => groupOf(m.node_id) === root);
118
+ const first = members.find((m) => kindOf(m) === "switch") ?? members[0];
119
+ const c = center(first);
120
+ const data = { name: nameFor(imp, first.name ?? "", "lan"), cidr: `10.10.${String(third++)}.0/24`, internet: members.some((m) => kindOf(m) === "internet") };
121
+ lan = { ...LAN_BEHAVIOR, id: uid("l"), type: "lan", position: { x: c.x - LAN_W / 2, y: c.y - LAN_H / 2 }, width: LAN_W, height: LAN_H, data };
122
+ imp.nodes.push(lan);
123
+ }
124
+ lanOf.set(n.node_id, lan);
125
+ lanOf.set(root, lan);
126
+ }
127
+ return lanOf;
128
+ }
129
+
130
+ /** What a GNS3 machine becomes, or null (with a warning) when nothing here matches it. */
131
+ function machineData(imp: Import, n: G3Node): Omit<MachineData, "name"> | null {
132
+ const t = n.node_type ?? "";
133
+ const p = n.properties ?? {};
134
+ const look = `${n.name ?? ""} ${str(p.image)} ${str(p.hda_disk_image)} ${str(p.vmname)}`;
135
+ const base = { image: "", os: "", provision: "", ports: "", access: false };
136
+ if (CISCO.has(t) || ROUTER_HINT.test(look)) {
137
+ if (CISCO.has(t)) imp.warnings.push(imp.text.cisco(n.name ?? n.node_id, t === "iou" ? "IOU" : "Dynamips"));
138
+ return { ...base, kind: "router", icon: "router" };
139
+ }
140
+ if (FIREWALL_HINT.test(look)) {
141
+ imp.warnings.push(imp.text.firewall(n.name ?? n.node_id));
142
+ return { ...base, kind: "router", icon: "shield" };
143
+ }
144
+ if (t === "docker") return { ...base, kind: "container", icon: "container", image: str(p.image) || "alpine:3.20" };
145
+ if (t === "vpcs" || t === "traceng") return { ...base, kind: "container", icon: "laptop", image: "alpine:3.20" };
146
+ if (VMS.has(t)) return { ...base, kind: "vm", icon: "monitor", os: guessOs(look) };
147
+ imp.warnings.push(imp.text.noEquivalent(n.name ?? n.node_id, t));
148
+ return null;
149
+ }
150
+
151
+ /** The machines, where GNS3 had them, by GNS3 node id. */
152
+ function importMachines(imp: Import): Map<string, MachineNode> {
153
+ const machines = new Map<string, MachineNode>();
154
+ for (const n of imp.g3nodes) {
155
+ if (kindOf(n) !== "machine") continue;
156
+ let data = machineData(imp, n);
157
+ if (!data) continue;
158
+ const name = nameFor(imp, n.name ?? "", data.kind === "router" ? "router" : "machine");
159
+ if (data.kind === "vm") data = { ...data, provision: `provision/${name}.${data.os.startsWith("windows") ? "ps1" : "sh"}` };
160
+ const c = center(n);
161
+ const m: MachineNode = { id: uid("m"), type: "machine", position: { x: c.x - MACHINE_W / 2, y: c.y - MACHINE_H / 2 }, data: { ...data, name } };
162
+ machines.set(n.node_id, m);
163
+ imp.nodes.push(m);
164
+ }
165
+ return machines;
166
+ }
167
+
168
+ /** The project's text drawings, as notes. */
169
+ function importNotes(imp: Import, drawings: G3Drawing[]): void {
170
+ for (const d of drawings) {
171
+ const note = drawingNote(d, SCALE);
172
+ if (!note) continue;
173
+ const n: NoteNode = {
174
+ id: uid("n"),
175
+ type: "note",
176
+ position: { x: (d.x ?? 0) * SCALE, y: (d.y ?? 0) * SCALE },
177
+ width: note.w,
178
+ height: note.h,
179
+ data: { text: note.text, shape: note.shape, color: "yellow" },
180
+ };
181
+ imp.nodes.push(n);
182
+ }
183
+ }
184
+
185
+ /** `text`: the editor's words for what goes wrong (warnings and errors are shown as they are). */
186
+ export function fromGns3(source: string, text: Gns3Text): { lab: Draft; warnings: string[] } {
187
+ const { project, g3nodes, links } = parse(source, text);
188
+ const imp: Import = { g3nodes, links, nodes: [], names: new Set(), warnings: [], text };
189
+ const lanOf = importLans(imp);
190
+ const machines = importMachines(imp);
191
+ const edges = gns3Cables({ links, machines, lanOf, nodes: imp.nodes, lanSize: { w: LAN_W, h: LAN_H } });
192
+ importNotes(imp, project.topology?.drawings ?? []);
193
+ // LANs first, so they stay under the machines.
194
+ const ordered = [...imp.nodes.filter((n) => n.type === "lan"), ...imp.nodes.filter((n) => n.type !== "lan")];
195
+ const lab: Draft = { name: cleanName((project.name ?? "").replace(/[\s_]+/g, "-")) || "gns3-lab", nodes: ordered, edges: migrateEdges(edges, ordered) };
196
+ if (!g3nodes.length) imp.warnings.push(text.noNodes);
197
+ return { lab, warnings: imp.warnings };
198
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { fromGns3 } from "./gns3-import";
3
+ import type { Gns3Text } from "./gns3-parts";
4
+ import { MACHINE_H, MACHINE_W, nodeName } from "./model";
5
+
6
+ const text: Gns3Text = { notGns3: "", noTopology: "", cisco: () => "", firewall: () => "", noEquivalent: () => "", noNodes: "" };
7
+ const docker = (node_id: string, name: string, x = 0, y = 0) => ({ node_id, node_type: "docker", name, x, y, properties: { image: "alpine" } });
8
+ const sw = (node_id: string) => ({ node_id, node_type: "ethernet_switch", name: node_id, x: 0, y: 0 });
9
+ /** A project's lab: its nodes, links (by GNS3 id) and drawings. */
10
+ const labOf = (nodes: object[], links: string[][] = [], drawings: object[] = [], name?: string) =>
11
+ fromGns3(JSON.stringify({ name, topology: { nodes, links: links.map((ends) => ({ nodes: ends.map((node_id) => ({ node_id })) })), drawings } }), text).lab;
12
+
13
+ describe("names", () => {
14
+ test("spaces and underscores become dashes, and a taken name gets -2, -3", () => {
15
+ expect(labOf([docker("a", "web"), docker("b", "web"), docker("c", "My_Box 1")]).nodes.map(nodeName)).toEqual(["web", "web-2", "my-box-1"]);
16
+ });
17
+
18
+ test("a nameless machine is called machine", () => {
19
+ expect(labOf([docker("a", "")]).nodes.map(nodeName)).toEqual(["machine"]);
20
+ });
21
+
22
+ test("the lab is named after the project, or gns3-lab", () => {
23
+ expect([labOf([], [], [], "a_b c").name, labOf([]).name]).toEqual(["a-b-c", "gns3-lab"]);
24
+ });
25
+ });
26
+
27
+ describe("where things go", () => {
28
+ test("positions spread out twice as far as GNS3's", () => {
29
+ expect(labOf([docker("a", "web", 100, 50)]).nodes[0].position).toEqual({ x: 230 - MACHINE_W / 2, y: 130 - MACHINE_H / 2 });
30
+ });
31
+
32
+ test("LANs come first, so they are drawn under the machines", () => {
33
+ expect(labOf([docker("a", "web"), sw("s1")]).nodes.map((n) => n.type)).toEqual(["lan", "machine"]);
34
+ });
35
+
36
+ test("a LAN drawn over a machine it isn't cabled to is hidden", () => {
37
+ const lan = labOf([sw("s1"), docker("a", "web", 0, 10)]).nodes[0];
38
+ expect(lan.type === "lan" && lan.data.hidden).toBe(true);
39
+ });
40
+
41
+ test("text drawings become notes, scaled; empty ones are left out", () => {
42
+ const drawings = [{ x: 10, y: 20, svg: '<svg width="100" height="40"><text>Hello</text></svg>' }, { svg: "<svg></svg>" }];
43
+ expect(labOf([], [], drawings).nodes).toEqual([
44
+ expect.objectContaining({ type: "note", position: { x: 20, y: 40 }, width: 200, height: 80, data: { text: "Hello", shape: "text", color: "yellow" } }),
45
+ ]);
46
+ });
47
+ });
48
+
49
+ describe("links", () => {
50
+ test("a link between two machines is a cable between them, each end on its next free port", () => {
51
+ const lab = labOf([docker("a", "web"), docker("b", "db"), docker("c", "cache")], [["a", "b"], ["a", "c"]]);
52
+ expect(lab.edges.map((e) => e.data)).toEqual([
53
+ { aPort: "eth0", bPort: "eth0" },
54
+ { aPort: "eth1", bPort: "eth0" },
55
+ ]);
56
+ });
57
+
58
+ test("a machine linked twice to one LAN gets one cable, and a link with one end none", () => {
59
+ const lab = labOf([sw("s1"), docker("a", "web", 300, 300)], [["s1", "a"], ["a", "s1"], ["a"]]);
60
+ expect(lab.edges).toHaveLength(1);
61
+ });
62
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { drawingNote, guessOs } from "./gns3-parts";
3
+
4
+ describe("guessOs", () => {
5
+ test("reads a Linux distribution and its version from the image's name", () => {
6
+ const names = ["kali linux", "Rocky", "alma", "centos", "fedora", "ubuntu 20.04", "ubuntu 24", "ubuntu", "debian 11", "debian 13", "debian"];
7
+ expect(names.map(guessOs)).toEqual([
8
+ "kali@rolling",
9
+ "rocky@9",
10
+ "alma@9",
11
+ "centos@7",
12
+ "fedora@42",
13
+ "ubuntu@20.04",
14
+ "ubuntu@24.04",
15
+ "ubuntu@22.04",
16
+ "debian@11",
17
+ "debian@13",
18
+ "debian@12",
19
+ ]);
20
+ });
21
+
22
+ test("reads Windows desktops and servers, Server 2022 by default", () => {
23
+ const names = ["windows-11.img", "Win10", "Windows Server 2016", "win 2019", "win 2025", "windows"];
24
+ expect(names.map(guessOs)).toEqual(["windows@11", "windows@10", "windows-server@2016", "windows-server@2019", "windows-server@2025", "windows-server@2022"]);
25
+ });
26
+
27
+ test("guesses Debian 12 when nothing says otherwise", () => {
28
+ expect(guessOs("my appliance")).toBe("debian@12");
29
+ });
30
+ });
31
+
32
+ describe("drawingNote", () => {
33
+ test("takes an SVG drawing's text, without markup and with entities decoded, one line per text element", () => {
34
+ const svg = '<svg width="100" height="40"><text font="x">Hello &amp; <b>bye</b></text><text>a &lt;b&gt;</text></svg>';
35
+ expect(drawingNote({ svg }, 2)).toEqual({ text: "Hello & bye\na <b>", shape: "text", w: 200, h: 80 });
36
+ });
37
+
38
+ test("keeps an ellipse or a box, sized by the drawing within bounds", () => {
39
+ expect(drawingNote({ svg: '<svg width="300" height="300"><ellipse/></svg>' }, 2)).toEqual({ text: "", shape: "ellipse", w: 600, h: 400 });
40
+ expect(drawingNote({ svg: "<svg><rect/></svg>" }, 2)).toEqual({ text: "", shape: "rect", w: 120, h: 50 });
41
+ });
42
+
43
+ test("is nothing for a drawing with no text and no shape", () => {
44
+ expect(drawingNote({ svg: "<svg></svg>" }, 2)).toBeNull();
45
+ expect(drawingNote({}, 2)).toBeNull();
46
+ });
47
+ });
@@ -0,0 +1,68 @@
1
+ import type { NoteShape } from "./model";
2
+ import { DEFAULT_OS } from "./os";
3
+
4
+ /** Pieces of a GNS3 import that read one thing: an OS from an image name, a note from a drawing. */
5
+
6
+ /** The editor's words for what a GNS3 import can't carry over (see the editor's text). */
7
+ export interface Gns3Text {
8
+ notGns3: string;
9
+ noTopology: string;
10
+ cisco: (node: string, kind: string) => string;
11
+ firewall: (node: string) => string;
12
+ noEquivalent: (node: string, type: string) => string;
13
+ noNodes: string;
14
+ }
15
+
16
+ export interface G3Drawing {
17
+ x?: number;
18
+ y?: number;
19
+ svg?: string;
20
+ }
21
+
22
+ function windowsOs(t: string): string {
23
+ if (t.includes("11")) return "windows@11";
24
+ if (t.includes("10") && !t.includes("server")) return "windows@10";
25
+ if (t.includes("2016")) return "windows-server@2016";
26
+ if (t.includes("2019")) return "windows-server@2019";
27
+ if (t.includes("2025")) return "windows-server@2025";
28
+ return "windows-server@2022";
29
+ }
30
+
31
+ /** Linux distributions by a word in the image's name, each with its version guess. */
32
+ const DISTROS: [word: string, os: (t: string) => string][] = [
33
+ ["kali", () => "kali@rolling"],
34
+ ["ubuntu", (t) => (t.includes("20.04") ? "ubuntu@20.04" : t.includes("24") ? "ubuntu@24.04" : "ubuntu@22.04")],
35
+ ["rocky", () => "rocky@9"],
36
+ ["alma", () => "alma@9"],
37
+ ["centos", () => "centos@7"],
38
+ ["fedora", () => "fedora@42"],
39
+ ["alpine", () => "alpine@3.19"],
40
+ ["debian", (t) => (t.includes("11") ? "debian@11" : t.includes("13") ? "debian@13" : DEFAULT_OS)],
41
+ ];
42
+
43
+ /** The Isoloom OS a VM's image or name suggests (Debian 12 when nothing does). */
44
+ export function guessOs(text: string): string {
45
+ const t = text.toLowerCase();
46
+ if (t.includes("win")) return windowsOs(t);
47
+ const distro = DISTROS.find(([word]) => t.includes(word));
48
+ return distro ? distro[1](t) : DEFAULT_OS;
49
+ }
50
+
51
+ /** The text in an SVG drawing's <text> elements, one line each. */
52
+ function svgText(svg: string): string {
53
+ return [...svg.matchAll(/<text[^>]*>([\s\S]*?)<\/text>/g)]
54
+ .map((m) => m[1].replace(/<[^>]+>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").trim())
55
+ .filter(Boolean)
56
+ .join("\n");
57
+ }
58
+
59
+ /** The text of an SVG drawing, and its shape and size (scaled by `scale`). */
60
+ export function drawingNote(d: G3Drawing, scale: number): { text: string; shape: NoteShape; w: number; h: number } | null {
61
+ const svg = d.svg ?? "";
62
+ const text = svgText(svg);
63
+ const shape: NoteShape = svg.includes("<ellipse") ? "ellipse" : svg.includes("<rect") ? "rect" : "text";
64
+ if (!text && shape === "text") return null;
65
+ const w = Number(/width="([\d.]+)"/.exec(svg)?.[1] ?? 0);
66
+ const h = Number(/height="([\d.]+)"/.exec(svg)?.[1] ?? 0);
67
+ return { text, shape, w: Math.max(120, Math.min(600, w * scale)) || 200, h: Math.max(50, Math.min(400, h * scale)) || 90 };
68
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { canvasIssues } from "./issues";
3
+ import { issueText, lan, machine, note, switchNode } from "@/test/lab";
4
+
5
+ describe("canvasIssues", () => {
6
+ test("finds nothing on a lab with distinct names and agreeing blocks", () => {
7
+ expect(canvasIssues([machine("web"), lan("office"), switchNode("sw1"), note("n")], issueText)).toEqual([]);
8
+ });
9
+
10
+ test("a VLAN with two blocks on two switches", () => {
11
+ const other = switchNode("sw2", { vlans: [{ id: 10, cidr: "10.10.99.0/24" }] });
12
+ expect(canvasIssues([switchNode("sw1"), other], issueText)).toEqual(["vlan10: 10.10.10.0/24 vs 10.10.99.0/24 on sw2"]);
13
+ });
14
+
15
+ test("a LAN area named like a VLAN with another block than its switches", () => {
16
+ expect(canvasIssues([switchNode("sw1"), lan("l", { name: "vlan20", cidr: "10.10.2.0/24" })], issueText)).toEqual([
17
+ "area vlan20: 10.10.2.0/24 vs 10.10.20.0/24",
18
+ ]);
19
+ });
20
+
21
+ test("a machine, a LAN or a switch without a name", () => {
22
+ const nodes = [machine("m", { name: "" }), lan("l", { name: "" }), switchNode("s", { name: "" })];
23
+ expect(canvasIssues(nodes, issueText)).toEqual(["unnamed machine", "unnamed lan", "unnamed switch"]);
24
+ });
25
+
26
+ test("each port entry Isoloom couldn't take, by machine", () => {
27
+ const nodes = [machine("web", { ports: "80, http, 53/udp, 70000" }), machine("db", { ports: "5432" })];
28
+ expect(canvasIssues(nodes, issueText)).toEqual(["web: port http", "web: port 70000"]);
29
+ });
30
+
31
+ test("the same name twice, per kind of node", () => {
32
+ const nodes = [machine("a", { name: "web" }), machine("b", { name: "web" }), lan("c", { name: "web" }), lan("d", { name: "x" }), lan("e", { name: "x" })];
33
+ expect(canvasIssues(nodes, issueText)).toEqual(["two machines web", "two lans x"]);
34
+ });
35
+ });
@@ -0,0 +1,60 @@
1
+ import { isLan, isMachine, isSwitch, type LabNode } from "./model";
2
+ import { readPorts } from "./ports";
3
+ import { vlanName } from "./vlans";
4
+
5
+ /** The editor's words for what the canvas finds wrong (see the editor's text). */
6
+ export interface IssueText {
7
+ vlanBlocks: (vlan: string, block: string, other: string, sw: string) => string;
8
+ areaBlock: (lan: string, block: string, switches: string) => string;
9
+ noName: Record<"machine" | "lan" | "switch", string>;
10
+ sameName: Record<"machine" | "lan" | "switch", (name: string) => string>;
11
+ /** A ports field entry Isoloom couldn't take: the machine and the entry. */
12
+ badPort: (machine: string, entry: string) => string;
13
+ }
14
+
15
+ /** One VLAN, one block: every switch (and a LAN area of that name) must agree on it. */
16
+ function blockIssues(nodes: LabNode[], text: IssueText): string[] {
17
+ const issues: string[] = [];
18
+ const blocks = new Map<string, string>();
19
+ for (const sw of nodes.filter(isSwitch)) {
20
+ for (const v of sw.data.vlans) {
21
+ const name = vlanName(v.id);
22
+ const seen = blocks.get(name);
23
+ if (seen && seen !== v.cidr) issues.push(text.vlanBlocks(name, seen, v.cidr, sw.data.name));
24
+ blocks.set(name, v.cidr);
25
+ }
26
+ }
27
+ for (const l of nodes.filter(isLan)) {
28
+ const seen = blocks.get(l.data.name);
29
+ if (seen && seen !== l.data.cidr) issues.push(text.areaBlock(l.data.name, l.data.cidr, seen));
30
+ }
31
+ return issues;
32
+ }
33
+
34
+ /** Names Isoloom would reject (none) or merge (the same twice), per kind of node. */
35
+ function nameIssues(nodes: LabNode[], text: IssueText): string[] {
36
+ const issues: string[] = [];
37
+ for (const [what, list] of [
38
+ ["machine", nodes.filter(isMachine)],
39
+ ["lan", nodes.filter(isLan)],
40
+ ["switch", nodes.filter(isSwitch)],
41
+ ] as const) {
42
+ const seen = new Set<string>();
43
+ for (const n of list) {
44
+ if (!n.data.name) issues.push(text.noName[what]);
45
+ else if (seen.has(n.data.name)) issues.push(text.sameName[what](n.data.name));
46
+ seen.add(n.data.name);
47
+ }
48
+ }
49
+ return issues;
50
+ }
51
+
52
+ /** Port entries Isoloom couldn't take (not a port, or not one of its protocols): left out of the topology, so said here. */
53
+ function portIssues(nodes: LabNode[], text: IssueText): string[] {
54
+ return nodes.filter(isMachine).flatMap((m) => readPorts(m.data.ports).bad.map((entry) => text.badPort(m.data.name, entry)));
55
+ }
56
+
57
+ /** What the canvas can tell before the engine runs: names Isoloom would reject or merge, ports it couldn't take. */
58
+ export function canvasIssues(nodes: LabNode[], text: IssueText): string[] {
59
+ return [...blockIssues(nodes, text), ...nameIssues(nodes, text), ...portIssues(nodes, text)];
60
+ }