@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,112 @@
1
+ "use client";
2
+
3
+ import { useContext, useMemo } from "react";
4
+ import { BaseEdge, EdgeLabelRenderer, Position, getSmoothStepPath, getStraightPath, type EdgeProps } from "@xyflow/react";
5
+ import { Unplug } from "lucide-react";
6
+ import { conditionsLabel } from "@/lib/tool/conditions";
7
+ import type { CableStatus } from "@/lib/tool/live";
8
+ import type { CableEdge } from "@/lib/tool/model";
9
+ import { cn } from "@/lib/utils";
10
+ import { useText } from "../text";
11
+ import { LiveContext } from "./contexts";
12
+ import { Sketch, gen, seedOf, useBusiness, useSketch } from "./sketch";
13
+
14
+ interface Point {
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ const translate = (p: Point) => `translate(-50%, -50%) translate(${String(p.x)}px, ${String(p.y)}px)`;
20
+
21
+ function PortLabel({ at, text }: { at: Point; text?: string }) {
22
+ if (!text) return null;
23
+ return (
24
+ <div style={{ transform: translate(at) }} className="nodrag nopan pointer-events-none absolute rounded-sm border bg-card px-1 py-px font-mono text-[10px] leading-none text-muted-foreground">
25
+ {text}
26
+ </div>
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Where a cable runs and where its labels sit. Business: right angles with rounded corners, as in
32
+ * a diagramming tool, a port's name just off its port in the direction the cable leaves; sketch:
33
+ * straight, the names along the line.
34
+ */
35
+ function cableGeometry(p: EdgeProps<CableEdge>, business: boolean) {
36
+ const { sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition } = p;
37
+ const [path, midX, midY] = business
38
+ ? getSmoothStepPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, borderRadius: 10, offset: 18 })
39
+ : getStraightPath({ sourceX, sourceY, targetX, targetY });
40
+ const along = (f: number) => ({ x: sourceX + (targetX - sourceX) * f, y: sourceY + (targetY - sourceY) * f });
41
+ const off = (x: number, y: number, side: Position) => ({
42
+ x: x + (side === Position.Left ? -18 : side === Position.Right ? 18 : 0),
43
+ y: y + (side === Position.Top ? -14 : side === Position.Bottom ? 14 : 0),
44
+ });
45
+ return {
46
+ path,
47
+ middle: business ? { x: midX, y: midY } : along(0.5),
48
+ a: business ? off(sourceX, sourceY, sourcePosition) : along(0.18),
49
+ b: business ? off(targetX, targetY, targetPosition) : along(0.82),
50
+ };
51
+ }
52
+
53
+ /** The running lab: a cable says whether it carries traffic. */
54
+ function LiveBadge({ live, at }: { live: Exclude<CableStatus, "unknown">; at: Point }) {
55
+ const t = useText().canvas.live;
56
+ return (
57
+ <div title={t.cable[live]} style={{ transform: translate(at) }} className="nodrag nopan pointer-events-auto absolute">
58
+ {live === "up" ? (
59
+ <span className="block size-2 rounded-full bg-success ring-2 ring-background" aria-label={t.cable.up} />
60
+ ) : (
61
+ <span className={cn("flex items-center gap-1 rounded-sm border bg-card px-1 py-px text-[10px] leading-none whitespace-nowrap", live === "cut" ? "border-destructive/50 text-destructive" : "text-muted-foreground")}>
62
+ <Unplug className="size-2.5" aria-hidden /> {t.cableShort[live]}
63
+ </span>
64
+ )}
65
+ </div>
66
+ );
67
+ }
68
+
69
+ /** The line itself, in the color of the VLAN or network on the port it's plugged into. */
70
+ function CableLine({ p, path, live, stroke }: { p: EdgeProps<CableEdge>; path: string; live: CableStatus | undefined; stroke: string }) {
71
+ const business = useBusiness();
72
+ const sketch = useSketch();
73
+ const { id, sourceX, sourceY, targetX, targetY, selected } = p;
74
+ const line = useMemo(
75
+ () => gen.line(sourceX, sourceY, targetX, targetY, { ...sketch, seed: seedOf(id), stroke: "S", strokeWidth: selected ? 2.5 : 1.5 }),
76
+ [id, sourceX, sourceY, targetX, targetY, selected, sketch],
77
+ );
78
+ const broken = live === "cut" || live === "down";
79
+ if (business) {
80
+ return <path d={path} fill="none" style={{ stroke, strokeWidth: selected ? 2.25 : 1.5, strokeLinejoin: "round", strokeDasharray: broken ? "6 5" : undefined, opacity: live === "down" ? 0.4 : 1 }} />;
81
+ }
82
+ const opacity = live === "down" ? 0.35 : selected ? 1 : p.data?.color ? 0.95 : 0.7;
83
+ return <Sketch drawable={line} stroke={stroke} opacity={opacity} dash={broken ? "7 6" : undefined} />;
84
+ }
85
+
86
+ /** A cable: a hand-drawn line with the port name next to each end, as in GNS3. */
87
+ export function CableEdgeView(p: EdgeProps<CableEdge>) {
88
+ const { id, data, selected } = p;
89
+ const business = useBusiness();
90
+ const { path, middle, a, b } = cableGeometry(p, business);
91
+ const live = useContext(LiveContext)?.cables[id];
92
+ const stroke = selected ? "var(--signal)" : live === "cut" ? "var(--destructive)" : (data?.color ?? (business ? "#64748b" : "var(--foreground)"));
93
+ const conditions = conditionsLabel(data?.tc);
94
+ return (
95
+ <>
96
+ {/* The straight path is invisible: it only gives the cable a wide, easy target to click. */}
97
+ <BaseEdge id={id} path={path} interactionWidth={18} style={{ stroke: "transparent" }} />
98
+ <CableLine p={p} path={path} live={live} stroke={stroke} />
99
+ <EdgeLabelRenderer>
100
+ {live && live !== "unknown" && <LiveBadge live={live} at={{ x: middle.x, y: middle.y + (conditions ? 14 : 0) }} />}
101
+ <PortLabel at={a} text={data?.aPort} />
102
+ <PortLabel at={b} text={data?.bPort} />
103
+ {/* A slow or lossy cable says so in its middle. */}
104
+ {conditions && (
105
+ <div style={{ transform: translate(middle) }} className="nodrag nopan pointer-events-none absolute rounded-sm border bg-pastel-yellow px-1 py-px font-mono text-[10px] leading-none whitespace-nowrap text-foreground">
106
+ {conditions}
107
+ </div>
108
+ )}
109
+ </EdgeLabelRenderer>
110
+ </>
111
+ );
112
+ }
@@ -0,0 +1,18 @@
1
+ import { createContext } from "react";
2
+ import type { LiveView } from "@/lib/tool/live";
3
+ import type { Side } from "@/lib/tool/model";
4
+ import type { Addresses, RouterPorts } from "@/lib/tool/router-ports";
5
+
6
+ /** What the canvas's nodes and cables read from the editor, without props through React Flow. */
7
+
8
+ /** Each machine's addresses (from the engine), by machine name: [network, IPv4][]. */
9
+ export const AddressesContext = createContext<Addresses>({});
10
+
11
+ /** Each real router's interfaces, by node id. */
12
+ export const PortsContext = createContext<Partial<Record<string, RouterPorts>>>({});
13
+
14
+ /** The running lab as the desktop app reads it (null: not running, or in a browser). */
15
+ export const LiveContext = createContext<LiveView | null>(null);
16
+
17
+ /** The LAN border each appliance sits on, by node id (so its port can face out), and the side each switch or router port faces, by `<id>:<port>`. */
18
+ export const SidesContext = createContext<Partial<Record<string, Side>>>({});
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ import type { CSSProperties } from "react";
4
+ import { Container, Laptop, Monitor, Router, Server, Shield, type LucideIcon } from "lucide-react";
5
+ import type { SimpleIcon } from "simple-icons";
6
+ import { dockerLogo, isDark, logoFor } from "@/lib/tool/logos";
7
+ import type { IconId, MachineData } from "@/lib/tool/model";
8
+ import { cn } from "@/lib/utils";
9
+
10
+ /** By icon id; a node from a link or an old draft may name one we don't have: a container then. */
11
+ const ICONS: Partial<Record<string, LucideIcon>> = {
12
+ container: Container,
13
+ server: Server,
14
+ router: Router,
15
+ shield: Shield,
16
+ monitor: Monitor,
17
+ laptop: Laptop,
18
+ };
19
+
20
+ /** What a machine is for, as people say it: its role in the lab. */
21
+ export function roleOf(m: Pick<MachineData, "kind" | "icon" | "image">): "router" | "firewall" | "workstation" | "server" | "desktop" | "host" {
22
+ if (m.icon === "router") return "router";
23
+ if (m.icon === "shield") return "firewall";
24
+ if (m.icon === "laptop") return "workstation";
25
+ if (m.icon === "server") return "server";
26
+ if (m.icon === "monitor") return m.kind === "vm" ? "host" : "desktop";
27
+ return "host";
28
+ }
29
+
30
+ /** Business: a device's accent, by what it is (the pastels' strong counterparts). */
31
+ export function accentFor(m: Pick<MachineData, "kind" | "icon">): string {
32
+ if (m.icon === "router") return "#d97706";
33
+ if (m.icon === "shield") return "#dc2626";
34
+ if (m.icon === "laptop") return "#7c3aed";
35
+ return m.kind === "vm" ? "#0d9488" : "#2563eb";
36
+ }
37
+
38
+ /** Each kind of device has its pastel, as on a whiteboard. */
39
+ export function pastelFor(m: Pick<MachineData, "kind" | "icon">): string {
40
+ if (m.icon === "router") return "var(--pastel-yellow)";
41
+ if (m.icon === "shield") return "var(--pastel-red)";
42
+ if (m.icon === "laptop") return "var(--pastel-violet)";
43
+ return m.kind === "vm" ? "var(--pastel-green)" : "var(--pastel-blue)";
44
+ }
45
+
46
+ /** Roles whose icon says more than the OS logo would: the logo becomes a small badge. */
47
+ const ROLE_ICONS: IconId[] = ["router", "laptop", "shield"];
48
+
49
+ /**
50
+ * A brand logo in its own color. On the dark theme the color is lifted toward white, so deep
51
+ * brand colors (Alpine, Kali, PostgreSQL) stay readable while keeping their hue.
52
+ */
53
+ function BrandLogo({ icon, className }: { icon: SimpleIcon; className?: string }) {
54
+ const dark = isDark(icon.hex);
55
+ return (
56
+ <svg
57
+ viewBox="0 0 24 24"
58
+ role="img"
59
+ aria-label={icon.title}
60
+ className={cn(className, !dark && "fill-[var(--logo)] dark:fill-[color-mix(in_oklab,var(--logo)_60%,white)]")}
61
+ style={{ "--logo": `#${icon.hex}` } as CSSProperties}
62
+ >
63
+ <path d={icon.path} fill={dark ? "currentColor" : undefined} />
64
+ </svg>
65
+ );
66
+ }
67
+
68
+ /** The tile's look: tinted with the accent (business), the pastel (a small one), or plain. */
69
+ function tileStyle(data: Pick<MachineData, "kind" | "icon">, size: "sm" | "md", accent: string | undefined): CSSProperties | undefined {
70
+ if (accent) return { backgroundColor: `color-mix(in oklab, ${accent} 11%, var(--card))`, boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${accent} 22%, transparent)` };
71
+ return size === "sm" ? { backgroundColor: pastelFor(data) } : undefined;
72
+ }
73
+
74
+ /**
75
+ * A device's picture: the logo of what it runs (its OS or image) when we know one, otherwise
76
+ * a generic icon. Routers and workstations keep their role icon, with the OS logo as a badge.
77
+ */
78
+ export function DeviceIcon({
79
+ data,
80
+ size = "md",
81
+ accent,
82
+ }: {
83
+ data: Pick<MachineData, "kind" | "image" | "os" | "icon">;
84
+ size?: "sm" | "md";
85
+ /** Business: the tile takes this tint, its glyph this color. */
86
+ accent?: string;
87
+ }) {
88
+ const logo = logoFor(data) ?? (data.kind === "container" ? dockerLogo : null);
89
+ const role = ROLE_ICONS.includes(data.icon);
90
+ const Icon = ICONS[data.icon] ?? Container;
91
+ const glyph = size === "md" ? "size-6" : "size-4";
92
+ return (
93
+ <span
94
+ className={cn("relative grid shrink-0 place-items-center bg-background text-foreground ring-1 ring-foreground/10", size === "md" ? "size-11 rounded-lg" : "size-8 rounded-md")}
95
+ style={tileStyle(data, size, accent)}
96
+ >
97
+ {logo && !role ? <BrandLogo icon={logo} className={cn(glyph, "text-foreground")} /> : <Icon className={glyph} style={accent ? { color: accent } : undefined} aria-hidden />}
98
+ {logo && role && (
99
+ <span className="absolute -right-1.5 -bottom-1.5 grid size-5 place-items-center rounded-full border bg-card shadow-sm">
100
+ <BrandLogo icon={logo} className="size-3 text-foreground" />
101
+ </span>
102
+ )}
103
+ </span>
104
+ );
105
+ }
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import { useState, type KeyboardEvent } from "react";
4
+ import { cn } from "@/lib/utils";
5
+ import { useText } from "../text";
6
+
7
+ interface InlineTextProps {
8
+ value: string;
9
+ onCommit: (v: string) => void;
10
+ clean?: (v: string) => string;
11
+ className?: string;
12
+ label: string;
13
+ /** Clearing the field means something (e.g. "back to automatic"), so an empty value counts. */
14
+ allowEmpty?: boolean;
15
+ }
16
+
17
+ /** The field while editing: as wide as the text, not the box around it. */
18
+ function InlineField({ draft, setDraft, done, clean, className, label }: { draft: string; setDraft: (v: string) => void; done: (keep: boolean) => void } & Pick<InlineTextProps, "clean" | "className" | "label">) {
19
+ return (
20
+ <input
21
+ autoFocus
22
+ aria-label={label}
23
+ value={draft}
24
+ // As wide as the text, not the box around it: the width follows what is typed.
25
+ size={1}
26
+ style={{ width: `${String(Math.max(3, draft.length) + 1.5)}ch`, fieldSizing: "content", minWidth: "3ch" }}
27
+ onChange={(e) => {
28
+ setDraft(clean ? clean(e.target.value) : e.target.value);
29
+ }}
30
+ onBlur={() => {
31
+ done(true);
32
+ }}
33
+ onKeyDown={(e: KeyboardEvent) => {
34
+ e.stopPropagation();
35
+ if (e.key === "Enter") done(true);
36
+ if (e.key === "Escape") done(false);
37
+ }}
38
+ className={cn("nodrag nopan w-auto max-w-[260px] rounded-sm bg-background px-0.5 outline-none ring-2 ring-signal", className)}
39
+ />
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Text edited in place, as in Excalidraw: double-click to edit, Enter or a click elsewhere to
45
+ * keep, Escape to drop. `nodrag` so typing never moves the node.
46
+ */
47
+ export function InlineText({ value, onCommit, clean, className, label, allowEmpty = false }: InlineTextProps) {
48
+ const t = useText().canvas;
49
+ const [draft, setDraft] = useState<string | null>(null);
50
+ if (draft === null) {
51
+ return (
52
+ <span
53
+ title={t.editTitle(label)}
54
+ onDoubleClick={(e) => {
55
+ e.stopPropagation();
56
+ setDraft(value);
57
+ }}
58
+ className={cn("cursor-text", className)}
59
+ >
60
+ {value || t.unnamed}
61
+ </span>
62
+ );
63
+ }
64
+ const done = (keep: boolean) => {
65
+ if (keep && (draft.trim() || allowEmpty)) onCommit(draft.trim());
66
+ setDraft(null);
67
+ };
68
+ return <InlineField draft={draft} setDraft={setDraft} done={done} clean={clean} className={className} label={label} />;
69
+ }
@@ -0,0 +1,148 @@
1
+ "use client";
2
+
3
+ import { useContext, useMemo } from "react";
4
+ import { Handle, NodeResizer, Position, useReactFlow, type NodeProps } from "@xyflow/react";
5
+ import { Eye, EyeOff, Globe, GlobeLock } from "lucide-react";
6
+ import { conditionsLabel } from "@/lib/tool/conditions";
7
+ import { LAN_H, LAN_W, cleanName, type LanData, type LanNode } from "@/lib/tool/model";
8
+ import { vlanColor, vlanOf } from "@/lib/tool/vlans";
9
+ import { cn } from "@/lib/utils";
10
+ import { useText } from "../text";
11
+ import { AddressesContext, LiveContext } from "./contexts";
12
+ import { InlineText } from "./inline-text";
13
+ import { SKETCH, Sketch, gen, seedOf, useSketch, type Drawable } from "./sketch";
14
+
15
+ const SIDES = [
16
+ ["top", Position.Top],
17
+ ["right", Position.Right],
18
+ ["bottom", Position.Bottom],
19
+ ["left", Position.Left],
20
+ ] as const;
21
+
22
+ /** Business: a panel, its header a band in the LAN's color. */
23
+ function LanPanel({ tint, selected }: { tint: string | null; selected: boolean }) {
24
+ const color = tint ?? "#2563eb";
25
+ return (
26
+ <div
27
+ aria-hidden
28
+ className={cn("pointer-events-none absolute inset-0 rounded-xl border", selected && "ring-2 ring-signal/25")}
29
+ style={{ background: `color-mix(in oklab, ${color} 4%, var(--background))`, borderColor: selected ? "var(--signal)" : `color-mix(in oklab, ${color} 30%, var(--border))` }}
30
+ >
31
+ <div className="h-10 rounded-t-xl border-b" style={{ background: `color-mix(in oklab, ${color} 9%, var(--background))`, borderColor: `color-mix(in oklab, ${color} 18%, var(--border))` }} />
32
+ </div>
33
+ );
34
+ }
35
+
36
+ /** Sketch: a dashed area in the LAN's tint (a VLAN's color), or a pale blue. */
37
+ function LanSketch({ frame, tint, selected, w, h }: { frame: Drawable; tint: string | null; selected: boolean; w: number; h: number }) {
38
+ return (
39
+ <svg aria-hidden className="pointer-events-none absolute inset-0 overflow-visible" width={w} height={h}>
40
+ <Sketch
41
+ drawable={frame}
42
+ fill={tint ? `color-mix(in oklab, ${tint} 9%, transparent)` : "color-mix(in oklab, var(--pastel-blue) 45%, transparent)"}
43
+ stroke={selected ? "var(--signal)" : (tint ?? "var(--muted-foreground)")}
44
+ dash="10 7"
45
+ />
46
+ </svg>
47
+ );
48
+ }
49
+
50
+ /** Where cables plug in (kept while the area is hidden, cables may hang on them), and the border: the LAN's grip, to move it and everything in it. */
51
+ function LanGrips({ hidden }: { hidden: boolean }) {
52
+ const grips = ["top-0 inset-x-0 h-3 -translate-y-1/2", "bottom-0 inset-x-0 h-3 translate-y-1/2", "left-0 inset-y-0 w-3 -translate-x-1/2", "right-0 inset-y-0 w-3 translate-x-1/2"] as const;
53
+ return (
54
+ <>
55
+ {SIDES.map(([side, position]) => (
56
+ <Handle
57
+ key={side}
58
+ id={side}
59
+ type="source"
60
+ position={position}
61
+ style={hidden ? { opacity: 0 } : undefined}
62
+ className="pointer-events-auto !z-10 !size-3 !border-2 !border-background !bg-muted-foreground/40 transition-colors hover:!bg-signal"
63
+ />
64
+ ))}
65
+ {!hidden && grips.map((place) => <div key={place} aria-hidden className={cn("lan-handle pointer-events-auto absolute cursor-move", place)} />)}
66
+ </>
67
+ );
68
+ }
69
+
70
+ /** Business: how many machines a LAN has. */
71
+ function LanHosts({ name }: { name: string }) {
72
+ const t = useText().canvas;
73
+ const n = Object.values(useContext(AddressesContext)).filter((ips) => ips?.some(([net]) => net === name)).length;
74
+ return <span className="rounded-sm px-1 text-[10px] whitespace-nowrap text-muted-foreground">{t.hosts(n)}</span>;
75
+ }
76
+
77
+ /** A running LAN's ports up, out of all: green when all are, amber when some aren't. */
78
+ function LanLive({ name }: { name: string }) {
79
+ const t = useText().canvas.live;
80
+ const lan = useContext(LiveContext)?.lans[name];
81
+ if (!lan?.total) return null;
82
+ const all = lan.up === lan.total;
83
+ return (
84
+ <span title={t.lanPorts(lan.up, lan.total)} className={cn("flex items-center gap-1 rounded-sm px-1 font-mono text-[10px] whitespace-nowrap", all ? "bg-success/15 text-success" : "bg-warning/20 text-foreground")}>
85
+ <span className={cn("size-1.5 rounded-full", all ? "bg-success" : "bg-warning")} aria-hidden />
86
+ {lan.up}/{lan.total}
87
+ </span>
88
+ );
89
+ }
90
+
91
+ /** The label: also a grip; double-click the name or the block to edit them in place. */
92
+ function LanLabel({ id, data, crisp }: { id: string; data: LanData; crisp: boolean }) {
93
+ const t = useText().canvas;
94
+ const { updateNodeData } = useReactFlow();
95
+ const hidden = !!data.hidden;
96
+ const conditions = conditionsLabel(data.tc);
97
+ const toggle = "nodrag grid size-5 place-items-center rounded text-muted-foreground hover:bg-muted hover:text-foreground";
98
+ return (
99
+ <div className="lan-handle pointer-events-auto absolute top-1.5 left-2 flex cursor-move items-center gap-2 rounded-md px-1.5 py-1 hover:bg-background/70">
100
+ <InlineText label={t.lanName} value={data.name} clean={cleanName} onCommit={(name) => { updateNodeData(id, { name }); }} className="font-heading text-base font-semibold" />
101
+ <InlineText
102
+ label={t.addressBlock}
103
+ value={data.cidr}
104
+ clean={(v) => v.replace(/[^0-9./]/g, "")}
105
+ onCommit={(cidr) => { updateNodeData(id, { cidr }); }}
106
+ className="font-mono text-[11px] text-muted-foreground in-data-[design=business]:rounded in-data-[design=business]:border in-data-[design=business]:bg-background in-data-[design=business]:px-1.5 in-data-[design=business]:py-px"
107
+ />
108
+ {conditions && <span className="rounded-sm bg-pastel-yellow/70 px-1 font-mono text-[10px] whitespace-nowrap text-foreground">{conditions}</span>}
109
+ {crisp && <LanHosts name={data.name} />}
110
+ <LanLive name={data.name} />
111
+ <button type="button" onClick={() => { updateNodeData(id, { internet: !data.internet }); }} title={data.internet ? t.internetOnTitle : t.internetOffTitle} aria-label={data.internet ? t.internetOn : t.internetOff} className={toggle}>
112
+ {data.internet ? <Globe className="size-3.5" /> : <GlobeLock className="size-3.5 text-warning" />}
113
+ </button>
114
+ <button type="button" onClick={() => { updateNodeData(id, { hidden: !hidden }); }} title={hidden ? t.showAreaTitle : t.hideAreaTitle} aria-label={hidden ? t.showArea : t.hideArea} className={toggle}>
115
+ {hidden ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
116
+ </button>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ /**
122
+ * A LAN on the canvas: a sketched, dashed area behind the machines, with its name and block.
123
+ * Machines inside it are on it; a cable from a machine to its edge puts that machine on it too
124
+ * (how a router joins several LANs). Resizable from its corners when selected.
125
+ */
126
+ export function LanNodeView({ id, data, selected, width, height }: NodeProps<LanNode>) {
127
+ const hidden = !!data.hidden;
128
+ // A LAN named vlan<n> is that VLAN: it takes the VLAN's color, as on the switches.
129
+ const vlan = vlanOf(data.name);
130
+ const tint = vlan === null ? null : vlanColor(vlan);
131
+ const w = width ?? LAN_W;
132
+ const h = height ?? LAN_H;
133
+ const sketch = useSketch();
134
+ const frame = useMemo(
135
+ () => gen.rectangle(2, 2, w - 4, h - 4, { ...sketch, roughness: sketch.roughness && 0.8, seed: seedOf(id), fill: "F", fillStyle: "solid", stroke: "S", strokeWidth: selected ? 2 : 1.4 }),
136
+ [id, w, h, selected, sketch],
137
+ );
138
+ return (
139
+ <div style={{ width: w, height: h }} className="group relative">
140
+ <NodeResizer isVisible={selected} minWidth={200} minHeight={160} lineClassName="!border-transparent" handleClassName="pointer-events-auto !z-10 !size-2.5 !rounded-sm !border-signal !bg-background" />
141
+ {/* Hidden: no rectangle, no grips; the label stays, to edit the LAN or show it again. */}
142
+ {!hidden && sketch === SKETCH && <LanSketch frame={frame} tint={tint} selected={selected} w={w} h={h} />}
143
+ {!hidden && sketch !== SKETCH && <LanPanel tint={tint} selected={selected} />}
144
+ <LanGrips hidden={hidden} />
145
+ <LanLabel id={id} data={data} crisp={sketch !== SKETCH} />
146
+ </div>
147
+ );
148
+ }
@@ -0,0 +1,117 @@
1
+ "use client";
2
+
3
+ import { useContext } from "react";
4
+ import { useReactFlow } from "@xyflow/react";
5
+ import { Disc3, Package, Pin } from "lucide-react";
6
+ import type { MachineData } from "@/lib/tool/model";
7
+ import { pinned } from "@/lib/tool/pins";
8
+ import { cn } from "@/lib/utils";
9
+ import { useText } from "../text";
10
+ import { AddressesContext, LiveContext } from "./contexts";
11
+ import { InlineText } from "./inline-text";
12
+
13
+ /** A machine's LAN addresses ([network, IPv4]; cables' /29 left out), whether each port is down, and fixing one by hand. */
14
+ function useAddresses(id: string, data: MachineData) {
15
+ const { updateNodeData } = useReactFlow();
16
+ const ips = (useContext(AddressesContext)[data.name] ?? []).filter(([net]) => !net.startsWith("link-"));
17
+ const ports = useContext(LiveContext)?.ports;
18
+ // Double-click an address to fix it by hand (same block, a new last number); clear it to go
19
+ // back to the next free one.
20
+ const pin = (net: string, ip: string, typed: string) => {
21
+ const pins = pinned(data.pins, net, ip, typed);
22
+ if (pins) updateNodeData(id, { pins });
23
+ };
24
+ const down = (net: string) => ports?.[`${data.name}/${net}`] === false;
25
+ return { ips, pin, down };
26
+ }
27
+
28
+ /** An address's tooltip: the network, whether it is fixed, and whether its port is down. */
29
+ function useAddressTitle(data: MachineData) {
30
+ const t = useText().canvas;
31
+ return (net: string, down: boolean) => {
32
+ const title = t.addressTitle(net, !!data.pins?.[net]);
33
+ return down ? `${t.live.portDown} · ${title}` : title;
34
+ };
35
+ }
36
+
37
+ const onlyAddressChars = (v: string) => v.replace(/[^0-9.]/g, "");
38
+
39
+ /** An address, edited in place. */
40
+ function Address({ id, data, net, ip, className }: { id: string; data: MachineData; net: string; ip: string; className?: string }) {
41
+ const t = useText().canvas;
42
+ const { pin } = useAddresses(id, data);
43
+ return <InlineText allowEmpty label={t.addressOn(net)} value={ip} clean={onlyAddressChars} onCommit={(v) => { pin(net, ip, v); }} className={className} />;
44
+ }
45
+
46
+ /** What a machine runs, in a short form: its OS, or its image's last part. */
47
+ function runsOf(data: MachineData): string | undefined {
48
+ if (data.kind === "vm") return data.os;
49
+ return data.kind === "container" ? data.image.split("/").pop() : "";
50
+ }
51
+
52
+ /**
53
+ * Business: what an asset card says about a machine, in labelled rows: its addresses (two at
54
+ * most, then how many more), and what it runs (its image or OS).
55
+ */
56
+ export function MachineFacts({ id, data }: { id: string; data: MachineData }) {
57
+ const t = useText().canvas;
58
+ const { ips, down } = useAddresses(id, data);
59
+ const title = useAddressTitle(data);
60
+ const runs = runsOf(data);
61
+ const shown = ips.slice(0, 2);
62
+ // The automatic router has no address of its own: nothing to list.
63
+ if (!shown.length && !runs) return null;
64
+ return (
65
+ <dl className="mt-2 grid w-full grid-cols-[12px_minmax(0,1fr)] items-center gap-x-1.5 gap-y-[3px] border-t pt-1.5 text-[10px] leading-tight">
66
+ {shown.map(([net, ip], i) => (
67
+ <div key={net} className="contents">
68
+ <dt className="text-[8.5px] font-semibold text-muted-foreground/80">{i === 0 ? "IP" : ""}</dt>
69
+ <dd title={title(net, down(net))} className={cn("nodrag flex min-w-0 items-center gap-0.5 font-mono", down(net) && "text-destructive line-through")}>
70
+ <Address id={id} data={data} net={net} ip={ip} className="truncate" />
71
+ {data.pins?.[net] !== undefined && <Pin className="size-2.5 shrink-0 text-signal" aria-label={t.fixed} />}
72
+ {i === 1 && ips.length > 2 && (
73
+ <span className="shrink-0 font-sans text-muted-foreground" title={ips.slice(2).map(([n, a]) => `${n}: ${a}`).join("\n")}>
74
+ +{ips.length - 2}
75
+ </span>
76
+ )}
77
+ </dd>
78
+ </div>
79
+ ))}
80
+ {runs && (
81
+ <>
82
+ <dt className="text-muted-foreground/80" title={data.kind === "vm" ? "OS" : t.imageShort}>
83
+ {data.kind === "vm" ? <Disc3 className="size-3" aria-label="OS" /> : <Package className="size-3" aria-label={t.imageShort} />}
84
+ </dt>
85
+ <dd className="truncate font-mono text-muted-foreground" title={data.kind === "vm" ? data.os : data.image}>
86
+ {runs}
87
+ </dd>
88
+ </>
89
+ )}
90
+ </dl>
91
+ );
92
+ }
93
+
94
+ /** The machine's IPs, under its box, as on a network diagram. */
95
+ export function MachineAddresses({ id, data }: { id: string; data: MachineData }) {
96
+ const t = useText().canvas;
97
+ const { ips, down } = useAddresses(id, data);
98
+ const title = useAddressTitle(data);
99
+ if (!ips.length) return null;
100
+ return (
101
+ <div className="pointer-events-none absolute top-full left-1/2 mt-1.5 flex -translate-x-1/2 flex-col items-center gap-0.5">
102
+ {ips.map(([net, ip]) => (
103
+ <span
104
+ key={net}
105
+ title={title(net, down(net))}
106
+ className={cn(
107
+ "nodrag pointer-events-auto flex items-center gap-0.5 rounded-sm bg-background/90 px-1 font-mono text-[10px] leading-tight whitespace-nowrap text-foreground",
108
+ down(net) && "text-destructive line-through",
109
+ )}
110
+ >
111
+ {data.pins?.[net] !== undefined && <Pin className="size-2.5 text-signal" aria-label={t.fixed} />}
112
+ <Address id={id} data={data} net={net} ip={ip} />
113
+ </span>
114
+ ))}
115
+ </div>
116
+ );
117
+ }