@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,215 @@
|
|
|
1
|
+
import type { Connection } from "@xyflow/react";
|
|
2
|
+
import { sideOnLan } from "./geometry";
|
|
3
|
+
import { isAppliance, isLan, isMachine, isRealRouter, isSwitch, routerPort, uid, type CableEdge, type LabNode, type LanNode, type MachineNode } from "./model";
|
|
4
|
+
import { accessVlan, switchPortLabel, vlanName } from "./vlans";
|
|
5
|
+
|
|
6
|
+
/** Cables: where they plug in, what their ends are called, and which network each one is. */
|
|
7
|
+
|
|
8
|
+
/** The handle a cable uses on a machine: an appliance's single port, or a machine's top/bottom. */
|
|
9
|
+
function machineHandle(m: MachineNode, old: string | null | undefined): string {
|
|
10
|
+
if (isAppliance(m)) return "port";
|
|
11
|
+
return old === "bottom" ? "bottom" : "top";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const isPort = (h: string | null | undefined): h is string => !!h && /^eth\d+$/.test(h);
|
|
15
|
+
|
|
16
|
+
/** The ports each real router's cables already plug into. */
|
|
17
|
+
function takenPorts(edges: CableEdge[], byId: Map<string, LabNode>): Map<string, Set<string>> {
|
|
18
|
+
const taken = new Map<string, Set<string>>();
|
|
19
|
+
for (const e of edges) {
|
|
20
|
+
for (const [id, h] of [
|
|
21
|
+
[e.source, e.sourceHandle],
|
|
22
|
+
[e.target, e.targetHandle],
|
|
23
|
+
] as const) {
|
|
24
|
+
const n = byId.get(id);
|
|
25
|
+
if (n && isRealRouter(n) && isPort(h)) taken.set(id, (taken.get(id) ?? new Set()).add(h));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return taken;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Cables saved with the old handles (one on each side of every machine) move onto the current
|
|
33
|
+
* ones: an appliance's single port, the top or bottom of other machines. LAN handles stay.
|
|
34
|
+
*/
|
|
35
|
+
export function migrateEdges(edges: CableEdge[], nodes: LabNode[]): CableEdge[] {
|
|
36
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
37
|
+
// A real router's cables plug into its ports: one that isn't on a port yet takes a free one.
|
|
38
|
+
const taken = takenPorts(edges, byId);
|
|
39
|
+
const freePort = (id: string) => {
|
|
40
|
+
const used = taken.get(id) ?? new Set<string>();
|
|
41
|
+
for (let i = 0; i < 64; i++) {
|
|
42
|
+
if (!used.has(routerPort(i))) {
|
|
43
|
+
used.add(routerPort(i));
|
|
44
|
+
taken.set(id, used);
|
|
45
|
+
return routerPort(i);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return routerPort(0);
|
|
49
|
+
};
|
|
50
|
+
const fix = (id: string, h: string | null | undefined) => {
|
|
51
|
+
const n = byId.get(id);
|
|
52
|
+
if (n && isRealRouter(n)) return isPort(h) ? h : freePort(id);
|
|
53
|
+
return n && isMachine(n) ? machineHandle(n, h) : h;
|
|
54
|
+
};
|
|
55
|
+
return edges.map((e) => ({ ...e, sourceHandle: fix(e.source, e.sourceHandle), targetHandle: fix(e.target, e.targetHandle) }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The ports a machine's cables already use. */
|
|
59
|
+
function usedPorts(nodeId: string, edges: CableEdge[]): string[] {
|
|
60
|
+
return edges.flatMap((e) => [...(e.source === nodeId && e.data ? [e.data.aPort] : []), ...(e.target === nodeId && e.data ? [e.data.bPort] : [])]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The first free `eth<n>` on a machine, from `first`: an appliance on a LAN's edge uses eth0 on
|
|
65
|
+
* that LAN, so its cables start at eth1.
|
|
66
|
+
*/
|
|
67
|
+
export function nextPort(nodeId: string, edges: CableEdge[], first = 0): string {
|
|
68
|
+
const used = new Set(usedPorts(nodeId, edges));
|
|
69
|
+
for (let i = first; ; i++) {
|
|
70
|
+
if (!used.has(`eth${String(i)}`)) return `eth${String(i)}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether a cable runs between two named machines: a link of its own in isoloom.yml. */
|
|
75
|
+
function isLink(e: CableEdge, byId: Map<string, LabNode>): boolean {
|
|
76
|
+
const x = byId.get(e.source);
|
|
77
|
+
const y = byId.get(e.target);
|
|
78
|
+
return !!x && !!y && isMachine(x) && isMachine(y) && !!x.data.name && !!y.data.name && !!e.data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The network a cable on a switch port carries: the access port's VLAN (a trunk carries several). */
|
|
82
|
+
function switchNetwork(edge: CableEdge, byId: Map<string, LabNode>): string | null | undefined {
|
|
83
|
+
for (const [sw, handle] of [
|
|
84
|
+
[byId.get(edge.source), edge.sourceHandle],
|
|
85
|
+
[byId.get(edge.target), edge.targetHandle],
|
|
86
|
+
] as const) {
|
|
87
|
+
if (!sw || !isSwitch(sw)) continue;
|
|
88
|
+
const vlan = accessVlan(sw, handle);
|
|
89
|
+
return vlan !== null ? vlanName(vlan) : null;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The Isoloom network a cable became (`link-<n>`): cables between two machines are numbered in
|
|
96
|
+
* the order `toTopology` lists them. A cable to a LAN is that LAN.
|
|
97
|
+
*/
|
|
98
|
+
export function networkOfCable(edgeId: string, nodes: LabNode[], edges: CableEdge[]): string | null {
|
|
99
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
100
|
+
const edge = edges.find((e) => e.id === edgeId);
|
|
101
|
+
if (!edge) return null;
|
|
102
|
+
const a = byId.get(edge.source);
|
|
103
|
+
const b = byId.get(edge.target);
|
|
104
|
+
if (a && isLan(a)) return a.data.name;
|
|
105
|
+
if (b && isLan(b)) return b.data.name;
|
|
106
|
+
const onSwitch = switchNetwork(edge, byId);
|
|
107
|
+
if (onSwitch !== undefined) return onSwitch;
|
|
108
|
+
const i = edges.filter((e) => isLink(e, byId)).findIndex((e) => e.id === edgeId);
|
|
109
|
+
return i < 0 ? null : `link-${String(i)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The names of a cable's ends that are machines (a LAN or a switch has no port to cut). */
|
|
113
|
+
export function machineEnds(edge: CableEdge, nodes: LabNode[]): string[] {
|
|
114
|
+
return [edge.source, edge.target].flatMap((id) => {
|
|
115
|
+
const n = nodes.find((x) => x.id === id);
|
|
116
|
+
return n && isMachine(n) ? [n.data.name] : [];
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What a cable's ends can plug into, by node id. */
|
|
121
|
+
export interface LabIndex {
|
|
122
|
+
lanIds: Set<string>;
|
|
123
|
+
switchIds: Set<string>;
|
|
124
|
+
routerIds: Set<string>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Whether a cable end on `node` takes a port: a switch's or a real router's. */
|
|
128
|
+
const ported = (index: LabIndex, node: string) => index.switchIds.has(node) || index.routerIds.has(node);
|
|
129
|
+
|
|
130
|
+
/** Whether a switch or router port already has a cable (other than `ignore`, the one moving). */
|
|
131
|
+
function portTaken(index: LabIndex, edges: CableEdge[], ignore: string | undefined, node: string, handle: string | null | undefined): boolean {
|
|
132
|
+
return (
|
|
133
|
+
ported(index, node) &&
|
|
134
|
+
edges.some((e) => e.id !== ignore && ((e.source === node && e.sourceHandle === handle) || (e.target === node && e.targetHandle === handle)))
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A cable's two ends, as React Flow gives them (a connection being drawn, or a cable). */
|
|
139
|
+
interface Ends {
|
|
140
|
+
source: string;
|
|
141
|
+
target: string;
|
|
142
|
+
sourceHandle?: string | null;
|
|
143
|
+
targetHandle?: string | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Machine to machine, LAN or port. Never LAN to LAN or switch to LAN, and a switch or router
|
|
148
|
+
* port takes one cable, as on real gear. `ignore`: the cable being moved, whose ends don't count.
|
|
149
|
+
*/
|
|
150
|
+
export function canConnect(c: Ends, index: LabIndex, edges: CableEdge[], ignore?: string): boolean {
|
|
151
|
+
if (c.source === c.target) return false;
|
|
152
|
+
const area = (id: string) => index.lanIds.has(id) || index.switchIds.has(id);
|
|
153
|
+
const bothSwitches = index.switchIds.has(c.source) && index.switchIds.has(c.target);
|
|
154
|
+
if (area(c.source) && area(c.target) && !bothSwitches) return false;
|
|
155
|
+
return !portTaken(index, edges, ignore, c.source, c.sourceHandle) && !portTaken(index, edges, ignore, c.target, c.targetHandle);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** An appliance on a LAN's edge already uses eth0 there. */
|
|
159
|
+
export function onLanEdge(id: string, nodes: LabNode[], lans: LanNode[]): boolean {
|
|
160
|
+
const n = nodes.find((x) => x.id === id);
|
|
161
|
+
return !!n && isAppliance(n) && sideOnLan(n, lans) !== null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The label of a cable's end on `node`: the switch port, the router's port, nothing on a LAN,
|
|
166
|
+
* or else `machinePort()` (the machine's next free port, or the one it keeps).
|
|
167
|
+
*/
|
|
168
|
+
function endLabel(index: LabIndex, node: string, handle: string | null | undefined, machinePort: () => string): string {
|
|
169
|
+
if (index.lanIds.has(node)) return "";
|
|
170
|
+
if (index.switchIds.has(node)) return switchPortLabel(handle);
|
|
171
|
+
if (index.routerIds.has(node)) return handle ?? "";
|
|
172
|
+
return machinePort();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A new cable for a connection, its ends labelled (`first(node)`: the first port to give). */
|
|
176
|
+
export function newCable(c: Connection, index: LabIndex, edges: CableEdge[], first: (node: string) => number): CableEdge {
|
|
177
|
+
return {
|
|
178
|
+
id: uid("c"),
|
|
179
|
+
type: "cable",
|
|
180
|
+
source: c.source,
|
|
181
|
+
sourceHandle: c.sourceHandle,
|
|
182
|
+
target: c.target,
|
|
183
|
+
targetHandle: c.targetHandle,
|
|
184
|
+
// A LAN has no ports: the cable's end on it stays unnamed.
|
|
185
|
+
data: {
|
|
186
|
+
aPort: endLabel(index, c.source, c.sourceHandle, () => nextPort(c.source, edges, first(c.source))),
|
|
187
|
+
bPort: endLabel(index, c.target, c.targetHandle, () => nextPort(c.target, edges, first(c.target))),
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The cables after `old` moved to `c`. A moved end gets the label of where it lands: the switch
|
|
194
|
+
* port, nothing on a LAN, or the machine's next free port. An end that stays put keeps its label
|
|
195
|
+
* (same machine: same port; where on its box the cable attaches is only drawing).
|
|
196
|
+
*/
|
|
197
|
+
export function reconnected(edges: CableEdge[], old: CableEdge, c: Connection, index: LabIndex, first: (node: string) => number): CableEdge[] {
|
|
198
|
+
const others = edges.filter((e) => e.id !== old.id);
|
|
199
|
+
const label = (node: string, handle: string | null, keep: string, same: boolean) => endLabel(index, node, handle, () => (same ? keep : nextPort(node, others, first(node))));
|
|
200
|
+
return edges.map((e) =>
|
|
201
|
+
e.id !== old.id || !e.data
|
|
202
|
+
? e
|
|
203
|
+
: {
|
|
204
|
+
...e,
|
|
205
|
+
source: c.source,
|
|
206
|
+
sourceHandle: c.sourceHandle,
|
|
207
|
+
target: c.target,
|
|
208
|
+
targetHandle: c.targetHandle,
|
|
209
|
+
data: {
|
|
210
|
+
aPort: label(c.source, c.sourceHandle, e.data.aPort, c.source === e.source),
|
|
211
|
+
bPort: label(c.target, c.targetHandle, e.data.bPort, c.target === e.target),
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { cloneLab } from "./clone";
|
|
3
|
+
import type { LabNode } from "./model";
|
|
4
|
+
import { byId, cable, lan, machine, note, switchNode } from "@/test/lab";
|
|
5
|
+
|
|
6
|
+
const office = lan("l1", { name: "office", cidr: "10.10.1.0/24" });
|
|
7
|
+
const web = machine("m1", { name: "web1", pins: { office: 20 } }, { x: 10, y: 20 });
|
|
8
|
+
const db = machine("m2", { name: "db1" });
|
|
9
|
+
const sw = switchNode("s1", { name: "sw1" });
|
|
10
|
+
const existing: LabNode[] = [office, web, db, sw];
|
|
11
|
+
const copy = (picked: LabNode[], edges = [cable("c1", "m1", "m2"), cable("c2", "m1", "l1")]) => cloneLab(picked, edges, existing, { x: 40, y: 50 });
|
|
12
|
+
|
|
13
|
+
describe("cloneLab", () => {
|
|
14
|
+
test("copies under new ids, moved by the offset, selected", () => {
|
|
15
|
+
const [c] = copy([web]).nodes;
|
|
16
|
+
expect(c.id).not.toBe(web.id);
|
|
17
|
+
expect(c).toMatchObject({ position: { x: 50, y: 70 }, selected: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("gives each copy the next free name of its kind", () => {
|
|
21
|
+
expect(copy([web, db, sw]).nodes.map((n) => (n.type === "note" ? "" : n.data.name))).toEqual(["web2", "db2", "sw2"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("names a copy of a nameless machine copy<n>", () => {
|
|
25
|
+
expect(copy([machine("m9", { name: "" })]).nodes.map((n) => (n.type === "note" ? "" : n.data.name))).toEqual(["copy1"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("drops a machine's fixed addresses, which would collide with the original's", () => {
|
|
29
|
+
const [c] = copy([web]).nodes;
|
|
30
|
+
expect(c.type === "machine" && c.data.pins).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("gives each copied LAN the next block free in the lab, one each", () => {
|
|
34
|
+
const blocks = copy([office, office]).nodes.map((n) => (n.type === "lan" ? n.data.cidr : ""));
|
|
35
|
+
expect(blocks).toEqual(["10.10.2.0/24", "10.10.3.0/24"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("keeps a switch's VLAN numbers, in lists of its own", () => {
|
|
39
|
+
const [c] = copy([sw]).nodes;
|
|
40
|
+
expect(c.type === "switch" && c.data.vlans).toEqual(sw.data.vlans);
|
|
41
|
+
expect(c.type === "switch" && c.data.vlans !== sw.data.vlans && c.data.ports !== sw.data.ports).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("copies a note as it is", () => {
|
|
45
|
+
expect(copy([note("n1")]).nodes[0].data).toEqual(note("n1").data);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("copies only the cables between copied nodes, joined to the copies", () => {
|
|
49
|
+
const { nodes, edges } = copy([web, db]);
|
|
50
|
+
expect(edges).toHaveLength(1);
|
|
51
|
+
expect(edges[0]).toMatchObject({ source: nodes[0].id, target: nodes[1].id, selected: false, data: { aPort: "eth0", bPort: "eth0" } });
|
|
52
|
+
expect(byId(nodes, edges[0].source).id).not.toBe("m1");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { XYPosition } from "@xyflow/react";
|
|
2
|
+
import { usedThirds } from "./create";
|
|
3
|
+
import { freeName, uid, type CableEdge, type LabNode } from "./model";
|
|
4
|
+
|
|
5
|
+
const idPrefix: Record<LabNode["type"], string> = { lan: "l", switch: "s", note: "n", machine: "m" };
|
|
6
|
+
|
|
7
|
+
/** The next free 10.10.<n>.0/24 for a copied LAN (and taken from then on). */
|
|
8
|
+
function takeThird(used: Set<number>): number {
|
|
9
|
+
let third = 1;
|
|
10
|
+
while (used.has(third)) third++;
|
|
11
|
+
used.add(third);
|
|
12
|
+
return third;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A copy of `n` with a fresh name (web1 -> web3) at `position`, its LAN block taken from `used`. */
|
|
16
|
+
function copyOf(n: Exclude<LabNode, { type: "note" }>, id: string, position: XYPosition, all: LabNode[], used: Set<number>): LabNode {
|
|
17
|
+
const name = freeName(n.data.name.replace(/\d+$/, "") || "copy", all);
|
|
18
|
+
if (n.type === "lan") return { ...n, id, position, selected: true, data: { ...n.data, name, cidr: `10.10.${String(takeThird(used))}.0/24` } };
|
|
19
|
+
if (n.type === "machine") return { ...n, id, position, selected: true, data: { ...n.data, name, pins: undefined } };
|
|
20
|
+
return { ...n, id, position, selected: true, data: { ...n.data, name, vlans: n.data.vlans.map((v) => ({ ...v })), ports: [...n.data.ports] } };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Copies of `picked` (and of the cables between them) ready to add to `existing`: new ids,
|
|
25
|
+
* moved by `offset`, fresh names (web1 -> web3), a LAN with its own free block, no fixed
|
|
26
|
+
* addresses (they would collide with the originals). VLANs keep their numbers: they are
|
|
27
|
+
* lab-wide.
|
|
28
|
+
*/
|
|
29
|
+
export function cloneLab(picked: LabNode[], edges: CableEdge[], existing: LabNode[], offset: XYPosition): { nodes: LabNode[]; edges: CableEdge[] } {
|
|
30
|
+
const ids = new Map<string, string>();
|
|
31
|
+
const all: LabNode[] = [...existing];
|
|
32
|
+
const used = usedThirds(all);
|
|
33
|
+
const nodes = picked.map((n) => {
|
|
34
|
+
const id = uid(idPrefix[n.type]);
|
|
35
|
+
ids.set(n.id, id);
|
|
36
|
+
const position = { x: n.position.x + offset.x, y: n.position.y + offset.y };
|
|
37
|
+
const copy: LabNode = n.type === "note" ? { ...n, id, position, selected: true, data: { ...n.data } } : copyOf(n, id, position, all, used);
|
|
38
|
+
all.push(copy);
|
|
39
|
+
return copy;
|
|
40
|
+
});
|
|
41
|
+
const copies = edges.flatMap((e) => {
|
|
42
|
+
const source = ids.get(e.source);
|
|
43
|
+
const target = ids.get(e.target);
|
|
44
|
+
if (source === undefined || target === undefined) return [];
|
|
45
|
+
return [{ ...e, id: uid("c"), source, target, selected: false, data: e.data ? { ...e.data } : e.data }];
|
|
46
|
+
});
|
|
47
|
+
return { nodes, edges: copies };
|
|
48
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fc from "fast-check";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { conditionsLabel, conditionsOf, tcOf, tcProblemText, withCondition } from "./conditions";
|
|
4
|
+
import { arbitraryConditions } from "@/test/arbitrary";
|
|
5
|
+
|
|
6
|
+
describe("tcOf", () => {
|
|
7
|
+
test("writes delay, jitter, loss and rate as Isoloom's tc takes them", () => {
|
|
8
|
+
expect(tcOf({ delay: 40, jitter: 5, loss: 0.5, rate: 10 })).toEqual({ delay: "40ms", jitter: "5ms", loss: 0.5, rate: "10mbit" });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("writes a fractional rate in kbit", () => {
|
|
12
|
+
expect(tcOf({ rate: 2.5 })).toEqual({ rate: "2500kbit" });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("writes a rate that isn't whole kbit/s in bits", () => {
|
|
16
|
+
expect(tcOf({ rate: 0.0005 })).toEqual({ rate: "500bit" });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("writes a loss above 100% and a jitter without a delay as they are, for Isoloom to report", () => {
|
|
20
|
+
expect(tcOf({ loss: 150, jitter: 5 })).toEqual({ loss: 150, jitter: "5ms" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("writes every number it holds, zero and negative ones too, as Isoloom reads them", () => {
|
|
24
|
+
expect(tcOf({ delay: 0, loss: -1 })).toEqual({ delay: "0ms", loss: -1 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("is undefined when nothing is set", () => {
|
|
28
|
+
for (const c of [undefined, {}, { delay: Number.NaN }, { rate: Number.POSITIVE_INFINITY }]) expect(tcOf(c)).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("conditionsLabel", () => {
|
|
33
|
+
test("summarizes the conditions for the canvas", () => {
|
|
34
|
+
expect(conditionsLabel({ delay: 40, jitter: 5, loss: 0.5, rate: 10 })).toBe("40 ms ±5 · 0.5% · 10 Mbit/s");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("is empty when there are no conditions", () => {
|
|
38
|
+
expect(conditionsLabel(undefined)).toBe("");
|
|
39
|
+
expect(conditionsLabel({})).toBe("");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("shows a jitter without a delay, as it is written", () => {
|
|
43
|
+
expect(conditionsLabel({ jitter: 5 })).toBe("±5 ms");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("conditionsOf", () => {
|
|
48
|
+
test("reads Isoloom's tc back into the editor's numbers", () => {
|
|
49
|
+
expect(conditionsOf({ delay: "40ms", jitter: "5ms", loss: 1, rate: "10mbit" })).toEqual({ conditions: { delay: 40, jitter: 5, loss: 1, rate: 10 }, problems: [] });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("reads a time in microseconds or seconds as milliseconds", () => {
|
|
53
|
+
expect(conditionsOf({ delay: "1s", jitter: "500us" }).conditions).toEqual({ delay: 1000, jitter: 0.5 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("converts gbit, kbit and bits to Mbit/s", () => {
|
|
57
|
+
expect(conditionsOf({ rate: "1gbit" }).conditions?.rate).toBe(1000);
|
|
58
|
+
expect(conditionsOf({ rate: "500kbit" }).conditions?.rate).toBe(0.5);
|
|
59
|
+
expect(conditionsOf({ rate: "2000000bit" }).conditions?.rate).toBe(2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("reports a rate without its unit, as Isoloom does", () => {
|
|
63
|
+
expect(conditionsOf({ rate: "2000000" })).toEqual({ conditions: undefined, problems: [{ field: "rate", value: "2000000" }] });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("reads a number in any spelling Isoloom reads: a sign, a lone point, an exponent", () => {
|
|
67
|
+
expect(conditionsOf({ delay: "+.5ms", jitter: "5.ms", rate: "1E-3gbit" }).conditions).toEqual({ delay: 0.5, jitter: 5, rate: 1 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("reads a rate in bytes per second as eight bits a byte", () => {
|
|
71
|
+
expect(conditionsOf({ rate: "2mbps" }).conditions?.rate).toBe(16);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("reports each value it can't read, and keeps the others", () => {
|
|
75
|
+
expect(conditionsOf({ delay: "fast", jitter: "5", loss: 1, rate: "10Mbit" })).toEqual({
|
|
76
|
+
conditions: { loss: 1 },
|
|
77
|
+
problems: [
|
|
78
|
+
{ field: "delay", value: "fast" },
|
|
79
|
+
{ field: "jitter", value: "5" },
|
|
80
|
+
{ field: "rate", value: "10Mbit" },
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("reads a negative time as Isoloom does, and reports one too large to be a number", () => {
|
|
86
|
+
expect(conditionsOf({ delay: "-5ms", jitter: "1e999ms" })).toEqual({ conditions: { delay: -5 }, problems: [{ field: "jitter", value: "1e999ms" }] });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("is no conditions and no problems when there is no tc, or nothing in it", () => {
|
|
90
|
+
expect(conditionsOf(undefined)).toEqual({ conditions: undefined, problems: [] });
|
|
91
|
+
expect(conditionsOf({})).toEqual({ conditions: undefined, problems: [] });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("inverts tcOf", () => {
|
|
95
|
+
const c = { delay: 40, jitter: 5, loss: 0.5, rate: 2.5 };
|
|
96
|
+
expect(conditionsOf(tcOf(c))).toEqual({ conditions: c, problems: [] });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("conditionsOf, for any value", () => {
|
|
102
|
+
test("inverts tcOf for any conditions the editor keeps", () => {
|
|
103
|
+
fc.assert(
|
|
104
|
+
fc.property(arbitraryConditions, (c) => {
|
|
105
|
+
expect(conditionsOf(tcOf(c)).conditions).toEqual(c);
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("reads a time the same in any of its units", () => {
|
|
111
|
+
fc.assert(
|
|
112
|
+
fc.property(fc.integer({ min: 0, max: 1_000_000 }), (s) => {
|
|
113
|
+
const read = [`${String(s)}s`, `${String(s * 1000)}ms`, `${String(s * 1_000_000)}us`].map((delay) => conditionsOf({ delay }).conditions?.delay);
|
|
114
|
+
expect(read).toEqual([s * 1000, s * 1000, s * 1000]);
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("reads a rate the same in any of its units, in bits or bytes", () => {
|
|
120
|
+
fc.assert(
|
|
121
|
+
fc.property(fc.integer({ min: 0, max: 1_000_000 }), (g) => {
|
|
122
|
+
const bits = [`${String(g)}gbit`, `${String(g * 1000)}mbit`, `${String(g * 1e6)}kbit`, `${String(g * 1e9)}bit`];
|
|
123
|
+
const bytes = [`${String(g * 125)}mbps`, `${String(g * 125_000)}kbps`, `${String(g * 125e6)}bps`];
|
|
124
|
+
expect([...bits, ...bytes].map((rate) => conditionsOf({ rate }).conditions?.rate)).toEqual(Array<number>(7).fill(g * 1000));
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("reads or reports every value, never dropping one", () => {
|
|
130
|
+
const value = fc.oneof(fc.string(), fc.tuple(fc.double(), fc.constantFrom("", "us", "ms", "s", "kbit", "mbit", "x")).map(([n, unit]) => `${String(n)}${unit}`));
|
|
131
|
+
fc.assert(
|
|
132
|
+
fc.property(fc.record({ delay: value, jitter: value, rate: value }, { requiredKeys: [] }), (tc) => {
|
|
133
|
+
const { conditions, problems } = conditionsOf(tc);
|
|
134
|
+
for (const field of ["delay", "jitter", "rate"] as const) {
|
|
135
|
+
const read = conditions?.[field];
|
|
136
|
+
const reported = problems.some((p) => p.field === field && p.value === tc[field]);
|
|
137
|
+
expect(read !== undefined || reported).toBe(tc[field] !== undefined);
|
|
138
|
+
expect(read !== undefined && reported).toBe(false);
|
|
139
|
+
expect(Number.isNaN(read)).toBe(false);
|
|
140
|
+
}
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("tcProblemText", () => {
|
|
147
|
+
test("names the field's path, the value, the form it takes, and what the editor does", () => {
|
|
148
|
+
expect(tcProblemText("networks.slow.tc", { field: "delay", value: "fast" })).toBe("networks.slow.tc.delay: `fast` isn't a time like 40ms, 500us or 1s; the drawing leaves it out");
|
|
149
|
+
expect(tcProblemText("links[0].tc", { field: "rate", value: "lots" })).toBe("links[0].tc.rate: `lots` isn't a rate like 10mbit or 512kbit; the drawing leaves it out");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("withCondition", () => {
|
|
154
|
+
test("sets a field to the positive number typed", () => {
|
|
155
|
+
expect(withCondition(undefined, "delay", "40")).toEqual({ delay: 40 });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("clears a field typed empty or not positive, keeping the others", () => {
|
|
159
|
+
expect(withCondition({ delay: 40, loss: 1 }, "delay", "-5")).toEqual({ loss: 1 });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("leaves no conditions once every field is cleared", () => {
|
|
163
|
+
expect(withCondition({ delay: 40 }, "delay", "")).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("keeps a field a stored lab carries that the editor doesn't know", () => {
|
|
167
|
+
const stored = { delay: 40, mtu: 1400 };
|
|
168
|
+
expect(withCondition(stored, "delay", "")).toEqual({ mtu: 1400 });
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { LinkConditions } from "./model";
|
|
2
|
+
|
|
3
|
+
/** Isoloom's `tc` for a slow or lossy link, as the engine's topology takes it. */
|
|
4
|
+
export interface Tc {
|
|
5
|
+
delay?: string;
|
|
6
|
+
jitter?: string;
|
|
7
|
+
loss?: number;
|
|
8
|
+
rate?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Whether a field holds a number: every finite one is written, for Isoloom to judge (it reports a loss above 100%, or a jitter without a delay). */
|
|
12
|
+
const isSet = (n: number | undefined): n is number => typeof n === "number" && Number.isFinite(n);
|
|
13
|
+
|
|
14
|
+
/** Whether `n` is a whole number, give or take what floating point loses in a multiplication. */
|
|
15
|
+
const whole = (n: number) => Math.abs(n - Math.round(n)) < 1e-9;
|
|
16
|
+
|
|
17
|
+
/** A rate (Mbit/s) as `tc` writes one: whole Mbit/s, else whole kbit/s, else bits. */
|
|
18
|
+
function rateText(rate: number): string {
|
|
19
|
+
if (Number.isInteger(rate)) return `${String(rate)}mbit`;
|
|
20
|
+
if (whole(rate * 1000)) return `${String(Math.round(rate * 1000))}kbit`;
|
|
21
|
+
return `${String(rate * 1_000_000)}bit`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The conditions as Isoloom's `tc` takes them, or `undefined` when none is set. */
|
|
25
|
+
export function tcOf(c: LinkConditions | undefined): Tc | undefined {
|
|
26
|
+
if (!c) return undefined;
|
|
27
|
+
const tc = {
|
|
28
|
+
...(isSet(c.delay) ? { delay: `${String(c.delay)}ms` } : {}),
|
|
29
|
+
...(isSet(c.jitter) ? { jitter: `${String(c.jitter)}ms` } : {}),
|
|
30
|
+
...(isSet(c.loss) ? { loss: c.loss } : {}),
|
|
31
|
+
...(isSet(c.rate) ? { rate: rateText(c.rate) } : {}),
|
|
32
|
+
};
|
|
33
|
+
return Object.keys(tc).length ? tc : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A short summary for the canvas: `40 ms ±5 · 0.5% · 10 Mbit/s`. */
|
|
37
|
+
export function conditionsLabel(c: LinkConditions | undefined): string {
|
|
38
|
+
if (!tcOf(c) || !c) return "";
|
|
39
|
+
const parts: string[] = [];
|
|
40
|
+
const jitter = isSet(c.jitter) ? ` ±${String(c.jitter)}` : "";
|
|
41
|
+
if (isSet(c.delay)) parts.push(`${String(c.delay)} ms${jitter}`);
|
|
42
|
+
else if (jitter) parts.push(`${jitter.trim()} ms`);
|
|
43
|
+
if (isSet(c.loss)) parts.push(`${String(c.loss)}%`);
|
|
44
|
+
if (isSet(c.rate)) parts.push(`${String(c.rate)} Mbit/s`);
|
|
45
|
+
return parts.join(" · ");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The `tc` fields written as a number and a unit: two times and a rate. */
|
|
49
|
+
type MeasuredField = "delay" | "jitter" | "rate";
|
|
50
|
+
|
|
51
|
+
/** A `tc` value the editor can't read: left out of the drawing and reported, never guessed. */
|
|
52
|
+
export interface TcProblem {
|
|
53
|
+
field: MeasuredField;
|
|
54
|
+
value: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A `tc` read back: the editor's numbers (none when nothing is set), and each value it couldn't read. */
|
|
58
|
+
export interface ReadConditions {
|
|
59
|
+
conditions: LinkConditions | undefined;
|
|
60
|
+
problems: TcProblem[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A unit as `[times, per]`: a value in it is `value * times / per` in the editor's unit. */
|
|
64
|
+
type Scale = readonly [times: number, per: number];
|
|
65
|
+
|
|
66
|
+
/** The time units Isoloom's `tc` takes, to milliseconds. */
|
|
67
|
+
const TIME: ReadonlyMap<string, Scale> = new Map([
|
|
68
|
+
["us", [1, 1000]],
|
|
69
|
+
["ms", [1, 1]],
|
|
70
|
+
["s", [1000, 1]],
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/** The rate units Isoloom's `tc` takes, to Mbit/s: `bps` counts bytes. A rate has its unit: Isoloom refuses a bare number. */
|
|
74
|
+
const RATE: ReadonlyMap<string, Scale> = new Map([
|
|
75
|
+
["bit", [1, 1_000_000]],
|
|
76
|
+
["kbit", [1, 1000]],
|
|
77
|
+
["mbit", [1, 1]],
|
|
78
|
+
["gbit", [1000, 1]],
|
|
79
|
+
["bps", [8, 1_000_000]],
|
|
80
|
+
["kbps", [8, 1000]],
|
|
81
|
+
["mbps", [8, 1]],
|
|
82
|
+
["gbps", [8000, 1]],
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const UNITS: Record<MeasuredField, ReadonlyMap<string, Scale>> = { delay: TIME, jitter: TIME, rate: RATE };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A number and the letters after it, split as Isoloom splits a `tc` value: the letters at the end
|
|
89
|
+
* are the unit, and what comes before is a number as Rust reads one (a sign, digits with or
|
|
90
|
+
* without a point, an exponent: `40`, `-0.5`, `.5`, `5.`, `1E-3`).
|
|
91
|
+
*/
|
|
92
|
+
const MEASURE = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)([A-Za-z]*)$/;
|
|
93
|
+
|
|
94
|
+
/** `40ms` -> 40, `500us` -> 0.5, `1gbit` -> 1000: in the editor's unit, or undefined when `v` isn't a number and one of `units` (Isoloom's, case and all), or too large to be one. */
|
|
95
|
+
function measure(v: string, units: ReadonlyMap<string, Scale>): number | undefined {
|
|
96
|
+
const m = MEASURE.exec(v);
|
|
97
|
+
const scale = m ? units.get(m[2]) : undefined;
|
|
98
|
+
if (!m || !scale) return undefined;
|
|
99
|
+
const n = (Number(m[1]) * scale[0]) / scale[1];
|
|
100
|
+
return Number.isFinite(n) ? n : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Isoloom's `tc` back to the editor's numbers: the inverse of `tcOf`, every value it can't read reported. */
|
|
104
|
+
export function conditionsOf(tc: Tc | undefined): ReadConditions {
|
|
105
|
+
const conditions: LinkConditions = {};
|
|
106
|
+
const problems: TcProblem[] = [];
|
|
107
|
+
for (const field of ["delay", "jitter", "rate"] as const) {
|
|
108
|
+
const value = tc?.[field];
|
|
109
|
+
if (value === undefined) continue;
|
|
110
|
+
const n = measure(value, UNITS[field]);
|
|
111
|
+
if (n === undefined) problems.push({ field, value });
|
|
112
|
+
else conditions[field] = n;
|
|
113
|
+
}
|
|
114
|
+
if (tc?.loss !== undefined) conditions.loss = tc.loss;
|
|
115
|
+
return { conditions: Object.keys(conditions).length ? conditions : undefined, problems };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What each field takes, as the fix a problem names. */
|
|
119
|
+
const EXPECTED: Record<MeasuredField, string> = { delay: "a time like 40ms, 500us or 1s", jitter: "a time like 40ms, 500us or 1s", rate: "a rate like 10mbit or 512kbit" };
|
|
120
|
+
|
|
121
|
+
/** A problem in words, for the `tc` at path `at` in the topology: where, the value, the form it takes. */
|
|
122
|
+
export function tcProblemText(at: string, p: TcProblem): string {
|
|
123
|
+
return `${at}.${p.field}: \`${p.value}\` isn't ${EXPECTED[p.field]}; the drawing leaves it out`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The conditions a field edit leaves: a positive number, or the field cleared (none when all are). */
|
|
127
|
+
export function withCondition(value: LinkConditions | undefined, key: keyof LinkConditions, raw: string): LinkConditions | undefined {
|
|
128
|
+
const n = raw === "" ? undefined : Number(raw);
|
|
129
|
+
const next = { ...value, [key]: n !== undefined && Number.isFinite(n) && n > 0 ? n : undefined };
|
|
130
|
+
// Every field it has, even one a stored lab carries that the editor doesn't know.
|
|
131
|
+
return Object.values(next as Record<string, number | undefined>).some((v) => v !== undefined) ? next : undefined;
|
|
132
|
+
}
|