@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.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/PUBLISHING.md +93 -0
- package/README.md +106 -2
- package/desktop-editor/editor.css +11 -0
- package/desktop-editor/index.html +14 -0
- package/desktop-editor/main.tsx +26 -0
- package/dist/desktop/editor.css +1 -0
- package/dist/desktop/editor.js +181 -0
- package/dist/desktop/fonts/geist-cyrillic-ext-wght-normal-RO72NYA2.woff2 +0 -0
- package/dist/desktop/fonts/geist-cyrillic-wght-normal-CK2XY27K.woff2 +0 -0
- package/dist/desktop/fonts/geist-latin-ext-wght-normal-XEQ56JUF.woff2 +0 -0
- package/dist/desktop/fonts/geist-latin-wght-normal-T72VVCUW.woff2 +0 -0
- package/dist/desktop/fonts/geist-mono-cyrillic-ext-wght-normal-QXKVIKFD.woff2 +0 -0
- package/dist/desktop/fonts/geist-mono-cyrillic-wght-normal-GOIAQAGU.woff2 +0 -0
- package/dist/desktop/fonts/geist-mono-latin-ext-wght-normal-HU3ZAWN6.woff2 +0 -0
- package/dist/desktop/fonts/geist-mono-latin-wght-normal-CJNQKXUG.woff2 +0 -0
- package/dist/desktop/fonts/geist-mono-symbols2-wght-normal-WZJJVLQO.woff2 +0 -0
- package/dist/desktop/fonts/geist-mono-vietnamese-wght-normal-KW5NM6YV.woff2 +0 -0
- package/dist/desktop/fonts/geist-vietnamese-wght-normal-Y2R7JZKV.woff2 +0 -0
- package/dist/desktop/fonts/shantell-sans-cyrillic-ext-wght-normal-ILPQWRJJ.woff2 +0 -0
- package/dist/desktop/fonts/shantell-sans-cyrillic-wght-normal-RKC6NDPM.woff2 +0 -0
- package/dist/desktop/fonts/shantell-sans-latin-ext-wght-normal-SNKHG3LM.woff2 +0 -0
- package/dist/desktop/fonts/shantell-sans-latin-wght-normal-7CHHGC3N.woff2 +0 -0
- package/dist/desktop/fonts/shantell-sans-vietnamese-wght-normal-IPFAQSBA.woff2 +0 -0
- package/dist/desktop/index.html +14 -0
- package/dist/desktop/styles.css +2 -0
- package/dist/desktop/wasm/isothrum_wasm.d.ts +60 -0
- package/dist/desktop/wasm/isothrum_wasm.js +312 -0
- package/dist/desktop/wasm/isothrum_wasm_bg.wasm +0 -0
- package/dist/index.js +7059 -0
- package/dist/index.js.map +7 -0
- package/dist/model.js +745 -0
- package/dist/model.js.map +7 -0
- package/dist/types/components/tool/canvas/cable-edge.d.ts +4 -0
- package/dist/types/components/tool/canvas/contexts.d.ts +12 -0
- package/dist/types/components/tool/canvas/device-icon.d.ts +17 -0
- package/dist/types/components/tool/canvas/inline-text.d.ts +15 -0
- package/dist/types/components/tool/canvas/lan-node.d.ts +8 -0
- package/dist/types/components/tool/canvas/machine-addresses.d.ts +14 -0
- package/dist/types/components/tool/canvas/machine-node.d.ts +4 -0
- package/dist/types/components/tool/canvas/note-node.d.ts +7 -0
- package/dist/types/components/tool/canvas/port-led.d.ts +10 -0
- package/dist/types/components/tool/canvas/router-node.d.ts +8 -0
- package/dist/types/components/tool/canvas/sketch.d.ts +50 -0
- package/dist/types/components/tool/canvas/status-dot.d.ts +5 -0
- package/dist/types/components/tool/canvas/switch-node.d.ts +8 -0
- package/dist/types/components/tool/canvas/use-measured-ports.d.ts +7 -0
- package/dist/types/components/tool/context-menus.d.ts +5 -0
- package/dist/types/components/tool/device-block.d.ts +12 -0
- package/dist/types/components/tool/editor-menu.d.ts +28 -0
- package/dist/types/components/tool/export/copy-button.d.ts +9 -0
- package/dist/types/components/tool/export/export-panel.d.ts +17 -0
- package/dist/types/components/tool/export/target-list.d.ts +15 -0
- package/dist/types/components/tool/floating-window.d.ts +13 -0
- package/dist/types/components/tool/flow-canvas.d.ts +6 -0
- package/dist/types/components/tool/hooks/use-cabling.d.ts +23 -0
- package/dist/types/components/tool/hooks/use-captures.d.ts +9 -0
- package/dist/types/components/tool/hooks/use-clipboard.d.ts +12 -0
- package/dist/types/components/tool/hooks/use-context-menus.d.ts +25 -0
- package/dist/types/components/tool/hooks/use-design.d.ts +10 -0
- package/dist/types/components/tool/hooks/use-desktop.d.ts +50 -0
- package/dist/types/components/tool/hooks/use-devices.d.ts +17 -0
- package/dist/types/components/tool/hooks/use-downloads.d.ts +9 -0
- package/dist/types/components/tool/hooks/use-editor.d.ts +609 -0
- package/dist/types/components/tool/hooks/use-engine.d.ts +9 -0
- package/dist/types/components/tool/hooks/use-history.d.ts +9 -0
- package/dist/types/components/tool/hooks/use-lab-actions.d.ts +25 -0
- package/dist/types/components/tool/hooks/use-lab-run.d.ts +25 -0
- package/dist/types/components/tool/hooks/use-lab.d.ts +18 -0
- package/dist/types/components/tool/hooks/use-lan-carry.d.ts +8 -0
- package/dist/types/components/tool/hooks/use-machine-actions.d.ts +26 -0
- package/dist/types/components/tool/hooks/use-placement.d.ts +27 -0
- package/dist/types/components/tool/hooks/use-project.d.ts +25 -0
- package/dist/types/components/tool/hooks/use-projects.d.ts +34 -0
- package/dist/types/components/tool/hooks/use-shared-link.d.ts +23 -0
- package/dist/types/components/tool/hooks/use-toast.d.ts +2 -0
- package/dist/types/components/tool/hooks/use-tools.d.ts +17 -0
- package/dist/types/components/tool/lab-canvas.d.ts +18 -0
- package/dist/types/components/tool/overlays.d.ts +5 -0
- package/dist/types/components/tool/projects-list.d.ts +9 -0
- package/dist/types/components/tool/text.d.ts +418 -0
- package/dist/types/components/tool/toolbar.d.ts +5 -0
- package/dist/types/components/tool/top-bars.d.ts +10 -0
- package/dist/types/components/tool/windows/conditions-fields.d.ts +6 -0
- package/dist/types/components/tool/windows/editor-windows.d.ts +5 -0
- package/dist/types/components/tool/windows/fields.d.ts +4 -0
- package/dist/types/components/tool/windows/lab-status.d.ts +6 -0
- package/dist/types/components/tool/windows/lab-windows.d.ts +13 -0
- package/dist/types/components/tool/windows/link-controls.d.ts +21 -0
- package/dist/types/components/tool/windows/machine-controls.d.ts +16 -0
- package/dist/types/components/tool/windows/machine-inspector.d.ts +11 -0
- package/dist/types/components/tool/windows/runs-fields.d.ts +18 -0
- package/dist/types/components/tool/windows/selected-windows.d.ts +21 -0
- package/dist/types/components/ui/button.d.ts +8 -0
- package/dist/types/components/ui/dropdown-menu.d.ts +21 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/lib/auth.d.ts +38 -0
- package/dist/types/lib/brand.d.ts +19 -0
- package/dist/types/lib/i18n.d.ts +19 -0
- package/dist/types/lib/tool/blocks.d.ts +35 -0
- package/dist/types/lib/tool/cables.d.ts +48 -0
- package/dist/types/lib/tool/clone.d.ts +12 -0
- package/dist/types/lib/tool/conditions.d.ts +31 -0
- package/dist/types/lib/tool/create.d.ts +22 -0
- package/dist/types/lib/tool/desktop.d.ts +63 -0
- package/dist/types/lib/tool/draft.d.ts +20 -0
- package/dist/types/lib/tool/engine.d.ts +33 -0
- package/dist/types/lib/tool/examples.d.ts +11 -0
- package/dist/types/lib/tool/export-targets.d.ts +21 -0
- package/dist/types/lib/tool/facing.d.ts +9 -0
- package/dist/types/lib/tool/geometry.d.ts +36 -0
- package/dist/types/lib/tool/gns3-cables.d.ts +17 -0
- package/dist/types/lib/tool/gns3-import.d.ts +15 -0
- package/dist/types/lib/tool/gns3-parts.d.ts +25 -0
- package/dist/types/lib/tool/issues.d.ts +12 -0
- package/dist/types/lib/tool/layout.d.ts +52 -0
- package/dist/types/lib/tool/live.d.ts +21 -0
- package/dist/types/lib/tool/logos.d.ts +7 -0
- package/dist/types/lib/tool/model.d.ts +173 -0
- package/dist/types/lib/tool/os.d.ts +20 -0
- package/dist/types/lib/tool/pins.d.ts +5 -0
- package/dist/types/lib/tool/placement.d.ts +21 -0
- package/dist/types/lib/tool/ports.d.ts +21 -0
- package/dist/types/lib/tool/presets.d.ts +21 -0
- package/dist/types/lib/tool/projects-api.d.ts +37 -0
- package/dist/types/lib/tool/router-ports.d.ts +22 -0
- package/dist/types/lib/tool/selection.d.ts +19 -0
- package/dist/types/lib/tool/storage.d.ts +25 -0
- package/dist/types/lib/tool/switch-edit.d.ts +17 -0
- package/dist/types/lib/tool/text/de.d.ts +2 -0
- package/dist/types/lib/tool/text/en.d.ts +429 -0
- package/dist/types/lib/tool/text/es.d.ts +2 -0
- package/dist/types/lib/tool/text/fr.d.ts +2 -0
- package/dist/types/lib/tool/text/index.d.ts +419 -0
- package/dist/types/lib/tool/topology.d.ts +2 -0
- package/dist/types/lib/tool/vlans.d.ts +24 -0
- package/dist/types/lib/utils.d.ts +2 -0
- package/dist/types/model.d.ts +13 -0
- package/package.json +88 -4
- package/scripts/build-desktop-editor.mjs +43 -0
- package/scripts/build-lib.mjs +38 -0
- package/src/components/tool/canvas/cable-edge.tsx +112 -0
- package/src/components/tool/canvas/contexts.ts +18 -0
- package/src/components/tool/canvas/device-icon.tsx +105 -0
- package/src/components/tool/canvas/inline-text.tsx +69 -0
- package/src/components/tool/canvas/lan-node.tsx +148 -0
- package/src/components/tool/canvas/machine-addresses.tsx +117 -0
- package/src/components/tool/canvas/machine-node.tsx +114 -0
- package/src/components/tool/canvas/note-node.tsx +109 -0
- package/src/components/tool/canvas/port-led.tsx +21 -0
- package/src/components/tool/canvas/router-node.tsx +114 -0
- package/src/components/tool/canvas/sketch.tsx +130 -0
- package/src/components/tool/canvas/status-dot.tsx +29 -0
- package/src/components/tool/canvas/switch-node.tsx +143 -0
- package/src/components/tool/canvas/use-measured-ports.ts +25 -0
- package/src/components/tool/context-menus.tsx +142 -0
- package/src/components/tool/device-block.tsx +107 -0
- package/src/components/tool/editor-menu.tsx +191 -0
- package/src/components/tool/export/copy-button.tsx +45 -0
- package/src/components/tool/export/export-panel.tsx +127 -0
- package/src/components/tool/export/target-list.tsx +87 -0
- package/src/components/tool/floating-window.tsx +69 -0
- package/src/components/tool/flow-canvas.tsx +138 -0
- package/src/components/tool/hooks/use-cabling.ts +66 -0
- package/src/components/tool/hooks/use-captures.ts +24 -0
- package/src/components/tool/hooks/use-clipboard.ts +54 -0
- package/src/components/tool/hooks/use-context-menus.ts +37 -0
- package/src/components/tool/hooks/use-design.ts +24 -0
- package/src/components/tool/hooks/use-desktop.ts +100 -0
- package/src/components/tool/hooks/use-devices.ts +58 -0
- package/src/components/tool/hooks/use-downloads.ts +42 -0
- package/src/components/tool/hooks/use-editor.ts +114 -0
- package/src/components/tool/hooks/use-engine.ts +29 -0
- package/src/components/tool/hooks/use-history.ts +107 -0
- package/src/components/tool/hooks/use-lab-actions.ts +57 -0
- package/src/components/tool/hooks/use-lab-run.ts +61 -0
- package/src/components/tool/hooks/use-lab.ts +32 -0
- package/src/components/tool/hooks/use-lan-carry.ts +43 -0
- package/src/components/tool/hooks/use-machine-actions.ts +57 -0
- package/src/components/tool/hooks/use-placement.ts +80 -0
- package/src/components/tool/hooks/use-project.ts +112 -0
- package/src/components/tool/hooks/use-projects.ts +85 -0
- package/src/components/tool/hooks/use-shared-link.ts +64 -0
- package/src/components/tool/hooks/use-toast.ts +16 -0
- package/src/components/tool/hooks/use-tools.ts +60 -0
- package/src/components/tool/lab-canvas.tsx +96 -0
- package/src/components/tool/overlays.tsx +104 -0
- package/src/components/tool/projects-list.tsx +96 -0
- package/src/components/tool/text.ts +9 -0
- package/src/components/tool/toolbar.tsx +106 -0
- package/src/components/tool/top-bars.tsx +90 -0
- package/src/components/tool/windows/conditions-fields.tsx +43 -0
- package/src/components/tool/windows/editor-windows.tsx +73 -0
- package/src/components/tool/windows/fields.ts +6 -0
- package/src/components/tool/windows/lab-status.tsx +68 -0
- package/src/components/tool/windows/lab-windows.tsx +130 -0
- package/src/components/tool/windows/link-controls.tsx +69 -0
- package/src/components/tool/windows/machine-controls.tsx +116 -0
- package/src/components/tool/windows/machine-inspector.tsx +83 -0
- package/src/components/tool/windows/runs-fields.tsx +127 -0
- package/src/components/tool/windows/selected-windows.tsx +147 -0
- package/src/components/ui/button.tsx +59 -0
- package/src/components/ui/dropdown-menu.tsx +185 -0
- package/src/index.ts +21 -0
- package/src/lib/auth.tsx +43 -0
- package/src/lib/brand.tsx +46 -0
- package/src/lib/i18n.ts +53 -0
- package/src/lib/tool/blocks.test.ts +64 -0
- package/src/lib/tool/blocks.ts +78 -0
- package/src/lib/tool/cable-wiring.test.ts +74 -0
- package/src/lib/tool/cables.test.ts +75 -0
- package/src/lib/tool/cables.ts +215 -0
- package/src/lib/tool/clone.test.ts +54 -0
- package/src/lib/tool/clone.ts +48 -0
- package/src/lib/tool/conditions.test.ts +170 -0
- package/src/lib/tool/conditions.ts +132 -0
- package/src/lib/tool/create.test.ts +67 -0
- package/src/lib/tool/create.ts +83 -0
- package/src/lib/tool/desktop.test.ts +59 -0
- package/src/lib/tool/desktop.ts +99 -0
- package/src/lib/tool/draft.test.ts +150 -0
- package/src/lib/tool/draft.ts +109 -0
- package/src/lib/tool/engine.test.ts +39 -0
- package/src/lib/tool/engine.ts +58 -0
- package/src/lib/tool/examples.test.ts +35 -0
- package/src/lib/tool/examples.ts +71 -0
- package/src/lib/tool/export-targets.test.ts +47 -0
- package/src/lib/tool/export-targets.ts +84 -0
- package/src/lib/tool/facing.test.ts +72 -0
- package/src/lib/tool/facing.ts +100 -0
- package/src/lib/tool/geometry.test.ts +80 -0
- package/src/lib/tool/geometry.ts +109 -0
- package/src/lib/tool/gns3-cables.test.ts +47 -0
- package/src/lib/tool/gns3-cables.ts +81 -0
- package/src/lib/tool/gns3-import.test.ts +102 -0
- package/src/lib/tool/gns3-import.ts +198 -0
- package/src/lib/tool/gns3-layout.test.ts +62 -0
- package/src/lib/tool/gns3-parts.test.ts +47 -0
- package/src/lib/tool/gns3-parts.ts +68 -0
- package/src/lib/tool/issues.test.ts +35 -0
- package/src/lib/tool/issues.ts +60 -0
- package/src/lib/tool/layout.test.ts +128 -0
- package/src/lib/tool/layout.ts +229 -0
- package/src/lib/tool/live.test.ts +46 -0
- package/src/lib/tool/live.ts +49 -0
- package/src/lib/tool/logos.test.ts +30 -0
- package/src/lib/tool/logos.ts +99 -0
- package/src/lib/tool/model.test.ts +128 -0
- package/src/lib/tool/model.ts +238 -0
- package/src/lib/tool/os.test.ts +45 -0
- package/src/lib/tool/os.ts +57 -0
- package/src/lib/tool/parity.test.ts +145 -0
- package/src/lib/tool/pins.test.ts +24 -0
- package/src/lib/tool/pins.ts +11 -0
- package/src/lib/tool/placement.test.ts +79 -0
- package/src/lib/tool/placement.ts +60 -0
- package/src/lib/tool/ports.test.ts +39 -0
- package/src/lib/tool/ports.ts +48 -0
- package/src/lib/tool/presets.test.ts +50 -0
- package/src/lib/tool/presets.ts +77 -0
- package/src/lib/tool/projects-api.test.ts +59 -0
- package/src/lib/tool/projects-api.ts +47 -0
- package/src/lib/tool/router-ports.test.ts +63 -0
- package/src/lib/tool/router-ports.ts +88 -0
- package/src/lib/tool/selection.test.ts +44 -0
- package/src/lib/tool/selection.ts +34 -0
- package/src/lib/tool/storage.test.ts +123 -0
- package/src/lib/tool/storage.ts +127 -0
- package/src/lib/tool/switch-edit.test.ts +49 -0
- package/src/lib/tool/switch-edit.ts +38 -0
- package/src/lib/tool/text/de.ts +358 -0
- package/src/lib/tool/text/en.ts +371 -0
- package/src/lib/tool/text/es.ts +358 -0
- package/src/lib/tool/text/fr.ts +358 -0
- package/src/lib/tool/text/index.ts +21 -0
- package/src/lib/tool/text/text.test.ts +33 -0
- package/src/lib/tool/topology.test.ts +108 -0
- package/src/lib/tool/topology.ts +116 -0
- package/src/lib/tool/vlans.test.ts +64 -0
- package/src/lib/tool/vlans.ts +79 -0
- package/src/lib/utils.ts +6 -0
- package/src/model.ts +13 -0
- package/src/test/arbitrary.ts +41 -0
- package/src/test/engine.ts +48 -0
- package/src/test/lab.ts +101 -0
- package/styles/globals.css +258 -0
- package/styles/theme.css +192 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.json +22 -0
- package/wasm/isothrum_wasm.d.ts +60 -0
- package/wasm/isothrum_wasm.js +312 -0
- package/wasm/isothrum_wasm_bg.wasm +0 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useContext, useMemo } from "react";
|
|
4
|
+
import { Handle, Position, useReactFlow, type NodeProps } from "@xyflow/react";
|
|
5
|
+
import { MACHINE_H, MACHINE_W, cleanName, type MachineData, type MachineNode, type Side } from "@/lib/tool/model";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { useText } from "../text";
|
|
8
|
+
import { LiveContext, SidesContext } from "./contexts";
|
|
9
|
+
import { DeviceIcon, accentFor, pastelFor, roleOf } from "./device-icon";
|
|
10
|
+
import { InlineText } from "./inline-text";
|
|
11
|
+
import { MachineAddresses, MachineFacts } from "./machine-addresses";
|
|
12
|
+
import { RouterFaceplate } from "./router-node";
|
|
13
|
+
import { Frame, gen, seedOf, useBusiness, useSketch } from "./sketch";
|
|
14
|
+
import { StatusDot } from "./status-dot";
|
|
15
|
+
import { useRemeasure } from "./use-measured-ports";
|
|
16
|
+
|
|
17
|
+
/** A machine on the canvas: a hand-drawn box in its pastel, its logo and name, a socket per side. */
|
|
18
|
+
export function MachineNodeView(props: NodeProps<MachineNode>) {
|
|
19
|
+
// A router you configure is drawn as its faceplate; the automatic one stays a box.
|
|
20
|
+
if (props.data.icon === "router" && props.data.kind !== "router") return <RouterFaceplate {...props} />;
|
|
21
|
+
return <MachineBox {...props} />;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const HANDLE = "!size-3 !border-2 !border-background !bg-foreground/50 transition-colors group-hover:!bg-signal";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where cables plug in. An appliance: its outside port (every cable when it stands alone; on a
|
|
28
|
+
* LAN's edge, the cables to the other network), facing out of the LAN, and on a LAN's edge its
|
|
29
|
+
* inside port. Another machine: its top and bottom.
|
|
30
|
+
*/
|
|
31
|
+
function MachineHandles({ appliance, side }: { appliance: boolean; side: Side | undefined }) {
|
|
32
|
+
if (!appliance) {
|
|
33
|
+
return (["top", "bottom"] as const).map((h) => (
|
|
34
|
+
<Handle key={h} id={h} type="source" position={h === "top" ? Position.Top : Position.Bottom} className="!size-2.5 !border-2 !border-background !bg-foreground/40 transition-colors group-hover:!bg-signal" />
|
|
35
|
+
));
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<Handle id="port" type="source" position={side === "bottom" ? Position.Bottom : Position.Top} className={HANDLE} />
|
|
40
|
+
{side && <Handle id="lan" type="source" position={side === "bottom" ? Position.Top : Position.Bottom} className={HANDLE} />}
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What the machine runs as, and whether it is the access machine (two pieces of text, as drawn). */
|
|
46
|
+
function KindLine({ data }: { data: MachineData }) {
|
|
47
|
+
const t = useText().canvas;
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
{data.kind === "vm" ? t.vm : data.kind === "router" ? t.automatic : t.container}
|
|
51
|
+
{data.access ? ` · ${t.access}` : ""}
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Business: an asset card, its icon tinted, its name and role, then its facts. */
|
|
57
|
+
function BusinessBody({ id, data, down }: { id: string; data: MachineData; down: boolean }) {
|
|
58
|
+
const t = useText().canvas;
|
|
59
|
+
const { updateNodeData } = useReactFlow();
|
|
60
|
+
return (
|
|
61
|
+
<div className={cn("relative flex w-full flex-col items-start self-stretch px-2.5 pt-2.5", down && "opacity-50")}>
|
|
62
|
+
<div className="flex w-full min-w-0 items-center gap-2">
|
|
63
|
+
<DeviceIcon data={data} size="sm" accent={accentFor(data)} />
|
|
64
|
+
<div className="min-w-0 flex-1">
|
|
65
|
+
<div className="truncate text-[12.5px] leading-tight font-semibold tracking-tight">
|
|
66
|
+
<InlineText label={t.machineName} value={data.name} clean={cleanName} onCommit={(name) => { updateNodeData(id, { name }); }} />
|
|
67
|
+
</div>
|
|
68
|
+
<div className="mt-0.5 truncate text-[10px] leading-tight text-muted-foreground">{t.roles[roleOf(data)]}</div>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="mt-1.5 text-[8.5px] leading-none font-semibold tracking-wider text-muted-foreground/80 uppercase"><KindLine data={data} /></div>
|
|
72
|
+
<MachineFacts id={id} data={data} />
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Sketch: the logo, the name, what it runs as. */
|
|
78
|
+
function SketchBody({ id, data, down }: { id: string; data: MachineData; down: boolean }) {
|
|
79
|
+
const t = useText().canvas;
|
|
80
|
+
const { updateNodeData } = useReactFlow();
|
|
81
|
+
return (
|
|
82
|
+
<div className={cn("relative flex flex-col items-center gap-1", down && "opacity-50")}>
|
|
83
|
+
<DeviceIcon data={data} />
|
|
84
|
+
<div className="max-w-[104px] truncate font-heading text-sm leading-tight font-semibold">
|
|
85
|
+
<InlineText label={t.machineName} value={data.name} clean={cleanName} onCommit={(name) => { updateNodeData(id, { name }); }} />
|
|
86
|
+
</div>
|
|
87
|
+
<div className="text-[10px] leading-none text-muted-foreground in-data-[design=business]:text-[9px] in-data-[design=business]:font-medium in-data-[design=business]:tracking-wider in-data-[design=business]:uppercase">
|
|
88
|
+
<KindLine data={data} />
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function MachineBox({ id, data, selected }: NodeProps<MachineNode>) {
|
|
95
|
+
const side = useContext(SidesContext)[id];
|
|
96
|
+
// React Flow caches where handles are: re-measure when the port flips.
|
|
97
|
+
useRemeasure(id, side);
|
|
98
|
+
const sketch = useSketch();
|
|
99
|
+
const box = useMemo(
|
|
100
|
+
() => gen.rectangle(3, 3, MACHINE_W - 6, MACHINE_H - 6, { ...sketch, seed: seedOf(id), fill: "F", fillStyle: "solid", stroke: "S", strokeWidth: selected ? 2.25 : 1.4 }),
|
|
101
|
+
[id, selected, sketch],
|
|
102
|
+
);
|
|
103
|
+
const down = useContext(LiveContext)?.machines[data.name]?.status === "down";
|
|
104
|
+
const business = useBusiness();
|
|
105
|
+
return (
|
|
106
|
+
<div style={{ width: MACHINE_W, height: MACHINE_H }} className={cn("group relative flex flex-col items-center gap-1", business ? "justify-start" : "justify-center px-2")}>
|
|
107
|
+
<Frame drawable={box} fill={pastelFor(data)} selected={selected} dim={down} />
|
|
108
|
+
<StatusDot name={data.name} className="-top-1 -right-1" />
|
|
109
|
+
<MachineHandles appliance={data.icon === "router" || data.icon === "shield"} side={side} />
|
|
110
|
+
{business ? <BusinessBody id={id} data={data} down={down} /> : <SketchBody id={id} data={data} down={down} />}
|
|
111
|
+
{!business && <MachineAddresses id={id} data={data} />}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { NodeResizer, NodeToolbar, Position, useReactFlow, type NodeProps } from "@xyflow/react";
|
|
5
|
+
import { Circle, Square, Type, type LucideIcon } from "lucide-react";
|
|
6
|
+
import { NOTE_COLORS, type NoteNode, type NoteShape } from "@/lib/tool/model";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import { useText } from "../text";
|
|
9
|
+
import { Sketch, gen, seedOf, useSketch } from "./sketch";
|
|
10
|
+
|
|
11
|
+
/** When selected, a small bar picks the note's shape and color. */
|
|
12
|
+
function NoteBar({ id, data, visible }: { id: string; data: NoteNode["data"]; visible: boolean }) {
|
|
13
|
+
const t = useText().canvas;
|
|
14
|
+
const { updateNodeData } = useReactFlow();
|
|
15
|
+
const shapes: [NoteShape, LucideIcon, string][] = [
|
|
16
|
+
["text", Type, t.textOnly],
|
|
17
|
+
["rect", Square, t.box],
|
|
18
|
+
["ellipse", Circle, t.ellipse],
|
|
19
|
+
];
|
|
20
|
+
return (
|
|
21
|
+
<NodeToolbar isVisible={visible} position={Position.Top} className="flex items-center gap-1 rounded-lg border bg-popover p-1 shadow-sm">
|
|
22
|
+
{shapes.map(([shape, Icon, label]) => (
|
|
23
|
+
<button key={shape} type="button" title={label} aria-label={label} aria-pressed={data.shape === shape} onClick={() => { updateNodeData(id, { shape }); }} className={cn("grid size-7 place-items-center rounded-md hover:bg-muted", data.shape === shape && "bg-signal/15 text-signal")}>
|
|
24
|
+
<Icon className="size-4" />
|
|
25
|
+
</button>
|
|
26
|
+
))}
|
|
27
|
+
<span className="mx-0.5 h-5 w-px bg-border" />
|
|
28
|
+
{NOTE_COLORS.map((color) => (
|
|
29
|
+
<button
|
|
30
|
+
key={color}
|
|
31
|
+
type="button"
|
|
32
|
+
title={t.colors[color]}
|
|
33
|
+
aria-label={t.color(t.colors[color])}
|
|
34
|
+
aria-pressed={data.color === color}
|
|
35
|
+
onClick={() => { updateNodeData(id, { color }); }}
|
|
36
|
+
className={cn("size-5 rounded-full border", data.color === color && "ring-2 ring-signal ring-offset-1 ring-offset-popover")}
|
|
37
|
+
style={{ background: `var(--pastel-${color})` }}
|
|
38
|
+
/>
|
|
39
|
+
))}
|
|
40
|
+
</NodeToolbar>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The note's text; double-click to write (Enter a new line; Escape or Ctrl/⌘ Enter is done). */
|
|
45
|
+
function NoteText({ id, data, selected, draft, setDraft }: { id: string; data: NoteNode["data"]; selected: boolean; draft: string | null; setDraft: (d: string | null) => void }) {
|
|
46
|
+
const t = useText().canvas;
|
|
47
|
+
const { updateNodeData } = useReactFlow();
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
className={cn(
|
|
51
|
+
"absolute inset-0 flex items-center justify-center overflow-hidden p-3 font-heading text-base leading-snug text-foreground",
|
|
52
|
+
data.shape === "ellipse" && "px-[15%]",
|
|
53
|
+
data.shape === "text" && selected && "rounded-md outline-1 outline-signal/50 outline-dashed",
|
|
54
|
+
)}
|
|
55
|
+
onDoubleClick={(e) => {
|
|
56
|
+
e.stopPropagation();
|
|
57
|
+
setDraft(data.text);
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{draft === null ? (
|
|
61
|
+
<p className={cn("w-full text-center break-words whitespace-pre-wrap", !data.text && "text-muted-foreground")}>{data.text || t.write}</p>
|
|
62
|
+
) : (
|
|
63
|
+
<textarea
|
|
64
|
+
autoFocus
|
|
65
|
+
aria-label={t.note}
|
|
66
|
+
value={draft}
|
|
67
|
+
onChange={(e) => { setDraft(e.target.value); }}
|
|
68
|
+
onBlur={() => {
|
|
69
|
+
updateNodeData(id, { text: draft });
|
|
70
|
+
setDraft(null);
|
|
71
|
+
}}
|
|
72
|
+
onKeyDown={(e) => {
|
|
73
|
+
if (e.key === "Escape" || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) e.currentTarget.blur();
|
|
74
|
+
}}
|
|
75
|
+
className="nodrag nowheel size-full resize-none bg-transparent text-center outline-none"
|
|
76
|
+
/>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A note: text on the canvas, bare or in a sketched box or ellipse, to explain the lab. It stays
|
|
84
|
+
* in the editor (the draft, share links) and never reaches isoloom.yml.
|
|
85
|
+
*/
|
|
86
|
+
export function NoteNodeView({ id, data, selected, width, height }: NodeProps<NoteNode>) {
|
|
87
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
88
|
+
const w = width ?? 200;
|
|
89
|
+
const h = height ?? 90;
|
|
90
|
+
const sketch = useSketch();
|
|
91
|
+
const outline = useMemo(() => {
|
|
92
|
+
const o = { ...sketch, seed: seedOf(id), fill: "F", fillStyle: "solid", stroke: "S", strokeWidth: selected ? 2 : 1.3 };
|
|
93
|
+
if (data.shape === "rect") return gen.rectangle(2, 2, w - 4, h - 4, o);
|
|
94
|
+
if (data.shape === "ellipse") return gen.ellipse(w / 2, h / 2, w - 4, h - 4, o);
|
|
95
|
+
return null;
|
|
96
|
+
}, [id, w, h, selected, data.shape, sketch]);
|
|
97
|
+
return (
|
|
98
|
+
<div style={{ width: w, height: h }} className="relative">
|
|
99
|
+
<NodeResizer isVisible={selected} minWidth={80} minHeight={40} lineClassName="!border-transparent" handleClassName="pointer-events-auto !z-10 !size-2.5 !rounded-sm !border-signal !bg-background" />
|
|
100
|
+
<NoteBar id={id} data={data} visible={selected && draft === null} />
|
|
101
|
+
{outline && (
|
|
102
|
+
<svg aria-hidden className="pointer-events-none absolute inset-0 overflow-visible" width={w} height={h}>
|
|
103
|
+
<Sketch drawable={outline} fill={`var(--pastel-${data.color})`} stroke={selected ? "var(--signal)" : "var(--foreground)"} />
|
|
104
|
+
</svg>
|
|
105
|
+
)}
|
|
106
|
+
<NoteText id={id} data={data} selected={selected} draft={draft} setDraft={setDraft} />
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Business: a socket's status light, in its VLAN's or network's color (a trunk's stripes), lit
|
|
3
|
+
* when something is assigned to the port; dark when nothing is.
|
|
4
|
+
*/
|
|
5
|
+
export function PortLed({ color }: { color: string | null }) {
|
|
6
|
+
return (
|
|
7
|
+
<>
|
|
8
|
+
{/* The socket's latch. */}
|
|
9
|
+
<span aria-hidden className="pointer-events-none absolute inset-x-[6px] top-[3px] h-[4px] rounded-t-[1px] border border-b-0 border-white/12" />
|
|
10
|
+
<span
|
|
11
|
+
aria-hidden
|
|
12
|
+
className="pointer-events-none absolute bottom-[3px] left-1/2 h-[3px] w-3 -translate-x-1/2 rounded-full"
|
|
13
|
+
style={color ? { background: color, boxShadow: color.startsWith("repeating") ? undefined : `0 0 5px ${color}` } : { background: "#2a3341" }}
|
|
14
|
+
/>
|
|
15
|
+
</>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The look of a port's square: business sockets are dark and recessed. */
|
|
20
|
+
export const PORT_BOX = "rounded-[3px] border border-foreground/40";
|
|
21
|
+
export const BUSINESS_SOCKET = "rounded-[2px] border-black/80 shadow-[inset_0_1px_3px_rgb(0_0_0/0.7)]";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useContext, useMemo } from "react";
|
|
4
|
+
import { Handle, Position, useReactFlow, type NodeProps } from "@xyflow/react";
|
|
5
|
+
import { Router } from "lucide-react";
|
|
6
|
+
import { APPLIANCES, ROUTER_H, ROUTER_PORTS, ROUTER_W, cleanName, routerPort, type MachineData, type MachineNode } from "@/lib/tool/model";
|
|
7
|
+
import type { RouterPorts } from "@/lib/tool/router-ports";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
import { useText } from "../text";
|
|
10
|
+
import { LiveContext, PortsContext, SidesContext } from "./contexts";
|
|
11
|
+
import { InlineText } from "./inline-text";
|
|
12
|
+
import { BUSINESS_SOCKET, PORT_BOX, PortLed } from "./port-led";
|
|
13
|
+
import { Frame, gen, seedOf, useBusiness, useSketch } from "./sketch";
|
|
14
|
+
import { StatusDot } from "./status-dot";
|
|
15
|
+
import { usePortSides } from "./use-measured-ports";
|
|
16
|
+
|
|
17
|
+
const RPORT = 24;
|
|
18
|
+
const RPORT_GAP = 8;
|
|
19
|
+
const RPAD = 14;
|
|
20
|
+
|
|
21
|
+
/** The router's model under its name: a VM, a Cisco OS, or FRR. */
|
|
22
|
+
function modelOf(data: MachineData, vm: string): string {
|
|
23
|
+
if (data.kind === "vm") return vm;
|
|
24
|
+
return data.appliance ? `Cisco ${APPLIANCES.find((x) => x.id === data.appliance)?.label.split(" (")[0] ?? ""}` : "FRR";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Interface = RouterPorts["interfaces"][number];
|
|
28
|
+
|
|
29
|
+
/** One port of the faceplate: its name, its square in its network's color, its handle. */
|
|
30
|
+
function RouterPort({ id, data, port, i, view }: { id: string; data: MachineData; port: string; i: number; view: RouterPorts }) {
|
|
31
|
+
const t = useText().canvas;
|
|
32
|
+
const tl = useText().canvas.live;
|
|
33
|
+
const sides = useContext(SidesContext);
|
|
34
|
+
const live = useContext(LiveContext);
|
|
35
|
+
const business = useBusiness();
|
|
36
|
+
const x = RPAD + i * (RPORT + RPORT_GAP);
|
|
37
|
+
const color = view.colors[port];
|
|
38
|
+
const nets = view.interfaces.filter((f) => f.name === port || f.name.startsWith(`${port}.`));
|
|
39
|
+
// A port is down when one of its networks' ports is (unknown counts as up).
|
|
40
|
+
const down = nets.some((f) => live?.ports[`${data.name}/${f.network}`] === false);
|
|
41
|
+
const lines = nets.map((f: Interface) => `${f.name}: ${f.network}${f.ip ? ` · ${f.ip}` : ""}`).join("\n");
|
|
42
|
+
return (
|
|
43
|
+
<div>
|
|
44
|
+
<span className={cn("absolute top-[26px] w-6 text-center font-mono text-[9px] text-muted-foreground", business && "text-slate-400")} style={{ left: x }}>
|
|
45
|
+
{port}
|
|
46
|
+
</span>
|
|
47
|
+
<span
|
|
48
|
+
title={(down ? `${tl.portDown}\n` : "") + (nets.length ? lines : t.portFree(port))}
|
|
49
|
+
className={cn("absolute top-[38px]", PORT_BOX, business && BUSINESS_SOCKET, !business && down && "border-dashed border-destructive opacity-40")}
|
|
50
|
+
style={{ left: x, width: RPORT, height: 20, background: business ? "#0b1017" : (color ?? "var(--muted)") }}
|
|
51
|
+
>
|
|
52
|
+
{business && <PortLed color={down ? "#ef4444" : (color ?? null)} />}
|
|
53
|
+
</span>
|
|
54
|
+
<Handle
|
|
55
|
+
id={port}
|
|
56
|
+
type="source"
|
|
57
|
+
position={sides[`${id}:${port}`] === "top" ? Position.Top : Position.Bottom}
|
|
58
|
+
style={{ left: x + RPORT / 2 }}
|
|
59
|
+
className="!size-2.5 !border-2 !border-background !bg-foreground/50 transition-colors group-hover:!bg-signal"
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Its interfaces, beside the faceplate: port, address, network. */
|
|
66
|
+
function InterfaceList({ data, view }: { data: MachineData; view: RouterPorts }) {
|
|
67
|
+
const live = useContext(LiveContext);
|
|
68
|
+
return (
|
|
69
|
+
<ul className="pointer-events-none absolute top-0 left-full ml-3 flex w-max flex-col gap-0.5 font-mono text-[10px]">
|
|
70
|
+
{view.interfaces.map((f) => (
|
|
71
|
+
<li key={f.name} className={cn("flex items-center gap-1.5 rounded-sm bg-background/90 px-1", live?.ports[`${data.name}/${f.network}`] === false && "text-destructive line-through")}>
|
|
72
|
+
<span className="size-2 rounded-[2px]" style={{ background: f.color }} aria-hidden />
|
|
73
|
+
<span className="text-muted-foreground">{f.name}</span>
|
|
74
|
+
<span>{f.ip ?? "—"}</span>
|
|
75
|
+
<span className="text-muted-foreground">{f.network}</span>
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A router you configure, drawn like a switch: its interfaces eth0–eth3, each in the color of the
|
|
84
|
+
* network plugged into it, and beside it the list of interfaces with their addresses. A cable
|
|
85
|
+
* plugs into one port; on a LAN's edge, eth0 is that LAN.
|
|
86
|
+
*/
|
|
87
|
+
export function RouterFaceplate({ id, data, selected }: NodeProps<MachineNode>) {
|
|
88
|
+
const t = useText().canvas;
|
|
89
|
+
const { updateNodeData } = useReactFlow();
|
|
90
|
+
usePortSides(id);
|
|
91
|
+
const view = useContext(PortsContext)[id] ?? { colors: {}, interfaces: [] };
|
|
92
|
+
const sketch = useSketch();
|
|
93
|
+
const box = useMemo(
|
|
94
|
+
() => gen.rectangle(2, 2, ROUTER_W - 4, ROUTER_H - 4, { ...sketch, seed: seedOf(id), fill: "F", fillStyle: "solid", stroke: "S", strokeWidth: selected ? 2.25 : 1.4 }),
|
|
95
|
+
[id, selected, sketch],
|
|
96
|
+
);
|
|
97
|
+
const live = useContext(LiveContext);
|
|
98
|
+
const business = useBusiness();
|
|
99
|
+
return (
|
|
100
|
+
<div style={{ width: ROUTER_W, height: ROUTER_H }} className="group relative">
|
|
101
|
+
<StatusDot name={data.name} className="-top-1 -right-1" />
|
|
102
|
+
<Frame drawable={box} fill="var(--pastel-yellow)" selected={selected} dim={live?.machines[data.name]?.status === "down"} hardware={business} />
|
|
103
|
+
<div className={cn("absolute top-1.5 left-3.5 flex items-center gap-1.5 font-heading text-sm font-semibold", business && "left-5 text-[13px] text-white")}>
|
|
104
|
+
<Router className={cn("size-3.5", business && "text-amber-400")} aria-hidden />
|
|
105
|
+
<InlineText label={t.routerName} value={data.name} clean={cleanName} onCommit={(name) => { updateNodeData(id, { name }); }} />
|
|
106
|
+
<span className={cn("font-sans text-[10px] font-normal text-muted-foreground", business && "text-[9px] font-semibold tracking-wider text-slate-400 uppercase")}>{modelOf(data, t.vm)}</span>
|
|
107
|
+
</div>
|
|
108
|
+
{Array.from({ length: ROUTER_PORTS }, (_, i) => routerPort(i)).map((port, i) => (
|
|
109
|
+
<RouterPort key={port} id={id} data={data} port={port} i={i} view={view} />
|
|
110
|
+
))}
|
|
111
|
+
<InterfaceList data={data} view={view} />
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
import rough from "roughjs";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import type { Design } from "@/lib/tool/model";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The whiteboard look: boxes and cables drawn by rough.js (the library behind Excalidraw), kept
|
|
10
|
+
* gentle so the diagram stays clear. Each shape has a fixed seed, so its wobble doesn't change
|
|
11
|
+
* from one render to the next.
|
|
12
|
+
*/
|
|
13
|
+
export const gen = rough.generator();
|
|
14
|
+
export const SKETCH = { roughness: 1.15, bowing: 1 };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The canvas's look: the hand-drawn sketch (default), or business: crisp single lines, the plain
|
|
18
|
+
* sans, muted fills, for a diagram that goes into a report. The fonts and colors switch through
|
|
19
|
+
* CSS (`data-design` on the canvas); the strokes switch here.
|
|
20
|
+
*/
|
|
21
|
+
export const DesignContext = createContext<Design>("sketch");
|
|
22
|
+
const CRISP = { roughness: 0, bowing: 0, disableMultiStroke: true, disableMultiStrokeFill: true, preserveVertices: true };
|
|
23
|
+
|
|
24
|
+
/** The stroke options for this design (rough.js draws both: at roughness 0 a line is straight). */
|
|
25
|
+
export function useSketch() {
|
|
26
|
+
return useContext(DesignContext) === "business" ? CRISP : SKETCH;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether the canvas is in the business look. */
|
|
30
|
+
export function useBusiness(): boolean {
|
|
31
|
+
return useContext(DesignContext) === "business";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function seedOf(id: string): number {
|
|
35
|
+
let h = 7;
|
|
36
|
+
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) % 2147483647;
|
|
37
|
+
return h || 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Drawable = ReturnType<typeof gen.rectangle>;
|
|
41
|
+
|
|
42
|
+
/** rough.js output as SVG paths; "F" and "S" stand for the fill and stroke we style with CSS. */
|
|
43
|
+
export function Sketch({
|
|
44
|
+
drawable,
|
|
45
|
+
fill,
|
|
46
|
+
stroke,
|
|
47
|
+
opacity = 1,
|
|
48
|
+
dash,
|
|
49
|
+
}: {
|
|
50
|
+
drawable: Drawable;
|
|
51
|
+
fill?: string;
|
|
52
|
+
stroke: string;
|
|
53
|
+
opacity?: number;
|
|
54
|
+
/** rough.js returns the path only: a dash pattern has to be applied here. */
|
|
55
|
+
dash?: string;
|
|
56
|
+
}) {
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
{gen.toPaths(drawable).map((p, i) => (
|
|
60
|
+
<path
|
|
61
|
+
key={i}
|
|
62
|
+
d={p.d}
|
|
63
|
+
style={{
|
|
64
|
+
fill: p.fill === "F" && fill ? fill : "none",
|
|
65
|
+
stroke: p.stroke === "S" ? stroke : "none",
|
|
66
|
+
strokeWidth: p.strokeWidth,
|
|
67
|
+
strokeLinecap: "round",
|
|
68
|
+
strokeLinejoin: "round",
|
|
69
|
+
strokeDasharray: p.stroke === "S" ? dash : undefined,
|
|
70
|
+
opacity,
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
))}
|
|
74
|
+
</>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface FrameProps {
|
|
79
|
+
drawable: Drawable;
|
|
80
|
+
fill: string;
|
|
81
|
+
selected: boolean;
|
|
82
|
+
dim?: boolean;
|
|
83
|
+
/** Business: network equipment, drawn as a dark faceplate instead of a card. */
|
|
84
|
+
hardware?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Business, network equipment: a dark faceplate with a screw at each end (its rack ears). */
|
|
88
|
+
function Faceplate({ selected, dim }: { selected: boolean; dim: boolean }) {
|
|
89
|
+
return (
|
|
90
|
+
<div
|
|
91
|
+
aria-hidden
|
|
92
|
+
className={cn(
|
|
93
|
+
"pointer-events-none absolute inset-0 rounded-md border border-black/70 shadow-[inset_0_1px_0_rgb(255_255_255/0.09),0_1px_2px_rgb(15_23_42/0.25),0_10px_24px_-12px_rgb(15_23_42/0.55)]",
|
|
94
|
+
selected && "ring-2 ring-signal ring-offset-2 ring-offset-background",
|
|
95
|
+
dim && "opacity-55 saturate-0",
|
|
96
|
+
)}
|
|
97
|
+
style={{ background: "linear-gradient(180deg, #2c3646 0%, #1b2230 100%)" }}
|
|
98
|
+
>
|
|
99
|
+
{["left-1.5", "right-1.5"].map((side) => (
|
|
100
|
+
<span key={side} className={cn("absolute top-1/2 size-[7px] -translate-y-1/2 rounded-full bg-[#0e131b] ring-1 ring-white/15", side)} />
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A node's frame. Sketch: the hand-drawn shape in its pastel. Business: a card, as in a network
|
|
108
|
+
* diagram tool: rounded corners, a hairline border, a soft shadow (or, for network equipment, a
|
|
109
|
+
* dark faceplate).
|
|
110
|
+
*/
|
|
111
|
+
export function Frame({ drawable, fill, selected, dim = false, hardware = false }: FrameProps) {
|
|
112
|
+
if (!useBusiness()) {
|
|
113
|
+
return (
|
|
114
|
+
<svg aria-hidden className={cn("pointer-events-none absolute inset-0 overflow-visible", dim && "opacity-40")} width="100%" height="100%">
|
|
115
|
+
<Sketch drawable={drawable} fill={fill} stroke={selected ? "var(--signal)" : "var(--foreground)"} opacity={selected ? 1 : 0.8} />
|
|
116
|
+
</svg>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (hardware) return <Faceplate selected={selected} dim={dim} />;
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
aria-hidden
|
|
123
|
+
className={cn(
|
|
124
|
+
"pointer-events-none absolute inset-0 overflow-hidden rounded-lg border bg-card shadow-[0_1px_2px_rgb(15_23_42/0.06),0_4px_12px_-6px_rgb(15_23_42/0.12)] transition-shadow",
|
|
125
|
+
selected ? "border-signal ring-2 ring-signal/25" : "border-border group-hover:border-foreground/25",
|
|
126
|
+
dim && "border-dashed bg-muted/60 shadow-none",
|
|
127
|
+
)}
|
|
128
|
+
></div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useContext } from "react";
|
|
4
|
+
import type { Status } from "@/lib/tool/desktop";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { useText } from "../text";
|
|
7
|
+
import { LiveContext } from "./contexts";
|
|
8
|
+
|
|
9
|
+
const STATUS_DOT: Record<Status, string> = {
|
|
10
|
+
up: "bg-success",
|
|
11
|
+
starting: "bg-warning animate-pulse",
|
|
12
|
+
unhealthy: "bg-destructive",
|
|
13
|
+
down: "bg-muted-foreground/50",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** A machine's status as a dot in its corner, its state in the tooltip. */
|
|
17
|
+
export function StatusDot({ name, className }: { name: string; className?: string }) {
|
|
18
|
+
const t = useText().canvas.live;
|
|
19
|
+
const m = useContext(LiveContext)?.machines[name];
|
|
20
|
+
if (!m) return null;
|
|
21
|
+
return (
|
|
22
|
+
<span
|
|
23
|
+
role="img"
|
|
24
|
+
title={`${t[m.status]} · ${m.detail}`}
|
|
25
|
+
aria-label={t[m.status]}
|
|
26
|
+
className={cn("pointer-events-auto absolute z-10 size-3 rounded-full ring-2 ring-background", STATUS_DOT[m.status], className)}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useContext, useMemo } from "react";
|
|
4
|
+
import { Handle, Position, useReactFlow, type NodeProps } from "@xyflow/react";
|
|
5
|
+
import { Plus, X } from "lucide-react";
|
|
6
|
+
import { SWITCH_H, SWITCH_PORTS, SWITCH_W, TRUNK, cleanName, type SwitchNode } from "@/lib/tool/model";
|
|
7
|
+
import { addVlan, changeVlan, cyclePort, removeVlan, vlanNumber } from "@/lib/tool/switch-edit";
|
|
8
|
+
import { trunkStripes, vlanColor, vlanName } from "@/lib/tool/vlans";
|
|
9
|
+
import { cn } from "@/lib/utils";
|
|
10
|
+
import { useText } from "../text";
|
|
11
|
+
import { SidesContext } from "./contexts";
|
|
12
|
+
import { InlineText } from "./inline-text";
|
|
13
|
+
import { BUSINESS_SOCKET, PORT_BOX, PortLed } from "./port-led";
|
|
14
|
+
import { Frame, gen, seedOf, useBusiness, useSketch } from "./sketch";
|
|
15
|
+
import { usePortSides } from "./use-measured-ports";
|
|
16
|
+
|
|
17
|
+
const PORT = 24;
|
|
18
|
+
const PORT_GAP = 6;
|
|
19
|
+
const SW_PAD = 14;
|
|
20
|
+
|
|
21
|
+
/** Where a port's square starts, from the switch's left. The trunk sits apart, after a gap. */
|
|
22
|
+
function portX(i: number | typeof TRUNK): number {
|
|
23
|
+
return i === TRUNK ? SW_PAD + SWITCH_PORTS * (PORT + PORT_GAP) + 14 : SW_PAD + i * (PORT + PORT_GAP);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type SwitchData = SwitchNode["data"];
|
|
27
|
+
|
|
28
|
+
/** A port's tooltip: what it carries, and that a click changes it (not on the T port). */
|
|
29
|
+
function usePortTitle(data: SwitchData) {
|
|
30
|
+
const t = useText().canvas;
|
|
31
|
+
return (i: number | typeof TRUNK) => {
|
|
32
|
+
const mode = i === TRUNK ? TRUNK : data.ports[i];
|
|
33
|
+
const name = i === TRUNK ? "T" : t.port(i + 1);
|
|
34
|
+
if (mode === TRUNK) return `${name}: ${t.trunkCarries(data.vlans.map((v) => vlanName(v.id)).join(", ") || t.noVlan)}${i === TRUNK ? "" : ` ${t.clickToChange}`}`;
|
|
35
|
+
return `${name}: ${mode === null ? t.noVlan : vlanName(mode)} ${t.clickToChange}`;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One port: its number, its square in its VLAN's color (striped for a trunk), its handle. */
|
|
40
|
+
function SwitchPort({ id, data, i, onCycle }: { id: string; data: SwitchData; i: number | typeof TRUNK; onCycle: (i: number) => void }) {
|
|
41
|
+
const t = useText().canvas;
|
|
42
|
+
const sides = useContext(SidesContext);
|
|
43
|
+
const business = useBusiness();
|
|
44
|
+
const title = usePortTitle(data);
|
|
45
|
+
const trunk = i === TRUNK;
|
|
46
|
+
const mode = trunk ? TRUNK : data.ports[i];
|
|
47
|
+
const vlan = typeof mode === "number" ? mode : null;
|
|
48
|
+
const color = mode === TRUNK ? trunkStripes(data.vlans) : vlan === null ? null : vlanColor(vlan);
|
|
49
|
+
const handle = trunk ? TRUNK : `p${String(i + 1)}`;
|
|
50
|
+
const x = portX(i);
|
|
51
|
+
return (
|
|
52
|
+
<div>
|
|
53
|
+
<span className={cn("absolute top-[26px] w-6 text-center font-mono text-[9px] text-muted-foreground", business && "text-slate-400")} style={{ left: x }}>
|
|
54
|
+
{trunk ? "T" : i + 1}
|
|
55
|
+
</span>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={() => {
|
|
59
|
+
if (!trunk) onCycle(i);
|
|
60
|
+
}}
|
|
61
|
+
title={title(i)}
|
|
62
|
+
aria-label={trunk ? t.trunkPort : t.port(i + 1)}
|
|
63
|
+
className={cn("nodrag absolute top-[38px]", PORT_BOX, trunk ? "cursor-default" : "cursor-pointer hover:ring-2 hover:ring-signal", business && BUSINESS_SOCKET)}
|
|
64
|
+
style={{ left: x, width: PORT, height: 20, background: business ? "#0b1017" : (color ?? "var(--muted)") }}
|
|
65
|
+
>
|
|
66
|
+
{business && <PortLed color={color} />}
|
|
67
|
+
</button>
|
|
68
|
+
<Handle
|
|
69
|
+
id={handle}
|
|
70
|
+
type="source"
|
|
71
|
+
position={sides[`${id}:${handle}`] === "top" ? Position.Top : Position.Bottom}
|
|
72
|
+
style={{ left: x + PORT / 2 }}
|
|
73
|
+
className="!size-2.5 !border-2 !border-background !bg-foreground/50 transition-colors group-hover:!bg-signal"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The VLANs, beside the faceplate (cables leave from above and below it): double-click to edit, + to add, × to remove. */
|
|
80
|
+
function VlanList({ id, data }: { id: string; data: SwitchData }) {
|
|
81
|
+
const t = useText().canvas;
|
|
82
|
+
const { updateNodeData } = useReactFlow();
|
|
83
|
+
return (
|
|
84
|
+
<div className="nodrag absolute top-0 left-full ml-3 flex w-max flex-col items-start gap-1 text-[11px]">
|
|
85
|
+
{data.vlans.map((v) => (
|
|
86
|
+
<span key={v.id} className="flex items-center gap-1 rounded-md bg-background/90 pr-1">
|
|
87
|
+
<span className="size-2.5 rounded-[2px]" style={{ background: vlanColor(v.id) }} aria-hidden />
|
|
88
|
+
<span className="font-heading font-semibold">
|
|
89
|
+
vlan
|
|
90
|
+
<InlineText
|
|
91
|
+
label={t.vlanNumber}
|
|
92
|
+
value={String(v.id)}
|
|
93
|
+
clean={(x) => x.replace(/\D/g, "").slice(0, 4)}
|
|
94
|
+
onCommit={(x) => {
|
|
95
|
+
const n = vlanNumber(data, x);
|
|
96
|
+
if (n !== null) updateNodeData(id, changeVlan(data, v.id, { id: n }));
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
</span>
|
|
100
|
+
<InlineText label={t.vlanBlock} value={v.cidr} clean={(x) => x.replace(/[^0-9./]/g, "")} onCommit={(cidr) => { updateNodeData(id, changeVlan(data, v.id, { cidr })); }} className="font-mono text-muted-foreground" />
|
|
101
|
+
<button type="button" onClick={() => { updateNodeData(id, removeVlan(data, v.id)); }} aria-label={t.remove(vlanName(v.id))} className="text-muted-foreground hover:text-foreground">
|
|
102
|
+
<X className="size-3" />
|
|
103
|
+
</button>
|
|
104
|
+
</span>
|
|
105
|
+
))}
|
|
106
|
+
<button type="button" onClick={() => { updateNodeData(id, addVlan(data)); }} className="flex items-center gap-0.5 rounded-md px-1 text-muted-foreground hover:bg-muted hover:text-foreground">
|
|
107
|
+
<Plus className="size-3" /> VLAN
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A switch, drawn as its faceplate: numbered access ports in their VLAN's color (click one to
|
|
115
|
+
* change its VLAN), and a trunk striped with every VLAN it carries. A cable plugs into a port.
|
|
116
|
+
* Its VLANs are listed under it: double-click to edit, + to add, × to remove.
|
|
117
|
+
*/
|
|
118
|
+
export function SwitchNodeView({ id, data, selected }: NodeProps<SwitchNode>) {
|
|
119
|
+
const t = useText().canvas;
|
|
120
|
+
const { updateNodeData } = useReactFlow();
|
|
121
|
+
// Each port faces the device plugged into it: up when that device is above the switch.
|
|
122
|
+
usePortSides(id);
|
|
123
|
+
const sketch = useSketch();
|
|
124
|
+
const box = useMemo(
|
|
125
|
+
() => gen.rectangle(2, 2, SWITCH_W - 4, SWITCH_H - 4, { ...sketch, seed: seedOf(id), fill: "F", fillStyle: "solid", stroke: "S", strokeWidth: selected ? 2.25 : 1.4 }),
|
|
126
|
+
[id, selected, sketch],
|
|
127
|
+
);
|
|
128
|
+
const business = useBusiness();
|
|
129
|
+
const ports: (number | typeof TRUNK)[] = [...data.ports.map((_, i) => i), TRUNK];
|
|
130
|
+
return (
|
|
131
|
+
<div style={{ width: SWITCH_W, height: SWITCH_H }} className="group relative">
|
|
132
|
+
<Frame drawable={box} fill="var(--card)" selected={selected} hardware={business} />
|
|
133
|
+
<div className={cn("absolute top-1.5 left-3.5 flex items-baseline gap-2 font-heading text-sm font-semibold", business && "left-5 text-[13px] text-white")}>
|
|
134
|
+
<InlineText label={t.switchName} value={data.name} clean={cleanName} onCommit={(name) => { updateNodeData(id, { name }); }} />
|
|
135
|
+
{business && <span className="font-sans text-[9px] font-semibold tracking-wider text-slate-400 uppercase">{t.switchModel(data.ports.length)}</span>}
|
|
136
|
+
</div>
|
|
137
|
+
{ports.map((i) => (
|
|
138
|
+
<SwitchPort key={String(i)} id={id} data={data} i={i} onCycle={(port) => { updateNodeData(id, cyclePort(data, port)); }} />
|
|
139
|
+
))}
|
|
140
|
+
<VlanList id={id} data={data} />
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|