diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 5fd208f0a..9f38b1b82 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -12,7 +12,9 @@ module.exports = { "\\.(test|spec)\\.tsx?$", // test files "__mocks__", // mock files "\\.d\\.ts$", // type declaration files - "index\\.ts$" // barrel files (may be entry points) + "index\\.ts$", // barrel files (may be entry points) + "src/webviews/explorer/quickActions\\.ts$", // test-only compatibility helper + "src/webviews/imageManager/entry\\.tsx$" // esbuild webview entry point ] }, to: {} diff --git a/.dependency-cruiser.local-ui.cjs b/.dependency-cruiser.local-ui.cjs new file mode 100644 index 000000000..4bfaf55c6 --- /dev/null +++ b/.dependency-cruiser.local-ui.cjs @@ -0,0 +1,11 @@ +const base = require("./.dependency-cruiser.cjs"); + +module.exports = { + ...base, + options: { + ...base.options, + tsConfig: { + fileName: "tsconfig.local-ui.json" + } + } +}; diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e9bd56d50..b2947ea65 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -4,9 +4,15 @@ on: pull_request: types: [opened, synchronize, reopened] +permissions: + contents: read + packages: read + jobs: build-and-test: runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout repository @@ -15,11 +21,11 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: "npm" - name: Install dependencies - run: npm install + run: npm ci - name: Create package and run lint run: npm run package @@ -70,6 +76,8 @@ jobs: runs-on: ubuntu-latest needs: build-and-test timeout-minutes: 60 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout repository @@ -78,21 +86,32 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: "npm" - name: Install dependencies run: npm ci + - name: Check E2E support + id: e2e + run: | + HAS_E2E_SCRIPT="$(node -p "Boolean(require('./package.json').scripts?.['test:e2e'])")" + echo "has_script=${HAS_E2E_SCRIPT}" >> "$GITHUB_OUTPUT" + if [ "$HAS_E2E_SCRIPT" != "true" ]; then + echo "No test:e2e script configured; skipping E2E job." + fi + - name: Install Playwright browsers and dependencies + if: steps.e2e.outputs.has_script == 'true' run: npx playwright install --with-deps chromium - name: Run E2E tests + if: steps.e2e.outputs.has_script == 'true' run: npm run test:e2e -- --workers=3 - name: Upload Playwright report + if: ${{ !cancelled() && steps.e2e.outputs.has_script == 'true' }} uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} with: name: playwright-report-${{ github.run_number }} path: playwright-report/ @@ -103,6 +122,8 @@ jobs: runs-on: ubuntu-latest needs: build-and-test if: always() + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout repository @@ -111,11 +132,11 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: "npm" - name: Install dependencies - run: npm install + run: npm ci - name: Download packaged extension uses: actions/download-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc01d42ba..a8ed88468 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,9 @@ jobs: if: github.repository == 'srl-labs/vscode-containerlab' permissions: contents: write + packages: read + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout repository diff --git a/.github/workflows/sync-clab-schema.yml b/.github/workflows/sync-clab-schema.yml deleted file mode 100644 index e25fb7419..000000000 --- a/.github/workflows/sync-clab-schema.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Sync containerlab schema - -on: - schedule: - - cron: "0 0 * * *" - workflow_dispatch: - -jobs: - sync-schema: - runs-on: ubuntu-latest - if: github.repository == 'srl-labs/vscode-containerlab' - permissions: - contents: write - pull-requests: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Fetch upstream schema - run: | - curl -fsSL \ - https://raw.githubusercontent.com/srl-labs/containerlab/main/schemas/clab.schema.json \ - -o /tmp/clab.schema.json - - - name: Update local schema when changed - id: schema - run: | - if diff -q /tmp/clab.schema.json schema/clab.schema.json > /dev/null; then - echo "changed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - mv /tmp/clab.schema.json schema/clab.schema.json - echo "changed=true" >> "$GITHUB_OUTPUT" - - - name: Open pull request - if: steps.schema.outputs.changed == 'true' - uses: peter-evans/create-pull-request@v7 - with: - commit-message: "chore: sync containerlab schema" - branch: "chore/sync-clab-schema" - delete-branch: true - title: "chore: sync containerlab schema from upstream" - body: | - Automated sync for `schema/clab.schema.json`. - - Upstream source: - - https://github.com/srl-labs/containerlab/blob/main/schemas/clab.schema.json - labels: | - dependencies diff --git a/.gitignore b/.gitignore index 1f7d2b760..6fe8b7b9f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ coverage/ .nyc_output/ test-results/ playwright-report/ +blob-report/ playwright/.cache/ clab-*/ *.clab.yml* @@ -60,4 +61,4 @@ memory/** .claude-flow/** patch.sh report/ -src/reactTopoViewer/docs \ No newline at end of file +src/reactTopoViewer/docs diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..c6ea269ad --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +@srl-labs:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} diff --git a/CHANGELOG.md b/CHANGELOG.md index 435052543..894c47dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Change Log +## [0.25.0] - 2026-04-26 + +- TopoViewer/editor: + - Uses the shared published `@srl-labs/clab-ui` runtime package, keeping VS Code aligned with the standalone UI implementation. +- Images: + - Added a container image manager webview. +- Explorer and webviews: + - Improved explorer quick actions and endpoint tree expansion. + ## [0.24.0] - 2026-03-08 - TopoViewer/editor: diff --git a/README.md b/README.md index f3c8e7d09..9a3bca1f5 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,33 @@ See `test/README.md` for a short overview of the test setup and stub utilities. --- +## UI Dependency Mode + +By default, this repository consumes the published `@srl-labs/clab-ui` package from GitHub Packages after `npm install`. + +This is the default path for normal development, CI, and packaging. + +If you are working in a sibling checkout with `clab-ui` and want to test local unpublished UI changes, opt in explicitly: + +```bash +CLAB_UI_SOURCE=local npm run build +CLAB_UI_SOURCE=local npm run package +``` + +Convenience scripts are also available: + +```bash +npm run build:local-ui +npm run package:local-ui +``` + +The local override resolves against `../clab-ui/dist`, so make sure that the +package repo is built before running the local override scripts. + +The local override affects only bundling/runtime resolution. The default install path remains the published npm package. + +--- + ## Feedback and Contributions If you’d like to request features or report issues: diff --git a/dev/README.md b/dev/README.md deleted file mode 100644 index f5b25aa95..000000000 --- a/dev/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# React TopoViewer Development Server - -This directory contains a standalone Vite dev server for rapid UI prototyping of the React TopoViewer without needing to run the full VS Code extension. - -## Quick Start - -```bash -npm run dev -``` - -This opens `http://localhost:5173` in your browser with the React TopoViewer running in standalone mode. - -## Features - -- **Instant Updates**: Changes to React components are reflected immediately (full page reload, Fast Refresh disabled for stability) -- **VS Code Theme Simulation**: CSS variables simulate the VS Code dark/light themes -- **Mock VS Code API**: All `postMessage` calls are logged to browser console -- **Sample Topology Data**: Pre-loaded spine-leaf topology for testing -- **Monaco Source Editor in Palette**: Edit YAML and annotations from the TopoViewer Palette tabs (live apply) - -## Settings Panel - -Click the **⚙ gear button** (bottom-right) to open the settings panel: - -| Setting | Options | Description | -| -------------------- | ------------------------------------------------------ | -------------------------------------- | -| **Topology** | Sample, Empty, Large (25), Large (100), Massive (1000) | Load different test topologies | -| **Mode** | Edit, View | Switch between editor and viewer modes | -| **Deployment State** | Deployed, Undeployed, Unknown | Simulate lab deployment status | -| **Light Theme** | Toggle switch | Switch between dark/light themes | - -The panel closes when clicking outside of it. - -## Console Utilities - -The same utilities are also available in the browser console: - -```javascript -__DEV__.loadTopology("sample"); // spine-leaf topology (6 nodes) -__DEV__.loadTopology("empty"); // empty canvas -__DEV__.loadTopology("large"); // 25-node grid -__DEV__.loadTopology("large100"); // 100-node grid -__DEV__.loadTopology("large1000"); // 1000-node grid - -__DEV__.setMode("edit"); // editor mode -__DEV__.setMode("view"); // view-only mode - -__DEV__.setDeploymentState("deployed"); -__DEV__.setDeploymentState("undeployed"); -__DEV__.setDeploymentState("unknown"); -``` - -## UI Indicators - -- **DEV MODE banner** (top-center): Confirms you're in development mode -- **⚙ gear button** (bottom-right): Opens settings panel - -## File Structure - -``` -dev/ -├── vite.config.ts # Vite configuration with path aliases -├── tsconfig.json # TypeScript config for the dev environment -├── index.html # Entry HTML with VS Code CSS variables -├── main.tsx # Bootstrap with mocked VS Code API -├── mockData.ts # Sample topology data and utilities -└── README.md # This file -``` - -## How It Works - -1. **Vite** serves the dev environment with hot module replacement -2. **Path aliases** (`@webview/*`, `@shared/*`) point to the actual source files -3. **Mock VS Code API** intercepts `postMessage` calls and logs them -4. **CSS variables** simulate VS Code's theme system - -## Workflow - -1. Run `npm run dev` -2. Make changes to components in `src/reactTopoViewer/webview/` -3. Browser reloads automatically -4. Test UI behavior with different topologies/states -5. When ready, test in VS Code with `npm run package` - -## Troubleshooting - -### Blank screen with console errors - -Clear Vite cache and restart: - -```bash -rm -rf node_modules/.vite -npm run dev -``` - -### Styles not updating - -Hard refresh: `Ctrl+Shift+R` (or `Cmd+Shift+R` on Mac) - -### Mock API not receiving messages - -Check browser console - all `postMessage` calls are logged with green `[postMessage to Extension]` prefix. - -## Notes - -- Fast Refresh is disabled to avoid React hook order issues with the complex hook structure -- The dev server runs independently of VS Code - no extension debugging needed -- Changes to `src/reactTopoViewer/` files are picked up automatically diff --git a/dev/components/DevSettingsOverlay.tsx b/dev/components/DevSettingsOverlay.tsx deleted file mode 100644 index bcded41e3..000000000 --- a/dev/components/DevSettingsOverlay.tsx +++ /dev/null @@ -1,265 +0,0 @@ -/** - * DevSettingsOverlay - MUI-based dev mode overlay - * - * A single top-center bar that shows "DEV" and expands into a popover - * with topology file selection, mode switching, and theme toggling. - */ -import React, { useState, useEffect, useCallback, useRef } from "react"; -import Chip from "@mui/material/Chip"; -import Divider from "@mui/material/Divider"; -import IconButton from "@mui/material/IconButton"; -import MenuItem from "@mui/material/MenuItem"; -import Paper from "@mui/material/Paper"; -import Popover from "@mui/material/Popover"; -import Select from "@mui/material/Select"; -import Stack from "@mui/material/Stack"; -import Switch from "@mui/material/Switch"; -import ToggleButton from "@mui/material/ToggleButton"; -import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; -import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import SettingsIcon from "@mui/icons-material/Settings"; -import RefreshIcon from "@mui/icons-material/Refresh"; -import RestartAltIcon from "@mui/icons-material/RestartAlt"; - -import type { DevStateManager, TopoMode } from "../mock/DevState"; - -// ============================================================================ -// Types -// ============================================================================ - -interface TopologyFile { - filename: string; - path: string; - hasAnnotations: boolean; -} - -export interface DevSettingsOverlayProps { - stateManager: DevStateManager; - loadTopologyFile: (filePath: string) => Promise; - listTopologyFiles: () => Promise; - resetFiles: () => Promise; - getCurrentFile: () => string | null; - setMode: (mode: TopoMode) => void; - onToggleTheme: () => void; -} - -// ============================================================================ -// Component -// ============================================================================ - -export const DevSettingsOverlay: React.FC = ({ - stateManager, - loadTopologyFile, - listTopologyFiles, - resetFiles, - getCurrentFile, - setMode, - onToggleTheme -}) => { - const chipRef = useRef(null); - const [open, setOpen] = useState(false); - const [mode, setModeState] = useState(stateManager.getMode()); - const [isLight, setIsLight] = useState(document.documentElement.classList.contains("light")); - const [files, setFiles] = useState([]); - const [selectedFile, setSelectedFile] = useState(""); - const [status, setStatus] = useState(""); - - // Subscribe to state manager - useEffect(() => { - const unsub = stateManager.subscribe((state) => { - setModeState(state.mode); - }); - return unsub; - }, [stateManager]); - - // Load file list on mount - const refreshFiles = useCallback(async () => { - setStatus("Loading files..."); - try { - const list = await listTopologyFiles(); - setFiles(list); - setStatus(`${list.length} files`); - const current = getCurrentFile(); - if (current) setSelectedFile(current); - } catch { - setStatus("Error loading files"); - } - }, [listTopologyFiles, getCurrentFile]); - - useEffect(() => { - void refreshFiles(); - }, [refreshFiles]); - - const handleFileChange = useCallback( - async (path: string) => { - if (!path) return; - setSelectedFile(path); - setStatus("Loading..."); - try { - await loadTopologyFile(path); - setStatus("Loaded"); - } catch { - setStatus("Error loading file"); - } - }, - [loadTopologyFile] - ); - - const handleReset = useCallback(async () => { - setStatus("Resetting..."); - try { - await resetFiles(); - setStatus("Reset complete"); - await refreshFiles(); - } catch { - setStatus("Reset failed"); - } - }, [resetFiles, refreshFiles]); - - const handleModeChange = useCallback( - (_: unknown, value: TopoMode | null) => { - if (value) setMode(value); - }, - [setMode] - ); - - const handleThemeToggle = useCallback(() => { - onToggleTheme(); - setIsLight((prev) => !prev); - }, [onToggleTheme]); - - return ( - <> - {/* Single top-center chip — click to open settings */} - } - label="DEV" - size="small" - color="warning" - onClick={() => setOpen((o) => !o)} - sx={{ - position: "fixed", - top: 6, - left: "50%", - transform: "translateX(-50%)", - zIndex: 10000, - fontWeight: 700, - letterSpacing: 1, - height: 24, - cursor: "pointer", - boxShadow: 2 - }} - /> - - {/* Settings Popover */} - setOpen(false)} - anchorOrigin={{ vertical: "bottom", horizontal: "center" }} - transformOrigin={{ vertical: "top", horizontal: "center" }} - slotProps={{ - paper: { sx: { width: 280, mt: 0.5 } } - }} - > - - - Dev Settings - - - - {/* Topology Files */} - - - Topology Files - - - - void refreshFiles()} title="Refresh"> - - - - - {status && ( - - {status} - - )} - - - - - {/* Mode */} - - - Mode - - - - Edit - - - View - - - - - - - {/* Theme */} - - - Light Theme - - - - - - - ); -}; diff --git a/dev/components/DevToolbar.tsx b/dev/components/DevToolbar.tsx deleted file mode 100644 index 0c1a02a7d..000000000 --- a/dev/components/DevToolbar.tsx +++ /dev/null @@ -1,297 +0,0 @@ -/** - * DevToolbar - Visual development toolbar for React TopoViewer - * - * Provides quick access to dev utilities like topology switching, - * mode changes, latency simulation, and split view toggling. - */ - -import * as React from "react"; -import { useState, useEffect, useCallback } from "react"; -import type { DevStateManager } from "../mock/DevState"; -import type { LatencySimulator, LatencyProfile } from "../mock/LatencySimulator"; - -// ============================================================================ -// Types -// ============================================================================ - -/** Interface for split view panel control */ -export interface SplitViewPanel { - getIsOpen(): boolean; - toggle(): void; - getYaml(): string; - copyYamlToClipboard(): void; - getAnnotationsJson(): string; - copyAnnotationsToClipboard(): void; -} - -/** Interface for message handler */ -export interface MessageHandler { - // Currently unused placeholder -} - -export interface DevToolbarProps { - stateManager: DevStateManager; - latencySimulator: LatencySimulator; - splitViewPanel: SplitViewPanel; - messageHandler?: MessageHandler; - loadTopology: (name: TopologyName) => void; -} - -type TopologyName = - | "sample" - | "sampleWithAnnotations" - | "annotated" - | "network" - | "empty" - | "large" - | "large100" - | "large1000"; - -// ============================================================================ -// Component -// ============================================================================ - -export function DevToolbar({ - stateManager, - latencySimulator, - splitViewPanel, - loadTopology -}: DevToolbarProps): React.ReactElement { - const [isExpanded, setIsExpanded] = useState(false); - const [mode, setMode] = useState(stateManager.getMode()); - const [deploymentState, setDeploymentState] = useState(stateManager.getDeploymentState()); - const [latencyProfile, setLatencyProfile] = useState( - latencySimulator.getProfile() - ); - const [splitViewOpen, setSplitViewOpen] = useState(splitViewPanel.getIsOpen()); - - // Subscribe to state changes - useEffect(() => { - const unsubscribe = stateManager.subscribe((state) => { - setMode(state.mode); - setDeploymentState(state.deploymentState); - }); - return unsubscribe; - }, [stateManager]); - - // Subscribe to latency profile changes - useEffect(() => { - const unsubscribe = latencySimulator.onProfileChange((profile) => { - setLatencyProfile(profile); - }); - return unsubscribe; - }, [latencySimulator]); - - // Handlers - const handleTopologyChange = useCallback( - (e: React.ChangeEvent) => { - loadTopology(e.target.value as TopologyName); - }, - [loadTopology] - ); - - const handleModeChange = useCallback( - (newMode: "edit" | "view") => { - stateManager.setMode(newMode); - window.postMessage( - { - type: "topo-mode-changed", - data: { - mode: newMode === "view" ? "viewer" : "editor", - deploymentState: stateManager.getDeploymentState() - } - }, - "*" - ); - }, - [stateManager] - ); - - const handleDeploymentChange = useCallback( - (newState: "deployed" | "undeployed" | "unknown") => { - stateManager.setDeploymentState(newState); - window.postMessage( - { - type: "topo-mode-changed", - data: { - mode: stateManager.getMode() === "view" ? "viewer" : "editor", - deploymentState: newState - } - }, - "*" - ); - }, - [stateManager] - ); - - const handleLatencyChange = useCallback( - (e: React.ChangeEvent) => { - latencySimulator.setProfile(e.target.value as LatencyProfile); - }, - [latencySimulator] - ); - - const handleToggleSplitView = useCallback(() => { - splitViewPanel.toggle(); - }, [splitViewPanel]); - - const handleExportYaml = useCallback(() => { - const yaml = splitViewPanel.getYaml(); - console.log(yaml); - splitViewPanel.copyYamlToClipboard(); - }, [splitViewPanel]); - - const handleExportAnnotations = useCallback(() => { - const json = splitViewPanel.getAnnotationsJson(); - console.log(json); - splitViewPanel.copyAnnotationsToClipboard(); - }, [splitViewPanel]); - - return ( -
- {/* Collapsed button */} - - - {/* Expanded panel */} - {isExpanded && ( -
- {/* Topology selector */} -
- - -
- - {/* Mode controls */} -
- -
- - -
-
- - {/* Deployment state */} -
- -
- - -
-
- - {/* Latency profile */} -
- - -
- - {/* Split view toggle */} -
- -
- - {/* Export buttons */} -
- - -
- - {/* Info */} -
-

- Console: __DEV__.* -

-
-
- )} -
- ); -} diff --git a/dev/index.html b/dev/index.html deleted file mode 100644 index a54acdb03..000000000 --- a/dev/index.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - React TopoViewer - Dev Mode - - - -
-
- - - diff --git a/dev/main.tsx b/dev/main.tsx deleted file mode 100644 index cdf674546..000000000 --- a/dev/main.tsx +++ /dev/null @@ -1,1369 +0,0 @@ -// Dev mode entry point. -import React from "react"; -import { createRoot, type Root as ReactRoot } from "react-dom/client"; -import { ThemeProvider } from "@mui/material/styles"; -import CssBaseline from "@mui/material/CssBaseline"; -import { App } from "@webview/App"; -import type { CustomNodeTemplate } from "@shared/types/editors"; -import type { CustomIconInfo } from "@shared/types/icons"; -import "@webview/styles/global.css"; -import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; -import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; - -import { setHostContext } from "@webview/services/topologyHostClient"; -import { refreshTopologySnapshot } from "@webview/services/topologyHostCommands"; -import { useGraphStore, useTopoViewerStore } from "@webview/stores"; -import { applyDevVars } from "@webview/theme/devTheme"; -import { vscodeTheme } from "@webview/theme/vscodeTheme"; -import { EXPORT_COMMANDS } from "@shared/messages/extension"; -import { MSG_SVG_EXPORT_RESULT } from "@shared/messages/webview"; - -import { DevStateManager } from "./mock/DevState"; -import { DevSettingsOverlay } from "./components/DevSettingsOverlay"; -import { sampleCustomNodes, sampleCustomIcons } from "./mockData"; - -import clabSchema from "../schema/clab.schema.json"; -import { parseSchemaData } from "@shared/schema"; -import type { SchemaData } from "@shared/schema"; -import { - buildExplorerSnapshot, - type ExplorerActionInvocation, - type ExplorerSnapshotOptions, - type ExplorerSnapshotProviders -} from "../src/webviews/explorer/explorerSnapshotAdapter"; -import type { - ExplorerIncomingMessage, - ExplorerOutgoingMessage, - ExplorerUiState -} from "../src/webviews/shared/explorer/types"; - -const monacoGlobal = self as typeof self & { - MonacoEnvironment?: { - getWorker: (workerId: string, label: string) => Worker; - }; -}; - -if (!monacoGlobal.MonacoEnvironment) { - monacoGlobal.MonacoEnvironment = { - getWorker: (_workerId: string, label: string) => { - if (label === "json") { - return new JsonWorker(); - } - return new EditorWorker(); - } - }; -} - -// Session Management - -function getSessionId(): string | null { - const urlParams = new URLSearchParams(window.location.search); - return urlParams.get("sessionId"); -} - -const sessionId = getSessionId(); -if (sessionId) { - console.log(`%c[Dev] Session ID: ${sessionId}`, "color: #9C27B0;"); -} - -// Dev State - -const stateManager = new DevStateManager({ - mode: "edit", - deploymentState: "undeployed", - customNodes: sampleCustomNodes as CustomNodeTemplate[] -}); - -// Initial Bootstrap Data - -const schemaData = parseSchemaData(clabSchema as Record); - -const dockerImages = [ - "ghcr.io/nokia/srlinux:latest", - "ghcr.io/nokia/srlinux:24.10.1", - "alpine:latest", - "ubuntu:latest" -]; - -interface InitialData { - schemaData: SchemaData; - dockerImages: string[]; - customNodes: CustomNodeTemplate[]; - defaultNode: string; - customIcons: CustomIconInfo[]; -} - -const initialData: InitialData = { - schemaData, - dockerImages, - customNodes: sampleCustomNodes as CustomNodeTemplate[], - defaultNode: stateManager.getDefaultCustomNode(), - customIcons: sampleCustomIcons as CustomIconInfo[] -}; - -// Render App - -let reactRoot: ReactRoot | null = null; -let renderKey = 0; - -function renderApp(): void { - (window as unknown as Record).__INITIAL_DATA__ = initialData; - (window as unknown as Record).__SCHEMA_DATA__ = initialData.schemaData; - (window as unknown as Record).__DOCKER_IMAGES__ = initialData.dockerImages; - - const container = document.getElementById("root"); - if (!container) { - throw new Error("Root element not found"); - } - - if (!reactRoot) { - reactRoot = createRoot(container); - } - - renderKey++; - reactRoot.render(); -} - -// Topology Loading - -const DEFAULT_TOPOLOGY = "simple.clab.yml"; - -let currentFilePath: string | null = null; - -function normalizeTopologyRequestPath(pathValue: string): string { - const trimmed = pathValue.trim(); - if (trimmed.length === 0) { - return trimmed; - } - - const normalized = trimmed.replace(/\\/g, "/").replace(/^\.\//, ""); - const marker = "/topologies/"; - const markerIndex = normalized.lastIndexOf(marker); - if (markerIndex >= 0) { - return normalized.slice(markerIndex + marker.length); - } - if (normalized.startsWith("topologies/")) { - return normalized.slice("topologies/".length); - } - if (normalized.startsWith("dev/topologies/")) { - return normalized.slice("dev/topologies/".length); - } - - return trimmed; -} - -function syncHostContext( - options: { - mode?: "edit" | "view"; - deploymentState?: "deployed" | "undeployed" | "unknown"; - } = {} -): void { - const path = normalizeTopologyRequestPath(currentFilePath ?? DEFAULT_TOPOLOGY); - setHostContext({ - path, - mode: options.mode ?? stateManager.getMode(), - deploymentState: options.deploymentState ?? stateManager.getDeploymentState(), - sessionId: sessionId ?? undefined - }); -} - -async function loadTopologyFile(filePath: string): Promise { - const normalizedPath = normalizeTopologyRequestPath(filePath); - console.log(`%c[Dev] Loading topology: ${normalizedPath}`, "color: #2196F3;"); - currentFilePath = normalizedPath; - stateManager.setLoadedFile(normalizedPath); - - syncHostContext(); - - renderApp(); - await refreshTopologySnapshot(); -} - -async function listTopologyFiles(): Promise< - Array<{ filename: string; path: string; hasAnnotations: boolean }> -> { - try { - const response = await fetch(sessionId ? `/files?sessionId=${sessionId}` : "/files"); - return await response.json(); - } catch (error) { - console.error("[Dev] Failed to list topology files:", error); - return []; - } -} - -async function resetFiles(): Promise { - console.log("%c[Dev] Resetting files...", "color: #f44336;"); - - const url = sessionId ? `/api/reset?sessionId=${sessionId}` : "/api/reset"; - const response = await fetch(url, { method: "POST" }); - const result = await response.json(); - - if (!result.success) { - throw new Error(result.error || "Failed to reset files"); - } - - console.log("%c[Dev] Files reset successfully", "color: #4CAF50;"); - - if (currentFilePath) { - await loadTopologyFile(currentFilePath); - } -} - -// External File Changes (SSE) - -function subscribeToFileChanges(): void { - const url = sessionId ? `/api/events?sessionId=${sessionId}` : "/api/events"; - const source = new EventSource(url); - source.addEventListener("file-changed", (event) => { - const message = (event as MessageEvent).data; - if (!message) return; - try { - const payload = JSON.parse(message) as { path?: string; type?: string }; - if (payload.type === "yaml") { - scheduleExplorerSnapshot(0); - } - - if (!currentFilePath) { - return; - } - - const filename = currentFilePath.split("/").pop(); - if (!filename) return; - - const isYamlChange = payload.type === "yaml" && payload.path === filename; - const isAnnotationsChange = - payload.type === "annotations" && payload.path === `${filename}.annotations.json`; - - // Refresh topology on YAML or annotations changes - if (isYamlChange || isAnnotationsChange) { - void refreshTopologySnapshot({ externalChange: true }); - } - } catch (err) { - console.warn("[Dev] Failed to parse SSE message", err); - } - }); -} - -// Window Globals for Dev Console - -interface DevServerInterface { - loadTopologyFile: (filePath: string) => Promise; - listTopologyFiles: () => Promise< - Array<{ filename: string; path: string; hasAnnotations: boolean }> - >; - resetFiles: () => Promise; - getCurrentFile: () => string | null; - setMode: (mode: "edit" | "view") => void; - setDeploymentState: (state: "deployed" | "undeployed" | "unknown") => void; - onHostUpdate: () => void; - stateManager: DevStateManager; -} - -(window as unknown as { __DEV__: DevServerInterface }).__DEV__ = { - loadTopologyFile, - listTopologyFiles, - resetFiles, - getCurrentFile: () => currentFilePath, - setMode: (mode: "edit" | "view") => { - stateManager.setMode(mode); - syncHostContext({ mode }); - void refreshTopologySnapshot(); - }, - setDeploymentState: (state: "deployed" | "undeployed" | "unknown") => { - stateManager.setDeploymentState(state); - syncHostContext({ deploymentState: state }); - void refreshTopologySnapshot(); - }, - onHostUpdate: () => {}, - stateManager -}; - -// Explorer bridge for Vite dev mode (uses the same snapshot adapter as the extension host). - -const EXPLORER_REFRESH_DEBOUNCE_MS = 90; -const TREE_ITEM_NONE = 0; -const TREE_ITEM_COLLAPSED = 1; - -interface DevExplorerTreeItem { - id?: string; - label: string; - description?: string; - tooltip?: string; - contextValue?: string; - command?: { command: string; title: string; arguments?: unknown[] }; - collapsibleState?: number; - state?: string; - status?: string; - link?: string; - labPath?: { absolute: string; relative: string }; - name?: string; - cID?: string; - kind?: string; - image?: string; - mac?: string; - v4Address?: string; - v6Address?: string; - children?: DevExplorerTreeItem[]; -} - -class DevExplorerProvider { - constructor(private readonly roots: DevExplorerTreeItem[]) {} - - getChildren(element?: DevExplorerTreeItem): DevExplorerTreeItem[] { - if (!element) { - return this.roots; - } - return Array.isArray(element.children) ? element.children : []; - } -} - -const HELP_LINKS = [ - { label: "Containerlab Documentation", url: "https://containerlab.dev/" }, - { label: "VS Code Extension Documentation", url: "https://containerlab.dev/manual/vsc-extension/" }, - { label: "Browse Labs on GitHub (srl-labs)", url: "https://github.com/srl-labs/" }, - { - label: 'Find more labs tagged with "clab-topo"', - url: "https://github.com/search?q=topic%3Aclab-topo++fork%3Atrue&type=repositories" - }, - { label: "Join our Discord server", url: "https://discord.gg/vAyddtaEV9" }, - { - label: "Download cshargextcap Wireshark plugin", - url: "https://github.com/siemens/cshargextcap/releases/latest" - } -] as const; - -let explorerFilterText = ""; -let explorerUiState: ExplorerUiState = {}; -let explorerRefreshTimer: number | null = null; -let explorerActionBindings = new Map(); -const unhandledExplorerCommands = new Set(); -const favoriteLabPaths = new Set(); -const sshxShareLinksByLab = new Map(); -const gottyShareLinksByLab = new Map(); - -function stripTopologySuffix(name: string): string { - return name.replace(/\.clab\.(ya?ml)$/i, ""); -} - -function safeFilename(pathValue: string): string { - const segments = pathValue.split("/").filter(Boolean); - return segments.length > 0 ? segments[segments.length - 1] : pathValue; -} - -function sendExplorerMessage(message: ExplorerIncomingMessage): void { - window.dispatchEvent(new MessageEvent("message", { data: message })); -} - -function postExplorerFilterState(): void { - sendExplorerMessage({ command: "filterState", filterText: explorerFilterText }); -} - -function postExplorerUiState(): void { - sendExplorerMessage({ command: "uiState", state: explorerUiState }); -} - -function postExplorerError(message: string): void { - sendExplorerMessage({ command: "error", message }); -} - -async function copyTextToClipboard(text: string): Promise { - try { - await navigator.clipboard.writeText(text); - } catch { - console.info(`[Dev Explorer] Clipboard write unavailable, value: ${text}`); - } -} - -function filterTreeItems(items: DevExplorerTreeItem[], filterText: string): DevExplorerTreeItem[] { - const query = filterText.trim().toLowerCase(); - if (query.length === 0) { - return items; - } - - const visit = (item: DevExplorerTreeItem): DevExplorerTreeItem | null => { - const filteredChildren = (item.children ?? []) - .map((child) => visit(child)) - .filter((child): child is DevExplorerTreeItem => child !== null); - const haystack = [item.label, item.description, item.tooltip] - .filter((value): value is string => typeof value === "string") - .join(" ") - .toLowerCase(); - - if (haystack.includes(query) || filteredChildren.length > 0) { - return { ...item, children: filteredChildren }; - } - return null; - }; - - return items.map((item) => visit(item)).filter((item): item is DevExplorerTreeItem => item !== null); -} - -interface DevLocalLabFile { - filename: string; - path: string; - hasAnnotations: boolean; -} - -interface DevLocalFolderBranch { - name: string; - relativePath: string; - folders: Map; - labs: DevExplorerTreeItem[]; -} - -function toPosixPath(pathValue: string): string { - return pathValue.replace(/\\/g, "/"); -} - -function toFavoriteLabKey(pathValue: string): string { - return toPosixPath(normalizeTopologyRequestPath(pathValue)); -} - -function isFavoriteLabPath(pathValue: string | undefined): boolean { - if (!pathValue) { - return false; - } - return favoriteLabPaths.has(toFavoriteLabKey(pathValue)); -} - -function createDevShareLink(sessionType: "sshx" | "gotty", labPath: string): string { - const labKey = toFavoriteLabKey(labPath); - const labSlug = encodeURIComponent(stripTopologySuffix(safeFilename(labKey))); - if (sessionType === "sshx") { - return `https://sshx.dev/dev-${labSlug}`; - } - return `http://localhost:8080/?lab=${labSlug}`; -} - -function getShareLinksForLab( - pathValue: string | undefined -): { sshxLink?: string; gottyLink?: string } { - if (!pathValue) { - return {}; - } - const labKey = toFavoriteLabKey(pathValue); - return { - sshxLink: sshxShareLinksByLab.get(labKey), - gottyLink: gottyShareLinksByLab.get(labKey) - }; -} - -function buildShareNodesForLab(pathValue: string | undefined): DevExplorerTreeItem[] { - if (!pathValue) { - return []; - } - const labKey = toFavoriteLabKey(pathValue); - const shareLinks = getShareLinksForLab(pathValue); - const nodes: DevExplorerTreeItem[] = []; - - if (shareLinks.gottyLink) { - nodes.push({ - id: `running-gotty-link:${labKey}`, - label: "Web Terminal", - tooltip: shareLinks.gottyLink, - contextValue: "containerlabGottyLink", - collapsibleState: TREE_ITEM_NONE, - link: shareLinks.gottyLink, - command: { - command: "containerlab.lab.gotty.copyLink", - title: "Copy GoTTY link", - arguments: [shareLinks.gottyLink] - }, - children: [] - }); - } - - if (shareLinks.sshxLink) { - nodes.push({ - id: `running-sshx-link:${labKey}`, - label: "Shared Terminal", - tooltip: shareLinks.sshxLink, - contextValue: "containerlabSSHXLink", - collapsibleState: TREE_ITEM_NONE, - link: shareLinks.sshxLink, - command: { - command: "containerlab.lab.sshx.copyLink", - title: "Copy SSHX link", - arguments: [shareLinks.sshxLink] - }, - children: [] - }); - } - - return nodes; -} - -function topologyRelativePath(pathValue: string): string { - const normalized = toPosixPath(pathValue); - const marker = "/topologies/"; - const markerIndex = normalized.lastIndexOf(marker); - if (markerIndex >= 0) { - return normalized.slice(markerIndex + marker.length); - } - return safeFilename(pathValue); -} - -interface DevContainerTooltipInput { - name: string; - state: string; - status: string; - kind: string; - type: string; - image: string; - id: string; - ipv4?: string; - ipv6?: string; -} - -function buildDevContainerTooltip(input: DevContainerTooltipInput): string { - const lines = [ - `Name: ${input.name}`, - `State: ${input.state}`, - `Status: ${input.status}`, - `Kind: ${input.kind}`, - `Type: ${input.type}`, - `Image: ${input.image}`, - `ID: ${input.id}` - ]; - - if (input.ipv4 && input.ipv4 !== "N/A") { - lines.push(`IPv4: ${input.ipv4}`); - } - if (input.ipv6 && input.ipv6 !== "N/A") { - lines.push(`IPv6: ${input.ipv6}`); - } - - return lines.join("\n"); -} - -function buildDevInterfaceTooltip(name: string, peer: string, mac: string): string { - return [ - `Name: ${name}`, - "State: up", - "Type: veth", - `Peer: ${peer}`, - `MAC: ${mac}` - ].join("\n"); -} - -function createMockRunningLabItem(): DevExplorerTreeItem { - const pathValue = "test/test.clab.yml"; - const isFavorite = isFavoriteLabPath(pathValue); - const shareLinks = getShareLinksForLab(pathValue); - const spineStatus = "Up 18 minutes"; - const leafStatus = "Up 11 minutes"; - const image = "ghcr.io/nokia/srlinux:latest"; - const type = "ixrd2"; - - const containers: DevExplorerTreeItem[] = [ - { - id: "running-container:dev-mock-spine1", - label: "dev-mock-spine1", - description: spineStatus, - tooltip: buildDevContainerTooltip({ - name: "dev-mock-spine1", - state: "running", - status: spineStatus, - kind: "srl", - type, - image, - id: "dev-mock-spine1", - ipv4: "N/A", - ipv6: "N/A" - }), - contextValue: "containerlabContainer", - collapsibleState: TREE_ITEM_COLLAPSED, - state: "running", - status: spineStatus, - name: "dev-mock-spine1", - cID: "dev-mock-spine1", - kind: "srl", - image, - v4Address: "N/A", - v6Address: "N/A", - children: [ - { - id: "running-interface:dev-mock-spine1:0", - label: "eth1", - description: "dev-mock-leaf1", - tooltip: buildDevInterfaceTooltip("eth1", "dev-mock-leaf1", "00:00:5e:00:53:01"), - contextValue: "containerlabInterfaceUp", - collapsibleState: TREE_ITEM_NONE, - cID: "dev-mock-spine1", - name: "eth1", - mac: "00:00:5e:00:53:01" - } - ] - }, - { - id: "running-container:dev-mock-leaf1", - label: "dev-mock-leaf1", - description: leafStatus, - tooltip: buildDevContainerTooltip({ - name: "dev-mock-leaf1", - state: "running", - status: leafStatus, - kind: "srl", - type, - image, - id: "dev-mock-leaf1", - ipv4: "N/A", - ipv6: "N/A" - }), - contextValue: "containerlabContainer", - collapsibleState: TREE_ITEM_COLLAPSED, - state: "running", - status: leafStatus, - name: "dev-mock-leaf1", - cID: "dev-mock-leaf1", - kind: "srl", - image, - v4Address: "N/A", - v6Address: "N/A", - children: [ - { - id: "running-interface:dev-mock-leaf1:0", - label: "eth1", - description: "dev-mock-spine1", - tooltip: buildDevInterfaceTooltip("eth1", "dev-mock-spine1", "00:00:5e:00:53:02"), - contextValue: "containerlabInterfaceUp", - collapsibleState: TREE_ITEM_NONE, - cID: "dev-mock-leaf1", - name: "eth1", - mac: "00:00:5e:00:53:02" - } - ] - } - ]; - const shareNodes = buildShareNodesForLab(pathValue); - - const labItem: DevExplorerTreeItem = { - id: "running-lab:dev-mock", - label: "dev-mock-running-lab", - description: pathValue, - tooltip: pathValue, - contextValue: isFavorite ? "containerlabLabDeployedFavorite" : "containerlabLabDeployed", - collapsibleState: TREE_ITEM_COLLAPSED, - labPath: { - absolute: pathValue, - relative: pathValue - }, - children: [...shareNodes, ...containers] - }; - - if (shareLinks.sshxLink) { - labItem.command = { - command: "containerlab.lab.sshx.copyLink", - title: "Copy SSHX link", - arguments: [shareLinks.sshxLink] - }; - } else if (shareLinks.gottyLink) { - labItem.command = { - command: "containerlab.lab.gotty.copyLink", - title: "Copy GoTTY link", - arguments: [shareLinks.gottyLink] - }; - } else { - labItem.command = { - command: "containerlab.lab.graph.topoViewer", - title: "Open TopoViewer", - arguments: [labItem] - }; - } - - return labItem; -} - -function buildRunningLabItems(filterText: string): DevExplorerTreeItem[] { - const items: DevExplorerTreeItem[] = []; - - if (stateManager.getDeploymentState() === "deployed") { - const graphState = useGraphStore.getState(); - const topoState = useTopoViewerStore.getState(); - const pathValue = currentFilePath ?? topoState.yamlFileName; - const labName = stripTopologySuffix(topoState.labName || safeFilename(pathValue || "Current Lab")); - const labPath = pathValue || labName; - const isFavorite = isFavoriteLabPath(labPath); - const shareLinks = getShareLinksForLab(labPath); - - const edgeGroups = new Map>(); - for (const edge of graphState.edges) { - const source = String(edge.source); - const target = String(edge.target); - const edgeId = String(edge.id); - const sourceList = edgeGroups.get(source) ?? []; - sourceList.push({ peer: target, edgeId }); - edgeGroups.set(source, sourceList); - const targetList = edgeGroups.get(target) ?? []; - targetList.push({ peer: source, edgeId }); - edgeGroups.set(target, targetList); - } - - const containers = graphState.nodes.map((node, index) => { - const nodeId = String(node.id); - const uptime = `Up ${(index + 1) * 3} minutes`; - const nodeType = typeof node.type === "string" ? node.type : "node"; - const nodeImage = nodeType; - const interfaces = (edgeGroups.get(nodeId) ?? []).map((entry, index) => ({ - id: `running-interface:${nodeId}:${index}`, - label: `eth${index}`, - description: entry.peer, - tooltip: buildDevInterfaceTooltip(`eth${index}`, entry.peer, entry.edgeId), - contextValue: "containerlabInterfaceUp", - collapsibleState: TREE_ITEM_NONE, - cID: nodeId, - name: `eth${index}`, - mac: entry.edgeId - })); - - return { - id: `running-container:${nodeId}`, - label: nodeId, - description: uptime, - tooltip: buildDevContainerTooltip({ - name: nodeId, - state: "running", - status: uptime, - kind: nodeType, - type: nodeType, - image: nodeImage, - id: nodeId, - ipv4: "N/A", - ipv6: "N/A" - }), - contextValue: "containerlabContainer", - collapsibleState: interfaces.length > 0 ? TREE_ITEM_COLLAPSED : TREE_ITEM_NONE, - state: "running", - status: uptime, - name: nodeId, - cID: nodeId, - kind: nodeType, - image: nodeImage, - v4Address: "N/A", - v6Address: "N/A", - children: interfaces - }; - }); - const shareNodes = buildShareNodesForLab(labPath); - const labChildren = [...shareNodes, ...containers]; - - const labItem: DevExplorerTreeItem = { - id: `running-lab:${labPath}`, - label: labName, - description: pathValue, - tooltip: pathValue, - contextValue: isFavorite ? "containerlabLabDeployedFavorite" : "containerlabLabDeployed", - collapsibleState: labChildren.length > 0 ? TREE_ITEM_COLLAPSED : TREE_ITEM_NONE, - labPath: { - absolute: labPath, - relative: labPath - }, - children: labChildren - }; - if (shareLinks.sshxLink) { - labItem.command = { - command: "containerlab.lab.sshx.copyLink", - title: "Copy SSHX link", - arguments: [shareLinks.sshxLink] - }; - } else if (shareLinks.gottyLink) { - labItem.command = { - command: "containerlab.lab.gotty.copyLink", - title: "Copy GoTTY link", - arguments: [shareLinks.gottyLink] - }; - } else { - labItem.command = { - command: "containerlab.lab.graph.topoViewer", - title: "Open TopoViewer", - arguments: [labItem] - }; - } - items.push(labItem); - } - - items.push(createMockRunningLabItem()); - return filterTreeItems(items, filterText); -} - -async function buildLocalLabItems(filterText: string): Promise { - const localFiles = await listTopologyFiles(); - if (!Array.isArray(localFiles) || localFiles.length === 0) { - return []; - } - - const root: DevLocalFolderBranch = { - name: "", - relativePath: "", - folders: new Map(), - labs: [] - }; - - const files = localFiles as DevLocalLabFile[]; - for (const file of files) { - const relativePath = topologyRelativePath(file.path); - const segments = toPosixPath(relativePath).split("/").filter(Boolean); - if (segments.length === 0) { - continue; - } - - const folderSegments = segments.slice(0, -1); - let current = root; - let currentRelativePath = ""; - for (const segment of folderSegments) { - currentRelativePath = currentRelativePath - ? `${currentRelativePath}/${segment}` - : segment; - if (!current.folders.has(segment)) { - current.folders.set(segment, { - name: segment, - relativePath: currentRelativePath, - folders: new Map(), - labs: [] - }); - } - current = current.folders.get(segment)!; - } - - const item: DevExplorerTreeItem = { - id: `local-lab:${file.path}`, - label: file.filename || safeFilename(file.path), - description: file.path, - tooltip: file.path, - contextValue: isFavoriteLabPath(file.path) - ? "containerlabLabUndeployedFavorite" - : "containerlabLabUndeployed", - collapsibleState: TREE_ITEM_NONE, - labPath: { - absolute: file.path, - relative: relativePath - }, - children: [] - }; - item.command = { - command: "containerlab.lab.graph.topoViewer", - title: "Open TopoViewer", - arguments: [item] - }; - current.labs.push(item); - } - - const toTreeItems = (branch: DevLocalFolderBranch): DevExplorerTreeItem[] => { - const labItems = [...branch.labs].sort((a, b) => String(a.label).localeCompare(String(b.label))); - const folderItems = Array.from(branch.folders.values()) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((folder) => ({ - id: `local-folder:${folder.relativePath}`, - label: folder.name, - tooltip: folder.relativePath, - contextValue: "containerlabFolder", - collapsibleState: TREE_ITEM_COLLAPSED, - children: toTreeItems(folder) - })); - return [...labItems, ...folderItems]; - }; - - return filterTreeItems(toTreeItems(root), filterText); -} - -function buildHelpItems(): DevExplorerTreeItem[] { - return HELP_LINKS.map((link) => ({ - id: `help:${link.url}`, - label: link.label, - tooltip: link.url, - collapsibleState: TREE_ITEM_NONE, - command: { - command: "containerlab.openLink", - title: "Open Link", - arguments: [link.url] - }, - children: [] - })); -} - -async function buildExplorerProviders(): Promise { - const runningItems = buildRunningLabItems(explorerFilterText); - const localItems = await buildLocalLabItems(explorerFilterText); - const helpItems = buildHelpItems(); - - return { - runningProvider: new DevExplorerProvider(runningItems) as unknown as ExplorerSnapshotProviders["runningProvider"], - localProvider: new DevExplorerProvider(localItems) as unknown as ExplorerSnapshotProviders["localProvider"], - helpProvider: new DevExplorerProvider(helpItems) as unknown as ExplorerSnapshotProviders["helpProvider"] - }; -} - -async function postExplorerSnapshot(): Promise { - try { - const providers = await buildExplorerProviders(); - const options: ExplorerSnapshotOptions = { - hideNonOwnedLabs: false, - isLocalCaptureAllowed: true - }; - const { snapshot, actionBindings } = await buildExplorerSnapshot( - providers, - explorerFilterText, - options - ); - explorerActionBindings = actionBindings; - sendExplorerMessage(snapshot); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - postExplorerError(`Explorer refresh failed in dev mode: ${message}`); - } -} - -function scheduleExplorerSnapshot(delay = EXPLORER_REFRESH_DEBOUNCE_MS): void { - if (explorerRefreshTimer !== null) { - window.clearTimeout(explorerRefreshTimer); - } - explorerRefreshTimer = window.setTimeout(() => { - explorerRefreshTimer = null; - void postExplorerSnapshot(); - }, delay); -} - -function firstArgAsTreeItem(args: unknown[]): DevExplorerTreeItem | undefined { - const arg = args[0]; - if (!arg || typeof arg !== "object") { - return undefined; - } - return arg as DevExplorerTreeItem; -} - -function resolveLabPath(args: unknown[]): string | undefined { - const first = args[0]; - if (typeof first === "string" && first.length > 0) { - return normalizeTopologyRequestPath(first); - } - const item = firstArgAsTreeItem(args); - if (!item) { - return currentFilePath ? normalizeTopologyRequestPath(currentFilePath) : undefined; - } - if (item.labPath?.absolute) { - return normalizeTopologyRequestPath(item.labPath.absolute); - } - if (typeof item.description === "string" && item.description.includes(".clab.")) { - return normalizeTopologyRequestPath(item.description); - } - return currentFilePath ? normalizeTopologyRequestPath(currentFilePath) : undefined; -} - -async function setDeploymentStateAndRefresh( - state: "deployed" | "undeployed" | "unknown", - labPath?: string -): Promise { - stateManager.setDeploymentState(state); - syncHostContext({ deploymentState: state }); - if (state !== "deployed") { - sshxShareLinksByLab.clear(); - gottyShareLinksByLab.clear(); - } - if (labPath) { - await loadTopologyFile(labPath); - return; - } - await refreshTopologySnapshot(); -} - -function openExternalLink(url: string): void { - window.open(url, "_blank", "noopener,noreferrer"); -} - -async function executeExplorerCommand(commandId: string, args: unknown[]): Promise { - const item = firstArgAsTreeItem(args); - const labPath = resolveLabPath(args); - - switch (commandId) { - case "containerlab.openLink": { - const link = typeof args[0] === "string" ? args[0] : undefined; - if (link) { - openExternalLink(link); - } - return; - } - case "containerlab.lab.graph.topoViewer": - case "containerlab.lab.openFile": - case "containerlab.editor.topoViewerEditor.open": { - if (labPath) { - await loadTopologyFile(labPath); - } - return; - } - case "containerlab.lab.copyPath": { - if (labPath) { - await copyTextToClipboard(labPath); - } - return; - } - case "containerlab.lab.toggleFavorite": { - if (!labPath) { - return; - } - const favoriteKey = toFavoriteLabKey(labPath); - if (favoriteLabPaths.has(favoriteKey)) { - favoriteLabPaths.delete(favoriteKey); - } else { - favoriteLabPaths.add(favoriteKey); - } - return; - } - case "containerlab.lab.sshx.attach": - case "containerlab.lab.sshx.reattach": - case "containerlab.lab.sshx.detach": { - if (!labPath) { - return; - } - const labKey = toFavoriteLabKey(labPath); - if (commandId.endsWith(".detach")) { - sshxShareLinksByLab.delete(labKey); - } else if (!sshxShareLinksByLab.has(labKey)) { - sshxShareLinksByLab.set(labKey, createDevShareLink("sshx", labPath)); - } - return; - } - case "containerlab.lab.gotty.attach": - case "containerlab.lab.gotty.reattach": - case "containerlab.lab.gotty.detach": { - if (!labPath) { - return; - } - const labKey = toFavoriteLabKey(labPath); - if (commandId.endsWith(".detach")) { - gottyShareLinksByLab.delete(labKey); - } else if (!gottyShareLinksByLab.has(labKey)) { - gottyShareLinksByLab.set(labKey, createDevShareLink("gotty", labPath)); - } - return; - } - case "containerlab.lab.deploy": - case "containerlab.lab.deploy.cleanup": - case "containerlab.lab.deploy.specificFile": { - await setDeploymentStateAndRefresh("deployed", labPath); - return; - } - case "containerlab.lab.destroy": - case "containerlab.lab.destroy.cleanup": { - await setDeploymentStateAndRefresh("undeployed"); - return; - } - case "containerlab.lab.redeploy": - case "containerlab.lab.redeploy.cleanup": { - await setDeploymentStateAndRefresh("deployed", labPath); - return; - } - case "containerlab.node.copyName": { - await copyTextToClipboard(item?.name || item?.label || ""); - return; - } - case "containerlab.node.copyID": { - await copyTextToClipboard(item?.cID || ""); - return; - } - case "containerlab.node.copyKind": { - await copyTextToClipboard(item?.kind || ""); - return; - } - case "containerlab.node.copyImage": { - await copyTextToClipboard(item?.image || ""); - return; - } - case "containerlab.node.copyIPv4Address": { - await copyTextToClipboard(item?.v4Address || ""); - return; - } - case "containerlab.node.copyIPv6Address": { - await copyTextToClipboard(item?.v6Address || ""); - return; - } - case "containerlab.interface.copyMACAddress": { - await copyTextToClipboard(item?.mac || ""); - return; - } - case "containerlab.lab.sshx.copyLink": - case "containerlab.lab.gotty.copyLink": { - const link = typeof args[0] === "string" ? args[0] : item?.link; - if (link) { - await copyTextToClipboard(link); - } - return; - } - default: { - if (!unhandledExplorerCommands.has(commandId)) { - unhandledExplorerCommands.add(commandId); - console.info(`[Dev Explorer] Command not implemented in dev mode: ${commandId}`); - } - } - } -} - -(window as unknown as { __DEV__: DevServerInterface }).__DEV__.onHostUpdate = () => { - scheduleExplorerSnapshot(); -}; - -console.log( - "%c[React TopoViewer - Dev Mode]", - "color: #E91E63; font-weight: bold; font-size: 14px;" -); -console.log("%cFile operations:", "color: #4CAF50; font-weight: bold;"); -console.log(' __DEV__.loadTopologyFile("/path/to/file.clab.yml")'); -console.log(" __DEV__.listTopologyFiles()"); -console.log(" __DEV__.resetFiles()"); -console.log(" __DEV__.getCurrentFile()"); -console.log(""); -console.log("%cMode and state:", "color: #2196F3; font-weight: bold;"); -console.log(' __DEV__.setMode("edit" | "view")'); -console.log(' __DEV__.setDeploymentState("deployed" | "undeployed")'); - -// Dev mode command interceptor — mocks window.vscode.postMessage for HTTP. -function setupDevModeCommandInterceptor(): void { - type DevVscodeMessage = { - command?: string; - type?: string; - level?: string; - message?: string; - fileLine?: string; - actionRef?: string; - value?: string; - state?: ExplorerUiState; - requestId?: string; - baseName?: string; - svgContent?: string; - dashboardJson?: string; - panelYaml?: string; - }; - - const warnedCommands = new Set(); - const logLevelMap: Record void> = { - debug: console.debug.bind(console), - info: console.info.bind(console), - warn: console.warn.bind(console), - error: console.error.bind(console) - }; - - const warnOnce = (command: string) => { - if (warnedCommands.has(command)) return; - warnedCommands.add(command); - console.warn(`[Dev] Unhandled VS Code command: ${command}`); - }; - - const postToWebview = (data: Record): void => { - window.dispatchEvent(new MessageEvent("message", { data })); - }; - - const triggerDownload = (filename: string, content: string, mimeType: string): void => { - const blob = new Blob([content], { type: `${mimeType};charset=utf-8` }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - link.click(); - URL.revokeObjectURL(url); - }; - - const sanitizeBaseName = (value: string): string => { - const trimmed = value.trim(); - if (!trimmed) return "topology"; - const withoutSvg = trimmed.replace(/\.svg$/i, ""); - const sanitized = withoutSvg - .split("") - .map((char) => { - const code = char.charCodeAt(0); - const isInvalid = code < 32 || "<>:\"/\\|?*".includes(char); - return isInvalid ? "-" : char; - }) - .join("") - .trim(); - return sanitized || "topology"; - }; - - const handleGrafanaBundleExport = (msg: DevVscodeMessage): void => { - const requestId = typeof msg.requestId === "string" ? msg.requestId.trim() : ""; - const svgContent = typeof msg.svgContent === "string" ? msg.svgContent : ""; - const dashboardJson = typeof msg.dashboardJson === "string" ? msg.dashboardJson : ""; - const panelYaml = typeof msg.panelYaml === "string" ? msg.panelYaml : ""; - const baseName = sanitizeBaseName(typeof msg.baseName === "string" ? msg.baseName : "topology"); - - if (!requestId || !svgContent || !dashboardJson || !panelYaml) { - postToWebview({ - type: MSG_SVG_EXPORT_RESULT, - requestId, - success: false, - error: "Invalid SVG Grafana export payload" - }); - return; - } - - try { - const svgFilename = `${baseName}.svg`; - const dashboardFilename = `${baseName}.grafana.json`; - const panelFilename = `${baseName}.flow_panel.yaml`; - - triggerDownload(svgFilename, svgContent, "image/svg+xml"); - triggerDownload(dashboardFilename, dashboardJson, "application/json"); - triggerDownload(panelFilename, panelYaml, "application/x-yaml"); - - postToWebview({ - type: MSG_SVG_EXPORT_RESULT, - requestId, - success: true, - files: [svgFilename, dashboardFilename, panelFilename] - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - postToWebview({ - type: MSG_SVG_EXPORT_RESULT, - requestId, - success: false, - error: message - }); - } - }; - - const handleViewerLog = (msg: DevVscodeMessage) => { - const level = typeof msg.level === "string" ? msg.level : "info"; - const logger = logLevelMap[level] ?? console.log.bind(console); - const fileLine = typeof msg.fileLine === "string" ? msg.fileLine : ""; - const message = typeof msg.message === "string" ? msg.message : ""; - const prefix = fileLine ? `[${fileLine}] ` : ""; - logger(`${prefix}${message}`); - }; - - const isExplorerOutgoingMessage = ( - message: DevVscodeMessage - ): message is ExplorerOutgoingMessage => { - return ( - message.command === "ready" || - message.command === "setFilter" || - message.command === "invokeAction" || - message.command === "requestRefresh" || - message.command === "persistUiState" - ); - }; - - const handleExplorerMessage = (message: ExplorerOutgoingMessage): void => { - if (message.command === "ready") { - postExplorerFilterState(); - postExplorerUiState(); - scheduleExplorerSnapshot(0); - return; - } - - if (message.command === "setFilter") { - explorerFilterText = message.value.trim(); - postExplorerFilterState(); - scheduleExplorerSnapshot(0); - return; - } - - if (message.command === "persistUiState") { - explorerUiState = message.state || {}; - return; - } - - if (message.command === "requestRefresh") { - scheduleExplorerSnapshot(0); - return; - } - - if (message.command === "invokeAction") { - const binding = explorerActionBindings.get(message.actionRef); - if (!binding) { - postExplorerError("Action is no longer available. Refresh the explorer and try again."); - return; - } - - Promise.resolve(executeExplorerCommand(binding.commandId, binding.args ?? [])) - .then(() => { - scheduleExplorerSnapshot(0); - }) - .catch((error: unknown) => { - const actionError = error instanceof Error ? error.message : String(error); - postExplorerError(`Failed to execute action: ${actionError}`); - }); - } - }; - - const commandHandlers: Record void> = { - reactTopoViewerLog: handleViewerLog, - topoViewerLog: handleViewerLog, - [EXPORT_COMMANDS.EXPORT_SVG_GRAFANA_BUNDLE]: handleGrafanaBundleExport - }; - - // Create a mock vscode API that intercepts postMessage calls - const mockVscodeApi = { - __isDevMock__: true, - postMessage: (message: unknown) => { - const msg = message as DevVscodeMessage | undefined; - - // Ignore topology-host messages - these should use HTTP in dev mode - if (msg?.type?.startsWith("topology-host:")) { - return; - } - - if (!msg?.command) return; - - if (isExplorerOutgoingMessage(msg)) { - handleExplorerMessage(msg); - return; - } - - const handler = commandHandlers[msg.command]; - if (handler) { - handler(msg); - return; - } - - warnOnce(msg.command); - } - }; - - // Expose the mock API on window.vscode - (window as unknown as { vscode: typeof mockVscodeApi }).vscode = mockVscodeApi; -} - -// Dev Overlay - -function mountDevOverlay(): void { - const container = document.getElementById("dev-overlay"); - if (!container) return; - - const detectMode = () => - document.documentElement.classList.contains("light") ? ("light" as const) : ("dark" as const); - - function DevOverlayWrapper() { - const handleToggleTheme = React.useCallback(() => { - document.documentElement.classList.toggle("light"); - applyDevVars(detectMode()); - }, []); - - return ( - - - currentFilePath} - setMode={(mode) => { - (window as unknown as { __DEV__: { setMode: (m: string) => void } }).__DEV__.setMode( - mode - ); - }} - onToggleTheme={handleToggleTheme} - /> - - ); - } - - createRoot(container).render(); -} - -// Bootstrap - -applyDevVars("dark"); -setupDevModeCommandInterceptor(); -subscribeToFileChanges(); -mountDevOverlay(); -void loadTopologyFile(DEFAULT_TOPOLOGY); diff --git a/dev/mock/DevState.ts b/dev/mock/DevState.ts deleted file mode 100644 index a6676dc50..000000000 --- a/dev/mock/DevState.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * DevState - Minimal state for dev mode - * - * Only holds state that doesn't exist on the server: - * - currentFilePath: which file is loaded - * - customNodes: node template config (with setDefault flag on nodes) - * - mode/deploymentState: for lifecycle simulation - * - * Elements and annotations are fetched from server - no local cache. - * Clipboard is handled locally by useUnifiedClipboard (React refs). - */ - -import type { CustomNodeTemplate } from "@shared/types/editors"; - -// ============================================================================ -// Types -// ============================================================================ - -export type TopoMode = "edit" | "view"; -export type DeploymentState = "deployed" | "undeployed" | "unknown"; - -export interface DevState { - /** Currently loaded topology file path */ - currentFilePath: string | null; - /** Custom node templates (default is marked with setDefault: true) */ - customNodes: CustomNodeTemplate[]; - /** Editor mode (for lifecycle simulation) */ - mode: TopoMode; - /** Lab deployment state (for lifecycle simulation) */ - deploymentState: DeploymentState; -} - -export type StateListener = (state: Readonly) => void; - -/** Helper to create empty annotations object */ -export function createDefaultAnnotations() { - return { - freeTextAnnotations: [], - freeShapeAnnotations: [], - groupStyleAnnotations: [], - nodeAnnotations: [], - networkNodeAnnotations: [], - aliasEndpointAnnotations: [] - }; -} - -// ============================================================================ -// DevStateManager Class -// ============================================================================ - -export class DevStateManager { - private state: DevState; - private listeners: Set = new Set(); - - constructor(initialState?: Partial) { - this.state = { - currentFilePath: null, - customNodes: [], - mode: "edit", - deploymentState: "undeployed", - ...initialState - }; - } - - // -------------------------------------------------------------------------- - // Getters - // -------------------------------------------------------------------------- - - getState(): Readonly { - return this.state; - } - - getCurrentFilePath(): string | null { - return this.state.currentFilePath; - } - - getCustomNodes(): CustomNodeTemplate[] { - return this.state.customNodes; - } - - /** Get default custom node name (from node with setDefault: true) */ - getDefaultCustomNode(): string { - const defaultNode = this.state.customNodes.find((n) => n.setDefault === true); - return defaultNode?.name || ""; - } - - getMode(): TopoMode { - return this.state.mode; - } - - getDeploymentState(): DeploymentState { - return this.state.deploymentState; - } - - // -------------------------------------------------------------------------- - // Setters - // -------------------------------------------------------------------------- - - setCurrentFilePath(filePath: string | null): void { - this.state = { ...this.state, currentFilePath: filePath }; - this.notify(); - } - - setCustomNodes(customNodes: CustomNodeTemplate[]): void { - this.state = { ...this.state, customNodes }; - this.notify(); - } - - setMode(mode: TopoMode): void { - this.state = { ...this.state, mode }; - this.notify(); - } - - setDeploymentState(deploymentState: DeploymentState): void { - this.state = { ...this.state, deploymentState }; - this.notify(); - } - - // -------------------------------------------------------------------------- - // Custom Node CRUD (matches production CustomNodeConfigManager behavior) - // -------------------------------------------------------------------------- - - /** Save or update a custom node template */ - saveCustomNode(data: CustomNodeTemplate & { oldName?: string }): void { - let nodes = [...this.state.customNodes]; - - // If setDefault is true, clear setDefault on all other nodes first - if (data.setDefault) { - nodes = nodes.map((n) => ({ ...n, setDefault: false })); - } - - // Remove oldName from data before storing - const { oldName, ...nodeData } = data; - - if (oldName) { - // Update existing node (find by oldName) - const idx = nodes.findIndex((n) => n.name === oldName); - if (idx >= 0) { - nodes[idx] = nodeData; - } else { - nodes.push(nodeData); - } - } else { - // Add new or update existing (find by name) - const idx = nodes.findIndex((n) => n.name === data.name); - if (idx >= 0) { - nodes[idx] = nodeData; - } else { - nodes.push(nodeData); - } - } - - this.state = { ...this.state, customNodes: nodes }; - this.notify(); - } - - /** Set a node as default (updates setDefault flag on all nodes) */ - setDefaultCustomNodeByName(name: string): void { - const nodes = this.state.customNodes.map((n) => ({ - ...n, - setDefault: n.name === name - })); - this.state = { ...this.state, customNodes: nodes }; - this.notify(); - } - - /** Delete a custom node template by name */ - deleteCustomNode(name: string): void { - const nodes = this.state.customNodes.filter((n) => n.name !== name); - this.state = { ...this.state, customNodes: nodes }; - this.notify(); - } - - // -------------------------------------------------------------------------- - // File Loading (just sets the path, server has the data) - // -------------------------------------------------------------------------- - - /** Record that a file was loaded */ - setLoadedFile(filePath: string): void { - this.state = { ...this.state, currentFilePath: filePath }; - this.notify(); - } - - // -------------------------------------------------------------------------- - // Pub/Sub - // -------------------------------------------------------------------------- - - subscribe(listener: StateListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private notify(): void { - const state = this.getState(); - for (const listener of this.listeners) { - listener(state); - } - } - - // -------------------------------------------------------------------------- - // Reset - // -------------------------------------------------------------------------- - - reset(): void { - this.state = { - currentFilePath: null, - customNodes: [], - mode: "edit", - deploymentState: "undeployed" - }; - this.notify(); - } -} diff --git a/dev/mock/LatencySimulator.ts b/dev/mock/LatencySimulator.ts deleted file mode 100644 index 39a0e3ac0..000000000 --- a/dev/mock/LatencySimulator.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * LatencySimulator - Simulate network latency in dev mode - * - * Provides configurable delays to simulate realistic extension - * response times for testing loading states, spinners, etc. - */ - -// ============================================================================ -// Types -// ============================================================================ - -export type LatencyProfile = "instant" | "fast" | "normal" | "slow"; - -export type OperationType = - | "request" // POST/RESPONSE async requests - | "command" // Fire-and-forget commands - | "lifecycle" // Deploy/destroy operations - | "editor" // Node/link editor saves - | "annotation"; // Annotation saves - -interface LatencyConfig { - request: number; - command: number; - lifecycle: number; - editor: number; - annotation: number; -} - -const LATENCY_PROFILES: Record = { - instant: { - request: 0, - command: 0, - lifecycle: 0, - editor: 0, - annotation: 0 - }, - fast: { - request: 50, - command: 50, - lifecycle: 500, - editor: 100, - annotation: 50 - }, - normal: { - request: 200, - command: 150, - lifecycle: 1500, - editor: 300, - annotation: 150 - }, - slow: { - request: 500, - command: 300, - lifecycle: 3000, - editor: 800, - annotation: 400 - } -}; - -export interface LatencySimulatorConfig { - /** Which latency profile to use */ - profile?: LatencyProfile; - /** Add random jitter (0-1, where 0.2 = +/- 20%) */ - jitter?: number; - /** Enable logging of delays */ - verbose?: boolean; -} - -// ============================================================================ -// LatencySimulator Class -// ============================================================================ - -export class LatencySimulator { - private profile: LatencyProfile; - private jitter: number; - private verbose: boolean; - private listeners: Set<(profile: LatencyProfile) => void> = new Set(); - - constructor(config: LatencySimulatorConfig = {}) { - this.profile = config.profile ?? "fast"; - this.jitter = config.jitter ?? 0.2; - this.verbose = config.verbose ?? false; - } - - // -------------------------------------------------------------------------- - // Profile Management - // -------------------------------------------------------------------------- - - /** Get current profile */ - getProfile(): LatencyProfile { - return this.profile; - } - - /** Set latency profile */ - setProfile(profile: LatencyProfile): void { - this.profile = profile; - this.notifyListeners(); - if (this.verbose) { - console.log(`%c[Latency] Profile set to: ${profile}`, "color: #9C27B0;"); - } - } - - /** Subscribe to profile changes */ - onProfileChange(listener: (profile: LatencyProfile) => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - private notifyListeners(): void { - for (const listener of this.listeners) { - listener(this.profile); - } - } - - // -------------------------------------------------------------------------- - // Delay Calculation - // -------------------------------------------------------------------------- - - /** Get delay for an operation type */ - getDelay(operation: OperationType): number { - const baseDelay = LATENCY_PROFILES[this.profile][operation]; - - if (this.jitter === 0) { - return baseDelay; - } - - // Add random jitter - const jitterAmount = baseDelay * this.jitter; - const jitterOffset = (Math.random() * 2 - 1) * jitterAmount; - return Math.max(0, Math.round(baseDelay + jitterOffset)); - } - - // -------------------------------------------------------------------------- - // Simulation - // -------------------------------------------------------------------------- - - /** - * Simulate delay for an operation - * Returns a promise that resolves after the delay - */ - async delay(operation: OperationType): Promise { - const ms = this.getDelay(operation); - if (ms === 0) return; - - if (this.verbose) { - console.log(`%c[Latency] Simulating ${ms}ms delay for: ${operation}`, "color: #9C27B0;"); - } - - await new Promise((resolve) => setTimeout(resolve, ms)); - } - - /** - * Wrap a function with simulated latency - */ - async simulate(operation: OperationType, fn: () => T | Promise): Promise { - await this.delay(operation); - return fn(); - } - - /** - * Wrap a callback with simulated latency - * Useful for setTimeout-style patterns - */ - simulateCallback(operation: OperationType, callback: () => void): void { - const delay = this.getDelay(operation); - setTimeout(callback, delay); - } - - // -------------------------------------------------------------------------- - // Configuration - // -------------------------------------------------------------------------- - - /** Set jitter amount (0-1) */ - setJitter(jitter: number): void { - this.jitter = Math.max(0, Math.min(1, jitter)); - } - - /** Enable/disable verbose logging */ - setVerbose(verbose: boolean): void { - this.verbose = verbose; - } - - /** Get all profile names */ - static getProfiles(): LatencyProfile[] { - return ["instant", "fast", "normal", "slow"]; - } - - /** Get config for a profile */ - static getProfileConfig(profile: LatencyProfile): LatencyConfig { - return { ...LATENCY_PROFILES[profile] }; - } -} - -// ============================================================================ -// Singleton Instance (Optional) -// ============================================================================ - -let defaultInstance: LatencySimulator | null = null; - -/** - * Get the default LatencySimulator instance - */ -export function getLatencySimulator(): LatencySimulator { - if (!defaultInstance) { - defaultInstance = new LatencySimulator(); - } - return defaultInstance; -} - -/** - * Set the default LatencySimulator instance - */ -export function setLatencySimulator(simulator: LatencySimulator): void { - defaultInstance = simulator; -} diff --git a/dev/mockData.ts b/dev/mockData.ts deleted file mode 100644 index 05e70218e..000000000 --- a/dev/mockData.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Mock data for standalone development of React TopoViewer. - */ - -import type { CustomNodeTemplate } from "@shared/types/editors"; -import type { CustomIconInfo } from "@shared/types/icons"; - -/** - * Sample custom node templates (from VS Code settings) - */ -export const sampleCustomNodes: CustomNodeTemplate[] = [ - { - name: "SRLinux Latest", - kind: "nokia_srlinux", - type: "ixrd1", - image: "ghcr.io/nokia/srlinux:latest", - icon: "router", - baseName: "srl", - interfacePattern: "e1-{n}", - setDefault: true - }, - { - name: "Network Multitool", - kind: "linux", - image: "ghcr.io/srl-labs/network-multitool:latest", - icon: "client", - baseName: "client", - interfacePattern: "eth{n}", - setDefault: false - }, - { - name: "Arista cEOS", - kind: "arista_ceos", - image: "ceos:latest", - icon: "router", - baseName: "ceos", - interfacePattern: "Ethernet{n}", - setDefault: false - } -]; - -/** - * Sample custom icons for development. - * These match the format of built-in icons (120x120 with viewBox). - */ -export const sampleCustomIcons: CustomIconInfo[] = [ - { - name: "my-router", - source: "global", - format: "svg", - // Simple router icon (orange) - matches built-in icon format - dataUri: - "data:image/svg+xml;utf8," + - encodeURIComponent( - `R` - ) - }, - { - name: "my-switch", - source: "global", - format: "svg", - // Simple switch icon (purple) - matches built-in icon format - dataUri: - "data:image/svg+xml;utf8," + - encodeURIComponent( - `S` - ) - }, - { - name: "firewall", - source: "workspace", - format: "svg", - // Simple firewall icon (red) - matches built-in icon format - dataUri: - "data:image/svg+xml;utf8," + - encodeURIComponent( - `FW` - ) - } -]; diff --git a/dev/server/SessionFsAdapter.ts b/dev/server/SessionFsAdapter.ts deleted file mode 100644 index af1fddbb6..000000000 --- a/dev/server/SessionFsAdapter.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * SessionFsAdapter - Session-aware file system adapter for dev server - * - * Implements FileSystemAdapter using in-memory storage with session isolation. - * This enables Playwright tests to run in parallel without file conflicts. - * - * Storage model: - * - YAML files stored in session Map - * - Annotations stored in session Map - * - Falls back to disk for files not yet in session - */ - -import * as fs from "fs"; -import * as path from "path"; -import { FileSystemAdapter } from "../../src/reactTopoViewer/shared/io/types"; - -/** Session storage maps */ -export interface SessionMaps { - yamlFiles: Map>; - annotationFiles: Map>; -} - -/** - * Session-aware file system adapter - * - * Reads/writes go to in-memory session storage when a sessionId is provided. - * Falls back to disk for files not in session or when no session exists. - */ -export class SessionFsAdapter implements FileSystemAdapter { - private yamlStorage: Map; - private annotationsStorage: Map; - private diskBasePath: string; - private sessionId: string; - - constructor(sessionId: string, sessionMaps: SessionMaps, diskBasePath: string) { - this.sessionId = sessionId; - this.diskBasePath = diskBasePath; - - // Get or create session storage - if (!sessionMaps.yamlFiles.has(sessionId)) { - sessionMaps.yamlFiles.set(sessionId, new Map()); - } - if (!sessionMaps.annotationFiles.has(sessionId)) { - sessionMaps.annotationFiles.set(sessionId, new Map()); - } - - this.yamlStorage = sessionMaps.yamlFiles.get(sessionId)!; - this.annotationsStorage = sessionMaps.annotationFiles.get(sessionId)!; - } - - /** - * Normalize separators for stable map keys - */ - private toPosixPath(pathValue: string): string { - return pathValue.replace(/\\/g, "/"); - } - - private isPathInsideBase(targetPath: string): boolean { - const relative = path.relative(this.diskBasePath, targetPath); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); - } - - private normalizeRelativePath(filePath: string): string { - const normalized = this.toPosixPath(filePath).replace(/^\.\//, ""); - const marker = "/topologies/"; - const markerIndex = normalized.lastIndexOf(marker); - if (markerIndex >= 0) { - return normalized.slice(markerIndex + marker.length); - } - if (normalized.startsWith("topologies/")) { - return normalized.slice("topologies/".length); - } - if (normalized.startsWith("dev/topologies/")) { - return normalized.slice("dev/topologies/".length); - } - return normalized; - } - - private resolveDiskPath(filePath: string): string { - const candidate = path.isAbsolute(filePath) - ? path.resolve(filePath) - : path.resolve(this.diskBasePath, this.normalizeRelativePath(filePath)); - if (this.isPathInsideBase(candidate)) { - return candidate; - } - return path.join(this.diskBasePath, path.basename(this.normalizeRelativePath(filePath))); - } - - /** - * Get relative path key for session storage. - */ - private getStorageKey(filePath: string): string { - const diskPath = this.resolveDiskPath(filePath); - const relative = path.relative(this.diskBasePath, diskPath); - return this.toPosixPath(relative); - } - - /** - * Check if path is an annotations file - */ - private isAnnotationsFile(filePath: string): boolean { - return filePath.endsWith(".annotations.json"); - } - - /** - * Get YAML storage key from annotations path - */ - private getYamlStorageKey(annotationsPath: string): string { - const annotationsKey = this.getStorageKey(annotationsPath); - return annotationsKey.replace(/\.annotations\.json$/, ""); - } - - async readFile(filePath: string): Promise { - const storageKey = this.getStorageKey(filePath); - const diskPath = this.resolveDiskPath(filePath); - const isAnnotation = this.isAnnotationsFile(filePath); - - if (isAnnotation) { - const yamlKey = this.getYamlStorageKey(filePath); - - // Check session cache - if (this.annotationsStorage.has(yamlKey)) { - const content = this.annotationsStorage.get(yamlKey); - if (content === null || content === undefined) { - throw new Error(`ENOENT: no such file ${filePath}`); - } - return content; - } - - // Try to load from disk into session - try { - const content = await fs.promises.readFile(diskPath, "utf8"); - this.annotationsStorage.set(yamlKey, content); - return content; - } catch { - this.annotationsStorage.set(yamlKey, null); - throw new Error(`ENOENT: no such file ${filePath}`); - } - } else { - // YAML file - if (this.yamlStorage.has(storageKey)) { - return this.yamlStorage.get(storageKey)!; - } - - // Try to load from disk into session - try { - const content = await fs.promises.readFile(diskPath, "utf8"); - this.yamlStorage.set(storageKey, content); - return content; - } catch { - throw new Error(`ENOENT: no such file ${filePath}`); - } - } - } - - async writeFile(filePath: string, content: string): Promise { - const storageKey = this.getStorageKey(filePath); - const isAnnotation = this.isAnnotationsFile(filePath); - - if (isAnnotation) { - const yamlKey = this.getYamlStorageKey(filePath); - this.annotationsStorage.set(yamlKey, content); - console.log(`[SessionFs] Session ${this.sessionId}: Wrote annotations: ${yamlKey}`); - } else { - this.yamlStorage.set(storageKey, content); - console.log(`[SessionFs] Session ${this.sessionId}: Wrote YAML: ${storageKey}`); - } - } - - async unlink(filePath: string): Promise { - const storageKey = this.getStorageKey(filePath); - const isAnnotation = this.isAnnotationsFile(filePath); - - if (isAnnotation) { - const yamlKey = this.getYamlStorageKey(filePath); - this.annotationsStorage.set(yamlKey, null); // Mark as deleted - console.log(`[SessionFs] Session ${this.sessionId}: Deleted annotations: ${yamlKey}`); - } else { - this.yamlStorage.delete(storageKey); - console.log(`[SessionFs] Session ${this.sessionId}: Deleted YAML: ${storageKey}`); - } - } - - async rename(oldPath: string, newPath: string): Promise { - const oldIsAnnotation = this.isAnnotationsFile(oldPath); - const newIsAnnotation = this.isAnnotationsFile(newPath); - - if (oldIsAnnotation !== newIsAnnotation) { - throw new Error(`Cannot rename between different file types: ${oldPath} -> ${newPath}`); - } - - if (oldIsAnnotation) { - const oldKey = this.getYamlStorageKey(oldPath); - const newKey = this.getYamlStorageKey(newPath); - if (this.annotationsStorage.has(oldKey)) { - const content = this.annotationsStorage.get(oldKey); - this.annotationsStorage.set(newKey, content ?? null); - this.annotationsStorage.delete(oldKey); - return; - } - - // Fall back to disk if not in session - const diskOld = this.resolveDiskPath(oldPath); - const diskNew = this.resolveDiskPath(newPath); - await fs.promises.mkdir(path.dirname(diskNew), { recursive: true }); - await fs.promises.rename(diskOld, diskNew); - return; - } - - const oldKey = this.getStorageKey(oldPath); - const newKey = this.getStorageKey(newPath); - if (this.yamlStorage.has(oldKey)) { - const content = this.yamlStorage.get(oldKey)!; - this.yamlStorage.set(newKey, content); - this.yamlStorage.delete(oldKey); - return; - } - - // Fall back to disk if not in session - const diskOld = this.resolveDiskPath(oldPath); - const diskNew = this.resolveDiskPath(newPath); - await fs.promises.mkdir(path.dirname(diskNew), { recursive: true }); - await fs.promises.rename(diskOld, diskNew); - } - - async exists(filePath: string): Promise { - const storageKey = this.getStorageKey(filePath); - const diskPath = this.resolveDiskPath(filePath); - const isAnnotation = this.isAnnotationsFile(filePath); - - if (isAnnotation) { - const yamlKey = this.getYamlStorageKey(filePath); - - // Check session cache - if (this.annotationsStorage.has(yamlKey)) { - return this.annotationsStorage.get(yamlKey) !== null; - } - - // Check disk - try { - await fs.promises.access(diskPath); - return true; - } catch { - return false; - } - } else { - // YAML file - if (this.yamlStorage.has(storageKey)) { - return true; - } - - // Check disk - try { - await fs.promises.access(diskPath); - return true; - } catch { - return false; - } - } - } - - dirname(filePath: string): string { - return path.dirname(filePath); - } - - basename(filePath: string): string { - return path.basename(filePath); - } - - join(...segments: string[]): string { - return path.join(...segments); - } -} - -/** - * Reset session storage by copying disk files - */ -export async function resetSession( - sessionId: string, - sessionMaps: SessionMaps, - diskBasePath: string -): Promise { - // Get or create session maps - if (!sessionMaps.yamlFiles.has(sessionId)) { - sessionMaps.yamlFiles.set(sessionId, new Map()); - } - if (!sessionMaps.annotationFiles.has(sessionId)) { - sessionMaps.annotationFiles.set(sessionId, new Map()); - } - - const yamlMap = sessionMaps.yamlFiles.get(sessionId)!; - const annotMap = sessionMaps.annotationFiles.get(sessionId)!; - - yamlMap.clear(); - annotMap.clear(); - - const toPosixPath = (pathValue: string): string => pathValue.replace(/\\/g, "/"); - const collectYamlFiles = async (currentDir: string): Promise => { - const entries = await fs.promises.readdir(currentDir, { withFileTypes: true }); - const yamlFiles: string[] = []; - for (const entry of entries) { - const fullPath = path.join(currentDir, entry.name); - if (entry.isDirectory()) { - yamlFiles.push(...(await collectYamlFiles(fullPath))); - continue; - } - if (entry.isFile() && entry.name.endsWith(".clab.yml")) { - yamlFiles.push(fullPath); - } - } - return yamlFiles; - }; - - // Copy disk files to session - try { - const yamlFiles = await collectYamlFiles(diskBasePath); - - for (const yamlPath of yamlFiles) { - const relativeYamlPath = toPosixPath(path.relative(diskBasePath, yamlPath)); - // Read YAML - const yamlContent = await fs.promises.readFile(yamlPath, "utf8"); - yamlMap.set(relativeYamlPath, yamlContent); - - // Read annotations if they exist - const annotPath = `${yamlPath}.annotations.json`; - try { - const annotContent = await fs.promises.readFile(annotPath, "utf8"); - annotMap.set(relativeYamlPath, annotContent); - } catch { - // No annotations file - that's fine - annotMap.set(relativeYamlPath, null); - } - } - } catch (err) { - console.error(`[SessionFs] Failed to reset session ${sessionId}:`, err); - } - - console.log(`[SessionFs] Reset session: ${sessionId}`); -} - -/** - * Create session maps (shared between all sessions) - */ -export function createSessionMaps(): SessionMaps { - return { - yamlFiles: new Map(), - annotationFiles: new Map() - }; -} diff --git a/dev/server/fileApi.ts b/dev/server/fileApi.ts deleted file mode 100644 index da0d996ff..000000000 --- a/dev/server/fileApi.ts +++ /dev/null @@ -1,584 +0,0 @@ -// Vite API middleware — REST endpoints for file I/O and TopologyHost commands. - -import type { Plugin } from "vite"; -import * as fs from "fs"; -import * as path from "path"; -import { TopologyHostCore } from "../../src/reactTopoViewer/shared/host/TopologyHostCore"; -import { nodeFsAdapter } from "../../src/reactTopoViewer/shared/io"; -import type { TopologyHostCommand } from "../../src/reactTopoViewer/shared/types/messages"; -import type { DeploymentState } from "../../src/reactTopoViewer/shared/types/topology"; -import { SessionFsAdapter, SessionMaps, createSessionMaps, resetSession } from "./SessionFsAdapter"; -import { addClient, broadcastFileChange, startFileWatcher } from "./sseManager"; -import { beginInternalUpdate, endInternalUpdate } from "./internalUpdateTracker"; - -const TOPOLOGIES_DIR = path.join(__dirname, "../topologies"); -const TOPOLOGIES_ORIGINAL_DIR = path.join(__dirname, "../topologies-original"); - -// Host cache (per session + file) -const topologyHosts = new Map(); - -function toPosixPath(pathValue: string): string { - return pathValue.replace(/\\/g, "/"); -} - -function isPathInsideBase(baseDir: string, targetPath: string): boolean { - const relative = path.relative(baseDir, targetPath); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -async function listRelativeFilesRecursive(rootDir: string): Promise { - const result: string[] = []; - - async function walk(currentDir: string): Promise { - const entries = await fs.promises.readdir(currentDir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(currentDir, entry.name); - if (entry.isDirectory()) { - await walk(fullPath); - continue; - } - if (!entry.isFile()) { - continue; - } - result.push(toPosixPath(path.relative(rootDir, fullPath))); - } - } - - await walk(rootDir); - return result; -} - -async function copyDirectoryContents(sourceDir: string, targetDir: string): Promise { - await fs.promises.mkdir(targetDir, { recursive: true }); - const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true }); - - for (const entry of entries) { - const sourcePath = path.join(sourceDir, entry.name); - const targetPath = path.join(targetDir, entry.name); - - if (entry.isDirectory()) { - await copyDirectoryContents(sourcePath, targetPath); - continue; - } - if (!entry.isFile()) { - continue; - } - - await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); - await fs.promises.copyFile(sourcePath, targetPath); - } -} - -// ============================================================================ -// Session Management -// ============================================================================ - -// Shared session maps for all sessions -const sessionMaps: SessionMaps = createSessionMaps(); - -/** - * Get file system adapter for a session - */ -function getFsAdapter(sessionId?: string): SessionFsAdapter | null { - if (sessionId) { - return new SessionFsAdapter(sessionId, sessionMaps, TOPOLOGIES_DIR); - } - return null; // Use disk directly -} - -function normalizeTopologyPath(requestedPath: string): string { - const baseDir = path.resolve(TOPOLOGIES_DIR); - const input = requestedPath.trim(); - const normalizedInput = toPosixPath(input).replace(/^\.\//, ""); - - // Accept paths that already include a topologies prefix (e.g. dev/topologies/foo.clab.yml) - // and reinterpret them as topology-root relative. - const marker = "/topologies/"; - const markerIndex = normalizedInput.lastIndexOf(marker); - if (markerIndex >= 0) { - const relative = normalizedInput.slice(markerIndex + marker.length); - const rootedCandidate = path.resolve(baseDir, relative); - if (isPathInsideBase(baseDir, rootedCandidate)) { - return rootedCandidate; - } - } - if (normalizedInput.startsWith("topologies/")) { - const rootedCandidate = path.resolve(baseDir, normalizedInput.slice("topologies/".length)); - if (isPathInsideBase(baseDir, rootedCandidate)) { - return rootedCandidate; - } - } - if (normalizedInput.startsWith("dev/topologies/")) { - const rootedCandidate = path.resolve(baseDir, normalizedInput.slice("dev/topologies/".length)); - if (isPathInsideBase(baseDir, rootedCandidate)) { - return rootedCandidate; - } - } - - const directCandidate = path.isAbsolute(input) - ? path.resolve(input) - : path.resolve(baseDir, normalizedInput); - if (isPathInsideBase(baseDir, directCandidate)) { - return directCandidate; - } - - return path.join(baseDir, path.basename(input)); -} - -function getTopologyHost( - sessionId: string | undefined, - filePath: string, - context?: { mode?: "edit" | "view"; deploymentState?: DeploymentState } -): TopologyHostCore { - const normalizedPath = normalizeTopologyPath(filePath); - const key = `${sessionId ?? "__disk__"}:${normalizedPath}`; - const fsAdapter = sessionId - ? new SessionFsAdapter(sessionId, sessionMaps, TOPOLOGIES_DIR) - : nodeFsAdapter; - - let host = topologyHosts.get(key); - if (!host) { - host = new TopologyHostCore({ - fs: fsAdapter, - yamlFilePath: normalizedPath, - mode: context?.mode ?? "edit", - deploymentState: context?.deploymentState ?? "undeployed", - setInternalUpdate: (updating: boolean) => { - if (updating) { - beginInternalUpdate(normalizedPath); - } else { - endInternalUpdate(normalizedPath); - } - }, - logger: console - }); - topologyHosts.set(key, host); - } else if (context) { - host.updateContext({ - mode: context.mode, - deploymentState: context.deploymentState - }); - } - - return host; -} - -function dropTopologyHosts(sessionId?: string): void { - const prefix = sessionId ? `${sessionId}:` : "__disk__:"; - for (const key of topologyHosts.keys()) { - if (key.startsWith(prefix)) { - topologyHosts.delete(key); - } - } -} - -// ============================================================================ -// Reset Functionality -// ============================================================================ - -/** - * Reset all disk files to their original state (from topologies-original folder) - */ -async function resetDiskFiles(): Promise { - console.log("[FileAPI] Resetting disk files from topologies-original..."); - - try { - await fs.promises.rm(TOPOLOGIES_DIR, { recursive: true, force: true }); - await copyDirectoryContents(TOPOLOGIES_ORIGINAL_DIR, TOPOLOGIES_DIR); - - console.log("[FileAPI] Disk reset complete"); - } catch (err) { - console.error("[FileAPI] Failed to reset disk files:", err); - throw err; - } -} - -// ============================================================================ -// File Operations -// ============================================================================ - -interface TopologyFile { - filename: string; - path: string; - hasAnnotations: boolean; -} - -/** - * List all .clab.yml files - */ -async function listTopologyFiles(sessionId?: string): Promise { - try { - // Always read disk files as base - const relativeFiles = await listRelativeFilesRecursive(TOPOLOGIES_DIR); - const diskYamlFiles = relativeFiles.filter((f) => f.endsWith(".clab.yml")); - const diskFileSet = new Set(relativeFiles); - - // If session exists, merge with session storage (session takes priority) - if (sessionId && sessionMaps.yamlFiles.has(sessionId)) { - const yamlMap = sessionMaps.yamlFiles.get(sessionId)!; - const annotMap = sessionMaps.annotationFiles.get(sessionId)!; - - // Start with disk files - const allFiles = new Set(diskYamlFiles); - // Add any session-only files - for (const filename of yamlMap.keys()) { - if (filename.endsWith(".clab.yml")) { - allFiles.add(filename); - } - } - - return Array.from(allFiles) - .sort() - .map((yamlRelativePath) => ({ - filename: path.basename(yamlRelativePath), - path: path.join(TOPOLOGIES_DIR, yamlRelativePath), - hasAnnotations: annotMap.has(yamlRelativePath) - ? annotMap.get(yamlRelativePath) !== null - : diskFileSet.has(`${yamlRelativePath}.annotations.json`) - })); - } - - // No session - just return disk files - return diskYamlFiles.sort().map((yamlRelativePath) => ({ - filename: path.basename(yamlRelativePath), - path: path.join(TOPOLOGIES_DIR, yamlRelativePath), - hasAnnotations: diskFileSet.has(`${yamlRelativePath}.annotations.json`) - })); - } catch (err) { - console.error("[FileAPI] Failed to list topologies:", err); - return []; - } -} - -/** - * Read a file (from session or disk) - */ -async function readFile(filePath: string, sessionId?: string): Promise { - const fsAdapter = getFsAdapter(sessionId); - - if (fsAdapter) { - try { - return await fsAdapter.readFile(filePath); - } catch { - return null; - } - } - - // No session - read from disk - try { - return await fs.promises.readFile(filePath, "utf8"); - } catch { - return null; - } -} - -/** - * Write a file (to session or disk) - */ -async function writeFile(filePath: string, content: string, sessionId?: string): Promise { - const fsAdapter = getFsAdapter(sessionId); - - if (fsAdapter) { - await fsAdapter.writeFile(filePath, content); - return; - } - - // No session - write to disk - const dir = path.dirname(filePath); - await fs.promises.mkdir(dir, { recursive: true }); - await fs.promises.writeFile(filePath, content, "utf8"); -} - -/** - * Delete a file (from session or disk) - */ -async function deleteFile(filePath: string, sessionId?: string): Promise { - const fsAdapter = getFsAdapter(sessionId); - - if (fsAdapter) { - await fsAdapter.unlink(filePath); - return; - } - - // No session - delete from disk - try { - await fs.promises.unlink(filePath); - } catch (err) { - // Ignore if file doesn't exist - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err; - } - } -} - -/** - * Check if a file exists (in session or disk) - */ -async function fileExists(filePath: string, sessionId?: string): Promise { - const fsAdapter = getFsAdapter(sessionId); - - if (fsAdapter) { - return fsAdapter.exists(filePath); - } - - // No session - check disk - try { - await fs.promises.access(filePath); - return true; - } catch { - return false; - } -} - -// ============================================================================ -// Vite Plugin -// ============================================================================ - -/** - * Extract session ID from request (header or query param) - */ -function getSessionId(req: import("http").IncomingMessage, url: string): string | undefined { - // Check X-Session-ID header first - const headerSession = req.headers["x-session-id"]; - if (headerSession && typeof headerSession === "string") { - return headerSession; - } - - // Check query parameter - const urlObj = new URL(url, "http://localhost"); - return urlObj.searchParams.get("sessionId") || undefined; -} - -/** - * Decode file path from URL parameter - */ -function decodeFilePath(encodedPath: string): string { - return decodeURIComponent(encodedPath); -} - -/** - * Read request body as text - */ -function readBody(req: import("http").IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let body = ""; - req.on("data", (chunk) => { - body += chunk.toString(); - }); - req.on("end", () => resolve(body)); - req.on("error", reject); - }); -} - -/** - * Vite plugin that adds API middleware for file operations - */ -export function fileApiPlugin(): Plugin { - return { - name: "file-api", - configureServer(server) { - // Ensure the working topologies directory exists by copying from originals - if (!fs.existsSync(TOPOLOGIES_DIR)) { - fs.mkdirSync(TOPOLOGIES_DIR, { recursive: true }); - if (fs.existsSync(TOPOLOGIES_ORIGINAL_DIR)) { - fs.cpSync(TOPOLOGIES_ORIGINAL_DIR, TOPOLOGIES_DIR, { recursive: true }); - console.log("[FileAPI] Created topologies/ from topologies-original/"); - } - } - - // Start file watcher for disk changes (for dev mode without session) - startFileWatcher(TOPOLOGIES_DIR); - - server.middlewares.use(async (req, res, next) => { - const fullUrl = req.url || ""; - - // Parse URL without query string for route matching - const urlWithoutQuery = fullUrl.split("?")[0]; - const sessionId = getSessionId(req, fullUrl); - - try { - // ---------------------------------------------------------------- - // GET /files - List available topology files - // ---------------------------------------------------------------- - if (urlWithoutQuery === "/files" && req.method === "GET") { - const files = await listTopologyFiles(sessionId); - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify(files)); - return; - } - - // ---------------------------------------------------------------- - // GET /api/events - SSE endpoint for live file change notifications - // ---------------------------------------------------------------- - if (urlWithoutQuery === "/api/events" && req.method === "GET") { - // Use sessionId if provided, otherwise use a special "no-session" identifier - // This allows dev mode (no session) to receive disk file change notifications - const effectiveSessionId = sessionId || "__dev_mode__"; - - // Set SSE headers - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - res.setHeader("Access-Control-Allow-Origin", "*"); - - // Send initial connection message - res.write(`event: connected\ndata: {"sessionId":"${effectiveSessionId}"}\n\n`); - - // Register client with SSE manager - addClient(effectiveSessionId, res); - - // Keep connection open (don't call res.end()) - return; - } - - // ---------------------------------------------------------------- - // POST /api/reset - Reset files to original state - // ---------------------------------------------------------------- - if (urlWithoutQuery === "/api/reset" && req.method === "POST") { - if (sessionId) { - // Reset session to use current disk files - await resetSession(sessionId, sessionMaps, TOPOLOGIES_DIR); - dropTopologyHosts(sessionId); - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ success: true, sessionId })); - } else { - // Reset disk files to original state - await resetDiskFiles(); - dropTopologyHosts(); - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ success: true })); - } - return; - } - - // ---------------------------------------------------------------- - // POST /api/topology/snapshot - Get host snapshot - // ---------------------------------------------------------------- - if (urlWithoutQuery === "/api/topology/snapshot" && req.method === "POST") { - const body = await readBody(req); - const payload = JSON.parse(body || "{}") as { - path?: string; - mode?: "edit" | "view"; - deploymentState?: DeploymentState; - externalChange?: boolean; - }; - - if (!payload.path) { - res.statusCode = 400; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ error: "Missing topology path" })); - return; - } - - const host = getTopologyHost(sessionId, payload.path, { - mode: payload.mode, - deploymentState: payload.deploymentState - }); - const snapshot = payload.externalChange - ? await host.onExternalChange() - : await host.getSnapshot(); - - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ snapshot })); - return; - } - - // ---------------------------------------------------------------- - // POST /api/topology/command - Apply command to host - // ---------------------------------------------------------------- - if (urlWithoutQuery === "/api/topology/command" && req.method === "POST") { - const body = await readBody(req); - const payload = JSON.parse(body || "{}") as { - path?: string; - baseRevision?: number; - command?: TopologyHostCommand; - mode?: "edit" | "view"; - deploymentState?: DeploymentState; - }; - - if (!payload.path || !payload.command || typeof payload.baseRevision !== "number") { - res.statusCode = 400; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ error: "Invalid topology command request" })); - return; - } - - const host = getTopologyHost(sessionId, payload.path, { - mode: payload.mode, - deploymentState: payload.deploymentState - }); - const response = await host.applyCommand(payload.command, payload.baseRevision); - - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify(response)); - return; - } - - // ---------------------------------------------------------------- - // /file/:path - File CRUD operations - // ---------------------------------------------------------------- - const fileMatch = urlWithoutQuery.match(/^\/file\/(.+)$/); - if (fileMatch) { - const filePath = decodeFilePath(fileMatch[1]); - - // HEAD /file/:path - Check if file exists - if (req.method === "HEAD") { - const exists = await fileExists(filePath, sessionId); - res.statusCode = exists ? 200 : 404; - res.end(); - return; - } - - // GET /file/:path - Read file - if (req.method === "GET") { - const content = await readFile(filePath, sessionId); - if (content === null) { - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain"); - res.end("Not found"); - } else { - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end(content); - } - return; - } - - // PUT /file/:path - Write file - if (req.method === "PUT") { - const content = await readBody(req); - await writeFile(filePath, content, sessionId); - - // Broadcast file change to SSE clients - if (sessionId) { - // Extract just the filename from the path for broadcasting - const filename = path.basename(filePath); - broadcastFileChange(sessionId, filename); - } - - res.statusCode = 200; - res.end(); - return; - } - - // DELETE /file/:path - Delete file - if (req.method === "DELETE") { - await deleteFile(filePath, sessionId); - res.statusCode = 200; - res.end(); - return; - } - } - - // Not an API route - pass to next handler - return next(); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error("[FileAPI] Error:", message); - res.statusCode = 500; - res.setHeader("Content-Type", "text/plain"); - res.end(message); - } - }); - } - }; -} diff --git a/dev/server/internalUpdateTracker.ts b/dev/server/internalUpdateTracker.ts deleted file mode 100644 index 42f30086e..000000000 --- a/dev/server/internalUpdateTracker.ts +++ /dev/null @@ -1,42 +0,0 @@ -const INTERNAL_UPDATE_GRACE_MS = 1000; - -type InternalEntry = { - depth: number; - ignoreUntil: number; -}; - -const internalUpdates = new Map(); - -function getBasePath(filePath: string): string { - return filePath.endsWith(".annotations.json") - ? filePath.slice(0, -".annotations.json".length) - : filePath; -} - -export function beginInternalUpdate(filePath: string): void { - const key = getBasePath(filePath); - const entry = internalUpdates.get(key) ?? { depth: 0, ignoreUntil: 0 }; - entry.depth += 1; - entry.ignoreUntil = 0; - internalUpdates.set(key, entry); -} - -export function endInternalUpdate(filePath: string): void { - const key = getBasePath(filePath); - const entry = internalUpdates.get(key) ?? { depth: 0, ignoreUntil: 0 }; - entry.depth = Math.max(0, entry.depth - 1); - if (entry.depth === 0) { - entry.ignoreUntil = Date.now() + INTERNAL_UPDATE_GRACE_MS; - } - internalUpdates.set(key, entry); -} - -export function isInternalUpdate(filePath: string): boolean { - const key = getBasePath(filePath); - const entry = internalUpdates.get(key); - if (!entry) return false; - if (entry.depth > 0) return true; - if (entry.ignoreUntil > Date.now()) return true; - internalUpdates.delete(key); - return false; -} diff --git a/dev/server/sseManager.ts b/dev/server/sseManager.ts deleted file mode 100644 index b361048f5..000000000 --- a/dev/server/sseManager.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * SSE (Server-Sent Events) Manager for Live File Updates - * - * Manages SSE client connections per session, enabling real-time - * file change notifications to the browser. - */ - -import type { ServerResponse } from "http"; -import * as path from "path"; -import { watch } from "chokidar"; -import { isInternalUpdate } from "./internalUpdateTracker"; - -// Type for SSE client connection -interface SSEClient { - res: ServerResponse; - sessionId: string; -} - -// Active SSE clients grouped by sessionId -const clients: Map> = new Map(); - -// Special session ID for non-session clients (direct dev mode) -const NO_SESSION = "__no_session__"; - -/** - * Add a new SSE client connection - */ -export function addClient(sessionId: string, res: ServerResponse): void { - if (!clients.has(sessionId)) { - clients.set(sessionId, new Set()); - } - - const client: SSEClient = { res, sessionId }; - clients.get(sessionId)!.add(client); - - console.log( - `[SSE] Client connected (session: ${sessionId}, total: ${clients.get(sessionId)!.size})` - ); - - // Handle disconnect - res.on("close", () => { - removeClient(sessionId, client); - }); -} - -/** - * Remove a disconnected client - */ -function removeClient(sessionId: string, client: SSEClient): void { - const sessionClients = clients.get(sessionId); - if (sessionClients) { - sessionClients.delete(client); - console.log( - `[SSE] Client disconnected (session: ${sessionId}, remaining: ${sessionClients.size})` - ); - - // Clean up empty session - if (sessionClients.size === 0) { - clients.delete(sessionId); - } - } -} - -/** - * Broadcast a file change event to all clients in a session - */ -export function broadcastFileChange(sessionId: string, filePath: string): void { - const sessionClients = clients.get(sessionId); - if (!sessionClients || sessionClients.size === 0) { - return; - } - - // Determine file type - const type = filePath.endsWith(".annotations.json") ? "annotations" : "yaml"; - - const event = { - path: filePath, - type, - timestamp: Date.now() - }; - - const message = `event: file-changed\ndata: ${JSON.stringify(event)}\n\n`; - - console.log( - `[SSE] Broadcasting file change (session: ${sessionId}, path: ${filePath}, clients: ${sessionClients.size})` - ); - - for (const client of sessionClients) { - try { - client.res.write(message); - } catch (err) { - console.error("[SSE] Failed to send message:", err); - } - } -} - -/** - * Broadcast a file change event to ALL connected clients (for disk file changes) - */ -export function broadcastFileChangeToAll(filePath: string): void { - // Determine file type - const type = filePath.endsWith(".annotations.json") ? "annotations" : "yaml"; - const filename = path.basename(filePath); - - const event = { - path: filename, - type, - timestamp: Date.now() - }; - - const message = `event: file-changed\ndata: ${JSON.stringify(event)}\n\n`; - - let totalClients = 0; - for (const [sessionId, sessionClients] of clients) { - for (const client of sessionClients) { - try { - client.res.write(message); - totalClients++; - } catch (err) { - console.error("[SSE] Failed to send message:", err); - } - } - } - - if (totalClients > 0) { - console.log(`[SSE] Broadcast disk file change to ${totalClients} clients: ${filename}`); - } -} - -/** - * Send a heartbeat to keep connections alive - */ -export function sendHeartbeat(): void { - const message = `: heartbeat\n\n`; - - for (const [sessionId, sessionClients] of clients) { - for (const client of sessionClients) { - try { - client.res.write(message); - } catch { - // Client likely disconnected, will be cleaned up - } - } - } -} - -// Send heartbeat every 30 seconds to keep connections alive -setInterval(sendHeartbeat, 30000); - -// ============================================================================ -// File Watcher for Disk Changes -// ============================================================================ - -let fileWatcher: ReturnType | null = null; - -/** - * Start watching the topologies directory for file changes - */ -export function startFileWatcher(topologiesDir: string): void { - if (fileWatcher) { - console.log("[SSE] File watcher already running"); - return; - } - - console.log(`[SSE] Starting file watcher for: ${topologiesDir}`); - - fileWatcher = watch(topologiesDir, { - ignored: /(^|[\/\\])\../, // Ignore dotfiles - persistent: true, - ignoreInitial: true, - // Debounce rapid changes - awaitWriteFinish: { - stabilityThreshold: 300, - pollInterval: 100 - } - }); - - const onFileEvent = (eventName: string, filePath: string) => { - // Only watch .clab.yml and .annotations.json files - if (!filePath.endsWith(".clab.yml") && !filePath.endsWith(".annotations.json")) { - return; - } - if (isInternalUpdate(filePath)) { - return; - } - console.log(`[SSE] Disk file ${eventName}: ${filePath}`); - broadcastFileChangeToAll(filePath); - }; - - fileWatcher.on("add", (filePath) => onFileEvent("added", filePath)); - fileWatcher.on("change", (filePath) => onFileEvent("changed", filePath)); - fileWatcher.on("unlink", (filePath) => onFileEvent("removed", filePath)); - - fileWatcher.on("error", (error) => { - console.error("[SSE] File watcher error:", error); - }); - - console.log("[SSE] File watcher started"); -} - -/** - * Stop the file watcher - */ -export function stopFileWatcher(): void { - if (fileWatcher) { - fileWatcher.close(); - fileWatcher = null; - console.log("[SSE] File watcher stopped"); - } -} diff --git a/dev/stubs/vscode.ts b/dev/stubs/vscode.ts deleted file mode 100644 index 31c117ea3..000000000 --- a/dev/stubs/vscode.ts +++ /dev/null @@ -1,13 +0,0 @@ -type ContributedExtension = { - packageJSON?: unknown; -}; - -export const extensions = { - all: [] as ContributedExtension[] -}; - -export const commands = { - async executeCommand(_command: string, ..._args: unknown[]): Promise { - return undefined; - } -}; diff --git a/dev/topologies-original/datacenter.clab.yml b/dev/topologies-original/datacenter.clab.yml deleted file mode 100644 index 177fa0959..000000000 --- a/dev/topologies-original/datacenter.clab.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: datacenter - -topology: - nodes: - border1: - kind: nokia_srlinux - type: ixrd3 - image: ghcr.io/nokia/srlinux:latest - border2: - kind: nokia_srlinux - type: ixrd3 - image: ghcr.io/nokia/srlinux:latest - spine1: - kind: nokia_srlinux - type: ixrd3 - image: ghcr.io/nokia/srlinux:latest - spine2: - kind: nokia_srlinux - type: ixrd3 - image: ghcr.io/nokia/srlinux:latest - spine3: - kind: nokia_srlinux - type: ixrd3 - image: ghcr.io/nokia/srlinux:latest - leaf1: - kind: nokia_srlinux - type: ixrd2 - image: ghcr.io/nokia/srlinux:latest - leaf2: - kind: nokia_srlinux - type: ixrd2 - image: ghcr.io/nokia/srlinux:latest - leaf3: - kind: nokia_srlinux - type: ixrd2 - image: ghcr.io/nokia/srlinux:latest - leaf4: - kind: nokia_srlinux - type: ixrd2 - image: ghcr.io/nokia/srlinux:latest - server1: - kind: linux - image: ghcr.io/srl-labs/network-multitool:latest - server2: - kind: linux - image: ghcr.io/srl-labs/network-multitool:latest - server3: - kind: linux - image: ghcr.io/srl-labs/network-multitool:latest - server4: - kind: linux - image: ghcr.io/srl-labs/network-multitool:latest - server5: - kind: linux - image: ghcr.io/srl-labs/network-multitool:latest - server6: - kind: linux - image: ghcr.io/srl-labs/network-multitool:latest - - links: - # Border to Spine - - endpoints: ["border1:e1-1", "spine1:e1-49"] - - endpoints: ["border1:e1-2", "spine2:e1-49"] - - endpoints: ["border2:e1-1", "spine2:e1-50"] - - endpoints: ["border2:e1-2", "spine3:e1-49"] - # Spine to Leaf (full mesh) - - endpoints: ["spine1:e1-1", "leaf1:e1-49"] - - endpoints: ["spine1:e1-2", "leaf2:e1-49"] - - endpoints: ["spine2:e1-1", "leaf1:e1-50"] - - endpoints: ["spine2:e1-2", "leaf2:e1-50"] - - endpoints: ["spine2:e1-3", "leaf3:e1-49"] - - endpoints: ["spine2:e1-4", "leaf4:e1-49"] - - endpoints: ["spine3:e1-1", "leaf3:e1-50"] - - endpoints: ["spine3:e1-2", "leaf4:e1-50"] - # Leaf to Servers (Rack A) - - endpoints: ["leaf1:e1-1", "server1:eth1"] - - endpoints: ["leaf1:e1-2", "server2:eth1"] - - endpoints: ["leaf2:e1-1", "server2:eth2"] - - endpoints: ["leaf2:e1-2", "server3:eth1"] - # Leaf to Servers (Rack B) - - endpoints: ["leaf3:e1-1", "server4:eth1"] - - endpoints: ["leaf3:e1-2", "server5:eth1"] - - endpoints: ["leaf4:e1-1", "server5:eth2"] - - endpoints: ["leaf4:e1-2", "server6:eth1"] diff --git a/dev/topologies-original/datacenter.clab.yml.annotations.json b/dev/topologies-original/datacenter.clab.yml.annotations.json deleted file mode 100644 index 4488d9ab6..000000000 --- a/dev/topologies-original/datacenter.clab.yml.annotations.json +++ /dev/null @@ -1,408 +0,0 @@ -{ - "nodeAnnotations": [ - { - "id": "border1", - "position": { - "x": 290, - "y": 35 - }, - "group": "group-border", - "level": "1", - "interfacePattern": "e1-{n}" - }, - { - "id": "border2", - "position": { - "x": 430, - "y": 35 - }, - "group": "group-border", - "level": "1", - "interfacePattern": "e1-{n}" - }, - { - "id": "spine1", - "position": { - "x": 259, - "y": 105 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "e1-{n}" - }, - { - "id": "spine2", - "position": { - "x": 371, - "y": 119 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "e1-{n}" - }, - { - "id": "spine3", - "position": { - "x": 497, - "y": 119 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "e1-{n}" - }, - { - "id": "leaf1", - "position": { - "x": 161, - "y": 231 - }, - "group": "group-leaf-a", - "level": "1", - "interfacePattern": "e1-{n}" - }, - { - "id": "leaf2", - "position": { - "x": 259, - "y": 231 - }, - "group": "group-leaf-a", - "level": "1", - "interfacePattern": "e1-{n}" - }, - { - "id": "leaf3", - "position": { - "x": 414, - "y": 240 - }, - "group": "group-leaf-b", - "level": "1", - "interfacePattern": "e1-{n}" - }, - { - "id": "leaf4", - "position": { - "x": 498, - "y": 240 - }, - "group": "group-leaf-b", - "level": "1", - "interfacePattern": "e1-{n}" - }, - { - "id": "server1", - "position": { - "x": 161, - "y": 357 - }, - "group": "group-servers-a", - "level": "1" - }, - { - "id": "server2", - "position": { - "x": 203, - "y": 357 - }, - "group": "group-servers-a", - "level": "1" - }, - { - "id": "server3", - "position": { - "x": 259, - "y": 357 - }, - "group": "group-servers-a", - "level": "1" - }, - { - "id": "server4", - "position": { - "x": 413, - "y": 357 - }, - "group": "group-servers-b", - "level": "1" - }, - { - "id": "server5", - "position": { - "x": 455, - "y": 357 - }, - "group": "group-servers-b", - "level": "1" - }, - { - "id": "server6", - "position": { - "x": 497, - "y": 357 - }, - "group": "group-servers-b", - "level": "1" - } - ], - "freeTextAnnotations": [ - { - "id": "text-title", - "text": "Data Center West", - "position": { - "x": 107, - "y": -37 - }, - "fontSize": 20, - "fontColor": "#1e40af", - "backgroundColor": "transparent", - "fontWeight": "bold", - "fontStyle": "normal", - "textDecoration": "none", - "textAlign": "center", - "fontFamily": "sans-serif", - "rotation": 0, - "roundedBackground": false - }, - { - "id": "text-border", - "text": "Border Layer", - "position": { - "x": 620, - "y": 50 - }, - "fontSize": 12, - "fontColor": "#dc2626", - "backgroundColor": "#fef2f2", - "fontWeight": "bold", - "fontStyle": "normal", - "textDecoration": "none", - "textAlign": "center", - "fontFamily": "monospace", - "rotation": 0, - "roundedBackground": true - }, - { - "id": "text-spine", - "text": "Spine Layer", - "position": { - "x": 620, - "y": 150 - }, - "fontSize": 12, - "fontColor": "#7c3aed", - "backgroundColor": "#f5f3ff", - "fontWeight": "bold", - "fontStyle": "normal", - "textDecoration": "none", - "textAlign": "center", - "fontFamily": "monospace", - "rotation": 0, - "roundedBackground": true - }, - { - "id": "text-leaf", - "text": "Leaf Layer", - "position": { - "x": 631, - "y": 243 - }, - "fontSize": 12, - "fontColor": "#059669", - "backgroundColor": "#ecfdf5", - "fontWeight": "bold", - "fontStyle": "normal", - "textDecoration": "none", - "textAlign": "center", - "fontFamily": "monospace", - "rotation": 0, - "roundedBackground": true - }, - { - "id": "text-rack-a", - "text": "Rack A", - "position": { - "x": 93, - "y": 409 - }, - "fontSize": 14, - "fontColor": "#0369a1", - "backgroundColor": "transparent", - "fontWeight": "bold", - "fontStyle": "normal", - "textDecoration": "none", - "textAlign": "center", - "fontFamily": "sans-serif", - "rotation": 0, - "roundedBackground": false - }, - { - "id": "text-rack-b", - "text": "Rack B", - "position": { - "x": 550, - "y": 415 - }, - "fontSize": 14, - "fontColor": "#0369a1", - "backgroundColor": "transparent", - "fontWeight": "bold", - "fontStyle": "normal", - "textDecoration": "none", - "textAlign": "center", - "fontFamily": "sans-serif", - "rotation": 0, - "roundedBackground": false - } - ], - "freeShapeAnnotations": [ - { - "id": "shape-rack-b", - "shapeType": "rectangle", - "position": { - "x": 387, - "y": 245 - }, - "width": 703, - "height": 129, - "fillColor": "#0369a1", - "fillOpacity": 0.05, - "borderColor": "#0369a1", - "borderWidth": 2, - "borderStyle": "dashed", - "rotation": 0, - "zIndex": -10, - "cornerRadius": 12 - } - ], - "groupStyleAnnotations": [ - { - "id": "group-border", - "name": "Border", - "level": "L0", - "position": { - "x": 378.2607640745679, - "y": 37.84010728662861 - }, - "width": 280, - "height": 50, - "color": "#dc2626", - "backgroundColor": "#dc2626", - "backgroundOpacity": 0.08, - "borderColor": "#dc2626", - "borderWidth": 2, - "borderStyle": "solid", - "borderRadius": 8, - "labelColor": "#dc2626", - "labelPosition": "top-left", - "zIndex": -5 - }, - { - "id": "group-spine", - "name": "Spine", - "level": "L1", - "position": { - "x": 358.0362466294679, - "y": 113.92104913961019 - }, - "width": 380, - "height": 60, - "color": "#7c3aed", - "backgroundColor": "#7c3aed", - "backgroundOpacity": 0.08, - "borderColor": "#7c3aed", - "borderWidth": 2, - "borderStyle": "solid", - "borderRadius": 8, - "labelColor": "#7c3aed", - "labelPosition": "top-left", - "zIndex": -5 - }, - { - "id": "group-leaf-a", - "name": "Leaf A", - "level": "L2", - "position": { - "x": 209.4530198802192, - "y": 239.41018022756342 - }, - "width": 180, - "height": 55, - "color": "#059669", - "backgroundColor": "#059669", - "backgroundOpacity": 0.08, - "borderColor": "#059669", - "borderWidth": 2, - "borderStyle": "solid", - "borderRadius": 6, - "labelColor": "#059669", - "labelPosition": "top-left", - "zIndex": -4 - }, - { - "id": "group-leaf-b", - "name": "Leaf B", - "level": "L2", - "position": { - "x": 457.3108607193591, - "y": 243.92104913961018 - }, - "width": 180, - "height": 55, - "color": "#059669", - "backgroundColor": "#059669", - "backgroundOpacity": 0.08, - "borderColor": "#059669", - "borderWidth": 2, - "borderStyle": "solid", - "borderRadius": 6, - "labelColor": "#059669", - "labelPosition": "top-right", - "zIndex": -4 - }, - { - "id": "group-servers-a", - "name": "Compute A", - "level": "L3", - "position": { - "x": 201.37956974468278, - "y": 361.30887777227156 - }, - "width": 180, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-b", - "name": "Compute B", - "level": "L3", - "position": { - "x": 446.5256107222066, - "y": 362.7493214197705 - }, - "width": 180, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - } - ], - "networkNodeAnnotations": [], - "aliasEndpointAnnotations": [] -} diff --git a/dev/topologies-original/empty.clab.yml b/dev/topologies-original/empty.clab.yml deleted file mode 100644 index 93384278c..000000000 --- a/dev/topologies-original/empty.clab.yml +++ /dev/null @@ -1,5 +0,0 @@ -name: empty - -topology: - nodes: {} - links: [] diff --git a/dev/topologies-original/inheritance.clab.yml b/dev/topologies-original/inheritance.clab.yml deleted file mode 100644 index db4673ee4..000000000 --- a/dev/topologies-original/inheritance.clab.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: inheritance - -topology: - defaults: - # Global defaults applied to all nodes - labels: - owner: containerlab - - kinds: - nokia_srsim: - image: registry.srlinux.dev/pub/nokia_srsim:25.10.R1 - type: sr-1x-48d - nokia_srlinux: - image: ghcr.io/nokia/srlinux:latest - type: ixrd2 - - nodes: - # These inherit image/type from kinds section - srsim1: - kind: nokia_srsim - srsim2: - kind: nokia_srsim - srl1: - kind: nokia_srlinux - srl2: - kind: nokia_srlinux - - links: - - endpoints: ["srsim1:1/1/c1", "srsim2:1/1/c1"] - - endpoints: ["srl1:e1-1", "srl2:e1-1"] - - endpoints: ["srsim1:1/1/c2", "srl1:e1-2"] - - endpoints: ["srsim2:1/1/c2", "srl2:e1-2"] diff --git a/dev/topologies-original/inheritance.clab.yml.annotations.json b/dev/topologies-original/inheritance.clab.yml.annotations.json deleted file mode 100644 index b897d7ddc..000000000 --- a/dev/topologies-original/inheritance.clab.yml.annotations.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "freeTextAnnotations": [], - "freeShapeAnnotations": [], - "groupStyleAnnotations": [], - "networkNodeAnnotations": [], - "nodeAnnotations": [ - { "id": "srsim1", "interfacePattern": "1/1/c{n}" }, - { "id": "srsim2", "interfacePattern": "1/1/c{n}" }, - { "id": "srl1", "interfacePattern": "e1-{n}" }, - { "id": "srl2", "interfacePattern": "e1-{n}" } - ], - "aliasEndpointAnnotations": [] -} diff --git a/dev/topologies-original/large-100.clab.yml b/dev/topologies-original/large-100.clab.yml deleted file mode 100644 index 73f9e0c8d..000000000 --- a/dev/topologies-original/large-100.clab.yml +++ /dev/null @@ -1,275 +0,0 @@ -name: large-100 - -topology: - defaults: - kind: linux - image: ghcr.io/srl-labs/network-multitool:latest - - nodes: - # Border routers (2) - border1: - border2: - # Spine routers (4) - spine1: - spine2: - spine3: - spine4: - # Leaf switches (14) - leaf1: - leaf2: - leaf3: - leaf4: - leaf5: - leaf6: - leaf7: - leaf8: - leaf9: - leaf10: - leaf11: - leaf12: - leaf13: - leaf14: - # Servers (80) - server1: - server2: - server3: - server4: - server5: - server6: - server7: - server8: - server9: - server10: - server11: - server12: - server13: - server14: - server15: - server16: - server17: - server18: - server19: - server20: - server21: - server22: - server23: - server24: - server25: - server26: - server27: - server28: - server29: - server30: - server31: - server32: - server33: - server34: - server35: - server36: - server37: - server38: - server39: - server40: - server41: - server42: - server43: - server44: - server45: - server46: - server47: - server48: - server49: - server50: - server51: - server52: - server53: - server54: - server55: - server56: - server57: - server58: - server59: - server60: - server61: - server62: - server63: - server64: - server65: - server66: - server67: - server68: - server69: - server70: - server71: - server72: - server73: - server74: - server75: - server76: - server77: - server78: - server79: - server80: - - links: - # Border to Spine (full mesh) - - endpoints: ["border1:eth1", "spine1:eth1"] - - endpoints: ["border1:eth2", "spine2:eth1"] - - endpoints: ["border1:eth3", "spine3:eth1"] - - endpoints: ["border1:eth4", "spine4:eth1"] - - endpoints: ["border2:eth1", "spine1:eth2"] - - endpoints: ["border2:eth2", "spine2:eth2"] - - endpoints: ["border2:eth3", "spine3:eth2"] - - endpoints: ["border2:eth4", "spine4:eth2"] - # Spine to Leaf (each spine connects to all leaves) - - endpoints: ["spine1:eth3", "leaf1:eth1"] - - endpoints: ["spine1:eth4", "leaf2:eth1"] - - endpoints: ["spine1:eth5", "leaf3:eth1"] - - endpoints: ["spine1:eth6", "leaf4:eth1"] - - endpoints: ["spine1:eth7", "leaf5:eth1"] - - endpoints: ["spine1:eth8", "leaf6:eth1"] - - endpoints: ["spine1:eth9", "leaf7:eth1"] - - endpoints: ["spine1:eth10", "leaf8:eth1"] - - endpoints: ["spine1:eth11", "leaf9:eth1"] - - endpoints: ["spine1:eth12", "leaf10:eth1"] - - endpoints: ["spine1:eth13", "leaf11:eth1"] - - endpoints: ["spine1:eth14", "leaf12:eth1"] - - endpoints: ["spine1:eth15", "leaf13:eth1"] - - endpoints: ["spine1:eth16", "leaf14:eth1"] - - endpoints: ["spine2:eth3", "leaf1:eth2"] - - endpoints: ["spine2:eth4", "leaf2:eth2"] - - endpoints: ["spine2:eth5", "leaf3:eth2"] - - endpoints: ["spine2:eth6", "leaf4:eth2"] - - endpoints: ["spine2:eth7", "leaf5:eth2"] - - endpoints: ["spine2:eth8", "leaf6:eth2"] - - endpoints: ["spine2:eth9", "leaf7:eth2"] - - endpoints: ["spine2:eth10", "leaf8:eth2"] - - endpoints: ["spine2:eth11", "leaf9:eth2"] - - endpoints: ["spine2:eth12", "leaf10:eth2"] - - endpoints: ["spine2:eth13", "leaf11:eth2"] - - endpoints: ["spine2:eth14", "leaf12:eth2"] - - endpoints: ["spine2:eth15", "leaf13:eth2"] - - endpoints: ["spine2:eth16", "leaf14:eth2"] - - endpoints: ["spine3:eth3", "leaf1:eth3"] - - endpoints: ["spine3:eth4", "leaf2:eth3"] - - endpoints: ["spine3:eth5", "leaf3:eth3"] - - endpoints: ["spine3:eth6", "leaf4:eth3"] - - endpoints: ["spine3:eth7", "leaf5:eth3"] - - endpoints: ["spine3:eth8", "leaf6:eth3"] - - endpoints: ["spine3:eth9", "leaf7:eth3"] - - endpoints: ["spine3:eth10", "leaf8:eth3"] - - endpoints: ["spine3:eth11", "leaf9:eth3"] - - endpoints: ["spine3:eth12", "leaf10:eth3"] - - endpoints: ["spine3:eth13", "leaf11:eth3"] - - endpoints: ["spine3:eth14", "leaf12:eth3"] - - endpoints: ["spine3:eth15", "leaf13:eth3"] - - endpoints: ["spine3:eth16", "leaf14:eth3"] - - endpoints: ["spine4:eth3", "leaf1:eth4"] - - endpoints: ["spine4:eth4", "leaf2:eth4"] - - endpoints: ["spine4:eth5", "leaf3:eth4"] - - endpoints: ["spine4:eth6", "leaf4:eth4"] - - endpoints: ["spine4:eth7", "leaf5:eth4"] - - endpoints: ["spine4:eth8", "leaf6:eth4"] - - endpoints: ["spine4:eth9", "leaf7:eth4"] - - endpoints: ["spine4:eth10", "leaf8:eth4"] - - endpoints: ["spine4:eth11", "leaf9:eth4"] - - endpoints: ["spine4:eth12", "leaf10:eth4"] - - endpoints: ["spine4:eth13", "leaf11:eth4"] - - endpoints: ["spine4:eth14", "leaf12:eth4"] - - endpoints: ["spine4:eth15", "leaf13:eth4"] - - endpoints: ["spine4:eth16", "leaf14:eth4"] - # Leaf to Servers (each leaf has ~5-6 servers) - # Leaf1 servers - - endpoints: ["leaf1:eth5", "server1:eth1"] - - endpoints: ["leaf1:eth6", "server2:eth1"] - - endpoints: ["leaf1:eth7", "server3:eth1"] - - endpoints: ["leaf1:eth8", "server4:eth1"] - - endpoints: ["leaf1:eth9", "server5:eth1"] - - endpoints: ["leaf1:eth10", "server6:eth1"] - # Leaf2 servers - - endpoints: ["leaf2:eth5", "server7:eth1"] - - endpoints: ["leaf2:eth6", "server8:eth1"] - - endpoints: ["leaf2:eth7", "server9:eth1"] - - endpoints: ["leaf2:eth8", "server10:eth1"] - - endpoints: ["leaf2:eth9", "server11:eth1"] - - endpoints: ["leaf2:eth10", "server12:eth1"] - # Leaf3 servers - - endpoints: ["leaf3:eth5", "server13:eth1"] - - endpoints: ["leaf3:eth6", "server14:eth1"] - - endpoints: ["leaf3:eth7", "server15:eth1"] - - endpoints: ["leaf3:eth8", "server16:eth1"] - - endpoints: ["leaf3:eth9", "server17:eth1"] - - endpoints: ["leaf3:eth10", "server18:eth1"] - # Leaf4 servers - - endpoints: ["leaf4:eth5", "server19:eth1"] - - endpoints: ["leaf4:eth6", "server20:eth1"] - - endpoints: ["leaf4:eth7", "server21:eth1"] - - endpoints: ["leaf4:eth8", "server22:eth1"] - - endpoints: ["leaf4:eth9", "server23:eth1"] - - endpoints: ["leaf4:eth10", "server24:eth1"] - # Leaf5 servers - - endpoints: ["leaf5:eth5", "server25:eth1"] - - endpoints: ["leaf5:eth6", "server26:eth1"] - - endpoints: ["leaf5:eth7", "server27:eth1"] - - endpoints: ["leaf5:eth8", "server28:eth1"] - - endpoints: ["leaf5:eth9", "server29:eth1"] - - endpoints: ["leaf5:eth10", "server30:eth1"] - # Leaf6 servers - - endpoints: ["leaf6:eth5", "server31:eth1"] - - endpoints: ["leaf6:eth6", "server32:eth1"] - - endpoints: ["leaf6:eth7", "server33:eth1"] - - endpoints: ["leaf6:eth8", "server34:eth1"] - - endpoints: ["leaf6:eth9", "server35:eth1"] - - endpoints: ["leaf6:eth10", "server36:eth1"] - # Leaf7 servers - - endpoints: ["leaf7:eth5", "server37:eth1"] - - endpoints: ["leaf7:eth6", "server38:eth1"] - - endpoints: ["leaf7:eth7", "server39:eth1"] - - endpoints: ["leaf7:eth8", "server40:eth1"] - - endpoints: ["leaf7:eth9", "server41:eth1"] - - endpoints: ["leaf7:eth10", "server42:eth1"] - # Leaf8 servers - - endpoints: ["leaf8:eth5", "server43:eth1"] - - endpoints: ["leaf8:eth6", "server44:eth1"] - - endpoints: ["leaf8:eth7", "server45:eth1"] - - endpoints: ["leaf8:eth8", "server46:eth1"] - - endpoints: ["leaf8:eth9", "server47:eth1"] - - endpoints: ["leaf8:eth10", "server48:eth1"] - # Leaf9 servers - - endpoints: ["leaf9:eth5", "server49:eth1"] - - endpoints: ["leaf9:eth6", "server50:eth1"] - - endpoints: ["leaf9:eth7", "server51:eth1"] - - endpoints: ["leaf9:eth8", "server52:eth1"] - - endpoints: ["leaf9:eth9", "server53:eth1"] - - endpoints: ["leaf9:eth10", "server54:eth1"] - # Leaf10 servers - - endpoints: ["leaf10:eth5", "server55:eth1"] - - endpoints: ["leaf10:eth6", "server56:eth1"] - - endpoints: ["leaf10:eth7", "server57:eth1"] - - endpoints: ["leaf10:eth8", "server58:eth1"] - - endpoints: ["leaf10:eth9", "server59:eth1"] - - endpoints: ["leaf10:eth10", "server60:eth1"] - # Leaf11 servers - - endpoints: ["leaf11:eth5", "server61:eth1"] - - endpoints: ["leaf11:eth6", "server62:eth1"] - - endpoints: ["leaf11:eth7", "server63:eth1"] - - endpoints: ["leaf11:eth8", "server64:eth1"] - - endpoints: ["leaf11:eth9", "server65:eth1"] - # Leaf12 servers - - endpoints: ["leaf12:eth5", "server66:eth1"] - - endpoints: ["leaf12:eth6", "server67:eth1"] - - endpoints: ["leaf12:eth7", "server68:eth1"] - - endpoints: ["leaf12:eth8", "server69:eth1"] - - endpoints: ["leaf12:eth9", "server70:eth1"] - # Leaf13 servers - - endpoints: ["leaf13:eth5", "server71:eth1"] - - endpoints: ["leaf13:eth6", "server72:eth1"] - - endpoints: ["leaf13:eth7", "server73:eth1"] - - endpoints: ["leaf13:eth8", "server74:eth1"] - - endpoints: ["leaf13:eth9", "server75:eth1"] - # Leaf14 servers - - endpoints: ["leaf14:eth5", "server76:eth1"] - - endpoints: ["leaf14:eth6", "server77:eth1"] - - endpoints: ["leaf14:eth7", "server78:eth1"] - - endpoints: ["leaf14:eth8", "server79:eth1"] - - endpoints: ["leaf14:eth9", "server80:eth1"] diff --git a/dev/topologies-original/large-100.clab.yml.annotations.json b/dev/topologies-original/large-100.clab.yml.annotations.json deleted file mode 100644 index 2c6451dd2..000000000 --- a/dev/topologies-original/large-100.clab.yml.annotations.json +++ /dev/null @@ -1,1035 +0,0 @@ -{ - "nodeAnnotations": [ - { - "id": "border1", - "position": { - "x": 2340, - "y": 50 - }, - "group": "group-border", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "border2", - "position": { - "x": 2400, - "y": 50 - }, - "group": "group-border", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine1", - "position": { - "x": 2280, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine2", - "position": { - "x": 2340, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine3", - "position": { - "x": 2400, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine4", - "position": { - "x": 2460, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf1", - "position": { - "x": 1980, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf2", - "position": { - "x": 2040, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf3", - "position": { - "x": 2100, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf4", - "position": { - "x": 2160, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf5", - "position": { - "x": 2220, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf6", - "position": { - "x": 2280, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf7", - "position": { - "x": 2340, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf8", - "position": { - "x": 2400, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf9", - "position": { - "x": 2460, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf10", - "position": { - "x": 2520, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf11", - "position": { - "x": 2580, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf12", - "position": { - "x": 2640, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf13", - "position": { - "x": 2700, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf14", - "position": { - "x": 2760, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "server1", - "position": { - "x": 900, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server2", - "position": { - "x": 960, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server3", - "position": { - "x": 1020, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server4", - "position": { - "x": 1080, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server5", - "position": { - "x": 1140, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server6", - "position": { - "x": 1200, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server7", - "position": { - "x": 1260, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server8", - "position": { - "x": 1320, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server9", - "position": { - "x": 1380, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server10", - "position": { - "x": 1440, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server11", - "position": { - "x": 1500, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server12", - "position": { - "x": 1560, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server13", - "position": { - "x": 1620, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server14", - "position": { - "x": 1680, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server15", - "position": { - "x": 1740, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server16", - "position": { - "x": 1800, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server17", - "position": { - "x": 1860, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server18", - "position": { - "x": 1920, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server19", - "position": { - "x": 1980, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server20", - "position": { - "x": 2040, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server21", - "position": { - "x": 2100, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server22", - "position": { - "x": 2160, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server23", - "position": { - "x": 2220, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server24", - "position": { - "x": 2280, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server25", - "position": { - "x": 2340, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server26", - "position": { - "x": 2400, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server27", - "position": { - "x": 2460, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server28", - "position": { - "x": 2520, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server29", - "position": { - "x": 2580, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server30", - "position": { - "x": 2640, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server31", - "position": { - "x": 2700, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server32", - "position": { - "x": 2760, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server33", - "position": { - "x": 2820, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server34", - "position": { - "x": 2880, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server35", - "position": { - "x": 2940, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server36", - "position": { - "x": 3000, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server37", - "position": { - "x": 3060, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server38", - "position": { - "x": 3120, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server39", - "position": { - "x": 3180, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server40", - "position": { - "x": 3240, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server41", - "position": { - "x": 3300, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server42", - "position": { - "x": 3360, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server43", - "position": { - "x": 3420, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server44", - "position": { - "x": 3480, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server45", - "position": { - "x": 3540, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server46", - "position": { - "x": 3600, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server47", - "position": { - "x": 3660, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server48", - "position": { - "x": 3720, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server49", - "position": { - "x": 3780, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server50", - "position": { - "x": 3840, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server51", - "position": { - "x": 1500, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server52", - "position": { - "x": 1560, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server53", - "position": { - "x": 1620, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server54", - "position": { - "x": 1680, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server55", - "position": { - "x": 1740, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server56", - "position": { - "x": 1800, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server57", - "position": { - "x": 1860, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server58", - "position": { - "x": 1920, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server59", - "position": { - "x": 1980, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server60", - "position": { - "x": 2040, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server61", - "position": { - "x": 2100, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server62", - "position": { - "x": 2160, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server63", - "position": { - "x": 2220, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server64", - "position": { - "x": 2280, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server65", - "position": { - "x": 2340, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server66", - "position": { - "x": 2400, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server67", - "position": { - "x": 2460, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server68", - "position": { - "x": 2520, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server69", - "position": { - "x": 2580, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server70", - "position": { - "x": 2640, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server71", - "position": { - "x": 2700, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server72", - "position": { - "x": 2760, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server73", - "position": { - "x": 2820, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server74", - "position": { - "x": 2880, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server75", - "position": { - "x": 2940, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server76", - "position": { - "x": 3000, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server77", - "position": { - "x": 3060, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server78", - "position": { - "x": 3120, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server79", - "position": { - "x": 3180, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server80", - "position": { - "x": 3240, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - } - ], - "freeTextAnnotations": [], - "freeShapeAnnotations": [], - "groupStyleAnnotations": [ - { - "id": "group-border", - "name": "Border", - "level": "L0", - "position": { - "x": 2450, - "y": 50 - }, - "width": 200, - "height": 60, - "color": "#dc2626", - "backgroundColor": "#dc2626", - "backgroundOpacity": 0.08, - "borderColor": "#dc2626", - "borderWidth": 2, - "borderStyle": "solid", - "borderRadius": 8, - "labelColor": "#dc2626", - "labelPosition": "top-left", - "zIndex": -5 - }, - { - "id": "group-spine", - "name": "Spine", - "level": "L1", - "position": { - "x": 2450, - "y": 170 - }, - "width": 320, - "height": 60, - "color": "#7c3aed", - "backgroundColor": "#7c3aed", - "backgroundOpacity": 0.08, - "borderColor": "#7c3aed", - "borderWidth": 2, - "borderStyle": "solid", - "borderRadius": 8, - "labelColor": "#7c3aed", - "labelPosition": "top-left", - "zIndex": -5 - }, - { - "id": "group-leaf", - "name": "Leaf", - "level": "L2", - "position": { - "x": 2450, - "y": 290 - }, - "width": 920, - "height": 60, - "color": "#059669", - "backgroundColor": "#059669", - "backgroundOpacity": 0.08, - "borderColor": "#059669", - "borderWidth": 2, - "borderStyle": "solid", - "borderRadius": 8, - "labelColor": "#059669", - "labelPosition": "top-left", - "zIndex": -4 - }, - { - "id": "group-servers-row1", - "name": "Servers 1", - "level": "L3", - "position": { - "x": 2450, - "y": 410 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row2", - "name": "Servers 2", - "level": "L3", - "position": { - "x": 2450, - "y": 490 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - } - ], - "networkNodeAnnotations": [], - "aliasEndpointAnnotations": [] -} diff --git a/dev/topologies-original/large-1000.clab.yml b/dev/topologies-original/large-1000.clab.yml deleted file mode 100644 index 08fbcbe9b..000000000 --- a/dev/topologies-original/large-1000.clab.yml +++ /dev/null @@ -1,1993 +0,0 @@ -name: large-1000 - -topology: - defaults: - kind: linux - image: ghcr.io/srl-labs/network-multitool:latest - - nodes: - # Border routers (4) - border1: - border2: - border3: - border4: - # Spine routers (16) - spine1: - spine2: - spine3: - spine4: - spine5: - spine6: - spine7: - spine8: - spine9: - spine10: - spine11: - spine12: - spine13: - spine14: - spine15: - spine16: - # Leaf switches (80) - leaf1: - leaf2: - leaf3: - leaf4: - leaf5: - leaf6: - leaf7: - leaf8: - leaf9: - leaf10: - leaf11: - leaf12: - leaf13: - leaf14: - leaf15: - leaf16: - leaf17: - leaf18: - leaf19: - leaf20: - leaf21: - leaf22: - leaf23: - leaf24: - leaf25: - leaf26: - leaf27: - leaf28: - leaf29: - leaf30: - leaf31: - leaf32: - leaf33: - leaf34: - leaf35: - leaf36: - leaf37: - leaf38: - leaf39: - leaf40: - leaf41: - leaf42: - leaf43: - leaf44: - leaf45: - leaf46: - leaf47: - leaf48: - leaf49: - leaf50: - leaf51: - leaf52: - leaf53: - leaf54: - leaf55: - leaf56: - leaf57: - leaf58: - leaf59: - leaf60: - leaf61: - leaf62: - leaf63: - leaf64: - leaf65: - leaf66: - leaf67: - leaf68: - leaf69: - leaf70: - leaf71: - leaf72: - leaf73: - leaf74: - leaf75: - leaf76: - leaf77: - leaf78: - leaf79: - leaf80: - # Servers (900) - server1: - server2: - server3: - server4: - server5: - server6: - server7: - server8: - server9: - server10: - server11: - server12: - server13: - server14: - server15: - server16: - server17: - server18: - server19: - server20: - server21: - server22: - server23: - server24: - server25: - server26: - server27: - server28: - server29: - server30: - server31: - server32: - server33: - server34: - server35: - server36: - server37: - server38: - server39: - server40: - server41: - server42: - server43: - server44: - server45: - server46: - server47: - server48: - server49: - server50: - server51: - server52: - server53: - server54: - server55: - server56: - server57: - server58: - server59: - server60: - server61: - server62: - server63: - server64: - server65: - server66: - server67: - server68: - server69: - server70: - server71: - server72: - server73: - server74: - server75: - server76: - server77: - server78: - server79: - server80: - server81: - server82: - server83: - server84: - server85: - server86: - server87: - server88: - server89: - server90: - server91: - server92: - server93: - server94: - server95: - server96: - server97: - server98: - server99: - server100: - server101: - server102: - server103: - server104: - server105: - server106: - server107: - server108: - server109: - server110: - server111: - server112: - server113: - server114: - server115: - server116: - server117: - server118: - server119: - server120: - server121: - server122: - server123: - server124: - server125: - server126: - server127: - server128: - server129: - server130: - server131: - server132: - server133: - server134: - server135: - server136: - server137: - server138: - server139: - server140: - server141: - server142: - server143: - server144: - server145: - server146: - server147: - server148: - server149: - server150: - server151: - server152: - server153: - server154: - server155: - server156: - server157: - server158: - server159: - server160: - server161: - server162: - server163: - server164: - server165: - server166: - server167: - server168: - server169: - server170: - server171: - server172: - server173: - server174: - server175: - server176: - server177: - server178: - server179: - server180: - server181: - server182: - server183: - server184: - server185: - server186: - server187: - server188: - server189: - server190: - server191: - server192: - server193: - server194: - server195: - server196: - server197: - server198: - server199: - server200: - server201: - server202: - server203: - server204: - server205: - server206: - server207: - server208: - server209: - server210: - server211: - server212: - server213: - server214: - server215: - server216: - server217: - server218: - server219: - server220: - server221: - server222: - server223: - server224: - server225: - server226: - server227: - server228: - server229: - server230: - server231: - server232: - server233: - server234: - server235: - server236: - server237: - server238: - server239: - server240: - server241: - server242: - server243: - server244: - server245: - server246: - server247: - server248: - server249: - server250: - server251: - server252: - server253: - server254: - server255: - server256: - server257: - server258: - server259: - server260: - server261: - server262: - server263: - server264: - server265: - server266: - server267: - server268: - server269: - server270: - server271: - server272: - server273: - server274: - server275: - server276: - server277: - server278: - server279: - server280: - server281: - server282: - server283: - server284: - server285: - server286: - server287: - server288: - server289: - server290: - server291: - server292: - server293: - server294: - server295: - server296: - server297: - server298: - server299: - server300: - server301: - server302: - server303: - server304: - server305: - server306: - server307: - server308: - server309: - server310: - server311: - server312: - server313: - server314: - server315: - server316: - server317: - server318: - server319: - server320: - server321: - server322: - server323: - server324: - server325: - server326: - server327: - server328: - server329: - server330: - server331: - server332: - server333: - server334: - server335: - server336: - server337: - server338: - server339: - server340: - server341: - server342: - server343: - server344: - server345: - server346: - server347: - server348: - server349: - server350: - server351: - server352: - server353: - server354: - server355: - server356: - server357: - server358: - server359: - server360: - server361: - server362: - server363: - server364: - server365: - server366: - server367: - server368: - server369: - server370: - server371: - server372: - server373: - server374: - server375: - server376: - server377: - server378: - server379: - server380: - server381: - server382: - server383: - server384: - server385: - server386: - server387: - server388: - server389: - server390: - server391: - server392: - server393: - server394: - server395: - server396: - server397: - server398: - server399: - server400: - server401: - server402: - server403: - server404: - server405: - server406: - server407: - server408: - server409: - server410: - server411: - server412: - server413: - server414: - server415: - server416: - server417: - server418: - server419: - server420: - server421: - server422: - server423: - server424: - server425: - server426: - server427: - server428: - server429: - server430: - server431: - server432: - server433: - server434: - server435: - server436: - server437: - server438: - server439: - server440: - server441: - server442: - server443: - server444: - server445: - server446: - server447: - server448: - server449: - server450: - server451: - server452: - server453: - server454: - server455: - server456: - server457: - server458: - server459: - server460: - server461: - server462: - server463: - server464: - server465: - server466: - server467: - server468: - server469: - server470: - server471: - server472: - server473: - server474: - server475: - server476: - server477: - server478: - server479: - server480: - server481: - server482: - server483: - server484: - server485: - server486: - server487: - server488: - server489: - server490: - server491: - server492: - server493: - server494: - server495: - server496: - server497: - server498: - server499: - server500: - server501: - server502: - server503: - server504: - server505: - server506: - server507: - server508: - server509: - server510: - server511: - server512: - server513: - server514: - server515: - server516: - server517: - server518: - server519: - server520: - server521: - server522: - server523: - server524: - server525: - server526: - server527: - server528: - server529: - server530: - server531: - server532: - server533: - server534: - server535: - server536: - server537: - server538: - server539: - server540: - server541: - server542: - server543: - server544: - server545: - server546: - server547: - server548: - server549: - server550: - server551: - server552: - server553: - server554: - server555: - server556: - server557: - server558: - server559: - server560: - server561: - server562: - server563: - server564: - server565: - server566: - server567: - server568: - server569: - server570: - server571: - server572: - server573: - server574: - server575: - server576: - server577: - server578: - server579: - server580: - server581: - server582: - server583: - server584: - server585: - server586: - server587: - server588: - server589: - server590: - server591: - server592: - server593: - server594: - server595: - server596: - server597: - server598: - server599: - server600: - server601: - server602: - server603: - server604: - server605: - server606: - server607: - server608: - server609: - server610: - server611: - server612: - server613: - server614: - server615: - server616: - server617: - server618: - server619: - server620: - server621: - server622: - server623: - server624: - server625: - server626: - server627: - server628: - server629: - server630: - server631: - server632: - server633: - server634: - server635: - server636: - server637: - server638: - server639: - server640: - server641: - server642: - server643: - server644: - server645: - server646: - server647: - server648: - server649: - server650: - server651: - server652: - server653: - server654: - server655: - server656: - server657: - server658: - server659: - server660: - server661: - server662: - server663: - server664: - server665: - server666: - server667: - server668: - server669: - server670: - server671: - server672: - server673: - server674: - server675: - server676: - server677: - server678: - server679: - server680: - server681: - server682: - server683: - server684: - server685: - server686: - server687: - server688: - server689: - server690: - server691: - server692: - server693: - server694: - server695: - server696: - server697: - server698: - server699: - server700: - server701: - server702: - server703: - server704: - server705: - server706: - server707: - server708: - server709: - server710: - server711: - server712: - server713: - server714: - server715: - server716: - server717: - server718: - server719: - server720: - server721: - server722: - server723: - server724: - server725: - server726: - server727: - server728: - server729: - server730: - server731: - server732: - server733: - server734: - server735: - server736: - server737: - server738: - server739: - server740: - server741: - server742: - server743: - server744: - server745: - server746: - server747: - server748: - server749: - server750: - server751: - server752: - server753: - server754: - server755: - server756: - server757: - server758: - server759: - server760: - server761: - server762: - server763: - server764: - server765: - server766: - server767: - server768: - server769: - server770: - server771: - server772: - server773: - server774: - server775: - server776: - server777: - server778: - server779: - server780: - server781: - server782: - server783: - server784: - server785: - server786: - server787: - server788: - server789: - server790: - server791: - server792: - server793: - server794: - server795: - server796: - server797: - server798: - server799: - server800: - server801: - server802: - server803: - server804: - server805: - server806: - server807: - server808: - server809: - server810: - server811: - server812: - server813: - server814: - server815: - server816: - server817: - server818: - server819: - server820: - server821: - server822: - server823: - server824: - server825: - server826: - server827: - server828: - server829: - server830: - server831: - server832: - server833: - server834: - server835: - server836: - server837: - server838: - server839: - server840: - server841: - server842: - server843: - server844: - server845: - server846: - server847: - server848: - server849: - server850: - server851: - server852: - server853: - server854: - server855: - server856: - server857: - server858: - server859: - server860: - server861: - server862: - server863: - server864: - server865: - server866: - server867: - server868: - server869: - server870: - server871: - server872: - server873: - server874: - server875: - server876: - server877: - server878: - server879: - server880: - server881: - server882: - server883: - server884: - server885: - server886: - server887: - server888: - server889: - server890: - server891: - server892: - server893: - server894: - server895: - server896: - server897: - server898: - server899: - server900: - - links: - # Border to Spine (each border to 4 spines) - - endpoints: ["border1:eth1", "spine1:eth1"] - - endpoints: ["border1:eth2", "spine2:eth1"] - - endpoints: ["border1:eth3", "spine3:eth1"] - - endpoints: ["border1:eth4", "spine4:eth1"] - - endpoints: ["border2:eth1", "spine5:eth1"] - - endpoints: ["border2:eth2", "spine6:eth1"] - - endpoints: ["border2:eth3", "spine7:eth1"] - - endpoints: ["border2:eth4", "spine8:eth1"] - - endpoints: ["border3:eth1", "spine9:eth1"] - - endpoints: ["border3:eth2", "spine10:eth1"] - - endpoints: ["border3:eth3", "spine11:eth1"] - - endpoints: ["border3:eth4", "spine12:eth1"] - - endpoints: ["border4:eth1", "spine13:eth1"] - - endpoints: ["border4:eth2", "spine14:eth1"] - - endpoints: ["border4:eth3", "spine15:eth1"] - - endpoints: ["border4:eth4", "spine16:eth1"] - # Spine to Leaf (each spine to 5 leaves) - - endpoints: ["spine1:eth2", "leaf1:eth1"] - - endpoints: ["spine1:eth3", "leaf2:eth1"] - - endpoints: ["spine1:eth4", "leaf3:eth1"] - - endpoints: ["spine1:eth5", "leaf4:eth1"] - - endpoints: ["spine1:eth6", "leaf5:eth1"] - - endpoints: ["spine2:eth2", "leaf6:eth1"] - - endpoints: ["spine2:eth3", "leaf7:eth1"] - - endpoints: ["spine2:eth4", "leaf8:eth1"] - - endpoints: ["spine2:eth5", "leaf9:eth1"] - - endpoints: ["spine2:eth6", "leaf10:eth1"] - - endpoints: ["spine3:eth2", "leaf11:eth1"] - - endpoints: ["spine3:eth3", "leaf12:eth1"] - - endpoints: ["spine3:eth4", "leaf13:eth1"] - - endpoints: ["spine3:eth5", "leaf14:eth1"] - - endpoints: ["spine3:eth6", "leaf15:eth1"] - - endpoints: ["spine4:eth2", "leaf16:eth1"] - - endpoints: ["spine4:eth3", "leaf17:eth1"] - - endpoints: ["spine4:eth4", "leaf18:eth1"] - - endpoints: ["spine4:eth5", "leaf19:eth1"] - - endpoints: ["spine4:eth6", "leaf20:eth1"] - - endpoints: ["spine5:eth2", "leaf21:eth1"] - - endpoints: ["spine5:eth3", "leaf22:eth1"] - - endpoints: ["spine5:eth4", "leaf23:eth1"] - - endpoints: ["spine5:eth5", "leaf24:eth1"] - - endpoints: ["spine5:eth6", "leaf25:eth1"] - - endpoints: ["spine6:eth2", "leaf26:eth1"] - - endpoints: ["spine6:eth3", "leaf27:eth1"] - - endpoints: ["spine6:eth4", "leaf28:eth1"] - - endpoints: ["spine6:eth5", "leaf29:eth1"] - - endpoints: ["spine6:eth6", "leaf30:eth1"] - - endpoints: ["spine7:eth2", "leaf31:eth1"] - - endpoints: ["spine7:eth3", "leaf32:eth1"] - - endpoints: ["spine7:eth4", "leaf33:eth1"] - - endpoints: ["spine7:eth5", "leaf34:eth1"] - - endpoints: ["spine7:eth6", "leaf35:eth1"] - - endpoints: ["spine8:eth2", "leaf36:eth1"] - - endpoints: ["spine8:eth3", "leaf37:eth1"] - - endpoints: ["spine8:eth4", "leaf38:eth1"] - - endpoints: ["spine8:eth5", "leaf39:eth1"] - - endpoints: ["spine8:eth6", "leaf40:eth1"] - - endpoints: ["spine9:eth2", "leaf41:eth1"] - - endpoints: ["spine9:eth3", "leaf42:eth1"] - - endpoints: ["spine9:eth4", "leaf43:eth1"] - - endpoints: ["spine9:eth5", "leaf44:eth1"] - - endpoints: ["spine9:eth6", "leaf45:eth1"] - - endpoints: ["spine10:eth2", "leaf46:eth1"] - - endpoints: ["spine10:eth3", "leaf47:eth1"] - - endpoints: ["spine10:eth4", "leaf48:eth1"] - - endpoints: ["spine10:eth5", "leaf49:eth1"] - - endpoints: ["spine10:eth6", "leaf50:eth1"] - - endpoints: ["spine11:eth2", "leaf51:eth1"] - - endpoints: ["spine11:eth3", "leaf52:eth1"] - - endpoints: ["spine11:eth4", "leaf53:eth1"] - - endpoints: ["spine11:eth5", "leaf54:eth1"] - - endpoints: ["spine11:eth6", "leaf55:eth1"] - - endpoints: ["spine12:eth2", "leaf56:eth1"] - - endpoints: ["spine12:eth3", "leaf57:eth1"] - - endpoints: ["spine12:eth4", "leaf58:eth1"] - - endpoints: ["spine12:eth5", "leaf59:eth1"] - - endpoints: ["spine12:eth6", "leaf60:eth1"] - - endpoints: ["spine13:eth2", "leaf61:eth1"] - - endpoints: ["spine13:eth3", "leaf62:eth1"] - - endpoints: ["spine13:eth4", "leaf63:eth1"] - - endpoints: ["spine13:eth5", "leaf64:eth1"] - - endpoints: ["spine13:eth6", "leaf65:eth1"] - - endpoints: ["spine14:eth2", "leaf66:eth1"] - - endpoints: ["spine14:eth3", "leaf67:eth1"] - - endpoints: ["spine14:eth4", "leaf68:eth1"] - - endpoints: ["spine14:eth5", "leaf69:eth1"] - - endpoints: ["spine14:eth6", "leaf70:eth1"] - - endpoints: ["spine15:eth2", "leaf71:eth1"] - - endpoints: ["spine15:eth3", "leaf72:eth1"] - - endpoints: ["spine15:eth4", "leaf73:eth1"] - - endpoints: ["spine15:eth5", "leaf74:eth1"] - - endpoints: ["spine15:eth6", "leaf75:eth1"] - - endpoints: ["spine16:eth2", "leaf76:eth1"] - - endpoints: ["spine16:eth3", "leaf77:eth1"] - - endpoints: ["spine16:eth4", "leaf78:eth1"] - - endpoints: ["spine16:eth5", "leaf79:eth1"] - - endpoints: ["spine16:eth6", "leaf80:eth1"] - # Leaf to Servers (each leaf has ~11 servers) - - endpoints: ["leaf1:eth2", "server1:eth1"] - - endpoints: ["leaf1:eth3", "server2:eth1"] - - endpoints: ["leaf1:eth4", "server3:eth1"] - - endpoints: ["leaf1:eth5", "server4:eth1"] - - endpoints: ["leaf1:eth6", "server5:eth1"] - - endpoints: ["leaf1:eth7", "server6:eth1"] - - endpoints: ["leaf1:eth8", "server7:eth1"] - - endpoints: ["leaf1:eth9", "server8:eth1"] - - endpoints: ["leaf1:eth10", "server9:eth1"] - - endpoints: ["leaf1:eth11", "server10:eth1"] - - endpoints: ["leaf1:eth12", "server11:eth1"] - - endpoints: ["leaf2:eth2", "server12:eth1"] - - endpoints: ["leaf2:eth3", "server13:eth1"] - - endpoints: ["leaf2:eth4", "server14:eth1"] - - endpoints: ["leaf2:eth5", "server15:eth1"] - - endpoints: ["leaf2:eth6", "server16:eth1"] - - endpoints: ["leaf2:eth7", "server17:eth1"] - - endpoints: ["leaf2:eth8", "server18:eth1"] - - endpoints: ["leaf2:eth9", "server19:eth1"] - - endpoints: ["leaf2:eth10", "server20:eth1"] - - endpoints: ["leaf2:eth11", "server21:eth1"] - - endpoints: ["leaf2:eth12", "server22:eth1"] - - endpoints: ["leaf3:eth2", "server23:eth1"] - - endpoints: ["leaf3:eth3", "server24:eth1"] - - endpoints: ["leaf3:eth4", "server25:eth1"] - - endpoints: ["leaf3:eth5", "server26:eth1"] - - endpoints: ["leaf3:eth6", "server27:eth1"] - - endpoints: ["leaf3:eth7", "server28:eth1"] - - endpoints: ["leaf3:eth8", "server29:eth1"] - - endpoints: ["leaf3:eth9", "server30:eth1"] - - endpoints: ["leaf3:eth10", "server31:eth1"] - - endpoints: ["leaf3:eth11", "server32:eth1"] - - endpoints: ["leaf3:eth12", "server33:eth1"] - - endpoints: ["leaf4:eth2", "server34:eth1"] - - endpoints: ["leaf4:eth3", "server35:eth1"] - - endpoints: ["leaf4:eth4", "server36:eth1"] - - endpoints: ["leaf4:eth5", "server37:eth1"] - - endpoints: ["leaf4:eth6", "server38:eth1"] - - endpoints: ["leaf4:eth7", "server39:eth1"] - - endpoints: ["leaf4:eth8", "server40:eth1"] - - endpoints: ["leaf4:eth9", "server41:eth1"] - - endpoints: ["leaf4:eth10", "server42:eth1"] - - endpoints: ["leaf4:eth11", "server43:eth1"] - - endpoints: ["leaf4:eth12", "server44:eth1"] - - endpoints: ["leaf5:eth2", "server45:eth1"] - - endpoints: ["leaf5:eth3", "server46:eth1"] - - endpoints: ["leaf5:eth4", "server47:eth1"] - - endpoints: ["leaf5:eth5", "server48:eth1"] - - endpoints: ["leaf5:eth6", "server49:eth1"] - - endpoints: ["leaf5:eth7", "server50:eth1"] - - endpoints: ["leaf5:eth8", "server51:eth1"] - - endpoints: ["leaf5:eth9", "server52:eth1"] - - endpoints: ["leaf5:eth10", "server53:eth1"] - - endpoints: ["leaf5:eth11", "server54:eth1"] - - endpoints: ["leaf5:eth12", "server55:eth1"] - - endpoints: ["leaf6:eth2", "server56:eth1"] - - endpoints: ["leaf6:eth3", "server57:eth1"] - - endpoints: ["leaf6:eth4", "server58:eth1"] - - endpoints: ["leaf6:eth5", "server59:eth1"] - - endpoints: ["leaf6:eth6", "server60:eth1"] - - endpoints: ["leaf6:eth7", "server61:eth1"] - - endpoints: ["leaf6:eth8", "server62:eth1"] - - endpoints: ["leaf6:eth9", "server63:eth1"] - - endpoints: ["leaf6:eth10", "server64:eth1"] - - endpoints: ["leaf6:eth11", "server65:eth1"] - - endpoints: ["leaf6:eth12", "server66:eth1"] - - endpoints: ["leaf7:eth2", "server67:eth1"] - - endpoints: ["leaf7:eth3", "server68:eth1"] - - endpoints: ["leaf7:eth4", "server69:eth1"] - - endpoints: ["leaf7:eth5", "server70:eth1"] - - endpoints: ["leaf7:eth6", "server71:eth1"] - - endpoints: ["leaf7:eth7", "server72:eth1"] - - endpoints: ["leaf7:eth8", "server73:eth1"] - - endpoints: ["leaf7:eth9", "server74:eth1"] - - endpoints: ["leaf7:eth10", "server75:eth1"] - - endpoints: ["leaf7:eth11", "server76:eth1"] - - endpoints: ["leaf7:eth12", "server77:eth1"] - - endpoints: ["leaf8:eth2", "server78:eth1"] - - endpoints: ["leaf8:eth3", "server79:eth1"] - - endpoints: ["leaf8:eth4", "server80:eth1"] - - endpoints: ["leaf8:eth5", "server81:eth1"] - - endpoints: ["leaf8:eth6", "server82:eth1"] - - endpoints: ["leaf8:eth7", "server83:eth1"] - - endpoints: ["leaf8:eth8", "server84:eth1"] - - endpoints: ["leaf8:eth9", "server85:eth1"] - - endpoints: ["leaf8:eth10", "server86:eth1"] - - endpoints: ["leaf8:eth11", "server87:eth1"] - - endpoints: ["leaf8:eth12", "server88:eth1"] - - endpoints: ["leaf9:eth2", "server89:eth1"] - - endpoints: ["leaf9:eth3", "server90:eth1"] - - endpoints: ["leaf9:eth4", "server91:eth1"] - - endpoints: ["leaf9:eth5", "server92:eth1"] - - endpoints: ["leaf9:eth6", "server93:eth1"] - - endpoints: ["leaf9:eth7", "server94:eth1"] - - endpoints: ["leaf9:eth8", "server95:eth1"] - - endpoints: ["leaf9:eth9", "server96:eth1"] - - endpoints: ["leaf9:eth10", "server97:eth1"] - - endpoints: ["leaf9:eth11", "server98:eth1"] - - endpoints: ["leaf9:eth12", "server99:eth1"] - - endpoints: ["leaf10:eth2", "server100:eth1"] - - endpoints: ["leaf10:eth3", "server101:eth1"] - - endpoints: ["leaf10:eth4", "server102:eth1"] - - endpoints: ["leaf10:eth5", "server103:eth1"] - - endpoints: ["leaf10:eth6", "server104:eth1"] - - endpoints: ["leaf10:eth7", "server105:eth1"] - - endpoints: ["leaf10:eth8", "server106:eth1"] - - endpoints: ["leaf10:eth9", "server107:eth1"] - - endpoints: ["leaf10:eth10", "server108:eth1"] - - endpoints: ["leaf10:eth11", "server109:eth1"] - - endpoints: ["leaf10:eth12", "server110:eth1"] - - endpoints: ["leaf11:eth2", "server111:eth1"] - - endpoints: ["leaf11:eth3", "server112:eth1"] - - endpoints: ["leaf11:eth4", "server113:eth1"] - - endpoints: ["leaf11:eth5", "server114:eth1"] - - endpoints: ["leaf11:eth6", "server115:eth1"] - - endpoints: ["leaf11:eth7", "server116:eth1"] - - endpoints: ["leaf11:eth8", "server117:eth1"] - - endpoints: ["leaf11:eth9", "server118:eth1"] - - endpoints: ["leaf11:eth10", "server119:eth1"] - - endpoints: ["leaf11:eth11", "server120:eth1"] - - endpoints: ["leaf11:eth12", "server121:eth1"] - - endpoints: ["leaf12:eth2", "server122:eth1"] - - endpoints: ["leaf12:eth3", "server123:eth1"] - - endpoints: ["leaf12:eth4", "server124:eth1"] - - endpoints: ["leaf12:eth5", "server125:eth1"] - - endpoints: ["leaf12:eth6", "server126:eth1"] - - endpoints: ["leaf12:eth7", "server127:eth1"] - - endpoints: ["leaf12:eth8", "server128:eth1"] - - endpoints: ["leaf12:eth9", "server129:eth1"] - - endpoints: ["leaf12:eth10", "server130:eth1"] - - endpoints: ["leaf12:eth11", "server131:eth1"] - - endpoints: ["leaf12:eth12", "server132:eth1"] - - endpoints: ["leaf13:eth2", "server133:eth1"] - - endpoints: ["leaf13:eth3", "server134:eth1"] - - endpoints: ["leaf13:eth4", "server135:eth1"] - - endpoints: ["leaf13:eth5", "server136:eth1"] - - endpoints: ["leaf13:eth6", "server137:eth1"] - - endpoints: ["leaf13:eth7", "server138:eth1"] - - endpoints: ["leaf13:eth8", "server139:eth1"] - - endpoints: ["leaf13:eth9", "server140:eth1"] - - endpoints: ["leaf13:eth10", "server141:eth1"] - - endpoints: ["leaf13:eth11", "server142:eth1"] - - endpoints: ["leaf13:eth12", "server143:eth1"] - - endpoints: ["leaf14:eth2", "server144:eth1"] - - endpoints: ["leaf14:eth3", "server145:eth1"] - - endpoints: ["leaf14:eth4", "server146:eth1"] - - endpoints: ["leaf14:eth5", "server147:eth1"] - - endpoints: ["leaf14:eth6", "server148:eth1"] - - endpoints: ["leaf14:eth7", "server149:eth1"] - - endpoints: ["leaf14:eth8", "server150:eth1"] - - endpoints: ["leaf14:eth9", "server151:eth1"] - - endpoints: ["leaf14:eth10", "server152:eth1"] - - endpoints: ["leaf14:eth11", "server153:eth1"] - - endpoints: ["leaf14:eth12", "server154:eth1"] - - endpoints: ["leaf15:eth2", "server155:eth1"] - - endpoints: ["leaf15:eth3", "server156:eth1"] - - endpoints: ["leaf15:eth4", "server157:eth1"] - - endpoints: ["leaf15:eth5", "server158:eth1"] - - endpoints: ["leaf15:eth6", "server159:eth1"] - - endpoints: ["leaf15:eth7", "server160:eth1"] - - endpoints: ["leaf15:eth8", "server161:eth1"] - - endpoints: ["leaf15:eth9", "server162:eth1"] - - endpoints: ["leaf15:eth10", "server163:eth1"] - - endpoints: ["leaf15:eth11", "server164:eth1"] - - endpoints: ["leaf15:eth12", "server165:eth1"] - - endpoints: ["leaf16:eth2", "server166:eth1"] - - endpoints: ["leaf16:eth3", "server167:eth1"] - - endpoints: ["leaf16:eth4", "server168:eth1"] - - endpoints: ["leaf16:eth5", "server169:eth1"] - - endpoints: ["leaf16:eth6", "server170:eth1"] - - endpoints: ["leaf16:eth7", "server171:eth1"] - - endpoints: ["leaf16:eth8", "server172:eth1"] - - endpoints: ["leaf16:eth9", "server173:eth1"] - - endpoints: ["leaf16:eth10", "server174:eth1"] - - endpoints: ["leaf16:eth11", "server175:eth1"] - - endpoints: ["leaf16:eth12", "server176:eth1"] - - endpoints: ["leaf17:eth2", "server177:eth1"] - - endpoints: ["leaf17:eth3", "server178:eth1"] - - endpoints: ["leaf17:eth4", "server179:eth1"] - - endpoints: ["leaf17:eth5", "server180:eth1"] - - endpoints: ["leaf17:eth6", "server181:eth1"] - - endpoints: ["leaf17:eth7", "server182:eth1"] - - endpoints: ["leaf17:eth8", "server183:eth1"] - - endpoints: ["leaf17:eth9", "server184:eth1"] - - endpoints: ["leaf17:eth10", "server185:eth1"] - - endpoints: ["leaf17:eth11", "server186:eth1"] - - endpoints: ["leaf17:eth12", "server187:eth1"] - - endpoints: ["leaf18:eth2", "server188:eth1"] - - endpoints: ["leaf18:eth3", "server189:eth1"] - - endpoints: ["leaf18:eth4", "server190:eth1"] - - endpoints: ["leaf18:eth5", "server191:eth1"] - - endpoints: ["leaf18:eth6", "server192:eth1"] - - endpoints: ["leaf18:eth7", "server193:eth1"] - - endpoints: ["leaf18:eth8", "server194:eth1"] - - endpoints: ["leaf18:eth9", "server195:eth1"] - - endpoints: ["leaf18:eth10", "server196:eth1"] - - endpoints: ["leaf18:eth11", "server197:eth1"] - - endpoints: ["leaf18:eth12", "server198:eth1"] - - endpoints: ["leaf19:eth2", "server199:eth1"] - - endpoints: ["leaf19:eth3", "server200:eth1"] - - endpoints: ["leaf19:eth4", "server201:eth1"] - - endpoints: ["leaf19:eth5", "server202:eth1"] - - endpoints: ["leaf19:eth6", "server203:eth1"] - - endpoints: ["leaf19:eth7", "server204:eth1"] - - endpoints: ["leaf19:eth8", "server205:eth1"] - - endpoints: ["leaf19:eth9", "server206:eth1"] - - endpoints: ["leaf19:eth10", "server207:eth1"] - - endpoints: ["leaf19:eth11", "server208:eth1"] - - endpoints: ["leaf19:eth12", "server209:eth1"] - - endpoints: ["leaf20:eth2", "server210:eth1"] - - endpoints: ["leaf20:eth3", "server211:eth1"] - - endpoints: ["leaf20:eth4", "server212:eth1"] - - endpoints: ["leaf20:eth5", "server213:eth1"] - - endpoints: ["leaf20:eth6", "server214:eth1"] - - endpoints: ["leaf20:eth7", "server215:eth1"] - - endpoints: ["leaf20:eth8", "server216:eth1"] - - endpoints: ["leaf20:eth9", "server217:eth1"] - - endpoints: ["leaf20:eth10", "server218:eth1"] - - endpoints: ["leaf20:eth11", "server219:eth1"] - - endpoints: ["leaf20:eth12", "server220:eth1"] - - endpoints: ["leaf21:eth2", "server221:eth1"] - - endpoints: ["leaf21:eth3", "server222:eth1"] - - endpoints: ["leaf21:eth4", "server223:eth1"] - - endpoints: ["leaf21:eth5", "server224:eth1"] - - endpoints: ["leaf21:eth6", "server225:eth1"] - - endpoints: ["leaf21:eth7", "server226:eth1"] - - endpoints: ["leaf21:eth8", "server227:eth1"] - - endpoints: ["leaf21:eth9", "server228:eth1"] - - endpoints: ["leaf21:eth10", "server229:eth1"] - - endpoints: ["leaf21:eth11", "server230:eth1"] - - endpoints: ["leaf21:eth12", "server231:eth1"] - - endpoints: ["leaf22:eth2", "server232:eth1"] - - endpoints: ["leaf22:eth3", "server233:eth1"] - - endpoints: ["leaf22:eth4", "server234:eth1"] - - endpoints: ["leaf22:eth5", "server235:eth1"] - - endpoints: ["leaf22:eth6", "server236:eth1"] - - endpoints: ["leaf22:eth7", "server237:eth1"] - - endpoints: ["leaf22:eth8", "server238:eth1"] - - endpoints: ["leaf22:eth9", "server239:eth1"] - - endpoints: ["leaf22:eth10", "server240:eth1"] - - endpoints: ["leaf22:eth11", "server241:eth1"] - - endpoints: ["leaf22:eth12", "server242:eth1"] - - endpoints: ["leaf23:eth2", "server243:eth1"] - - endpoints: ["leaf23:eth3", "server244:eth1"] - - endpoints: ["leaf23:eth4", "server245:eth1"] - - endpoints: ["leaf23:eth5", "server246:eth1"] - - endpoints: ["leaf23:eth6", "server247:eth1"] - - endpoints: ["leaf23:eth7", "server248:eth1"] - - endpoints: ["leaf23:eth8", "server249:eth1"] - - endpoints: ["leaf23:eth9", "server250:eth1"] - - endpoints: ["leaf23:eth10", "server251:eth1"] - - endpoints: ["leaf23:eth11", "server252:eth1"] - - endpoints: ["leaf23:eth12", "server253:eth1"] - - endpoints: ["leaf24:eth2", "server254:eth1"] - - endpoints: ["leaf24:eth3", "server255:eth1"] - - endpoints: ["leaf24:eth4", "server256:eth1"] - - endpoints: ["leaf24:eth5", "server257:eth1"] - - endpoints: ["leaf24:eth6", "server258:eth1"] - - endpoints: ["leaf24:eth7", "server259:eth1"] - - endpoints: ["leaf24:eth8", "server260:eth1"] - - endpoints: ["leaf24:eth9", "server261:eth1"] - - endpoints: ["leaf24:eth10", "server262:eth1"] - - endpoints: ["leaf24:eth11", "server263:eth1"] - - endpoints: ["leaf24:eth12", "server264:eth1"] - - endpoints: ["leaf25:eth2", "server265:eth1"] - - endpoints: ["leaf25:eth3", "server266:eth1"] - - endpoints: ["leaf25:eth4", "server267:eth1"] - - endpoints: ["leaf25:eth5", "server268:eth1"] - - endpoints: ["leaf25:eth6", "server269:eth1"] - - endpoints: ["leaf25:eth7", "server270:eth1"] - - endpoints: ["leaf25:eth8", "server271:eth1"] - - endpoints: ["leaf25:eth9", "server272:eth1"] - - endpoints: ["leaf25:eth10", "server273:eth1"] - - endpoints: ["leaf25:eth11", "server274:eth1"] - - endpoints: ["leaf25:eth12", "server275:eth1"] - - endpoints: ["leaf26:eth2", "server276:eth1"] - - endpoints: ["leaf26:eth3", "server277:eth1"] - - endpoints: ["leaf26:eth4", "server278:eth1"] - - endpoints: ["leaf26:eth5", "server279:eth1"] - - endpoints: ["leaf26:eth6", "server280:eth1"] - - endpoints: ["leaf26:eth7", "server281:eth1"] - - endpoints: ["leaf26:eth8", "server282:eth1"] - - endpoints: ["leaf26:eth9", "server283:eth1"] - - endpoints: ["leaf26:eth10", "server284:eth1"] - - endpoints: ["leaf26:eth11", "server285:eth1"] - - endpoints: ["leaf26:eth12", "server286:eth1"] - - endpoints: ["leaf27:eth2", "server287:eth1"] - - endpoints: ["leaf27:eth3", "server288:eth1"] - - endpoints: ["leaf27:eth4", "server289:eth1"] - - endpoints: ["leaf27:eth5", "server290:eth1"] - - endpoints: ["leaf27:eth6", "server291:eth1"] - - endpoints: ["leaf27:eth7", "server292:eth1"] - - endpoints: ["leaf27:eth8", "server293:eth1"] - - endpoints: ["leaf27:eth9", "server294:eth1"] - - endpoints: ["leaf27:eth10", "server295:eth1"] - - endpoints: ["leaf27:eth11", "server296:eth1"] - - endpoints: ["leaf27:eth12", "server297:eth1"] - - endpoints: ["leaf28:eth2", "server298:eth1"] - - endpoints: ["leaf28:eth3", "server299:eth1"] - - endpoints: ["leaf28:eth4", "server300:eth1"] - - endpoints: ["leaf28:eth5", "server301:eth1"] - - endpoints: ["leaf28:eth6", "server302:eth1"] - - endpoints: ["leaf28:eth7", "server303:eth1"] - - endpoints: ["leaf28:eth8", "server304:eth1"] - - endpoints: ["leaf28:eth9", "server305:eth1"] - - endpoints: ["leaf28:eth10", "server306:eth1"] - - endpoints: ["leaf28:eth11", "server307:eth1"] - - endpoints: ["leaf28:eth12", "server308:eth1"] - - endpoints: ["leaf29:eth2", "server309:eth1"] - - endpoints: ["leaf29:eth3", "server310:eth1"] - - endpoints: ["leaf29:eth4", "server311:eth1"] - - endpoints: ["leaf29:eth5", "server312:eth1"] - - endpoints: ["leaf29:eth6", "server313:eth1"] - - endpoints: ["leaf29:eth7", "server314:eth1"] - - endpoints: ["leaf29:eth8", "server315:eth1"] - - endpoints: ["leaf29:eth9", "server316:eth1"] - - endpoints: ["leaf29:eth10", "server317:eth1"] - - endpoints: ["leaf29:eth11", "server318:eth1"] - - endpoints: ["leaf29:eth12", "server319:eth1"] - - endpoints: ["leaf30:eth2", "server320:eth1"] - - endpoints: ["leaf30:eth3", "server321:eth1"] - - endpoints: ["leaf30:eth4", "server322:eth1"] - - endpoints: ["leaf30:eth5", "server323:eth1"] - - endpoints: ["leaf30:eth6", "server324:eth1"] - - endpoints: ["leaf30:eth7", "server325:eth1"] - - endpoints: ["leaf30:eth8", "server326:eth1"] - - endpoints: ["leaf30:eth9", "server327:eth1"] - - endpoints: ["leaf30:eth10", "server328:eth1"] - - endpoints: ["leaf30:eth11", "server329:eth1"] - - endpoints: ["leaf30:eth12", "server330:eth1"] - - endpoints: ["leaf31:eth2", "server331:eth1"] - - endpoints: ["leaf31:eth3", "server332:eth1"] - - endpoints: ["leaf31:eth4", "server333:eth1"] - - endpoints: ["leaf31:eth5", "server334:eth1"] - - endpoints: ["leaf31:eth6", "server335:eth1"] - - endpoints: ["leaf31:eth7", "server336:eth1"] - - endpoints: ["leaf31:eth8", "server337:eth1"] - - endpoints: ["leaf31:eth9", "server338:eth1"] - - endpoints: ["leaf31:eth10", "server339:eth1"] - - endpoints: ["leaf31:eth11", "server340:eth1"] - - endpoints: ["leaf31:eth12", "server341:eth1"] - - endpoints: ["leaf32:eth2", "server342:eth1"] - - endpoints: ["leaf32:eth3", "server343:eth1"] - - endpoints: ["leaf32:eth4", "server344:eth1"] - - endpoints: ["leaf32:eth5", "server345:eth1"] - - endpoints: ["leaf32:eth6", "server346:eth1"] - - endpoints: ["leaf32:eth7", "server347:eth1"] - - endpoints: ["leaf32:eth8", "server348:eth1"] - - endpoints: ["leaf32:eth9", "server349:eth1"] - - endpoints: ["leaf32:eth10", "server350:eth1"] - - endpoints: ["leaf32:eth11", "server351:eth1"] - - endpoints: ["leaf32:eth12", "server352:eth1"] - - endpoints: ["leaf33:eth2", "server353:eth1"] - - endpoints: ["leaf33:eth3", "server354:eth1"] - - endpoints: ["leaf33:eth4", "server355:eth1"] - - endpoints: ["leaf33:eth5", "server356:eth1"] - - endpoints: ["leaf33:eth6", "server357:eth1"] - - endpoints: ["leaf33:eth7", "server358:eth1"] - - endpoints: ["leaf33:eth8", "server359:eth1"] - - endpoints: ["leaf33:eth9", "server360:eth1"] - - endpoints: ["leaf33:eth10", "server361:eth1"] - - endpoints: ["leaf33:eth11", "server362:eth1"] - - endpoints: ["leaf33:eth12", "server363:eth1"] - - endpoints: ["leaf34:eth2", "server364:eth1"] - - endpoints: ["leaf34:eth3", "server365:eth1"] - - endpoints: ["leaf34:eth4", "server366:eth1"] - - endpoints: ["leaf34:eth5", "server367:eth1"] - - endpoints: ["leaf34:eth6", "server368:eth1"] - - endpoints: ["leaf34:eth7", "server369:eth1"] - - endpoints: ["leaf34:eth8", "server370:eth1"] - - endpoints: ["leaf34:eth9", "server371:eth1"] - - endpoints: ["leaf34:eth10", "server372:eth1"] - - endpoints: ["leaf34:eth11", "server373:eth1"] - - endpoints: ["leaf34:eth12", "server374:eth1"] - - endpoints: ["leaf35:eth2", "server375:eth1"] - - endpoints: ["leaf35:eth3", "server376:eth1"] - - endpoints: ["leaf35:eth4", "server377:eth1"] - - endpoints: ["leaf35:eth5", "server378:eth1"] - - endpoints: ["leaf35:eth6", "server379:eth1"] - - endpoints: ["leaf35:eth7", "server380:eth1"] - - endpoints: ["leaf35:eth8", "server381:eth1"] - - endpoints: ["leaf35:eth9", "server382:eth1"] - - endpoints: ["leaf35:eth10", "server383:eth1"] - - endpoints: ["leaf35:eth11", "server384:eth1"] - - endpoints: ["leaf35:eth12", "server385:eth1"] - - endpoints: ["leaf36:eth2", "server386:eth1"] - - endpoints: ["leaf36:eth3", "server387:eth1"] - - endpoints: ["leaf36:eth4", "server388:eth1"] - - endpoints: ["leaf36:eth5", "server389:eth1"] - - endpoints: ["leaf36:eth6", "server390:eth1"] - - endpoints: ["leaf36:eth7", "server391:eth1"] - - endpoints: ["leaf36:eth8", "server392:eth1"] - - endpoints: ["leaf36:eth9", "server393:eth1"] - - endpoints: ["leaf36:eth10", "server394:eth1"] - - endpoints: ["leaf36:eth11", "server395:eth1"] - - endpoints: ["leaf36:eth12", "server396:eth1"] - - endpoints: ["leaf37:eth2", "server397:eth1"] - - endpoints: ["leaf37:eth3", "server398:eth1"] - - endpoints: ["leaf37:eth4", "server399:eth1"] - - endpoints: ["leaf37:eth5", "server400:eth1"] - - endpoints: ["leaf37:eth6", "server401:eth1"] - - endpoints: ["leaf37:eth7", "server402:eth1"] - - endpoints: ["leaf37:eth8", "server403:eth1"] - - endpoints: ["leaf37:eth9", "server404:eth1"] - - endpoints: ["leaf37:eth10", "server405:eth1"] - - endpoints: ["leaf37:eth11", "server406:eth1"] - - endpoints: ["leaf37:eth12", "server407:eth1"] - - endpoints: ["leaf38:eth2", "server408:eth1"] - - endpoints: ["leaf38:eth3", "server409:eth1"] - - endpoints: ["leaf38:eth4", "server410:eth1"] - - endpoints: ["leaf38:eth5", "server411:eth1"] - - endpoints: ["leaf38:eth6", "server412:eth1"] - - endpoints: ["leaf38:eth7", "server413:eth1"] - - endpoints: ["leaf38:eth8", "server414:eth1"] - - endpoints: ["leaf38:eth9", "server415:eth1"] - - endpoints: ["leaf38:eth10", "server416:eth1"] - - endpoints: ["leaf38:eth11", "server417:eth1"] - - endpoints: ["leaf38:eth12", "server418:eth1"] - - endpoints: ["leaf39:eth2", "server419:eth1"] - - endpoints: ["leaf39:eth3", "server420:eth1"] - - endpoints: ["leaf39:eth4", "server421:eth1"] - - endpoints: ["leaf39:eth5", "server422:eth1"] - - endpoints: ["leaf39:eth6", "server423:eth1"] - - endpoints: ["leaf39:eth7", "server424:eth1"] - - endpoints: ["leaf39:eth8", "server425:eth1"] - - endpoints: ["leaf39:eth9", "server426:eth1"] - - endpoints: ["leaf39:eth10", "server427:eth1"] - - endpoints: ["leaf39:eth11", "server428:eth1"] - - endpoints: ["leaf39:eth12", "server429:eth1"] - - endpoints: ["leaf40:eth2", "server430:eth1"] - - endpoints: ["leaf40:eth3", "server431:eth1"] - - endpoints: ["leaf40:eth4", "server432:eth1"] - - endpoints: ["leaf40:eth5", "server433:eth1"] - - endpoints: ["leaf40:eth6", "server434:eth1"] - - endpoints: ["leaf40:eth7", "server435:eth1"] - - endpoints: ["leaf40:eth8", "server436:eth1"] - - endpoints: ["leaf40:eth9", "server437:eth1"] - - endpoints: ["leaf40:eth10", "server438:eth1"] - - endpoints: ["leaf40:eth11", "server439:eth1"] - - endpoints: ["leaf40:eth12", "server440:eth1"] - - endpoints: ["leaf41:eth2", "server441:eth1"] - - endpoints: ["leaf41:eth3", "server442:eth1"] - - endpoints: ["leaf41:eth4", "server443:eth1"] - - endpoints: ["leaf41:eth5", "server444:eth1"] - - endpoints: ["leaf41:eth6", "server445:eth1"] - - endpoints: ["leaf41:eth7", "server446:eth1"] - - endpoints: ["leaf41:eth8", "server447:eth1"] - - endpoints: ["leaf41:eth9", "server448:eth1"] - - endpoints: ["leaf41:eth10", "server449:eth1"] - - endpoints: ["leaf41:eth11", "server450:eth1"] - - endpoints: ["leaf41:eth12", "server451:eth1"] - - endpoints: ["leaf42:eth2", "server452:eth1"] - - endpoints: ["leaf42:eth3", "server453:eth1"] - - endpoints: ["leaf42:eth4", "server454:eth1"] - - endpoints: ["leaf42:eth5", "server455:eth1"] - - endpoints: ["leaf42:eth6", "server456:eth1"] - - endpoints: ["leaf42:eth7", "server457:eth1"] - - endpoints: ["leaf42:eth8", "server458:eth1"] - - endpoints: ["leaf42:eth9", "server459:eth1"] - - endpoints: ["leaf42:eth10", "server460:eth1"] - - endpoints: ["leaf42:eth11", "server461:eth1"] - - endpoints: ["leaf42:eth12", "server462:eth1"] - - endpoints: ["leaf43:eth2", "server463:eth1"] - - endpoints: ["leaf43:eth3", "server464:eth1"] - - endpoints: ["leaf43:eth4", "server465:eth1"] - - endpoints: ["leaf43:eth5", "server466:eth1"] - - endpoints: ["leaf43:eth6", "server467:eth1"] - - endpoints: ["leaf43:eth7", "server468:eth1"] - - endpoints: ["leaf43:eth8", "server469:eth1"] - - endpoints: ["leaf43:eth9", "server470:eth1"] - - endpoints: ["leaf43:eth10", "server471:eth1"] - - endpoints: ["leaf43:eth11", "server472:eth1"] - - endpoints: ["leaf43:eth12", "server473:eth1"] - - endpoints: ["leaf44:eth2", "server474:eth1"] - - endpoints: ["leaf44:eth3", "server475:eth1"] - - endpoints: ["leaf44:eth4", "server476:eth1"] - - endpoints: ["leaf44:eth5", "server477:eth1"] - - endpoints: ["leaf44:eth6", "server478:eth1"] - - endpoints: ["leaf44:eth7", "server479:eth1"] - - endpoints: ["leaf44:eth8", "server480:eth1"] - - endpoints: ["leaf44:eth9", "server481:eth1"] - - endpoints: ["leaf44:eth10", "server482:eth1"] - - endpoints: ["leaf44:eth11", "server483:eth1"] - - endpoints: ["leaf44:eth12", "server484:eth1"] - - endpoints: ["leaf45:eth2", "server485:eth1"] - - endpoints: ["leaf45:eth3", "server486:eth1"] - - endpoints: ["leaf45:eth4", "server487:eth1"] - - endpoints: ["leaf45:eth5", "server488:eth1"] - - endpoints: ["leaf45:eth6", "server489:eth1"] - - endpoints: ["leaf45:eth7", "server490:eth1"] - - endpoints: ["leaf45:eth8", "server491:eth1"] - - endpoints: ["leaf45:eth9", "server492:eth1"] - - endpoints: ["leaf45:eth10", "server493:eth1"] - - endpoints: ["leaf45:eth11", "server494:eth1"] - - endpoints: ["leaf45:eth12", "server495:eth1"] - - endpoints: ["leaf46:eth2", "server496:eth1"] - - endpoints: ["leaf46:eth3", "server497:eth1"] - - endpoints: ["leaf46:eth4", "server498:eth1"] - - endpoints: ["leaf46:eth5", "server499:eth1"] - - endpoints: ["leaf46:eth6", "server500:eth1"] - - endpoints: ["leaf46:eth7", "server501:eth1"] - - endpoints: ["leaf46:eth8", "server502:eth1"] - - endpoints: ["leaf46:eth9", "server503:eth1"] - - endpoints: ["leaf46:eth10", "server504:eth1"] - - endpoints: ["leaf46:eth11", "server505:eth1"] - - endpoints: ["leaf46:eth12", "server506:eth1"] - - endpoints: ["leaf47:eth2", "server507:eth1"] - - endpoints: ["leaf47:eth3", "server508:eth1"] - - endpoints: ["leaf47:eth4", "server509:eth1"] - - endpoints: ["leaf47:eth5", "server510:eth1"] - - endpoints: ["leaf47:eth6", "server511:eth1"] - - endpoints: ["leaf47:eth7", "server512:eth1"] - - endpoints: ["leaf47:eth8", "server513:eth1"] - - endpoints: ["leaf47:eth9", "server514:eth1"] - - endpoints: ["leaf47:eth10", "server515:eth1"] - - endpoints: ["leaf47:eth11", "server516:eth1"] - - endpoints: ["leaf47:eth12", "server517:eth1"] - - endpoints: ["leaf48:eth2", "server518:eth1"] - - endpoints: ["leaf48:eth3", "server519:eth1"] - - endpoints: ["leaf48:eth4", "server520:eth1"] - - endpoints: ["leaf48:eth5", "server521:eth1"] - - endpoints: ["leaf48:eth6", "server522:eth1"] - - endpoints: ["leaf48:eth7", "server523:eth1"] - - endpoints: ["leaf48:eth8", "server524:eth1"] - - endpoints: ["leaf48:eth9", "server525:eth1"] - - endpoints: ["leaf48:eth10", "server526:eth1"] - - endpoints: ["leaf48:eth11", "server527:eth1"] - - endpoints: ["leaf48:eth12", "server528:eth1"] - - endpoints: ["leaf49:eth2", "server529:eth1"] - - endpoints: ["leaf49:eth3", "server530:eth1"] - - endpoints: ["leaf49:eth4", "server531:eth1"] - - endpoints: ["leaf49:eth5", "server532:eth1"] - - endpoints: ["leaf49:eth6", "server533:eth1"] - - endpoints: ["leaf49:eth7", "server534:eth1"] - - endpoints: ["leaf49:eth8", "server535:eth1"] - - endpoints: ["leaf49:eth9", "server536:eth1"] - - endpoints: ["leaf49:eth10", "server537:eth1"] - - endpoints: ["leaf49:eth11", "server538:eth1"] - - endpoints: ["leaf49:eth12", "server539:eth1"] - - endpoints: ["leaf50:eth2", "server540:eth1"] - - endpoints: ["leaf50:eth3", "server541:eth1"] - - endpoints: ["leaf50:eth4", "server542:eth1"] - - endpoints: ["leaf50:eth5", "server543:eth1"] - - endpoints: ["leaf50:eth6", "server544:eth1"] - - endpoints: ["leaf50:eth7", "server545:eth1"] - - endpoints: ["leaf50:eth8", "server546:eth1"] - - endpoints: ["leaf50:eth9", "server547:eth1"] - - endpoints: ["leaf50:eth10", "server548:eth1"] - - endpoints: ["leaf50:eth11", "server549:eth1"] - - endpoints: ["leaf50:eth12", "server550:eth1"] - - endpoints: ["leaf51:eth2", "server551:eth1"] - - endpoints: ["leaf51:eth3", "server552:eth1"] - - endpoints: ["leaf51:eth4", "server553:eth1"] - - endpoints: ["leaf51:eth5", "server554:eth1"] - - endpoints: ["leaf51:eth6", "server555:eth1"] - - endpoints: ["leaf51:eth7", "server556:eth1"] - - endpoints: ["leaf51:eth8", "server557:eth1"] - - endpoints: ["leaf51:eth9", "server558:eth1"] - - endpoints: ["leaf51:eth10", "server559:eth1"] - - endpoints: ["leaf51:eth11", "server560:eth1"] - - endpoints: ["leaf51:eth12", "server561:eth1"] - - endpoints: ["leaf52:eth2", "server562:eth1"] - - endpoints: ["leaf52:eth3", "server563:eth1"] - - endpoints: ["leaf52:eth4", "server564:eth1"] - - endpoints: ["leaf52:eth5", "server565:eth1"] - - endpoints: ["leaf52:eth6", "server566:eth1"] - - endpoints: ["leaf52:eth7", "server567:eth1"] - - endpoints: ["leaf52:eth8", "server568:eth1"] - - endpoints: ["leaf52:eth9", "server569:eth1"] - - endpoints: ["leaf52:eth10", "server570:eth1"] - - endpoints: ["leaf52:eth11", "server571:eth1"] - - endpoints: ["leaf52:eth12", "server572:eth1"] - - endpoints: ["leaf53:eth2", "server573:eth1"] - - endpoints: ["leaf53:eth3", "server574:eth1"] - - endpoints: ["leaf53:eth4", "server575:eth1"] - - endpoints: ["leaf53:eth5", "server576:eth1"] - - endpoints: ["leaf53:eth6", "server577:eth1"] - - endpoints: ["leaf53:eth7", "server578:eth1"] - - endpoints: ["leaf53:eth8", "server579:eth1"] - - endpoints: ["leaf53:eth9", "server580:eth1"] - - endpoints: ["leaf53:eth10", "server581:eth1"] - - endpoints: ["leaf53:eth11", "server582:eth1"] - - endpoints: ["leaf53:eth12", "server583:eth1"] - - endpoints: ["leaf54:eth2", "server584:eth1"] - - endpoints: ["leaf54:eth3", "server585:eth1"] - - endpoints: ["leaf54:eth4", "server586:eth1"] - - endpoints: ["leaf54:eth5", "server587:eth1"] - - endpoints: ["leaf54:eth6", "server588:eth1"] - - endpoints: ["leaf54:eth7", "server589:eth1"] - - endpoints: ["leaf54:eth8", "server590:eth1"] - - endpoints: ["leaf54:eth9", "server591:eth1"] - - endpoints: ["leaf54:eth10", "server592:eth1"] - - endpoints: ["leaf54:eth11", "server593:eth1"] - - endpoints: ["leaf54:eth12", "server594:eth1"] - - endpoints: ["leaf55:eth2", "server595:eth1"] - - endpoints: ["leaf55:eth3", "server596:eth1"] - - endpoints: ["leaf55:eth4", "server597:eth1"] - - endpoints: ["leaf55:eth5", "server598:eth1"] - - endpoints: ["leaf55:eth6", "server599:eth1"] - - endpoints: ["leaf55:eth7", "server600:eth1"] - - endpoints: ["leaf55:eth8", "server601:eth1"] - - endpoints: ["leaf55:eth9", "server602:eth1"] - - endpoints: ["leaf55:eth10", "server603:eth1"] - - endpoints: ["leaf55:eth11", "server604:eth1"] - - endpoints: ["leaf55:eth12", "server605:eth1"] - - endpoints: ["leaf56:eth2", "server606:eth1"] - - endpoints: ["leaf56:eth3", "server607:eth1"] - - endpoints: ["leaf56:eth4", "server608:eth1"] - - endpoints: ["leaf56:eth5", "server609:eth1"] - - endpoints: ["leaf56:eth6", "server610:eth1"] - - endpoints: ["leaf56:eth7", "server611:eth1"] - - endpoints: ["leaf56:eth8", "server612:eth1"] - - endpoints: ["leaf56:eth9", "server613:eth1"] - - endpoints: ["leaf56:eth10", "server614:eth1"] - - endpoints: ["leaf56:eth11", "server615:eth1"] - - endpoints: ["leaf56:eth12", "server616:eth1"] - - endpoints: ["leaf57:eth2", "server617:eth1"] - - endpoints: ["leaf57:eth3", "server618:eth1"] - - endpoints: ["leaf57:eth4", "server619:eth1"] - - endpoints: ["leaf57:eth5", "server620:eth1"] - - endpoints: ["leaf57:eth6", "server621:eth1"] - - endpoints: ["leaf57:eth7", "server622:eth1"] - - endpoints: ["leaf57:eth8", "server623:eth1"] - - endpoints: ["leaf57:eth9", "server624:eth1"] - - endpoints: ["leaf57:eth10", "server625:eth1"] - - endpoints: ["leaf57:eth11", "server626:eth1"] - - endpoints: ["leaf57:eth12", "server627:eth1"] - - endpoints: ["leaf58:eth2", "server628:eth1"] - - endpoints: ["leaf58:eth3", "server629:eth1"] - - endpoints: ["leaf58:eth4", "server630:eth1"] - - endpoints: ["leaf58:eth5", "server631:eth1"] - - endpoints: ["leaf58:eth6", "server632:eth1"] - - endpoints: ["leaf58:eth7", "server633:eth1"] - - endpoints: ["leaf58:eth8", "server634:eth1"] - - endpoints: ["leaf58:eth9", "server635:eth1"] - - endpoints: ["leaf58:eth10", "server636:eth1"] - - endpoints: ["leaf58:eth11", "server637:eth1"] - - endpoints: ["leaf58:eth12", "server638:eth1"] - - endpoints: ["leaf59:eth2", "server639:eth1"] - - endpoints: ["leaf59:eth3", "server640:eth1"] - - endpoints: ["leaf59:eth4", "server641:eth1"] - - endpoints: ["leaf59:eth5", "server642:eth1"] - - endpoints: ["leaf59:eth6", "server643:eth1"] - - endpoints: ["leaf59:eth7", "server644:eth1"] - - endpoints: ["leaf59:eth8", "server645:eth1"] - - endpoints: ["leaf59:eth9", "server646:eth1"] - - endpoints: ["leaf59:eth10", "server647:eth1"] - - endpoints: ["leaf59:eth11", "server648:eth1"] - - endpoints: ["leaf59:eth12", "server649:eth1"] - - endpoints: ["leaf60:eth2", "server650:eth1"] - - endpoints: ["leaf60:eth3", "server651:eth1"] - - endpoints: ["leaf60:eth4", "server652:eth1"] - - endpoints: ["leaf60:eth5", "server653:eth1"] - - endpoints: ["leaf60:eth6", "server654:eth1"] - - endpoints: ["leaf60:eth7", "server655:eth1"] - - endpoints: ["leaf60:eth8", "server656:eth1"] - - endpoints: ["leaf60:eth9", "server657:eth1"] - - endpoints: ["leaf60:eth10", "server658:eth1"] - - endpoints: ["leaf60:eth11", "server659:eth1"] - - endpoints: ["leaf60:eth12", "server660:eth1"] - - endpoints: ["leaf61:eth2", "server661:eth1"] - - endpoints: ["leaf61:eth3", "server662:eth1"] - - endpoints: ["leaf61:eth4", "server663:eth1"] - - endpoints: ["leaf61:eth5", "server664:eth1"] - - endpoints: ["leaf61:eth6", "server665:eth1"] - - endpoints: ["leaf61:eth7", "server666:eth1"] - - endpoints: ["leaf61:eth8", "server667:eth1"] - - endpoints: ["leaf61:eth9", "server668:eth1"] - - endpoints: ["leaf61:eth10", "server669:eth1"] - - endpoints: ["leaf61:eth11", "server670:eth1"] - - endpoints: ["leaf61:eth12", "server671:eth1"] - - endpoints: ["leaf62:eth2", "server672:eth1"] - - endpoints: ["leaf62:eth3", "server673:eth1"] - - endpoints: ["leaf62:eth4", "server674:eth1"] - - endpoints: ["leaf62:eth5", "server675:eth1"] - - endpoints: ["leaf62:eth6", "server676:eth1"] - - endpoints: ["leaf62:eth7", "server677:eth1"] - - endpoints: ["leaf62:eth8", "server678:eth1"] - - endpoints: ["leaf62:eth9", "server679:eth1"] - - endpoints: ["leaf62:eth10", "server680:eth1"] - - endpoints: ["leaf62:eth11", "server681:eth1"] - - endpoints: ["leaf62:eth12", "server682:eth1"] - - endpoints: ["leaf63:eth2", "server683:eth1"] - - endpoints: ["leaf63:eth3", "server684:eth1"] - - endpoints: ["leaf63:eth4", "server685:eth1"] - - endpoints: ["leaf63:eth5", "server686:eth1"] - - endpoints: ["leaf63:eth6", "server687:eth1"] - - endpoints: ["leaf63:eth7", "server688:eth1"] - - endpoints: ["leaf63:eth8", "server689:eth1"] - - endpoints: ["leaf63:eth9", "server690:eth1"] - - endpoints: ["leaf63:eth10", "server691:eth1"] - - endpoints: ["leaf63:eth11", "server692:eth1"] - - endpoints: ["leaf63:eth12", "server693:eth1"] - - endpoints: ["leaf64:eth2", "server694:eth1"] - - endpoints: ["leaf64:eth3", "server695:eth1"] - - endpoints: ["leaf64:eth4", "server696:eth1"] - - endpoints: ["leaf64:eth5", "server697:eth1"] - - endpoints: ["leaf64:eth6", "server698:eth1"] - - endpoints: ["leaf64:eth7", "server699:eth1"] - - endpoints: ["leaf64:eth8", "server700:eth1"] - - endpoints: ["leaf64:eth9", "server701:eth1"] - - endpoints: ["leaf64:eth10", "server702:eth1"] - - endpoints: ["leaf64:eth11", "server703:eth1"] - - endpoints: ["leaf64:eth12", "server704:eth1"] - - endpoints: ["leaf65:eth2", "server705:eth1"] - - endpoints: ["leaf65:eth3", "server706:eth1"] - - endpoints: ["leaf65:eth4", "server707:eth1"] - - endpoints: ["leaf65:eth5", "server708:eth1"] - - endpoints: ["leaf65:eth6", "server709:eth1"] - - endpoints: ["leaf65:eth7", "server710:eth1"] - - endpoints: ["leaf65:eth8", "server711:eth1"] - - endpoints: ["leaf65:eth9", "server712:eth1"] - - endpoints: ["leaf65:eth10", "server713:eth1"] - - endpoints: ["leaf65:eth11", "server714:eth1"] - - endpoints: ["leaf65:eth12", "server715:eth1"] - - endpoints: ["leaf66:eth2", "server716:eth1"] - - endpoints: ["leaf66:eth3", "server717:eth1"] - - endpoints: ["leaf66:eth4", "server718:eth1"] - - endpoints: ["leaf66:eth5", "server719:eth1"] - - endpoints: ["leaf66:eth6", "server720:eth1"] - - endpoints: ["leaf66:eth7", "server721:eth1"] - - endpoints: ["leaf66:eth8", "server722:eth1"] - - endpoints: ["leaf66:eth9", "server723:eth1"] - - endpoints: ["leaf66:eth10", "server724:eth1"] - - endpoints: ["leaf66:eth11", "server725:eth1"] - - endpoints: ["leaf66:eth12", "server726:eth1"] - - endpoints: ["leaf67:eth2", "server727:eth1"] - - endpoints: ["leaf67:eth3", "server728:eth1"] - - endpoints: ["leaf67:eth4", "server729:eth1"] - - endpoints: ["leaf67:eth5", "server730:eth1"] - - endpoints: ["leaf67:eth6", "server731:eth1"] - - endpoints: ["leaf67:eth7", "server732:eth1"] - - endpoints: ["leaf67:eth8", "server733:eth1"] - - endpoints: ["leaf67:eth9", "server734:eth1"] - - endpoints: ["leaf67:eth10", "server735:eth1"] - - endpoints: ["leaf67:eth11", "server736:eth1"] - - endpoints: ["leaf67:eth12", "server737:eth1"] - - endpoints: ["leaf68:eth2", "server738:eth1"] - - endpoints: ["leaf68:eth3", "server739:eth1"] - - endpoints: ["leaf68:eth4", "server740:eth1"] - - endpoints: ["leaf68:eth5", "server741:eth1"] - - endpoints: ["leaf68:eth6", "server742:eth1"] - - endpoints: ["leaf68:eth7", "server743:eth1"] - - endpoints: ["leaf68:eth8", "server744:eth1"] - - endpoints: ["leaf68:eth9", "server745:eth1"] - - endpoints: ["leaf68:eth10", "server746:eth1"] - - endpoints: ["leaf68:eth11", "server747:eth1"] - - endpoints: ["leaf68:eth12", "server748:eth1"] - - endpoints: ["leaf69:eth2", "server749:eth1"] - - endpoints: ["leaf69:eth3", "server750:eth1"] - - endpoints: ["leaf69:eth4", "server751:eth1"] - - endpoints: ["leaf69:eth5", "server752:eth1"] - - endpoints: ["leaf69:eth6", "server753:eth1"] - - endpoints: ["leaf69:eth7", "server754:eth1"] - - endpoints: ["leaf69:eth8", "server755:eth1"] - - endpoints: ["leaf69:eth9", "server756:eth1"] - - endpoints: ["leaf69:eth10", "server757:eth1"] - - endpoints: ["leaf69:eth11", "server758:eth1"] - - endpoints: ["leaf69:eth12", "server759:eth1"] - - endpoints: ["leaf70:eth2", "server760:eth1"] - - endpoints: ["leaf70:eth3", "server761:eth1"] - - endpoints: ["leaf70:eth4", "server762:eth1"] - - endpoints: ["leaf70:eth5", "server763:eth1"] - - endpoints: ["leaf70:eth6", "server764:eth1"] - - endpoints: ["leaf70:eth7", "server765:eth1"] - - endpoints: ["leaf70:eth8", "server766:eth1"] - - endpoints: ["leaf70:eth9", "server767:eth1"] - - endpoints: ["leaf70:eth10", "server768:eth1"] - - endpoints: ["leaf70:eth11", "server769:eth1"] - - endpoints: ["leaf70:eth12", "server770:eth1"] - - endpoints: ["leaf71:eth2", "server771:eth1"] - - endpoints: ["leaf71:eth3", "server772:eth1"] - - endpoints: ["leaf71:eth4", "server773:eth1"] - - endpoints: ["leaf71:eth5", "server774:eth1"] - - endpoints: ["leaf71:eth6", "server775:eth1"] - - endpoints: ["leaf71:eth7", "server776:eth1"] - - endpoints: ["leaf71:eth8", "server777:eth1"] - - endpoints: ["leaf71:eth9", "server778:eth1"] - - endpoints: ["leaf71:eth10", "server779:eth1"] - - endpoints: ["leaf71:eth11", "server780:eth1"] - - endpoints: ["leaf71:eth12", "server781:eth1"] - - endpoints: ["leaf72:eth2", "server782:eth1"] - - endpoints: ["leaf72:eth3", "server783:eth1"] - - endpoints: ["leaf72:eth4", "server784:eth1"] - - endpoints: ["leaf72:eth5", "server785:eth1"] - - endpoints: ["leaf72:eth6", "server786:eth1"] - - endpoints: ["leaf72:eth7", "server787:eth1"] - - endpoints: ["leaf72:eth8", "server788:eth1"] - - endpoints: ["leaf72:eth9", "server789:eth1"] - - endpoints: ["leaf72:eth10", "server790:eth1"] - - endpoints: ["leaf72:eth11", "server791:eth1"] - - endpoints: ["leaf72:eth12", "server792:eth1"] - - endpoints: ["leaf73:eth2", "server793:eth1"] - - endpoints: ["leaf73:eth3", "server794:eth1"] - - endpoints: ["leaf73:eth4", "server795:eth1"] - - endpoints: ["leaf73:eth5", "server796:eth1"] - - endpoints: ["leaf73:eth6", "server797:eth1"] - - endpoints: ["leaf73:eth7", "server798:eth1"] - - endpoints: ["leaf73:eth8", "server799:eth1"] - - endpoints: ["leaf73:eth9", "server800:eth1"] - - endpoints: ["leaf73:eth10", "server801:eth1"] - - endpoints: ["leaf73:eth11", "server802:eth1"] - - endpoints: ["leaf73:eth12", "server803:eth1"] - - endpoints: ["leaf74:eth2", "server804:eth1"] - - endpoints: ["leaf74:eth3", "server805:eth1"] - - endpoints: ["leaf74:eth4", "server806:eth1"] - - endpoints: ["leaf74:eth5", "server807:eth1"] - - endpoints: ["leaf74:eth6", "server808:eth1"] - - endpoints: ["leaf74:eth7", "server809:eth1"] - - endpoints: ["leaf74:eth8", "server810:eth1"] - - endpoints: ["leaf74:eth9", "server811:eth1"] - - endpoints: ["leaf74:eth10", "server812:eth1"] - - endpoints: ["leaf74:eth11", "server813:eth1"] - - endpoints: ["leaf74:eth12", "server814:eth1"] - - endpoints: ["leaf75:eth2", "server815:eth1"] - - endpoints: ["leaf75:eth3", "server816:eth1"] - - endpoints: ["leaf75:eth4", "server817:eth1"] - - endpoints: ["leaf75:eth5", "server818:eth1"] - - endpoints: ["leaf75:eth6", "server819:eth1"] - - endpoints: ["leaf75:eth7", "server820:eth1"] - - endpoints: ["leaf75:eth8", "server821:eth1"] - - endpoints: ["leaf75:eth9", "server822:eth1"] - - endpoints: ["leaf75:eth10", "server823:eth1"] - - endpoints: ["leaf75:eth11", "server824:eth1"] - - endpoints: ["leaf75:eth12", "server825:eth1"] - - endpoints: ["leaf76:eth2", "server826:eth1"] - - endpoints: ["leaf76:eth3", "server827:eth1"] - - endpoints: ["leaf76:eth4", "server828:eth1"] - - endpoints: ["leaf76:eth5", "server829:eth1"] - - endpoints: ["leaf76:eth6", "server830:eth1"] - - endpoints: ["leaf76:eth7", "server831:eth1"] - - endpoints: ["leaf76:eth8", "server832:eth1"] - - endpoints: ["leaf76:eth9", "server833:eth1"] - - endpoints: ["leaf76:eth10", "server834:eth1"] - - endpoints: ["leaf76:eth11", "server835:eth1"] - - endpoints: ["leaf76:eth12", "server836:eth1"] - - endpoints: ["leaf77:eth2", "server837:eth1"] - - endpoints: ["leaf77:eth3", "server838:eth1"] - - endpoints: ["leaf77:eth4", "server839:eth1"] - - endpoints: ["leaf77:eth5", "server840:eth1"] - - endpoints: ["leaf77:eth6", "server841:eth1"] - - endpoints: ["leaf77:eth7", "server842:eth1"] - - endpoints: ["leaf77:eth8", "server843:eth1"] - - endpoints: ["leaf77:eth9", "server844:eth1"] - - endpoints: ["leaf77:eth10", "server845:eth1"] - - endpoints: ["leaf77:eth11", "server846:eth1"] - - endpoints: ["leaf77:eth12", "server847:eth1"] - - endpoints: ["leaf78:eth2", "server848:eth1"] - - endpoints: ["leaf78:eth3", "server849:eth1"] - - endpoints: ["leaf78:eth4", "server850:eth1"] - - endpoints: ["leaf78:eth5", "server851:eth1"] - - endpoints: ["leaf78:eth6", "server852:eth1"] - - endpoints: ["leaf78:eth7", "server853:eth1"] - - endpoints: ["leaf78:eth8", "server854:eth1"] - - endpoints: ["leaf78:eth9", "server855:eth1"] - - endpoints: ["leaf78:eth10", "server856:eth1"] - - endpoints: ["leaf78:eth11", "server857:eth1"] - - endpoints: ["leaf78:eth12", "server858:eth1"] - - endpoints: ["leaf79:eth2", "server859:eth1"] - - endpoints: ["leaf79:eth3", "server860:eth1"] - - endpoints: ["leaf79:eth4", "server861:eth1"] - - endpoints: ["leaf79:eth5", "server862:eth1"] - - endpoints: ["leaf79:eth6", "server863:eth1"] - - endpoints: ["leaf79:eth7", "server864:eth1"] - - endpoints: ["leaf79:eth8", "server865:eth1"] - - endpoints: ["leaf79:eth9", "server866:eth1"] - - endpoints: ["leaf79:eth10", "server867:eth1"] - - endpoints: ["leaf79:eth11", "server868:eth1"] - - endpoints: ["leaf79:eth12", "server869:eth1"] - - endpoints: ["leaf80:eth2", "server870:eth1"] - - endpoints: ["leaf80:eth3", "server871:eth1"] - - endpoints: ["leaf80:eth4", "server872:eth1"] - - endpoints: ["leaf80:eth5", "server873:eth1"] - - endpoints: ["leaf80:eth6", "server874:eth1"] - - endpoints: ["leaf80:eth7", "server875:eth1"] - - endpoints: ["leaf80:eth8", "server876:eth1"] - - endpoints: ["leaf80:eth9", "server877:eth1"] - - endpoints: ["leaf80:eth10", "server878:eth1"] - - endpoints: ["leaf80:eth11", "server879:eth1"] - - endpoints: ["leaf80:eth12", "server880:eth1"] diff --git a/dev/topologies-original/large-1000.clab.yml.annotations.json b/dev/topologies-original/large-1000.clab.yml.annotations.json deleted file mode 100644 index 4a45c1948..000000000 --- a/dev/topologies-original/large-1000.clab.yml.annotations.json +++ /dev/null @@ -1,9551 +0,0 @@ -{ - "nodeAnnotations": [ - { - "id": "border1", - "position": { - "x": 26880, - "y": 50 - }, - "group": "group-border", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "border2", - "position": { - "x": 26940, - "y": 50 - }, - "group": "group-border", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "border3", - "position": { - "x": 27000, - "y": 50 - }, - "group": "group-border", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "border4", - "position": { - "x": 27060, - "y": 50 - }, - "group": "group-border", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine1", - "position": { - "x": 26520, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine2", - "position": { - "x": 26580, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine3", - "position": { - "x": 26640, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine4", - "position": { - "x": 26700, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine5", - "position": { - "x": 26760, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine6", - "position": { - "x": 26820, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine7", - "position": { - "x": 26880, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine8", - "position": { - "x": 26940, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine9", - "position": { - "x": 27000, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine10", - "position": { - "x": 27060, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine11", - "position": { - "x": 27120, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine12", - "position": { - "x": 27180, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine13", - "position": { - "x": 27240, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine14", - "position": { - "x": 27300, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine15", - "position": { - "x": 27360, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "spine16", - "position": { - "x": 27420, - "y": 170 - }, - "group": "group-spine", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf1", - "position": { - "x": 24600, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf2", - "position": { - "x": 24660, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf3", - "position": { - "x": 24720, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf4", - "position": { - "x": 24780, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf5", - "position": { - "x": 24840, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf6", - "position": { - "x": 24900, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf7", - "position": { - "x": 24960, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf8", - "position": { - "x": 25020, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf9", - "position": { - "x": 25080, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf10", - "position": { - "x": 25140, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf11", - "position": { - "x": 25200, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf12", - "position": { - "x": 25260, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf13", - "position": { - "x": 25320, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf14", - "position": { - "x": 25380, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf15", - "position": { - "x": 25440, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf16", - "position": { - "x": 25500, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf17", - "position": { - "x": 25560, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf18", - "position": { - "x": 25620, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf19", - "position": { - "x": 25680, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf20", - "position": { - "x": 25740, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf21", - "position": { - "x": 25800, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf22", - "position": { - "x": 25860, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf23", - "position": { - "x": 25920, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf24", - "position": { - "x": 25980, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf25", - "position": { - "x": 26040, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf26", - "position": { - "x": 26100, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf27", - "position": { - "x": 26160, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf28", - "position": { - "x": 26220, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf29", - "position": { - "x": 26280, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf30", - "position": { - "x": 26340, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf31", - "position": { - "x": 26400, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf32", - "position": { - "x": 26460, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf33", - "position": { - "x": 26520, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf34", - "position": { - "x": 26580, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf35", - "position": { - "x": 26640, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf36", - "position": { - "x": 26700, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf37", - "position": { - "x": 26760, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf38", - "position": { - "x": 26820, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf39", - "position": { - "x": 26880, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf40", - "position": { - "x": 26940, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf41", - "position": { - "x": 27000, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf42", - "position": { - "x": 27060, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf43", - "position": { - "x": 27120, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf44", - "position": { - "x": 27180, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf45", - "position": { - "x": 27240, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf46", - "position": { - "x": 27300, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf47", - "position": { - "x": 27360, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf48", - "position": { - "x": 27420, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf49", - "position": { - "x": 27480, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf50", - "position": { - "x": 27540, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf51", - "position": { - "x": 27600, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf52", - "position": { - "x": 27660, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf53", - "position": { - "x": 27720, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf54", - "position": { - "x": 27780, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf55", - "position": { - "x": 27840, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf56", - "position": { - "x": 27900, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf57", - "position": { - "x": 27960, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf58", - "position": { - "x": 28020, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf59", - "position": { - "x": 28080, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf60", - "position": { - "x": 28140, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf61", - "position": { - "x": 28200, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf62", - "position": { - "x": 28260, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf63", - "position": { - "x": 28320, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf64", - "position": { - "x": 28380, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf65", - "position": { - "x": 28440, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf66", - "position": { - "x": 28500, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf67", - "position": { - "x": 28560, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf68", - "position": { - "x": 28620, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf69", - "position": { - "x": 28680, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf70", - "position": { - "x": 28740, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf71", - "position": { - "x": 28800, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf72", - "position": { - "x": 28860, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf73", - "position": { - "x": 28920, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf74", - "position": { - "x": 28980, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf75", - "position": { - "x": 29040, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf76", - "position": { - "x": 29100, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf77", - "position": { - "x": 29160, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf78", - "position": { - "x": 29220, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf79", - "position": { - "x": 29280, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "leaf80", - "position": { - "x": 29340, - "y": 290 - }, - "group": "group-leaf", - "level": "1", - "interfacePattern": "eth{n}" - }, - { - "id": "server1", - "position": { - "x": 25500, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server2", - "position": { - "x": 25560, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server3", - "position": { - "x": 25620, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server4", - "position": { - "x": 25680, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server5", - "position": { - "x": 25740, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server6", - "position": { - "x": 25800, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server7", - "position": { - "x": 25860, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server8", - "position": { - "x": 25920, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server9", - "position": { - "x": 25980, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server10", - "position": { - "x": 26040, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server11", - "position": { - "x": 26100, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server12", - "position": { - "x": 26160, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server13", - "position": { - "x": 26220, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server14", - "position": { - "x": 26280, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server15", - "position": { - "x": 26340, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server16", - "position": { - "x": 26400, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server17", - "position": { - "x": 26460, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server18", - "position": { - "x": 26520, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server19", - "position": { - "x": 26580, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server20", - "position": { - "x": 26640, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server21", - "position": { - "x": 26700, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server22", - "position": { - "x": 26760, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server23", - "position": { - "x": 26820, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server24", - "position": { - "x": 26880, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server25", - "position": { - "x": 26940, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server26", - "position": { - "x": 27000, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server27", - "position": { - "x": 27060, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server28", - "position": { - "x": 27120, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server29", - "position": { - "x": 27180, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server30", - "position": { - "x": 27240, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server31", - "position": { - "x": 27300, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server32", - "position": { - "x": 27360, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server33", - "position": { - "x": 27420, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server34", - "position": { - "x": 27480, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server35", - "position": { - "x": 27540, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server36", - "position": { - "x": 27600, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server37", - "position": { - "x": 27660, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server38", - "position": { - "x": 27720, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server39", - "position": { - "x": 27780, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server40", - "position": { - "x": 27840, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server41", - "position": { - "x": 27900, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server42", - "position": { - "x": 27960, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server43", - "position": { - "x": 28020, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server44", - "position": { - "x": 28080, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server45", - "position": { - "x": 28140, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server46", - "position": { - "x": 28200, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server47", - "position": { - "x": 28260, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server48", - "position": { - "x": 28320, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server49", - "position": { - "x": 28380, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server50", - "position": { - "x": 28440, - "y": 410 - }, - "group": "group-servers-row1", - "level": "1" - }, - { - "id": "server51", - "position": { - "x": 25500, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server52", - "position": { - "x": 25560, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server53", - "position": { - "x": 25620, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server54", - "position": { - "x": 25680, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server55", - "position": { - "x": 25740, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server56", - "position": { - "x": 25800, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server57", - "position": { - "x": 25860, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server58", - "position": { - "x": 25920, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server59", - "position": { - "x": 25980, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server60", - "position": { - "x": 26040, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server61", - "position": { - "x": 26100, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server62", - "position": { - "x": 26160, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server63", - "position": { - "x": 26220, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server64", - "position": { - "x": 26280, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server65", - "position": { - "x": 26340, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server66", - "position": { - "x": 26400, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server67", - "position": { - "x": 26460, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server68", - "position": { - "x": 26520, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server69", - "position": { - "x": 26580, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server70", - "position": { - "x": 26640, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server71", - "position": { - "x": 26700, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server72", - "position": { - "x": 26760, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server73", - "position": { - "x": 26820, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server74", - "position": { - "x": 26880, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server75", - "position": { - "x": 26940, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server76", - "position": { - "x": 27000, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server77", - "position": { - "x": 27060, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server78", - "position": { - "x": 27120, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server79", - "position": { - "x": 27180, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server80", - "position": { - "x": 27240, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server81", - "position": { - "x": 27300, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server82", - "position": { - "x": 27360, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server83", - "position": { - "x": 27420, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server84", - "position": { - "x": 27480, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server85", - "position": { - "x": 27540, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server86", - "position": { - "x": 27600, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server87", - "position": { - "x": 27660, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server88", - "position": { - "x": 27720, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server89", - "position": { - "x": 27780, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server90", - "position": { - "x": 27840, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server91", - "position": { - "x": 27900, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server92", - "position": { - "x": 27960, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server93", - "position": { - "x": 28020, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server94", - "position": { - "x": 28080, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server95", - "position": { - "x": 28140, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server96", - "position": { - "x": 28200, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server97", - "position": { - "x": 28260, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server98", - "position": { - "x": 28320, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server99", - "position": { - "x": 28380, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server100", - "position": { - "x": 28440, - "y": 490 - }, - "group": "group-servers-row2", - "level": "1" - }, - { - "id": "server101", - "position": { - "x": 25500, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server102", - "position": { - "x": 25560, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server103", - "position": { - "x": 25620, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server104", - "position": { - "x": 25680, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server105", - "position": { - "x": 25740, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server106", - "position": { - "x": 25800, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server107", - "position": { - "x": 25860, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server108", - "position": { - "x": 25920, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server109", - "position": { - "x": 25980, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server110", - "position": { - "x": 26040, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server111", - "position": { - "x": 26100, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server112", - "position": { - "x": 26160, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server113", - "position": { - "x": 26220, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server114", - "position": { - "x": 26280, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server115", - "position": { - "x": 26340, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server116", - "position": { - "x": 26400, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server117", - "position": { - "x": 26460, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server118", - "position": { - "x": 26520, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server119", - "position": { - "x": 26580, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server120", - "position": { - "x": 26640, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server121", - "position": { - "x": 26700, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server122", - "position": { - "x": 26760, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server123", - "position": { - "x": 26820, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server124", - "position": { - "x": 26880, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server125", - "position": { - "x": 26940, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server126", - "position": { - "x": 27000, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server127", - "position": { - "x": 27060, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server128", - "position": { - "x": 27120, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server129", - "position": { - "x": 27180, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server130", - "position": { - "x": 27240, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server131", - "position": { - "x": 27300, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server132", - "position": { - "x": 27360, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server133", - "position": { - "x": 27420, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server134", - "position": { - "x": 27480, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server135", - "position": { - "x": 27540, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server136", - "position": { - "x": 27600, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server137", - "position": { - "x": 27660, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server138", - "position": { - "x": 27720, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server139", - "position": { - "x": 27780, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server140", - "position": { - "x": 27840, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server141", - "position": { - "x": 27900, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server142", - "position": { - "x": 27960, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server143", - "position": { - "x": 28020, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server144", - "position": { - "x": 28080, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server145", - "position": { - "x": 28140, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server146", - "position": { - "x": 28200, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server147", - "position": { - "x": 28260, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server148", - "position": { - "x": 28320, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server149", - "position": { - "x": 28380, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server150", - "position": { - "x": 28440, - "y": 570 - }, - "group": "group-servers-row3", - "level": "1" - }, - { - "id": "server151", - "position": { - "x": 25500, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server152", - "position": { - "x": 25560, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server153", - "position": { - "x": 25620, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server154", - "position": { - "x": 25680, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server155", - "position": { - "x": 25740, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server156", - "position": { - "x": 25800, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server157", - "position": { - "x": 25860, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server158", - "position": { - "x": 25920, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server159", - "position": { - "x": 25980, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server160", - "position": { - "x": 26040, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server161", - "position": { - "x": 26100, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server162", - "position": { - "x": 26160, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server163", - "position": { - "x": 26220, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server164", - "position": { - "x": 26280, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server165", - "position": { - "x": 26340, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server166", - "position": { - "x": 26400, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server167", - "position": { - "x": 26460, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server168", - "position": { - "x": 26520, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server169", - "position": { - "x": 26580, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server170", - "position": { - "x": 26640, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server171", - "position": { - "x": 26700, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server172", - "position": { - "x": 26760, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server173", - "position": { - "x": 26820, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server174", - "position": { - "x": 26880, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server175", - "position": { - "x": 26940, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server176", - "position": { - "x": 27000, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server177", - "position": { - "x": 27060, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server178", - "position": { - "x": 27120, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server179", - "position": { - "x": 27180, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server180", - "position": { - "x": 27240, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server181", - "position": { - "x": 27300, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server182", - "position": { - "x": 27360, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server183", - "position": { - "x": 27420, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server184", - "position": { - "x": 27480, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server185", - "position": { - "x": 27540, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server186", - "position": { - "x": 27600, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server187", - "position": { - "x": 27660, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server188", - "position": { - "x": 27720, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server189", - "position": { - "x": 27780, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server190", - "position": { - "x": 27840, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server191", - "position": { - "x": 27900, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server192", - "position": { - "x": 27960, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server193", - "position": { - "x": 28020, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server194", - "position": { - "x": 28080, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server195", - "position": { - "x": 28140, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server196", - "position": { - "x": 28200, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server197", - "position": { - "x": 28260, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server198", - "position": { - "x": 28320, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server199", - "position": { - "x": 28380, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server200", - "position": { - "x": 28440, - "y": 650 - }, - "group": "group-servers-row4", - "level": "1" - }, - { - "id": "server201", - "position": { - "x": 25500, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server202", - "position": { - "x": 25560, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server203", - "position": { - "x": 25620, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server204", - "position": { - "x": 25680, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server205", - "position": { - "x": 25740, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server206", - "position": { - "x": 25800, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server207", - "position": { - "x": 25860, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server208", - "position": { - "x": 25920, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server209", - "position": { - "x": 25980, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server210", - "position": { - "x": 26040, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server211", - "position": { - "x": 26100, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server212", - "position": { - "x": 26160, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server213", - "position": { - "x": 26220, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server214", - "position": { - "x": 26280, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server215", - "position": { - "x": 26340, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server216", - "position": { - "x": 26400, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server217", - "position": { - "x": 26460, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server218", - "position": { - "x": 26520, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server219", - "position": { - "x": 26580, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server220", - "position": { - "x": 26640, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server221", - "position": { - "x": 26700, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server222", - "position": { - "x": 26760, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server223", - "position": { - "x": 26820, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server224", - "position": { - "x": 26880, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server225", - "position": { - "x": 26940, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server226", - "position": { - "x": 27000, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server227", - "position": { - "x": 27060, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server228", - "position": { - "x": 27120, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server229", - "position": { - "x": 27180, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server230", - "position": { - "x": 27240, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server231", - "position": { - "x": 27300, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server232", - "position": { - "x": 27360, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server233", - "position": { - "x": 27420, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server234", - "position": { - "x": 27480, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server235", - "position": { - "x": 27540, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server236", - "position": { - "x": 27600, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server237", - "position": { - "x": 27660, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server238", - "position": { - "x": 27720, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server239", - "position": { - "x": 27780, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server240", - "position": { - "x": 27840, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server241", - "position": { - "x": 27900, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server242", - "position": { - "x": 27960, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server243", - "position": { - "x": 28020, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server244", - "position": { - "x": 28080, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server245", - "position": { - "x": 28140, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server246", - "position": { - "x": 28200, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server247", - "position": { - "x": 28260, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server248", - "position": { - "x": 28320, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server249", - "position": { - "x": 28380, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server250", - "position": { - "x": 28440, - "y": 730 - }, - "group": "group-servers-row5", - "level": "1" - }, - { - "id": "server251", - "position": { - "x": 25500, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server252", - "position": { - "x": 25560, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server253", - "position": { - "x": 25620, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server254", - "position": { - "x": 25680, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server255", - "position": { - "x": 25740, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server256", - "position": { - "x": 25800, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server257", - "position": { - "x": 25860, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server258", - "position": { - "x": 25920, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server259", - "position": { - "x": 25980, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server260", - "position": { - "x": 26040, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server261", - "position": { - "x": 26100, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server262", - "position": { - "x": 26160, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server263", - "position": { - "x": 26220, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server264", - "position": { - "x": 26280, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server265", - "position": { - "x": 26340, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server266", - "position": { - "x": 26400, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server267", - "position": { - "x": 26460, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server268", - "position": { - "x": 26520, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server269", - "position": { - "x": 26580, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server270", - "position": { - "x": 26640, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server271", - "position": { - "x": 26700, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server272", - "position": { - "x": 26760, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server273", - "position": { - "x": 26820, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server274", - "position": { - "x": 26880, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server275", - "position": { - "x": 26940, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server276", - "position": { - "x": 27000, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server277", - "position": { - "x": 27060, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server278", - "position": { - "x": 27120, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server279", - "position": { - "x": 27180, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server280", - "position": { - "x": 27240, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server281", - "position": { - "x": 27300, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server282", - "position": { - "x": 27360, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server283", - "position": { - "x": 27420, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server284", - "position": { - "x": 27480, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server285", - "position": { - "x": 27540, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server286", - "position": { - "x": 27600, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server287", - "position": { - "x": 27660, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server288", - "position": { - "x": 27720, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server289", - "position": { - "x": 27780, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server290", - "position": { - "x": 27840, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server291", - "position": { - "x": 27900, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server292", - "position": { - "x": 27960, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server293", - "position": { - "x": 28020, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server294", - "position": { - "x": 28080, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server295", - "position": { - "x": 28140, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server296", - "position": { - "x": 28200, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server297", - "position": { - "x": 28260, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server298", - "position": { - "x": 28320, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server299", - "position": { - "x": 28380, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server300", - "position": { - "x": 28440, - "y": 810 - }, - "group": "group-servers-row6", - "level": "1" - }, - { - "id": "server301", - "position": { - "x": 25500, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server302", - "position": { - "x": 25560, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server303", - "position": { - "x": 25620, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server304", - "position": { - "x": 25680, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server305", - "position": { - "x": 25740, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server306", - "position": { - "x": 25800, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server307", - "position": { - "x": 25860, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server308", - "position": { - "x": 25920, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server309", - "position": { - "x": 25980, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server310", - "position": { - "x": 26040, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server311", - "position": { - "x": 26100, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server312", - "position": { - "x": 26160, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server313", - "position": { - "x": 26220, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server314", - "position": { - "x": 26280, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server315", - "position": { - "x": 26340, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server316", - "position": { - "x": 26400, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server317", - "position": { - "x": 26460, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server318", - "position": { - "x": 26520, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server319", - "position": { - "x": 26580, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server320", - "position": { - "x": 26640, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server321", - "position": { - "x": 26700, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server322", - "position": { - "x": 26760, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server323", - "position": { - "x": 26820, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server324", - "position": { - "x": 26880, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server325", - "position": { - "x": 26940, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server326", - "position": { - "x": 27000, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server327", - "position": { - "x": 27060, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server328", - "position": { - "x": 27120, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server329", - "position": { - "x": 27180, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server330", - "position": { - "x": 27240, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server331", - "position": { - "x": 27300, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server332", - "position": { - "x": 27360, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server333", - "position": { - "x": 27420, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server334", - "position": { - "x": 27480, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server335", - "position": { - "x": 27540, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server336", - "position": { - "x": 27600, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server337", - "position": { - "x": 27660, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server338", - "position": { - "x": 27720, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server339", - "position": { - "x": 27780, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server340", - "position": { - "x": 27840, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server341", - "position": { - "x": 27900, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server342", - "position": { - "x": 27960, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server343", - "position": { - "x": 28020, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server344", - "position": { - "x": 28080, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server345", - "position": { - "x": 28140, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server346", - "position": { - "x": 28200, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server347", - "position": { - "x": 28260, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server348", - "position": { - "x": 28320, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server349", - "position": { - "x": 28380, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server350", - "position": { - "x": 28440, - "y": 890 - }, - "group": "group-servers-row7", - "level": "1" - }, - { - "id": "server351", - "position": { - "x": 25500, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server352", - "position": { - "x": 25560, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server353", - "position": { - "x": 25620, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server354", - "position": { - "x": 25680, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server355", - "position": { - "x": 25740, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server356", - "position": { - "x": 25800, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server357", - "position": { - "x": 25860, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server358", - "position": { - "x": 25920, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server359", - "position": { - "x": 25980, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server360", - "position": { - "x": 26040, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server361", - "position": { - "x": 26100, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server362", - "position": { - "x": 26160, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server363", - "position": { - "x": 26220, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server364", - "position": { - "x": 26280, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server365", - "position": { - "x": 26340, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server366", - "position": { - "x": 26400, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server367", - "position": { - "x": 26460, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server368", - "position": { - "x": 26520, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server369", - "position": { - "x": 26580, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server370", - "position": { - "x": 26640, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server371", - "position": { - "x": 26700, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server372", - "position": { - "x": 26760, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server373", - "position": { - "x": 26820, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server374", - "position": { - "x": 26880, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server375", - "position": { - "x": 26940, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server376", - "position": { - "x": 27000, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server377", - "position": { - "x": 27060, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server378", - "position": { - "x": 27120, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server379", - "position": { - "x": 27180, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server380", - "position": { - "x": 27240, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server381", - "position": { - "x": 27300, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server382", - "position": { - "x": 27360, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server383", - "position": { - "x": 27420, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server384", - "position": { - "x": 27480, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server385", - "position": { - "x": 27540, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server386", - "position": { - "x": 27600, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server387", - "position": { - "x": 27660, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server388", - "position": { - "x": 27720, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server389", - "position": { - "x": 27780, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server390", - "position": { - "x": 27840, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server391", - "position": { - "x": 27900, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server392", - "position": { - "x": 27960, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server393", - "position": { - "x": 28020, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server394", - "position": { - "x": 28080, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server395", - "position": { - "x": 28140, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server396", - "position": { - "x": 28200, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server397", - "position": { - "x": 28260, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server398", - "position": { - "x": 28320, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server399", - "position": { - "x": 28380, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server400", - "position": { - "x": 28440, - "y": 970 - }, - "group": "group-servers-row8", - "level": "1" - }, - { - "id": "server401", - "position": { - "x": 25500, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server402", - "position": { - "x": 25560, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server403", - "position": { - "x": 25620, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server404", - "position": { - "x": 25680, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server405", - "position": { - "x": 25740, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server406", - "position": { - "x": 25800, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server407", - "position": { - "x": 25860, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server408", - "position": { - "x": 25920, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server409", - "position": { - "x": 25980, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server410", - "position": { - "x": 26040, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server411", - "position": { - "x": 26100, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server412", - "position": { - "x": 26160, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server413", - "position": { - "x": 26220, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server414", - "position": { - "x": 26280, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server415", - "position": { - "x": 26340, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server416", - "position": { - "x": 26400, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server417", - "position": { - "x": 26460, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server418", - "position": { - "x": 26520, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server419", - "position": { - "x": 26580, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server420", - "position": { - "x": 26640, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server421", - "position": { - "x": 26700, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server422", - "position": { - "x": 26760, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server423", - "position": { - "x": 26820, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server424", - "position": { - "x": 26880, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server425", - "position": { - "x": 26940, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server426", - "position": { - "x": 27000, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server427", - "position": { - "x": 27060, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server428", - "position": { - "x": 27120, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server429", - "position": { - "x": 27180, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server430", - "position": { - "x": 27240, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server431", - "position": { - "x": 27300, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server432", - "position": { - "x": 27360, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server433", - "position": { - "x": 27420, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server434", - "position": { - "x": 27480, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server435", - "position": { - "x": 27540, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server436", - "position": { - "x": 27600, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server437", - "position": { - "x": 27660, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server438", - "position": { - "x": 27720, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server439", - "position": { - "x": 27780, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server440", - "position": { - "x": 27840, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server441", - "position": { - "x": 27900, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server442", - "position": { - "x": 27960, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server443", - "position": { - "x": 28020, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server444", - "position": { - "x": 28080, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server445", - "position": { - "x": 28140, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server446", - "position": { - "x": 28200, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server447", - "position": { - "x": 28260, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server448", - "position": { - "x": 28320, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server449", - "position": { - "x": 28380, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server450", - "position": { - "x": 28440, - "y": 1050 - }, - "group": "group-servers-row9", - "level": "1" - }, - { - "id": "server451", - "position": { - "x": 25500, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server452", - "position": { - "x": 25560, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server453", - "position": { - "x": 25620, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server454", - "position": { - "x": 25680, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server455", - "position": { - "x": 25740, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server456", - "position": { - "x": 25800, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server457", - "position": { - "x": 25860, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server458", - "position": { - "x": 25920, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server459", - "position": { - "x": 25980, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server460", - "position": { - "x": 26040, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server461", - "position": { - "x": 26100, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server462", - "position": { - "x": 26160, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server463", - "position": { - "x": 26220, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server464", - "position": { - "x": 26280, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server465", - "position": { - "x": 26340, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server466", - "position": { - "x": 26400, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server467", - "position": { - "x": 26460, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server468", - "position": { - "x": 26520, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server469", - "position": { - "x": 26580, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server470", - "position": { - "x": 26640, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server471", - "position": { - "x": 26700, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server472", - "position": { - "x": 26760, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server473", - "position": { - "x": 26820, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server474", - "position": { - "x": 26880, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server475", - "position": { - "x": 26940, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server476", - "position": { - "x": 27000, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server477", - "position": { - "x": 27060, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server478", - "position": { - "x": 27120, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server479", - "position": { - "x": 27180, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server480", - "position": { - "x": 27240, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server481", - "position": { - "x": 27300, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server482", - "position": { - "x": 27360, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server483", - "position": { - "x": 27420, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server484", - "position": { - "x": 27480, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server485", - "position": { - "x": 27540, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server486", - "position": { - "x": 27600, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server487", - "position": { - "x": 27660, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server488", - "position": { - "x": 27720, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server489", - "position": { - "x": 27780, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server490", - "position": { - "x": 27840, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server491", - "position": { - "x": 27900, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server492", - "position": { - "x": 27960, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server493", - "position": { - "x": 28020, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server494", - "position": { - "x": 28080, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server495", - "position": { - "x": 28140, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server496", - "position": { - "x": 28200, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server497", - "position": { - "x": 28260, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server498", - "position": { - "x": 28320, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server499", - "position": { - "x": 28380, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server500", - "position": { - "x": 28440, - "y": 1130 - }, - "group": "group-servers-row10", - "level": "1" - }, - { - "id": "server501", - "position": { - "x": 25500, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server502", - "position": { - "x": 25560, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server503", - "position": { - "x": 25620, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server504", - "position": { - "x": 25680, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server505", - "position": { - "x": 25740, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server506", - "position": { - "x": 25800, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server507", - "position": { - "x": 25860, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server508", - "position": { - "x": 25920, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server509", - "position": { - "x": 25980, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server510", - "position": { - "x": 26040, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server511", - "position": { - "x": 26100, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server512", - "position": { - "x": 26160, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server513", - "position": { - "x": 26220, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server514", - "position": { - "x": 26280, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server515", - "position": { - "x": 26340, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server516", - "position": { - "x": 26400, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server517", - "position": { - "x": 26460, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server518", - "position": { - "x": 26520, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server519", - "position": { - "x": 26580, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server520", - "position": { - "x": 26640, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server521", - "position": { - "x": 26700, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server522", - "position": { - "x": 26760, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server523", - "position": { - "x": 26820, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server524", - "position": { - "x": 26880, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server525", - "position": { - "x": 26940, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server526", - "position": { - "x": 27000, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server527", - "position": { - "x": 27060, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server528", - "position": { - "x": 27120, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server529", - "position": { - "x": 27180, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server530", - "position": { - "x": 27240, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server531", - "position": { - "x": 27300, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server532", - "position": { - "x": 27360, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server533", - "position": { - "x": 27420, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server534", - "position": { - "x": 27480, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server535", - "position": { - "x": 27540, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server536", - "position": { - "x": 27600, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server537", - "position": { - "x": 27660, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server538", - "position": { - "x": 27720, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server539", - "position": { - "x": 27780, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server540", - "position": { - "x": 27840, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server541", - "position": { - "x": 27900, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server542", - "position": { - "x": 27960, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server543", - "position": { - "x": 28020, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server544", - "position": { - "x": 28080, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server545", - "position": { - "x": 28140, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server546", - "position": { - "x": 28200, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server547", - "position": { - "x": 28260, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server548", - "position": { - "x": 28320, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server549", - "position": { - "x": 28380, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server550", - "position": { - "x": 28440, - "y": 1210 - }, - "group": "group-servers-row11", - "level": "1" - }, - { - "id": "server551", - "position": { - "x": 25500, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server552", - "position": { - "x": 25560, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server553", - "position": { - "x": 25620, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server554", - "position": { - "x": 25680, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server555", - "position": { - "x": 25740, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server556", - "position": { - "x": 25800, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server557", - "position": { - "x": 25860, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server558", - "position": { - "x": 25920, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server559", - "position": { - "x": 25980, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server560", - "position": { - "x": 26040, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server561", - "position": { - "x": 26100, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server562", - "position": { - "x": 26160, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server563", - "position": { - "x": 26220, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server564", - "position": { - "x": 26280, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server565", - "position": { - "x": 26340, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server566", - "position": { - "x": 26400, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server567", - "position": { - "x": 26460, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server568", - "position": { - "x": 26520, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server569", - "position": { - "x": 26580, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server570", - "position": { - "x": 26640, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server571", - "position": { - "x": 26700, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server572", - "position": { - "x": 26760, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server573", - "position": { - "x": 26820, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server574", - "position": { - "x": 26880, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server575", - "position": { - "x": 26940, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server576", - "position": { - "x": 27000, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server577", - "position": { - "x": 27060, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server578", - "position": { - "x": 27120, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server579", - "position": { - "x": 27180, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server580", - "position": { - "x": 27240, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server581", - "position": { - "x": 27300, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server582", - "position": { - "x": 27360, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server583", - "position": { - "x": 27420, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server584", - "position": { - "x": 27480, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server585", - "position": { - "x": 27540, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server586", - "position": { - "x": 27600, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server587", - "position": { - "x": 27660, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server588", - "position": { - "x": 27720, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server589", - "position": { - "x": 27780, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server590", - "position": { - "x": 27840, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server591", - "position": { - "x": 27900, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server592", - "position": { - "x": 27960, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server593", - "position": { - "x": 28020, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server594", - "position": { - "x": 28080, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server595", - "position": { - "x": 28140, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server596", - "position": { - "x": 28200, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server597", - "position": { - "x": 28260, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server598", - "position": { - "x": 28320, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server599", - "position": { - "x": 28380, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server600", - "position": { - "x": 28440, - "y": 1290 - }, - "group": "group-servers-row12", - "level": "1" - }, - { - "id": "server601", - "position": { - "x": 25500, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server602", - "position": { - "x": 25560, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server603", - "position": { - "x": 25620, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server604", - "position": { - "x": 25680, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server605", - "position": { - "x": 25740, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server606", - "position": { - "x": 25800, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server607", - "position": { - "x": 25860, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server608", - "position": { - "x": 25920, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server609", - "position": { - "x": 25980, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server610", - "position": { - "x": 26040, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server611", - "position": { - "x": 26100, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server612", - "position": { - "x": 26160, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server613", - "position": { - "x": 26220, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server614", - "position": { - "x": 26280, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server615", - "position": { - "x": 26340, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server616", - "position": { - "x": 26400, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server617", - "position": { - "x": 26460, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server618", - "position": { - "x": 26520, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server619", - "position": { - "x": 26580, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server620", - "position": { - "x": 26640, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server621", - "position": { - "x": 26700, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server622", - "position": { - "x": 26760, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server623", - "position": { - "x": 26820, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server624", - "position": { - "x": 26880, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server625", - "position": { - "x": 26940, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server626", - "position": { - "x": 27000, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server627", - "position": { - "x": 27060, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server628", - "position": { - "x": 27120, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server629", - "position": { - "x": 27180, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server630", - "position": { - "x": 27240, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server631", - "position": { - "x": 27300, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server632", - "position": { - "x": 27360, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server633", - "position": { - "x": 27420, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server634", - "position": { - "x": 27480, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server635", - "position": { - "x": 27540, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server636", - "position": { - "x": 27600, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server637", - "position": { - "x": 27660, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server638", - "position": { - "x": 27720, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server639", - "position": { - "x": 27780, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server640", - "position": { - "x": 27840, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server641", - "position": { - "x": 27900, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server642", - "position": { - "x": 27960, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server643", - "position": { - "x": 28020, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server644", - "position": { - "x": 28080, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server645", - "position": { - "x": 28140, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server646", - "position": { - "x": 28200, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server647", - "position": { - "x": 28260, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server648", - "position": { - "x": 28320, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server649", - "position": { - "x": 28380, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server650", - "position": { - "x": 28440, - "y": 1370 - }, - "group": "group-servers-row13", - "level": "1" - }, - { - "id": "server651", - "position": { - "x": 25500, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server652", - "position": { - "x": 25560, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server653", - "position": { - "x": 25620, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server654", - "position": { - "x": 25680, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server655", - "position": { - "x": 25740, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server656", - "position": { - "x": 25800, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server657", - "position": { - "x": 25860, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server658", - "position": { - "x": 25920, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server659", - "position": { - "x": 25980, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server660", - "position": { - "x": 26040, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server661", - "position": { - "x": 26100, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server662", - "position": { - "x": 26160, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server663", - "position": { - "x": 26220, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server664", - "position": { - "x": 26280, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server665", - "position": { - "x": 26340, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server666", - "position": { - "x": 26400, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server667", - "position": { - "x": 26460, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server668", - "position": { - "x": 26520, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server669", - "position": { - "x": 26580, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server670", - "position": { - "x": 26640, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server671", - "position": { - "x": 26700, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server672", - "position": { - "x": 26760, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server673", - "position": { - "x": 26820, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server674", - "position": { - "x": 26880, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server675", - "position": { - "x": 26940, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server676", - "position": { - "x": 27000, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server677", - "position": { - "x": 27060, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server678", - "position": { - "x": 27120, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server679", - "position": { - "x": 27180, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server680", - "position": { - "x": 27240, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server681", - "position": { - "x": 27300, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server682", - "position": { - "x": 27360, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server683", - "position": { - "x": 27420, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server684", - "position": { - "x": 27480, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server685", - "position": { - "x": 27540, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server686", - "position": { - "x": 27600, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server687", - "position": { - "x": 27660, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server688", - "position": { - "x": 27720, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server689", - "position": { - "x": 27780, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server690", - "position": { - "x": 27840, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server691", - "position": { - "x": 27900, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server692", - "position": { - "x": 27960, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server693", - "position": { - "x": 28020, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server694", - "position": { - "x": 28080, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server695", - "position": { - "x": 28140, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server696", - "position": { - "x": 28200, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server697", - "position": { - "x": 28260, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server698", - "position": { - "x": 28320, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server699", - "position": { - "x": 28380, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server700", - "position": { - "x": 28440, - "y": 1450 - }, - "group": "group-servers-row14", - "level": "1" - }, - { - "id": "server701", - "position": { - "x": 25500, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server702", - "position": { - "x": 25560, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server703", - "position": { - "x": 25620, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server704", - "position": { - "x": 25680, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server705", - "position": { - "x": 25740, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server706", - "position": { - "x": 25800, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server707", - "position": { - "x": 25860, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server708", - "position": { - "x": 25920, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server709", - "position": { - "x": 25980, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server710", - "position": { - "x": 26040, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server711", - "position": { - "x": 26100, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server712", - "position": { - "x": 26160, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server713", - "position": { - "x": 26220, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server714", - "position": { - "x": 26280, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server715", - "position": { - "x": 26340, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server716", - "position": { - "x": 26400, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server717", - "position": { - "x": 26460, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server718", - "position": { - "x": 26520, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server719", - "position": { - "x": 26580, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server720", - "position": { - "x": 26640, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server721", - "position": { - "x": 26700, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server722", - "position": { - "x": 26760, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server723", - "position": { - "x": 26820, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server724", - "position": { - "x": 26880, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server725", - "position": { - "x": 26940, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server726", - "position": { - "x": 27000, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server727", - "position": { - "x": 27060, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server728", - "position": { - "x": 27120, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server729", - "position": { - "x": 27180, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server730", - "position": { - "x": 27240, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server731", - "position": { - "x": 27300, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server732", - "position": { - "x": 27360, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server733", - "position": { - "x": 27420, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server734", - "position": { - "x": 27480, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server735", - "position": { - "x": 27540, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server736", - "position": { - "x": 27600, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server737", - "position": { - "x": 27660, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server738", - "position": { - "x": 27720, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server739", - "position": { - "x": 27780, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server740", - "position": { - "x": 27840, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server741", - "position": { - "x": 27900, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server742", - "position": { - "x": 27960, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server743", - "position": { - "x": 28020, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server744", - "position": { - "x": 28080, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server745", - "position": { - "x": 28140, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server746", - "position": { - "x": 28200, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server747", - "position": { - "x": 28260, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server748", - "position": { - "x": 28320, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server749", - "position": { - "x": 28380, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server750", - "position": { - "x": 28440, - "y": 1530 - }, - "group": "group-servers-row15", - "level": "1" - }, - { - "id": "server751", - "position": { - "x": 25500, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server752", - "position": { - "x": 25560, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server753", - "position": { - "x": 25620, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server754", - "position": { - "x": 25680, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server755", - "position": { - "x": 25740, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server756", - "position": { - "x": 25800, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server757", - "position": { - "x": 25860, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server758", - "position": { - "x": 25920, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server759", - "position": { - "x": 25980, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server760", - "position": { - "x": 26040, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server761", - "position": { - "x": 26100, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server762", - "position": { - "x": 26160, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server763", - "position": { - "x": 26220, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server764", - "position": { - "x": 26280, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server765", - "position": { - "x": 26340, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server766", - "position": { - "x": 26400, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server767", - "position": { - "x": 26460, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server768", - "position": { - "x": 26520, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server769", - "position": { - "x": 26580, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server770", - "position": { - "x": 26640, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server771", - "position": { - "x": 26700, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server772", - "position": { - "x": 26760, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server773", - "position": { - "x": 26820, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server774", - "position": { - "x": 26880, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server775", - "position": { - "x": 26940, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server776", - "position": { - "x": 27000, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server777", - "position": { - "x": 27060, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server778", - "position": { - "x": 27120, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server779", - "position": { - "x": 27180, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server780", - "position": { - "x": 27240, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server781", - "position": { - "x": 27300, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server782", - "position": { - "x": 27360, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server783", - "position": { - "x": 27420, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server784", - "position": { - "x": 27480, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server785", - "position": { - "x": 27540, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server786", - "position": { - "x": 27600, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server787", - "position": { - "x": 27660, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server788", - "position": { - "x": 27720, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server789", - "position": { - "x": 27780, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server790", - "position": { - "x": 27840, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server791", - "position": { - "x": 27900, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server792", - "position": { - "x": 27960, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server793", - "position": { - "x": 28020, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server794", - "position": { - "x": 28080, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server795", - "position": { - "x": 28140, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server796", - "position": { - "x": 28200, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server797", - "position": { - "x": 28260, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server798", - "position": { - "x": 28320, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server799", - "position": { - "x": 28380, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server800", - "position": { - "x": 28440, - "y": 1610 - }, - "group": "group-servers-row16", - "level": "1" - }, - { - "id": "server801", - "position": { - "x": 25500, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server802", - "position": { - "x": 25560, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server803", - "position": { - "x": 25620, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server804", - "position": { - "x": 25680, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server805", - "position": { - "x": 25740, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server806", - "position": { - "x": 25800, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server807", - "position": { - "x": 25860, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server808", - "position": { - "x": 25920, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server809", - "position": { - "x": 25980, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server810", - "position": { - "x": 26040, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server811", - "position": { - "x": 26100, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server812", - "position": { - "x": 26160, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server813", - "position": { - "x": 26220, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server814", - "position": { - "x": 26280, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server815", - "position": { - "x": 26340, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server816", - "position": { - "x": 26400, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server817", - "position": { - "x": 26460, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server818", - "position": { - "x": 26520, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server819", - "position": { - "x": 26580, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server820", - "position": { - "x": 26640, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server821", - "position": { - "x": 26700, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server822", - "position": { - "x": 26760, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server823", - "position": { - "x": 26820, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server824", - "position": { - "x": 26880, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server825", - "position": { - "x": 26940, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server826", - "position": { - "x": 27000, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server827", - "position": { - "x": 27060, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server828", - "position": { - "x": 27120, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server829", - "position": { - "x": 27180, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server830", - "position": { - "x": 27240, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server831", - "position": { - "x": 27300, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server832", - "position": { - "x": 27360, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server833", - "position": { - "x": 27420, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server834", - "position": { - "x": 27480, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server835", - "position": { - "x": 27540, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server836", - "position": { - "x": 27600, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server837", - "position": { - "x": 27660, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server838", - "position": { - "x": 27720, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server839", - "position": { - "x": 27780, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server840", - "position": { - "x": 27840, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server841", - "position": { - "x": 27900, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server842", - "position": { - "x": 27960, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server843", - "position": { - "x": 28020, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server844", - "position": { - "x": 28080, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server845", - "position": { - "x": 28140, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server846", - "position": { - "x": 28200, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server847", - "position": { - "x": 28260, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server848", - "position": { - "x": 28320, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server849", - "position": { - "x": 28380, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server850", - "position": { - "x": 28440, - "y": 1690 - }, - "group": "group-servers-row17", - "level": "1" - }, - { - "id": "server851", - "position": { - "x": 25500, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server852", - "position": { - "x": 25560, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server853", - "position": { - "x": 25620, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server854", - "position": { - "x": 25680, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server855", - "position": { - "x": 25740, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server856", - "position": { - "x": 25800, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server857", - "position": { - "x": 25860, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server858", - "position": { - "x": 25920, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server859", - "position": { - "x": 25980, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server860", - "position": { - "x": 26040, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server861", - "position": { - "x": 26100, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server862", - "position": { - "x": 26160, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server863", - "position": { - "x": 26220, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server864", - "position": { - "x": 26280, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server865", - "position": { - "x": 26340, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server866", - "position": { - "x": 26400, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server867", - "position": { - "x": 26460, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server868", - "position": { - "x": 26520, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server869", - "position": { - "x": 26580, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server870", - "position": { - "x": 26640, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server871", - "position": { - "x": 26700, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server872", - "position": { - "x": 26760, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server873", - "position": { - "x": 26820, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server874", - "position": { - "x": 26880, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server875", - "position": { - "x": 26940, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server876", - "position": { - "x": 27000, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server877", - "position": { - "x": 27060, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server878", - "position": { - "x": 27120, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server879", - "position": { - "x": 27180, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server880", - "position": { - "x": 27240, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server881", - "position": { - "x": 27300, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server882", - "position": { - "x": 27360, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server883", - "position": { - "x": 27420, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server884", - "position": { - "x": 27480, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server885", - "position": { - "x": 27540, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server886", - "position": { - "x": 27600, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server887", - "position": { - "x": 27660, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server888", - "position": { - "x": 27720, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server889", - "position": { - "x": 27780, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server890", - "position": { - "x": 27840, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server891", - "position": { - "x": 27900, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server892", - "position": { - "x": 27960, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server893", - "position": { - "x": 28020, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server894", - "position": { - "x": 28080, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server895", - "position": { - "x": 28140, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server896", - "position": { - "x": 28200, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server897", - "position": { - "x": 28260, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server898", - "position": { - "x": 28320, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server899", - "position": { - "x": 28380, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - }, - { - "id": "server900", - "position": { - "x": 28440, - "y": 1770 - }, - "group": "group-servers-row18", - "level": "1" - } - ], - "freeTextAnnotations": [], - "freeShapeAnnotations": [], - "groupStyleAnnotations": [ - { - "id": "group-border", - "name": "Border", - "level": "L0", - "position": { - "x": 27050, - "y": 50 - }, - "width": 320, - "height": 60, - "color": "#dc2626", - "backgroundColor": "#dc2626", - "backgroundOpacity": 0.08, - "borderColor": "#dc2626", - "borderWidth": 2, - "borderStyle": "solid", - "borderRadius": 8, - "labelColor": "#dc2626", - "labelPosition": "top-left", - "zIndex": -5 - }, - { - "id": "group-spine", - "name": "Spine", - "level": "L1", - "position": { - "x": 27050, - "y": 170 - }, - "width": 1040, - "height": 60, - "color": "#7c3aed", - "backgroundColor": "#7c3aed", - "backgroundOpacity": 0.08, - "borderColor": "#7c3aed", - "borderWidth": 2, - "borderStyle": "solid", - "borderRadius": 8, - "labelColor": "#7c3aed", - "labelPosition": "top-left", - "zIndex": -5 - }, - { - "id": "group-leaf", - "name": "Leaf", - "level": "L2", - "position": { - "x": 27050, - "y": 290 - }, - "width": 4880, - "height": 60, - "color": "#059669", - "backgroundColor": "#059669", - "backgroundOpacity": 0.08, - "borderColor": "#059669", - "borderWidth": 2, - "borderStyle": "solid", - "borderRadius": 8, - "labelColor": "#059669", - "labelPosition": "top-left", - "zIndex": -4 - }, - { - "id": "group-servers-row1", - "name": "Servers 1", - "level": "L3", - "position": { - "x": 27050, - "y": 410 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row2", - "name": "Servers 2", - "level": "L3", - "position": { - "x": 27050, - "y": 490 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row3", - "name": "Servers 3", - "level": "L3", - "position": { - "x": 27050, - "y": 570 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row4", - "name": "Servers 4", - "level": "L3", - "position": { - "x": 27050, - "y": 650 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row5", - "name": "Servers 5", - "level": "L3", - "position": { - "x": 27050, - "y": 730 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row6", - "name": "Servers 6", - "level": "L3", - "position": { - "x": 27050, - "y": 810 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row7", - "name": "Servers 7", - "level": "L3", - "position": { - "x": 27050, - "y": 890 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row8", - "name": "Servers 8", - "level": "L3", - "position": { - "x": 27050, - "y": 970 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row9", - "name": "Servers 9", - "level": "L3", - "position": { - "x": 27050, - "y": 1050 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row10", - "name": "Servers 10", - "level": "L3", - "position": { - "x": 27050, - "y": 1130 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row11", - "name": "Servers 11", - "level": "L3", - "position": { - "x": 27050, - "y": 1210 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row12", - "name": "Servers 12", - "level": "L3", - "position": { - "x": 27050, - "y": 1290 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row13", - "name": "Servers 13", - "level": "L3", - "position": { - "x": 27050, - "y": 1370 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row14", - "name": "Servers 14", - "level": "L3", - "position": { - "x": 27050, - "y": 1450 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row15", - "name": "Servers 15", - "level": "L3", - "position": { - "x": 27050, - "y": 1530 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row16", - "name": "Servers 16", - "level": "L3", - "position": { - "x": 27050, - "y": 1610 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row17", - "name": "Servers 17", - "level": "L3", - "position": { - "x": 27050, - "y": 1690 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - }, - { - "id": "group-servers-row18", - "name": "Servers 18", - "level": "L3", - "position": { - "x": 27050, - "y": 1770 - }, - "width": 3080, - "height": 60, - "color": "#ea580c", - "backgroundColor": "#ea580c", - "backgroundOpacity": 0.06, - "borderColor": "#ea580c", - "borderWidth": 1, - "borderStyle": "dashed", - "borderRadius": 6, - "labelColor": "#ea580c", - "labelPosition": "bottom-center", - "zIndex": -4 - } - ], - "networkNodeAnnotations": [], - "aliasEndpointAnnotations": [] -} diff --git a/dev/topologies-original/network.clab.yml b/dev/topologies-original/network.clab.yml deleted file mode 100644 index b533f842f..000000000 --- a/dev/topologies-original/network.clab.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: network - -topology: - nodes: - srl1: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux - srl2: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux - linux1: - kind: linux - image: alpine:3 - bridge0: - kind: bridge - - links: - # Regular link between nodes - - endpoints: ["srl1:e1-1", "srl2:e1-1"] - - # Host link - connects to host namespace - - endpoints: ["srl1:e1-2", "host:srl1_e1-2"] - - # Management network link - - endpoints: ["srl2:e1-2", "mgmt-net:srl2-e1-2"] - - # Macvlan link - - endpoints: ["linux1:eth1", "macvlan:enp0s3"] - - type: mgmt-net - endpoint: - node: "linux1" - interface: "eth3" - host-interface: eth1 - - type: dummy - endpoint: - node: "srl2" - interface: "eth1" - - endpoints: ["bridge0:eth0", "linux1:eth0"] diff --git a/dev/topologies-original/network.clab.yml.annotations.json b/dev/topologies-original/network.clab.yml.annotations.json deleted file mode 100644 index 342c62b59..000000000 --- a/dev/topologies-original/network.clab.yml.annotations.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "freeTextAnnotations": [], - "freeShapeAnnotations": [], - "groupStyleAnnotations": [], - "networkNodeAnnotations": [ - { - "id": "mgmt-net:srl2-e1-2", - "type": "mgmt-net", - "position": { - "x": -63, - "y": 21 - } - }, - { - "id": "host:srl1_e1-2", - "type": "host", - "position": { - "x": 147, - "y": -21 - } - }, - { - "id": "macvlan:enp0s3", - "type": "macvlan", - "position": { - "x": 189, - "y": 49 - } - }, - { - "id": "mgmt-net:eth1", - "type": "mgmt-net", - "position": { - "x": 245, - "y": 147 - } - }, - { - "id": "bridge0", - "type": "bridge", - "label": "bridge0", - "position": { - "x": 91, - "y": 91 - } - }, - { - "id": "dummy0", - "label": "dummy0", - "type": "dummy", - "position": { - "x": 21, - "y": -21 - } - } - ], - "nodeAnnotations": [ - { - "id": "srl1", - "interfacePattern": "e1-{n}", - "position": { - "x": 91, - "y": 21 - } - }, - { - "id": "srl2", - "interfacePattern": "e1-{n}", - "position": { - "x": 21, - "y": 49 - } - }, - { - "id": "linux1", - "position": { - "x": 203, - "y": 119 - } - }, - { - "id": "bridge0", - "position": { - "x": 105, - "y": 147 - } - } - ], - "aliasEndpointAnnotations": [] -} diff --git a/dev/topologies-original/simple.clab.yml b/dev/topologies-original/simple.clab.yml deleted file mode 100644 index d5d6d5e3b..000000000 --- a/dev/topologies-original/simple.clab.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: simple - -topology: - nodes: - srl1: - kind: nokia_srlinux - type: ixrd1 - image: ghcr.io/nokia/srlinux:latest - srl2: - kind: nokia_srlinux - type: ixrd1 - image: ghcr.io/nokia/srlinux:latest - - links: - - endpoints: ["srl1:e1-1", "srl2:e1-1"] diff --git a/dev/topologies-original/simple.clab.yml.annotations.json b/dev/topologies-original/simple.clab.yml.annotations.json deleted file mode 100644 index 654a578d4..000000000 --- a/dev/topologies-original/simple.clab.yml.annotations.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "freeTextAnnotations": [], - "freeShapeAnnotations": [], - "groupStyleAnnotations": [], - "networkNodeAnnotations": [], - "nodeAnnotations": [ - { - "id": "srl1", - "interfacePattern": "e1-{n}" - }, - { - "id": "srl2", - "interfacePattern": "e1-{n}" - } - ], - "aliasEndpointAnnotations": [] -} diff --git a/dev/topologies-original/spine-leaf.clab.yml b/dev/topologies-original/spine-leaf.clab.yml deleted file mode 100644 index 5823faae0..000000000 --- a/dev/topologies-original/spine-leaf.clab.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: spine-leaf - -topology: - nodes: - spine1: - kind: nokia_srlinux - type: ixrd3 - image: ghcr.io/nokia/srlinux:latest - spine2: - kind: nokia_srlinux - type: ixrd3 - image: ghcr.io/nokia/srlinux:latest - leaf1: - kind: nokia_srlinux - type: ixrd2 - image: ghcr.io/nokia/srlinux:latest - leaf2: - kind: nokia_srlinux - type: ixrd2 - image: ghcr.io/nokia/srlinux:latest - client1: - kind: linux - image: ghcr.io/srl-labs/network-multitool:latest - client2: - kind: linux - image: ghcr.io/srl-labs/network-multitool:latest - - links: - - endpoints: ["spine1:e1-1", "leaf1:e1-49"] - - endpoints: ["spine1:e1-2", "leaf2:e1-49"] - - endpoints: ["spine2:e1-1", "leaf1:e1-50"] - - endpoints: ["spine2:e1-2", "leaf2:e1-50"] - - endpoints: ["leaf1:e1-1", "client1:eth1"] - - endpoints: ["leaf2:e1-1", "client2:eth1"] diff --git a/dev/topologies-original/spine-leaf.clab.yml.annotations.json b/dev/topologies-original/spine-leaf.clab.yml.annotations.json deleted file mode 100644 index d401aaeae..000000000 --- a/dev/topologies-original/spine-leaf.clab.yml.annotations.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "nodeAnnotations": [ - { - "id": "spine1", - "position": { - "x": 200, - "y": 100 - }, - "interfacePattern": "e1-{n}" - }, - { - "id": "spine2", - "position": { - "x": 315, - "y": 105 - }, - "interfacePattern": "e1-{n}" - }, - { - "id": "leaf1", - "position": { - "x": 175, - "y": 189 - }, - "interfacePattern": "e1-{n}" - }, - { - "id": "leaf2", - "position": { - "x": 343, - "y": 189 - }, - "interfacePattern": "e1-{n}" - }, - { - "id": "client1", - "position": { - "x": 175, - "y": 273 - } - }, - { - "id": "client2", - "position": { - "x": 343, - "y": 273 - } - } - ], - "freeTextAnnotations": [], - "freeShapeAnnotations": [], - "groupStyleAnnotations": [], - "networkNodeAnnotations": [], - "aliasEndpointAnnotations": [] -} diff --git a/dev/topologies-original/srsim-simple.clab.yml b/dev/topologies-original/srsim-simple.clab.yml deleted file mode 100644 index 854a98b51..000000000 --- a/dev/topologies-original/srsim-simple.clab.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: srsim-simple - -topology: - nodes: - srsim1: - kind: nokia_srsim - type: sr-1x-48d - image: registry.srlinux.dev/pub/nokia_srsim:25.10.R1 - srsim2: - kind: nokia_srsim - type: sr-1x-48d - image: registry.srlinux.dev/pub/nokia_srsim:25.10.R1 - - links: - - endpoints: ["srsim1:1/1/c1", "srsim2:1/1/c1"] diff --git a/dev/topologies-original/srsim-simple.clab.yml.annotations.json b/dev/topologies-original/srsim-simple.clab.yml.annotations.json deleted file mode 100644 index 95a442c8f..000000000 --- a/dev/topologies-original/srsim-simple.clab.yml.annotations.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "freeTextAnnotations": [], - "freeShapeAnnotations": [], - "groupStyleAnnotations": [], - "networkNodeAnnotations": [], - "nodeAnnotations": [ - { "id": "srsim1", "interfacePattern": "1/1/c{n}" }, - { "id": "srsim2", "interfacePattern": "1/1/c{n}" } - ], - "aliasEndpointAnnotations": [] -} diff --git a/dev/topologies-original/test/test.clab.yml b/dev/topologies-original/test/test.clab.yml deleted file mode 100644 index 292440222..000000000 --- a/dev/topologies-original/test/test.clab.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: test - -topology: - nodes: - dev-mock-spine1: - kind: nokia_srlinux - type: ixrd2 - image: ghcr.io/nokia/srlinux:latest - dev-mock-leaf1: - kind: nokia_srlinux - type: ixrd2 - image: ghcr.io/nokia/srlinux:latest - - links: - - endpoints: ["dev-mock-spine1:e1-1", "dev-mock-leaf1:e1-1"] diff --git a/dev/tsconfig.json b/dev/tsconfig.json deleted file mode 100644 index 98d6e5932..000000000 --- a/dev/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "paths": { - "@webview/*": ["../src/reactTopoViewer/webview/*"], - "@shared/*": ["../src/reactTopoViewer/shared/*"] - } - }, - "include": [ - "./**/*.ts", - "./**/*.tsx", - "../src/reactTopoViewer/**/*.ts", - "../src/reactTopoViewer/**/*.tsx" - ] -} diff --git a/dev/vite.config.ts b/dev/vite.config.ts deleted file mode 100644 index 13018876d..000000000 --- a/dev/vite.config.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -import path from "path"; -import { fileURLToPath } from "url"; -import { fileApiPlugin } from "./server/fileApi"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const nodeModules = path.resolve(__dirname, "../node_modules"); - -export default defineConfig({ - plugins: [ - react(), - // File API middleware for YAML and annotation file operations - fileApiPlugin() - ], - root: __dirname, - publicDir: path.resolve(__dirname, "../resources"), - resolve: { - alias: { - // CRITICAL: Force single React instance by explicit paths - react: path.resolve(nodeModules, "react"), - "react-dom": path.resolve(nodeModules, "react-dom"), - // Browser/dev builds do not have VS Code extension host APIs available. - vscode: path.resolve(__dirname, "./stubs/vscode.ts"), - // Allow importing from the actual webview source - "@webview": path.resolve(__dirname, "../src/reactTopoViewer/webview"), - "@shared": path.resolve(__dirname, "../src/reactTopoViewer/shared") - }, - dedupe: ["react", "react-dom"] - }, - optimizeDeps: { - // Keep this list limited to real, direct deps. A missing entry here can cause - // CI-only startup issues (e.g. "Failed to resolve dependency ..."). - include: ["react", "react-dom"] - }, - css: { - postcss: path.resolve(__dirname, "../postcss.config.js") - }, - server: { - port: 5173, - // Don't attempt to open a browser in CI. - open: !process.env.CI - }, - build: { - outDir: path.resolve(__dirname, "../dist-dev") - } -}); diff --git a/esbuild.config.js b/esbuild.config.js index ff4ccd98b..82621f22f 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -3,6 +3,149 @@ const path = require("path"); const fs = require("fs"); const { execSync } = require("child_process"); +const localClabUiRoot = path.resolve(__dirname, "../clab-ui"); +const localClabUiDistRoot = path.join(localClabUiRoot, "dist"); +const useLocalClabUi = + process.env.CLAB_UI_SOURCE === "local" && + fs.existsSync(path.join(localClabUiDistRoot, "index.js")); +const clabUiEntry = (relativePath, packageSubpath) => + useLocalClabUi + ? path.join(localClabUiDistRoot, relativePath) + : require.resolve(packageSubpath); + +const reactTopoViewerEntry = path.join(__dirname, "src/webviews/reactTopoViewer/entry.tsx"); +const explorerWebviewEntry = path.join(__dirname, "src/webviews/explorer/entry.tsx"); +const inspectWebviewEntry = path.join(__dirname, "src/webviews/inspect/entry.tsx"); +const imageManagerWebviewEntry = path.join(__dirname, "src/webviews/imageManager/entry.tsx"); +const welcomeWebviewEntry = path.join(__dirname, "src/webviews/welcome/entry.tsx"); +const nodeImpairmentsWebviewEntry = path.join(__dirname, "src/webviews/nodeImpairments/entry.tsx"); +const wiresharkVncWebviewEntry = path.join(__dirname, "src/webviews/wiresharkVnc/entry.tsx"); +const clabUiGlobalCss = clabUiEntry("styles/global.css", "@srl-labs/clab-ui/styles/global.css"); + +function findPackageRootFromEntry(entryPath) { + let current = path.dirname(entryPath); + while (true) { + const packageJsonPath = path.join(current, "package.json"); + if (fs.existsSync(packageJsonPath)) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + throw new Error(`Unable to find package root for entry: ${entryPath}`); + } + current = parent; + } +} + +const clabUiPackageRoot = useLocalClabUi + ? localClabUiRoot + : findPackageRootFromEntry(clabUiGlobalCss); + +function resolveFromRoots(moduleId, roots) { + for (const root of roots) { + try { + return require.resolve(moduleId, { paths: [root] }); + } catch { + // Try next candidate root. + } + } + throw new Error(`Unable to resolve '${moduleId}' from roots: ${roots.join(", ")}`); +} + +const monacoPackageJsonPath = resolveFromRoots("monaco-editor/package.json", [ + clabUiPackageRoot, + __dirname +]); +const monacoRoot = path.dirname(monacoPackageJsonPath); +const monacoCodiconFontCandidates = [ + "min/vs/base/browser/ui/codicons/codicon/codicon.ttf", + "esm/vs/base/browser/ui/codicons/codicon/codicon.ttf", + "dev/vs/base/browser/ui/codicons/codicon/codicon.ttf" +].map((relativePath) => path.join(monacoRoot, relativePath)); +const monacoCodiconFontPath = + monacoCodiconFontCandidates.find((candidate) => fs.existsSync(candidate)) ?? null; +const monacoEditorWorkerEntry = path.join(monacoRoot, "esm/vs/editor/editor.worker.js"); +const monacoJsonWorkerEntry = path.join(monacoRoot, "esm/vs/language/json/json.worker.js"); + +const localClabUiEntrypoints = new Map([ + ["@srl-labs/clab-ui", path.join(localClabUiDistRoot, "index.js")], + ["@srl-labs/clab-ui/host", path.join(localClabUiDistRoot, "host/index.js")], + ["@srl-labs/clab-ui/session", path.join(localClabUiDistRoot, "session/index.js")], + ["@srl-labs/clab-ui/theme", path.join(localClabUiDistRoot, "theme/index.js")], + ["@srl-labs/clab-ui/explorer", path.join(localClabUiDistRoot, "explorer/index.js")], + [ + "@srl-labs/clab-ui/image-manager", + path.join(localClabUiDistRoot, "image-manager/index.js") + ], + [ + "@srl-labs/clab-ui/image-manager/catalog", + path.join(localClabUiDistRoot, "image-manager/catalog.js") + ], + ["@srl-labs/clab-ui/inspect", path.join(localClabUiDistRoot, "inspect/index.js")], + ["@srl-labs/clab-ui/welcome", path.join(localClabUiDistRoot, "welcome/index.js")], + [ + "@srl-labs/clab-ui/node-impairments", + path.join(localClabUiDistRoot, "node-impairments/index.js") + ], + [ + "@srl-labs/clab-ui/wireshark-vnc", + path.join(localClabUiDistRoot, "wireshark-vnc/index.js") + ], + [ + "@srl-labs/clab-ui/styles/global.css", + path.join(localClabUiDistRoot, "styles/global.css") + ] +]); + +const clabUiLocalAliasPlugin = { + name: "clab-ui-local-alias", + setup(build) { + if (!useLocalClabUi) { + return; + } + + build.onResolve({ filter: /^@srl-labs\/clab-ui(?:\/.*)?$/ }, (args) => { + const resolved = localClabUiEntrypoints.get(args.path) ?? null; + if (!resolved) { + return null; + } + return { path: resolved }; + }); + } +}; + +const reactSingletonAliasPlugin = { + name: "react-singleton-alias", + setup(build) { + const aliasTargets = new Map([ + ["react", require.resolve("react")], + ["react/jsx-runtime", require.resolve("react/jsx-runtime")], + ["react/jsx-dev-runtime", require.resolve("react/jsx-dev-runtime")], + ["react-dom", require.resolve("react-dom")], + ["react-dom/client", require.resolve("react-dom/client")] + ]); + + build.onResolve( + { filter: /^(react|react\/jsx-runtime|react\/jsx-dev-runtime|react-dom|react-dom\/client)$/ }, + (args) => { + const resolved = aliasTargets.get(args.path); + return resolved ? { path: resolved } : null; + } + ); + } +}; + +const browserAssetLoaders = { + ".svg": "dataurl", + ".png": "dataurl", + ".jpg": "dataurl", + ".gif": "dataurl", + ".woff": "dataurl", + ".woff2": "dataurl", + ".ttf": "dataurl", + ".eot": "dataurl" +}; + // Plugin to stub native .node files - ssh2 has JS fallbacks const nativeNodeModulesPlugin = { name: "native-node-modules", @@ -37,22 +180,9 @@ async function copyFonts() { const fontDir = path.join(__dirname, "dist/webfonts"); await fs.promises.mkdir(fontDir, { recursive: true }); - // Copy wireshark SVG - const wiresharkSrc = path.join( - __dirname, - "src/reactTopoViewer/webview/assets/images/wireshark_bold.svg" - ); - if (fs.existsSync(wiresharkSrc)) { - await fs.promises.copyFile(wiresharkSrc, path.join(fontDir, "wireshark_bold.svg")); - } - // Monaco codicon font (used by Monaco UI widgets) - const codiconSrc = path.join( - __dirname, - "node_modules/monaco-editor/min/vs/base/browser/ui/codicons/codicon/codicon.ttf" - ); - if (fs.existsSync(codiconSrc)) { - await fs.promises.copyFile(codiconSrc, path.join(fontDir, "codicon.ttf")); + if (monacoCodiconFontPath) { + await fs.promises.copyFile(monacoCodiconFontPath, path.join(fontDir, "codicon.ttf")); } } @@ -68,8 +198,10 @@ async function copyMapLibreWorker() { async function buildCss() { console.log("Building CSS with PostCSS..."); execSync( - "npx postcss src/reactTopoViewer/webview/styles/global.css -o dist/reactTopoViewerStyles.css", - { stdio: "inherit" } + `npx postcss "${clabUiGlobalCss}" --config "${path.join(__dirname, "postcss.config.js")}" -o dist/reactTopoViewerStyles.css`, + { + stdio: "inherit" + } ); // Fix font paths - rewrite node_modules paths to webfonts/ @@ -115,25 +247,24 @@ async function build() { format: "cjs", external: ["vscode"], outfile: "dist/extension.js", - plugins: [nativeNodeModulesPlugin] + plugins: [nativeNodeModulesPlugin, clabUiLocalAliasPlugin, reactSingletonAliasPlugin] }); // Build webview (Browser) - CSS handled separately const webviewBuild = esbuild.build({ ...commonOptions, - entryPoints: ["src/reactTopoViewer/webview/index.tsx"], + entryPoints: [reactTopoViewerEntry], platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outfile: "dist/reactTopoViewerWebview.js", - plugins: [ignoreCssPlugin], + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], jsx: "automatic", - loader: { - ".svg": "dataurl", - ".png": "dataurl", - ".jpg": "dataurl", - ".gif": "dataurl" - }, + loader: browserAssetLoaders, define: { "process.env.NODE_ENV": isDev ? '"development"' : '"production"' } @@ -141,19 +272,18 @@ async function build() { const explorerWebviewBuild = esbuild.build({ ...commonOptions, - entryPoints: ["src/webviews/explorer/containerlabExplorerView.webview.tsx"], + entryPoints: [explorerWebviewEntry], platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outfile: "dist/containerlabExplorerView.js", - plugins: [ignoreCssPlugin], + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], jsx: "automatic", - loader: { - ".svg": "dataurl", - ".png": "dataurl", - ".jpg": "dataurl", - ".gif": "dataurl" - }, + loader: browserAssetLoaders, define: { "process.env.NODE_ENV": isDev ? '"development"' : '"production"' } @@ -161,19 +291,18 @@ async function build() { const welcomeWebviewBuild = esbuild.build({ ...commonOptions, - entryPoints: ["src/webviews/welcome/welcomePage.webview.tsx"], + entryPoints: [welcomeWebviewEntry], platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outfile: "dist/welcomePageWebview.js", - plugins: [ignoreCssPlugin], + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], jsx: "automatic", - loader: { - ".svg": "dataurl", - ".png": "dataurl", - ".jpg": "dataurl", - ".gif": "dataurl" - }, + loader: browserAssetLoaders, define: { "process.env.NODE_ENV": isDev ? '"development"' : '"production"' } @@ -181,19 +310,37 @@ async function build() { const inspectWebviewBuild = esbuild.build({ ...commonOptions, - entryPoints: ["src/webviews/inspect/inspect.webview.tsx"], + entryPoints: [inspectWebviewEntry], platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outfile: "dist/inspectWebview.js", - plugins: [ignoreCssPlugin], + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], jsx: "automatic", - loader: { - ".svg": "dataurl", - ".png": "dataurl", - ".jpg": "dataurl", - ".gif": "dataurl" - }, + loader: browserAssetLoaders, + define: { + "process.env.NODE_ENV": isDev ? '"development"' : '"production"' + } + }); + + const imageManagerWebviewBuild = esbuild.build({ + ...commonOptions, + entryPoints: [imageManagerWebviewEntry], + platform: "browser", + format: "iife", + target: ["es2020", "chrome90", "firefox90", "safari14.1"], + outfile: "dist/imageManagerWebview.js", + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], + jsx: "automatic", + loader: browserAssetLoaders, define: { "process.env.NODE_ENV": isDev ? '"development"' : '"production"' } @@ -201,19 +348,18 @@ async function build() { const nodeImpairmentsWebviewBuild = esbuild.build({ ...commonOptions, - entryPoints: ["src/webviews/nodeImpairments/nodeImpairments.webview.tsx"], + entryPoints: [nodeImpairmentsWebviewEntry], platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outfile: "dist/nodeImpairmentsWebview.js", - plugins: [ignoreCssPlugin], + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], jsx: "automatic", - loader: { - ".svg": "dataurl", - ".png": "dataurl", - ".jpg": "dataurl", - ".gif": "dataurl" - }, + loader: browserAssetLoaders, define: { "process.env.NODE_ENV": isDev ? '"development"' : '"production"' } @@ -221,19 +367,18 @@ async function build() { const wiresharkVncWebviewBuild = esbuild.build({ ...commonOptions, - entryPoints: ["src/webviews/wiresharkVnc/wiresharkVnc.webview.tsx"], + entryPoints: [wiresharkVncWebviewEntry], platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outfile: "dist/wiresharkVncWebview.js", - plugins: [ignoreCssPlugin], + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], jsx: "automatic", - loader: { - ".svg": "dataurl", - ".png": "dataurl", - ".jpg": "dataurl", - ".gif": "dataurl" - }, + loader: browserAssetLoaders, define: { "process.env.NODE_ENV": isDev ? '"development"' : '"production"' } @@ -243,12 +388,12 @@ async function build() { const monacoWorkersBuild = esbuild.build({ ...commonOptions, entryPoints: { - "monaco-editor-worker": "node_modules/monaco-editor/esm/vs/editor/editor.worker.js", - "monaco-json-worker": "node_modules/monaco-editor/esm/vs/language/json/json.worker.js" + "monaco-editor-worker": monacoEditorWorkerEntry, + "monaco-json-worker": monacoJsonWorkerEntry }, platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outdir: "dist", plugins: [ignoreCssPlugin] }); @@ -260,6 +405,7 @@ async function build() { explorerWebviewBuild, welcomeWebviewBuild, inspectWebviewBuild, + imageManagerWebviewBuild, nodeImpairmentsWebviewBuild, wiresharkVncWebviewBuild, monacoWorkersBuild, @@ -282,122 +428,132 @@ async function build() { format: "cjs", external: ["vscode"], outfile: "dist/extension.js", - plugins: [nativeNodeModulesPlugin] + plugins: [nativeNodeModulesPlugin, clabUiLocalAliasPlugin, reactSingletonAliasPlugin] }); const webCtx = await esbuild.context({ ...commonOptions, - entryPoints: ["src/reactTopoViewer/webview/index.tsx"], + entryPoints: [reactTopoViewerEntry], platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outfile: "dist/reactTopoViewerWebview.js", - plugins: [ignoreCssPlugin], + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], jsx: "automatic", - loader: { - ".svg": "dataurl", - ".png": "dataurl", - ".jpg": "dataurl", - ".gif": "dataurl" - } + loader: browserAssetLoaders }); const explorerWebCtx = await esbuild.context({ ...commonOptions, - entryPoints: ["src/webviews/explorer/containerlabExplorerView.webview.tsx"], + entryPoints: [explorerWebviewEntry], platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outfile: "dist/containerlabExplorerView.js", - plugins: [ignoreCssPlugin], + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], jsx: "automatic", - loader: { - ".svg": "dataurl", - ".png": "dataurl", - ".jpg": "dataurl", - ".gif": "dataurl" - } + loader: browserAssetLoaders }); const welcomeWebCtx = await esbuild.context({ ...commonOptions, - entryPoints: ["src/webviews/welcome/welcomePage.webview.tsx"], + entryPoints: [welcomeWebviewEntry], platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outfile: "dist/welcomePageWebview.js", - plugins: [ignoreCssPlugin], + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], jsx: "automatic", - loader: { - ".svg": "dataurl", - ".png": "dataurl", - ".jpg": "dataurl", - ".gif": "dataurl" - } + loader: browserAssetLoaders }); const inspectWebCtx = await esbuild.context({ ...commonOptions, - entryPoints: ["src/webviews/inspect/inspect.webview.tsx"], + entryPoints: [inspectWebviewEntry], platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outfile: "dist/inspectWebview.js", - plugins: [ignoreCssPlugin], + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], jsx: "automatic", - loader: { - ".svg": "dataurl", - ".png": "dataurl", - ".jpg": "dataurl", - ".gif": "dataurl" - } + loader: browserAssetLoaders + }); + + const imageManagerWebCtx = await esbuild.context({ + ...commonOptions, + entryPoints: [imageManagerWebviewEntry], + platform: "browser", + format: "iife", + target: ["es2020", "chrome90", "firefox90", "safari14.1"], + outfile: "dist/imageManagerWebview.js", + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], + jsx: "automatic", + loader: browserAssetLoaders }); const nodeImpairmentsWebCtx = await esbuild.context({ ...commonOptions, - entryPoints: ["src/webviews/nodeImpairments/nodeImpairments.webview.tsx"], + entryPoints: [nodeImpairmentsWebviewEntry], platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outfile: "dist/nodeImpairmentsWebview.js", - plugins: [ignoreCssPlugin], + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], jsx: "automatic", - loader: { - ".svg": "dataurl", - ".png": "dataurl", - ".jpg": "dataurl", - ".gif": "dataurl" - } + loader: browserAssetLoaders }); const wiresharkVncWebCtx = await esbuild.context({ ...commonOptions, - entryPoints: ["src/webviews/wiresharkVnc/wiresharkVnc.webview.tsx"], + entryPoints: [wiresharkVncWebviewEntry], platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outfile: "dist/wiresharkVncWebview.js", - plugins: [ignoreCssPlugin], + plugins: [ + ignoreCssPlugin, + clabUiLocalAliasPlugin, + reactSingletonAliasPlugin + ], jsx: "automatic", - loader: { - ".svg": "dataurl", - ".png": "dataurl", - ".jpg": "dataurl", - ".gif": "dataurl" - } + loader: browserAssetLoaders }); const monacoWorkersCtx = await esbuild.context({ ...commonOptions, entryPoints: { - "monaco-editor-worker": "node_modules/monaco-editor/esm/vs/editor/editor.worker.js", - "monaco-json-worker": "node_modules/monaco-editor/esm/vs/language/json/json.worker.js" + "monaco-editor-worker": monacoEditorWorkerEntry, + "monaco-json-worker": monacoJsonWorkerEntry }, platform: "browser", format: "iife", - target: ["es2020", "chrome90", "firefox90", "safari14"], + target: ["es2020", "chrome90", "firefox90", "safari14.1"], outdir: "dist", - plugins: [ignoreCssPlugin] + plugins: [ignoreCssPlugin, clabUiLocalAliasPlugin] }); await Promise.all([ @@ -406,13 +562,17 @@ async function build() { explorerWebCtx.watch(), welcomeWebCtx.watch(), inspectWebCtx.watch(), + imageManagerWebCtx.watch(), nodeImpairmentsWebCtx.watch(), wiresharkVncWebCtx.watch(), monacoWorkersCtx.watch() ]); // Watch CSS files and rebuild - const cssWatcher = watch("src/reactTopoViewer/webview/styles/**/*.css", { + const cssWatchRoot = useLocalClabUi + ? path.join(localClabUiDistRoot, "styles") + : path.dirname(clabUiGlobalCss); + const cssWatcher = watch(path.join(cssWatchRoot, "**/*.css"), { ignoreInitial: true }); cssWatcher.on("change", () => { diff --git a/package-lock.json b/package-lock.json index 72b4323c8..56f2ec4fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,67 +1,46 @@ { "name": "vscode-containerlab", - "version": "0.24.2", + "version": "0.25.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-containerlab", - "version": "0.24.2", - "hasInstallScript": true, + "version": "0.25.0", "license": "Apache-2.0", "devDependencies": { - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.1", - "@fontsource/roboto": "^5.2.10", - "@mui/icons-material": "^7.3.9", - "@mui/material": "^7.3.9", - "@mui/x-charts": "^8.27.5", - "@mui/x-tree-view": "^8.27.2", - "@playwright/test": "^1.58.2", + "@srl-labs/clab-ui": "0.0.26", "@types/chai": "^5.2.3", - "@types/d3-force": "^3.0.10", "@types/dockerode": "^4.0.1", - "@types/markdown-it": "^14.1.2", - "@types/markdown-it-emoji": "^3.0.1", "@types/mocha": "^10.0.10", - "@types/node": "^25.5.0", + "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@types/sinon": "^21.0.0", - "@types/vscode": "^1.99.0", - "@vitejs/plugin-react": "^6.0.1", - "@vscode/vsce": "^3.7.1", - "@xyflow/react": "^12.10.1", - "ajv": "^8.18.0", + "@types/sinon": "^21.0.1", + "@types/vscode": "1.99.0", + "@vscode/vsce": "^3.9.1", + "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "autoprefixer": "^10.4.27", + "autoprefixer": "^10.5.0", "chai": "^6.2.2", - "d3-force": "^3.0.0", - "dependency-cruiser": "^17.3.9", - "dockerode": "^4.0.9", - "dompurify": "^3.3.3", - "esbuild": "^0.27.4", - "highlight.js": "^11.11.1", - "jscpd": "^4.0.8", + "dependency-cruiser": "^17.3.10", + "dockerode": "^4.0.12", + "esbuild": "^0.28.0", + "jscpd": "^4.0.9", "madge": "^8.0.0", - "maplibre-gl": "^5.20.1", - "markdown-it": "^14.1.1", - "markdown-it-emoji": "^3.0.0", "mocha": "^11.7.5", "mochawesome": "^7.1.4", - "monaco-editor": "^0.55.1", - "oxlint": "^1.55.0", - "oxlint-tsgolint": "^0.17.0", - "postcss": "^8.5.8", + "oxlint": "^1.61.0", + "oxlint-tsgolint": "^0.21.1", + "postcss": "^8.5.12", "postcss-cli": "^11.0.1", "postcss-import": "^16.1.1", - "prettier": "^3.8.1", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "sinon": "^21.0.2", + "prettier": "^3.8.3", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "sinon": "^21.1.2", "typescript": "^5.9.3", - "vite": "^8.0.0", - "yaml": "^2.8.2" + "yaml": "^2.8.3" }, "engines": { "node": ">=24.0.0", @@ -180,9 +159,9 @@ } }, "node_modules/@azure/identity": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz", - "integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", "dev": true, "license": "MIT", "dependencies": { @@ -193,8 +172,8 @@ "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^4.2.0", - "@azure/msal-node": "^3.5.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", "open": "^10.1.0", "tslib": "^2.2.0" }, @@ -217,22 +196,22 @@ } }, "node_modules/@azure/msal-browser": { - "version": "4.29.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.29.0.tgz", - "integrity": "sha512-/f3eHkSNUTl6DLQHm+bKecjBKcRQxbd/XLx8lvSYp8Nl/HRyPuIPOijt9Dt0sH50/SxOwQ62RnFCmFlGK+bR/w==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.8.0.tgz", + "integrity": "sha512-X7IZV77bN56l7sbLjkcbQJX1t3U4tgxqztDr/XFbUcUfKk+z2FavcLgKP+OYUNj0wl/pEEtV9lldW9siY8BuHQ==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "15.15.0" + "@azure/msal-common": "16.5.1" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.15.0.tgz", - "integrity": "sha512-/n+bN0AKlVa+AOcETkJSKj38+bvFs78BaP4rNtv3MJCmPH0YrHiskMRe74OhyZ5DZjGISlFyxqvf9/4QVEi2tw==", + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.5.1.tgz", + "integrity": "sha512-WS9w9SfI8SEYO7mTnxGeZ3UwQfhAVYCWglYF2/7GNx3ioHiAs2gPkl9eSwVs8cPrmiGh+zi9ai/OOKoq4cyzDw==", "dev": true, "license": "MIT", "engines": { @@ -240,18 +219,18 @@ } }, "node_modules/@azure/msal-node": { - "version": "3.8.8", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.8.tgz", - "integrity": "sha512-+f1VrJH1iI517t4zgmuhqORja0bL6LDQXfBqkjuMmfTYXTQQnh1EvwwxO3UbKLT05N0obF72SRHFrC1RBDv5Gg==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.4.tgz", + "integrity": "sha512-G4LXGGggok1QC48uKu64/SV2DPRDlddmV8EieK8pflsNYMj9/Zz+Y9OHoEBhT15h+zpdwXXLYA/7PJCR/yZ8aw==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "15.15.0", + "@azure/msal-common": "16.5.1", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" }, "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@babel/code-frame": { @@ -331,9 +310,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { @@ -347,9 +326,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "dev": true, "license": "MIT", "engines": { @@ -412,14 +391,14 @@ "license": "Apache-2.0" }, "node_modules/@base-ui/utils": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.5.tgz", - "integrity": "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.6", - "@floating-ui/utils": "^0.2.10", + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, @@ -446,9 +425,9 @@ } }, "node_modules/@dependents/detective-less": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@dependents/detective-less/-/detective-less-5.0.1.tgz", - "integrity": "sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@dependents/detective-less/-/detective-less-5.0.2.tgz", + "integrity": "sha512-QPKO4ao2+iniYAYnPZwHKK67EgDG2GAdye9OCy11xsmApHGwzpH3AcSdPjGyPO3tC2/K8mF7JjWX3A/FTRnskg==", "dev": true, "license": "MIT", "dependencies": { @@ -459,40 +438,6 @@ "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -653,9 +598,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -670,9 +615,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -687,9 +632,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -704,9 +649,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -721,9 +666,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -738,9 +683,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -755,9 +700,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -772,9 +717,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -789,9 +734,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -806,9 +751,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -823,9 +768,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -840,9 +785,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -857,9 +802,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -874,9 +819,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -891,9 +836,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -908,9 +853,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -925,9 +870,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -942,9 +887,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -959,9 +904,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -976,9 +921,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -993,9 +938,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -1010,9 +955,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -1027,9 +972,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -1044,9 +989,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -1061,9 +1006,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -1078,9 +1023,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -1224,9 +1169,9 @@ } }, "node_modules/@jscpd/badge-reporter": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@jscpd/badge-reporter/-/badge-reporter-4.0.4.tgz", - "integrity": "sha512-I9b4MmLXPM2vo0SxSUWnNGKcA4PjQlD3GzXvFK60z43cN/EIdLbOq3FVwCL+dg2obUqGXKIzAm7EsDFTg0D+mQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@jscpd/badge-reporter/-/badge-reporter-4.0.5.tgz", + "integrity": "sha512-SLVhP00R9lkQ//Ivaanfm7k0L9sewpBven670kk1uGec2SWUOa7MVQcuad/TV59KEZ73UIC1lXvi6O9hAnbpUw==", "dev": true, "license": "MIT", "dependencies": { @@ -1236,9 +1181,9 @@ } }, "node_modules/@jscpd/core": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@jscpd/core/-/core-4.0.4.tgz", - "integrity": "sha512-QGMT3iXEX1fI6lgjPH+x8eyJwhwr2KkpSF5uBpjC0Z5Xloj0yFTFLtwJT+RhxP/Ob4WYrtx2jvpKB269oIwgMQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@jscpd/core/-/core-4.0.5.tgz", + "integrity": "sha512-Udvym21nWzxjYRVXwwpYNBqZ6b50QV2zHN3fFNzOPPg4cfQVYOZerILB7xNDUsXHC1PCr/N52Tq3q7AElvjWWA==", "dev": true, "license": "MIT", "dependencies": { @@ -1246,14 +1191,14 @@ } }, "node_modules/@jscpd/finder": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@jscpd/finder/-/finder-4.0.4.tgz", - "integrity": "sha512-qVUWY7Nzuvfd5OIk+n7/5CM98LmFroLqblRXAI2gDABwZrc7qS+WH2SNr0qoUq0f4OqwM+piiwKvwL/VDNn/Cg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@jscpd/finder/-/finder-4.0.5.tgz", + "integrity": "sha512-/2VkRoVrrfya+51sitZo5I9MdwsRaPKB8X3L3khAYoHFXk4L/mUuG81RmGazDHjUIGg22ItlkQtwzorNZ2+aPw==", "dev": true, "license": "MIT", "dependencies": { - "@jscpd/core": "4.0.4", - "@jscpd/tokenizer": "4.0.4", + "@jscpd/core": "4.0.5", + "@jscpd/tokenizer": "4.0.5", "blamer": "^1.0.6", "bytes": "^3.1.2", "cli-table3": "^0.6.5", @@ -1265,9 +1210,9 @@ } }, "node_modules/@jscpd/html-reporter": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@jscpd/html-reporter/-/html-reporter-4.0.4.tgz", - "integrity": "sha512-YiepyeYkeH74Kx59PJRdUdonznct0wHPFkf6FLQN+mCBoy6leAWCcOfHtcexnp+UsBFDlItG5nRdKrDSxSH+Kg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@jscpd/html-reporter/-/html-reporter-4.0.5.tgz", + "integrity": "sha512-drK2J8KyPIW9wvaElSIobZFp4dBO9GA++JW4gx3oihvLdDSp8qSo/CNqH47Dw0XkjQTxND3j/+Wz5JWvYRBgFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1277,13 +1222,13 @@ } }, "node_modules/@jscpd/tokenizer": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@jscpd/tokenizer/-/tokenizer-4.0.4.tgz", - "integrity": "sha512-xxYYY/qaLah/FlwogEbGIxx9CjDO+G9E6qawcy26WwrflzJb6wsnhjwdneN6Wb0RNCDsqvzY+bzG453jsin4UQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@jscpd/tokenizer/-/tokenizer-4.0.5.tgz", + "integrity": "sha512-WzRujQtN5WedxZVDKuoanxmKAFrxcLrHpcA6kaM4z8AhGtWXZ325yseqgL5TZ8OK7Auwu7kQLlqhfk05fGYG7A==", "dev": true, "license": "MIT", "dependencies": { - "@jscpd/core": "4.0.4", + "@jscpd/core": "4.0.5", "reprism": "^0.0.11", "spark-md5": "^3.0.2" } @@ -1305,9 +1250,9 @@ "license": "ISC" }, "node_modules/@mapbox/tiny-sdf": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", - "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.1.0.tgz", + "integrity": "sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==", "dev": true, "license": "BSD-2-Clause" }, @@ -1341,16 +1286,19 @@ } }, "node_modules/@maplibre/geojson-vt": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", - "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz", + "integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } }, "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "24.7.0", - "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz", - "integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==", + "version": "24.8.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.1.tgz", + "integrity": "sha512-zxa92qF96ZNojLxeAjnaRpjVCy+swoUNJvDhtpC90k7u5F0TMr4GmvNqMKvYrMoPB8d7gRSXbMG1hBbmgESIsw==", "dev": true, "license": "ISC", "dependencies": { @@ -1369,9 +1317,9 @@ } }, "node_modules/@maplibre/mlt": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.7.tgz", - "integrity": "sha512-HZSsXrgn2V6T3o0qklMwKERfKaAxjO8shmiFnVygCtXTg4SPKWVX+U99RkvxUfCsjYBEcT4ltor8lSlBSCca7Q==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.9.tgz", + "integrity": "sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==", "dev": true, "license": "(MIT OR Apache-2.0)", "dependencies": { @@ -1394,10 +1342,17 @@ "supercluster": "^8.0.1" } }, + "node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@mui/core-downloads-tracker": { - "version": "7.3.9", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.9.tgz", - "integrity": "sha512-MOkOCTfbMJwLshlBCKJ59V2F/uaLYfmKnN76kksj6jlGUVdI25A9Hzs08m+zjBRdLv+sK7Rqdsefe8X7h/6PCw==", + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.10.tgz", + "integrity": "sha512-vrOpWRmPJSuwLo23J62wggEm/jvGdzqctej+UOCtgDUz6nZJQuj3ByPccVyaa7eQmwAzUwKN56FQPMKkqbj1GA==", "dev": true, "license": "MIT", "funding": { @@ -1406,9 +1361,9 @@ } }, "node_modules/@mui/icons-material": { - "version": "7.3.9", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.9.tgz", - "integrity": "sha512-BT+zPJXss8Hg/oEMRmHl17Q97bPACG4ufFSfGEdhiE96jOyR5Dz1ty7ZWt1fVGR0y1p+sSgEwQT/MNZQmoWDCw==", + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.10.tgz", + "integrity": "sha512-Au0ma4NSKGKNiimukj8UT/W1x2Qx6Qwn2RvFGykiSqVLYBNlIOPbjnIMvrwLGLu89EEpTVdu/ys/OduZR+tWqw==", "dev": true, "license": "MIT", "dependencies": { @@ -1422,7 +1377,7 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^7.3.9", + "@mui/material": "^7.3.10", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -1433,17 +1388,17 @@ } }, "node_modules/@mui/material": { - "version": "7.3.9", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.9.tgz", - "integrity": "sha512-I8yO3t4T0y7bvDiR1qhIN6iBWZOTBfVOnmLlM7K6h3dx5YX2a7rnkuXzc2UkZaqhxY9NgTnEbdPlokR1RxCNRQ==", + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.10.tgz", + "integrity": "sha512-cHvGOk2ZEfbQt3LnGe0ZKd/ETs9gsUpkW66DCO+GSjMZhpdKU4XsuIr7zJ/B/2XaN8ihxuzHfYAR4zPtCN4RYg==", "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.6", - "@mui/core-downloads-tracker": "^7.3.9", - "@mui/system": "^7.3.9", + "@mui/core-downloads-tracker": "^7.3.10", + "@mui/system": "^7.3.10", "@mui/types": "^7.4.12", - "@mui/utils": "^7.3.9", + "@mui/utils": "^7.3.10", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", @@ -1462,7 +1417,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^7.3.9", + "@mui/material-pigment-css": "^7.3.10", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1483,14 +1438,14 @@ } }, "node_modules/@mui/private-theming": { - "version": "7.3.9", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.9.tgz", - "integrity": "sha512-ErIyRQvsiQEq7Yvcvfw9UDHngaqjMy9P3JDPnRAaKG5qhpl2C4tX/W1S4zJvpu+feihmZJStjIyvnv6KDbIrlw==", + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.10.tgz", + "integrity": "sha512-j3EZN+zOctxUISvJSmsEPo5o2F8zse4l5vRkBY+ps6UtnL6J7o14kUaI4w7gwo73id9e3cDNMVQK/9BVaMHVBw==", "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.6", - "@mui/utils": "^7.3.9", + "@mui/utils": "^7.3.10", "prop-types": "^15.8.1" }, "engines": { @@ -1511,9 +1466,9 @@ } }, "node_modules/@mui/styled-engine": { - "version": "7.3.9", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.9.tgz", - "integrity": "sha512-JqujWt5bX4okjUPGpVof/7pvgClqh7HvIbsIBIOOlCh2u3wG/Bwp4+E1bc1dXSwkrkp9WUAoNdI5HEC+5HKvMw==", + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", + "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", "dev": true, "license": "MIT", "dependencies": { @@ -1546,17 +1501,17 @@ } }, "node_modules/@mui/system": { - "version": "7.3.9", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.9.tgz", - "integrity": "sha512-aL1q9am8XpRrSabv9qWf5RHhJICJql34wnrc1nz0MuOglPRYF/liN+c8VqZdTvUn9qg+ZjRVbKf4sJVFfIDtmg==", + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.10.tgz", + "integrity": "sha512-/sfPpdpJaQn7BSF+avjIdHSYmxHp0UOBYNxSG9QGKfMOD6sLANCpRPCnanq1Pe0lFf0NHkO2iUk0TNzdWC1USQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.6", - "@mui/private-theming": "^7.3.9", - "@mui/styled-engine": "^7.3.9", + "@mui/private-theming": "^7.3.10", + "@mui/styled-engine": "^7.3.10", "@mui/types": "^7.4.12", - "@mui/utils": "^7.3.9", + "@mui/utils": "^7.3.10", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1" @@ -1605,9 +1560,9 @@ } }, "node_modules/@mui/utils": { - "version": "7.3.9", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.9.tgz", - "integrity": "sha512-U6SdZaGbfb65fqTsH3V5oJdFj9uYwyLE2WVuNvmbggTSDBb8QHrFsqY8BN3taK9t3yJ8/BPHD/kNvLNyjwM7Yw==", + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.10.tgz", + "integrity": "sha512-7y2eIfy0h7JPz+Yy4pS+wgV68d46PuuxDqKBN4Q8VlPQSsCAGwroMCV6xWyc7g9dvEp8ZNFsknc59GHWO+r6Ow==", "dev": true, "license": "MIT", "dependencies": { @@ -1636,9 +1591,9 @@ } }, "node_modules/@mui/x-charts": { - "version": "8.27.5", - "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.27.5.tgz", - "integrity": "sha512-45XAKzEaTXx8D612zAghr6ofNK/OHukKTl9kuI+UmpaOE3se+khNwKHeOyXcus2uUoGoL6jxZcENklZmJDxzCg==", + "version": "8.28.2", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.28.2.tgz", + "integrity": "sha512-xvQto+uVcwwWitZjzwmHaw1KZDsIju687kLSyOe3qsg4JYldgT+WWguBFQREF7Tsw7PFGAPahDAzbsTNuIkkTA==", "dev": true, "license": "MIT", "dependencies": { @@ -1739,9 +1694,9 @@ } }, "node_modules/@mui/x-tree-view": { - "version": "8.27.2", - "resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-8.27.2.tgz", - "integrity": "sha512-gceKjUEqKHBVt5BV0Yscx2NKgbI9z6IgEx3BHToeAupNCIZ7kAlnZUtk+FyIIcN+Vr6CFz5J0zxjnPgfHjbj2A==", + "version": "8.28.3", + "resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-8.28.3.tgz", + "integrity": "sha512-VRsT44UlJJQKgxjddoP/QWSK9sMxICRaQOYDRU5o35fC5fc6/5uwFk/Xh5bEkYbMVin49U/KiaOi+OayO9sPKg==", "dev": true, "license": "MIT", "dependencies": { @@ -1778,23 +1733,6 @@ } } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1833,30 +1771,10 @@ "node": ">= 8" } }, - "node_modules/@oxc-project/runtime": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", - "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, "node_modules/@oxlint-tsgolint/darwin-arm64": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.17.0.tgz", - "integrity": "sha512-z3XwCDuOAKgk7bO4y5tyH8Zogwr51G56R0XGKC3tlAbrAq8DecoxAd3qhRZqWBMG2Gzl5bWU3Ghu7lrxuLPzYw==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.21.1.tgz", + "integrity": "sha512-7TLjyWe4wG9saJc992VWmaHq2hwKfOEEVTjheReXJXaDhavMZI4X9a6nKhbEng4IVkYtzjD2jw16vw2WFXLYLw==", "cpu": [ "arm64" ], @@ -1868,9 +1786,9 @@ ] }, "node_modules/@oxlint-tsgolint/darwin-x64": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.17.0.tgz", - "integrity": "sha512-TZgVXy0MtI8nt0MYiceuZhHPwHcwlIZ/YwzFTAKrgdHiTvVzFbqHVdXi5wbZfT/o1nHGw9fbGWPlb6qKZ4uZ9Q==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.21.1.tgz", + "integrity": "sha512-7wf9Wf75nTzA7zpL9myhFe2RKvfuqGUOADNvUooCjEWvh7hmPz3lSEqTMh5Z/VQhzsG04mM9ACyghxhRzq7zFw==", "cpu": [ "x64" ], @@ -1882,9 +1800,9 @@ ] }, "node_modules/@oxlint-tsgolint/linux-arm64": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.17.0.tgz", - "integrity": "sha512-IDfhFl/Y8bjidCvAP6QAxVyBsl78TmfCHlfjtEv2XtJXgYmIwzv6muO18XMp74SZ2qAyD4y2n2dUedrmghGHeA==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.21.1.tgz", + "integrity": "sha512-IPuQN/Vd0Rjklg/cCGBbQyUuRBp2f6LQXpZYwk5ivOR6V/+CgiYsv8pn/PVY7gjeyoNvPQrXB7xMjHUO2YZbdw==", "cpu": [ "arm64" ], @@ -1896,9 +1814,9 @@ ] }, "node_modules/@oxlint-tsgolint/linux-x64": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.17.0.tgz", - "integrity": "sha512-Bgdgqx/m8EnfjmmlRLEeYy9Yhdt1GdFrMr5mTu/NyLRGkB1C9VLAikdxB7U9QambAGTAmjMbHNFDFk8Vx69Huw==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.21.1.tgz", + "integrity": "sha512-d1niGuTbh2qiv7dR7tqkbOcM5cIR63of0lMBFdEQavL1KrJV8zuRdwdi68K7MNGdgoR+J5A9ajpGGvsHwp1bPg==", "cpu": [ "x64" ], @@ -1910,9 +1828,9 @@ ] }, "node_modules/@oxlint-tsgolint/win32-arm64": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.17.0.tgz", - "integrity": "sha512-dO6wyKMDqFWh1vwr+zNZS7/ovlfGgl4S3P1LDy4CKjP6V6NGtdmEwWkWax8j/I8RzGZdfXKnoUfb/qhVg5bx0w==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.21.1.tgz", + "integrity": "sha512-ICu9y2JLnFPvFqstnWPPNqBM8LK8BWw2OTeaR0UgEMm4hOSbrZAKv1/hwZYyiLqnCNjBL87AGSQIgTHCYlsipw==", "cpu": [ "arm64" ], @@ -1924,9 +1842,9 @@ ] }, "node_modules/@oxlint-tsgolint/win32-x64": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.17.0.tgz", - "integrity": "sha512-lPGYFp3yX2nh6hLTpIuMnJbZnt3Df42VkoA/fSkMYi2a/LXdDytQGpgZOrb5j47TICARd34RauKm0P3OA4Oxbw==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.21.1.tgz", + "integrity": "sha512-cTEFCFjCj6iXfrSHcvajSPNqhEA4TxSzU3gFxbdGSAUTNXGToU99IbdhWAPSbhcucoym0XE4Zl7E41NiSkNTug==", "cpu": [ "x64" ], @@ -1938,9 +1856,9 @@ ] }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.55.0.tgz", - "integrity": "sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.61.0.tgz", + "integrity": "sha512-6eZBPgiigK5txqoVgRqxbaxiom4lM8AP8CyKPPvpzKnQ3iFRFOIDc+0AapF+qsUSwjOzr5SGk4SxQDpQhkSJMQ==", "cpu": [ "arm" ], @@ -1955,9 +1873,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.55.0.tgz", - "integrity": "sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.61.0.tgz", + "integrity": "sha512-CkwLR69MUnyv5wjzebvbbtTSUwqLxM35CXE79bHqDIK+NtKmPEUpStTcLQRZMCo4MP0qRT6TXIQVpK0ZVScnMA==", "cpu": [ "arm64" ], @@ -1972,9 +1890,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.55.0.tgz", - "integrity": "sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.61.0.tgz", + "integrity": "sha512-8JbefTkbmvqkqWjmQrHke+MdpgT2UghhD/ktM4FOQSpGeCgbMToJEKdl9zwhr/YWTl92i4QI1KiTwVExpcUN8A==", "cpu": [ "arm64" ], @@ -1989,9 +1907,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.55.0.tgz", - "integrity": "sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.61.0.tgz", + "integrity": "sha512-uWpoxDT47hTnDLcdEh5jVbso8rlTTu5o0zuqa9J8E0JAKmIWn7kGFEIB03Pycn2hd2vKxybPGLhjURy/9We5FQ==", "cpu": [ "x64" ], @@ -2006,9 +1924,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.55.0.tgz", - "integrity": "sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.61.0.tgz", + "integrity": "sha512-K/o4hEyW7flfMel0iBVznmMBt7VIMHGdjADocHKpK1DUF9erpWnJ+BSSWd2W0c8K3mPtpph+CuHzRU6CI3l9jQ==", "cpu": [ "x64" ], @@ -2023,9 +1941,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.55.0.tgz", - "integrity": "sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.61.0.tgz", + "integrity": "sha512-P6040ZkcyweJ0Po9yEFqJCdvZnf3VNCGs1SIHgXDf8AAQNC6ID/heXQs9iSgo2FH7gKaKq32VWc59XZwL34C5Q==", "cpu": [ "arm" ], @@ -2040,9 +1958,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.55.0.tgz", - "integrity": "sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.61.0.tgz", + "integrity": "sha512-bwxrGCzTZkuB+THv2TQ1aTkVEfv5oz8sl+0XZZCpoYzErJD8OhPQOTA0ENPd1zJz8QsVdSzSrS2umKtPq4/JXg==", "cpu": [ "arm" ], @@ -2057,9 +1975,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.55.0.tgz", - "integrity": "sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.61.0.tgz", + "integrity": "sha512-vkhb9/wKguMkLlrm3FoJW/Xmdv31GgYAE+x8lxxQ+7HeOxXUySI0q36a3NTVIuQUdLzxCI1zzMGsk1o37FOe3w==", "cpu": [ "arm64" ], @@ -2074,9 +1992,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.55.0.tgz", - "integrity": "sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.61.0.tgz", + "integrity": "sha512-bl1dQh8LnVqsj6oOQAcxwbuOmNJkwc4p6o//HTBZhNTzJy21TLDwAviMqUFNUxDHkPGpmdKTSN4tWTjLryP8xg==", "cpu": [ "arm64" ], @@ -2091,9 +2009,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.55.0.tgz", - "integrity": "sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.61.0.tgz", + "integrity": "sha512-QoOX6KB2IiEpyOj/HKqaxi+NQHPnOgNgnr22n9N4ANJCzXkUlj1UmeAbFb4PpqdlHIzvGDM5xZ0OKtcLq9RhiQ==", "cpu": [ "ppc64" ], @@ -2108,9 +2026,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.55.0.tgz", - "integrity": "sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.61.0.tgz", + "integrity": "sha512-1TGcTerjY6p152wCof3oKElccq3xHljS/Mucp04gV/4ATpP6nO7YNnp7opEg6SHkv2a57/b4b8Ndm9znJ1/qAw==", "cpu": [ "riscv64" ], @@ -2125,9 +2043,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.55.0.tgz", - "integrity": "sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.61.0.tgz", + "integrity": "sha512-65wXEmZIrX2ADwC8i/qFL4EWLSbeuBpAm3suuX1vu4IQkKd+wLT/HU/BOl84kp91u2SxPkPDyQgu4yrqp8vwVA==", "cpu": [ "riscv64" ], @@ -2142,9 +2060,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.55.0.tgz", - "integrity": "sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.61.0.tgz", + "integrity": "sha512-TVvhgMvor7Qa6COeXxCJ7ENOM+lcAOGsQ0iUdPSCv2hxb9qSHLQ4XF1h50S6RE1gBOJ0WV3rNukg4JJJP1LWRA==", "cpu": [ "s390x" ], @@ -2159,9 +2077,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.55.0.tgz", - "integrity": "sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.61.0.tgz", + "integrity": "sha512-SjpS5uYuFoDnDdZPwZE59ndF95AsY47R5MliuneTWR1pDm2CxGJaYXbKULI71t5TVfLQUWmrHEGRL9xvuq6dnA==", "cpu": [ "x64" ], @@ -2176,9 +2094,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.55.0.tgz", - "integrity": "sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.61.0.tgz", + "integrity": "sha512-gGfAeGD4sNJGILZbc/yKcIimO9wQnPMoYp9swAaKeEtwsSQAbU+rsdQze5SBtIP6j0QDzeYd4XSSUCRCF+LIeQ==", "cpu": [ "x64" ], @@ -2193,9 +2111,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.55.0.tgz", - "integrity": "sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.61.0.tgz", + "integrity": "sha512-OlVT0LrG/ct33EVtWRyR+B/othwmDWeRxfi13wUdPeb3lAT5TgTcFDcfLfarZtzB4W1nWF/zICMgYdkggX2WmQ==", "cpu": [ "arm64" ], @@ -2210,9 +2128,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.55.0.tgz", - "integrity": "sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.61.0.tgz", + "integrity": "sha512-vI//NZPJk6DToiovPtaiwD4iQ7kO1r5ReWQD0sOOyKRtP3E2f6jxin4uvwi3OvDzHA2EFfd7DcZl5dtkQh7g1w==", "cpu": [ "arm64" ], @@ -2227,9 +2145,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.55.0.tgz", - "integrity": "sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.61.0.tgz", + "integrity": "sha512-0ySj4/4zd2XjePs3XAQq7IigIstN4LPQZgCyigX5/ERMLjdWAJfnxcTsrtxZxuij8guJW8foXuHmhGxW0H4dDA==", "cpu": [ "ia32" ], @@ -2244,9 +2162,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.55.0.tgz", - "integrity": "sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.61.0.tgz", + "integrity": "sha512-0xgSiyeqDLDZxXoe9CVJrOx3TUVsfyoOY7cNi03JbItNcC9WCZqrSNdrAbHONxhSPaVh/lzfnDcON1RqSUMhHw==", "cpu": [ "x64" ], @@ -2271,22 +2189,6 @@ "node": ">=14" } }, - "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -2372,399 +2274,137 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", - "cpu": [ - "arm64" - ], + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@secretlint/types": "^10.2.2" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.0.0" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", - "cpu": [ - "arm64" - ], + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.0.0" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", - "cpu": [ - "x64" - ], + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.0.0" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", - "cpu": [ - "x64" - ], + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.0.0" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", - "cpu": [ - "arm" - ], + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", - "cpu": [ - "arm64" - ], + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.0.0" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", - "cpu": [ - "arm64" - ], + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "node-sarif-builder": "^3.2.0" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@secretlint/config-creator": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", - "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/types": "^10.2.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/config-loader": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", - "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/profiler": "^10.2.2", - "@secretlint/resolver": "^10.2.2", - "@secretlint/types": "^10.2.2", - "ajv": "^8.17.1", - "debug": "^4.4.1", - "rc-config-loader": "^4.1.3" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/core": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", - "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/profiler": "^10.2.2", - "@secretlint/types": "^10.2.2", - "debug": "^4.4.1", - "structured-source": "^4.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/formatter": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", - "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/resolver": "^10.2.2", - "@secretlint/types": "^10.2.2", - "@textlint/linter-formatter": "^15.2.0", - "@textlint/module-interop": "^15.2.0", - "@textlint/types": "^15.2.0", - "chalk": "^5.4.1", - "debug": "^4.4.1", - "pluralize": "^8.0.0", - "strip-ansi": "^7.1.0", - "table": "^6.9.0", - "terminal-link": "^4.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/formatter/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@secretlint/node": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", - "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/config-loader": "^10.2.2", - "@secretlint/core": "^10.2.2", - "@secretlint/formatter": "^10.2.2", - "@secretlint/profiler": "^10.2.2", - "@secretlint/source-creator": "^10.2.2", - "@secretlint/types": "^10.2.2", - "debug": "^4.4.1", - "p-map": "^7.0.3" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/profiler": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", - "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", - "dev": true, - "license": "MIT" - }, - "node_modules/@secretlint/resolver": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", - "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@secretlint/secretlint-formatter-sarif": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", - "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "node-sarif-builder": "^3.2.0" - } - }, - "node_modules/@secretlint/secretlint-rule-no-dotenv": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", - "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", "dev": true, "license": "MIT", "dependencies": { @@ -2832,9 +2472,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", - "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", + "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2842,9 +2482,9 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-9.0.2.tgz", - "integrity": "sha512-H/JSxa4GNKZuuU41E3b8Y3tbSEx8y4uq4UH1C56ONQac16HblReJomIvv3Ud7ANQHQmkeSowY49Ij972e/pGxQ==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2862,29 +2502,63 @@ "node": ">=4" } }, + "node_modules/@srl-labs/clab-ui": { + "version": "0.0.26", + "resolved": "https://npm.pkg.github.com/download/@srl-labs/clab-ui/0.0.26/2fd1e69319179960a5b293a95e31bc7d445a517f", + "integrity": "sha512-LX3ctJIgO9R5NPUNRTrNml1lVjQJwe6y2C5LgArOyIhjUHh/zPutxcO2k22Lz6AiOxZaJPnmYVFg6psk5lX6nw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@fontsource/roboto": "^5.2.10", + "@mui/icons-material": "^7.3.10", + "@mui/material": "^7.3.10", + "@mui/x-charts": "^8.28.2", + "@mui/x-tree-view": "^8.28.3", + "@xyflow/react": "^12.10.2", + "ajv": "^8.18.0", + "d3-force": "^3.0.0", + "dompurify": "^3.4.1", + "highlight.js": "^11.11.1", + "maplibre-gl": "^5.24.0", + "markdown-it": "^14.1.1", + "markdown-it-emoji": "^3.0.0", + "monaco-editor": "^0.55.1", + "yaml": "^2.8.3", + "zustand": "^4.5.7" + }, + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5" + } + }, "node_modules/@textlint/ast-node-types": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.5.2.tgz", - "integrity": "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.5.4.tgz", + "integrity": "sha512-bVtB6VEy9U9DpW8cTt25k5T+lz86zV5w6ImePZqY1AXzSuPhqQNT77lkMPxonXzUducEIlSvUu3o7sKw3y9+Sw==", "dev": true, "license": "MIT" }, "node_modules/@textlint/linter-formatter": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.5.2.tgz", - "integrity": "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.5.4.tgz", + "integrity": "sha512-D9qJedKBLmAo+kiudop4UKgSxXMi4O8U86KrCidVXZ9RsK0NSVIw6+r2rlMUOExq79iEY81FRENyzmNVRxDBsg==", "dev": true, "license": "MIT", "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", - "@textlint/module-interop": "15.5.2", - "@textlint/resolver": "15.5.2", - "@textlint/types": "15.5.2", + "@textlint/module-interop": "15.5.4", + "@textlint/resolver": "15.5.4", + "@textlint/types": "15.5.4", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", - "lodash": "^4.17.23", + "lodash": "^4.18.1", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", @@ -2923,27 +2597,27 @@ } }, "node_modules/@textlint/module-interop": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.5.2.tgz", - "integrity": "sha512-mg6rMQ3+YjwiXCYoQXbyVfDucpTa1q5mhspd/9qHBxUq4uY6W8GU42rmT3GW0V1yOfQ9z/iRrgPtkp71s8JzXg==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.5.4.tgz", + "integrity": "sha512-JyAUd26ll3IFF87LP0uGoa8Tzw5ZKiYvGs6v8jLlzyND1lUYCI4+2oIAslrODLkf0qwoCaJrBQWM3wsw+asVGQ==", "dev": true, "license": "MIT" }, "node_modules/@textlint/resolver": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.5.2.tgz", - "integrity": "sha512-YEITdjRiJaQrGLUWxWXl4TEg+d2C7+TNNjbGPHPH7V7CCnXm+S9GTjGAL7Q2WSGJyFEKt88Jvx6XdJffRv4HEA==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.5.4.tgz", + "integrity": "sha512-5GUagtpQuYcmhlOzBGdmVBvDu5lKgVTjwbxtdfoidN4OIqblIxThJHHjazU+ic+/bCIIzI2JcOjHGSaRmE8Gcg==", "dev": true, "license": "MIT" }, "node_modules/@textlint/types": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.5.2.tgz", - "integrity": "sha512-sJOrlVLLXp4/EZtiWKWq9y2fWyZlI8GP+24rnU5avtPWBIMm/1w97yzKrAqYF8czx2MqR391z5akhnfhj2f/AQ==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.5.4.tgz", + "integrity": "sha512-mY28j2U7nrWmZbxyKnRvB8vJxJab4AxqOobLfb6iozrLelJbqxcOTvBQednadWPfAk9XWaZVMqUr9Nird3mutg==", "dev": true, "license": "MIT", "dependencies": { - "@textlint/ast-node-types": "15.5.2" + "@textlint/ast-node-types": "15.5.4" } }, "node_modules/@ts-graphviz/adapter": { @@ -3036,17 +2710,6 @@ "node": ">=18" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3082,13 +2745,6 @@ "@types/d3-selection": "*" } }, - "node_modules/@types/d3-force": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", - "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/d3-format": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", @@ -3219,41 +2875,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/markdown-it-emoji": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/markdown-it-emoji/-/markdown-it-emoji-3.0.1.tgz", - "integrity": "sha512-cz1j8R35XivBqq9mwnsrP2fsz2yicLhB8+PDtuVkKOExwEdsVBNI+ROL3sbhtR5occRZ66vT0QnwFZCqdjf3pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/markdown-it": "^14" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/mocha": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", @@ -3262,13 +2883,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/normalize-package-data": { @@ -3330,9 +2951,9 @@ "license": "MIT" }, "node_modules/@types/sinon": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-21.0.0.tgz", - "integrity": "sha512-+oHKZ0lTI+WVLxx1IbJDNmReQaIsQJjN2e7UUrJHEeByG7bFeKJYsv1E75JxTQ9QKJDp21bAa/0W2Xo4srsDnw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-21.0.1.tgz", + "integrity": "sha512-5yoJSqLbjH8T9V2bksgRayuhpZy+723/z6wBOR+Soe4ZlXC0eW8Na71TeaZPUWDQvM7LYKa9UGFc6LRqxiR5fQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3392,21 +3013,21 @@ "optional": true }, "node_modules/@types/vscode": { - "version": "1.110.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz", - "integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==", + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.99.0.tgz", + "integrity": "sha512-30sjmas1hQ0gVbX68LAWlm/YYlEqUErunPJJKLpEl+xhK0mKn+jyzlCOpsdTwfkZfPy4U6CDkmygBLC3AB8W9Q==", "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", - "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.1", - "@typescript-eslint/types": "^8.56.1", + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "engines": { @@ -3417,13 +3038,13 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", - "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", "dev": true, "license": "MIT", "engines": { @@ -3434,13 +3055,13 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", - "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", "dev": true, "license": "MIT", "engines": { @@ -3452,21 +3073,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", - "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.1", - "@typescript-eslint/tsconfig-utils": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3476,7 +3097,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { @@ -3490,9 +3111,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3503,13 +3124,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -3518,27 +3139,14 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", - "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3550,9 +3158,9 @@ } }, "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.4.tgz", - "integrity": "sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", + "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", "dev": true, "license": "MIT", "dependencies": { @@ -3564,36 +3172,10 @@ "node": ">=20.0.0" } }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, "node_modules/@vscode/vsce": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz", - "integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==", + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.1.tgz", + "integrity": "sha512-MPn5p+DoudI+3GfJSpAZZraE1lgLv0LcwbH3+xy7RgEhty3UIkmUMUA+5jPTDaxXae00AnX5u77FxGM8FhfKKA==", "dev": true, "license": "MIT", "dependencies": { @@ -3624,7 +3206,7 @@ "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", - "yauzl": "^2.3.1", + "yauzl": "^3.2.1", "yazl": "^2.2.2" }, "bin": { @@ -3782,28 +3364,15 @@ "win32" ] }, - "node_modules/@vscode/vsce/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@vue/compiler-core": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", - "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/shared": "3.5.29", + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" @@ -3823,60 +3392,60 @@ } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", - "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.29", - "@vue/shared": "3.5.29" + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", - "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz", + "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/compiler-core": "3.5.29", - "@vue/compiler-dom": "3.5.29", - "@vue/compiler-ssr": "3.5.29", - "@vue/shared": "3.5.29", + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.6", + "postcss": "^8.5.10", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", - "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz", + "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.29", - "@vue/shared": "3.5.29" + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" } }, "node_modules/@vue/shared": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", - "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", "dev": true, "license": "MIT" }, "node_modules/@xyflow/react": { - "version": "12.10.1", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", - "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", "dev": true, "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.75", + "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -3886,9 +3455,9 @@ } }, "node_modules/@xyflow/system": { - "version": "0.0.75", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", - "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", "dev": true, "license": "MIT", "dependencies": { @@ -3970,9 +3539,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -4071,9 +3640,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -4159,9 +3728,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.27", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", "dev": true, "funding": [ { @@ -4179,8 +3748,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -4271,9 +3840,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4370,9 +3939,9 @@ "license": "BSD-2-Clause" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -4401,9 +3970,9 @@ "license": "ISC" }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -4421,11 +3990,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -4567,9 +4136,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001777", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", - "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", "dev": true, "funding": [ { @@ -4911,9 +4480,9 @@ } }, "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { @@ -5350,9 +4919,9 @@ } }, "node_modules/dependency-cruiser": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-17.3.9.tgz", - "integrity": "sha512-LwaotlB9bZ8zhdFGGYf/g2oYkYj7YNxlqx1btL/XIYGob/aKRArsSwkLKo+ZrHiegsEArQVg4ZQ3NhAh8uk+hg==", + "version": "17.3.10", + "resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-17.3.10.tgz", + "integrity": "sha512-jF5WaIb+O+wLabXrQE7iBY2zYBEW8VlnuuL0+iZPvZHGhTaAYdLk31DI0zkwhcGE8CiHcDwGhMnn3PfOAYnVdQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5362,12 +4931,12 @@ "acorn-loose": "8.5.2", "acorn-walk": "8.3.5", "commander": "14.0.3", - "enhanced-resolve": "5.20.0", + "enhanced-resolve": "5.20.1", "ignore": "7.0.5", "interpret": "3.1.1", "is-installed-globally": "1.0.0", "json5": "2.2.3", - "picomatch": "4.0.3", + "picomatch": "4.0.4", "prompts": "2.4.2", "rechoir": "0.8.0", "safe-regex": "2.1.1", @@ -5390,24 +4959,11 @@ "node_modules/dependency-cruiser/node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/dependency-cruiser/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=20" } }, "node_modules/dependency-graph": { @@ -5421,15 +4977,15 @@ } }, "node_modules/dependency-tree": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/dependency-tree/-/dependency-tree-11.4.0.tgz", - "integrity": "sha512-r4wZ1pfv8eQrnoWbIGdrJTVmlb0dkXdwBjKsotKO4gmfqrOsAMG+0+cfA5EZ3NO8umc85twXOl1eO27E5pjTzw==", + "version": "11.4.3", + "resolved": "https://registry.npmjs.org/dependency-tree/-/dependency-tree-11.4.3.tgz", + "integrity": "sha512-Y2gzOJ2Rb2X7MN6pT9llWpXxl5J5s5/11CBpJ5b85DjEqZH7jv3T9RO6HRV/PI/3MDmaKn/g7uoYdYmSb9vLlw==", "dev": true, "license": "MIT", "dependencies": { "commander": "^12.1.0", - "filing-cabinet": "^5.2.0", - "precinct": "^12.2.0", + "filing-cabinet": "^5.3.0", + "precinct": "^12.3.1", "typescript": "^5.9.3" }, "bin": { @@ -5445,14 +5001,15 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=8" } }, "node_modules/detective-amd": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/detective-amd/-/detective-amd-6.0.1.tgz", - "integrity": "sha512-TtyZ3OhwUoEEIhTFoc1C9IyJIud3y+xYkSRjmvCt65+ycQuc3VcBrPRTMWoO/AnuCyOB8T5gky+xf7Igxtjd3g==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/detective-amd/-/detective-amd-6.0.2.tgz", + "integrity": "sha512-qX4zkNVcufOoo7pKlRnLHEzUwDcqIY5N9FEuNJN+rDUjct3gikNdVJXRfpI6sG/Y9pfIMjcXeNdHV1oYulxjmw==", "dev": true, "license": "MIT", "dependencies": { @@ -5469,9 +5026,9 @@ } }, "node_modules/detective-cjs": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detective-cjs/-/detective-cjs-6.1.0.tgz", - "integrity": "sha512-Qt3S4IddVNDb+71lm+jmt5NznIsgcKlibTnrw9Zr91rT9vRwKp+73+ImqLTNrQj4YuOxnzrC7GwIAVwF7136XQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/detective-cjs/-/detective-cjs-6.1.1.tgz", + "integrity": "sha512-pSh7mkCKEtLlmANqLu3KDFS3NV8Hx41jy/JF1/gAWOgU+Uo5QTkeI1tWNP4dWGo4L0E9j18Ez9EPsTleautKqA==", "dev": true, "license": "MIT", "dependencies": { @@ -5483,9 +5040,9 @@ } }, "node_modules/detective-es6": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/detective-es6/-/detective-es6-5.0.1.tgz", - "integrity": "sha512-XusTPuewnSUdoxRSx8OOI6xIA/uld/wMQwYsouvFN2LAg7HgP06NF1lHRV3x6BZxyL2Kkoih4ewcq8hcbGtwew==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/detective-es6/-/detective-es6-5.0.2.tgz", + "integrity": "sha512-+qHHGYhjupiVs4rnIpI9nZ5B130A4AmE35ZX1w33hb46vcZ7T3jfDbvmPw0FhWtMHn5BS5HHu7ZtnZ53bMcXZA==", "dev": true, "license": "MIT", "dependencies": { @@ -5551,13 +5108,13 @@ } }, "node_modules/detective-typescript": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/detective-typescript/-/detective-typescript-14.0.0.tgz", - "integrity": "sha512-pgN43/80MmWVSEi5LUuiVvO/0a9ss5V7fwVfrJ4QzAQRd3cwqU1SfWGXJFcNKUqoD5cS+uIovhw5t/0rSeC5Mw==", + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/detective-typescript/-/detective-typescript-14.1.2.tgz", + "integrity": "sha512-bIeEn0eVi/JRsE1YizBR2ilnMlWRAIBJJ6kXCKNFxEEWhUcEY3R6I3KYIAy48ieURbD1hcb3Ebvl8AqeoPMSzg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "^8.23.0", + "@typescript-eslint/typescript-estree": "^8.58.2", "ast-module-types": "^6.0.1", "node-source-walk": "^7.0.1" }, @@ -5565,29 +5122,29 @@ "node": ">=18" }, "peerDependencies": { - "typescript": "^5.4.4" + "typescript": "^5.4.4 || ^6.0.2" } }, "node_modules/detective-vue2": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/detective-vue2/-/detective-vue2-2.2.0.tgz", - "integrity": "sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/detective-vue2/-/detective-vue2-2.3.0.tgz", + "integrity": "sha512-3gwbZPqVTm9sL9XdZsgEJ7x4x99O853VVZHapQAiEkGuMJMpFPjHDrecSgfqnS5JW3FJfYXesLZGvUOibjn49g==", "dev": true, "license": "MIT", "dependencies": { "@dependents/detective-less": "^5.0.1", - "@vue/compiler-sfc": "^3.5.13", + "@vue/compiler-sfc": "^3.5.32", "detective-es6": "^5.0.1", "detective-sass": "^6.0.1", "detective-scss": "^5.0.1", "detective-stylus": "^5.0.1", - "detective-typescript": "^14.0.0" + "detective-typescript": "^14.1.0" }, "engines": { "node": ">=18" }, "peerDependencies": { - "typescript": "^5.4.4" + "typescript": "^5.4.4 || ^6.0.2" } }, "node_modules/diff": { @@ -5601,9 +5158,9 @@ } }, "node_modules/docker-modem": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", - "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5617,16 +5174,16 @@ } }, "node_modules/dockerode": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.9.tgz", - "integrity": "sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz", + "integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", - "docker-modem": "^5.0.6", + "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4", "uuid": "^10.0.0" @@ -5712,9 +5269,9 @@ } }, "node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", + "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", "dev": true, "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { @@ -5793,9 +5350,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.307", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", - "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", "dev": true, "license": "ISC" }, @@ -5831,9 +5388,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", - "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "dev": true, "license": "MIT", "dependencies": { @@ -5930,9 +5487,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5943,32 +5500,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escalade": { @@ -6188,16 +5745,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -6217,21 +5764,21 @@ } }, "node_modules/filing-cabinet": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/filing-cabinet/-/filing-cabinet-5.2.0.tgz", - "integrity": "sha512-eNrCJGdYQY0tV+ACNesQ7vb2aMxD76NM7THayMn0Z5XBt1Tonr4vbVN+FbhHfekKGQG9O5UaciDDR7+dw8P9ZA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/filing-cabinet/-/filing-cabinet-5.3.0.tgz", + "integrity": "sha512-2EwtzdQkC37FJxDOrKuEOplTFzzaToCqzT008DrIWW27RQ6psxitfUi6hct5mUhMHO7C6xopOhxubyjyPCapbQ==", "dev": true, "license": "MIT", "dependencies": { "app-module-path": "^2.2.0", "commander": "^12.1.0", - "enhanced-resolve": "^5.20.0", + "enhanced-resolve": "^5.20.1", "module-definition": "^6.0.1", "module-lookup-amd": "^9.1.1", - "resolve": "^1.22.11", + "resolve": "^1.22.12", "resolve-dependency-path": "^4.0.1", - "sass-lookup": "^6.1.0", - "stylus-lookup": "^6.1.0", + "sass-lookup": "^6.1.1", + "stylus-lookup": "^6.1.1", "tsconfig-paths": "^4.2.0", "typescript": "^5.9.3" }, @@ -6367,9 +5914,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6399,9 +5946,9 @@ } }, "node_modules/get-amd-module-type": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-amd-module-type/-/get-amd-module-type-6.0.1.tgz", - "integrity": "sha512-MtjsmYiCXcYDDrGqtNbeIYdAl85n+5mSv2r3FbzER/YV3ZILw4HNNIw34HuV5pyl0jzs6GFYU1VHVEefhgcNHQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/get-amd-module-type/-/get-amd-module-type-6.0.2.tgz", + "integrity": "sha512-7zShVYAYtMnj9S65CfN+hvpBCByfuB1OY8xID01nZEzXTZbx4YyysAfi+nMl95JSR6odt4q8TCj2W63KAoyVLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6492,16 +6039,6 @@ "license": "MIT", "optional": true }, - "node_modules/gitignore-to-glob": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/gitignore-to-glob/-/gitignore-to-glob-0.3.0.tgz", - "integrity": "sha512-mk74BdnK7lIwDHnotHddx1wsjMOFIThpLY3cPNniJ/2fA/tlLzHnFxIdR+4sLOu5KGgQJdij4kjJ2RoUNnCNMA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.4 <5 || >=6.9" - } - }, "node_modules/gl-matrix": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", @@ -6558,9 +6095,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6571,13 +6108,13 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -6712,9 +6249,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, "license": "MIT", "dependencies": { @@ -6774,26 +6311,6 @@ "node": ">=10" } }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/hosted-git-info/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -7341,31 +6858,30 @@ } }, "node_modules/jscpd": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-4.0.8.tgz", - "integrity": "sha512-d2VNT/2Hv4dxT2/59He8Lyda4DYOxPRyRG9zBaOpTZAqJCVf2xLrBlZkT8Va6Lo9u3X2qz8Bpq4HrDi4JsrQhA==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-4.0.9.tgz", + "integrity": "sha512-fp6Sh42W3mIPoQgZmgYmKDLQzEDnnX2vaGlTN4haILkB2vsi+ewcCHEtWR/2CR/QbsBvAvsNo8U5Sa+p9aHiGw==", "dev": true, "license": "MIT", "dependencies": { - "@jscpd/badge-reporter": "4.0.4", - "@jscpd/core": "4.0.4", - "@jscpd/finder": "4.0.4", - "@jscpd/html-reporter": "4.0.4", - "@jscpd/tokenizer": "4.0.4", + "@jscpd/badge-reporter": "4.0.5", + "@jscpd/core": "4.0.5", + "@jscpd/finder": "4.0.5", + "@jscpd/html-reporter": "4.0.5", + "@jscpd/tokenizer": "4.0.5", "colors": "^1.4.0", "commander": "^5.0.0", "fs-extra": "^11.2.0", - "gitignore-to-glob": "^0.3.0", - "jscpd-sarif-reporter": "4.0.6" + "jscpd-sarif-reporter": "4.0.7" }, "bin": { "jscpd": "bin/jscpd" } }, "node_modules/jscpd-sarif-reporter": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/jscpd-sarif-reporter/-/jscpd-sarif-reporter-4.0.6.tgz", - "integrity": "sha512-b9Sm3IPZ3+m8Lwa4gZa+4/LhDhlc/ZLEsLXKSOy1DANQ6kx0ueqZT+fUHWEdQ6m0o3+RIVIa7DmvLSojQD05ng==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/jscpd-sarif-reporter/-/jscpd-sarif-reporter-4.0.7.tgz", + "integrity": "sha512-Q/VlfTI/Nbjc8dZ/2pDVIf1aRi2bM2CTYujcAoeYr7brRnS4o5ZeW86W8q7MM7cQu40gezlNckl+E9wKFSMFiA==", "dev": true, "license": "MIT", "dependencies": { @@ -7446,9 +6962,9 @@ "license": "MIT" }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7481,19 +6997,6 @@ "npm": ">=6" } }, - "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jstransformer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", @@ -7522,311 +7025,50 @@ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "dev": true, - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/kdbush": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", - "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", - "dev": true, - "license": "ISC" - }, - "node_modules/keytar": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", - "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^4.3.0", - "prebuild-install": "^7.0.1" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", "dev": true, - "license": "MPL-2.0", + "license": "ISC" + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=6" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=6" } }, "node_modules/lilconfig": { @@ -7876,9 +7118,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -8003,6 +7245,19 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/madge": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/madge/-/madge-8.0.0.tgz", @@ -8063,21 +7318,21 @@ } }, "node_modules/maplibre-gl": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.20.1.tgz", - "integrity": "sha512-57YIgfRct+rrk78ldoWRuLWRnXV/1vM2Rk0QYfEDQmsXdpgbACwvGoREIOZtyDIaq/GJK/ORYEriaAdVZuNfvw==", + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", - "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/tiny-sdf": "^2.1.0", "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^2.0.4", "@mapbox/whoots-js": "^3.1.0", - "@maplibre/geojson-vt": "^6.0.2", - "@maplibre/maplibre-gl-style-spec": "^24.7.0", - "@maplibre/mlt": "^1.1.7", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", "@maplibre/vt-pbf": "^4.3.0", "@types/geojson": "^7946.0.16", "earcut": "^3.0.2", @@ -8097,16 +7352,6 @@ "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" } }, - "node_modules/maplibre-gl/node_modules/@maplibre/geojson-vt": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.2.tgz", - "integrity": "sha512-OnXnV2m1yBULKOlUanNFTiOeXCktvWYY4yWoHVETlp6ShJGUhY3DNt9XzPByL24h4JcoJRccPBlMhH1o8cvmyQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "kdbush": "^4.0.2" - } - }, "node_modules/markdown-it": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", @@ -8208,9 +7453,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -8389,9 +7634,9 @@ } }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -8639,9 +7884,9 @@ } }, "node_modules/module-definition": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/module-definition/-/module-definition-6.0.1.tgz", - "integrity": "sha512-FeVc50FTfVVQnolk/WQT8MX+2WVcDnTGiq6Wo+/+lJ2ET1bRVi3HG3YlJUfqagNMc/kUlFSoR96AJkxGpKz13g==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/module-definition/-/module-definition-6.0.2.tgz", + "integrity": "sha512-SvAU3lB0+Yjbq55yHY3wkRZBOh+fhU1SnIF3IFbTewv6mtAh7yUT8ACHAJ2mGIJ7tCes2QuCL/cl6m0JSZ/ArA==", "dev": true, "license": "MIT", "dependencies": { @@ -8656,9 +7901,9 @@ } }, "node_modules/module-lookup-amd": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/module-lookup-amd/-/module-lookup-amd-9.1.1.tgz", - "integrity": "sha512-JzXhQvud8K3yT9l24XTDMXMQ4/LD9a9oXBcbLP0ubdvBpVrGFsybm5+2PDIl0negUYP1l88fCgjQzoMMg247+Q==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/module-lookup-amd/-/module-lookup-amd-9.1.2.tgz", + "integrity": "sha512-HFEiUNm8/woZFJZcd42wrovEHjHN6nwfNjf2CjiVLbVFRbj+sEmEJn0mrx8JY4/qJP8wSZTtmguikAJBqEuRRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8716,9 +7961,9 @@ "license": "ISC" }, "node_modules/nan": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", - "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", "dev": true, "license": "MIT", "optional": true @@ -8751,9 +7996,9 @@ "optional": true }, "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", "dev": true, "license": "MIT", "optional": true, @@ -8764,20 +8009,6 @@ "node": ">=10" } }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", @@ -8787,9 +8018,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true, "license": "MIT" }, @@ -8855,19 +8086,6 @@ "dev": true, "license": "ISC" }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -9030,9 +8248,9 @@ } }, "node_modules/oxlint": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.55.0.tgz", - "integrity": "sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.61.0.tgz", + "integrity": "sha512-ZC0ALuhDZ6ivOFG+sy0D0pEDN49EvsId98zVlmYdkcXHsEM14m/qTNUEsUpiFiCVbpIxYtVBmmLE87nsbUHohQ==", "dev": true, "license": "MIT", "bin": { @@ -9045,28 +8263,28 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.55.0", - "@oxlint/binding-android-arm64": "1.55.0", - "@oxlint/binding-darwin-arm64": "1.55.0", - "@oxlint/binding-darwin-x64": "1.55.0", - "@oxlint/binding-freebsd-x64": "1.55.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.55.0", - "@oxlint/binding-linux-arm-musleabihf": "1.55.0", - "@oxlint/binding-linux-arm64-gnu": "1.55.0", - "@oxlint/binding-linux-arm64-musl": "1.55.0", - "@oxlint/binding-linux-ppc64-gnu": "1.55.0", - "@oxlint/binding-linux-riscv64-gnu": "1.55.0", - "@oxlint/binding-linux-riscv64-musl": "1.55.0", - "@oxlint/binding-linux-s390x-gnu": "1.55.0", - "@oxlint/binding-linux-x64-gnu": "1.55.0", - "@oxlint/binding-linux-x64-musl": "1.55.0", - "@oxlint/binding-openharmony-arm64": "1.55.0", - "@oxlint/binding-win32-arm64-msvc": "1.55.0", - "@oxlint/binding-win32-ia32-msvc": "1.55.0", - "@oxlint/binding-win32-x64-msvc": "1.55.0" + "@oxlint/binding-android-arm-eabi": "1.61.0", + "@oxlint/binding-android-arm64": "1.61.0", + "@oxlint/binding-darwin-arm64": "1.61.0", + "@oxlint/binding-darwin-x64": "1.61.0", + "@oxlint/binding-freebsd-x64": "1.61.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.61.0", + "@oxlint/binding-linux-arm-musleabihf": "1.61.0", + "@oxlint/binding-linux-arm64-gnu": "1.61.0", + "@oxlint/binding-linux-arm64-musl": "1.61.0", + "@oxlint/binding-linux-ppc64-gnu": "1.61.0", + "@oxlint/binding-linux-riscv64-gnu": "1.61.0", + "@oxlint/binding-linux-riscv64-musl": "1.61.0", + "@oxlint/binding-linux-s390x-gnu": "1.61.0", + "@oxlint/binding-linux-x64-gnu": "1.61.0", + "@oxlint/binding-linux-x64-musl": "1.61.0", + "@oxlint/binding-openharmony-arm64": "1.61.0", + "@oxlint/binding-win32-arm64-msvc": "1.61.0", + "@oxlint/binding-win32-ia32-msvc": "1.61.0", + "@oxlint/binding-win32-x64-msvc": "1.61.0" }, "peerDependencies": { - "oxlint-tsgolint": ">=0.15.0" + "oxlint-tsgolint": ">=0.18.0" }, "peerDependenciesMeta": { "oxlint-tsgolint": { @@ -9075,21 +8293,21 @@ } }, "node_modules/oxlint-tsgolint": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.17.0.tgz", - "integrity": "sha512-TdrKhDZCgEYqONFo/j+KvGan7/k3tP5Ouz88wCqpOvJtI2QmcLfGsm1fcMvDnTik48Jj6z83IJBqlkmK9DnY1A==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.21.1.tgz", + "integrity": "sha512-O2hxiT14C2HJkwzBU6CQBFPoagSd/IcV+Tt3e3UUaXFwbW4BO5DSDPSSboc3UM5MIDY+MLyepvtQwBQafNxWdw==", "dev": true, "license": "MIT", "bin": { "tsgolint": "bin/tsgolint.js" }, "optionalDependencies": { - "@oxlint-tsgolint/darwin-arm64": "0.17.0", - "@oxlint-tsgolint/darwin-x64": "0.17.0", - "@oxlint-tsgolint/linux-arm64": "0.17.0", - "@oxlint-tsgolint/linux-x64": "0.17.0", - "@oxlint-tsgolint/win32-arm64": "0.17.0", - "@oxlint-tsgolint/win32-x64": "0.17.0" + "@oxlint-tsgolint/darwin-arm64": "0.21.1", + "@oxlint-tsgolint/darwin-x64": "0.21.1", + "@oxlint-tsgolint/linux-arm64": "0.21.1", + "@oxlint-tsgolint/linux-x64": "0.21.1", + "@oxlint-tsgolint/win32-arm64": "0.21.1", + "@oxlint-tsgolint/win32-x64": "0.21.1" } }, "node_modules/p-limit": { @@ -9304,9 +8522,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -9351,9 +8569,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -9373,38 +8591,6 @@ "node": ">=0.10.0" } }, - "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -9416,9 +8602,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "dev": true, "funding": [ { @@ -9499,9 +8685,9 @@ } }, "node_modules/postcss-cli/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -9671,27 +8857,27 @@ } }, "node_modules/precinct": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/precinct/-/precinct-12.2.0.tgz", - "integrity": "sha512-NFBMuwIfaJ4SocE9YXPU/n4AcNSoFMVFjP72nvl3cx69j/ke61/hPOWFREVxLkFhhEGnA8ZuVfTqJBa+PK3b5w==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/precinct/-/precinct-12.3.1.tgz", + "integrity": "sha512-wGyTIvtxh2S2NAHxTJj0YymxWOIcEDotu17yHoQUd2Bz2C07LrS28L1nvXDMxrCHvHmV6KTlaIQy5PzRm7Y8rg==", "dev": true, "license": "MIT", "dependencies": { "@dependents/detective-less": "^5.0.1", "commander": "^12.1.0", "detective-amd": "^6.0.1", - "detective-cjs": "^6.0.1", + "detective-cjs": "^6.1.0", "detective-es6": "^5.0.1", "detective-postcss": "^7.0.1", "detective-sass": "^6.0.1", "detective-scss": "^5.0.1", "detective-stylus": "^5.0.1", - "detective-typescript": "^14.0.0", - "detective-vue2": "^2.2.0", + "detective-typescript": "^14.1.1", + "detective-vue2": "^2.3.0", "module-definition": "^6.0.1", "node-source-walk": "^7.0.1", - "postcss": "^8.5.1", - "typescript": "^5.7.3" + "postcss": "^8.5.10", + "typescript": "^5.9.3" }, "bin": { "precinct": "bin/cli.js" @@ -9701,9 +8887,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -9786,9 +8972,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", @@ -9811,20 +8997,20 @@ } }, "node_modules/protocol-buffers-schema": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", - "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "dev": true, "license": "MIT" }, "node_modules/pug": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.3.tgz", - "integrity": "sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.4.tgz", + "integrity": "sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==", "dev": true, "license": "MIT", "dependencies": { - "pug-code-gen": "^3.0.3", + "pug-code-gen": "^3.0.4", "pug-filters": "^4.0.0", "pug-lexer": "^5.0.1", "pug-linker": "^4.0.0", @@ -9847,9 +9033,9 @@ } }, "node_modules/pug-code-gen": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.3.tgz", - "integrity": "sha512-cYQg0JW0w32Ux+XTeZnBEeuWrAY7/HNE6TWnhiHGnnRYlCgyAUPoyh9KzCMa9WhcJlJ1AtQqpEYHc+vbCzA+Aw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.4.tgz", + "integrity": "sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==", "dev": true, "license": "MIT", "dependencies": { @@ -9975,9 +9161,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -10082,9 +9268,9 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "dev": true, "license": "MIT", "engines": { @@ -10092,22 +9278,22 @@ } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.5" } }, "node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", "dev": true, "license": "MIT" }, @@ -10327,12 +9513,13 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -10409,47 +9596,6 @@ "node": ">=0.10.0" } }, - "node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", - "dev": true, - "license": "MIT" - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -10533,14 +9679,14 @@ "license": "MIT" }, "node_modules/sass-lookup": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/sass-lookup/-/sass-lookup-6.1.0.tgz", - "integrity": "sha512-Zx+lVyoWqXZxHuYWlTA17Z5sczJ6braNT2C7rmClw+c4E7r/n911Zwss3h1uHI9reR5AgHZyNHF7c2+VIp5AUA==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/sass-lookup/-/sass-lookup-6.1.1.tgz", + "integrity": "sha512-12dvZdQYTeKZ1ypjuiijZYuMZ1m0F+4+BkRX5yJi2WA9W3DBUrcdCt7bVuKlagHl11n8eYtalWDle+m98Ol2DA==", "dev": true, "license": "MIT", "dependencies": { "commander": "^12.1.0", - "enhanced-resolve": "^5.18.0" + "enhanced-resolve": "^5.20.0" }, "bin": { "sass-lookup": "bin/cli.js" @@ -10550,9 +9696,9 @@ } }, "node_modules/sax": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", - "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -10588,6 +9734,19 @@ "node": ">=20.0.0" } }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -10642,14 +9801,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -10760,17 +9919,16 @@ } }, "node_modules/sinon": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.2.tgz", - "integrity": "sha512-VHV4UaoxIe5jrMd89Y9duI76T5g3Lp+ET+ctLhLDaZtSznDPah1KKpRElbdBV4RwqWSw2vadFiVs9Del7MbVeQ==", + "version": "21.1.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.1.2.tgz", + "integrity": "sha512-FS6mN+/bx7e2ajpXkEmOcWB6xBzWiuNoAQT18/+a20SS4U7FSYl8Ms7N6VTUxN/1JAjkx7aXp+THMC8xdpp0gA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^15.1.1", - "@sinonjs/samsam": "^9.0.2", - "diff": "^8.0.3", - "supports-color": "^7.2.0" + "@sinonjs/fake-timers": "^15.3.2", + "@sinonjs/samsam": "^10.0.2", + "diff": "^8.0.4" }, "funding": { "type": "opencollective", @@ -10778,9 +9936,9 @@ } }, "node_modules/sinon/node_modules/diff": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", - "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -11116,9 +10274,9 @@ "license": "MIT" }, "node_modules/stylus-lookup": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/stylus-lookup/-/stylus-lookup-6.1.0.tgz", - "integrity": "sha512-5QSwgxAzXPMN+yugy61C60PhoANdItfdjSEZR8siFwz7yL9jTmV0UBKDCfn3K8GkGB4g0Y9py7vTCX8rFu4/pQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/stylus-lookup/-/stylus-lookup-6.1.1.tgz", + "integrity": "sha512-0+xmFLaqWksv5/pMiZtONG6gP82YNGVWgKiQXvw8cdKVFEJ++X9dySGR0hG+A+78PBtbHPqiJzXi2ZKoWr/7Sg==", "dev": true, "license": "MIT", "dependencies": { @@ -11225,9 +10383,9 @@ } }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -11326,21 +10484,21 @@ } }, "node_modules/thenby": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/thenby/-/thenby-1.3.4.tgz", - "integrity": "sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/thenby/-/thenby-1.4.1.tgz", + "integrity": "sha512-D5a/bO0KdalOE3q8MlrRmSxjbKZHT3MQmXkJP+r97Vw8MMwOZKOwUSEyTtK7eSMj2y0kyAjpYMRMZmmLw1FtNQ==", "dev": true, "license": "Apache-2.0" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -11387,9 +10545,9 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -11558,9 +10716,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { @@ -11568,9 +10726,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, @@ -11686,100 +10844,6 @@ "url": "https://bevry.me/fund" } }, - "node_modules/vite": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", - "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/runtime": "0.115.0", - "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.9", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.0.0-alpha.31", - "esbuild": "^0.27.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -12026,10 +11090,17 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { @@ -12088,14 +11159,17 @@ } }, "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.0.tgz", + "integrity": "sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ==", "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" } }, "node_modules/yazl": { diff --git a/package.json b/package.json index a5cd7e9db..2b092dd6f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "icon": "resources/containerlab.png", "description": "Manages containerlab topologies in VS Code", "author": "SRL Labs", - "version": "0.24.2", + "version": "0.25.0", "homepage": "https://containerlab.dev/manual/vsc-extension/", "engines": { "vscode": "^1.99.0", @@ -150,6 +150,12 @@ "category": "Containerlab", "icon": "$(info)" }, + { + "command": "containerlab.images.manage", + "title": "Manage Images", + "category": "Containerlab", + "icon": "$(package)" + }, { "command": "containerlab.inspectOneLab", "title": "Inspect", @@ -903,85 +909,64 @@ "compile": "tsc -p .", "watch": "tsc -w -p .", "typecheck": "tsc --noEmit", + "typecheck:local-ui": "tsc --noEmit -p tsconfig.local-ui.json", "build": "node esbuild.config.js", "build:watch": "node esbuild.config.js --watch", "build:dev": "node esbuild.config.js --dev", - "dev": "vite --config dev/vite.config.ts", - "dev:build": "vite build --config dev/vite.config.ts", + "build:local-ui": "CLAB_UI_SOURCE=local node esbuild.config.js", "bundle": "node esbuild.config.js", "package": "rm -rf dist && npm run lint && vsce package", + "package:local-ui": "rm -rf dist && CLAB_UI_SOURCE=local npm run lint:local-ui && CLAB_UI_SOURCE=local vsce package", "vscode:prepublish": "node esbuild.config.js", - "package:dev": "node esbuild.config.js --watch --dev", "lint": "npm run typecheck && (npm run lint:ts & p1=$!; npm run lint:circular & p2=$!; npm run lint:deps & p3=$!; npm run lint:cpd & p4=$!; npm run lint:barrels & p5=$!; npm run lint:prettier & p6=$!; wait $p1; s1=$?; wait $p2; s2=$?; wait $p3; s3=$?; wait $p4; s4=$?; wait $p5; s5=$?; wait $p6; s6=$?; [ $s1 -eq 0 ] && [ $s2 -eq 0 ] && [ $s3 -eq 0 ] && [ $s4 -eq 0 ] && [ $s5 -eq 0 ] && [ $s6 -eq 0 ])", + "lint:local-ui": "npm run typecheck:local-ui && (npm run lint:ts:local-ui & p1=$!; npm run lint:circular:local-ui & p2=$!; npm run lint:deps:local-ui & p3=$!; npm run lint:cpd & p4=$!; npm run lint:barrels & p5=$!; npm run lint:prettier & p6=$!; wait $p1; s1=$?; wait $p2; s2=$?; wait $p3; s3=$?; wait $p4; s4=$?; wait $p5; s5=$?; wait $p6; s6=$?; [ $s1 -eq 0 ] && [ $s2 -eq 0 ] && [ $s3 -eq 0 ] && [ $s4 -eq 0 ] && [ $s5 -eq 0 ] && [ $s6 -eq 0 ])", "lint:ts": "oxlint --type-aware --import-plugin --react-plugin --jsx-a11y-plugin .", + "lint:ts:local-ui": "oxlint --tsconfig tsconfig.local-ui.json --type-aware --import-plugin --react-plugin --jsx-a11y-plugin .", "lint:ts:fix": "oxlint --type-aware --import-plugin --react-plugin --jsx-a11y-plugin . --fix --fix-suggestions", "lint:prettier": "prettier --check \"src/**/*.{ts,tsx}\"", "lint:prettier:fix": "prettier --write \"src/**/*.{ts,tsx}\"", "lint:fix": "npm run lint:ts:fix && npm run lint:prettier:fix", "lint:circular": "madge --circular --extensions ts,tsx src/", + "lint:circular:local-ui": "madge --circular --extensions ts,tsx src/ --ts-config tsconfig.local-ui.json", "lint:deps": "depcruise src --config .dependency-cruiser.cjs", + "lint:deps:local-ui": "depcruise src --config .dependency-cruiser.local-ui.cjs", "lint:cpd": "jscpd src/", "lint:barrels": "node scripts/check-barrel-exports.js", - "test": "npm run test:compile && mocha --extension js --reporter mochawesome \"out/test/**/*.test.js\"", - "test:compile": "tsc -p test/tsconfig.json", - "test:e2e": "playwright test --config test/e2e/playwright.config.ts", - "test:e2e:ui": "playwright test --config test/e2e/playwright.config.ts --ui", - "test:e2e:ui:remote": "npx playwright test --config test/e2e/playwright.config.ts --ui --ui-host=0.0.0.0 --ui-port=8080", - "test:e2e:debug": "playwright test --config test/e2e/playwright.config.ts --debug", - "postinstall": "playwright install chromium" + "test": "npm run test:compile && mocha --exit --extension js --reporter mochawesome \"out/test/**/*.test.js\"", + "test:compile": "rm -rf out/test && tsc -p test/tsconfig.mocha.json" }, "devDependencies": { - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.1", - "@fontsource/roboto": "^5.2.10", - "@mui/icons-material": "^7.3.9", - "@mui/material": "^7.3.9", - "@mui/x-charts": "^8.27.5", - "@mui/x-tree-view": "^8.27.2", - "@playwright/test": "^1.58.2", + "@srl-labs/clab-ui": "0.0.26", "@types/chai": "^5.2.3", - "@types/d3-force": "^3.0.10", "@types/dockerode": "^4.0.1", - "@types/markdown-it": "^14.1.2", - "@types/markdown-it-emoji": "^3.0.1", "@types/mocha": "^10.0.10", - "@types/node": "^25.5.0", + "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@types/sinon": "^21.0.0", - "@types/vscode": "^1.99.0", - "@vitejs/plugin-react": "^6.0.1", - "@vscode/vsce": "^3.7.1", - "@xyflow/react": "^12.10.1", - "ajv": "^8.18.0", + "@types/sinon": "^21.0.1", + "@types/vscode": "1.99.0", + "@vscode/vsce": "^3.9.1", + "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "autoprefixer": "^10.4.27", + "autoprefixer": "^10.5.0", "chai": "^6.2.2", - "d3-force": "^3.0.0", - "dependency-cruiser": "^17.3.9", - "dockerode": "^4.0.9", - "dompurify": "^3.3.3", - "esbuild": "^0.27.4", - "highlight.js": "^11.11.1", - "jscpd": "^4.0.8", + "dependency-cruiser": "^17.3.10", + "dockerode": "^4.0.12", + "esbuild": "^0.28.0", + "jscpd": "^4.0.9", "madge": "^8.0.0", - "maplibre-gl": "^5.20.1", - "markdown-it": "^14.1.1", - "markdown-it-emoji": "^3.0.0", "mocha": "^11.7.5", "mochawesome": "^7.1.4", - "monaco-editor": "^0.55.1", - "oxlint": "^1.55.0", - "oxlint-tsgolint": "^0.17.0", - "postcss": "^8.5.8", + "oxlint": "^1.61.0", + "oxlint-tsgolint": "^0.21.1", + "postcss": "^8.5.12", "postcss-cli": "^11.0.1", "postcss-import": "^16.1.1", - "prettier": "^3.8.1", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "sinon": "^21.0.2", + "prettier": "^3.8.3", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "sinon": "^21.1.2", "typescript": "^5.9.3", - "vite": "^8.0.0", - "yaml": "^2.8.2" + "yaml": "^2.8.3" } -} \ No newline at end of file +} diff --git a/schema/clab.schema.json b/schema/clab.schema.json deleted file mode 100644 index d9700b961..000000000 --- a/schema/clab.schema.json +++ /dev/null @@ -1,2195 +0,0 @@ -{ - "$id": "https://containerlab.dev/clab.schema.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Containerlab topology definition file", - "definitions": { - "env": { - "type": "object", - "description": "environment variables", - "markdownDescription": "[environment variables](https://containerlab.dev/manual/nodes/#env)", - "patternProperties": { - ".+": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] - } - } - }, - "node-config": { - "type": "object", - "description": "topology node configuration container", - "markdownDescription": "topology [node](https://containerlab.dev/manual/nodes/) configuration container", - "properties": { - "image": { - "type": "string", - "description": "container image to use for this node", - "markdownDescription": "container [image](https://containerlab.dev/manual/nodes/#image) to use for this node" - }, - "image-pull-policy": { - "type": "string", - "description": "policy for pulling the referenced container image", - "markdownDescription": "container [image-pull-policy](https://containerlab.dev/manual/nodes/#image-pull-policy) to use for this node", - "enum": [ - "always", - "Always", - "never", - "Never", - "ifnotpresent", - "IfNotPresent" - ] - }, - "restart-policy": { - "type": "string", - "description": "restart policy for the referenced container image", - "markdownDescription": "container [restart-policy](https://containerlab.dev/manual/nodes/#restart-policy) to use for this node", - "enum": [ - "no", - "No", - "on-failure", - "On-failure", - "Always", - "always", - "unless-stopped", - "Unless-stopped" - ] - }, - "kind": { - "type": "string", - "description": "kind of this node", - "markdownDescription": "[kind](https://containerlab.dev/manual/nodes/#kind) of this node", - "enum": [ - "nokia_srlinux", - "arista_ceos", - "juniper_crpd", - "sonic-vs", - "sonic-vm", - "nokia_sros", - "nokia_srsim", - "juniper_vmx", - "juniper_vqfx", - "juniper_vsrx", - "juniper_vjunosrouter", - "juniper_vjunosswitch", - "juniper_vjunosevolved", - "cisco_xrv", - "cisco_xrv9k", - "arista_veos", - "cisco_csr1000v", - "paloalto_panos", - "mikrotik_ros", - "6wind_vsr", - "cisco_n9kv", - "cisco_ftdv", - "dell_ftosv", - "dell_sonic", - "aruba_aoscx", - "linux", - "bridge", - "ovs-bridge", - "border0", - "host", - "keysight_ixia-c-one", - "ipinfusion_ocnos", - "checkpoint_cloudguard", - "ext-container", - "rare", - "cisco_xrd", - "cisco_c8000", - "cisco_c8000v", - "cisco_cat9kv", - "cisco_iol", - "cisco_asav", - "cisco_vios", - "cumulus_cvx", - "huawei_vrp", - "openbsd", - "freebsd", - "generic_vm", - "fortinet_fortigate", - "k8s-kind", - "fdio_vpp", - "vyosnetworks_vyos", - "juniper_cjunosevolved", - "arrcus_arcos", - "f5_bigip-ve", - "cisco_sdwan", - "openwrt", - "spirent_stc", - "veesix_osvbng", - "ostinato" - ] - }, - "license": { - "type": "string", - "description": "path to a license file", - "markdownDescription": "path to a [license](https://containerlab.dev/manual/nodes/#license) file" - }, - "type": { - "type": "string", - "description": "type is a per-node property that can select a special type of a node", - "markdownDescription": "node's [type](https://containerlab.dev/manual/nodes/#type) file" - }, - "group": { - "type": "string", - "description": "grouping parameter of a node. A free form string that is mainly used in sorting elements when graphing", - "markdownDescription": "path to a [license](https://containerlab.dev/manual/nodes/#group) file" - }, - "startup-config": { - "type": "string", - "description": "path to a startup config file (if supported by the kind)", - "markdownDescription": "path to a startup [config file](https://containerlab.dev/manual/nodes/#startup-config) (if supported by the kind)" - }, - "startup-delay": { - "type": "integer", - "description": "Optional startup delay (seconds) to apply", - "markdownDescription": "Optional [startup delay](https://containerlab.dev/manual/nodes/#startup-delay) in seconds" - }, - "enforce-startup-config": { - "type": "boolean", - "description": "Set to `true` to make the node to boot with a startup-config even if the config file is present in the lab directory", - "markdownDescription": "Set to `true` to [make the node to boot with a startup-config](https://containerlab.dev/manual/nodes/#enforce-startup-config) even if the config file is present in the lab directory" - }, - "suppress-startup-config": { - "type": "boolean", - "description": "Set to `true` to prevent a startup-config file from being created (in a Zero-Touch Provisioning lab, for example)", - "markdownDescription": "Set to `true` to [prevent a startup-config file from being created](https://containerlab.dev/manual/nodes/#suppress-startup-config) By default, containerlab will create a startup-config when initially creating a lab." - }, - "auto-remove": { - "type": "boolean", - "description": "Set to `true` to remove the node automatically, instead of auto-restarting", - "markdownDescription": "Set to `true` to [remove the node/container automatically](https://containerlab.dev/manual/nodes/#auto-remove), instead of auto-restarting it" - }, - "exec": { - "type": "array", - "description": "list of commands to execute post deploy", - "markdownDescription": "list of [commands to execute](https://containerlab.dev/manual/nodes/#exec) post deploy", - "minItems": 1, - "items": { - "type": "string" - } - }, - "binds": { - "type": "array", - "description": "list of file/directory bindings", - "markdownDescription": "list of file/directory [bindings](https://containerlab.dev/manual/nodes/#binds)", - "minItems": 1, - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "ports": { - "type": "array", - "description": "list of port mappings", - "markdownDescription": "list of [port](https://containerlab.dev/manual/nodes/#ports) mappings", - "minItems": 0, - "items": { - "type": "string", - "pattern": "^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(%[\\p{N}\\p{L}]+)?:([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]):([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$|^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(%[\\p{N}\\p{L}]+)?:([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]):([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])+(/tcp|/udp|/sctp)$|^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]):([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$|^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]):([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])+(/tcp|/udp|/sctp)$|^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$|^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])-([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]):([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])+(/tcp|/udp|/sctp)?$" - }, - "uniqueItems": true - }, - "env": { - "type": "object", - "$ref": "#/definitions/env" - }, - "credentials": { - "type": "object", - "description": "username and password for SSH/NETCONF/GNMI/etc. (overrides kind default)", - "markdownDescription": "[credentials](https://containerlab.dev/manual/nodes/#credentials) for SSH/NETCONF/GNMI/etc. (overrides kind default)", - "properties": { - "username": { - "type": "string", - "description": "username to use when accessing the node over SSH/NETCONF/GNMI/etc.", - "markdownDescription": "[username](https://containerlab.dev/manual/nodes/#credentials) to use when accessing the node over SSH/NETCONF/GNMI/etc." - }, - "password": { - "type": "string", - "description": "password to use when accessing the node over SSH/NETCONF/GNMI/etc.", - "markdownDescription": "[password](https://containerlab.dev/manual/nodes/#credentials) to use when accessing the node over SSH/NETCONF/GNMI/etc." - } - } - }, - "env-files": { - "type": "array", - "description": "list of external files containing environment variables", - "markdownDescription": "list of external files containing [environment variables](https://containerlab.dev/manual/nodes/#env-files)", - "minItems": 1, - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "user": { - "description": "user to use within the container", - "markdownDescription": "[user](https://containerlab.dev/manual/nodes/#user) to use within the container", - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "number", - "minimum": 0 - } - ] - }, - "entrypoint": { - "type": "string", - "description": "container's entrypoint", - "markdownDescription": "container's [entrypoint](https://containerlab.dev/manual/nodes/#entrypoint)" - }, - "cmd": { - "type": "string", - "description": "command to launch container with", - "markdownDescription": "[command](https://containerlab.dev/manual/nodes/#cmd) to launch container with" - }, - "labels": { - "$ref": "#/definitions/labels" - }, - "runtime": { - "type": "string", - "description": "Runtime used to launch the container node", - "markdownDescription": "[Runtime](https://containerlab.dev/manual/nodes/#runtime) for the node", - "enum": [ - "docker", - "podman" - ] - }, - "mgmt-ipv4": { - "description": "IPv4 management address of the node (e.g. 172.10.10.11)", - "markdownDescription": "[IPv4 management address](https://containerlab.dev/manual/nodes/#mgmt-ipv4) of the node (e.g. 172.10.10.11)", - "$ref": "#/definitions/ipv4-addr" - }, - "mgmt-ipv6": { - "description": "IPv6 management address of the node (e.g. 172.10.10.11)", - "markdownDescription": "[IPv6 management address](https://containerlab.dev/manual/nodes/#mgmt-ipv6) of the node (e.g. 172.10.10.11)", - "$ref": "#/definitions/ipv6-addr" - }, - "network-mode": { - "type": "string", - "description": "node network mode (can only be set host, defaults to bridge)", - "markdownDescription": "node [network mode](https://containerlab.dev/manual/nodes/#network-mode) (can only be set host, defaults to bridge)", - "pattern": "^(host)|(container:\\S+)|(none)$" - }, - "cpu": { - "type": "number", - "description": "number of vcpu to allocate for this node/container", - "markdownDescription": "Allowed [CPU](https://containerlab.dev/manual/nodes/#cpu) usage by the node/container", - "minimum": 0 - }, - "memory": { - "type": "string", - "description": "memory limit for this node/container", - "markdownDescription": "Allowed [Memory](https://containerlab.dev/manual/nodes/#memory) usage by the node/container" - }, - "cpu-set": { - "type": "string", - "description": "CPU cores to use by this node/container", - "markdownDescription": "[CPU cores](https://containerlab.dev/manual/nodes/#cpu-set) to be used by the node/container" - }, - "extras": { - "type": "object", - "$ref": "#/definitions/extras-config" - }, - "config": { - "$ref": "#/definitions/config-config" - }, - "stages": { - "type": "object", - "$ref": "#/definitions/stages-config" - }, - "dns": { - "type": "object", - "$ref": "#/definitions/dns-config" - }, - "certificate": { - "type": "object", - "$ref": "#/definitions/certificate-config" - }, - "healthcheck": { - "type": "object", - "$ref": "#/definitions/healthcheck-config" - }, - "components": { - "type": "array", - "items": { - "type": "object", - "properties": { - "slot": { - "description": "Set component physical position on a distributed chassis", - "anyOf": [ - { - "type": "string", - "pattern": "^[ABab]$" - }, - { - "type": "integer", - "minimum": 1 - } - ] - }, - "type": { - "description": "Set component type" - }, - "sfm": { - "description": "Set SFM type (SR OS specific).", - "$ref": "#/definitions/sros-sfm-types" - }, - "xiom": { - "type": "array", - "description": "Define list of XIOMs (SR OS Specific). Each XIOM must have values of 'slot' and 'type'. MDAs must be defined under the XIOM.", - "items": { - "type": "object", - "properties": { - "slot": { - "type": "integer", - "minimum": 1 - }, - "type": { - "$ref": "#/definitions/sros-xiom-types" - }, - "mda": { - "type": "array", - "description": "Define list of MDAs under this XIOM. Each defined MDA must have values of 'slot' and 'type'", - "items": { - "type": "object", - "properties": { - "slot": { - "type": "integer", - "minimum": 1 - }, - "type": { - "$ref": "#/definitions/sros-xiom-mda-types" - } - }, - "required": [ - "slot", - "type" - ], - "additionalProperties": false - } - } - }, - "required": [ - "slot", - "type" - ], - "additionalProperties": false - }, - "uniqueItems": true - }, - "mda": { - "type": "array", - "description": "Define list of MDAs (SR OS Specific). Each defined MDA must have values of 'slot' and 'type'", - "items": { - "type": "object", - "properties": { - "slot": { - "type": "integer", - "minimum": 1 - }, - "type": { - "description": "Set MDA type (SR OS specific)", - "$ref": "#/definitions/sros-mda-types" - } - }, - "required": [ - "slot", - "type" - ], - "additionalProperties": false - } - }, - "env": { - "type": "object", - "$ref": "#/definitions/env" - } - }, - "additionalProperties": false, - "allOf": [ - { - "if": { - "properties": { - "slot": { - "type": "string" - } - } - }, - "then": { - "properties": { - "type": { - "$ref": "#/definitions/sros-cpm-types" - } - } - }, - "else": { - "properties": { - "type": { - "$ref": "#/definitions/sros-card-types" - } - } - } - } - ] - }, - "uniqueItems": true, - "description": "List of node components, used for multicontainer systems", - "markdownDescription": "Dependency list for Components" - }, - "aliases": { - "type": "array", - "description": "list of additional network aliases for the node", - "markdownDescription": "list of [aliases](https://containerlab.dev/manual/nodes/#aliases) for the node", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "shm-size": { - "type": "string", - "description": "shared memory size limit allocated to the container. Supported memory suffixes (case insensitive): b, kib, kb, mib, mb, gib, gb", - "markdownDescription": "[shared memory size limit](https://containerlab.dev/manual/nodes/#shm-size) allocated to the container (e.g. 256MB). Supported memory suffixes (case insensitive): b, kib, kb, mib, mb, gib, gb", - "pattern": "^[0-9]+(\\.?[0-9]*)?\\s*([bB]|[kK][iI]?[bB]|[mM][iI]?[bB]|[gG][iI]?[bB])?$" - }, - "cap-add": { - "type": "array", - "description": "list of capabilities to add to the container", - "markdownDescription": "list of [capabilities](https://containerlab.dev/manual/nodes/#cap-add) to add to the container", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "sysctls": { - "type": "object", - "description": "sysctl kernel parameters to set in the container", - "markdownDescription": "[sysctl kernel parameters](https://containerlab.dev/manual/nodes/#sysctls) to set in the container", - "patternProperties": { - ".+": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ] - } - } - }, - "devices": { - "type": "array", - "description": "list of host devices to add to the container", - "markdownDescription": "list of [host devices](https://containerlab.dev/manual/nodes/#devices) to add to the container", - "items": { - "type": "string" - }, - "uniqueItems": true - } - }, - "allOf": [ - { - "properties": { - "type": { - "type": "string", - "description": "type of a node", - "markdownDescription": "node [type](https://containerlab.dev/manual/nodes/#type)" - } - } - }, - { - "if": { - "properties": { - "kind": { - "pattern": "(nokia_srlinux)" - } - }, - "required": [ - "kind" - ] - }, - "then": { - "properties": { - "type": { - "type": "string", - "enum": [ - "ixsa1", - "ixs-a1", - "ixrd1", - "ixr-d1", - "ixrd2", - "ixr-d2", - "ixrd3", - "ixr-d3", - "ixrd2l", - "ixr-d2l", - "ixrd3l", - "ixr-d3l", - "ixrd4", - "ixr-d4", - "ixrd5", - "ixr-d5", - "ixrh2", - "ixr-h2", - "ixrh3", - "ixr-h3", - "ixrh4", - "ixr-h4", - "ixrh432d", - "ixr-h4-32d", - "ixrh5", - "ixr-h5", - "ixrh564d", - "ixr-h5-64d", - "ixrh564o", - "ixr-h5-64o", - "ixr6", - "ixr-6", - "ixr6e", - "ixr-6e", - "ixr10", - "ixr-10", - "ixr10e", - "ixr-10e", - "ixr18e", - "ixr-18e", - "sxr1x44s", - "sxr-1x-44s", - "sxr1d32d", - "sxr-1d-32d", - "sxr-1-32d", - "ixrx1b", - "ixr-x1b", - "ixrx3b", - "ixr-x3b", - "ixr-x4", - "ixr-x4-d" - ] - } - } - } - }, - { - "if": { - "properties": { - "kind": { - "pattern": "(nokia_sros)" - } - }, - "required": [ - "kind" - ] - }, - "then": { - "properties": { - "type": { - "type": "string", - "anyOf": [ - { - "enum": [ - "sr-1", - "sr-1-24d", - "sr-1e", - "sr-1e-sec", - "sr-1s", - "sr-1s-macsec", - "sr-2s", - "sr-7s", - "sr-7s-fp4", - "sr-14s", - "sr-a4", - "ixr-e-small", - "ixr-e-big", - "ixr-e2", - "ixr-ec", - "ixr-r6", - "ixr-s" - ] - }, - { - "pattern": "^\\s*(?:(?:cp|lc):\\s+)?(?:(?:(?:min_)?(?:cpu|ram|max_nics)=\\d+|slot=[A-Za-z0-9]+|chassis=[^\\s_][^\\s]*|card=[^\\s_][^\\s]*|mda/\\d+=[^\\s]+)(?:\\s+|$))+(?:\\s*___\\s*(?:(?:cp|lc):\\s+)?(?:(?:(?:min_)?(?:cpu|ram|max_nics)=\\d+|slot=[A-Za-z0-9]+|chassis=[^\\s_][^\\s]*|card=[^\\s_][^\\s]*|mda/\\d+=[^\\s]+)(?:\\s+|$))+)*\\s*$" - } - ] - } - } - } - }, - { - "if": { - "properties": { - "kind": { - "pattern": "(nokia_srsim)" - } - }, - "required": [ - "kind" - ] - }, - "then": { - "properties": { - "type": { - "type": "string", - "anyOf": [ - { - "enum": [ - "dms-1-24d", - "ixr-10", - "ixr-6", - "ixr-e2c", - "ixr-e2", - "ixr-e2n", - "ixr-ec", - "ixr-e", - "ixr-r4", - "ixr-r6dl", - "ixr-r6d", - "ixr-r6", - "ixr-s", - "ixr-x3", - "ixr-x", - "sar-1", - "sar-hm", - "sar-hmc", - "sr-1-24d", - "sr-12e", - "sr-12", - "sr-1-46s", - "sr-14s", - "sr-1-92s", - "sr-1e", - "sr-1se", - "sr-1s", - "sr-1-48d", - "sr-1x-48d", - "sr-1x-92s", - "sr-1", - "sr-2e", - "sr-2se", - "sr-2s", - "sr-3e", - "sr-7s", - "sr-7", - "sr-a4", - "sr-a8", - "vsr-i", - "xrs-20e", - "xrs-20", - "ess-7", - "ess-12" - ] - } - ] - } - } - } - }, - { - "if": { - "properties": { - "kind": { - "pattern": "(cisco_iol)" - } - }, - "required": [ - "kind" - ] - }, - "then": { - "properties": { - "type": { - "type": "string", - "enum": [ - "iol", - "l2" - ] - } - } - } - } - ], - "additionalProperties": false - }, - "link-config-short": { - "type": "object", - "description": "link configuration container", - "markdownDescription": "link configuration container", - "properties": { - "endpoints": { - "type": "array", - "description": "endpoints list", - "markdownDescription": "[endpoints](http://localhost:8000/manual/topo-def-file/#links) list", - "minItems": 2, - "items": { - "type": "string", - "pattern": "^\\S+:\\S+$" - }, - "uniqueItems": true - }, - "mtu": { - "$ref": "#/definitions/mtu" - }, - "ipv4": { - "description": "Assign IPv4 per endpoint in brief links. Accepts ordered list of IPv4 prefixes as strings", - "type": "array", - "minItems": 1, - "maxItems": 2, - "items": { - "type": "string", - "pattern": "^(?:$|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])/(?:[0-9]|[12][0-9]|3[0-2]))$" - }, - "uniqueItems": true - }, - "ipv6": { - "description": "Assign IPv6 per endpoint in brief links. Accepts ordered list of IPv6 prefixes as strings", - "type": "array", - "minItems": 1, - "maxItems": 2, - "items": { - "type": "string", - "pattern": "^(?:$|(?:(?:[A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}|(?:[A-Fa-f0-9]{1,4}:){1,7}:|(?:[A-Fa-f0-9]{1,4}:){1,6}:[A-Fa-f0-9]{1,4}|(?:[A-Fa-f0-9]{1,4}:){1,5}(?::[A-Fa-f0-9]{1,4}){1,2}|(?:[A-Fa-f0-9]{1,4}:){1,4}(?::[A-Fa-f0-9]{1,4}){1,3}|(?:[A-Fa-f0-9]{1,4}:){1,3}(?::[A-Fa-f0-9]{1,4}){1,4}|(?:[A-Fa-f0-9]{1,4}:){1,2}(?::[A-Fa-f0-9]{1,4}){1,5}|[A-Fa-f0-9]{1,4}:(?::[A-Fa-f0-9]{1,4}){1,6}|:(?:(?::[A-Fa-f0-9]{1,4}){1,7}|:))/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))$" - }, - "uniqueItems": true - }, - "vars": { - "$ref": "#/definitions/link-vars" - } - }, - "additionalProperties": false - }, - "link-type-veth": { - "type": "object", - "description": "Link definition to support the veth interfaces", - "markdownDescription": "Link definition to support the veth interfaces", - "properties": { - "type": { - "type": "string", - "const": "veth" - }, - "endpoints": { - "type": "array", - "description": "Endpoints for the links", - "minItems": 2, - "maxItems": 2, - "items": { - "$ref": "#/definitions/link-endpoint" - } - }, - "mtu": { - "$ref": "#/definitions/mtu" - }, - "ipv4": { - "description": "Assign IPv4 per endpoint. Accepts ordered list of IPv4 prefixes as strings", - "type": "array", - "minItems": 1, - "maxItems": 2, - "items": { - "type": "string", - "pattern": "^(?:$|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])/(?:[0-9]|[12][0-9]|3[0-2]))$" - }, - "uniqueItems": true - }, - "ipv6": { - "description": "Assign IPv6 per endpoint. Accepts ordered list of IPv6 prefixes as strings", - "type": "array", - "minItems": 1, - "maxItems": 2, - "items": { - "type": "string", - "pattern": "^(?:$|(?:(?:[A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}|(?:[A-Fa-f0-9]{1,4}:){1,7}:|(?:[A-Fa-f0-9]{1,4}:){1,6}:[A-Fa-f0-9]{1,4}|(?:[A-Fa-f0-9]{1,4}:){1,5}(?::[A-Fa-f0-9]{1,4}){1,2}|(?:[A-Fa-f0-9]{1,4}:){1,4}(?::[A-Fa-f0-9]{1,4}){1,3}|(?:[A-Fa-f0-9]{1,4}:){1,3}(?::[A-Fa-f0-9]{1,4}){1,4}|(?:[A-Fa-f0-9]{1,4}:){1,2}(?::[A-Fa-f0-9]{1,4}){1,5}|[A-Fa-f0-9]{1,4}:(?::[A-Fa-f0-9]{1,4}){1,6}|:(?:(?::[A-Fa-f0-9]{1,4}){1,7}|:))/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))$" - }, - "uniqueItems": true - }, - "vars": { - "$ref": "#/definitions/link-vars" - }, - "labels": { - "$ref": "#/definitions/labels" - } - }, - "required": [ - "type", - "endpoints" - ], - "additionalItems": false - }, - "link-type-mgmt-net": { - "type": "object", - "description": "Link definition for management network interfaces", - "properties": { - "type": { - "type": "string", - "const": "mgmt-net" - }, - "endpoint": { - "$ref": "#/definitions/link-endpoint" - }, - "host-interface": { - "$ref": "#/definitions/link-host-interface" - }, - "mtu": { - "$ref": "#/definitions/mtu" - }, - "vars": { - "$ref": "#/definitions/link-vars" - }, - "labels": { - "$ref": "#/definitions/labels" - } - }, - "required": [ - "type", - "endpoint", - "host-interface" - ], - "additionalProperties": false - }, - "link-type-macvlan": { - "type": "object", - "description": "Link definition describing a macvlan link endpoint configuration", - "properties": { - "type": { - "type": "string", - "const": "macvlan" - }, - "endpoint": { - "$ref": "#/definitions/link-endpoint" - }, - "host-interface": { - "$ref": "#/definitions/link-host-interface" - }, - "mode": { - "$ref": "#/definitions/link-macvlan-mode" - }, - "mtu": { - "$ref": "#/definitions/mtu" - }, - "vars": { - "$ref": "#/definitions/link-vars" - }, - "labels": { - "$ref": "#/definitions/labels" - } - }, - "required": [ - "type", - "endpoint", - "host-interface" - ], - "additionalProperties": false - }, - "link-type-host": { - "type": "object", - "description": "", - "properties": { - "type": { - "type": "string", - "const": "host" - }, - "endpoint": { - "$ref": "#/definitions/link-endpoint" - }, - "host-interface": { - "$ref": "#/definitions/link-host-interface" - }, - "mtu": { - "$ref": "#/definitions/mtu" - }, - "vars": { - "$ref": "#/definitions/link-vars" - }, - "labels": { - "$ref": "#/definitions/labels" - } - }, - "required": [ - "type", - "endpoint", - "host-interface" - ], - "additionalProperties": false - }, - "link-type-vxlan": { - "type": "object", - "description": "", - "properties": { - "type": { - "type": "string", - "const": "vxlan" - }, - "endpoint": { - "$ref": "#/definitions/link-endpoint" - }, - "remote": { - "$ref": "#/definitions/link-vxlan-remote" - }, - "vni": { - "$ref": "#/definitions/link-vxlan-vni" - }, - "dst-port": { - "$ref": "#/definitions/link-vxlan-dstport" - }, - "src-port": { - "$ref": "#/definitions/link-vxlan-srcport" - }, - "mtu": { - "$ref": "#/definitions/mtu" - }, - "vars": { - "$ref": "#/definitions/link-vars" - }, - "labels": { - "$ref": "#/definitions/labels" - } - }, - "required": [ - "type", - "endpoint", - "remote", - "vni", - "dst-port" - ], - "additionalProperties": false - }, - "link-type-vxlan-stitched": { - "type": "object", - "description": "", - "properties": { - "type": { - "type": "string", - "const": "vxlan-stitch" - }, - "endpoint": { - "$ref": "#/definitions/link-endpoint" - }, - "remote": { - "$ref": "#/definitions/link-vxlan-remote" - }, - "vni": { - "$ref": "#/definitions/link-vxlan-vni" - }, - "dst-port": { - "$ref": "#/definitions/link-vxlan-dstport" - }, - "src-port": { - "$ref": "#/definitions/link-vxlan-srcport" - }, - "mtu": { - "$ref": "#/definitions/mtu" - }, - "vars": { - "$ref": "#/definitions/link-vars" - }, - "labels": { - "$ref": "#/definitions/labels" - } - }, - "required": [ - "type", - "endpoint", - "remote", - "vni", - "dst-port" - ], - "additionalProperties": false - }, - "link-type-dummy": { - "type": "object", - "description": "", - "properties": { - "type": { - "type": "string", - "const": "dummy" - }, - "endpoint": { - "$ref": "#/definitions/link-endpoint" - }, - "mtu": { - "$ref": "#/definitions/mtu" - }, - "vars": { - "$ref": "#/definitions/link-vars" - }, - "labels": { - "$ref": "#/definitions/labels" - } - }, - "required": [ - "type", - "endpoint" - ], - "additionalProperties": false - }, - "link-endpoint": { - "type": "object", - "description": "Common link endpoint object for extended link configs", - "properties": { - "node": { - "type": "string", - "description": "" - }, - "interface": { - "type": "string", - "description": "" - }, - "mac": { - "type": "string", - "description": "", - "pattern": "^(?:[0-9A-Fa-f]{2}[:-]){5}(?:[0-9A-Fa-f]{2})" - }, - "ipv4": { - "$ref": "#/definitions/ipv4-prefix", - "description": "IPv4 address (in CIDR notation) to configure on this interface", - "markdownDescription": "Interface IPv4 address in CIDR notation, e.g. 10.0.0.1/24" - }, - "ipv6": { - "$ref": "#/definitions/ipv6-prefix", - "description": "IPv6 address (in CIDR notation) to configure on this interface", - "markdownDescription": "Interface IPv6 address in CIDR notation, e.g. 2001:db8::1/64" - }, - "vars": { - "$ref": "#/definitions/endpoint-vars" - } - }, - "required": [ - "node", - "interface" - ], - "additionalProperties": false - }, - "endpoint-vars": { - "type": "object", - "description": "per-endpoint variables", - "additionalProperties": true - }, - "link-vxlan-remote": { - "anyOf": [ - { - "$ref": "#/definitions/ipv4-addr" - }, - { - "$ref": "#/definitions/ipv6-addr" - } - ] - }, - "link-vxlan-vni": { - "type": "integer", - "description": "VXLAN VNI", - "minimum": 1, - "maximum": 16777215 - }, - "link-vxlan-dstport": { - "type": "integer", - "description": "Destination UDP port", - "minimum": 1, - "maximum": 65535 - }, - "link-vxlan-srcport": { - "type": "integer", - "description": "Source UDP port", - "minimum": 1, - "maximum": 65535 - }, - "link-vars": { - "type": "object", - "description": "link-scoped variables used by config engine", - "markdownDescription": "link-scoped variables used by config engine", - "additionalProperties": true - }, - "labels": { - "type": "object", - "description": "container labels", - "markdownDescription": "container [labels](https://containerlab.dev/manual/nodes/#labels)", - "patternProperties": { - ".+": { - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "number", - "minimum": 0 - } - ] - } - } - }, - "link-host-interface": { - "type": "string", - "description": "Name of the host interface", - "markdownDescription": "Name of the host interface" - }, - "link-macvlan-mode": { - "type": "string", - "description": "MACVLAN operating mode", - "markdownDescription": "MACVLAN operating mode", - "enum": [ - "private", - "vepa", - "bridge", - "passthru", - "source" - ] - }, - "extras-config": { - "type": "object", - "description": "node's extra configurations", - "properties": { - "ceos-copy-to-flash": { - "type": "array", - "description": "list of cEOS-specific configuration or override files to be copied to the flash directory and evaluated on startup", - "markdownDescription": "list of [cEOS-specific configuration or override files](https://containerlab.dev/manual/kinds/ceos/#copy-to-flash) to be copied to the flash directory and evaluated on startup", - "minItems": 1, - "items": { - "type": "string" - }, - "uniqueItems": true - } - }, - "additionalProperties": false - }, - "config-config": { - "type": "object", - "description": "containerlab config engine parameters", - "properties": { - "vars": { - "type": "object", - "description": "config variables passed to config engine", - "markdownDescription": "config variables passed to config engine" - } - }, - "additionalProperties": false - }, - "certificate-config": { - "type": "object", - "description": "Node's Certificate configuration option", - "markdownDescription": "Node's [Certificate configuration options](https://containerlab.dev/manual/nodes/#certificate)", - "properties": { - "issue": { - "description": "Set to `true` to generate a TLS certificate for the node", - "markdownDescription": "Set to `true` to [generate a TLS certificate for the node](https://containerlab.dev/manual/nodes/#certificate)" - }, - "sans": { - "type": "array", - "description": "list of subject alternative names (SAN) to use for this node", - "markdownDescription": "list of [subject alternative names](https://containerlab.dev/manual/nodes/#subject-alternative-names) to use for this node", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "key-size": { - "type": "integer", - "description": "size of the to be generated key", - "markdownDescription": "size of the to be generated key" - }, - "validity-duration": { - "type": "string", - "description": "Duration for how long the certificate issued by the CA will be valid.", - "markdownDescription": "Duration for how long the certificate issued by the CA will be valid." - } - }, - "additionalProperties": false - }, - "healthcheck-config": { - "type": "object", - "description": "Node's Healthcheck configuration option", - "markdownDescription": "Node's [Healthcheck configuration options](https://containerlab.dev/manual/nodes/#healthcheck)", - "properties": { - "test": { - "type": "array", - "description": "test command", - "items": { - "type": "string" - } - }, - "interval": { - "type": "integer", - "description": "test execution interval", - "markdownDescription": "test execution interval" - }, - "retries": { - "type": "integer", - "description": "test execution retries", - "markdownDescription": "test execution retries" - }, - "timeout": { - "type": "integer", - "description": "test execution timeout in seconds", - "markdownDescription": "test execution timeout in seconds" - }, - "start-period": { - "type": "integer", - "description": "time in seconds to wait before starting the healthcheck" - } - }, - "additionalProperties": false - }, - "dns-config": { - "type": "object", - "description": "Node's DNS configuration option", - "markdownDescription": "Node's [DNS configuration options](https://containerlab.dev/manual/nodes/#dns)", - "properties": { - "servers": { - "type": "array", - "description": "DNS server addresses", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "search": { - "type": "array", - "description": "DNS search domains", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "options": { - "type": "array", - "description": "DNS options", - "items": { - "type": "string" - }, - "uniqueItems": true - } - }, - "additionalProperties": false - }, - "certificate-authority-config": { - "type": "object", - "description": "Certificate Authority", - "markdownDescription": "", - "properties": { - "cert": { - "type": "string", - "description": "Path to the CA certificate file. If set, it is expected that the CA certificate already exists by that path" - }, - "key": { - "type": "string", - "description": "Path to the CA key file. If set, it is expected that the CA certificate already exists by that path" - }, - "key-size": { - "type": "integer", - "description": "Key size. Can only be set if the external CA certificate is not provided" - }, - "validity-duration": { - "type": "string", - "description": "CA certificate validity duration. Can only be set if the external CA certificate is not provided" - } - }, - "additionalProperties": false, - "oneOf": [ - { - "required": [ - "cert", - "key" - ], - "not": { - "anyOf": [ - { - "required": [ - "key-size" - ] - }, - { - "required": [ - "validity-duration" - ] - } - ] - } - }, - { - "anyOf": [ - { - "required": [ - "key-size" - ] - }, - { - "required": [ - "validity-duration" - ] - } - ], - "not": { - "anyOf": [ - { - "required": [ - "cert" - ] - }, - { - "required": [ - "key" - ] - } - ] - } - } - ] - }, - "stages-config": { - "type": "object", - "description": "node's stages configurations", - "markdownDescription": "node's [stages](https://containerlab.dev/manual/nodes/#stages) configurations", - "properties": { - "create": { - "type": "object", - "description": "create stage configuration", - "properties": { - "wait-for": { - "$ref": "#/definitions/wait-for-config" - }, - "exec": { - "$ref": "#/definitions/stage-exec" - } - }, - "additionalProperties": false - }, - "create-links": { - "type": "object", - "description": "create stage configuration", - "properties": { - "wait-for": { - "$ref": "#/definitions/wait-for-config" - }, - "exec": { - "$ref": "#/definitions/stage-exec" - } - }, - "additionalProperties": false - }, - "configure": { - "type": "object", - "description": "create stage configuration", - "properties": { - "wait-for": { - "$ref": "#/definitions/wait-for-config" - }, - "exec": { - "$ref": "#/definitions/stage-exec" - } - }, - "additionalProperties": false - }, - "healthy": { - "type": "object", - "description": "create stage configuration", - "properties": { - "wait-for": { - "$ref": "#/definitions/wait-for-config" - }, - "exec": { - "$ref": "#/definitions/stage-exec" - } - }, - "additionalProperties": false - }, - "exit": { - "type": "object", - "description": "create stage configuration", - "properties": { - "wait-for": { - "$ref": "#/definitions/wait-for-config" - }, - "exec": { - "$ref": "#/definitions/stage-exec" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "wait-for-config": { - "type": "array", - "items": { - "type": "object", - "properties": { - "node": { - "type": "string", - "description": "node name to wait for" - }, - "stage": { - "type": "string", - "description": "phase to wait for", - "$ref": "#/definitions/stages-enum" - } - }, - "additionalProperties": false - }, - "uniqueItems": true, - "description": "Dependency list for the node", - "markdownDescription": "Dependency list for the node" - }, - "stages-enum": { - "type": "string", - "enum": [ - "create", - "create-links", - "configure", - "healthy", - "exit" - ] - }, - "stage-exec": { - "description": "per-stage exec configuration", - "oneOf": [ - { - "type": "object", - "properties": { - "on-enter": { - "$ref": "#/definitions/stage-exec-list" - }, - "on-exit": { - "$ref": "#/definitions/stage-exec-list" - } - }, - "additionalProperties": false - }, - { - "type": "array", - "description": "List of exec commands as objects, each containing `command`, `target`, and `phase`", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "command": { - "type": "string", - "description": "Shell command to execute" - }, - "target": { - "type": "string", - "description": "Location to run the command (e.g. 'container', 'host')", - "default": "container" - }, - "phase": { - "type": "string", - "enum": [ - "on-enter", - "on-exit" - ], - "description": "Phase to execute this command (on-enter or on-exit)" - } - }, - "required": [ - "command", - "phase" - ] - } - } - ] - }, - "stage-exec-list": { - "type": "array", - "description": "list of commands to execute", - "markdownDescription": "list of [commands to execute](https://containerlab.dev/manual/nodes/#exec)", - "minItems": 1, - "items": { - "type": "string" - } - }, - "mtu": { - "description": "MTU for the custom network", - "markdownDescription": "[MTU](https://containerlab.dev/manual/network/#mtu) in Bytes for the custom management network", - "type": "number", - "maximum": 65535, - "minimum": 1, - "default": 1500 - }, - "ipv4-addr": { - "description": "IPv4 address", - "markdownDescription": "IPv4 address", - "type": "string", - "pattern": "^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])(%[\\p{N}\\p{L}]+)?$" - }, - "ipv6-addr": { - "description": "IPv6 address", - "markdownDescription": "IPv6 address", - "type": "string", - "pattern": "^((:|[0-9a-fA-F]{0,4}):)([0-9a-fA-F]{0,4}:){0,5}((([0-9a-fA-F]{0,4}:)?(:|[0-9a-fA-F]{0,4}))|(((25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])))(%[\\p{N}\\p{L}]+)?$" - }, - "ipv4-prefix": { - "description": "IPv4 address in CIDR notation", - "markdownDescription": "IPv4 address in CIDR notation, e.g. 10.0.0.1/24", - "type": "string", - "pattern": "^(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])/(?:[0-9]|[12][0-9]|3[0-2])$" - }, - "ipv6-prefix": { - "description": "IPv6 address in CIDR notation", - "markdownDescription": "IPv6 address in CIDR notation, e.g. 2001:db8::1/64", - "type": "string", - "pattern": "^(?:(?:[A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}|(?:[A-Fa-f0-9]{1,4}:){1,7}:|(?:[A-Fa-f0-9]{1,4}:){1,6}:[A-Fa-f0-9]{1,4}|(?:[A-Fa-f0-9]{1,4}:){1,5}(?::[A-Fa-f0-9]{1,4}){1,2}|(?:[A-Fa-f0-9]{1,4}:){1,4}(?::[A-Fa-f0-9]{1,4}){1,3}|(?:[A-Fa-f0-9]{1,4}:){1,3}(?::[A-Fa-f0-9]{1,4}){1,4}|(?:[A-Fa-f0-9]{1,4}:){1,2}(?::[A-Fa-f0-9]{1,4}){1,5}|[A-Fa-f0-9]{1,4}:(?::[A-Fa-f0-9]{1,4}){1,6}|:(?:(?::[A-Fa-f0-9]{1,4}){1,7}|:))/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8])$" - }, - "sros-card-types": { - "type": "string", - "description": "Card types for SR OS", - "enum": [ - "cpm-1se/imm36-800g-qsfpdd", - "cpm-1x/dms24-800g-qsfpdd-1", - "cpm-1x/i24-800g-qsfpdd-1", - "cpm-1x/i40-200g-sfpdd+6-800g-qsfpdd-1", - "cpm-1x/i48-400g-qsfpdd-1", - "cpm-1x/i48-800g-qsfpdd-1x", - "cpm-1x/i80-200g-sfpdd+12-400g-qsfpdd-1", - "cpm-1x/i80-200g-sfpdd+12-800g-qsfpdd-1x", - "cpm-ixr-e-gnss/imm14-10g-sfp++4-1g-tx", - "cpm-ixr-e-gnss/imm24-sfp++8-sfp28+2-qsfp28", - "cpm-ixr-e/imm14-10g-sfp++4-1g-tx", - "cpm-ixr-e/imm24-sfp++8-sfp28+2-qsfp28", - "cpm-ixr-e2", - "cpm-ixr-e2c", - "cpm-ixr-e2n/imm4-sfp+4-sfp+", - "cpm-ixr-e3c/imm4-qsfp28+16-sfp28+8-sfp56", - "cpm-ixr-e3x/imm16-sfp112+15-sfp56+6-qsfpdd", - "cpm-ixr-ec", - "cpm-ixr-s/imm48-sfp++6-qsfp28", - "cpm-ixr-x/imm32-qsfp28+4-qsfpdd", - "cpm-ixr-x/imm6-qsfpdd+48-sfp56", - "cpm-sar-hm", - "cpm-sar-hmc", - "dms24-800g-qsfpdd-1", - "i24-800g-qsfpdd-1", - "i40-200g-sfpdd+6-800g-qsfpdd-1", - "i48-400g-qsfpdd-1", - "i48-800g-qsfpdd-1x", - "i80-200g-sfpdd+12-400g-qsfpdd-1", - "i80-200g-sfpdd+12-800g-qsfpdd-1x", - "imm-2pac-fp3", - "imm12-sfp28+2-qsfp28", - "imm14-10g-sfp++4-1g-tx", - "imm2-qsfpdd+2-qsfp28+24-sfp28", - "imm24-sfp++8-sfp28+2-qsfp28", - "imm32-qsfp28+4-qsfpdd", - "imm36-100g-qsfp28", - "imm36-800g-qsfpdd", - "imm36-qsfpdd", - "imm4-100gb-cfp4", - "imm4-100gb-cxp", - "imm4-1g-tx+20-1g-sfp+6-10g-sfp+", - "imm40-10gb-sfp", - "imm40-10gb-sfp-ptp", - "imm48-1gb-sfp-c", - "imm48-sfp++6-qsfp28", - "imm48-sfp+2-qsfp28", - "imm6-qsfpdd+48-sfp56", - "iom-1", - "iom-a", - "iom-e", - "iom-ixr-r4", - "iom-ixr-r6", - "iom-ixr-r6d", - "iom-sar", - "iom-sar-hm", - "iom-sar-hmc", - "iom-v", - "iom4-e", - "iom4-e-b", - "iom4-e-hs", - "iom5-e", - "xcm-14s", - "xcm-14s-b", - "xcm-1s", - "xcm-2s", - "xcm-2se", - "xcm-7s", - "xcm-7s-b", - "xcm-x20", - "xcm2-14s", - "xcm2-7s", - "xcm2-x20", - "xcmc-2se" - ] - }, - "sros-cpm-types": { - "type": "string", - "description": "CPM card types for SR OS", - "enum": [ - "cpiom-ixr-r6", - "cpiom-ixr-r6d", - "cpm-1", - "cpm-1s", - "cpm-1se", - "cpm-1se/imm36-800g-qsfpdd", - "cpm-1x", - "cpm-1x/dms24-800g-qsfpdd-1", - "cpm-1x/i24-800g-qsfpdd-1", - "cpm-1x/i40-200g-sfpdd+6-800g-qsfpdd-1", - "cpm-1x/i48-400g-qsfpdd-1", - "cpm-1x/i48-800g-qsfpdd-1x", - "cpm-1x/i80-200g-sfpdd+12-400g-qsfpdd-1", - "cpm-1x/i80-200g-sfpdd+12-800g-qsfpdd-1x", - "cpm-2s", - "cpm-2se", - "cpm-a", - "cpm-e", - "cpm-ixr", - "cpm-ixr-e", - "cpm-ixr-e-gnss", - "cpm-ixr-e-gnss/imm14-10g-sfp++4-1g-tx", - "cpm-ixr-e-gnss/imm24-sfp++8-sfp28+2-qsfp28", - "cpm-ixr-e/imm14-10g-sfp++4-1g-tx", - "cpm-ixr-e/imm24-sfp++8-sfp28+2-qsfp28", - "cpm-ixr-e2", - "cpm-ixr-e2c", - "cpm-ixr-e2n/imm4-sfp+4-sfp+", - "cpm-ixr-e3c/imm4-qsfp28+16-sfp28+8-sfp56", - "cpm-ixr-e3x/imm16-sfp112+15-sfp56+6-qsfpdd", - "cpm-ixr-ec", - "cpm-ixr-r4", - "cpm-ixr-s", - "cpm-ixr-s/imm48-sfp++6-qsfp28", - "cpm-ixr-x", - "cpm-ixr-x/imm32-qsfp28+4-qsfpdd", - "cpm-ixr-x/imm6-qsfpdd+48-sfp56", - "cpm-s", - "cpm-sar", - "cpm-sar-hm", - "cpm-sar-hmc", - "cpm-v", - "cpm-v/iom-v", - "cpm-x20", - "cpm2-s", - "cpm2-x20", - "cpm5", - "iom-sar" - ] - }, - "sros-mda-types": { - "type": "string", - "description": "MDA types for SR OS", - "enum": [ - "a32-chds1v2", - "d24-800g-qsfpdd-1", - "i1-wlan", - "i2-cellular", - "i2-sdi", - "i3-10/100eth-tx", - "i6-10/100eth-tx", - "isa-aa-v", - "isa-bb-v", - "isa-ms-v", - "isa-tunnel-v", - "isa2-aa", - "isa2-bb", - "isa2-tunnel", - "isa2-video", - "m1-400g-qsfpdd+1-100g-qsfp28", - "m10-10g-sfp+", - "m10-1g-sfp+2-10g-sfp+", - "m10-50g-sfp56", - "m10-sfp++6-sfp", - "m12-sfp28+2-qsfp28", - "m14-10g-sfp++4-1g-tx", - "m18-25g-sfp28", - "m2-100g-qsfp28+16-10g-sfp+", - "m2-cfp2", - "m2-qsfpdd+2-qsfp28+24-sfp28", - "m20-10g-sfp+", - "m20-1g-csfp", - "m20-v", - "m24-800g-qsfpdd-1", - "m24-sfp++8-sfp28+2-qsfp28", - "m32-1g-csfp", - "m32-qsfp28+4-qsfpdd", - "m36-100g-qsfp28", - "m36-qsfpdd", - "m4-100g-cfp4", - "m4-10g-sfp++1-100g-cfp2", - "m4-1g-tx+20-1g-sfp+6-10g-sfp+", - "m40-10g-sfp", - "m40-10g-sfp-ptp", - "m40-200g-sfpdd+6-800g-qsfpdd-1", - "m46-10g-sfp+", - "m48-400g-qsfpdd-1", - "m48-800g-qsfpdd-1x", - "m48-sfp++6-qsfp28", - "m48-sfp+2-qsfp28", - "m5-100g-qsfp28", - "m5e2-100g-qsfp28+2-800g-qdd", - "m5e8-100g-sfp112+2-800g-qdd", - "m6-10g-sfp++1-100g-qsfp28", - "m6-10g-sfp++4-25g-sfp28", - "m6-qsfpdd+48-sfp56", - "m80-1g-csfp", - "m80-200g-sfpdd+12-400g-qsfpdd-1", - "m80-200g-sfpdd+12-800g-qsfpdd-1x", - "ma2-10gb-sfp+12-1gb-sfp", - "ma20-1gb-tx", - "ma4-10gb-sfp+", - "ma44-1gb-csfp", - "maxp1-100gb-cfp", - "maxp1-100gb-cfp2", - "maxp1-100gb-cfp4", - "maxp10-10/1gb-msec-sfp+", - "maxp10-10gb-sfp+", - "me-isa2-ms", - "me-isa2-ms-e", - "me1-100gb-cfp2", - "me10-10gb-sfp+", - "me12-10/1gb-sfp+", - "me12-100gb-qsfp28", - "me16-25gb-sfp28+2-100gb-qsfp-b", - "me16-25gb-sfp28+2-100gb-qsfp28", - "me2-100gb-ms-qsfp28", - "me2-100gb-qsfp28", - "me3-200gb-cfp2-dco", - "me3-400gb-qsfpdd", - "me40-1gb-csfp", - "me6-100gb-qsfp28", - "me6-10gb-sfp+", - "me6-400gb-qsfpdd", - "me8-10/25gb-sfp28", - "ms36-800g-qsfpdd", - "p-isa2-ms", - "p-isa2-ms-e", - "p1-100g-cfp", - "p10-10g-sfp", - "p20-1gb-sfp", - "p6-10g-sfp", - "s18-100gb-qsfp28", - "s36-100gb-qsfp28", - "s36-100gb-qsfp28-3.6t", - "s36-400gb-qsfpdd", - "x12-400g-qsfpdd", - "x2-s36-800g-qsfpdd-12.0t", - "x2-s36-800g-qsfpdd-18.0t", - "x24-100g-qsfp28", - "x4-100g-cfp2", - "x40-10g-sfp", - "x40-10g-sfp-ptp", - "x6-200g-cfp2-dco", - "x6-400g-cfp8" - ] - }, - "sros-xiom-types": { - "type": "string", - "description": "XIOM types for SR OS", - "enum": [ - "iom-s-1.5t", - "iom-s-3.0t", - "iom2-s-3.0t", - "iom2-s-6.0t", - "iom2-se-3.0t", - "iom2-se-6.0t", - "x2-s36-400g-qsfp112-3.0t", - "x2-s36-800g-qsfpdd-6.0t" - ] - }, - "sros-xiom-mda-types": { - "type": "string", - "description": "XIOM MDA types for SR OS", - "enum": [ - "ms6-200gb-cfp2-dco", - "ms3-200gb-cfp2-dco", - "ms16-100gb-sfpdd+4-100gb-qsfp28", - "ms18-100gb-qsfp28", - "ms4-400gb-qsfpdd+4-100gb-qsfp28", - "ms24-10/100gb-sfpdd", - "ms2-400gb-qsfpdd+2-100gb-qsfp28", - "ms8-100gb-sfpdd+2-100gb-qsfp28", - "ms16-sdd+4-qsfp28-b", - "ms8-sdd+2-qsfp28-b", - "mse24-200g-sfpdd", - "mse6-800g-cfp2-dco", - "mse14-800g+4-400g", - "m36-800g-qsfpdd", - "mse6-800g-qsfpdd", - "m36-400g-qsfp112", - "ms2-100g-qsfp28+2-800g-qsfpdd", - "ms8-100g-sfp112+2-800g-qsfpdd", - "ms4-400g-qsfpdd+4-100g-qsfp28" - ] - }, - "sros-sfm-types": { - "type": "string", - "description": "SFM types for SR OS", - "enum": [ - "m-sfm5-12", - "m-sfm5-12e", - "m-sfm5-7", - "m-sfm6-12e", - "m-sfm6-7/12", - "sfm-2s", - "sfm-2se", - "sfm-ixr-10", - "sfm-ixr-6", - "sfm-s", - "sfm-x20", - "sfm-x20-b", - "sfm-x20s-b", - "sfm2-s", - "sfm2-x20s" - ] - } - }, - "type": "object", - "properties": { - "name": { - "description": "topology name", - "type": "string", - "pattern": "^[a-zA-Z0-9][a-zA-Z0-9-._]*$" - }, - "prefix": { - "description": "lab prefix", - "type": "string", - "pattern": "^$|^__lab-name$|^[a-zA-Z0-9][a-zA-Z0-9-._]*$", - "markdownDescription": "[lab prefix](https://containerlab.dev/manual/topo-def-file/#prefix)" - }, - "mgmt": { - "description": "configuration container for management network", - "markdownDescription": "configuration container for [management network](https://containerlab.dev/manual/network/#management-network)", - "type": "object", - "properties": { - "network": { - "description": "management network name", - "markdownDescription": "[management network name](https://containerlab.dev/manual/network/#network-name)", - "type": "string" - }, - "bridge": { - "description": "Set bridge to use for the management network (instead of the default generated bridge).", - "markdownDescription": "Set [bridge](https://containerlab.dev/manual/network/#bridge-name) to use for the management network (instead of the default generated bridge).", - "type": "string" - }, - "ipv4-subnet": { - "description": "IPv4 subnet to use for the custom management network. e.g. 172.100.100.0/24", - "markdownDescription": "[IPv4 subnet](https://containerlab.dev/manual/network/#user-defined-addresses) to use for the custom management network. e.g. 172.100.100.0/24", - "type": "string", - "pattern": "(^.+/[0-9]{1,2}$)|(auto)" - }, - "ipv6-subnet": { - "description": "IPv6 subnet to use for the custom management network. e.g. 3fff:172:100:100::/64", - "markdownDescription": "[IPv6 subnet](https://containerlab.dev/manual/network/#user-defined-addresses) to be used for the custom management network. e.g. 3fff:172:100:100::/64", - "type": "string", - "pattern": "(^.+/[0-9]{1,3}$)|(auto)" - }, - "ipv4-gw": { - "description": "IPv4 gateway address that will be set on a bridge used for the management network. Will be set to the first available IP address by default", - "markdownDescription": "IPv4 gateway address that will be set on a bridge used for the management network. Will be set to the first available IP address by default", - "$ref": "#/definitions/ipv4-addr" - }, - "ipv6-gw": { - "description": "IPv6 gateway address that will be set on a bridge used for the management network. Will be set to the first available IP address by default", - "markdownDescription": "IPv6 gateway address that will be set on a bridge used for the management network. Will be set to the first available IP address by default", - "type": "string", - "$ref": "#/definitions/ipv6-addr" - }, - "ipv4-range": { - "description": "IPv4 range out of the ipv4-subnet to use for the custom management network. e.g. 172.100.100.128/25", - "markdownDescription": "[IPv4 range](https://containerlab.dev/manual/network/#ip-range) out of the ipv4-subnet to use for the custom management network. e.g. 172.100.100.128/25", - "type": "string", - "pattern": "^.+/[0-9]{1,2}$" - }, - "ipv6-range": { - "description": "IPv6 range out of the ipv6-subnet to use for the custom management network. e.g. 3fff:172:100:100:8000::/65", - "markdownDescription": "[IPv6 range](https://containerlab.dev/manual/network/#ip-range) out of the ipv6-subnet to use for the custom management network. e.g. 3fff:172:100:100:8000::/65", - "type": "string", - "pattern": "^((:|[0-9a-fA-F]{0,4}):)([0-9a-fA-F]{0,4}:){0,5}((([0-9a-fA-F]{0,4}:)?(:|[0-9a-fA-F]{0,4}))|(((25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])))(%[\\p{N}\\p{L}]+)?$" - }, - "mtu": { - "description": "MTU for the custom network", - "markdownDescription": "[MTU](https://containerlab.dev/manual/network/#mtu) in Bytes for the custom management network", - "$ref": "#/definitions/mtu" - }, - "external-access": { - "type": "boolean", - "description": "controls whether the management network has external access or not", - "markdownDescription": "controls whether the [management network has external access](https://containerlab.dev/manual/network/#external-access) or not" - }, - "driver-opts": { - "type": "object", - "description": "overrides for container runtime network driver options", - "markdownDescription": "[driver-opts](https://containerlab.dev/manual/network/#bridge-network-driver-options) lets you set overrides for the network driver of the container runtime", - "patternProperties": { - ".+": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] - } - } - } - }, - "minProperties": 1, - "additionalProperties": false - }, - "topology": { - "description": "topology configuration container", - "markdownDescription": "[topology](https://containerlab.dev/manual/topo-def-file/) configuration container", - "type": "object", - "properties": { - "nodes": { - "description": "topology nodes configuration container", - "markdownDescription": "topology [nodes](https://containerlab.dev/manual/nodes/) configuration container", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9][a-zA-Z0-9-._|]*$": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/definitions/node-config" - } - ] - } - }, - "additionalProperties": false - }, - "groups": { - "description": "topology groups configuration container", - "markdownDescription": "topology [groups](https://containerlab.dev/manual/topo-def-file/#groups) configuration container", - "type": "object", - "patternProperties": { - ".*": { - "$ref": "#/definitions/node-config" - } - } - }, - "kinds": { - "description": "topology kinds configuration container", - "markdownDescription": "topology [kinds](https://containerlab.dev/manual/topo-def-file/#kinds) configuration container", - "type": "object", - "properties": { - "nokia_srlinux": { - "$ref": "#/definitions/node-config" - }, - "nokia_srsim": { - "$ref": "#/definitions/node-config" - }, - "nokia_sros": { - "$ref": "#/definitions/node-config" - }, - "arista_ceos": { - "$ref": "#/definitions/node-config" - }, - "vyosnetworks_vyos": { - "$ref": "#/definitions/node-config" - }, - "juniper_crpd": { - "$ref": "#/definitions/node-config" - }, - "sonic-vs": { - "$ref": "#/definitions/node-config" - }, - "sonic-vm": { - "$ref": "#/definitions/node-config" - }, - "dell_ftosv": { - "$ref": "#/definitions/node-config" - }, - "dell_sonic": { - "$ref": "#/definitions/node-config" - }, - "juniper_vmx": { - "$ref": "#/definitions/node-config" - }, - "juniper_vsrx": { - "$ref": "#/definitions/node-config" - }, - "juniper_vjunosrouter": { - "$ref": "#/definitions/node-config" - }, - "juniper_vjunosswitch": { - "$ref": "#/definitions/node-config" - }, - "juniper_vjunosevolved": { - "$ref": "#/definitions/node-config" - }, - "cjunosevolved": { - "$ref": "#/definitions/node-config" - }, - "juniper_cjunosevolved": { - "$ref": "#/definitions/node-config" - }, - "aruba_aoscx": { - "$ref": "#/definitions/node-config" - }, - "cisco_xrd": { - "$ref": "#/definitions/node-config" - }, - "cisco_xrv": { - "$ref": "#/definitions/node-config" - }, - "cisco_xrv9k": { - "$ref": "#/definitions/node-config" - }, - "cisco_nxos": { - "$ref": "#/definitions/node-config" - }, - "cisco_n9kv": { - "$ref": "#/definitions/node-config" - }, - "cisco_csr": { - "$ref": "#/definitions/node-config" - }, - "cisco_cat9kv": { - "$ref": "#/definitions/node-config" - }, - "cisco_ftdv": { - "$ref": "#/definitions/node-config" - }, - "cisco_iol": { - "$ref": "#/definitions/node-config" - }, - "cisco_c8000": { - "$ref": "#/definitions/node-config" - }, - "cisco_c8000v": { - "$ref": "#/definitions/node-config" - }, - "cisco_sdwan": { - "$ref": "#/definitions/node-config" - }, - "cisco_asav": { - "$ref": "#/definitions/node-config" - }, - "linux": { - "$ref": "#/definitions/node-config" - }, - "bridge": { - "$ref": "#/definitions/node-config" - }, - "ovs-bridge": { - "$ref": "#/definitions/node-config" - }, - "host": { - "$ref": "#/definitions/node-config" - }, - "ipinfusion_ocnos": { - "$ref": "#/definitions/node-config" - }, - "keysight_ixia-c-one": { - "$ref": "#/definitions/node-config" - }, - "checkpoint_cloudguard": { - "$ref": "#/definitions/node-config" - }, - "ext-container": { - "$ref": "#/definitions/node-config" - }, - "rare": { - "$ref": "#/definitions/node-config" - }, - "cumulus_cvx": { - "$ref": "#/definitions/node-config" - }, - "openbsd": { - "$ref": "#/definitions/node-config" - }, - "freebsd": { - "$ref": "#/definitions/node-config" - }, - "openwrt": { - "$ref": "#/definitions/node-config" - }, - "huawei_vrp": { - "$ref": "#/definitions/node-config" - }, - "generic_vm": { - "$ref": "#/definitions/node-config" - }, - "fdio_vpp": { - "$ref": "#/definitions/node-config" - }, - "arrcus_arcos": { - "$ref": "#/definitions/node-config" - }, - "mikrotik_ros": { - "$ref": "#/definitions/node-config" - }, - "6wind_vsr": { - "$ref": "#/definitions/node-config" - }, - "spirent_stc": { - "$ref": "#/definitions/node-config" - }, - "veesix_osvbng": { - "$ref": "#/definitions/node-config" - }, - "ostinato": { - "$ref": "#/definitions/node-config" - } - }, - "additionalProperties": false - }, - "defaults": { - "$ref": "#/definitions/node-config" - }, - "links": { - "type": "array", - "description": "topology links section", - "markdownDescription": "[topology links](https://containerlab.dev/manual/topo-def-file/#links)", - "minItems": 1, - "items": { - "anyOf": [ - { - "$ref": "#/definitions/link-config-short" - }, - { - "$ref": "#/definitions/link-type-veth" - }, - { - "$ref": "#/definitions/link-type-mgmt-net" - }, - { - "$ref": "#/definitions/link-type-macvlan" - }, - { - "$ref": "#/definitions/link-type-host" - }, - { - "$ref": "#/definitions/link-type-vxlan" - }, - { - "$ref": "#/definitions/link-type-vxlan-stitched" - }, - { - "$ref": "#/definitions/link-type-dummy" - } - ] - } - } - }, - "additionalProperties": false, - "required": [ - "nodes" - ] - }, - "settings": { - "description": "Global containerlab settings", - "markdownDescription": "Global [containerlab settings]()", - "type": "object", - "properties": { - "certificate-authority": { - "$ref": "#/definitions/certificate-authority-config" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false, - "required": [ - "name", - "topology" - ] -} \ No newline at end of file diff --git a/scripts/check-barrel-exports.js b/scripts/check-barrel-exports.js index a3dc67bd4..8072459ca 100644 --- a/scripts/check-barrel-exports.js +++ b/scripts/check-barrel-exports.js @@ -14,10 +14,6 @@ const DEFAULT_MAX_EXPORTS = 50; const CUSTOM_LIMITS = { // Allow larger barrels for specific entry points "src/commands/index.ts": 120, - "src/reactTopoViewer/webview/components/panels/index.ts": 60, - "src/reactTopoViewer/webview/hooks/index.ts": 65, - "src/reactTopoViewer/shared/parsing/index.ts": 60, - "src/reactTopoViewer/shared/io/index.ts": 60, "src/treeView/index.ts": 60 }; diff --git a/src/commands/capture.ts b/src/commands/capture.ts index 8471eb166..046cc0efc 100644 --- a/src/commands/capture.ts +++ b/src/commands/capture.ts @@ -456,7 +456,8 @@ export async function killAllWiresharkVNCCtrs() { }); outputChannel.info(`Removed Wireshark VNC container: ${containerInfo.Id}`); } catch (err) { - outputChannel.warn(`Failed to remove container ${containerInfo.Id}: ${err}`); + const message = err instanceof Error ? err.message : String(err); + outputChannel.warn(`Failed to remove container ${containerInfo.Id}: ${message}`); } }) ); diff --git a/src/commands/command.ts b/src/commands/command.ts index 6a5293795..f14882412 100644 --- a/src/commands/command.ts +++ b/src/commands/command.ts @@ -307,20 +307,21 @@ export class Command { await this.onSuccessCallback(); } - vscode.window - .showInformationMessage(this.spinnerMsg?.successMsg!, "Show Logs") - .then((choice) => { + const successMsg = this.spinnerMsg?.successMsg; + if (successMsg !== undefined && successMsg.length > 0) { + vscode.window.showInformationMessage(successMsg, "Show Logs").then((choice) => { if (choice === "Show Logs") { outputChannel.show(true); } }); + } } catch (err: unknown) { const command = cmd[1]; const errMessage = err instanceof Error ? err.message : String(err); const customFailMsg = this.spinnerMsg?.failMsg; const failMsg = customFailMsg !== undefined && customFailMsg.length > 0 - ? `${customFailMsg}. Err: ${err}` + ? `${customFailMsg}. Err: ${errMessage}` : `${utils.titleCase(command)} failed: ${errMessage}`; const viewOutputBtn = await vscode.window.showErrorMessage(failMsg, "View logs"); if (viewOutputBtn === "View logs") { diff --git a/src/commands/gottyShare.ts b/src/commands/gottyShare.ts index 560b0326b..3d53d41b8 100644 --- a/src/commands/gottyShare.ts +++ b/src/commands/gottyShare.ts @@ -32,7 +32,8 @@ async function parseGottyLink(output: string): Promise { const fromText = tryParseLinkFromText(output, bracketedHost); if (fromText !== undefined && fromText.length > 0) return fromText; } catch (error) { - outputChannel.error(`Failed to parse GoTTY link: ${error}`); + const message = error instanceof Error ? error.message : String(error); + outputChannel.error(`Failed to parse GoTTY link: ${message}`); } return undefined; } diff --git a/src/commands/graph.ts b/src/commands/graph.ts index 8316766b4..38c77109a 100644 --- a/src/commands/graph.ts +++ b/src/commands/graph.ts @@ -8,10 +8,7 @@ import type { ReactTopoViewer } from "../reactTopoViewer"; import { ReactTopoViewerProvider } from "../reactTopoViewer"; import { getSelectedLabNode } from "../utils/utils"; import * as ins from "../treeView/inspector"; -import { - MSG_LAB_LIFECYCLE_LOG, - MSG_LAB_LIFECYCLE_STATUS -} from "../reactTopoViewer/shared/messages/webview"; +import { MSG_LAB_LIFECYCLE_LOG, MSG_LAB_LIFECYCLE_STATUS } from "@srl-labs/clab-ui/session"; import { ClabCommand } from "./clabCommand"; diff --git a/src/commands/images.ts b/src/commands/images.ts new file mode 100644 index 000000000..e25d1b2ac --- /dev/null +++ b/src/commands/images.ts @@ -0,0 +1,220 @@ +import * as vscode from "vscode"; + +import { + collectKindImageReferencesFromCustomTemplates, + collectKindImageReferencesFromYaml, + type ImageActionResult, + type ImageManagerTargetOptions, + type ImagePullRequest, + type ImageRemoveRequest, + type KindImageReference +} from "@srl-labs/clab-ui/image-manager/catalog"; + +import { getImageManagerWebviewHtml } from "../webviews/imageManager/imageManagerWebviewHtml"; +import { pullDockerImage } from "../utils/docker/docker"; +import { listDockerImageSummaries, removeDockerImage } from "../utils/docker/images"; +import { getCustomNodesFromConfig } from "../reactTopoViewer/extension/services/schema"; + +type ImageManagerRequestMessage = { + command?: string; + requestId?: string; + action?: string; + payload?: unknown; +}; + +let currentPanel: vscode.WebviewPanel | undefined; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optionalString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +function stringField(record: Record, key: string): string { + return optionalString(record, key) ?? ""; +} + +function isImageManagerRequest(message: unknown): message is Required { + if (!isRecord(message)) { + return false; + } + return ( + message.command === "image-manager:request" && + typeof message.requestId === "string" && + typeof message.action === "string" + ); +} + +function asTargetOptions(payload: unknown): ImageManagerTargetOptions { + const record = isRecord(payload) ? payload : {}; + const endpointId = optionalString(record, "endpointId"); + return endpointId === undefined ? {} : { endpointId }; +} + +function asPullRequest(payload: unknown): ImagePullRequest { + const record = isRecord(payload) ? payload : {}; + const endpointId = optionalString(record, "endpointId"); + const kind = optionalString(record, "kind"); + const request: ImagePullRequest = { + ...(endpointId === undefined ? {} : { endpointId }), + image: stringField(record, "image") + }; + if (kind !== undefined) { + request.kind = kind; + } + return request; +} + +function asRemoveRequest(payload: unknown): ImageRemoveRequest { + const record = isRecord(payload) ? payload : {}; + const endpointId = optionalString(record, "endpointId"); + return { + ...(endpointId === undefined ? {} : { endpointId }), + reference: stringField(record, "reference"), + force: record.force === true + }; +} + +function referenceOptions( + options: ImageManagerTargetOptions, + extra: { label: string; path?: string } +): { endpointId?: string; label: string; path?: string } { + return { + ...extra, + ...(options.endpointId === undefined ? {} : { endpointId: options.endpointId }) + }; +} + +async function collectWorkspaceImageReferences( + options: ImageManagerTargetOptions +): Promise { + const yamlFiles = await vscode.workspace.findFiles( + "**/*.clab.{yml,yaml}", + "**/{node_modules,.git,out,dist}/**", + 1000 + ); + const references: KindImageReference[] = []; + for (const uri of yamlFiles) { + try { + const content = Buffer.from(await vscode.workspace.fs.readFile(uri)).toString("utf8"); + references.push( + ...collectKindImageReferencesFromYaml( + content, + referenceOptions(options, { + label: vscode.workspace.asRelativePath(uri, false), + path: uri.fsPath + }) + ) + ); + } catch { + // Ignore malformed or unreadable topology candidates. + } + } + + references.push( + ...collectKindImageReferencesFromCustomTemplates( + getCustomNodesFromConfig(), + referenceOptions(options, { + label: "Custom" + }) + ) + ); + return references; +} + +async function handleImageManagerRequest( + message: Required +): Promise { + switch (message.action) { + case "listImages": + return listDockerImageSummaries(); + case "listImageReferences": + return collectWorkspaceImageReferences(asTargetOptions(message.payload)); + case "pullImage": { + const request = asPullRequest(message.payload); + const image = request.image.trim(); + if (image.length === 0) { + throw new Error("Image reference is required."); + } + const success = await pullDockerImage(image); + return { + success, + image, + message: success ? `Pulled ${image}.` : `Failed to pull ${image}.` + } satisfies ImageActionResult; + } + case "removeImage": { + const request = asRemoveRequest(message.payload); + const reference = request.reference.trim(); + if (reference.length === 0) { + throw new Error("Image reference is required."); + } + await removeDockerImage(reference, request.force === true); + return { + success: true, + image: reference, + message: `Removed ${reference}.` + } satisfies ImageActionResult; + } + default: + throw new Error(`Unsupported image manager action: ${message.action}`); + } +} + +async function respondToImageManagerRequest( + panel: vscode.WebviewPanel, + message: unknown +): Promise { + if (!isImageManagerRequest(message)) { + return; + } + try { + const result = await handleImageManagerRequest(message); + await panel.webview.postMessage({ + type: "image-manager:response", + requestId: message.requestId, + success: true, + result + }); + } catch (error) { + await panel.webview.postMessage({ + type: "image-manager:response", + requestId: message.requestId, + success: false, + error: error instanceof Error ? error.message : String(error) + }); + } +} + +export async function manageImages(context: vscode.ExtensionContext): Promise { + if (currentPanel) { + currentPanel.reveal(vscode.ViewColumn.One); + return; + } + + const panel = vscode.window.createWebviewPanel( + "containerlabImageManager", + "Containerlab Images", + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(context.extensionUri, "dist"), + vscode.Uri.joinPath(context.extensionUri, "resources") + ] + } + ); + currentPanel = panel; + panel.onDidDispose(() => { + currentPanel = undefined; + }); + + panel.webview.html = getImageManagerWebviewHtml(panel.webview, context.extensionUri, {}); + panel.webview.onDidReceiveMessage((message: unknown) => { + void respondToImageManagerRequest(panel, message); + }); +} diff --git a/src/commands/impairments.ts b/src/commands/impairments.ts index ba7551460..3f73fae52 100644 --- a/src/commands/impairments.ts +++ b/src/commands/impairments.ts @@ -31,7 +31,10 @@ export async function setImpairment( } const impairmentFlag = Object.entries(impairment) - .filter(([key, value]) => NETEM_FIELDS.includes(key) && typeof value === "string") + .filter( + (entry): entry is [string, string] => + NETEM_FIELDS.includes(entry[0]) && typeof entry[1] === "string" + ) .map(([key, value]) => `--${key} ${value}`) .join(" "); diff --git a/src/commands/index.ts b/src/commands/index.ts index e2c342049..db9d26ea8 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -88,6 +88,8 @@ export { } from "./workspace"; // External tool and repo commands +export { manageImages } from "./images"; + export { graphDrawIOHorizontal, graphDrawIOVertical, diff --git a/src/commands/inspect.ts b/src/commands/inspect.ts index afde7ed38..f9149b3b6 100644 --- a/src/commands/inspect.ts +++ b/src/commands/inspect.ts @@ -1,10 +1,10 @@ import * as vscode from "vscode"; +import type { InspectContainerData } from "@srl-labs/clab-ui/inspect"; import type { ClabLabTreeNode } from "../treeView/common"; import { outputChannel } from "../globals"; import * as inspector from "../treeView/inspector"; import { getInspectWebviewHtml } from "../webviews/inspect/inspectWebviewHtml"; -import type { InspectContainerData } from "../webviews/inspect/types"; // Store the current panel and context for refresh functionality let currentPanel: vscode.WebviewPanel | undefined; diff --git a/src/commands/nodeImpairments.ts b/src/commands/nodeImpairments.ts index be10795ec..cd95f33b1 100644 --- a/src/commands/nodeImpairments.ts +++ b/src/commands/nodeImpairments.ts @@ -1,10 +1,10 @@ import * as vscode from "vscode"; +import type { NetemFields } from "@srl-labs/clab-ui/node-impairments"; import type { ClabContainerTreeNode } from "../treeView/common"; import { outputChannel, containerlabBinaryPath } from "../globals"; import { runCommand } from "../utils/utils"; import { getNodeImpairmentsWebviewHtml } from "../webviews/nodeImpairments/nodeImpairmentsWebviewHtml"; -import type { NetemFields } from "../webviews/nodeImpairments/types"; /** * Raw netem item from CLI JSON output @@ -27,6 +27,16 @@ function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function getTreeItemLabelText(label: string | vscode.TreeItemLabel | undefined): string { + if (typeof label === "string") { + return label; + } + if (label !== undefined && typeof label.label === "string") { + return label.label; + } + return ""; +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -186,7 +196,9 @@ async function applyNetem( } else { try { await Promise.all(ops); - vscode.window.showInformationMessage(`Applied netem settings for ${node.label}`); + const labelText = getTreeItemLabelText(node.label); + const displayName = labelText !== "" ? labelText : node.name; + vscode.window.showInformationMessage(`Applied netem settings for ${displayName}`); } catch (err: unknown) { vscode.window.showErrorMessage(`Failed to apply settings: ${getErrorMessage(err)}`); } @@ -226,10 +238,11 @@ export async function manageNodeImpairments( context: vscode.ExtensionContext ) { const netemMap = await refreshNetemSettings(node); + const panelLabel = getTreeItemLabelText(node.label); const panel = vscode.window.createWebviewPanel( "clabNodeImpairments", - `Link Impairments: ${node.label}`, + `Link Impairments: ${panelLabel !== "" ? panelLabel : node.name}`, vscode.ViewColumn.One, { enableScripts: true, diff --git a/src/extension.ts b/src/extension.ts index 2920c7921..a8dd14eee 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -47,6 +47,10 @@ import { ContainerlabExplorerViewProvider } from "./webviews/explorer/containerl let explorerViewProvider: ContainerlabExplorerViewProvider | undefined; +function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + function stopRealtimeBackgroundWorkers(): void { stopEventStream(); stopFallbackPolling(); @@ -309,7 +313,8 @@ function registerCommands(context: vscode.ExtensionContext) { ["containerlab.lab.fcli.ni", cmd.fcliNi], ["containerlab.lab.fcli.subif", cmd.fcliSubif], ["containerlab.lab.fcli.sysInfo", cmd.fcliSysInfo], - ["containerlab.lab.fcli.custom", cmd.fcliCustom] + ["containerlab.lab.fcli.custom", cmd.fcliCustom], + ["containerlab.images.manage", () => cmd.manageImages(context)] ]; commands.forEach(([name, handler]) => { context.subscriptions.push(vscode.commands.registerCommand(name, handler)); @@ -426,7 +431,8 @@ function setClabBinPath(): boolean { return true; } } catch (err) { - outputChannel.warn(`Could not resolve containerlab bin path from sys PATH: ${err}`); + const message = getErrorMessage(err); + outputChannel.warn(`Could not resolve containerlab bin path from sys PATH: ${message}`); } setContainerlabBinaryPath("containerlab"); return true; @@ -440,7 +446,8 @@ function setClabBinPath(): boolean { return true; } catch (err) { // Path is invalid or not executable - try to resolve from PATH as fallback - outputChannel.error(`Invalid containerlab.binaryPath "${configPath}": ${err}`); + const message = getErrorMessage(err); + outputChannel.error(`Invalid containerlab.binaryPath "${configPath}": ${message}`); vscode.window.showErrorMessage( `Configured containerlab binary path "${configPath}" is invalid or not executable.` ); diff --git a/src/globals.ts b/src/globals.ts index 3bd78d3b0..3cb247f49 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -6,7 +6,14 @@ */ import * as vscode from "vscode"; import type Docker from "dockerode"; -import { getRecordUnknown } from "./reactTopoViewer/shared/utilities/typeHelpers"; + +function isRecordUnknown(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function getRecordUnknown(value: unknown): Record | undefined { + return isRecordUnknown(value) ? value : undefined; +} /** * Minimal interfaces for providers to avoid circular imports. diff --git a/src/helpers/popularLabs.ts b/src/helpers/popularLabs.ts index ca3b32de5..02862f20e 100644 --- a/src/helpers/popularLabs.ts +++ b/src/helpers/popularLabs.ts @@ -2,7 +2,9 @@ import * as https from "https"; import * as vscode from "vscode"; -import { getRecordUnknown } from "../reactTopoViewer/shared/utilities/typeHelpers"; +function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === "[object Object]"; +} export interface PopularRepo { name: string; @@ -23,12 +25,12 @@ interface GitHubSearchResponse { } function parseGitHubSearchResponse(value: unknown): GitHubSearchResponse { - const items = (getRecordUnknown(value) ?? {}).items; + const items = isPlainObject(value) ? value.items : undefined; if (!Array.isArray(items)) { return {}; } const parsedItems: PopularRepo[] = items - .map((item) => getRecordUnknown(item) ?? {}) + .map((item) => (isPlainObject(item) ? item : {})) .map((item) => ({ name: typeof item.name === "string" ? item.name : "", html_url: typeof item.html_url === "string" ? item.html_url : "", diff --git a/src/reactTopoViewer/extension/ReactTopoViewerProvider.ts b/src/reactTopoViewer/extension/ReactTopoViewerProvider.ts index 8d8522f97..3705964c4 100644 --- a/src/reactTopoViewer/extension/ReactTopoViewerProvider.ts +++ b/src/reactTopoViewer/extension/ReactTopoViewerProvider.ts @@ -12,22 +12,26 @@ import * as vscode from "vscode"; import { runningLabsProvider } from "../../globals"; import type { ClabLabTreeNode } from "../../treeView/common"; -import { TopologyHostCore } from "../shared/host/TopologyHostCore"; -import { nodeFsAdapter } from "../shared/io"; import { MSG_EDGE_STATS_UPDATE, MSG_FIT_VIEWPORT, MSG_NODE_DATA_UPDATED, - MSG_TOPO_MODE_CHANGE -} from "../shared/messages/webview"; -import type { TopoEdge } from "../shared/types/graph"; -import { TOPOLOGY_HOST_PROTOCOL_VERSION } from "../shared/types/messages"; - -import { log } from "./services/logger"; -import { ContainerDataAdapter } from "./services/ContainerDataAdapter"; + MSG_TOPO_MODE_CHANGE, + TopologySessionCore as TopologyHostCore, + buildRuntimeEdgeStatsUpdates, + buildRuntimeNodeUpdates, + createRuntimeContainerDataProvider, + buildTopologySnapshotMessage, + type TopoEdge, + type TopologySnapshot +} from "@srl-labs/clab-ui/session"; +import type { HostRuntimeContainer } from "@srl-labs/clab-ui/host"; +import { nodeFsAdapter } from "./shared/io"; + +import { formatErrorMessage, log } from "./services/logger"; import { deploymentStateChecker } from "./services/DeploymentStateChecker"; -import { buildEdgeStatsUpdates, buildNodeRuntimeUpdates } from "./services/EdgeStatsBuilder"; import { SplitViewManager } from "./services/SplitViewManager"; +import { labsToRuntimeContainers } from "./services/runtimeContainers"; import { createPanel, generateWebviewHtml, @@ -50,6 +54,18 @@ function isClabLabTreeNodeValue(value: unknown): value is ClabLabTreeNode { return typeof value.labPath.absolute === "string"; } +function isTopologySnapshotValue(value: unknown): value is TopologySnapshot { + if (!isRecord(value)) return false; + return ( + typeof value.revision === "number" && + Array.isArray(value.nodes) && + Array.isArray(value.edges) && + typeof value.labName === "string" && + typeof value.mode === "string" && + typeof value.deploymentState === "string" + ); +} + function toClabLabNodeRecord(value: unknown): Record | undefined { if (!isRecord(value)) return undefined; const result: Record = {}; @@ -71,7 +87,7 @@ export class ReactTopoViewer { public currentLabName: string = ""; public isViewMode: boolean = false; public deploymentState: "deployed" | "undeployed" | "unknown" = "unknown"; - private cacheClabTreeDataToTopoviewer: Record | undefined; + private runtimeContainers: HostRuntimeContainer[] = []; private lastTopologyEdges: TopoEdge[] = []; private watcherManager: WatcherManager; private messageRouter: MessageRouter | undefined; @@ -130,13 +146,13 @@ export class ReactTopoViewer { }, INTERNAL_UPDATE_CACHE_SYNC_DELAY_MS); } - private async loadRunningLabsData(): Promise | undefined> { + private async loadRunningLabRuntimeContainers(): Promise { try { const labsData = await runningLabsProvider.discoverInspectLabs(); - return toClabLabNodeRecord(labsData); + return labsToRuntimeContainers(toClabLabNodeRecord(labsData)); } catch (err) { - log.warn(`Failed to load running lab data: ${err}`); - return undefined; + log.warn(`Failed to load running lab data: ${formatErrorMessage(err)}`); + return []; } } @@ -149,12 +165,10 @@ export class ReactTopoViewer { this.internalUpdateDepth > 0 || Date.now() < this.internalUpdateGraceUntil }; const postSnapshot = (snapshot: unknown) => { - panel.webview.postMessage({ - type: "topology-host:snapshot", - protocolVersion: TOPOLOGY_HOST_PROTOCOL_VERSION, - snapshot, - reason: "external-change" - }); + if (!isTopologySnapshotValue(snapshot)) { + return; + } + panel.webview.postMessage(buildTopologySnapshotMessage(snapshot, "external-change")); }; this.watcherManager.setupFileWatcher( @@ -191,6 +205,7 @@ export class ReactTopoViewer { } this.topologyHost?.dispose(); this.topologyHost = undefined; + this.runtimeContainers = []; this.watcherManager.dispose(); }, null, @@ -215,12 +230,12 @@ export class ReactTopoViewer { try { this.deploymentState = await this.checkDeploymentState(labName, this.lastYamlFilePath); } catch (err) { - log.warn(`Failed to check deployment state: ${err}`); + log.warn(`Failed to check deployment state: ${formatErrorMessage(err)}`); this.deploymentState = "unknown"; } if (this.isViewMode) { - this.cacheClabTreeDataToTopoviewer = await this.loadRunningLabsData(); + this.runtimeContainers = await this.loadRunningLabRuntimeContainers(); } } @@ -258,16 +273,14 @@ export class ReactTopoViewer { await this.initializeLabState(labName); - const containerDataProvider = this.isViewMode - ? new ContainerDataAdapter(this.cacheClabTreeDataToTopoviewer) - : undefined; - this.topologyHost = new TopologyHostCore({ fs: nodeFsAdapter, yamlFilePath: this.lastYamlFilePath, mode: this.isViewMode ? "view" : "edit", deploymentState: this.deploymentState, - containerDataProvider, + containerDataProvider: this.isViewMode + ? createRuntimeContainerDataProvider(this.runtimeContainers) + : undefined, setInternalUpdate: (updating: boolean) => this.setInternalUpdate(updating), logger: log }); @@ -337,7 +350,7 @@ export class ReactTopoViewer { }); return true; } catch (err) { - log.error(`Failed to update panel: ${err}`); + log.error(`Failed to update panel: ${formatErrorMessage(err)}`); return false; } } @@ -364,27 +377,19 @@ export class ReactTopoViewer { } // Reload running lab data if switching to view mode - this.cacheClabTreeDataToTopoviewer = this.isViewMode - ? await this.loadRunningLabsData() - : undefined; + this.runtimeContainers = this.isViewMode ? await this.loadRunningLabRuntimeContainers() : []; if (this.topologyHost) { - const containerDataProvider = this.isViewMode - ? new ContainerDataAdapter(this.cacheClabTreeDataToTopoviewer) - : undefined; this.topologyHost.updateContext({ mode: this.isViewMode ? "view" : "edit", deploymentState: this.deploymentState, - containerDataProvider + containerDataProvider: this.isViewMode + ? createRuntimeContainerDataProvider(this.runtimeContainers) + : undefined }); const snapshot = await this.topologyHost.getSnapshot(); this.lastTopologyEdges = snapshot.edges; - this.currentPanel.webview.postMessage({ - type: "topology-host:snapshot", - protocolVersion: TOPOLOGY_HOST_PROTOCOL_VERSION, - snapshot, - reason: "resync" - }); + this.currentPanel.webview.postMessage(buildTopologySnapshotMessage(snapshot, "resync")); } // Notify webview of mode change @@ -395,7 +400,9 @@ export class ReactTopoViewer { ); return true; } catch (err) { - log.error(`[ReactTopoViewer] Failed to refresh after external command: ${err}`); + log.error( + `[ReactTopoViewer] Failed to refresh after external command: ${formatErrorMessage(err)}` + ); return false; } } @@ -435,20 +442,19 @@ export class ReactTopoViewer { } try { - // Update cached labs data - this.cacheClabTreeDataToTopoviewer = labsData; + const runtimeContainers = labsToRuntimeContainers(labsData); + this.runtimeContainers = runtimeContainers; if (this.topologyHost) { this.topologyHost.updateContext({ - containerDataProvider: new ContainerDataAdapter(labsData) + containerDataProvider: createRuntimeContainerDataProvider(runtimeContainers) }); } - // Build edge stats updates from cached edges using extracted builder - const edgeUpdates = buildEdgeStatsUpdates(this.lastTopologyEdges, labsData, { + const edgeUpdates = buildRuntimeEdgeStatsUpdates(this.lastTopologyEdges, runtimeContainers, { currentLabName: this.currentLabName, topology: this.topologyHost?.currentClabTopology?.topology }); - const nodeUpdates = buildNodeRuntimeUpdates(labsData, this.currentLabName); + const nodeUpdates = buildRuntimeNodeUpdates(runtimeContainers, this.currentLabName); if (edgeUpdates.length > 0) { // Send only edge stats updates (not full topology) @@ -465,7 +471,7 @@ export class ReactTopoViewer { }); } } catch (err) { - log.error(`[ReactTopoViewer] Failed to refresh link states: ${err}`); + log.error(`[ReactTopoViewer] Failed to refresh link states: ${formatErrorMessage(err)}`); } } diff --git a/src/reactTopoViewer/extension/panel/BootstrapDataBuilder.ts b/src/reactTopoViewer/extension/panel/BootstrapDataBuilder.ts index a7154a3ce..317127031 100644 --- a/src/reactTopoViewer/extension/panel/BootstrapDataBuilder.ts +++ b/src/reactTopoViewer/extension/panel/BootstrapDataBuilder.ts @@ -4,10 +4,9 @@ import type * as vscode from "vscode"; -import type { CustomIconInfo } from "../../shared/types/icons"; +import type { CustomIconInfo, CustomNodeTemplate } from "@srl-labs/clab-ui/session"; import { getDockerImages } from "../../../utils/docker/images"; -import type { CustomNodeTemplate, SchemaData } from "../../shared/schema"; -import { getCustomNodesFromConfig, loadSchemaData } from "../services/schema"; +import { getCustomNodesFromConfig } from "../services/schema"; import { iconService } from "../services/IconService"; /** @@ -16,7 +15,6 @@ import { iconService } from "../services/IconService"; export interface BootstrapData { customNodes: CustomNodeTemplate[]; defaultNode: string; - schemaData: SchemaData; dockerImages: string[]; customIcons: CustomIconInfo[]; } @@ -33,15 +31,12 @@ export interface BootstrapDataInput { * Assembles bootstrap data for the webview from various sources */ export async function buildBootstrapData(input: BootstrapDataInput): Promise { - const { extensionUri, yamlFilePath } = input; + const { yamlFilePath } = input; // Get custom nodes from VS Code configuration const customNodes = getCustomNodesFromConfig(); const defaultNode = customNodes.find((n) => n.setDefault === true)?.name ?? ""; - // Load schema data for kind/type dropdowns - const schemaData = await loadSchemaData(extensionUri); - // Get docker images for image dropdown const dockerImages = getDockerImages(); @@ -51,7 +46,6 @@ export async function buildBootstrapData(input: BootstrapDataInput): Promise & { @@ -59,25 +56,12 @@ interface GrafanaBundleExportPayload { panelYaml: string; } -const TOPOLOGY_HOST_GET_SNAPSHOT = "topology-host:get-snapshot"; -const TOPOLOGY_HOST_COMMAND = "topology-host:command"; -const TOPOLOGY_HOST_SNAPSHOT = "topology-host:snapshot"; -const TOPOLOGY_HOST_ACK = "topology-host:ack"; -const TOPOLOGY_HOST_REJECT = "topology-host:reject"; -const TOPOLOGY_HOST_ERROR = "topology-host:error"; const SNAPSHOT_ERROR_MODAL_COOLDOWN_MS = 5000; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -function isTopologyHostCommand(value: unknown): value is TopologyHostCommand { - if (!isRecord(value)) return false; - if (typeof value.command !== "string") return false; - if (value.command === "undo" || value.command === "redo") return true; - return "payload" in value; -} - function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((entry): entry is string => typeof entry === "string"); @@ -122,20 +106,28 @@ export class MessageRouter { const level = typeof message.level === "string" ? message.level : "info"; const logMsg = typeof message.message === "string" ? message.message : ""; const fileLine = typeof message.fileLine === "string" ? message.fileLine : undefined; - logWithLocation(level || "info", logMsg || "", fileLine); + logWithLocation(level, logMsg, fileLine); return true; } if (command === "topoViewerLog") { const level = typeof message.level === "string" ? message.level : "info"; const logMessage = typeof message.message === "string" ? message.message : ""; - const logger = - ( - { error: log.error, warn: log.warn, debug: log.debug } as Record< - string, - (m: string) => void - > - )[level] ?? log.info; + const loggers: Record void> = { + error: (value: string) => { + log.error(value); + }, + warn: (value: string) => { + log.warn(value); + }, + debug: (value: string) => { + log.debug(value); + }, + info: (value: string) => { + log.info(value); + } + }; + const logger = loggers[level] ?? loggers.info; logger(logMessage); return true; } @@ -146,61 +138,6 @@ export class MessageRouter { /** * Handle TopologyHost protocol messages (snapshot + commands) */ - private postTopologyHostError( - panel: vscode.WebviewPanel, - requestId: string, - error: string - ): void { - panel.webview.postMessage({ - type: TOPOLOGY_HOST_ERROR, - protocolVersion: TOPOLOGY_HOST_PROTOCOL_VERSION, - requestId, - error - }); - } - - private getTopologyHostProtocolVersion(message: WebviewMessage): number | undefined { - const protocolVersion = message.protocolVersion; - return typeof protocolVersion === "number" ? protocolVersion : undefined; - } - - private parseTopologyHostCommand( - message: WebviewMessage - ): { command: TopologyHostCommand; baseRevision: number } | null { - const baseRevisionRaw = message.baseRevision; - const commandPayload = message.command; - const baseRevision = - typeof baseRevisionRaw === "number" && Number.isFinite(baseRevisionRaw) - ? baseRevisionRaw - : NaN; - if (!isTopologyHostCommand(commandPayload) || !Number.isFinite(baseRevision)) { - return null; - } - return { command: commandPayload, baseRevision }; - } - - private async sendTopologySnapshot( - host: TopologyHost, - panel: vscode.WebviewPanel, - requestId: string - ): Promise { - try { - const snapshot = await host.getSnapshot(); - this.context.onHostSnapshot?.(snapshot); - panel.webview.postMessage({ - type: TOPOLOGY_HOST_SNAPSHOT, - protocolVersion: TOPOLOGY_HOST_PROTOCOL_VERSION, - requestId, - snapshot, - reason: "init" - }); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - this.showSnapshotLoadErrorModal(errorMessage); - this.postTopologyHostError(panel, requestId, errorMessage); - } - } - private showSnapshotLoadErrorModal(errorMessage: string): void { const yamlPath = this.context.yamlFilePath || "unknown topology file"; const modalMessage = `Failed to read topology files for:\n${yamlPath}\n\n${errorMessage}`; @@ -221,50 +158,19 @@ export class MessageRouter { message: WebviewMessage, panel: vscode.WebviewPanel ): Promise { - const msgType = typeof message.type === "string" ? message.type : ""; - if (msgType !== TOPOLOGY_HOST_GET_SNAPSHOT && msgType !== TOPOLOGY_HOST_COMMAND) { - return false; - } - - const requestId = typeof message.requestId === "string" ? message.requestId : ""; - const protocolVersion = this.getTopologyHostProtocolVersion(message); - - if (protocolVersion !== TOPOLOGY_HOST_PROTOCOL_VERSION) { - this.postTopologyHostError( - panel, - requestId, - `Unsupported topology host protocol version: ${protocolVersion ?? "unknown"}` - ); - return true; - } - - const host = this.context.topologyHost; - if (!host) { - this.postTopologyHostError(panel, requestId, "Topology host unavailable"); - return true; - } - - if (msgType === TOPOLOGY_HOST_GET_SNAPSHOT) { - await this.sendTopologySnapshot(host, panel, requestId); - return true; - } - - const commandData = this.parseTopologyHostCommand(message); - if (!commandData) { - this.postTopologyHostError(panel, requestId, "Invalid topology host command payload"); - return true; - } - - const response = await host.applyCommand(commandData.command, commandData.baseRevision); - const responseWithId = { ...response, requestId: requestId || response.requestId }; - if (responseWithId.type === TOPOLOGY_HOST_ACK || responseWithId.type === TOPOLOGY_HOST_REJECT) { - const snapshot = (responseWithId as { snapshot?: TopologySnapshot }).snapshot; - if (snapshot) { + return handleTopologyHostProtocolMessage({ + host: this.context.topologyHost, + message, + onSnapshot: (snapshot: TopologySnapshot) => { this.context.onHostSnapshot?.(snapshot); + }, + onSnapshotLoadError: (errorMessage: string) => { + this.showSnapshotLoadErrorModal(errorMessage); + }, + postMessage: (response: TopologyHostResponseMessage) => { + panel.webview.postMessage(response); } - } - panel.webview.postMessage(responseWithId); - return true; + }); } private async handleLifecycleCommand(command: LifecycleCommand): Promise { @@ -277,7 +183,7 @@ export class MessageRouter { if (res.error != null && res.error.length > 0) { log.error(`[MessageRouter] ${res.error}`); } else if (res.result != null) { - log.info(`[MessageRouter] ${String(res.result)}`); + log.info(`[MessageRouter] ${formatUnknownForLog(res.result)}`); } } @@ -291,7 +197,7 @@ export class MessageRouter { const isOpen = await this.context.splitViewManager.toggleSplitView(yamlFilePath, panel); log.info(`[MessageRouter] Split view toggled: ${isOpen ? "opened" : "closed"}`); } catch (err) { - log.error(`[MessageRouter] Failed to toggle split view: ${err}`); + log.error(`[MessageRouter] Failed to toggle split view: ${formatErrorMessage(err)}`); } } @@ -366,6 +272,8 @@ export class MessageRouter { const name = this.getCustomNodeName(message); return customNodeConfigManager.setDefaultCustomNode(name); } + default: + return undefined; } } @@ -539,7 +447,7 @@ export class MessageRouter { } const res = await nodeCommandService.handleNodeEndpoint(command, nodeName, yamlFilePath); if (res.error != null && res.error.length > 0) log.error(`[MessageRouter] ${res.error}`); - else if (res.result != null) log.info(`[MessageRouter] ${String(res.result)}`); + else if (res.result != null) log.info(`[MessageRouter] ${formatUnknownForLog(res.result)}`); } private async handleInterfaceCommand( @@ -561,7 +469,7 @@ export class MessageRouter { yamlFilePath ); if (res.error != null && res.error.length > 0) log.error(`[MessageRouter] ${res.error}`); - else if (res.result != null) log.info(`[MessageRouter] ${String(res.result)}`); + else if (res.result != null) log.info(`[MessageRouter] ${formatUnknownForLog(res.result)}`); } private async handleIconList(panel: vscode.WebviewPanel): Promise { @@ -621,7 +529,7 @@ export class MessageRouter { }; await handlers[command](); } catch (err) { - log.error(`[MessageRouter] Icon command error: ${err}`); + log.error(`[MessageRouter] Icon command error: ${formatErrorMessage(err)}`); } } diff --git a/src/reactTopoViewer/extension/panel/PanelManager.ts b/src/reactTopoViewer/extension/panel/PanelManager.ts index db978e400..8f008de94 100644 --- a/src/reactTopoViewer/extension/panel/PanelManager.ts +++ b/src/reactTopoViewer/extension/panel/PanelManager.ts @@ -78,9 +78,11 @@ export function generateWebviewHtml(data: WebviewHtmlData): string { const scriptUri = webview.asWebviewUri( vscode.Uri.joinPath(extensionUri, "dist", "reactTopoViewerWebview.js") ); + const scriptUriString = scriptUri.toString(); const styleUri = webview.asWebviewUri( vscode.Uri.joinPath(extensionUri, "dist", "reactTopoViewerStyles.css") ); + const styleUriString = styleUri.toString(); const maplibreWorkerUri = webview.asWebviewUri( vscode.Uri.joinPath(extensionUri, "dist", "maplibre-gl-csp-worker.js") ); @@ -112,21 +114,27 @@ export function generateWebviewHtml(data: WebviewHtmlData): string { - + TopoViewer (React)
- + `; } diff --git a/src/reactTopoViewer/extension/panel/Watchers.ts b/src/reactTopoViewer/extension/panel/Watchers.ts index 2ec89e5cc..3a375f1ad 100644 --- a/src/reactTopoViewer/extension/panel/Watchers.ts +++ b/src/reactTopoViewer/extension/panel/Watchers.ts @@ -5,7 +5,8 @@ import * as vscode from "vscode"; import { log } from "../services/logger"; -import { nodeFsAdapter } from "../../shared/io"; +import { createTopologySyncController } from "@srl-labs/clab-ui/host"; +import { nodeFsAdapter } from "../shared/io"; import { onDockerImagesUpdated } from "../../../utils/docker/images"; /** @@ -35,8 +36,10 @@ export class WatcherManager { private dockerImagesSubscription: vscode.Disposable | undefined; private lastYamlContent: string | undefined; private lastAnnotationsContent: string | undefined; - private isRefreshingFromFile = false; - private queuedRefresh = false; + private snapshotRefreshController = createTopologySyncController({ + debounceMs: 0, + refresh: async () => {} + }); /** * Dispose all watchers and listeners @@ -58,6 +61,7 @@ export class WatcherManager { this.dockerImagesSubscription.dispose(); this.dockerImagesSubscription = undefined; } + this.snapshotRefreshController.dispose(); } /** @@ -73,6 +77,16 @@ export class WatcherManager { this.fileWatcher?.dispose(); this.annotationsWatcher?.dispose(); + this.snapshotRefreshController.dispose(); + this.snapshotRefreshController = createTopologySyncController({ + debounceMs: 0, + refresh: async () => { + const snapshot = await loadSnapshot(); + if (snapshot !== undefined && snapshot !== null) { + postSnapshot(snapshot); + } + } + }); const fileUri = vscode.Uri.file(yamlFilePath); this.fileWatcher = vscode.workspace.createFileSystemWatcher(fileUri.fsPath); @@ -171,8 +185,8 @@ export class WatcherManager { trigger: "change" | "save", yamlFilePath: string, updateController: InternalUpdateController, - loadSnapshot: SnapshotLoader, - postSnapshot: SnapshotPoster + _loadSnapshot: SnapshotLoader, + _postSnapshot: SnapshotPoster ): Promise { if (!yamlFilePath) return; if (updateController.isInternalUpdate()) { @@ -181,12 +195,6 @@ export class WatcherManager { return; } - if (this.isRefreshingFromFile) { - this.queuedRefresh = true; - return; - } - - this.isRefreshingFromFile = true; try { const currentContent = await nodeFsAdapter.readFile(yamlFilePath); if (this.lastYamlContent === currentContent) { @@ -197,26 +205,10 @@ export class WatcherManager { } log.info(`[ReactTopoViewer] YAML ${trigger} detected, refreshing topology`); - - const snapshot = await loadSnapshot(); - if (snapshot !== undefined && snapshot !== null) { - postSnapshot(snapshot); - } this.lastYamlContent = currentContent; + await this.snapshotRefreshController.refresh({ externalChange: true }); } catch (err) { - log.error(`[ReactTopoViewer] Failed to refresh after YAML ${trigger}: ${err}`); - } finally { - this.isRefreshingFromFile = false; - if (this.queuedRefresh) { - this.queuedRefresh = false; - void this.handleExternalYamlChange( - trigger, - yamlFilePath, - updateController, - loadSnapshot, - postSnapshot - ); - } + log.error(`[ReactTopoViewer] Failed to refresh after YAML ${trigger}: ${String(err)}`); } } @@ -227,8 +219,8 @@ export class WatcherManager { trigger: "change" | "create" | "delete", annotationsPath: string, updateController: InternalUpdateController, - loadSnapshot: SnapshotLoader, - postSnapshot: SnapshotPoster + _loadSnapshot: SnapshotLoader, + _postSnapshot: SnapshotPoster ): Promise { if (!annotationsPath) return; if (updateController.isInternalUpdate()) { @@ -238,12 +230,6 @@ export class WatcherManager { return; } - if (this.isRefreshingFromFile) { - this.queuedRefresh = true; - return; - } - - this.isRefreshingFromFile = true; try { let currentContent = ""; try { @@ -260,27 +246,10 @@ export class WatcherManager { } log.info(`[ReactTopoViewer] Annotations ${trigger} detected, refreshing topology`); - - const snapshot = await loadSnapshot(); - if (snapshot !== undefined && snapshot !== null) { - postSnapshot(snapshot); - } - this.lastAnnotationsContent = currentContent; + await this.snapshotRefreshController.refresh({ externalChange: true }); } catch (err) { - log.error(`[ReactTopoViewer] Failed to refresh after annotations ${trigger}: ${err}`); - } finally { - this.isRefreshingFromFile = false; - if (this.queuedRefresh) { - this.queuedRefresh = false; - void this.handleExternalAnnotationsChange( - trigger, - annotationsPath, - updateController, - loadSnapshot, - postSnapshot - ); - } + log.error(`[ReactTopoViewer] Failed to refresh after annotations ${trigger}: ${String(err)}`); } } } diff --git a/src/reactTopoViewer/extension/panel/index.ts b/src/reactTopoViewer/extension/panel/index.ts index 4670d94d3..df4988500 100644 --- a/src/reactTopoViewer/extension/panel/index.ts +++ b/src/reactTopoViewer/extension/panel/index.ts @@ -19,13 +19,13 @@ export { buildBootstrapData } from "./BootstrapDataBuilder"; export type { BootstrapData, BootstrapDataInput } from "./BootstrapDataBuilder"; // Schema types and functions -export type { CustomNodeTemplate, SchemaData } from "../../shared/schema"; +export type { CustomNodeTemplate, SchemaData } from "@srl-labs/clab-ui/session"; export { extractKindsFromSchema, extractTypesByKindFromSchema, extractSrosComponentTypes, parseSchemaData -} from "../../shared/schema"; +} from "@srl-labs/clab-ui/session"; // Service adapters -export { getCustomNodesFromConfig, loadSchemaData } from "../services/schema"; +export { getCustomNodesFromConfig } from "../services/schema"; diff --git a/src/reactTopoViewer/extension/services/ContainerDataAdapter.ts b/src/reactTopoViewer/extension/services/ContainerDataAdapter.ts deleted file mode 100644 index 4bc9c8c79..000000000 --- a/src/reactTopoViewer/extension/services/ContainerDataAdapter.ts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * Adapter that wraps VS Code tree data to implement ContainerDataProvider. - * This allows the shared parser to access container data without VS Code dependencies. - */ - -import { - type ClabLabTreeNode, - type ClabContainerTreeNode, - type ClabInterfaceTreeNode, - flattenContainers -} from "../../../treeView/common"; -import { mapSrosInterfaceName } from "../../shared/parsing/DistributedSrosMapper"; -import type { - ContainerDataProvider, - ContainerInfo, - InterfaceInfo -} from "../../shared/parsing/types"; - -import { sortContainersByInterfacePriority } from "./TreeUtils"; - -/** - * Adapts VS Code tree nodes to the ContainerDataProvider interface. - */ -export class ContainerDataAdapter implements ContainerDataProvider { - /** Map from lab name to lab node (for quick lookup by name) */ - private readonly labByName: Map; - /** Map from file path to lab node (original keys from discoverInspectLabs) */ - private readonly labByPath: Map; - - constructor(clabTreeData: Record | undefined) { - this.labByName = new Map(); - this.labByPath = new Map(); - if (clabTreeData) { - for (const [pathKey, labNode] of Object.entries(clabTreeData)) { - // Store by path (the original key) - this.labByPath.set(pathKey, labNode); - // Also store by lab name for easier lookup - if (labNode.name !== undefined && labNode.name.length > 0) { - this.labByName.set(labNode.name, labNode); - } - } - } - } - - /** - * Finds a lab node by name, trying name map first then falling back to path map. - */ - private findLabNode(labName: string): ClabLabTreeNode | undefined { - // Try direct lookup by lab name first - const byName = this.labByName.get(labName); - if (byName) return byName; - - // Fall back to searching all labs by their name property - for (const labNode of this.labByPath.values()) { - if (labNode.name === labName) { - return labNode; - } - } - return undefined; - } - - /** - * Finds a container tree node by name within a lab. - */ - private findContainerNode( - containerName: string, - labName: string - ): ClabContainerTreeNode | undefined { - const labNode = this.findLabNode(labName); - if (!labNode?.containers) return undefined; - - return flattenContainers(labNode.containers).find( - (c) => c.name === containerName || c.name_short === containerName - ); - } - - private normalizeName(value: string | undefined): string { - return (value ?? "").trim().toLowerCase(); - } - - private extractDistributedBaseFromName(value: string | undefined): string | undefined { - const trimmed = (value ?? "").trim(); - if (!trimmed) { - return undefined; - } - const idx = trimmed.lastIndexOf("-"); - if (idx <= 0 || idx >= trimmed.length - 1) { - return undefined; - } - return trimmed.slice(0, idx); - } - - private containerMatchesDistributedNode( - container: ClabContainerTreeNode, - normalizedBase: string - ): boolean { - if (container.kind !== "nokia_srsim" || !normalizedBase) { - return false; - } - - const root = this.normalizeName(container.rootNodeName); - if (root && root === normalizedBase) { - return true; - } - - const shortName = this.normalizeName(container.name_short); - if (shortName.startsWith(`${normalizedBase}-`)) { - return true; - } - - const shortBase = this.normalizeName(this.extractDistributedBaseFromName(container.name_short)); - if (shortBase && shortBase === normalizedBase) { - return true; - } - - const label = - typeof container.label === "string" - ? this.normalizeName(container.label) - : this.normalizeName((container.label as { label?: string } | undefined)?.label); - if (label.startsWith(`${normalizedBase}-`)) { - return true; - } - - const labelBase = this.normalizeName( - this.extractDistributedBaseFromName( - typeof container.label === "string" ? container.label : "" - ) - ); - if (labelBase && labelBase === normalizedBase) { - return true; - } - - return false; - } - - private findDistributedContainerNodes( - baseNodeName: string, - labName: string - ): ClabContainerTreeNode[] { - const labNode = this.findLabNode(labName); - if (!labNode?.containers) { - return []; - } - - const normalizedBase = this.normalizeName(baseNodeName); - if (!normalizedBase) { - return []; - } - - const candidates = flattenContainers(labNode.containers).filter((container) => - this.containerMatchesDistributedNode(container, normalizedBase) - ); - - return sortContainersByInterfacePriority(candidates); - } - - private getSrosInterfaceCandidates(ifaceName: string): Set { - const candidates = new Set(); - const normalized = ifaceName.trim(); - if (normalized) { - candidates.add(normalized); - } - - const mapped = mapSrosInterfaceName(normalized); - if (mapped !== undefined && mapped.length > 0) { - candidates.add(mapped); - } - - return candidates; - } - - private findMatchingInterface( - container: ClabContainerTreeNode, - ifaceName: string - ): ClabInterfaceTreeNode | undefined { - const candidates = this.getSrosInterfaceCandidates(ifaceName); - if (candidates.size === 0) { - return undefined; - } - - return container.interfaces.find((iface) => { - const label = - typeof iface.label === "string" - ? iface.label - : ((iface.label as { label?: string } | undefined)?.label ?? ""); - return ( - candidates.has(iface.name) || - candidates.has(iface.alias) || - (label ? candidates.has(label) : false) - ); - }); - } - - /** - * Finds a container by name within a lab. - */ - findContainer(containerName: string, labName: string): ContainerInfo | undefined { - const container = this.findContainerNode(containerName, labName); - return container ? this.toContainerInfo(container) : undefined; - } - - /** - * Finds an interface by name within a container. - */ - findInterface( - containerName: string, - ifaceName: string, - labName: string - ): InterfaceInfo | undefined { - const container = this.findContainerNode(containerName, labName); - if (!container?.interfaces) return undefined; - - const iface = container.interfaces.find((i) => i.name === ifaceName || i.alias === ifaceName); - - return iface ? this.toInterfaceInfo(iface) : undefined; - } - - findDistributedSrosInterface(params: { - baseNodeName: string; - ifaceName: string; - fullPrefix: string; - labName: string; - components: unknown[]; - }): { containerName: string; ifaceData?: InterfaceInfo } | undefined { - const candidates = this.findDistributedContainerNodes(params.baseNodeName, params.labName); - for (const container of candidates) { - const iface = this.findMatchingInterface(container, params.ifaceName); - if (iface) { - return { - containerName: container.name, - ifaceData: this.toInterfaceInfo(iface) - }; - } - } - return undefined; - } - - findDistributedSrosContainer(params: { - baseNodeName: string; - fullPrefix: string; - labName: string; - components: unknown[]; - }): ContainerInfo | undefined { - const candidates = this.findDistributedContainerNodes(params.baseNodeName, params.labName); - if (candidates.length === 0) return undefined; - return this.toContainerInfo(candidates[0]); - } - - /** - * Gets all containers in a lab. - */ - getContainersForLab(labName: string): ContainerInfo[] { - const labNode = this.findLabNode(labName); - if (!labNode?.containers) return []; - - return flattenContainers(labNode.containers).map((c) => this.toContainerInfo(c)); - } - - /** - * Gets all interfaces for a container. - */ - getInterfacesForContainer(containerName: string, labName: string): InterfaceInfo[] { - const container = this.findContainerNode(containerName, labName); - if (!container?.interfaces) return []; - - return container.interfaces.map((i) => this.toInterfaceInfo(i)); - } - - /** - * Converts a ClabContainerTreeNode to ContainerInfo. - */ - private toContainerInfo(container: ClabContainerTreeNode): ContainerInfo { - // Extract label text - it may be a TreeItemLabel object or string - const label = - typeof container.label === "string" - ? container.label - : ((container.label as { label: string } | undefined)?.label ?? container.name); - - return { - name: container.name, - name_short: container.name_short, - rootNodeName: container.rootNodeName, - state: container.state, - kind: container.kind, - image: container.image, - // Use the getter methods that remove CIDR mask, default to empty string - IPv4Address: container.IPv4Address, - IPv6Address: container.IPv6Address, - nodeType: container.nodeType, - nodeGroup: container.nodeGroup, - interfaces: container.interfaces.map((i) => this.toInterfaceInfo(i)), - label - }; - } - - /** - * Converts a ClabInterfaceTreeNode to InterfaceInfo. - */ - private toInterfaceInfo(iface: ClabInterfaceTreeNode): InterfaceInfo { - return { - name: iface.name, - alias: iface.alias, - type: iface.type, - mac: iface.mac, - mtu: iface.mtu, - ifIndex: iface.ifIndex, - state: iface.state, - stats: iface.stats - ? { - rxBps: iface.stats.rxBps, - txBps: iface.stats.txBps, - rxPps: iface.stats.rxPps, - txPps: iface.stats.txPps, - rxBytes: iface.stats.rxBytes, - txBytes: iface.stats.txBytes, - rxPackets: iface.stats.rxPackets, - txPackets: iface.stats.txPackets, - statsIntervalSeconds: iface.stats.statsIntervalSeconds - } - : undefined, - netemState: iface.netemState - ? { - delay: iface.netemState.delay, - jitter: iface.netemState.jitter, - loss: iface.netemState.loss, - rate: iface.netemState.rate, - corruption: iface.netemState.corruption - } - : undefined - }; - } -} diff --git a/src/reactTopoViewer/extension/services/CustomNodeConfigManager.ts b/src/reactTopoViewer/extension/services/CustomNodeConfigManager.ts index 1c6ab8ecf..62ec7ba1f 100644 --- a/src/reactTopoViewer/extension/services/CustomNodeConfigManager.ts +++ b/src/reactTopoViewer/extension/services/CustomNodeConfigManager.ts @@ -1,9 +1,8 @@ import * as vscode from "vscode"; -import type { EndpointResult } from "../../shared/types/endpoint"; -import type { CustomNodeTemplate } from "../../shared/types/editors"; +import type { CustomNodeTemplate, EndpointResult } from "@srl-labs/clab-ui/session"; -import { log } from "./logger"; +import { formatErrorMessage, log } from "./logger"; const CONFIG_SECTION = "containerlab.editor"; @@ -59,7 +58,7 @@ export class CustomNodeConfigManager { log.info(`Saved custom node ${data.name}`); return { result: { customNodes, defaultNode: defaultCustomNode?.name ?? "" }, error: null }; } catch (err) { - const error = `Error saving custom node: ${err}`; + const error = `Error saving custom node: ${formatErrorMessage(err)}`; log.error(`Error saving custom node: ${JSON.stringify(err, null, 2)}`); return { result: null, error }; } @@ -95,7 +94,7 @@ export class CustomNodeConfigManager { error: null }; } catch (err) { - const error = `Error setting default custom node: ${err}`; + const error = `Error setting default custom node: ${formatErrorMessage(err)}`; log.error(`Error setting default custom node: ${JSON.stringify(err, null, 2)}`); return { result: null, error }; } @@ -117,7 +116,7 @@ export class CustomNodeConfigManager { error: null }; } catch (err) { - const error = `Error deleting custom node: ${err}`; + const error = `Error deleting custom node: ${formatErrorMessage(err)}`; log.error(`Error deleting custom node: ${JSON.stringify(err, null, 2)}`); return { result: null, error }; } diff --git a/src/reactTopoViewer/extension/services/DeploymentStateChecker.ts b/src/reactTopoViewer/extension/services/DeploymentStateChecker.ts index 85152782b..3b4d71d8e 100644 --- a/src/reactTopoViewer/extension/services/DeploymentStateChecker.ts +++ b/src/reactTopoViewer/extension/services/DeploymentStateChecker.ts @@ -4,9 +4,9 @@ */ import * as inspector from "../../../treeView/inspector"; -import type { DeploymentState } from "../../shared/types/topology"; +import type { DeploymentState } from "@srl-labs/clab-ui/session"; -import { log } from "./logger"; +import { formatErrorMessage, log } from "./logger"; function hasNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.length > 0; @@ -49,7 +49,7 @@ export class DeploymentStateChecker { return "undeployed"; } catch (err) { - log.warn(`Failed to check deployment state: ${err}`); + log.warn(`Failed to check deployment state: ${formatErrorMessage(err)}`); return "unknown"; } } diff --git a/src/reactTopoViewer/extension/services/EdgeStatsBuilder.ts b/src/reactTopoViewer/extension/services/EdgeStatsBuilder.ts deleted file mode 100644 index 813c80ecd..000000000 --- a/src/reactTopoViewer/extension/services/EdgeStatsBuilder.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * EdgeStatsBuilder - Builds edge statistics updates from lab inspection data - */ - -import { type ClabLabTreeNode, flattenContainers } from "../../../treeView/common"; -import type { ClabTopology } from "../../shared/types/topology"; -import type { TopoEdge, TopologyEdgeData } from "../../shared/types/graph"; -import { extractEdgeInterfaceStats, computeEdgeClassFromStates } from "../../shared/parsing"; - -import { findInterfaceNode } from "./TreeUtils"; - -export interface EdgeStatsUpdate { - id: string; - extraData: Record; - classes?: string; -} - -export interface EdgeStatsBuilderContext { - currentLabName: string; - topology: ClabTopology["topology"] | undefined; -} - -export interface NodeRuntimeUpdate { - containerLongName: string; - containerShortName: string; - state: string; - status?: string; - mgmtIpv4Address?: string; - mgmtIpv6Address?: string; -} - -/** - * Build edge stats updates from cached edges and fresh labs data. - */ -export function buildEdgeStatsUpdates( - edges: TopoEdge[], - labs: Record | undefined, - context: EdgeStatsBuilderContext -): EdgeStatsUpdate[] { - if (!labs || edges.length === 0) { - return []; - } - - const updates: EdgeStatsUpdate[] = []; - - for (const edge of edges) { - const update = buildSingleEdgeUpdate(edge, labs, context); - if (update) { - updates.push(update); - } - } - - return updates; -} - -/** - * Build node runtime updates from fresh lab/container data. - */ -export function buildNodeRuntimeUpdates( - labs: Record | undefined, - currentLabName: string -): NodeRuntimeUpdate[] { - if (!labs) { - return []; - } - - const updates: NodeRuntimeUpdate[] = []; - const labValues = Object.values(labs).filter((lab) => lab.name === currentLabName); - if (labValues.length === 0) { - return []; - } - - for (const lab of labValues) { - for (const container of flattenContainers(lab.containers)) { - updates.push({ - containerLongName: container.name, - containerShortName: container.name_short, - state: container.state, - status: container.status, - mgmtIpv4Address: container.IPv4Address, - mgmtIpv6Address: container.IPv6Address - }); - } - } - - return updates; -} - -/** - * Build update for a single edge. - */ -function buildSingleEdgeUpdate( - edge: TopoEdge, - labs: Record, - context: EdgeStatsBuilderContext -): EdgeStatsUpdate | null { - const edgeData = edge.data; - const extraData = edgeData?.extraData ?? {}; - - // Look up fresh interface data - const { sourceIface, targetIface } = lookupEdgeInterfaces( - edge, - edgeData, - extraData, - labs, - context.currentLabName - ); - - // Build updated extraData from interfaces - const updatedExtraData = buildInterfaceExtraData(sourceIface, targetIface); - - // Compute edge class based on interface states - const edgeClass = computeEdgeClassForUpdate( - context.topology, - extraData, - edge, - sourceIface?.state, - targetIface?.state - ); - - // Only return update if we have something to update - if (Object.keys(updatedExtraData).length === 0) { - return null; - } - - return { id: edge.id, extraData: updatedExtraData, classes: edgeClass }; -} - -/** - * Look up source and target interfaces for an edge. - */ -function lookupEdgeInterfaces( - _edge: TopoEdge, - edgeData: TopologyEdgeData | undefined, - extraData: Record, - labs: Record, - currentLabName: string -): { - sourceIface: ReturnType; - targetIface: ReturnType; -} { - const sourceIfaceName = normalizeInterfaceName( - extraData.clabSourcePort, - edgeData?.sourceEndpoint - ); - const targetIfaceName = normalizeInterfaceName( - extraData.clabTargetPort, - edgeData?.targetEndpoint - ); - - const sourceNodeIdentifier = normalizeNodeIdentifier( - extraData.yamlSourceNodeId, - extraData.clabSourceLongName, - _edge.source - ); - const targetNodeIdentifier = normalizeNodeIdentifier( - extraData.yamlTargetNodeId, - extraData.clabTargetLongName, - _edge.target - ); - - const sourceIface = findInterfaceNode( - labs, - sourceNodeIdentifier, - sourceIfaceName, - currentLabName - ); - const targetIface = findInterfaceNode( - labs, - targetNodeIdentifier, - targetIfaceName, - currentLabName - ); - - return { sourceIface, targetIface }; -} - -/** - * Build extraData object from interface data. - */ -function buildInterfaceExtraData( - sourceIface: ReturnType, - targetIface: ReturnType -): Record { - const updatedExtraData: Record = {}; - - if (sourceIface) { - applyInterfaceToExtraData(updatedExtraData, "Source", sourceIface); - } - if (targetIface) { - applyInterfaceToExtraData(updatedExtraData, "Target", targetIface); - } - - return updatedExtraData; -} - -/** - * Apply interface data to extraData object with given prefix. - */ -function applyInterfaceToExtraData( - extraData: Record, - prefix: "Source" | "Target", - iface: NonNullable> -): void { - extraData[`clab${prefix}InterfaceState`] = iface.state || ""; - extraData[`clab${prefix}MacAddress`] = iface.mac; - extraData[`clab${prefix}Mtu`] = iface.mtu; - extraData[`clab${prefix}Type`] = iface.type; - extraData[`clab${prefix}Netem`] = iface.netemState ?? undefined; - const stats = extractEdgeInterfaceStats(iface); - if (stats) { - extraData[`clab${prefix}Stats`] = stats; - } -} - -/** - * Compute edge class for an update. - */ -function computeEdgeClassForUpdate( - topology: ClabTopology["topology"] | undefined, - extraData: Record, - edge: TopoEdge, - sourceState?: string, - targetState?: string -): string | undefined { - if (!topology) return undefined; - const sourceNodeId = normalizeNodeIdentifier(extraData.yamlSourceNodeId, edge.source); - const targetNodeId = normalizeNodeIdentifier(extraData.yamlTargetNodeId, edge.target); - return computeEdgeClassFromStates(topology, sourceNodeId, targetNodeId, sourceState, targetState); -} - -/** - * Normalize interface name, using fallback if primary is empty. - */ -function normalizeInterfaceName(value: unknown, fallback: unknown): string { - if (typeof value === "string" && value.trim().length > 0) { - return value; - } - if (typeof fallback === "string" && fallback.trim().length > 0) { - return fallback; - } - return ""; -} - -function normalizeNodeIdentifier(...values: unknown[]): string { - for (const value of values) { - if (typeof value === "string" && value.trim().length > 0) { - return value; - } - } - return ""; -} diff --git a/src/reactTopoViewer/extension/services/IconService.ts b/src/reactTopoViewer/extension/services/IconService.ts index a93b8bbf5..31cb210b7 100644 --- a/src/reactTopoViewer/extension/services/IconService.ts +++ b/src/reactTopoViewer/extension/services/IconService.ts @@ -12,15 +12,15 @@ import * as path from "path"; import * as vscode from "vscode"; -import type { CustomIconInfo } from "../../shared/types/icons"; +import type { CustomIconInfo } from "@srl-labs/clab-ui/session"; import { getIconFormat, getIconMimeType, isBuiltInIcon, isSupportedIconExtension -} from "../../shared/types/icons"; +} from "@srl-labs/clab-ui/session"; -import { log } from "./logger"; +import { formatErrorMessage, log } from "./logger"; /** * Name of the workspace icons folder @@ -94,7 +94,7 @@ export class IconService { const base64 = buffer.toString("base64"); return `data:${mimeType};base64,${base64}`; } catch (err) { - log.warn(`Failed to load icon ${filePath}: ${err}`); + log.warn(`Failed to load icon ${filePath}: ${formatErrorMessage(err)}`); return null; } } @@ -136,7 +136,7 @@ export class IconService { } } } catch (err) { - log.warn(`Failed to list icons from ${dirPath}: ${err}`); + log.warn(`Failed to list icons from ${dirPath}: ${formatErrorMessage(err)}`); } return icons; @@ -294,7 +294,7 @@ export class IconService { log.info(`Copied icon "${iconName}" to workspace`); return true; } catch (err) { - log.error(`Failed to copy icon to workspace: ${err}`); + log.error(`Failed to copy icon to workspace: ${formatErrorMessage(err)}`); return false; } } @@ -321,7 +321,7 @@ export class IconService { return false; } catch (err) { - log.error(`Failed to delete workspace icon: ${err}`); + log.error(`Failed to delete workspace icon: ${formatErrorMessage(err)}`); return false; } } @@ -392,7 +392,7 @@ export class IconService { } } } catch (err) { - log.error(`Failed to reconcile workspace icons: ${err}`); + log.error(`Failed to reconcile workspace icons: ${formatErrorMessage(err)}`); } } @@ -420,7 +420,7 @@ export class IconService { // Remove folder if empty await this.removeEmptyDir(workspaceDir); } catch (err) { - log.warn(`Failed to clean workspace icons folder: ${err}`); + log.warn(`Failed to clean workspace icons folder: ${formatErrorMessage(err)}`); } } diff --git a/src/reactTopoViewer/extension/services/LabLifecycleService.ts b/src/reactTopoViewer/extension/services/LabLifecycleService.ts index da2a1b3fc..3e79bf1c9 100644 --- a/src/reactTopoViewer/extension/services/LabLifecycleService.ts +++ b/src/reactTopoViewer/extension/services/LabLifecycleService.ts @@ -5,7 +5,8 @@ import * as vscode from "vscode"; -import type { EndpointResult } from "../../shared/types/endpoint"; +import type { EndpointResult } from "@srl-labs/clab-ui/session"; +import { createLifecycleCommandController } from "@srl-labs/clab-ui/host"; import { log } from "./logger"; @@ -22,7 +23,7 @@ interface LabAction { /** * Map of available lab lifecycle actions. */ -const LAB_ACTIONS: Partial> = { +const LAB_ACTIONS = { deployLab: { command: "containerlab.lab.deploy", resultMsg: "Lab deployment initiated", @@ -59,7 +60,14 @@ const LAB_ACTIONS: Partial> = { errorMsg: "Error redeploying lab with cleanup", noLabPath: "No lab path provided for redeploy with cleanup" } -}; +} satisfies Record; + +type LabActionName = keyof typeof LAB_ACTIONS; +const lifecycleController = createLifecycleCommandController(); + +function isLabActionName(value: string): value is LabActionName { + return value in LAB_ACTIONS; +} /** * Service for handling lab lifecycle operations (deploy, destroy, redeploy). @@ -72,30 +80,34 @@ export class LabLifecycleService { * @param labPath The path to the lab topology file */ async handleLabLifecycleEndpoint(endpointName: string, labPath: string): Promise { - const action = LAB_ACTIONS[endpointName]; - if (!action) { + if (!isLabActionName(endpointName)) { const error = `Unknown endpoint "${endpointName}".`; log.error(error); return { result: null, error }; } + const action = LAB_ACTIONS[endpointName]; if (!labPath) { return { result: null, error: action.noLabPath }; } - try { - const { ClabLabTreeNode } = await import("../../../treeView/common"); - const tempNode = new ClabLabTreeNode("", vscode.TreeItemCollapsibleState.None, { - absolute: labPath, - relative: "" - }); - vscode.commands.executeCommand(action.command, tempNode); - return { result: `${action.resultMsg} for ${labPath}`, error: null }; - } catch (innerError) { - const error = `${action.errorMsg}: ${innerError}`; - log.error(`${action.errorMsg}: ${JSON.stringify(innerError, null, 2)}`); - return { result: null, error }; - } + return ( + (await lifecycleController.run(endpointName, async () => { + try { + const { ClabLabTreeNode } = await import("../../../treeView/common"); + const tempNode = new ClabLabTreeNode("", vscode.TreeItemCollapsibleState.None, { + absolute: labPath, + relative: "" + }); + vscode.commands.executeCommand(action.command, tempNode); + return { result: `${action.resultMsg} for ${labPath}`, error: null }; + } catch (innerError) { + const error = `${action.errorMsg}: ${String(innerError)}`; + log.error(`${action.errorMsg}: ${JSON.stringify(innerError, null, 2)}`); + return { result: null, error }; + } + })) ?? { result: null, error: null } + ); } } diff --git a/src/reactTopoViewer/extension/services/NodeCommandService.ts b/src/reactTopoViewer/extension/services/NodeCommandService.ts index 95177547c..74da29599 100644 --- a/src/reactTopoViewer/extension/services/NodeCommandService.ts +++ b/src/reactTopoViewer/extension/services/NodeCommandService.ts @@ -12,9 +12,9 @@ import { flattenContainers } from "../../../treeView/common"; import { runningLabsProvider } from "../../../globals"; -import type { EndpointResult } from "../../shared/types/endpoint"; +import type { EndpointResult } from "@srl-labs/clab-ui/session"; -import { log } from "./logger"; +import { formatErrorMessage, log } from "./logger"; /** * Type guard to check if a value is a valid ClabLabTreeNode. @@ -222,7 +222,7 @@ export class NodeCommandService { await vscode.commands.executeCommand("containerlab.node.ssh", containerNode); result = `SSH connection executed for ${nodeName}`; } catch (innerError) { - error = `Error executing SSH connection: ${innerError}`; + error = `Error executing SSH connection: ${formatErrorMessage(innerError)}`; log.error(`Error executing SSH connection: ${JSON.stringify(innerError, null, 2)}`); } break; @@ -236,7 +236,7 @@ export class NodeCommandService { await vscode.commands.executeCommand("containerlab.node.attachShell", node); result = `Attach shell executed for ${nodeName}`; } catch (innerError) { - error = `Error executing attach shell: ${innerError}`; + error = `Error executing attach shell: ${formatErrorMessage(innerError)}`; log.error(`Error executing attach shell: ${JSON.stringify(innerError, null, 2)}`); } break; @@ -250,7 +250,7 @@ export class NodeCommandService { await vscode.commands.executeCommand("containerlab.node.showLogs", node); result = `Show logs executed for ${nodeName}`; } catch (innerError) { - error = `Error executing show logs: ${innerError}`; + error = `Error executing show logs: ${formatErrorMessage(innerError)}`; log.error(`Error executing show logs: ${JSON.stringify(innerError, null, 2)}`); } break; @@ -323,7 +323,7 @@ export class NodeCommandService { await vscode.commands.executeCommand("containerlab.interface.capture", iface); return { result: `Capture executed for ${nodeName}/${actualInterfaceName}`, error: null }; } catch (innerError) { - const errorMsg = `Error executing capture: ${innerError}`; + const errorMsg = `Error executing capture: ${formatErrorMessage(innerError)}`; log.error(`Error executing capture: ${JSON.stringify(innerError, null, 2)}`); return { result: null, error: errorMsg }; } @@ -347,7 +347,7 @@ export class NodeCommandService { await vscode.commands.executeCommand("containerlab.interface.setImpairment", iface, data); return { result: `Link impairment set for ${nodeName}:${interfaceName}`, error: null }; } catch (innerError) { - const errorMsg = `Error executing capture: ${innerError}`; + const errorMsg = `Error executing capture: ${formatErrorMessage(innerError)}`; log.error(`Error executing capture: ${JSON.stringify(innerError, null, 2)}`); return { result: null, error: errorMsg }; } diff --git a/src/reactTopoViewer/extension/services/SplitViewManager.ts b/src/reactTopoViewer/extension/services/SplitViewManager.ts index 78129f20c..9e5da0254 100644 --- a/src/reactTopoViewer/extension/services/SplitViewManager.ts +++ b/src/reactTopoViewer/extension/services/SplitViewManager.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode"; -import { log } from "./logger"; +import { formatErrorMessage, log } from "./logger"; /** * Simple sleep utility. @@ -49,7 +49,8 @@ export class SplitViewManager { panel.reveal(); } } catch (error) { - vscode.window.showErrorMessage(`Error opening template file: ${error}`); + const errorMessage = formatErrorMessage(error); + void vscode.window.showErrorMessage(`Error opening template file: ${errorMessage}`); } } @@ -74,8 +75,9 @@ export class SplitViewManager { return this.isSplitViewOpen; } catch (error) { - vscode.window.showErrorMessage(`Error toggling split view: ${error}`); - log.error(`Error toggling split view: ${error}`); + const errorMessage = formatErrorMessage(error); + void vscode.window.showErrorMessage(`Error toggling split view: ${errorMessage}`); + log.error(`Error toggling split view: ${errorMessage}`); return this.isSplitViewOpen; } } diff --git a/src/reactTopoViewer/extension/services/TreeUtils.ts b/src/reactTopoViewer/extension/services/TreeUtils.ts deleted file mode 100644 index 210d8184f..000000000 --- a/src/reactTopoViewer/extension/services/TreeUtils.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Tree utilities for finding containers and interfaces. - */ - -import { - type ClabLabTreeNode, - type ClabContainerTreeNode, - type ClabInterfaceTreeNode, - flattenContainers -} from "../../../treeView/common"; - -function labValuesFor( - labs: Record | undefined, - clabName?: string -): ClabLabTreeNode[] { - if (!labs) { - return []; - } - return clabName !== undefined && clabName.length > 0 - ? Object.values(labs).filter((lab) => lab.name === clabName) - : Object.values(labs); -} - -function normalizeName(value: string | undefined): string { - return (value ?? "").trim().toLowerCase(); -} - -function labelText(value: unknown): string { - if (typeof value === "string") { - return value; - } - if (typeof value === "object" && value !== null) { - const label = (value as { label?: unknown }).label; - if (typeof label === "string") { - return label; - } - } - return ""; -} - -function matchesInterfaceName(intfNode: ClabInterfaceTreeNode, intf: string): boolean { - const ifaceLabel = labelText(intfNode.label); - return intfNode.name === intf || intfNode.alias === intf || ifaceLabel === intf; -} - -export function sortContainersByInterfacePriority( - containers: ClabContainerTreeNode[] -): ClabContainerTreeNode[] { - return [...containers].sort((left, right) => { - const leftHasInterfaces = left.interfaces.length > 0 ? 0 : 1; - const rightHasInterfaces = right.interfaces.length > 0 ? 0 : 1; - if (leftHasInterfaces !== rightHasInterfaces) { - return leftHasInterfaces - rightHasInterfaces; - } - return left.name.localeCompare(right.name); - }); -} - -function containerMatchesNodeIdentifier( - container: ClabContainerTreeNode, - nodeName: string -): boolean { - const normalizedNode = normalizeName(nodeName); - if (!normalizedNode) { - return false; - } - - const candidates = [ - normalizeName(container.name), - normalizeName(container.name_short), - normalizeName(labelText(container.label)), - normalizeName(container.rootNodeName) - ]; - if (candidates.some((candidate) => candidate === normalizedNode)) { - return true; - } - - // Distributed SROS fallback: identify component containers by logical root name - // without relying on slot suffix conventions (-a/-1/etc). - if (container.kind !== "nokia_srsim") { - return false; - } - - const shortName = normalizeName(container.name_short); - const label = normalizeName(labelText(container.label)); - - return shortName.startsWith(`${normalizedNode}-`) || label.startsWith(`${normalizedNode}-`); -} - -function sortedMatchingContainers( - lab: ClabLabTreeNode, - nodeName: string, - includeDistributedSiblings: boolean = false -): ClabContainerTreeNode[] { - const allContainers = flattenContainers(lab.containers); - const matched = allContainers.filter((container) => - containerMatchesNodeIdentifier(container, nodeName) - ); - if (!includeDistributedSiblings || matched.length === 0) { - return sortContainersByInterfacePriority(matched); - } - - const siblingRoots = new Set( - matched - .filter((container) => container.kind === "nokia_srsim") - .map((container) => normalizeName(container.rootNodeName)) - .filter((root) => root.length > 0) - ); - if (siblingRoots.size === 0) { - return sortContainersByInterfacePriority(matched); - } - - const candidates: ClabContainerTreeNode[] = []; - const seen = new Set(); - for (const container of matched) { - if (!seen.has(container.name)) { - candidates.push(container); - seen.add(container.name); - } - } - for (const container of allContainers) { - if (container.kind !== "nokia_srsim") { - continue; - } - const root = normalizeName(container.rootNodeName); - if (!root || !siblingRoots.has(root) || seen.has(container.name)) { - continue; - } - candidates.push(container); - seen.add(container.name); - } - - return sortContainersByInterfacePriority(candidates); -} - -/** - * Finds a container node by name in the labs data. - */ -export function findContainerNode( - labs: Record | undefined, - name: string, - clabName?: string -): ClabContainerTreeNode | undefined { - const labValues = labValuesFor(labs, clabName); - for (const lab of labValues) { - const candidates = sortedMatchingContainers(lab, name); - if (candidates.length > 0) { - return candidates[0]; - } - } - return undefined; -} - -/** - * Finds an interface node by name in the labs data. - */ -export function findInterfaceNode( - labs: Record | undefined, - nodeName: string, - intf: string, - clabName?: string -): ClabInterfaceTreeNode | undefined { - const labValues = labValuesFor(labs, clabName); - - for (const lab of labValues) { - const candidates = sortedMatchingContainers(lab, nodeName, true); - for (const container of candidates) { - const match = container.interfaces.find((i: ClabInterfaceTreeNode) => - matchesInterfaceName(i, intf) - ); - if (match) { - return match; - } - } - } - - return undefined; -} diff --git a/src/reactTopoViewer/extension/services/logger.ts b/src/reactTopoViewer/extension/services/logger.ts index 61f2f1d64..d704e1627 100644 --- a/src/reactTopoViewer/extension/services/logger.ts +++ b/src/reactTopoViewer/extension/services/logger.ts @@ -5,12 +5,7 @@ import * as vscode from "vscode"; -import { - type LogLevel, - formatMessage, - getCallerFileLine, - createLogger -} from "../../shared/utilities/loggerUtils"; +type LogLevel = "info" | "debug" | "warn" | "error"; let outputChannel: vscode.LogOutputChannel | undefined; @@ -54,11 +49,85 @@ function toLogLevel(level: string): LogLevel { } } +function objectTag(value: unknown): string { + return Object.prototype.toString.call(value); +} + +export function formatUnknownForLog(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (value instanceof Error) { + return value.stack ?? value.message; + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return `${value}`; + } + if (typeof value === "symbol") { + return value.toString(); + } + if (typeof value === "function") { + return `[function ${value.name || "anonymous"}]`; + } + if (value === undefined) { + return "undefined"; + } + if (value === null) { + return "null"; + } + try { + return JSON.stringify(value); + } catch { + // Fall through to the object tag fallback. + } + + return objectTag(value); +} + +export function formatErrorMessage(error: unknown): string { + const formatted = formatUnknownForLog(error); + return formatted.length > 0 ? formatted : "Unknown error"; +} + +function getCallerFileLine(skipFrames: number = 0): string { + const stack = new Error().stack; + if (stack == null || stack.length === 0) { + return ""; + } + + const lines = stack.split("\n").slice(1); + const targetLine = lines[3 + skipFrames] ?? ""; + const match = targetLine.match(/(?:\/|\\)([^/\\]+:\d+:\d+)/); + return match?.[1] ?? ""; +} + +function createLogger(logFn: (level: LogLevel, message: unknown) => void): { + info(msg: unknown): void; + debug(msg: unknown): void; + warn(msg: unknown): void; + error(msg: unknown): void; +} { + return { + info: (message: unknown) => { + logFn("info", message); + }, + debug: (message: unknown) => { + logFn("debug", message); + }, + warn: (message: unknown) => { + logFn("warn", message); + }, + error: (message: unknown) => { + logFn("error", message); + } + }; +} + /** * Core logging function that writes to VS Code output channel. */ function logMessage(level: LogLevel, message: unknown): void { - const formatted = formatMessage(message); + const formatted = formatUnknownForLog(message); const fileLine = getCallerFileLine(1); const text = fileLine ? `${fileLine} - ${formatted}` : formatted; writeToChannel(level, text); diff --git a/src/reactTopoViewer/extension/services/runtimeContainers.ts b/src/reactTopoViewer/extension/services/runtimeContainers.ts new file mode 100644 index 000000000..af8e81507 --- /dev/null +++ b/src/reactTopoViewer/extension/services/runtimeContainers.ts @@ -0,0 +1,106 @@ +import type { ClabLabTreeNode } from "../../../treeView/common"; +import { flattenContainers } from "../../../treeView/common"; +import type { HostRuntimeContainer, HostRuntimeInterface } from "@srl-labs/clab-ui/host"; + +function treeItemLabelText(label: unknown): string | undefined { + if (typeof label === "string") { + return label; + } + if (label !== null && typeof label === "object" && "label" in label) { + const value = (label as { label?: unknown }).label; + return typeof value === "string" ? value : undefined; + } + return undefined; +} + +function toRuntimeInterface(iface: { + label?: unknown; + name: string; + alias: string; + mac: string; + mtu: number; + state: string; + type: string; + ifIndex?: number; + stats?: { + rxBps?: number; + txBps?: number; + rxPps?: number; + txPps?: number; + rxBytes?: number; + txBytes?: number; + rxPackets?: number; + txPackets?: number; + statsIntervalSeconds?: number; + }; + netemState?: { + delay?: string; + jitter?: string; + loss?: string; + rate?: string; + corruption?: string; + }; +}): HostRuntimeInterface { + return { + name: iface.name, + alias: iface.alias, + label: treeItemLabelText(iface.label), + mac: iface.mac, + mtu: iface.mtu, + state: iface.state, + type: iface.type, + ifIndex: iface.ifIndex, + stats: iface.stats + ? { + rxBps: iface.stats.rxBps, + txBps: iface.stats.txBps, + rxPps: iface.stats.rxPps, + txPps: iface.stats.txPps, + rxBytes: iface.stats.rxBytes, + txBytes: iface.stats.txBytes, + rxPackets: iface.stats.rxPackets, + txPackets: iface.stats.txPackets, + statsIntervalSeconds: iface.stats.statsIntervalSeconds + } + : undefined, + netemState: iface.netemState + ? { + delay: iface.netemState.delay, + jitter: iface.netemState.jitter, + loss: iface.netemState.loss, + rate: iface.netemState.rate, + corruption: iface.netemState.corruption + } + : undefined + }; +} + +export function labsToRuntimeContainers( + labs: Record | undefined +): HostRuntimeContainer[] { + if (!labs) { + return []; + } + + const containers: HostRuntimeContainer[] = []; + for (const lab of Object.values(labs)) { + const labName = lab.name ?? ""; + for (const container of flattenContainers(lab.containers)) { + containers.push({ + name: container.name, + nodeName: container.rootNodeName ?? container.name_short, + labName, + state: container.state, + kind: container.kind, + image: container.image, + ipv4Address: container.IPv4Address, + ipv6Address: container.IPv6Address, + interfaces: container.interfaces + .map((iface) => toRuntimeInterface(iface)) + .sort((left, right) => left.name.localeCompare(right.name)) + }); + } + } + + return containers.sort((left, right) => left.name.localeCompare(right.name)); +} diff --git a/src/reactTopoViewer/extension/services/schema.ts b/src/reactTopoViewer/extension/services/schema.ts index 4228345dd..a54def670 100644 --- a/src/reactTopoViewer/extension/services/schema.ts +++ b/src/reactTopoViewer/extension/services/schema.ts @@ -1,31 +1,13 @@ /** - * Schema utilities (VS Code extension host) - * - * VS Code-specific schema loading and configuration helpers. - * Pure schema parsing is implemented in `src/reactTopoViewer/shared/schema`. + * VS Code-specific configuration helpers for clab-ui bootstrap data. */ import * as vscode from "vscode"; -import { nodeFsAdapter } from "../../shared/io"; -import type { CustomNodeTemplate, SchemaData } from "../../shared/schema"; -import { parseSchemaData } from "../../shared/schema"; - -import { log } from "./logger"; +import type { CustomNodeTemplate } from "@srl-labs/clab-ui/session"; const CONFIG_SECTION = "containerlab.editor"; -function toRecord(value: unknown): Record | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return undefined; - } - const record: Record = {}; - for (const [key, entryValue] of Object.entries(value)) { - record[key] = entryValue; - } - return record; -} - /** * Get custom nodes from VS Code configuration. */ @@ -33,25 +15,3 @@ export function getCustomNodesFromConfig(): CustomNodeTemplate[] { const config = vscode.workspace.getConfiguration(CONFIG_SECTION); return config.get("customNodes", []); } - -/** - * Load schema data from the extension's schema file. - */ -export async function loadSchemaData(extensionUri: vscode.Uri): Promise { - try { - const schemaUri = vscode.Uri.joinPath(extensionUri, "schema", "clab.schema.json"); - const schemaContent = await nodeFsAdapter.readFile(schemaUri.fsPath); - const schema = toRecord(JSON.parse(schemaContent) as unknown); - if (schema === undefined) { - throw new Error("Invalid schema format"); - } - return parseSchemaData(schema); - } catch (err) { - log.error(`Error loading schema data: ${err}`); - return { - kinds: [], - typesByKind: {}, - srosComponentTypes: { sfm: [], cpm: [], card: [], mda: [], xiom: [], xiomMda: [] } - }; - } -} diff --git a/src/reactTopoViewer/shared/io/NodeFsAdapter.ts b/src/reactTopoViewer/extension/shared/io.ts similarity index 77% rename from src/reactTopoViewer/shared/io/NodeFsAdapter.ts rename to src/reactTopoViewer/extension/shared/io.ts index aeb9e0745..572f88803 100644 --- a/src/reactTopoViewer/shared/io/NodeFsAdapter.ts +++ b/src/reactTopoViewer/extension/shared/io.ts @@ -1,22 +1,12 @@ -/** - * NodeFsAdapter - Node.js file system adapter - * - * Implements FileSystemAdapter using Node.js fs.promises. - * Used by the VS Code extension for direct file operations. - */ - import * as fs from "fs"; import * as path from "path"; -import type { FileSystemAdapter } from "./types"; +import type { FileSystemAdapter } from "@srl-labs/clab-ui/session"; function isErrnoException(value: unknown): value is NodeJS.ErrnoException { return value instanceof Error && "code" in value; } -/** - * File system adapter using Node.js fs.promises - */ export class NodeFsAdapter implements FileSystemAdapter { async readFile(filePath: string): Promise { return fs.promises.readFile(filePath, "utf8"); @@ -30,7 +20,6 @@ export class NodeFsAdapter implements FileSystemAdapter { try { await fs.promises.unlink(filePath); } catch (err) { - // Ignore ENOENT (file doesn't exist) if (!isErrnoException(err) || err.code !== "ENOENT") { throw err; } @@ -63,5 +52,4 @@ export class NodeFsAdapter implements FileSystemAdapter { } } -/** Singleton instance for convenience */ export const nodeFsAdapter = new NodeFsAdapter(); diff --git a/src/reactTopoViewer/shared/annotations/index.ts b/src/reactTopoViewer/shared/annotations/index.ts deleted file mode 100644 index 7fa72a0b5..000000000 --- a/src/reactTopoViewer/shared/annotations/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Shared Annotations Module - * - * This module provides VS Code-free annotation parsing and merging utilities - * that can be used by both the production extension and the dev server. - */ - -// Types -export type { - FreeTextAnnotation, - FreeShapeAnnotation, - GroupStyleAnnotation, - NetworkNodeAnnotation, - NodeAnnotation, - AliasEndpointAnnotation, - TopologyAnnotations -} from "./types"; - -// Utilities -export { createEmptyAnnotations } from "./types"; diff --git a/src/reactTopoViewer/shared/annotations/types.ts b/src/reactTopoViewer/shared/annotations/types.ts deleted file mode 100644 index 505c3c9f3..000000000 --- a/src/reactTopoViewer/shared/annotations/types.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Annotation type definitions for the shared annotations module. - * Types are available from shared/types/topology directly. - */ - -import type { - FreeTextAnnotation as _FreeTextAnnotation, - FreeShapeAnnotation as _FreeShapeAnnotation, - TrafficRateAnnotation as _TrafficRateAnnotation, - GroupStyleAnnotation as _GroupStyleAnnotation, - NetworkNodeAnnotation as _NetworkNodeAnnotation, - NodeAnnotation as _NodeAnnotation, - EdgeAnnotation as _EdgeAnnotation, - AliasEndpointAnnotation as _AliasEndpointAnnotation, - TopologyAnnotations as _TopologyAnnotations -} from "../types/topology"; - -// Re-export types -export type FreeTextAnnotation = _FreeTextAnnotation; -export type FreeShapeAnnotation = _FreeShapeAnnotation; -export type TrafficRateAnnotation = _TrafficRateAnnotation; -export type GroupStyleAnnotation = _GroupStyleAnnotation; -export type NetworkNodeAnnotation = _NetworkNodeAnnotation; -export type NodeAnnotation = _NodeAnnotation; -export type EdgeAnnotation = _EdgeAnnotation; -export type AliasEndpointAnnotation = _AliasEndpointAnnotation; -export type TopologyAnnotations = _TopologyAnnotations; - -/** - * Default empty annotations object. - */ -export function createEmptyAnnotations(): TopologyAnnotations { - return { - freeTextAnnotations: [], - freeShapeAnnotations: [], - trafficRateAnnotations: [], - groupStyleAnnotations: [], - networkNodeAnnotations: [], - nodeAnnotations: [], - edgeAnnotations: [], - aliasEndpointAnnotations: [], - viewerSettings: {} - }; -} diff --git a/src/reactTopoViewer/shared/constants/interfacePatterns.ts b/src/reactTopoViewer/shared/constants/interfacePatterns.ts deleted file mode 100644 index 8b9ca195a..000000000 --- a/src/reactTopoViewer/shared/constants/interfacePatterns.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const DEFAULT_INTERFACE_PATTERNS: Record = { - nokia_srlinux: "e1-{n}", - nokia_srsim: "1/1/c{n}/1", - nokia_sros: "1/1/{n}", - cisco_xrd: "Gi0-0-0-{n}", - cisco_xrv: "Gi0/0/0/{n}", - cisco_xrv9k: "Gi0/0/0/{n}", - cisco_csr1000v: "Gi{n}", - cisco_c8000v: "Gi{n}", - cisco_cat9kv: "Gi1/0/{n}", - cisco_iol: "e0/{n}" -}; diff --git a/src/reactTopoViewer/shared/host/TopologyHostCore.ts b/src/reactTopoViewer/shared/host/TopologyHostCore.ts deleted file mode 100644 index 08dc054bc..000000000 --- a/src/reactTopoViewer/shared/host/TopologyHostCore.ts +++ /dev/null @@ -1,1370 +0,0 @@ -// TopologyHostCore — host-side authoritative topology model. -// Runs in Node (VS Code extension host / dev server). -// Owns YAML + annotations persistence, revisioning, and undo/redo history. - -import * as YAML from "yaml"; - -import type { - ClabTopology, - DeploymentState, - FreeTextAnnotation, - GroupStyleAnnotation, - TopologyAnnotations, - NodeAnnotation -} from "../types/topology"; -import type { LabSettings } from "../types/labSettings"; -import type { - TopologyHostCommand, - TopologyHostResponseMessage, - TopologySnapshot -} from "../types/messages"; -import { TOPOLOGY_HOST_PROTOCOL_VERSION } from "../types/messages"; -import type { TopologyHost } from "../types/topologyHost"; -import type { TopologyData } from "../types/graph"; -import type { ContainerDataProvider, ParserLogger } from "../parsing/types"; -import { TopologyParser } from "../parsing/TopologyParser"; -import { applyInterfacePatternMigrations } from "../utilities"; -import type { FileSystemAdapter, IOLogger } from "../io/types"; -import { AnnotationsIO, TopologyIO, TransactionalFileSystemAdapter } from "../io"; -import { createEmptyAnnotations } from "../annotations/types"; - -interface TopologyHostCoreOptions { - fs: FileSystemAdapter; - yamlFilePath: string; - mode: "edit" | "view"; - deploymentState: DeploymentState; - containerDataProvider?: ContainerDataProvider; - setInternalUpdate?: (updating: boolean) => void; - logger?: IOLogger; - maxHistory?: number; -} - -interface HistoryEntry { - yamlContent: string; - annotationsContent: string | null; -} - -const DEFAULT_HISTORY_LIMIT = 50; -const TOPOLOGY_HOST_ACK = "topology-host:ack"; -const RENAME_HISTORY_MERGE_WINDOW_MS = 800; -const LEGACY_GROUP_PADDING = 40; -const LEGACY_NODE_WIDTH = 100; -const LEGACY_NODE_HEIGHT = 100; -const LEGACY_DEFAULT_GROUP_WIDTH = 300; -const LEGACY_DEFAULT_GROUP_HEIGHT = 200; -const LEGACY_DEFAULT_MEDIA_TEXT_WIDTH = 120; -const LEGACY_MEDIA_TEXT_HEIGHT_RATIO = 0.62; -const LEGACY_MIN_MEDIA_TEXT_HEIGHT = 48; - -/** Migration entry for graph label data (position, icon, group info) */ -interface GraphLabelMigration { - nodeId: string; - position?: { x: number; y: number }; - icon?: string; - group?: string; - level?: string; - groupLabelPos?: string; - geoCoordinates?: { lat: number; lng: number }; -} - -const noopLogger: IOLogger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} -}; - -export class TopologyHostCore implements TopologyHost { - private yamlFilePath: string; - private mode: "edit" | "view"; - private deploymentState: DeploymentState; - private containerDataProvider?: ContainerDataProvider; - private setInternalUpdate?: (updating: boolean) => void; - private logger: IOLogger; - private parserLogger: ParserLogger; - - private baseFs: FileSystemAdapter; - private transactionalFs: TransactionalFileSystemAdapter; - private annotationsIO: AnnotationsIO; - private topologyIO: TopologyIO; - - private revision = 1; - private snapshot: TopologySnapshot | null = null; - private past: HistoryEntry[] = []; - private future: HistoryEntry[] = []; - private historyLimit: number; - private historyMergeUntil: number | null = null; - - public currentClabTopology: ClabTopology | undefined; - - constructor(options: TopologyHostCoreOptions) { - this.baseFs = options.fs; - this.yamlFilePath = options.yamlFilePath; - this.mode = options.mode; - this.deploymentState = options.deploymentState; - this.containerDataProvider = options.containerDataProvider; - this.setInternalUpdate = options.setInternalUpdate; - this.logger = options.logger ?? noopLogger; - this.historyLimit = options.maxHistory ?? DEFAULT_HISTORY_LIMIT; - - this.parserLogger = { - info: (msg) => this.logger.info(msg), - warn: (msg) => this.logger.warn(msg), - debug: (msg) => this.logger.debug(msg), - error: (msg) => this.logger.error(msg) - }; - - this.transactionalFs = new TransactionalFileSystemAdapter(this.baseFs); - this.annotationsIO = new AnnotationsIO({ fs: this.transactionalFs, logger: this.logger }); - this.topologyIO = new TopologyIO({ - fs: this.transactionalFs, - annotationsIO: this.annotationsIO, - setInternalUpdate: this.setInternalUpdate, - logger: this.logger - }); - } - - updateContext( - context: Partial< - Pick - > - ): void { - const modeChanged = context.mode !== undefined && context.mode !== this.mode; - const deploymentChanged = - context.deploymentState !== undefined && context.deploymentState !== this.deploymentState; - const containerChanged = - context.containerDataProvider !== undefined && - context.containerDataProvider !== this.containerDataProvider; - - if (context.mode) this.mode = context.mode; - if (context.deploymentState) this.deploymentState = context.deploymentState; - if (context.containerDataProvider !== undefined) { - this.containerDataProvider = context.containerDataProvider; - } - - if (modeChanged || deploymentChanged || containerChanged) { - this.snapshot = null; - } - } - - async getSnapshot(): Promise { - if (this.snapshot) { - return this.snapshot; - } - this.snapshot = await this.buildSnapshot(); - return this.snapshot; - } - - async applyCommand( - command: TopologyHostCommand, - baseRevision: number - ): Promise { - const commandName = (command as { command?: string }).command ?? "unknown"; - if (baseRevision !== this.revision) { - this.logger.warn( - `[TopologyHost] Rejecting ${commandName}: stale baseRevision ${baseRevision} (current ${this.revision})` - ); - const snapshot = await this.getSnapshot(); - return { - type: "topology-host:reject", - protocolVersion: TOPOLOGY_HOST_PROTOCOL_VERSION, - requestId: "", - revision: this.revision, - snapshot, - reason: "stale" - }; - } - - if (command.command === "undo") { - this.historyMergeUntil = null; - return this.handleUndoRedo("undo"); - } - if (command.command === "redo") { - this.historyMergeUntil = null; - return this.handleUndoRedo("redo"); - } - - const beforeState = await this.captureHistoryEntry(); - - try { - this.logger.debug(`[TopologyHost] Applying ${commandName} @ revision ${this.revision}`); - this.setInternalUpdate?.(true); - this.transactionalFs.beginTransaction(); - await this.ensureTopologyInitialized(); - await this.executeCommand(command); - await this.transactionalFs.commitTransaction(); - this.annotationsIO.clearCache(); - } catch (err) { - this.transactionalFs.rollbackTransaction(); - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`[TopologyHost] Command ${commandName} failed: ${message}`); - this.historyMergeUntil = null; - return { - type: "topology-host:error", - protocolVersion: TOPOLOGY_HOST_PROTOCOL_VERSION, - requestId: "", - error: message - }; - } finally { - this.setInternalUpdate?.(false); - } - - const now = Date.now(); - const mergeHistory = this.historyMergeUntil !== null && now <= this.historyMergeUntil; - const skipHistory = shouldSkipHistory(command); - - if (!mergeHistory && !skipHistory) { - this.pushHistory(beforeState); - } - - if (isRenameEditCommand(command as TopologyHostCommand)) { - this.historyMergeUntil = now + RENAME_HISTORY_MERGE_WINDOW_MS; - } else if (this.historyMergeUntil !== null && now > this.historyMergeUntil) { - this.historyMergeUntil = null; - } - this.future = []; - this.revision += 1; - this.snapshot = await this.buildSnapshot(); - - return { - type: TOPOLOGY_HOST_ACK, - protocolVersion: TOPOLOGY_HOST_PROTOCOL_VERSION, - requestId: "", - revision: this.revision, - snapshot: this.snapshot - }; - } - - async onExternalChange(): Promise { - this.past = []; - this.future = []; - this.revision += 1; - try { - this.setInternalUpdate?.(true); - await this.reloadFromDisk(); - this.snapshot = await this.buildSnapshot(); - return this.snapshot; - } finally { - this.setInternalUpdate?.(false); - } - } - - dispose(): void { - this.past = []; - this.future = []; - this.snapshot = null; - this.annotationsIO.clearCache(); - } - - // --------------------------------------------------------------------------- - // Command execution - // --------------------------------------------------------------------------- - - private async executeCommand(command: TopologyHostCommand): Promise { - if (isNodeHostCommand(command)) { - await this.handleNodeCommand(command); - return; - } - if (isLinkHostCommand(command)) { - await this.handleLinkCommand(command); - return; - } - if (isSourceContentHostCommand(command)) { - await this.handleSourceContentCommand(command); - return; - } - if (isSaveHostCommand(command)) { - await this.handleSaveCommand(command); - return; - } - if (isAnnotationHostCommand(command)) { - await this.handleAnnotationSettingsCommand(command); - return; - } - if (isMembershipHostCommand(command)) { - await this.handleNodeGroupMemberships(command); - return; - } - if (command.command === "batch") { - await this.handleBatchCommand(command); - return; - } - if (command.command === "setLabSettings") { - await this.applyLabSettings(command.payload); - return; - } - throw new Error(`${command.command} handled before executeCommand`); - } - - private async handleNodeCommand( - command: Extract - ): Promise { - switch (command.command) { - case "addNode": - await this.topologyIO.addNode(command.payload); - break; - case "editNode": - await this.topologyIO.editNode(command.payload); - break; - case "deleteNode": - await this.topologyIO.deleteNode(command.payload.id); - break; - } - } - - private async handleLinkCommand( - command: Extract - ): Promise { - switch (command.command) { - case "addLink": - await this.topologyIO.addLink(command.payload); - break; - case "editLink": - await this.topologyIO.editLink(command.payload); - break; - case "deleteLink": - await this.topologyIO.deleteLink(command.payload); - break; - } - } - - private async handleSaveCommand( - command: Extract< - TopologyHostCommand, - { command: "savePositions" | "savePositionsAndAnnotations" } - > - ): Promise { - if (command.command === "savePositions") { - await this.topologyIO.savePositions(command.payload); - } else { - await this.topologyIO.savePositions(command.payload.positions); - if (command.payload.annotations) { - await this.mergeAnnotations(command.payload.annotations); - } - } - } - - private async handleSourceContentCommand( - command: Extract - ): Promise { - if (command.command === "setYamlContent") { - const content = command.payload.content; - const doc = YAML.parseDocument(content); - if (doc.errors.length > 0) { - const details = doc.errors.map((e) => e.message).join("\n"); - throw new Error(details || "Invalid YAML"); - } - await this.transactionalFs.writeFile(this.yamlFilePath, content); - await this.reloadFromDisk(); - return; - } - - const raw = command.payload.content; - const content = raw.trim().length === 0 ? "{}\n" : raw; - try { - JSON.parse(content); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid annotations JSON: ${msg}`); - } - const annotationsPath = this.annotationsIO.getAnnotationsFilePath(this.yamlFilePath); - await this.transactionalFs.writeFile(annotationsPath, content); - await this.reloadFromDisk(); - } - - private async handleBatchCommand( - command: Extract - ): Promise { - const commands = command.payload.commands; - this.topologyIO.beginBatch(); - try { - for (const entry of commands) { - if (entry.command === "batch") { - throw new Error("Nested batch commands are not supported"); - } - if (entry.command === "undo" || entry.command === "redo") { - throw new Error("undo/redo not allowed inside batch"); - } - await this.executeCommand(entry); - } - } finally { - await this.topologyIO.endBatch(); - } - } - - private async handleAnnotationSettingsCommand( - command: Extract< - TopologyHostCommand, - { - command: - | "setAnnotations" - | "setAnnotationsWithMemberships" - | "setEdgeAnnotations" - | "setViewerSettings"; - } - > - ): Promise { - switch (command.command) { - case "setAnnotations": - await this.mergeAnnotations(command.payload); - break; - case "setAnnotationsWithMemberships": { - const { annotations, memberships } = command.payload; - await this.annotationsIO.modifyAnnotations(this.yamlFilePath, (current) => { - const merged = mergeAnnotationsPayload(current, annotations); - return applyNodeGroupMembershipsToAnnotations(merged, memberships); - }); - break; - } - case "setEdgeAnnotations": - await this.annotationsIO.modifyAnnotations(this.yamlFilePath, (current) => ({ - ...current, - edgeAnnotations: command.payload - })); - break; - case "setViewerSettings": - await this.annotationsIO.modifyAnnotations(this.yamlFilePath, (current) => ({ - ...current, - viewerSettings: { - ...(current.viewerSettings ?? {}), - ...command.payload - } - })); - break; - } - } - - private async mergeAnnotations(annotations: Partial): Promise { - await this.annotationsIO.modifyAnnotations(this.yamlFilePath, (current) => { - return mergeAnnotationsPayload(current, annotations); - }); - } - - private async handleNodeGroupMemberships( - command: Extract< - TopologyHostCommand, - { command: "setNodeGroupMembership" | "setNodeGroupMemberships" } - > - ): Promise { - if (command.command === "setNodeGroupMembership") { - await this.applyNodeGroupMembership(command.payload.nodeId, command.payload.groupId); - } else { - await this.applyNodeGroupMemberships(command.payload); - } - } - - private async applyLabSettings(settings: LabSettings): Promise { - await this.ensureTopologyInitialized(); - const doc = this.topologyIO.getDocument(); - if (!doc) { - throw new Error("Topology document not initialized"); - } - - const rootContents = doc.contents; - if (!rootContents || !YAML.isMap(rootContents)) { - throw new Error("YAML document root is not a map"); - } - const rootMap = rootContents; - - if (settings.name !== undefined) { - setKey(rootMap, "name", doc.createNode(settings.name)); - } - - if (settings.prefix !== undefined) { - if (settings.prefix === null) { - deleteKey(rootMap, "prefix"); - } else { - setKeyAfter(rootMap, "prefix", doc.createNode(settings.prefix), "name"); - } - } - - if (settings.mgmt !== undefined) { - if (settings.mgmt === null) { - deleteKey(rootMap, "mgmt"); - } else { - const mgmtMap = doc.createNode(settings.mgmt) as YAML.YAMLMap; - const hasPrefixKey = rootMap.items.some( - (pair) => YAML.isScalar(pair.key) && pair.key.value === "prefix" - ); - const afterKey = hasPrefixKey ? "prefix" : "name"; - setKeyAfter(rootMap, "mgmt", mgmtMap, afterKey); - } - } - - await this.topologyIO.save(); - } - - private async applyNodeGroupMembership(nodeId: string, groupId: string | null): Promise { - await this.annotationsIO.modifyAnnotations(this.yamlFilePath, (current) => { - const nodeAnnotations = current.nodeAnnotations ? [...current.nodeAnnotations] : []; - const existingIndex = nodeAnnotations.findIndex((n) => n.id === nodeId); - - if (existingIndex >= 0) { - const existing = nodeAnnotations[existingIndex]; - if (groupId !== null && groupId !== "") { - nodeAnnotations[existingIndex] = { ...existing, groupId }; - } else { - const { group: _group, groupId: _groupId, ...rest } = existing; - nodeAnnotations[existingIndex] = rest; - } - } else if (groupId !== null && groupId !== "") { - nodeAnnotations.push({ id: nodeId, groupId }); - } - - return { ...current, nodeAnnotations }; - }); - } - - private async applyNodeGroupMemberships( - memberships: Array<{ nodeId: string; groupId: string | null }> - ): Promise { - await this.annotationsIO.modifyAnnotations(this.yamlFilePath, (current) => - applyNodeGroupMembershipsToAnnotations(current, memberships) - ); - } - - // --------------------------------------------------------------------------- - // Undo/redo - // --------------------------------------------------------------------------- - - private async handleUndoRedo(direction: "undo" | "redo"): Promise { - const stack = direction === "undo" ? this.past : this.future; - if (stack.length === 0) { - const snapshot = await this.getSnapshot(); - return { - type: TOPOLOGY_HOST_ACK, - protocolVersion: TOPOLOGY_HOST_PROTOCOL_VERSION, - requestId: "", - revision: this.revision, - snapshot - }; - } - - const current = await this.captureHistoryEntry(); - const entry = stack.pop()!; - - if (direction === "undo") { - this.future.push(current); - } else { - this.past.push(current); - } - - try { - this.setInternalUpdate?.(true); - await this.restoreHistoryEntry(entry); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - type: "topology-host:error", - protocolVersion: TOPOLOGY_HOST_PROTOCOL_VERSION, - requestId: "", - error: message - }; - } finally { - this.setInternalUpdate?.(false); - } - - this.revision += 1; - this.snapshot = await this.buildSnapshot(); - return { - type: TOPOLOGY_HOST_ACK, - protocolVersion: TOPOLOGY_HOST_PROTOCOL_VERSION, - requestId: "", - revision: this.revision, - snapshot: this.snapshot - }; - } - - // --------------------------------------------------------------------------- - // Snapshot building - // --------------------------------------------------------------------------- - - private async readAnnotationsContent(): Promise { - const annotationsPath = this.annotationsIO.getAnnotationsFilePath(this.yamlFilePath); - try { - return await this.baseFs.readFile(annotationsPath); - } catch (err) { - if (isFileNotFoundError(err)) { - return null; - } - throw new Error( - `Failed to read annotations file "${annotationsPath}": ${errorToMessage(err)}` - ); - } - } - - private async buildSnapshot(): Promise { - const yamlContent = await this.baseFs.readFile(this.yamlFilePath); - const yamlDoc = YAML.parseDocument(yamlContent); - const parsed = normalizeParsedTopologyValue(yamlDoc.toJS()); - this.currentClabTopology = parsed; - - const annotationsContent = await this.readAnnotationsContent(); - - let annotations = await this.annotationsIO.loadAnnotations(this.yamlFilePath, true); - const annotationsUpdated = await this.reconcileAnnotationsForRenamedNodes(parsed); - if (annotationsUpdated) { - annotations = await this.annotationsIO.loadAnnotations(this.yamlFilePath, true); - } - const legacyMigration = migrateLegacyAnnotations(annotations); - if (legacyMigration.modified) { - await this.annotationsIO.saveAnnotations(this.yamlFilePath, legacyMigration.annotations); - annotations = legacyMigration.annotations; - this.logger.info("[TopologyHost] Migrated legacy annotations to current format"); - } - - const parseResult = this.parseTopology(yamlContent, annotations, parsed); - - const migrationsApplied = await this.applyMigrations( - annotations, - parseResult.graphLabelMigrations, - parseResult.pendingMigrations - ); - if (migrationsApplied) { - annotations = await this.annotationsIO.loadAnnotations(this.yamlFilePath, true); - } - - const finalParseResult = migrationsApplied - ? this.parseTopology(yamlContent, annotations, parsed) - : parseResult; - const topology = finalParseResult.topology; - const labName = finalParseResult.labName; - - const normalizedAnnotations = normalizeAnnotations(annotations); - const labSettings = extractLabSettings(yamlDoc); - - const yamlFileName = this.baseFs.basename(this.yamlFilePath); - const annotationsFileName = this.baseFs.basename( - this.annotationsIO.getAnnotationsFilePath(this.yamlFilePath) - ); - - return { - revision: this.revision, - nodes: topology.nodes, - edges: topology.edges, - annotations: normalizedAnnotations, - yamlFileName, - annotationsFileName, - yamlContent, - annotationsContent: annotationsContent ?? JSON.stringify(createEmptyAnnotations(), null, 2), - labName: labName ?? "", - mode: this.mode, - deploymentState: this.deploymentState, - labSettings: Object.keys(labSettings).length > 0 ? labSettings : undefined, - canUndo: this.past.length > 0, - canRedo: this.future.length > 0 - }; - } - - private parseTopology( - yamlContent: string, - annotations: TopologyAnnotations, - parsed?: ClabTopology - ): { - topology: TopologyData; - labName?: string; - pendingMigrations: Array<{ nodeId: string; interfacePattern: string }>; - graphLabelMigrations: GraphLabelMigration[]; - } { - const parserLabName = this.getParserLabName(parsed); - if (this.mode === "view") { - return parsed - ? TopologyParser.parseToReactFlowFromParsed(parsed, { - annotations, - labName: parserLabName, - containerDataProvider: this.containerDataProvider, - logger: this.parserLogger - }) - : TopologyParser.parseToReactFlow(yamlContent, { - annotations, - labName: parserLabName, - containerDataProvider: this.containerDataProvider, - logger: this.parserLogger - }); - } - return parsed - ? TopologyParser.parseToReactFlowFromParsed(parsed, { - annotations, - labName: parserLabName - }) - : TopologyParser.parseToReactFlow(yamlContent, { - annotations, - labName: parserLabName - }); - } - - private getParserLabName(parsed?: ClabTopology): string { - const yamlLabName = parsed?.name; - if (typeof yamlLabName === "string" && yamlLabName.trim().length > 0) { - return yamlLabName.trim(); - } - return this.getFallbackLabName(); - } - - private getFallbackLabName(): string { - const yamlFileName = this.baseFs.basename(this.yamlFilePath); - const clabName = yamlFileName.replace(/\.clab\.ya?ml$/i, ""); - if (clabName && clabName !== yamlFileName) { - return clabName; - } - - const yamlName = yamlFileName.replace(/\.ya?ml$/i, ""); - if (yamlName && yamlName !== yamlFileName) { - return yamlName; - } - - return "topology"; - } - - private async applyMigrations( - annotations: TopologyAnnotations, - graphLabelMigrations: GraphLabelMigration[], - pendingMigrations: Array<{ nodeId: string; interfacePattern: string }> - ): Promise { - let modified = false; - - if (graphLabelMigrations.length > 0) { - const updated = persistGraphLabelMigrations(annotations, graphLabelMigrations); - await this.annotationsIO.saveAnnotations(this.yamlFilePath, updated); - modified = true; - } - - if (pendingMigrations.length > 0) { - const result = applyInterfacePatternMigrations(annotations, pendingMigrations); - if (result.modified) { - await this.annotationsIO.saveAnnotations(this.yamlFilePath, result.annotations); - modified = true; - } - } - - return modified; - } - - private async reconcileAnnotationsForRenamedNodes( - parsedTopo: ClabTopology | undefined - ): Promise { - if (!this.yamlFilePath || !parsedTopo?.topology?.nodes) { - return false; - } - - const yamlNodeIds = new Set(Object.keys(parsedTopo.topology.nodes)); - try { - const annotations = await this.annotationsIO.loadAnnotations(this.yamlFilePath, true); - const nodeAnnotations = annotations.nodeAnnotations ?? []; - const missingIds = [...yamlNodeIds].filter((id) => !nodeAnnotations.some((n) => n.id === id)); - const orphanAnnotations = nodeAnnotations.filter((n) => !yamlNodeIds.has(n.id)); - - if (missingIds.length === 1 && orphanAnnotations.length > 0) { - const newId = missingIds[0]; - const newPrefix = getIdPrefix(newId); - const prefixMatches = orphanAnnotations.filter((n) => getIdPrefix(n.id) === newPrefix); - const candidate = prefixMatches[0] ?? orphanAnnotations[0]; - const oldId = candidate.id; - candidate.id = newId; - await this.annotationsIO.saveAnnotations(this.yamlFilePath, annotations); - this.logger.info(`Migrated annotation id from ${oldId} to ${newId} after YAML rename`); - return true; - } - } catch (err) { - this.logger.warn(`Failed to reconcile annotations on rename: ${err}`); - } - - return false; - } - - // --------------------------------------------------------------------------- - // History + reload helpers - // --------------------------------------------------------------------------- - - private pushHistory(entry: HistoryEntry): void { - this.past.push(entry); - if (this.past.length > this.historyLimit) { - this.past.shift(); - } - } - - private async captureHistoryEntry(): Promise { - const yamlContent = await this.baseFs.readFile(this.yamlFilePath); - const annotationsContent = await this.readAnnotationsContent(); - return { yamlContent, annotationsContent }; - } - - private async restoreHistoryEntry(entry: HistoryEntry): Promise { - this.transactionalFs.beginTransaction(); - await this.transactionalFs.writeFile(this.yamlFilePath, entry.yamlContent); - - const annotationsPath = this.annotationsIO.getAnnotationsFilePath(this.yamlFilePath); - if (entry.annotationsContent === null) { - await this.transactionalFs.unlink(annotationsPath); - } else { - await this.transactionalFs.writeFile(annotationsPath, entry.annotationsContent); - } - - await this.transactionalFs.commitTransaction(); - await this.reloadFromDisk(); - } - - private async reloadFromDisk(): Promise { - this.annotationsIO.clearCache(); - await this.topologyIO.initializeFromFile(this.yamlFilePath); - } - - private async ensureTopologyInitialized(): Promise { - if (!this.topologyIO.isInitialized()) { - const result = await this.topologyIO.initializeFromFile(this.yamlFilePath); - if (!result.success) { - throw new Error(result.error ?? "Failed to initialize topology"); - } - } - } -} - -// --------------------------------------------------------------------------- -// Helper utilities (shared with lab settings + migrations) -// --------------------------------------------------------------------------- - -function isNodeHostCommand( - command: TopologyHostCommand -): command is Extract { - return ( - command.command === "addNode" || - command.command === "editNode" || - command.command === "deleteNode" - ); -} - -function isLinkHostCommand( - command: TopologyHostCommand -): command is Extract { - return ( - command.command === "addLink" || - command.command === "editLink" || - command.command === "deleteLink" - ); -} - -function isSourceContentHostCommand( - command: TopologyHostCommand -): command is Extract< - TopologyHostCommand, - { command: "setYamlContent" | "setAnnotationsContent" } -> { - return command.command === "setYamlContent" || command.command === "setAnnotationsContent"; -} - -function isSaveHostCommand( - command: TopologyHostCommand -): command is Extract< - TopologyHostCommand, - { command: "savePositions" | "savePositionsAndAnnotations" } -> { - return command.command === "savePositions" || command.command === "savePositionsAndAnnotations"; -} - -function isAnnotationHostCommand(command: TopologyHostCommand): command is Extract< - TopologyHostCommand, - { - command: - | "setAnnotations" - | "setAnnotationsWithMemberships" - | "setEdgeAnnotations" - | "setViewerSettings"; - } -> { - return ( - command.command === "setAnnotations" || - command.command === "setAnnotationsWithMemberships" || - command.command === "setEdgeAnnotations" || - command.command === "setViewerSettings" - ); -} - -function isMembershipHostCommand( - command: TopologyHostCommand -): command is Extract< - TopologyHostCommand, - { command: "setNodeGroupMembership" | "setNodeGroupMemberships" } -> { - return ( - command.command === "setNodeGroupMembership" || command.command === "setNodeGroupMemberships" - ); -} - -function isRenameEditCommand(command: TopologyHostCommand): boolean { - if (command.command !== "editNode") return false; - const payload = command.payload; - const payloadRecord = toRecord(payload); - const oldNameRaw = payloadRecord?.oldName; - const oldName = typeof oldNameRaw === "string" ? oldNameRaw.trim() : ""; - const nextName = typeof payload.name === "string" ? payload.name.trim() : ""; - return oldName !== "" && nextName !== "" && oldName !== nextName; -} - -function shouldSkipHistory(command: TopologyHostCommand): boolean { - if ( - command.command === "savePositions" || - command.command === "savePositionsAndAnnotations" || - command.command === "setYamlContent" || - command.command === "setAnnotationsContent" - ) { - return command.skipHistory === true; - } - return false; -} - -function getIdPrefix(id: string): string { - const match = /^([a-zA-Z]+)/.exec(id); - return match ? match[1] : id; -} - -function normalizeAnnotations( - annotations: TopologyAnnotations | null | undefined -): TopologyAnnotations { - if (!annotations) return createEmptyAnnotations(); - return { - freeTextAnnotations: annotations.freeTextAnnotations ?? [], - freeShapeAnnotations: annotations.freeShapeAnnotations ?? [], - trafficRateAnnotations: annotations.trafficRateAnnotations ?? [], - groupStyleAnnotations: annotations.groupStyleAnnotations ?? [], - nodeAnnotations: annotations.nodeAnnotations ?? [], - networkNodeAnnotations: annotations.networkNodeAnnotations ?? [], - edgeAnnotations: annotations.edgeAnnotations ?? [], - aliasEndpointAnnotations: annotations.aliasEndpointAnnotations ?? [], - viewerSettings: annotations.viewerSettings ?? {} - }; -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function toRecord(value: unknown): Record | undefined { - return isRecord(value) ? value : undefined; -} - -function toFiniteNumber(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - } - return undefined; -} - -function toPosition(value: unknown): { x: number; y: number } | undefined { - if (!isRecord(value)) return undefined; - const rec = value; - const x = toFiniteNumber(rec.x); - const y = toFiniteNumber(rec.y); - if (x === undefined || y === undefined) return undefined; - return { x, y }; -} - -function errorToMessage(error: unknown): string { - if (error instanceof Error) return error.message; - return String(error); -} - -function isFileNotFoundError(error: unknown): boolean { - if (isRecord(error) && error.code === "ENOENT") { - return true; - } - const msg = errorToMessage(error); - return msg.includes("ENOENT") || /no such file/i.test(msg); -} - -function normalizeParsedTopologyValue(parsed: unknown): ClabTopology { - if (isRecord(parsed)) { - return parsed; - } - return {}; -} - -function hasStrictNumericPosition(value: unknown): boolean { - if (!isRecord(value)) return false; - const rec = value; - return ( - typeof rec.x === "number" && - Number.isFinite(rec.x) && - typeof rec.y === "number" && - Number.isFinite(rec.y) - ); -} - -function isStandaloneMarkdownImage(value: unknown): boolean { - if (!isNonEmptyString(value)) return false; - return /^\s*!\[[^\]]*\]\([^)]+\)\s*$/u.test(value); -} - -function inferLegacyMediaTextHeight(width: number): number { - return Math.max(LEGACY_MIN_MEDIA_TEXT_HEIGHT, Math.round(width * LEGACY_MEDIA_TEXT_HEIGHT_RATIO)); -} - -function normalizeLegacyFreeTextDimensions(annotation: FreeTextAnnotation): { - width?: number; - height?: number; - changed: boolean; -} { - const width = toFiniteNumber(annotation.width); - const height = toFiniteNumber(annotation.height); - const isMedia = isStandaloneMarkdownImage(annotation.text); - const mediaWidth = width ?? LEGACY_DEFAULT_MEDIA_TEXT_WIDTH; - - const normalizedWidth = isMedia ? mediaWidth : width; - const normalizedHeight = isMedia ? (height ?? inferLegacyMediaTextHeight(mediaWidth)) : height; - - const changed = - (annotation.width !== undefined && annotation.width !== normalizedWidth) || - (annotation.height !== undefined && annotation.height !== normalizedHeight) || - (isMedia && (annotation.width === undefined || annotation.height === undefined)); - - return { - ...(normalizedWidth !== undefined ? { width: normalizedWidth } : {}), - ...(normalizedHeight !== undefined ? { height: normalizedHeight } : {}), - changed - }; -} - -function parseLegacyGroupIdentity(groupId: string): { name: string; level: string } { - const idx = groupId.lastIndexOf(":"); - if (idx > 0 && idx < groupId.length - 1) { - return { name: groupId.slice(0, idx), level: groupId.slice(idx + 1) }; - } - return { name: groupId, level: "1" }; -} - -function nodeBelongsToLegacyGroup( - annotation: NodeAnnotation, - groupId: string, - groupName: string, - groupLevel: string -): boolean { - if (isNonEmptyString(annotation.groupId)) { - return annotation.groupId === groupId; - } - if (!isNonEmptyString(annotation.group)) { - return false; - } - if (annotation.group !== groupId && annotation.group !== groupName) { - return false; - } - const nodeLevel = isNonEmptyString(annotation.level) ? annotation.level : "1"; - return nodeLevel === groupLevel; -} - -function deriveLegacyGroupBounds( - groupId: string, - groupName: string, - groupLevel: string, - nodeAnnotations: NodeAnnotation[] -): { position: { x: number; y: number }; width: number; height: number } | undefined { - let minX = Number.POSITIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - let maxX = Number.NEGATIVE_INFINITY; - let maxY = Number.NEGATIVE_INFINITY; - - for (const annotation of nodeAnnotations) { - if (!nodeBelongsToLegacyGroup(annotation, groupId, groupName, groupLevel)) continue; - const position = toPosition(annotation.position); - if (!position) continue; - minX = Math.min(minX, position.x); - minY = Math.min(minY, position.y); - maxX = Math.max(maxX, position.x + LEGACY_NODE_WIDTH); - maxY = Math.max(maxY, position.y + LEGACY_NODE_HEIGHT); - } - - if ( - !Number.isFinite(minX) || - !Number.isFinite(minY) || - !Number.isFinite(maxX) || - !Number.isFinite(maxY) - ) { - return undefined; - } - - return { - position: { x: minX - LEGACY_GROUP_PADDING, y: minY - LEGACY_GROUP_PADDING }, - width: Math.max(LEGACY_DEFAULT_GROUP_WIDTH, maxX - minX + LEGACY_GROUP_PADDING * 2), - height: Math.max(LEGACY_DEFAULT_GROUP_HEIGHT, maxY - minY + LEGACY_GROUP_PADDING * 2) - }; -} - -function resolveLegacyGroupIdentity( - group: GroupStyleAnnotation, - index: number -): { id: string; name: string; level: string } { - const id = isNonEmptyString(group.id) ? group.id : `legacy-group-${index + 1}`; - const identity = parseLegacyGroupIdentity(id); - const name = isNonEmptyString(group.name) ? group.name : identity.name; - const level = isNonEmptyString(group.level) ? group.level : identity.level; - return { id, name, level }; -} - -function resolveLegacyGroupLabelColor(group: GroupStyleAnnotation): { - labelColor: string | undefined; - legacyColor: unknown; -} { - const legacyColor = (group as Record).color; - const explicitLabelColor = isNonEmptyString(group.labelColor) ? group.labelColor : undefined; - const fallbackLabelColor = isNonEmptyString(legacyColor) ? legacyColor : undefined; - return { - labelColor: explicitLabelColor ?? fallbackLabelColor, - legacyColor - }; -} - -function needsLegacyGroupStyleFix(group: GroupStyleAnnotation, legacyColor: unknown): boolean { - if (!isNonEmptyString(group.id)) return true; - if (!isNonEmptyString(group.name)) return true; - if (!isNonEmptyString(group.level)) return true; - if (!hasStrictNumericPosition(group.position)) return true; - if (typeof group.width !== "number" || !Number.isFinite(group.width)) return true; - if (typeof group.height !== "number" || !Number.isFinite(group.height)) return true; - if (!isNonEmptyString(group.labelColor) && isNonEmptyString(legacyColor)) return true; - return false; -} - -function normalizeLegacyGroupStyleAnnotation( - group: GroupStyleAnnotation, - index: number, - nodeAnnotations: NodeAnnotation[] -): { annotation: GroupStyleAnnotation; changed: boolean } { - const identity = resolveLegacyGroupIdentity(group, index); - const normalizedPosition = toPosition(group.position); - const normalizedWidth = toFiniteNumber(group.width); - const normalizedHeight = toFiniteNumber(group.height); - const hasAllBounds = - normalizedPosition !== undefined && - normalizedWidth !== undefined && - normalizedHeight !== undefined; - const derivedBounds = hasAllBounds - ? undefined - : deriveLegacyGroupBounds(identity.id, identity.name, identity.level, nodeAnnotations); - const { labelColor, legacyColor } = resolveLegacyGroupLabelColor(group); - - if (!needsLegacyGroupStyleFix(group, legacyColor)) { - return { annotation: group, changed: false }; - } - - return { - annotation: { - ...group, - id: identity.id, - name: identity.name, - level: identity.level, - position: normalizedPosition ?? derivedBounds?.position ?? { x: 0, y: 0 }, - width: normalizedWidth ?? derivedBounds?.width ?? LEGACY_DEFAULT_GROUP_WIDTH, - height: normalizedHeight ?? derivedBounds?.height ?? LEGACY_DEFAULT_GROUP_HEIGHT, - labelColor - }, - changed: true - }; -} - -function migrateLegacyAnnotations(annotations: TopologyAnnotations): { - annotations: TopologyAnnotations; - modified: boolean; -} { - const nodeAnnotations = annotations.nodeAnnotations ?? []; - let modifiedCount = 0; - - const freeTextAnnotations = (annotations.freeTextAnnotations ?? []).map((annotation) => { - const normalizedPosition = toPosition(annotation.position); - const dimensions = normalizeLegacyFreeTextDimensions(annotation); - const hasPositionFix = !normalizedPosition || !hasStrictNumericPosition(annotation.position); - if (!hasPositionFix && !dimensions.changed) { - return annotation; - } - modifiedCount += 1; - const normalizedAnnotation: FreeTextAnnotation = { - ...annotation, - position: normalizedPosition ?? { x: 0, y: 0 } - }; - if (dimensions.width !== undefined) { - normalizedAnnotation.width = dimensions.width; - } else { - delete normalizedAnnotation.width; - } - if (dimensions.height !== undefined) { - normalizedAnnotation.height = dimensions.height; - } else { - delete normalizedAnnotation.height; - } - return normalizedAnnotation; - }); - - const freeShapeAnnotations = (annotations.freeShapeAnnotations ?? []).map((annotation) => { - const normalizedPosition = toPosition(annotation.position); - const normalizedEndPosition = toPosition(annotation.endPosition); - const needsPositionFix = !hasStrictNumericPosition(annotation.position); - const needsEndFix = - annotation.endPosition !== undefined && !hasStrictNumericPosition(annotation.endPosition); - if (!needsPositionFix && !needsEndFix) { - return annotation; - } - modifiedCount += 1; - return { - ...annotation, - position: normalizedPosition ?? { x: 0, y: 0 }, - endPosition: normalizedEndPosition - }; - }); - - const groupStyleAnnotations = (annotations.groupStyleAnnotations ?? []).map((group, index) => { - const normalizedGroup = normalizeLegacyGroupStyleAnnotation(group, index, nodeAnnotations); - if (!normalizedGroup.changed) { - return normalizedGroup.annotation; - } - - modifiedCount += 1; - return normalizedGroup.annotation; - }); - - if (modifiedCount === 0) { - return { annotations, modified: false }; - } - - return { - annotations: { - ...annotations, - freeTextAnnotations, - freeShapeAnnotations, - groupStyleAnnotations - }, - modified: modifiedCount > 0 - }; -} - -function extractLabSettings(doc: YAML.Document.Parsed): LabSettings { - const settings: LabSettings = {}; - const nameValue = doc.get("name"); - const name = typeof nameValue === "string" ? nameValue : undefined; - const prefixValue = doc.get("prefix"); - const prefix = typeof prefixValue === "string" ? prefixValue : undefined; - const mgmtRaw = doc.get("mgmt"); - const mgmt = - mgmtRaw !== undefined && mgmtRaw !== null && YAML.isMap(mgmtRaw) - ? toRecord(mgmtRaw.toJSON()) - : toRecord(mgmtRaw); - - if (name !== undefined && name !== "") settings.name = name; - if (prefix !== undefined) settings.prefix = prefix; - if (mgmt !== undefined) { - settings.mgmt = mgmt; - } - - return settings; -} - -function persistGraphLabelMigrations( - annotations: TopologyAnnotations, - migrations: GraphLabelMigration[] -): TopologyAnnotations { - const nodeAnnotations: Array & { id: string }> = [ - ...(annotations.nodeAnnotations ?? []) - ]; - - const existingIds = new Set(nodeAnnotations.map((na) => na.id)); - for (const migration of migrations) { - if (existingIds.has(migration.nodeId)) continue; - const { nodeId, ...rest } = migration; - nodeAnnotations.push({ ...rest, id: nodeId }); - } - - return { ...annotations, nodeAnnotations }; -} - -function mergeAnnotationsPayload( - current: TopologyAnnotations, - annotations: Partial -): TopologyAnnotations { - const merged: TopologyAnnotations = { ...current, ...annotations }; - if (annotations.viewerSettings) { - merged.viewerSettings = { - ...(current.viewerSettings ?? {}), - ...annotations.viewerSettings - }; - } - return merged; -} - -function applyNodeGroupMembershipsToAnnotations( - annotations: TopologyAnnotations, - memberships: Array<{ nodeId: string; groupId: string | null }> -): TopologyAnnotations { - const membershipMap = new Map( - memberships.flatMap((m) => - m.groupId !== null && m.groupId !== "" ? ([[m.nodeId, m.groupId]] as const) : [] - ) - ); - - const existingAnnotations = annotations.nodeAnnotations ?? []; - const existingMap = new Map(existingAnnotations.map((a) => [a.id, a])); - const result: Array<{ id: string; groupId?: string }> = []; - - for (const [nodeId, groupId] of membershipMap) { - const existing = existingMap.get(nodeId); - if (existing) { - const { group: _group, ...rest } = existing; - result.push({ ...rest, groupId }); - existingMap.delete(nodeId); - } else { - result.push({ id: nodeId, groupId }); - } - } - - for (const [nodeId, annotation] of existingMap) { - if (!membershipMap.has(nodeId)) { - const { group: _group, groupId: _groupId, ...rest } = annotation; - if (Object.keys(rest).length > 1 || (Object.keys(rest).length === 1 && rest.id)) { - result.push(rest); - } - } - } - - return { ...annotations, nodeAnnotations: result }; -} - -function setKeyAfter(map: YAML.YAMLMap, key: string, value: YAML.Node, afterKey: string): void { - const existingIndex = map.items.findIndex( - (pair) => YAML.isScalar(pair.key) && pair.key.value === key - ); - if (existingIndex >= 0) { - map.items[existingIndex].value = value; - return; - } - - const afterIndex = map.items.findIndex( - (pair) => YAML.isScalar(pair.key) && pair.key.value === afterKey - ); - - const newPair = new YAML.Pair(new YAML.Scalar(key), value); - - if (afterIndex >= 0) { - map.items.splice(afterIndex + 1, 0, newPair); - } else { - map.items.push(newPair); - } -} - -function deleteKey(map: YAML.YAMLMap, key: string): void { - const index = map.items.findIndex((pair) => YAML.isScalar(pair.key) && pair.key.value === key); - if (index >= 0) { - map.items.splice(index, 1); - } -} - -function setKey(map: YAML.YAMLMap, key: string, value: YAML.Node): void { - const existingIndex = map.items.findIndex( - (pair) => YAML.isScalar(pair.key) && pair.key.value === key - ); - if (existingIndex >= 0) { - map.items[existingIndex].value = value; - } else { - map.items.push(new YAML.Pair(new YAML.Scalar(key), value)); - } -} diff --git a/src/reactTopoViewer/shared/io/AnnotationsIO.ts b/src/reactTopoViewer/shared/io/AnnotationsIO.ts deleted file mode 100644 index 45884b08b..000000000 --- a/src/reactTopoViewer/shared/io/AnnotationsIO.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * AnnotationsIO - Core annotations I/O with caching, queuing, and locks - * - * Manages .annotations.json files alongside .clab.yaml topology files. - * Environment-agnostic: works in both VS Code extension and dev server. - */ - -import type { TopologyAnnotations } from "../types/topology"; -import { createEmptyAnnotations } from "../annotations/types"; - -import type { FileSystemAdapter, IOLogger } from "./types"; -import { noopLogger } from "./types"; - -function toTopologyAnnotations(value: unknown): TopologyAnnotations { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return createEmptyAnnotations(); - } - return { ...createEmptyAnnotations(), ...value }; -} - -/** - * Options for creating an AnnotationsIO instance - */ -export interface AnnotationsIOOptions { - fs: FileSystemAdapter; - cacheTtlMs?: number; - logger?: IOLogger; -} - -/** - * AnnotationsIO - Manages annotations file I/O with caching, queuing, and locks. - * - * Features: - * - Caching with TTL to reduce disk I/O - * - Per-file save queues to prevent concurrent writes - * - Per-file modification locks for atomic read-modify-write - * - Content deduplication before writes - */ -export class AnnotationsIO { - private fs: FileSystemAdapter; - private cache: Map = new Map(); - private readonly CACHE_TTL: number; - private saveQueues: Map> = new Map(); - private modificationLocks: Map> = new Map(); - private logger: IOLogger; - - constructor(options: AnnotationsIOOptions) { - this.fs = options.fs; - this.CACHE_TTL = options.cacheTtlMs ?? 1000; - this.logger = options.logger ?? noopLogger; - } - - /** - * Get the annotations file path for a given YAML file. - */ - getAnnotationsFilePath(yamlFilePath: string): string { - const dir = this.fs.dirname(yamlFilePath); - const fullBasename = this.fs.basename(yamlFilePath); - const filename = fullBasename + ".annotations.json"; - return this.fs.join(dir, filename); - } - - /** - * Atomically modify annotations with a serialized read-modify-write operation. - * This prevents race conditions when multiple operations try to modify annotations concurrently. - * @param yamlFilePath Path to the YAML file - * @param modifier Function that receives current annotations and returns modified annotations - */ - async modifyAnnotations( - yamlFilePath: string, - modifier: (annotations: TopologyAnnotations) => TopologyAnnotations - ): Promise { - const annotationsPath = this.getAnnotationsFilePath(yamlFilePath); - - // Acquire modification lock for this file - const currentLock = this.modificationLocks.get(annotationsPath) ?? Promise.resolve(); - let releaseLock: () => void; - const newLock = new Promise((resolve) => { - releaseLock = resolve; - }); - this.modificationLocks.set( - annotationsPath, - currentLock.then(() => newLock) - ); - - // Wait for previous modification to complete - await currentLock; - - try { - // Load fresh data (skip cache since we're modifying) - const annotations = await this.loadAnnotations(yamlFilePath, true); - // Apply modification - const modified = modifier(annotations); - // Save the result - await this.saveAnnotations(yamlFilePath, modified); - } finally { - // Release the lock - releaseLock!(); - } - } - - /** - * Load annotations from the annotations file with caching. - * Waits for any pending saves to complete first. - * @param yamlFilePath Path to the YAML file - * @param skipCache If true, bypasses cache (use for read-modify-write operations) - */ - async loadAnnotations(yamlFilePath: string, skipCache = false): Promise { - const annotationsPath = this.getAnnotationsFilePath(yamlFilePath); - - // Wait for any pending save to complete first - const pendingSave = this.saveQueues.get(annotationsPath); - if (pendingSave) { - await pendingSave; - } - - // Check cache first (unless skipping cache for modification operations) - if (!skipCache) { - const cached = this.cache.get(annotationsPath); - if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { - this.logger.debug(`Using cached annotations for ${annotationsPath}`); - return cached.data; - } - } - - try { - const exists = await this.fs.exists(annotationsPath); - if (exists) { - const content = await this.fs.readFile(annotationsPath); - const annotations = toTopologyAnnotations(JSON.parse(content) as unknown); - this.logger.info(`Loaded annotations from ${annotationsPath}`); - - this.cache.set(annotationsPath, { data: annotations, timestamp: Date.now() }); - return annotations; - } - } catch (error) { - this.logger.warn(`Failed to load annotations from ${annotationsPath}: ${error}`); - } - - const emptyAnnotations = createEmptyAnnotations(); - this.cache.set(annotationsPath, { data: emptyAnnotations, timestamp: Date.now() }); - return emptyAnnotations; - } - - /** - * Save annotations to the annotations file (queued to prevent concurrent writes). - */ - async saveAnnotations(yamlFilePath: string, annotations: TopologyAnnotations): Promise { - const annotationsPath = this.getAnnotationsFilePath(yamlFilePath); - - // Queue saves per file to prevent concurrent writes - const currentQueue = this.saveQueues.get(annotationsPath) ?? Promise.resolve(); - const newQueue = currentQueue - .then(async () => { - this.cache.delete(annotationsPath); - - try { - const shouldSave = this.shouldSaveAnnotations(annotations); - if (shouldSave) { - const content = JSON.stringify(annotations, null, 2); - let shouldWrite = true; - - try { - const existing = await this.fs.readFile(annotationsPath); - if (existing === content) { - shouldWrite = false; - this.logger.debug(`Annotations unchanged, skipping save for ${annotationsPath}`); - } - } catch { - // File might not exist, so we need to write - } - - if (shouldWrite) { - await this.fs.writeFile(annotationsPath, content); - this.logger.info(`Saved annotations to ${annotationsPath}`); - } - } else { - // Delete the file if no annotations exist - await this.fs.unlink(annotationsPath); - this.logger.info(`Removed empty annotations file ${annotationsPath}`); - } - } catch (error) { - this.logger.error(`Failed to save annotations to ${annotationsPath}: ${error}`); - throw error; - } - }) - .catch((err) => { - this.logger.error(`Annotations save queue error: ${err}`); - }); - - this.saveQueues.set(annotationsPath, newQueue); - return newQueue; - } - - /** - * Clear all caches and pending operations. - * Useful for session reset in dev server. - */ - clearCache(): void { - this.cache.clear(); - this.saveQueues.clear(); - this.modificationLocks.clear(); - } - - /** - * Check if array has content. - */ - private hasContent(arr: unknown[] | undefined): boolean { - return Array.isArray(arr) && arr.length > 0; - } - - /** - * Determine if annotations should be saved (has any content). - */ - private shouldSaveAnnotations(annotations: TopologyAnnotations): boolean { - if (this.hasContent(annotations.freeTextAnnotations)) return true; - if (this.hasContent(annotations.freeShapeAnnotations)) return true; - if (this.hasContent(annotations.trafficRateAnnotations)) return true; - if (this.hasContent(annotations.groupStyleAnnotations)) return true; - if (this.hasContent(annotations.networkNodeAnnotations)) return true; - if (this.hasContent(annotations.nodeAnnotations)) return true; - if (this.hasContent(annotations.edgeAnnotations)) return true; - if (this.hasContent(annotations.aliasEndpointAnnotations)) return true; - if (annotations.viewerSettings && Object.keys(annotations.viewerSettings).length > 0) - return true; - return false; - } -} diff --git a/src/reactTopoViewer/shared/io/LinkPersistenceIO.ts b/src/reactTopoViewer/shared/io/LinkPersistenceIO.ts deleted file mode 100644 index e806fbc35..000000000 --- a/src/reactTopoViewer/shared/io/LinkPersistenceIO.ts +++ /dev/null @@ -1,733 +0,0 @@ -/** - * LinkPersistenceIO - Pure YAML AST operations for link CRUD - * - * Contains only the YAML manipulation logic without file I/O. - * Used by both VS Code extension and dev server. - */ - -import * as YAML from "yaml"; - -import type { SaveResult, IOLogger } from "./types"; -import { ERROR_LINKS_NOT_SEQ, noopLogger } from "./types"; -import { createQuotedScalar, setOrDelete } from "./YamlDocumentIO"; - -/** - * Gets the links sequence from a YAML document, returning an error result if not found. - */ -function getLinksSeqOrError( - doc: YAML.Document.Parsed -): { linksSeq: YAML.YAMLSeq } | { error: SaveResult } { - const linksNode = doc.getIn(["topology", "links"], true); - if (!YAML.isSeq(linksNode)) { - return { error: { success: false, error: ERROR_LINKS_NOT_SEQ } }; - } - return { linksSeq: linksNode }; -} - -/** - * Gets the links sequence, returning it directly or the error SaveResult if not found. - * Helper to reduce code duplication for the common pattern of error checking. - */ -function getValidatedLinksSeq(doc: YAML.Document.Parsed): YAML.YAMLSeq | SaveResult { - const result = getLinksSeqOrError(doc); - if ("error" in result) return result.error; - return result.linksSeq; -} - -/** Link data for save operations */ -export interface LinkSaveData { - id: string; - source: string; - target: string; - sourceEndpoint?: string; - targetEndpoint?: string; - extraData?: { - extType?: string; - extMtu?: string | number; - extHostInterface?: string; - extMode?: string; - extRemote?: string; - extVni?: string | number; - extDstPort?: string | number; - extSrcPort?: string | number; - extSourceMac?: string; - extTargetMac?: string; - extSourceIpv4?: string; - extSourceIpv6?: string; - extTargetIpv4?: string; - extTargetIpv6?: string; - extVars?: Record; - extLabels?: Record; - [key: string]: unknown; - }; - // Original values for finding the link when endpoints change - originalSource?: string; - originalTarget?: string; - originalSourceEndpoint?: string; - originalTargetEndpoint?: string; -} - -/** Link types that use single endpoint format (type + endpoint, not endpoints array) */ -const SINGLE_ENDPOINT_TYPES = new Set([ - "host", - "mgmt-net", - "macvlan", - "vxlan", - "vxlan-stitch", - "dummy" -]); - -function hasText(value: string | undefined): value is string { - return value !== undefined && value !== ""; -} - -/** - * Creates a link entry in brief format: endpoints: ["node1:eth1", "node2:eth1"] - */ -function createBriefLink(doc: YAML.Document, linkData: LinkSaveData): YAML.YAMLMap { - const linkMap = new YAML.YAMLMap(); - linkMap.flow = false; - - const srcStr = hasText(linkData.sourceEndpoint) - ? `${linkData.source}:${linkData.sourceEndpoint}` - : linkData.source; - const dstStr = hasText(linkData.targetEndpoint) - ? `${linkData.target}:${linkData.targetEndpoint}` - : linkData.target; - - const endpointsSeq = new YAML.YAMLSeq(); - endpointsSeq.flow = true; - endpointsSeq.add(createQuotedScalar(doc, srcStr)); - endpointsSeq.add(createQuotedScalar(doc, dstStr)); - - linkMap.set("endpoints", endpointsSeq); - return linkMap; -} - -/** Prefixes that indicate a special/visualization node (not a real YAML node) */ -const SPECIAL_NODE_PREFIXES = [ - "host:", - "mgmt-net:", - "macvlan:", - "vxlan:", - "vxlan-stitch:", - "dummy" -]; - -/** - * Check if a node ID represents a special/visualization node. - */ -function isSpecialNode(nodeId: string): boolean { - return SPECIAL_NODE_PREFIXES.some((prefix) => nodeId.startsWith(prefix)); -} - -function pickEndpointValue(useTarget: boolean, sourceValue: T, targetValue: T): T { - return useTarget ? targetValue : sourceValue; -} - -function setEndpointField( - doc: YAML.Document, - epMap: YAML.YAMLMap, - key: string, - value: string | undefined, - quoted = false -): void { - if (!hasText(value)) return; - epMap.set(key, quoted ? createQuotedScalar(doc, value) : doc.createNode(value)); -} - -/** - * Creates a single endpoint map for special link types. - * The endpoint should reference the REAL containerlab node, not the visualization node. - * We detect which side is the real node by checking for special node prefixes. - */ -function createSingleEndpointMap( - doc: YAML.Document, - linkData: LinkSaveData, - extra: LinkSaveData["extraData"], - _linkType: string -): YAML.YAMLMap { - const epMap = new YAML.YAMLMap(); - epMap.flow = false; - - // Determine which side is the real node (the one without special prefix) - const sourceIsSpecial = isSpecialNode(linkData.source); - const useTarget = sourceIsSpecial; - - const node = pickEndpointValue(useTarget, linkData.source, linkData.target); - const iface = pickEndpointValue(useTarget, linkData.sourceEndpoint, linkData.targetEndpoint); - const mac = pickEndpointValue(useTarget, extra?.extSourceMac, extra?.extTargetMac); - const ipv4 = pickEndpointValue(useTarget, extra?.extSourceIpv4, extra?.extTargetIpv4); - const ipv6 = pickEndpointValue(useTarget, extra?.extSourceIpv6, extra?.extTargetIpv6); - - epMap.set("node", createQuotedScalar(doc, node)); - setEndpointField(doc, epMap, "interface", iface, true); - setEndpointField(doc, epMap, "mac", mac); - setEndpointField(doc, epMap, "ipv4", ipv4); - setEndpointField(doc, epMap, "ipv6", ipv6); - return epMap; -} - -/** - * Extract the interface/identifier from a special node ID. - * e.g., "host:eth0" → "eth0", "macvlan:0" → "0" - */ -function extractSpecialNodeInterface(nodeId: string): string | undefined { - const colonIndex = nodeId.indexOf(":"); - if (colonIndex === -1) return undefined; - return nodeId.slice(colonIndex + 1) || undefined; -} - -/** - * Simple IPv4 validation check. - * Returns true if the string looks like an IP address (contains dots with numbers). - */ -function looksLikeIpAddress(value: string): boolean { - // Basic check: should contain at least one dot and only valid IP chars - return /^\d{1,3}(\.\d{1,3}){3}$/.test(value); -} - -/** - * Extract vxlan properties from the special node ID. - * Format: "vxlan:remote/vni/dst-port/src-port" or "vxlan-stitch:remote/vni/dst-port/src-port" - * Only extracts remote if it looks like an IP address to avoid using counter-based names. - */ -function extractVxlanProperties(nodeId: string): { - remote?: string; - vni?: string; - dstPort?: string; - srcPort?: string; -} { - const colonIndex = nodeId.indexOf(":"); - if (colonIndex === -1) return {}; - - const parts = nodeId.slice(colonIndex + 1).split("/"); - // Only use remote if it looks like an IP address - const remoteCandidate = parts[0]; - return { - remote: remoteCandidate && looksLikeIpAddress(remoteCandidate) ? remoteCandidate : undefined, - vni: parts[1] || undefined, - dstPort: parts[2] || undefined, - srcPort: parts[3] || undefined - }; -} - -/** Host/macvlan/mgmt-net link types that use host-interface */ -const HOST_INTERFACE_TYPES = new Set(["host", "macvlan", "mgmt-net"]); - -/** - * Apply host-interface properties for host/macvlan/mgmt-net link types - */ -function applyHostInterfaceProperties( - doc: YAML.Document, - linkMap: YAML.YAMLMap, - linkType: string, - extra: LinkSaveData["extraData"], - specialNodeId: string -): void { - const hostInterface = extra?.extHostInterface ?? extractSpecialNodeInterface(specialNodeId); - if (hasText(hostInterface)) { - linkMap.set("host-interface", doc.createNode(hostInterface)); - } - if (linkType === "macvlan" && hasText(extra?.extMode)) { - linkMap.set("mode", doc.createNode(extra.extMode)); - } -} - -/** Default values for required VXLAN properties */ -const VXLAN_DEFAULTS = { remote: "127.0.0.1", vni: 100, dstPort: 4789 }; - -/** Converts a value to number, returns default if conversion fails */ -function toNumber(value: string | number | undefined, defaultValue: number): number { - if (value === undefined) return defaultValue; - const num = Number(value); - return Number.isNaN(num) ? defaultValue : num; -} - -/** - * Apply vxlan-specific properties (remote, vni, dst-port, src-port) - */ -function applyVxlanProperties( - doc: YAML.Document, - linkMap: YAML.YAMLMap, - extra: LinkSaveData["extraData"], - specialNodeId: string -): void { - const parsed = extractVxlanProperties(specialNodeId); - - const remote = extra?.extRemote ?? parsed.remote ?? VXLAN_DEFAULTS.remote; - const vni = toNumber(extra?.extVni ?? parsed.vni, VXLAN_DEFAULTS.vni); - const dstPort = toNumber(extra?.extDstPort ?? parsed.dstPort, VXLAN_DEFAULTS.dstPort); - - linkMap.set("remote", doc.createNode(remote)); - linkMap.set("vni", doc.createNode(vni)); - linkMap.set("dst-port", doc.createNode(dstPort)); - - const srcPort = extra?.extSrcPort ?? parsed.srcPort; - if (srcPort !== undefined && srcPort !== "") { - linkMap.set("src-port", doc.createNode(toNumber(srcPort, 0))); - } -} - -/** - * Applies type-specific properties for single endpoint links - */ -function applySingleEndpointProperties( - doc: YAML.Document, - linkMap: YAML.YAMLMap, - linkType: string, - extra: LinkSaveData["extraData"], - linkData: LinkSaveData -): void { - // Get the special node ID (the one with the prefix like host:, vxlan:, etc.) - const specialNodeId = isSpecialNode(linkData.source) ? linkData.source : linkData.target; - - if (HOST_INTERFACE_TYPES.has(linkType)) { - applyHostInterfaceProperties(doc, linkMap, linkType, extra, specialNodeId); - } else if (linkType === "vxlan" || linkType === "vxlan-stitch") { - applyVxlanProperties(doc, linkMap, extra, specialNodeId); - } -} - -/** - * Creates dual endpoint sequence for veth links - */ -function createDualEndpointSeq( - doc: YAML.Document, - linkData: LinkSaveData, - extra: LinkSaveData["extraData"] -): YAML.YAMLSeq { - const endpointsSeq = new YAML.YAMLSeq(); - endpointsSeq.flow = false; - - const srcEp = new YAML.YAMLMap(); - srcEp.flow = false; - srcEp.set("node", createQuotedScalar(doc, linkData.source)); - if (hasText(linkData.sourceEndpoint)) { - srcEp.set("interface", createQuotedScalar(doc, linkData.sourceEndpoint)); - } - if (hasText(extra?.extSourceMac)) { - srcEp.set("mac", doc.createNode(extra.extSourceMac)); - } - if (hasText(extra?.extSourceIpv4)) { - srcEp.set("ipv4", doc.createNode(extra.extSourceIpv4)); - } - if (hasText(extra?.extSourceIpv6)) { - srcEp.set("ipv6", doc.createNode(extra.extSourceIpv6)); - } - - const dstEp = new YAML.YAMLMap(); - dstEp.flow = false; - dstEp.set("node", createQuotedScalar(doc, linkData.target)); - if (hasText(linkData.targetEndpoint)) { - dstEp.set("interface", createQuotedScalar(doc, linkData.targetEndpoint)); - } - if (hasText(extra?.extTargetMac)) { - dstEp.set("mac", doc.createNode(extra.extTargetMac)); - } - if (hasText(extra?.extTargetIpv4)) { - dstEp.set("ipv4", doc.createNode(extra.extTargetIpv4)); - } - if (hasText(extra?.extTargetIpv6)) { - dstEp.set("ipv6", doc.createNode(extra.extTargetIpv6)); - } - - endpointsSeq.add(srcEp); - endpointsSeq.add(dstEp); - return endpointsSeq; -} - -/** - * Creates a link entry in extended format with type and additional properties - */ -function createExtendedLink(doc: YAML.Document, linkData: LinkSaveData): YAML.YAMLMap { - const linkMap = new YAML.YAMLMap(); - linkMap.flow = false; - - const extra = linkData.extraData ?? {}; - const inferredType = inferSpecialLinkType(linkData); - const rawType = typeof extra.extType === "string" ? extra.extType.trim() : ""; - const linkType = rawType && rawType !== "veth" ? rawType : (inferredType ?? "veth"); - - linkMap.set("type", doc.createNode(linkType)); - - if (SINGLE_ENDPOINT_TYPES.has(linkType)) { - linkMap.set("endpoint", createSingleEndpointMap(doc, linkData, extra, linkType)); - applySingleEndpointProperties(doc, linkMap, linkType, extra, linkData); - } else { - linkMap.set("endpoints", createDualEndpointSeq(doc, linkData, extra)); - } - - // Common extended properties - setOrDelete(doc, linkMap, "mtu", extra.extMtu); - setOrDelete(doc, linkMap, "vars", extra.extVars); - setOrDelete(doc, linkMap, "labels", extra.extLabels); - - return linkMap; -} - -/** - * Checks if link data has extended properties requiring extended format - */ -function hasExtendedProperties(linkData: LinkSaveData): boolean { - const extra = linkData.extraData ?? {}; - const extendedKeys = [ - "extMtu", - "extSourceMac", - "extTargetMac", - "extSourceIpv4", - "extSourceIpv6", - "extTargetIpv4", - "extTargetIpv6", - "extHostInterface", - "extMode", - "extRemote", - "extVni", - "extDstPort", - "extSrcPort" - ]; - - if (extendedKeys.some((k) => extra[k] !== undefined && extra[k] !== "")) return true; - if ( - extra.extVars !== undefined && - typeof extra.extVars === "object" && - Object.keys(extra.extVars).length > 0 - ) { - return true; - } - if ( - extra.extLabels !== undefined && - typeof extra.extLabels === "object" && - Object.keys(extra.extLabels).length > 0 - ) { - return true; - } - if (hasText(extra.extType) && extra.extType !== "veth") return true; - const inferredType = inferSpecialLinkType(linkData); - if (inferredType !== null && inferredType !== "veth") return true; - - return false; -} - -/** - * Extracts the link type from a special node ID. - * E.g., "vxlan:vxlan0" -> "vxlan", "host:eth0" -> "host" - */ -function extractLinkTypeFromSpecialNode(nodeId: string): string | null { - const colonIdx = nodeId.indexOf(":"); - if (colonIdx === -1) { - // Handle dummy nodes which don't have a colon prefix - if (nodeId.startsWith("dummy")) return "dummy"; - return null; - } - return nodeId.substring(0, colonIdx); -} - -function inferSpecialLinkType(linkData: LinkSaveData): string | null { - if (isSpecialNode(linkData.source)) { - const sourceType = extractLinkTypeFromSpecialNode(linkData.source); - if (sourceType !== null && sourceType !== "") return sourceType; - } - if (isSpecialNode(linkData.target)) { - const targetType = extractLinkTypeFromSpecialNode(linkData.target); - if (targetType !== null && targetType !== "") return targetType; - } - return null; -} - -/** - * Generates a canonical key for a link to find duplicates. - * For special nodes (vxlan, host, etc.), uses the link type instead of the full node ID - * to match the YAML format. - */ -function getLinkKey(linkData: LinkSaveData): string { - const sourceIsSpecial = isSpecialNode(linkData.source); - const targetIsSpecial = isSpecialNode(linkData.target); - - let src: string; - let dst: string; - - if (sourceIsSpecial) { - const linkType = extractLinkTypeFromSpecialNode(linkData.source); - src = linkType ?? linkData.source; - dst = hasText(linkData.targetEndpoint) - ? `${linkData.target}:${linkData.targetEndpoint}` - : linkData.target; - } else if (targetIsSpecial) { - const linkType = extractLinkTypeFromSpecialNode(linkData.target); - src = hasText(linkData.sourceEndpoint) - ? `${linkData.source}:${linkData.sourceEndpoint}` - : linkData.source; - dst = linkType ?? linkData.target; - } else { - src = hasText(linkData.sourceEndpoint) - ? `${linkData.source}:${linkData.sourceEndpoint}` - : linkData.source; - dst = hasText(linkData.targetEndpoint) - ? `${linkData.target}:${linkData.targetEndpoint}` - : linkData.target; - } - - // Sort to ensure consistent key regardless of direction - return [src, dst].slice().sort().join("|"); -} - -/** - * Extracts endpoint string from a YAML endpoint item - */ -function extractEndpointString(ep: unknown): string | null { - if (YAML.isScalar(ep)) { - return String(ep.value); - } - if (YAML.isMap(ep)) { - const node = ep.get("node"); - const iface = ep.get("interface"); - if (iface === undefined || iface === null || iface === "") return String(node); - return `${node}:${String(iface)}`; - } - return null; -} - -/** - * Gets the canonical key from an existing YAML link map - */ -function getYamlLinkKey(linkMap: YAML.YAMLMap): string | null { - const endpoints: string[] = []; - - // Check endpoints array - const endpointsSeq = linkMap.get("endpoints", true); - if (YAML.isSeq(endpointsSeq)) { - for (const ep of endpointsSeq.items) { - const epStr = extractEndpointString(ep); - if (epStr !== null && epStr !== "") endpoints.push(epStr); - } - } - - // Check single endpoint - const endpoint = linkMap.get("endpoint", true); - if (YAML.isMap(endpoint)) { - const epStr = extractEndpointString(endpoint); - if (epStr !== null && epStr !== "") endpoints.push(epStr); - } - - if (endpoints.length < 1) return null; - - // For single-endpoint types, the second endpoint is the type (host, mgmt-net, etc.) - const linkType = linkMap.get("type"); - if (endpoints.length === 1 && linkType !== undefined && linkType !== null) { - endpoints.push(String(linkType)); - } - - return [...endpoints].sort().join("|"); -} - -/** - * Gets the lookup key for finding an existing link - * Uses original values if provided (for when endpoints have changed) - * For special nodes (vxlan, host, etc.), uses the link type instead of the full node ID - * to match the YAML format where single-endpoint links use the type as the second key part. - */ -function getLookupKey(linkData: LinkSaveData): string { - const source = linkData.originalSource ?? linkData.source; - const target = linkData.originalTarget ?? linkData.target; - const sourceEndpoint = linkData.originalSourceEndpoint ?? linkData.sourceEndpoint; - const targetEndpoint = linkData.originalTargetEndpoint ?? linkData.targetEndpoint; - - // Check if either endpoint is a special node - const sourceIsSpecial = isSpecialNode(source); - const targetIsSpecial = isSpecialNode(target); - - let src: string; - let dst: string; - - if (sourceIsSpecial) { - // Source is special node - use link type for that side, node:iface for other side - const linkType = extractLinkTypeFromSpecialNode(source); - src = linkType ?? source; - dst = hasText(targetEndpoint) ? `${target}:${targetEndpoint}` : target; - } else if (targetIsSpecial) { - // Target is special node - use link type for that side, node:iface for other side - const linkType = extractLinkTypeFromSpecialNode(target); - src = hasText(sourceEndpoint) ? `${source}:${sourceEndpoint}` : source; - dst = linkType ?? target; - } else { - // Normal veth link - use both endpoints - src = hasText(sourceEndpoint) ? `${source}:${sourceEndpoint}` : source; - dst = hasText(targetEndpoint) ? `${target}:${targetEndpoint}` : target; - } - - return [src, dst].slice().sort().join("|"); -} - -/** - * Builds a lookup key that treats all endpoints literally. - * This is used as a fallback for legacy links that used special node IDs - * directly in endpoints arrays (e.g. "host:eth0") instead of type-based links. - */ -function getLegacyLookupKey(linkData: LinkSaveData): string { - const source = linkData.originalSource ?? linkData.source; - const target = linkData.originalTarget ?? linkData.target; - const sourceEndpoint = linkData.originalSourceEndpoint ?? linkData.sourceEndpoint; - const targetEndpoint = linkData.originalTargetEndpoint ?? linkData.targetEndpoint; - - const src = hasText(sourceEndpoint) ? `${source}:${sourceEndpoint}` : source; - const dst = hasText(targetEndpoint) ? `${target}:${targetEndpoint}` : target; - return [src, dst].slice().sort().join("|"); -} - -/** - * Adds a new link to the topology - */ -export function addLinkToDoc( - doc: YAML.Document.Parsed, - linkData: LinkSaveData, - logger: IOLogger = noopLogger -): SaveResult { - try { - // Ensure links array exists - const linksNode = doc.getIn(["topology", "links"], true); - let linksSeq: YAML.YAMLSeq; - if (YAML.isSeq(linksNode)) { - linksSeq = linksNode; - } else { - linksSeq = new YAML.YAMLSeq(); - linksSeq.flow = false; - const topoNode = doc.getIn(["topology"], true); - if (YAML.isMap(topoNode)) { - const topoMap = topoNode; - topoMap.set("links", linksSeq); - } else { - return { success: false, error: "YAML topology is not a map" }; - } - } - - // Check for duplicate - const newKey = getLinkKey(linkData); - for (const item of linksSeq.items) { - if (YAML.isMap(item)) { - const existingKey = getYamlLinkKey(item); - if (existingKey === newKey) { - return { success: false, error: "Link already exists" }; - } - } - } - - // Create the link - const linkMap = hasExtendedProperties(linkData) - ? createExtendedLink(doc, linkData) - : createBriefLink(doc, linkData); - - linksSeq.add(linkMap); - - logger.info(`[SaveTopology] Added link: ${linkData.source} <-> ${linkData.target}`); - return { success: true }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: message }; - } -} - -/** - * Updates an existing link in the topology - */ -export function editLinkInDoc( - doc: YAML.Document.Parsed, - linkData: LinkSaveData, - logger: IOLogger = noopLogger -): SaveResult { - try { - const linksSeq = getValidatedLinksSeq(doc); - if (!YAML.isSeq(linksSeq)) return linksSeq; - - // Use original values to find the existing link (if endpoints were changed) - const lookupKey = getLookupKey(linkData); - let found = false; - - logger.info(`[SaveTopology] Looking for link with key: ${lookupKey}`); - - const tryUpdateWithKey = (key: string): boolean => { - for (let i = 0; i < linksSeq.items.length; i++) { - const item = linksSeq.items[i]; - if (!YAML.isMap(item)) continue; - - const existingKey = getYamlLinkKey(item); - if (existingKey === key) { - // Replace with updated link (using the new values) - const updatedLink = hasExtendedProperties(linkData) - ? createExtendedLink(doc, linkData) - : createBriefLink(doc, linkData); - linksSeq.items[i] = updatedLink; - logger.info(`[SaveTopology] Found and updated link at index ${i}`); - return true; - } - } - return false; - }; - - found = tryUpdateWithKey(lookupKey); - - if (!found) { - const legacyKey = getLegacyLookupKey(linkData); - if (legacyKey !== lookupKey) { - logger.info(`[SaveTopology] Falling back to legacy link key: ${legacyKey}`); - found = tryUpdateWithKey(legacyKey); - } - } - - if (!found) { - return { success: false, error: `Link not found (lookup key: ${lookupKey})` }; - } - - logger.info( - `[SaveTopology] Updated link: ${linkData.source}:${linkData.sourceEndpoint} <-> ${linkData.target}:${linkData.targetEndpoint}` - ); - return { success: true }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: message }; - } -} - -/** - * Removes a link from the topology - */ -export function deleteLinkFromDoc( - doc: YAML.Document.Parsed, - linkData: LinkSaveData, - logger: IOLogger = noopLogger -): SaveResult { - try { - const linksSeq = getValidatedLinksSeq(doc); - if (!YAML.isSeq(linksSeq)) return linksSeq; - - const targetKey = getLinkKey(linkData); - const initialLength = linksSeq.items.length; - - linksSeq.items = linksSeq.items.filter((item) => { - if (!YAML.isMap(item)) return true; - const existingKey = getYamlLinkKey(item); - return existingKey !== targetKey; - }); - - if (linksSeq.items.length === initialLength) { - const legacyKey = getLegacyLookupKey(linkData); - if (legacyKey !== targetKey) { - linksSeq.items = linksSeq.items.filter((item) => { - if (!YAML.isMap(item)) return true; - const existingKey = getYamlLinkKey(item); - return existingKey !== legacyKey; - }); - } - } - - if (linksSeq.items.length === initialLength) { - return { success: false, error: "Link not found" }; - } - - logger.info(`[SaveTopology] Deleted link: ${linkData.source} <-> ${linkData.target}`); - return { success: true }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: message }; - } -} diff --git a/src/reactTopoViewer/shared/io/NodePersistenceIO.ts b/src/reactTopoViewer/shared/io/NodePersistenceIO.ts deleted file mode 100644 index fca66fbbd..000000000 --- a/src/reactTopoViewer/shared/io/NodePersistenceIO.ts +++ /dev/null @@ -1,604 +0,0 @@ -/** - * NodePersistenceIO - Pure YAML AST operations for node CRUD - * - * Contains only the YAML manipulation logic without file I/O or annotations. - * Used by both VS Code extension and dev server. - */ - -import * as YAML from "yaml"; - -import type { ClabTopology } from "../types/topology"; - -import type { SaveResult, IOLogger } from "./types"; -import { ERROR_NODES_NOT_MAP, noopLogger } from "./types"; -import { deepEqual, setOrDelete } from "./YamlDocumentIO"; - -function getYamlMapIn( - doc: YAML.Document.Parsed, - path: readonly string[] -): YAML.YAMLMap | undefined { - const value = doc.getIn(path, true); - return YAML.isMap(value) ? value : undefined; -} - -function getYamlSeqIn( - doc: YAML.Document.Parsed, - path: readonly string[] -): YAML.YAMLSeq | undefined { - const value = doc.getIn(path, true); - return YAML.isSeq(value) ? value : undefined; -} - -/** - * Gets the nodes map from a YAML document, returning an error result if not found. - */ -function getNodesMapOrError( - doc: YAML.Document.Parsed -): { nodesMap: YAML.YAMLMap } | { error: SaveResult } { - const nodesMap = getYamlMapIn(doc, ["topology", "nodes"]); - if (nodesMap === undefined) { - return { error: { success: false, error: ERROR_NODES_NOT_MAP } }; - } - return { nodesMap }; -} - -/** - * Ensures the document has topology.nodes and topology.links containers. - * Used when creating or upserting nodes from an empty/minimal YAML file. - */ -function ensureTopologyContainers(doc: YAML.Document.Parsed): YAML.YAMLMap { - if (!doc.contents || !YAML.isMap(doc.contents)) { - doc.contents = YAML.parseDocument("{}").contents; - } - - const topology = doc.get("topology", true); - if (!YAML.isMap(topology)) { - doc.set("topology", new YAML.YAMLMap()); - } - - let nodesMap = getYamlMapIn(doc, ["topology", "nodes"]); - if (nodesMap === undefined) { - nodesMap = new YAML.YAMLMap(); - doc.setIn(["topology", "nodes"], nodesMap); - } - - const linksSeq = getYamlSeqIn(doc, ["topology", "links"]); - if (linksSeq === undefined) { - doc.setIn(["topology", "links"], new YAML.YAMLSeq()); - } - - return nodesMap; -} - -/** Node data for save operations */ -export interface NodeSaveData { - id: string; - name: string; - extraData?: { - kind?: string | null; - type?: string | null; - image?: string | null; - group?: string | null; - "startup-config"?: string | null; - "mgmt-ipv4"?: string | null; - "mgmt-ipv6"?: string | null; - labels?: Record | null; - env?: Record | null; - binds?: string[] | null; - ports?: string[] | null; - [key: string]: unknown; - }; - position?: { x: number; y: number }; -} - -/** Node annotation data that can be saved to annotations file */ -export interface NodeAnnotationData { - label?: string | null; - icon?: string; - iconColor?: string; - iconCornerRadius?: number; - labelPosition?: string | null; - direction?: string | null; - labelBackgroundColor?: string | null; - /** Interface pattern for link creation - tracks template inheritance */ - interfacePattern?: string; - /** Group ID for group membership */ - groupId?: string; -} - -/** Node properties that can be saved to YAML */ -const NODE_YAML_PROPERTIES = [ - "kind", - "type", - "image", - "group", - "startup-config", - "enforce-startup-config", - "suppress-startup-config", - "license", - "binds", - "env", - "env-files", - "labels", - "user", - "entrypoint", - "cmd", - "exec", - "restart-policy", - "auto-remove", - "startup-delay", - "mgmt-ipv4", - "mgmt-ipv6", - "network-mode", - "ports", - "dns", - "aliases", - "memory", - "cpu", - "cpu-set", - "shm-size", - "cap-add", - "sysctls", - "devices", - "certificate", - "healthcheck", - "image-pull-policy", - "runtime", - "components", - "stages" -] as const; - -/** - * Resolves inherited configuration from defaults, kinds, and groups - */ -export function resolveInheritedConfig( - topo: ClabTopology, - group?: string, - kind?: string -): Partial> { - const result: Record = {}; - const topology = topo.topology; - - // Apply defaults - if (topology?.defaults !== undefined) { - Object.assign(result, topology.defaults); - } - - // Apply kind defaults - if (kind !== undefined && kind.length > 0) { - const kindDefaults = topology?.kinds?.[kind]; - if (kindDefaults !== undefined) { - Object.assign(result, kindDefaults); - } - } - - // Apply group defaults - if (group !== undefined && group.length > 0) { - const groupDefaults = topology?.groups?.[group]; - if (groupDefaults !== undefined) { - Object.assign(result, groupDefaults); - } - } - - return result; -} - -/** - * Sets a single property on a node map if the value is valid. - * Returns true if the property was set, false otherwise. - */ -function setNodeProperty( - doc: YAML.Document, - nodeMap: YAML.YAMLMap, - prop: string, - value: unknown -): void { - // Skip undefined/null values - if (value === undefined || value === null) return; - - // Handle string values - trim whitespace - if (typeof value === "string") { - const trimmed = value.trim(); - if (trimmed) nodeMap.set(prop, doc.createNode(trimmed)); - return; - } - - // Handle arrays - set if non-empty - if (Array.isArray(value)) { - if (value.length > 0) setOrDelete(doc, nodeMap, prop, value); - return; - } - - // Handle objects - set if non-empty - if (typeof value === "object") { - if (Object.keys(value).length > 0) setOrDelete(doc, nodeMap, prop, value); - return; - } - - // Handle other primitives (numbers, booleans) - setOrDelete(doc, nodeMap, prop, value); -} - -/** - * Creates a new node entry in the YAML document - */ -function createNodeYaml(doc: YAML.Document, nodeData: NodeSaveData): YAML.YAMLMap { - const nodeMap = new YAML.YAMLMap(); - nodeMap.flow = false; - - const extra = nodeData.extraData ?? {}; - - // Set kind (required, defaults to nokia_srlinux) - const trimmedKind = extra.kind?.trim(); - const kind = trimmedKind !== undefined && trimmedKind !== "" ? trimmedKind : "nokia_srlinux"; - nodeMap.set("kind", doc.createNode(kind)); - - // Set all other supported properties from NODE_YAML_PROPERTIES - for (const prop of NODE_YAML_PROPERTIES) { - if (prop !== "kind") { - setNodeProperty(doc, nodeMap, prop, extra[prop]); - } - } - - return nodeMap; -} - -/** - * Updates an existing node in the YAML document - */ -function updateNodeYaml( - doc: YAML.Document, - nodeMap: YAML.YAMLMap, - nodeData: NodeSaveData, - inheritedConfig: Partial> -): void { - const extra = nodeData.extraData ?? {}; - - // Update each supported property - for (const prop of NODE_YAML_PROPERTIES) { - const value = extra[prop]; - const inherited = inheritedConfig[prop]; - - // Only delete properties that are explicitly set to null - // Undefined means "not provided" - preserve existing value - if (value === undefined) { - continue; - } - - // Explicit null means "delete this property" - if (value === null) { - if (nodeMap.has(prop)) nodeMap.delete(prop); - continue; - } - - // If value matches inherited, remove node-level override - if (deepEqual(value, inherited)) { - if (nodeMap.has(prop)) nodeMap.delete(prop); - continue; - } - - // Set the value - setOrDelete(doc, nodeMap, prop, value); - } -} - -/** - * Checks if an endpoint item references a specific node - */ -function endpointReferencesNode(ep: unknown, nodeId: string): boolean { - if (YAML.isScalar(ep)) { - const str = String(ep.value); - return str === nodeId || str.startsWith(`${nodeId}:`); - } - if (YAML.isMap(ep)) { - return ep.get("node") === nodeId; - } - return false; -} - -/** - * Checks if a link references a specific node - */ -function linkReferencesNode(linkMap: YAML.YAMLMap, nodeId: string): boolean { - // Check endpoints array - const endpoints = linkMap.get("endpoints", true); - if (YAML.isSeq(endpoints)) { - if (endpoints.items.some((ep) => endpointReferencesNode(ep, nodeId))) { - return true; - } - } - - // Check single endpoint - const endpoint = linkMap.get("endpoint", true); - if (YAML.isMap(endpoint)) { - return endpoint.get("node") === nodeId; - } - - return false; -} - -/** - * Updates endpoint references in a link when a node is renamed - */ -function updateEndpointReferences(ep: unknown, oldId: string, newId: string): void { - if (YAML.isScalar(ep)) { - const str = String(ep.value); - // Format: "nodeName:interface" or just "nodeName" - if (str === oldId) { - ep.value = newId; - } else if (str.startsWith(`${oldId}:`)) { - ep.value = `${newId}:${str.slice(oldId.length + 1)}`; - } - } else if (YAML.isMap(ep)) { - const epMap = ep; - if (epMap.get("node") === oldId) { - epMap.set("node", newId); - } - } -} - -/** - * Updates all link references when a node is renamed - */ -function updateLinksForRename(doc: YAML.Document.Parsed, oldId: string, newId: string): void { - const linksSeq = getYamlSeqIn(doc, ["topology", "links"]); - if (linksSeq === undefined) { - return; - } - - for (const item of linksSeq.items) { - if (!YAML.isMap(item)) continue; - const linkMap = item; - - // Check endpoints array - const endpoints = linkMap.get("endpoints", true); - if (YAML.isSeq(endpoints)) { - for (const ep of endpoints.items) { - updateEndpointReferences(ep, oldId, newId); - } - } - - // Check single endpoint (less common) - const endpoint = linkMap.get("endpoint", true); - if (YAML.isMap(endpoint)) { - if (endpoint.get("node") === oldId) { - endpoint.set("node", newId); - } - } - } -} - -/** - * Find or create a node for editing. Returns the node map and any early exit result. - */ -function findNodeForEdit( - nodesMap: YAML.YAMLMap, - originalId: string, - newName: string, - isRename: boolean, - logger: IOLogger -): { nodeMap: YAML.YAMLMap | null; earlyResult: SaveResult | null } { - const rawNode = nodesMap.get(originalId, true); - const nodeMap = YAML.isMap(rawNode) ? rawNode : undefined; - - if (nodeMap) { - return { nodeMap, earlyResult: null }; - } - - // Node doesn't exist with originalId - // For renames (undo/redo), check if target already exists (rename may have already happened) - if (isRename && nodesMap.has(newName)) { - logger.info( - `[SaveTopology] Node "${originalId}" not found, but "${newName}" exists - rename may already be applied` - ); - return { nodeMap: null, earlyResult: { success: true } }; - } - - // Node truly doesn't exist - fail for renames, create for simple edits - if (isRename) { - return { - nodeMap: null, - earlyResult: { - success: false, - error: `Cannot rename: source node "${originalId}" not found` - } - }; - } - - // For non-rename edits, create a new node - const newNodeMap = new YAML.YAMLMap(); - newNodeMap.flow = false; - nodesMap.set(newName, newNodeMap); - logger.warn(`[SaveTopology] Node "${originalId}" not found, creating new node "${newName}"`); - return { nodeMap: newNodeMap, earlyResult: null }; -} - -/** - * Adds a new node to the topology (YAML only, no annotations) - */ -export function addNodeToDoc( - doc: YAML.Document.Parsed, - nodeData: NodeSaveData, - logger: IOLogger = noopLogger -): SaveResult { - try { - const nodesMap = ensureTopologyContainers(doc); - - const nodeId = nodeData.name || nodeData.id; - if (!nodeId) { - return { success: false, error: "Node must have a name or id" }; - } - - // Check if node already exists - if (nodesMap.has(nodeId)) { - return { success: false, error: `Node "${nodeId}" already exists` }; - } - - // Create and add the node - const nodeYaml = createNodeYaml(doc, nodeData); - nodesMap.set(nodeId, nodeYaml); - - logger.info(`[SaveTopology] Added node: ${nodeId}`); - return { success: true }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: message }; - } -} - -/** - * Updates an existing node in the topology - */ -export function editNodeInDoc( - doc: YAML.Document.Parsed, - nodeData: NodeSaveData, - topoObj: ClabTopology, - logger: IOLogger = noopLogger -): SaveResult { - try { - const nodesMap = ensureTopologyContainers(doc); - - const originalId = nodeData.id; - const newName = nodeData.name || nodeData.id; - - if (!originalId) { - return { success: false, error: "Node must have an id" }; - } - - const isRename = newName !== originalId; - const { nodeMap, earlyResult } = findNodeForEdit( - nodesMap, - originalId, - newName, - isRename, - logger - ); - - if (earlyResult) { - return earlyResult; - } - - if (!nodeMap) { - return { success: false, error: "Failed to find or create node" }; - } - - const inheritedConfig = resolveInheritedConfig( - topoObj, - nodeData.extraData?.group ?? undefined, - nodeData.extraData?.kind ?? undefined - ); - - if (isRename) { - if (nodesMap.has(newName)) { - return { success: false, error: `Cannot rename: node "${newName}" already exists` }; - } - - updateNodeYaml(doc, nodeMap, nodeData, inheritedConfig); - nodesMap.set(newName, nodeMap); - nodesMap.delete(originalId); - updateLinksForRename(doc, originalId, newName); - logger.info(`[SaveTopology] Renamed node: ${originalId} -> ${newName}`); - } else { - updateNodeYaml(doc, nodeMap, nodeData, inheritedConfig); - logger.info(`[SaveTopology] Updated node: ${originalId}`); - } - - return { success: true, renamed: isRename ? { oldId: originalId, newId: newName } : undefined }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: message }; - } -} - -/** - * Removes a node from the topology - */ -export function deleteNodeFromDoc( - doc: YAML.Document.Parsed, - nodeId: string, - logger: IOLogger = noopLogger -): SaveResult { - try { - const result = getNodesMapOrError(doc); - if ("error" in result) return result.error; - const { nodesMap } = result; - - if (!nodesMap.has(nodeId)) { - return { success: false, error: `Node "${nodeId}" not found` }; - } - - nodesMap.delete(nodeId); - - // Also remove any links connected to this node - const linksSeq = getYamlSeqIn(doc, ["topology", "links"]); - if (linksSeq !== undefined) { - linksSeq.items = linksSeq.items.filter((item) => { - if (!YAML.isMap(item)) return true; - return !linkReferencesNode(item, nodeId); - }); - } - - logger.info(`[SaveTopology] Deleted node: ${nodeId}`); - return { success: true }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: message }; - } -} - -/** Apply annotation data to an annotation object */ -export function applyAnnotationData( - annotation: { - label?: string; - icon?: string; - iconColor?: string; - iconCornerRadius?: number; - labelPosition?: string; - direction?: string; - labelBackgroundColor?: string; - interfacePattern?: string; - groupId?: string; - }, - data?: NodeAnnotationData -): void { - if (!data) return; - const nullableKeys = ["label", "labelPosition", "direction", "labelBackgroundColor"] as const; - for (const key of nullableKeys) { - const value = data[key]; - if (value === null) { - delete annotation[key]; - } else if (value !== undefined) { - annotation[key] = value; - } - } - - const nonEmptyKeys = ["icon", "iconColor", "interfacePattern", "groupId"] as const; - for (const key of nonEmptyKeys) { - const value = data[key]; - if (value !== undefined && value.length > 0) { - annotation[key] = value; - } - } - - if (data.iconCornerRadius !== undefined) { - annotation.iconCornerRadius = data.iconCornerRadius; - } -} - -/** Build annotation properties for spread */ -export function buildAnnotationProps(data?: NodeAnnotationData): Record { - if (!data) return {}; - const props: Record = {}; - if (data.icon !== undefined && data.icon.length > 0) props.icon = data.icon; - if (data.iconColor !== undefined && data.iconColor.length > 0) props.iconColor = data.iconColor; - if (data.iconCornerRadius !== undefined) props.iconCornerRadius = data.iconCornerRadius; - if (data.labelPosition !== undefined) props.labelPosition = data.labelPosition; - if (data.direction !== undefined) props.direction = data.direction; - if (data.labelBackgroundColor !== undefined) - props.labelBackgroundColor = data.labelBackgroundColor; - if (data.interfacePattern !== undefined && data.interfacePattern.length > 0) { - props.interfacePattern = data.interfacePattern; - } - if (data.groupId !== undefined && data.groupId.length > 0) props.groupId = data.groupId; - return props; -} diff --git a/src/reactTopoViewer/shared/io/TopologyIO.ts b/src/reactTopoViewer/shared/io/TopologyIO.ts deleted file mode 100644 index 429cd0160..000000000 --- a/src/reactTopoViewer/shared/io/TopologyIO.ts +++ /dev/null @@ -1,647 +0,0 @@ -/** - * TopologyIO - Orchestration layer for topology persistence - * - * Combines YAML AST editing with annotations management. - * Provides batch operations and save queueing. - * Used by both VS Code extension and dev server. - */ - -import * as YAML from "yaml"; - -import type { ClabTopology, NodeAnnotation, TopologyAnnotations } from "../types/topology"; -import { applyInterfacePatternMigrations } from "../utilities"; - -import type { FileSystemAdapter, SaveResult, IOLogger } from "./types"; -import { noopLogger, ERROR_SERVICE_NOT_INIT, ERROR_NO_YAML_PATH } from "./types"; -import type { AnnotationsIO } from "./AnnotationsIO"; -import { writeYamlFile, parseYamlDocument } from "./YamlDocumentIO"; -import type { NodeSaveData, NodeAnnotationData } from "./NodePersistenceIO"; -import { - addNodeToDoc, - editNodeInDoc, - deleteNodeFromDoc, - applyAnnotationData -} from "./NodePersistenceIO"; -import type { LinkSaveData } from "./LinkPersistenceIO"; -import { addLinkToDoc, editLinkInDoc, deleteLinkFromDoc } from "./LinkPersistenceIO"; - -// Types are available from ./NodePersistenceIO and ./LinkPersistenceIO directly - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isRecordOfRecords(value: unknown): value is Record> { - if (!isRecord(value)) return false; - return Object.values(value).every((entry) => isRecord(entry)); -} - -function toOptionalString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function toOptionalNullableString(value: unknown): string | null | undefined { - if (value === null) return null; - return typeof value === "string" ? value : undefined; -} - -function toOptionalNumber(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; -} - -function parseTopologyForInheritance(raw: unknown): ClabTopology { - if (!isRecord(raw)) return {}; - const topologyRaw = raw.topology; - if (!isRecord(topologyRaw)) return {}; - - const topology: NonNullable = {}; - if (isRecord(topologyRaw.defaults)) { - topology.defaults = topologyRaw.defaults; - } - if (isRecordOfRecords(topologyRaw.kinds)) { - topology.kinds = topologyRaw.kinds; - } - if (isRecordOfRecords(topologyRaw.groups)) { - topology.groups = topologyRaw.groups; - } - - return { topology }; -} - -/** - * Helper to update position and/or geo coordinates on an annotation. - * Only updates fields that are provided - this allows GeoMap mode to update - * only geo coordinates without overwriting the preset position. - */ -function updateNodeAnnotationPosition( - annotation: { - position?: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - }, - position?: { x: number; y: number }, - geoCoordinates?: { lat: number; lng: number } -): void { - if (position) { - annotation.position = position; - } - if (geoCoordinates) { - annotation.geoCoordinates = geoCoordinates; - } -} - -/** - * Helper to create a new node annotation with position and/or geo coordinates. - * At least one of position or geoCoordinates should be provided. - */ -function createNodeAnnotationWithPosition( - id: string, - position?: { x: number; y: number }, - geoCoordinates?: { lat: number; lng: number } -): NodeAnnotation { - const annotation: NodeAnnotation = { id }; - if (position) { - annotation.position = position; - } - if (geoCoordinates) { - annotation.geoCoordinates = geoCoordinates; - } - return annotation; -} - -/** - * Options for creating a TopologyIO instance - */ -export interface TopologyIOOptions { - fs: FileSystemAdapter; - annotationsIO: AnnotationsIO; - setInternalUpdate?: (updating: boolean) => void; - logger?: IOLogger; -} - -/** - * TopologyIO - Orchestrates saving topology changes to YAML files - * - * Features: - * - Batch operations (defers saves until endBatch) - * - Save queueing to prevent concurrent writes - * - Integrated annotations management - */ -export class TopologyIO { - private fs: FileSystemAdapter; - private annotationsIO: AnnotationsIO; - private setInternalUpdate?: (updating: boolean) => void; - private logger: IOLogger; - - // State - private doc: YAML.Document.Parsed | null = null; - private yamlFilePath: string = ""; - private batchDepth = 0; - private pendingSave = false; - private saveQueue: Promise = Promise.resolve({ success: true }); - - constructor(options: TopologyIOOptions) { - this.fs = options.fs; - this.annotationsIO = options.annotationsIO; - this.setInternalUpdate = options.setInternalUpdate; - this.logger = options.logger ?? noopLogger; - } - - /** - * Initializes the service with a YAML document - */ - initialize(doc: YAML.Document.Parsed, yamlFilePath: string): void { - this.doc = doc; - this.yamlFilePath = yamlFilePath; - } - - /** - * Initializes the service by reading and parsing a YAML file - */ - async initializeFromFile(yamlFilePath: string): Promise { - try { - const content = await this.fs.readFile(yamlFilePath); - const doc = parseYamlDocument(content); - this.initialize(doc, yamlFilePath); - return { success: true }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: message }; - } - } - - /** - * Checks if the service is initialized - */ - isInitialized(): boolean { - return this.doc !== null && this.yamlFilePath !== ""; - } - - /** - * Gets the current YAML file path - */ - getYamlFilePath(): string { - return this.yamlFilePath; - } - - /** - * Gets the current YAML document - */ - getDocument(): YAML.Document.Parsed | null { - return this.doc; - } - - /** - * Begin a batch operation (defers saves until endBatch) - */ - beginBatch(): void { - this.batchDepth += 1; - } - - /** - * End a batch operation and flush pending saves - */ - async endBatch(): Promise { - if (this.batchDepth > 0) { - this.batchDepth -= 1; - } - if (this.batchDepth === 0 && this.pendingSave) { - this.pendingSave = false; - return this.save(); - } - return { success: true }; - } - - /** - * Save if not in batch mode, otherwise mark as pending - */ - private async saveMaybeDeferred(): Promise { - if (this.batchDepth > 0) { - this.pendingSave = true; - return { success: true }; - } - return this.save(); - } - - /** - * Adds a new node and saves to YAML - */ - async addNode(nodeData: NodeSaveData): Promise { - if (!this.doc) { - return { success: false, error: ERROR_SERVICE_NOT_INIT }; - } - - const result = addNodeToDoc(this.doc, nodeData, this.logger); - if (result.success) { - await this.saveMaybeDeferred(); - - // Save position and annotation data to annotations if provided - const nodeId = nodeData.name || nodeData.id; - if (nodeData.position && nodeId) { - const annotationData = this.buildNodeAnnotationData(nodeData.extraData, true); - await this.saveNodePosition(nodeId, nodeData.position, annotationData); - } - } - return result; - } - - /** - * Updates an existing node and saves to YAML - */ - async editNode(nodeData: NodeSaveData): Promise { - if (!this.doc) { - return { success: false, error: ERROR_SERVICE_NOT_INIT }; - } - - const topoObj = parseTopologyForInheritance(this.doc.toJS()); - const result = editNodeInDoc(this.doc, nodeData, topoObj, this.logger); - if (!result.success) return result; - - await this.saveMaybeDeferred(); - if (result.renamed) { - await this.renameNodeAnnotations(result.renamed.oldId, result.renamed.newId); - } - await this.persistEditedNodeAnnotations(nodeData, result.renamed); - return result; - } - - private buildNodeAnnotationData( - extraData: NodeSaveData["extraData"], - includeGroupId = false - ): NodeAnnotationData | undefined { - if (!extraData) return undefined; - return { - label: toOptionalNullableString(extraData.label), - icon: toOptionalString(extraData.topoViewerRole), - iconColor: toOptionalString(extraData.iconColor), - iconCornerRadius: toOptionalNumber(extraData.iconCornerRadius), - labelPosition: toOptionalNullableString(extraData.labelPosition), - direction: toOptionalNullableString(extraData.direction), - labelBackgroundColor: toOptionalNullableString(extraData.labelBackgroundColor), - interfacePattern: toOptionalString(extraData.interfacePattern), - groupId: includeGroupId ? toOptionalString(extraData.groupId) : undefined - }; - } - - private hasPersistableAnnotationData( - annotationData: NodeAnnotationData | undefined - ): annotationData is NodeAnnotationData { - if (!annotationData) return false; - return ( - annotationData.label !== undefined || - Boolean(annotationData.icon) || - Boolean(annotationData.iconColor) || - annotationData.iconCornerRadius !== undefined || - annotationData.labelPosition !== undefined || - annotationData.direction !== undefined || - annotationData.labelBackgroundColor !== undefined || - Boolean(annotationData.interfacePattern) - ); - } - - private async persistEditedNodeAnnotations( - nodeData: NodeSaveData, - renamed?: { oldId: string; newId: string } - ): Promise { - const nodeId = (renamed?.newId ?? nodeData.name) || nodeData.id; - if (!nodeId) return; - - const annotationData = this.buildNodeAnnotationData(nodeData.extraData); - if (!this.hasPersistableAnnotationData(annotationData)) return; - await this.saveNodeAnnotations(nodeId, annotationData); - } - - /** - * Helper to find or create a node annotation entry - */ - private ensureNodeAnnotation(annotations: TopologyAnnotations, nodeId: string): NodeAnnotation { - annotations.nodeAnnotations ??= []; - - let existing = annotations.nodeAnnotations.find((n) => n.id === nodeId); - if (!existing) { - existing = { id: nodeId }; - annotations.nodeAnnotations.push(existing); - } - return existing; - } - - /** - * Saves annotation data for a node (icon, color, etc.) without changing position - */ - private async saveNodeAnnotations( - nodeId: string, - annotationData: NodeAnnotationData - ): Promise { - await this.annotationsIO.modifyAnnotations(this.yamlFilePath, (annotations) => { - const node = this.ensureNodeAnnotation(annotations, nodeId); - applyAnnotationData(node, annotationData); - return annotations; - }); - } - - /** - * Renames a node's annotations from old ID to new ID - */ - private async renameNodeAnnotations(oldId: string, newId: string): Promise { - await this.annotationsIO.modifyAnnotations(this.yamlFilePath, (annotations) => { - if (annotations.nodeAnnotations) { - const nodeAnnotation = annotations.nodeAnnotations.find((n) => n.id === oldId); - if (nodeAnnotation) { - nodeAnnotation.id = newId; - } - } - return annotations; - }); - } - - /** - * Removes a node and saves to YAML - */ - async deleteNode(nodeId: string): Promise { - if (!this.doc) { - return { success: false, error: ERROR_SERVICE_NOT_INIT }; - } - - // Try to delete as a regular YAML node first - const result = deleteNodeFromDoc(this.doc, nodeId, this.logger); - if (result.success) { - await this.saveMaybeDeferred(); - // Also remove from annotations - await this.removeNodeAnnotations(nodeId); - return result; - } - - // If not found as a regular node, try to delete as a network node - // Network nodes (host, vxlan, dummy, etc.) are represented as links, not nodes - const networkResult = this.deleteNetworkNode(nodeId); - if (networkResult.success) { - await this.saveMaybeDeferred(); - } - // Always try to remove network node annotations, even if no links were found. - // Network nodes can exist in annotations before any links are created. - await this.removeNetworkNodeAnnotations(nodeId); - // Return success if either links were deleted OR annotations were potentially removed - return { success: true }; - } - - /** - * Deletes a network node by removing all links that reference it. - * Network nodes have IDs like: - * - host:eth0 (from host-interface property) - * - vxlan:vxlan0, vxlan-stitch:vxlan0 - * - mgmt-net:net0 - * - macvlan:0 - * - dummy0 - */ - private deleteNetworkNode(nodeId: string): SaveResult { - if (!this.doc) { - return { success: false, error: ERROR_SERVICE_NOT_INIT }; - } - - const links = this.doc.getIn(["topology", "links"], true); - if (!YAML.isSeq(links)) { - return { success: false, error: `Network node '${nodeId}' not found (no links in topology)` }; - } - const linksSeq = links; - - const initialCount = linksSeq.items.length; - linksSeq.items = linksSeq.items.filter((item) => !this.linkMatchesNetworkNode(item, nodeId)); - - const deleted = initialCount - linksSeq.items.length; - if (deleted === 0) { - return { success: false, error: `Network node '${nodeId}' not found in topology links` }; - } - - this.logger.info(`[SaveTopology] Deleted ${deleted} links for network node: ${nodeId}`); - return { success: true }; - } - - /** - * Checks if a link item matches a network node ID. - */ - private linkMatchesNetworkNode(item: unknown, nodeId: string): boolean { - if (!YAML.isMap(item)) return false; - const linkMap = item; - - const linkType = linkMap.get("type"); - if (linkType === undefined || linkType === null) return false; - const typeStr = YAML.isScalar(linkType) ? String(linkType.value) : String(linkType); - - const expectedId = this.buildExpectedNetworkNodeId(typeStr, linkMap, nodeId); - return expectedId === nodeId; - } - - /** - * Builds the expected network node ID for a link based on its type. - */ - private buildExpectedNetworkNodeId( - typeStr: string, - linkMap: YAML.YAMLMap, - nodeId: string - ): string | null { - if (typeStr === "host") { - const hostInterface = linkMap.get("host-interface"); - if (hostInterface !== undefined && hostInterface !== null) { - const ifaceStr = YAML.isScalar(hostInterface) - ? String(hostInterface.value) - : String(hostInterface); - return `host:${ifaceStr}`; - } - return null; - } - - // For counter-based types, match by prefix - const prefixMatches: Record = { - "mgmt-net": "mgmt-net:", - macvlan: "macvlan:", - vxlan: "vxlan:", - "vxlan-stitch": "vxlan-stitch:", - dummy: "dummy" - }; - - const prefix = prefixMatches[typeStr]; - if (prefix && nodeId.startsWith(prefix)) { - return nodeId; - } - - return null; - } - - /** - * Removes a node's annotations - */ - private async removeNodeAnnotations(nodeId: string): Promise { - await this.annotationsIO.modifyAnnotations(this.yamlFilePath, (annotations) => { - if (annotations.nodeAnnotations) { - annotations.nodeAnnotations = annotations.nodeAnnotations.filter((n) => n.id !== nodeId); - } - return annotations; - }); - } - - /** - * Removes a network node's annotations - */ - private async removeNetworkNodeAnnotations(nodeId: string): Promise { - await this.annotationsIO.modifyAnnotations(this.yamlFilePath, (annotations) => { - if (annotations.networkNodeAnnotations) { - annotations.networkNodeAnnotations = annotations.networkNodeAnnotations.filter( - (n) => n.id !== nodeId - ); - } - return annotations; - }); - } - - /** - * Adds a new link and saves to YAML - */ - async addLink(linkData: LinkSaveData): Promise { - if (!this.doc) { - return { success: false, error: ERROR_SERVICE_NOT_INIT }; - } - - const result = addLinkToDoc(this.doc, linkData, this.logger); - if (result.success) { - await this.saveMaybeDeferred(); - } - return result; - } - - /** - * Updates an existing link and saves to YAML - */ - async editLink(linkData: LinkSaveData): Promise { - if (!this.doc) { - return { success: false, error: ERROR_SERVICE_NOT_INIT }; - } - - const result = editLinkInDoc(this.doc, linkData, this.logger); - if (result.success) { - await this.saveMaybeDeferred(); - } - return result; - } - - /** - * Removes a link and saves to YAML - */ - async deleteLink(linkData: LinkSaveData): Promise { - if (!this.doc) { - return { success: false, error: ERROR_SERVICE_NOT_INIT }; - } - - const result = deleteLinkFromDoc(this.doc, linkData, this.logger); - if (result.success) { - await this.saveMaybeDeferred(); - } - return result; - } - - /** - * Saves the current document to disk (queued to prevent concurrent writes) - */ - async save(): Promise { - if (!this.doc) { - return { success: false, error: ERROR_SERVICE_NOT_INIT }; - } - // Queue saves to prevent concurrent writes that corrupt the file - this.saveQueue = this.saveQueue - .then(async () => { - if (!this.doc) { - return { success: false, error: ERROR_SERVICE_NOT_INIT }; - } - return writeYamlFile(this.doc, this.yamlFilePath, { - fs: this.fs, - setInternalUpdate: this.setInternalUpdate, - logger: this.logger - }); - }) - .catch(() => ({ success: false, error: "Save queue error" })); - return this.saveQueue; - } - - /** - * Saves a node's position and optional annotation data to the annotations file - */ - async saveNodePosition( - nodeId: string, - position: { x: number; y: number }, - annotationData?: NodeAnnotationData - ): Promise { - await this.annotationsIO.modifyAnnotations(this.yamlFilePath, (annotations) => { - const node = this.ensureNodeAnnotation(annotations, nodeId); - node.position = position; - applyAnnotationData(node, annotationData); - return annotations; - }); - } - - /** - * Saves multiple node positions to annotations file. - * Network nodes are saved to networkNodeAnnotations, regular nodes to nodeAnnotations. - * - * In GeoMap mode, only geoCoordinates should be provided (position is omitted) - * to avoid overwriting the preset position. - */ - async savePositions( - positions: Array<{ - id: string; - position?: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - }> - ): Promise { - if (!this.yamlFilePath) { - return { success: false, error: ERROR_NO_YAML_PATH }; - } - - try { - await this.annotationsIO.modifyAnnotations(this.yamlFilePath, (annotations) => { - annotations.nodeAnnotations ??= []; - - for (const { id, position, geoCoordinates } of positions) { - // Check if this is a network node (exists in networkNodeAnnotations) - const networkNode = annotations.networkNodeAnnotations?.find((n) => n.id === id); - if (networkNode) { - updateNodeAnnotationPosition(networkNode, position, geoCoordinates); - continue; - } - - // Update or add to nodeAnnotations - const existing = annotations.nodeAnnotations.find((n) => n.id === id); - if (existing) { - updateNodeAnnotationPosition(existing, position, geoCoordinates); - } else { - annotations.nodeAnnotations.push( - createNodeAnnotationWithPosition(id, position, geoCoordinates) - ); - } - } - - return annotations; - }); - return { success: true }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: message }; - } - } - - /** - * Migrates interface patterns to annotations for nodes that don't have them. - */ - async migrateInterfacePatterns( - migrations: Array<{ nodeId: string; interfacePattern: string }> - ): Promise { - if (migrations.length === 0) return; - - await this.annotationsIO.modifyAnnotations(this.yamlFilePath, (annotations) => { - const result = applyInterfacePatternMigrations(annotations, migrations); - - if (result.modified) { - this.logger.info(`[TopologyIO] Migrated interface patterns for ${migrations.length} nodes`); - } - - return result.annotations; - }); - } -} diff --git a/src/reactTopoViewer/shared/io/TransactionalFileSystemAdapter.ts b/src/reactTopoViewer/shared/io/TransactionalFileSystemAdapter.ts deleted file mode 100644 index a1ea8f8bb..000000000 --- a/src/reactTopoViewer/shared/io/TransactionalFileSystemAdapter.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * TransactionalFileSystemAdapter - * - * Buffers writes/deletes in memory and commits them atomically (best-effort) - * using temp files + rename. This allows multi-file updates (YAML + annotations) - * to behave as a single transaction from the host's perspective. - */ - -import { randomUUID } from "crypto"; - -import type { FileSystemAdapter } from "./types"; - -type PendingEntry = { path: string; content: string | null }; - -export class TransactionalFileSystemAdapter implements FileSystemAdapter { - private base: FileSystemAdapter; - private inTransaction = false; - private pending = new Map(); - - constructor(base: FileSystemAdapter) { - this.base = base; - } - - beginTransaction(): void { - if (this.inTransaction) return; - this.inTransaction = true; - } - - async commitTransaction(): Promise { - if (!this.inTransaction) return; - const entries: PendingEntry[] = Array.from(this.pending.entries()).map(([path, content]) => ({ - path, - content - })); - this.pending.clear(); - this.inTransaction = false; - if (entries.length === 0) return; - await this.commitEntries(entries); - } - - rollbackTransaction(): void { - this.pending.clear(); - this.inTransaction = false; - } - - isInTransaction(): boolean { - return this.inTransaction; - } - - // --------------------------------------------------------------------------- - // FileSystemAdapter implementation - // --------------------------------------------------------------------------- - - async readFile(filePath: string): Promise { - if (this.inTransaction && this.pending.has(filePath)) { - const value = this.pending.get(filePath); - if (value === null) { - throw new Error(`ENOENT: no such file ${filePath}`); - } - if (value === undefined) { - throw new Error(`Missing pending entry for ${filePath}`); - } - return value; - } - return this.base.readFile(filePath); - } - - async writeFile(filePath: string, content: string): Promise { - if (this.inTransaction) { - this.pending.set(filePath, content); - return; - } - await this.base.writeFile(filePath, content); - } - - async unlink(filePath: string): Promise { - if (this.inTransaction) { - this.pending.set(filePath, null); - return; - } - await this.base.unlink(filePath); - } - - async rename(oldPath: string, newPath: string): Promise { - if (this.inTransaction) { - const content = await this.readFile(oldPath); - this.pending.set(newPath, content); - this.pending.set(oldPath, null); - return; - } - await this.base.rename(oldPath, newPath); - } - - async exists(filePath: string): Promise { - if (this.inTransaction && this.pending.has(filePath)) { - return this.pending.get(filePath) !== null; - } - return this.base.exists(filePath); - } - - dirname(filePath: string): string { - return this.base.dirname(filePath); - } - - basename(filePath: string): string { - return this.base.basename(filePath); - } - - join(...segments: string[]): string { - return this.base.join(...segments); - } - - // --------------------------------------------------------------------------- - // Commit logic - // --------------------------------------------------------------------------- - - private buildTempPath(dir: string, base: string, prefix: "tmp" | "bak"): string { - return this.base.join(dir, `.${prefix}-${randomUUID()}-${base}`); - } - - private async writeTempFiles(entries: PendingEntry[]): Promise> { - const tempFiles = new Map(); - for (const entry of entries) { - if (entry.content === null) continue; - const dir = this.base.dirname(entry.path); - const base = this.base.basename(entry.path); - const tempPath = this.buildTempPath(dir, base, "tmp"); - await this.base.writeFile(tempPath, entry.content); - tempFiles.set(entry.path, tempPath); - } - return tempFiles; - } - - private async createBackups(entries: PendingEntry[]): Promise> { - const backups = new Map(); - for (const entry of entries) { - const exists = await this.base.exists(entry.path); - if (!exists) continue; - const dir = this.base.dirname(entry.path); - const base = this.base.basename(entry.path); - const backupPath = this.buildTempPath(dir, base, "bak"); - await this.base.rename(entry.path, backupPath); - backups.set(entry.path, backupPath); - } - return backups; - } - - private async applyWrites( - entries: PendingEntry[], - tempFiles: Map - ): Promise { - for (const entry of entries) { - if (entry.content === null) continue; - const tempPath = tempFiles.get(entry.path); - if (tempPath === undefined || tempPath.length === 0) { - throw new Error(`Missing temp file for ${entry.path}`); - } - await this.base.rename(tempPath, entry.path); - } - } - - private async cleanupBackups(backups: Map): Promise { - for (const backupPath of backups.values()) { - await this.base.unlink(backupPath); - } - } - - private async restoreBackups(backups: Map): Promise { - for (const [targetPath, backupPath] of backups) { - try { - const stillMissing = !(await this.base.exists(targetPath)); - if (stillMissing) { - await this.base.rename(backupPath, targetPath); - } - } catch { - // ignore rollback errors - } - } - } - - private async cleanupTempFiles(tempFiles: Map): Promise { - for (const tempPath of tempFiles.values()) { - try { - await this.base.unlink(tempPath); - } catch { - // ignore cleanup errors - } - } - } - - private async commitEntries(entries: PendingEntry[]): Promise { - if (entries.length === 0) return; - - let tempFiles = new Map(); - let backups = new Map(); - - try { - // 1) Write temp files for all writes. - tempFiles = await this.writeTempFiles(entries); - - // 2) Move existing targets to backups. - backups = await this.createBackups(entries); - - // 3) Apply writes (rename temp -> target). Deletes are handled by backup cleanup. - await this.applyWrites(entries, tempFiles); - - // 4) Clean up backups (delete original files that were replaced or deleted). - await this.cleanupBackups(backups); - } catch (err) { - // Best-effort rollback: restore backups and clean temp files. - await this.restoreBackups(backups); - await this.cleanupTempFiles(tempFiles); - throw err; - } - } -} diff --git a/src/reactTopoViewer/shared/io/YamlDocumentIO.ts b/src/reactTopoViewer/shared/io/YamlDocumentIO.ts deleted file mode 100644 index 56e9a74db..000000000 --- a/src/reactTopoViewer/shared/io/YamlDocumentIO.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * YamlDocumentIO - YAML AST utilities for document manipulation - * - * Provides functions for working with YAML documents using the yaml library's - * Document.Parsed AST, which preserves comments and formatting. - */ - -import * as YAML from "yaml"; - -import type { FileSystemAdapter, SaveResult, IOLogger } from "./types"; -import { noopLogger } from "./types"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Creates a YAML scalar with double quotes for endpoint values - */ -export function createQuotedScalar(doc: YAML.Document, value: string): YAML.Scalar { - const scalar = doc.createNode(value) as YAML.Scalar; - scalar.type = "QUOTE_DOUBLE"; - return scalar; -} - -/** - * Checks if two objects are structurally equal (ignoring key order) - */ -export function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (typeof a !== typeof b) return false; - if (a === null || b === null) return a === b; - - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) return false; - return a.every((item, i) => deepEqual(item, b[i])); - } - - if (isRecord(a) && isRecord(b)) { - const aKeys = Object.keys(a).sort(); - const bKeys = Object.keys(b).sort(); - if (aKeys.length !== bKeys.length) return false; - return aKeys.every((key, i) => key === bKeys[i] && deepEqual(a[key], b[key])); - } - - return false; -} - -/** - * Checks if a value should be persisted (not empty/undefined) - */ -export function shouldPersist(value: unknown): boolean { - if (value === undefined || value === null || value === "") return false; - if (Array.isArray(value)) return value.length > 0; - if (typeof value === "object") return Object.keys(value).length > 0; - return true; -} - -/** - * Sets or deletes a key in a YAML map based on the value - */ -export function setOrDelete( - doc: YAML.Document, - map: YAML.YAMLMap, - key: string, - value: unknown -): void { - if (!shouldPersist(value)) { - if (map.has(key)) map.delete(key); - return; - } - map.set(key, doc.createNode(value)); -} - -/** - * Parse YAML content to AST Document - */ -export function parseYamlDocument(content: string): YAML.Document.Parsed { - return YAML.parseDocument(content); -} - -/** - * Stringify YAML AST to string (preserves comments and formatting) - */ -export function stringifyYamlDocument(doc: YAML.Document.Parsed): string { - return doc.toString(); -} - -/** - * Options for writing YAML files - */ -export interface YamlWriteOptions { - fs: FileSystemAdapter; - setInternalUpdate?: (updating: boolean) => void; - logger?: IOLogger; -} - -/** - * Write YAML document to file with deduplication and internal update flag - */ -export async function writeYamlFile( - doc: YAML.Document.Parsed, - yamlFilePath: string, - options: YamlWriteOptions -): Promise { - const { fs, setInternalUpdate, logger = noopLogger } = options; - - try { - const newContent = doc.toString(); - - // Compare with existing content to avoid unnecessary writes - try { - const existingContent = await fs.readFile(yamlFilePath); - if (existingContent === newContent) { - logger.info("[SaveTopology] No changes detected, skipping write"); - return { success: true }; - } - } catch { - // File might not exist, which is fine - } - - // Write with internal update flag to prevent file watcher loops - if (setInternalUpdate) { - setInternalUpdate(true); - } - - await fs.writeFile(yamlFilePath, newContent); - - if (setInternalUpdate) { - // Small delay before clearing flag - await new Promise((resolve) => setTimeout(resolve, 50)); - setInternalUpdate(false); - } - - logger.info(`[SaveTopology] Saved YAML to: ${yamlFilePath}`); - return { success: true }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: message }; - } -} diff --git a/src/reactTopoViewer/shared/io/index.ts b/src/reactTopoViewer/shared/io/index.ts deleted file mode 100644 index aadcf1d48..000000000 --- a/src/reactTopoViewer/shared/io/index.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Shared I/O module for both VS Code extension and dev server - * - * This module provides a unified I/O layer for: - * - Annotations JSON files (with caching, queuing, locks) - * - YAML topology files (AST-based editing to preserve comments) - * - * Usage: - * - VS Code extension: Use NodeFsAdapter for direct file operations - * - Dev server: Use SessionFsAdapter for session-based test isolation - */ - -// Types -export type { FileSystemAdapter, SaveResult, IOLogger } from "./types"; -export { - noopLogger, - ERROR_NODES_NOT_MAP, - ERROR_LINKS_NOT_SEQ, - ERROR_SERVICE_NOT_INIT, - ERROR_NO_YAML_PATH -} from "./types"; - -// Re-export TopologyAnnotations from types module -export type { TopologyAnnotations } from "../types/topology"; - -// File system adapters -export { NodeFsAdapter, nodeFsAdapter } from "./NodeFsAdapter"; -export { TransactionalFileSystemAdapter } from "./TransactionalFileSystemAdapter"; - -// YAML utilities -export type { YamlWriteOptions } from "./YamlDocumentIO"; -export { - createQuotedScalar, - deepEqual, - shouldPersist, - setOrDelete, - parseYamlDocument, - stringifyYamlDocument, - writeYamlFile -} from "./YamlDocumentIO"; - -// Annotations I/O -export type { AnnotationsIOOptions } from "./AnnotationsIO"; -export { AnnotationsIO } from "./AnnotationsIO"; -export { createEmptyAnnotations } from "../annotations/types"; - -// Node persistence -export type { NodeSaveData, NodeAnnotationData } from "./NodePersistenceIO"; -export { - resolveInheritedConfig, - addNodeToDoc, - editNodeInDoc, - deleteNodeFromDoc, - applyAnnotationData, - buildAnnotationProps -} from "./NodePersistenceIO"; - -// Link persistence -export type { LinkSaveData } from "./LinkPersistenceIO"; -export { addLinkToDoc, editLinkInDoc, deleteLinkFromDoc } from "./LinkPersistenceIO"; - -// Topology I/O orchestration -export type { TopologyIOOptions } from "./TopologyIO"; -export { TopologyIO } from "./TopologyIO"; diff --git a/src/reactTopoViewer/shared/io/types.ts b/src/reactTopoViewer/shared/io/types.ts deleted file mode 100644 index 68699e062..000000000 --- a/src/reactTopoViewer/shared/io/types.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Shared I/O types for both VS Code extension and dev server - */ - -/** - * Result of a save operation - */ -export interface SaveResult { - success: boolean; - error?: string; - /** If a node was renamed, contains the old and new IDs */ - renamed?: { oldId: string; newId: string }; -} - -/** - * Logger interface for I/O operations - */ -export interface IOLogger { - debug: (msg: string) => void; - info: (msg: string) => void; - warn: (msg: string) => void; - error: (msg: string) => void; -} - -/** - * No-op logger for when logging is not needed - */ -export const noopLogger: IOLogger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} -}; - -/** - * FileSystemAdapter - Abstraction for file system operations - * - * This allows the same I/O logic to work in both: - * - VS Code extension (using Node.js fs.promises directly) - * - Dev server (using session-based in-memory storage for test isolation) - */ -export interface FileSystemAdapter { - /** - * Read file as UTF-8 string. - * @throws Error if file doesn't exist - */ - readFile(filePath: string): Promise; - - /** - * Write content to file (UTF-8). - * Creates parent directories if needed. - */ - writeFile(filePath: string, content: string): Promise; - - /** - * Delete file. - * Should not throw if file doesn't exist. - */ - unlink(filePath: string): Promise; - - /** - * Rename (or move) a file. - * Should replace the destination if supported by the platform. - */ - rename(oldPath: string, newPath: string): Promise; - - /** - * Check if file exists - */ - exists(filePath: string): Promise; - - /** - * Get directory path from file path - */ - dirname(filePath: string): string; - - /** - * Get filename from file path - */ - basename(filePath: string): string; - - /** - * Join path segments - */ - join(...segments: string[]): string; -} - -/** Common error messages */ -export const ERROR_NODES_NOT_MAP = "YAML topology.nodes is not a map"; -export const ERROR_LINKS_NOT_SEQ = "YAML topology.links is not a sequence"; -export const ERROR_SERVICE_NOT_INIT = "Service not initialized"; -export const ERROR_NO_YAML_PATH = "No YAML file path set"; diff --git a/src/reactTopoViewer/shared/messages/extension.ts b/src/reactTopoViewer/shared/messages/extension.ts deleted file mode 100644 index e1bc85838..000000000 --- a/src/reactTopoViewer/shared/messages/extension.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Types of postMessage commands that target the extension. - * Used by the message router. - */ - -/** Lifecycle commands */ -export const LIFECYCLE_COMMANDS = { - DEPLOY_LAB: "deployLab", - DESTROY_LAB: "destroyLab", - REDEPLOY_LAB: "redeployLab", - DEPLOY_LAB_CLEANUP: "deployLabCleanup", - DESTROY_LAB_CLEANUP: "destroyLabCleanup", - REDEPLOY_LAB_CLEANUP: "redeployLabCleanup" -} as const; - -export const MSG_CANCEL_LAB_LIFECYCLE = "cancelLabLifecycle" as const; - -const LIFECYCLE_COMMANDS_SET: ReadonlySet = new Set(Object.values(LIFECYCLE_COMMANDS)); - -export type LifecycleCommand = (typeof LIFECYCLE_COMMANDS)[keyof typeof LIFECYCLE_COMMANDS]; - -export function isLifecycleCommand(command: string): command is LifecycleCommand { - return LIFECYCLE_COMMANDS_SET.has(command); -} - -/** Node commands */ -export const NODE_COMMANDS = { - NODE_CONNECT_SSH: "clab-node-connect-ssh", - NODE_ATTACH_SHELL: "clab-node-attach-shell", - NODE_VIEW_LOG: "clab-node-view-logs" -} as const; - -const NODE_COMMANDS_SET: ReadonlySet = new Set(Object.values(NODE_COMMANDS)); - -export type NodeCommand = (typeof NODE_COMMANDS)[keyof typeof NODE_COMMANDS]; - -export function isNodeCommand(command: string): command is NodeCommand { - return NODE_COMMANDS_SET.has(command); -} - -/** Interface commands */ -export const INTERFACE_COMMANDS = { - INTERFACE_CAPTURE: "clab-interface-capture", - LINK_IMPAIRMENT: "clab-link-impairment" -} as const; - -const INTERFACE_COMMANDS_SET: ReadonlySet = new Set(Object.values(INTERFACE_COMMANDS)); - -export type InterfaceCommand = (typeof INTERFACE_COMMANDS)[keyof typeof INTERFACE_COMMANDS]; - -export function isInterfaceCommand(command: string): command is InterfaceCommand { - return INTERFACE_COMMANDS_SET.has(command); -} - -/** Custom node commands */ -export const CUSTOM_NODE_COMMANDS = { - SAVE_CUSTOM_NODE: "save-custom-node", - DELETE_CUSTOM_NODE: "delete-custom-node", - SET_DEFAULT_CUSTOM_NODE: "set-default-custom-node" -} as const; - -const CUSTOM_NODE_COMMANDS_SET: ReadonlySet = new Set(Object.values(CUSTOM_NODE_COMMANDS)); - -export type CustomNodeCommand = (typeof CUSTOM_NODE_COMMANDS)[keyof typeof CUSTOM_NODE_COMMANDS]; - -export function isCustomNodeCommand(command: string): command is CustomNodeCommand { - return CUSTOM_NODE_COMMANDS_SET.has(command); -} - -/** Icon commands */ -export const ICON_COMMANDS = { - ICON_LIST: "icon-list", - ICON_UPLOAD: "icon-upload", - ICON_DELETE: "icon-delete", - ICON_RECONCILE: "icon-reconcile" -} as const; - -const ICON_COMMANDS_SET: ReadonlySet = new Set(Object.values(ICON_COMMANDS)); - -export type IconCommand = (typeof ICON_COMMANDS)[keyof typeof ICON_COMMANDS]; - -export function isIconCommand(command: string): command is IconCommand { - return ICON_COMMANDS_SET.has(command); -} - -/** Export commands */ -export const EXPORT_COMMANDS = { - EXPORT_SVG_GRAFANA_BUNDLE: "export-svg-grafana-bundle" -} as const; - -const EXPORT_COMMANDS_SET: ReadonlySet = new Set(Object.values(EXPORT_COMMANDS)); - -export type ExportCommand = (typeof EXPORT_COMMANDS)[keyof typeof EXPORT_COMMANDS]; - -export function isExportCommand(command: string): command is ExportCommand { - return EXPORT_COMMANDS_SET.has(command); -} - -export const MSG_TOGGLE_SPLIT_VIEW = "topo-toggle-split-view" as const; - -export type ExtensionCommandType = - | LifecycleCommand - | NodeCommand - | InterfaceCommand - | CustomNodeCommand - | IconCommand - | ExportCommand - | typeof MSG_TOGGLE_SPLIT_VIEW - | typeof MSG_CANCEL_LAB_LIFECYCLE; diff --git a/src/reactTopoViewer/shared/messages/webview.ts b/src/reactTopoViewer/shared/messages/webview.ts deleted file mode 100644 index 67f53f002..000000000 --- a/src/reactTopoViewer/shared/messages/webview.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Types of postMessage commands that target webview. - */ - -/** Message type for topology data updates sent to webview */ -export const MSG_TOPOLOGY_DATA = "topology-data"; - -/** Message type for incremental edge stats updates */ -export const MSG_EDGE_STATS_UPDATE = "edge-stats-update"; - -/** Message type for external file change */ -export const MSG_EXTERNAL_FILE_CHANGE = "external-file-change"; - -/** Message type for view/edit mode change */ -export const MSG_TOPO_MODE_CHANGE = "topo-mode-changed"; - -/** Message type to update node name in place after change */ -export const MSG_NODE_RENAMED = "node-renamed"; - -/** Message type for updating node data */ -export const MSG_NODE_DATA_UPDATED = "node-data-updated"; - -/** Message type to request fit-to-viewport in webview */ -export const MSG_FIT_VIEWPORT = "fit-viewport"; - -export const MSG_PANEL_ACTION = "panel-action"; - -export const MSG_CUSTOM_NODE_UPDATED = "custom-nodes-updated"; - -export const MSG_CUSTOM_NODE_ERROR = "custom-node-error"; - -export const MSG_ICON_LIST_RESPONSE = "icon-list-response"; - -export const MSG_LAB_LIFECYCLE_STATUS = "lab-lifecycle-status"; -export const MSG_LAB_LIFECYCLE_LOG = "lab-lifecycle-log"; - -export const MSG_SVG_EXPORT_RESULT = "svg-export-result"; - -export type WebviewMessageType = - | typeof MSG_TOPOLOGY_DATA - | typeof MSG_EDGE_STATS_UPDATE - | typeof MSG_EXTERNAL_FILE_CHANGE - | typeof MSG_TOPO_MODE_CHANGE - | typeof MSG_NODE_RENAMED - | typeof MSG_NODE_DATA_UPDATED - | typeof MSG_FIT_VIEWPORT - | typeof MSG_PANEL_ACTION - | typeof MSG_CUSTOM_NODE_UPDATED - | typeof MSG_CUSTOM_NODE_ERROR - | typeof MSG_ICON_LIST_RESPONSE - | typeof MSG_LAB_LIFECYCLE_STATUS - | typeof MSG_LAB_LIFECYCLE_LOG - | typeof MSG_SVG_EXPORT_RESULT; diff --git a/src/reactTopoViewer/shared/parsing/AliasNodeHandler.ts b/src/reactTopoViewer/shared/parsing/AliasNodeHandler.ts deleted file mode 100644 index f13a5c70b..000000000 --- a/src/reactTopoViewer/shared/parsing/AliasNodeHandler.ts +++ /dev/null @@ -1,420 +0,0 @@ -/** - * Alias node handler for managing visual alias nodes (e.g., multiple bridge instances). - * Pure functions - no VS Code dependencies. - */ - -import type { - ParsedElement, - ClabTopology, - TopologyAnnotations, - NodeAnnotation -} from "../types/topology"; - -import { NODE_KIND_BRIDGE, NODE_KIND_OVS_BRIDGE } from "./LinkNormalizer"; -import type { ParserLogger } from "./types"; -import { nullLogger } from "./types"; - -export const CLASS_ALIASED_BASE_BRIDGE = "aliased-base-bridge" as const; - -export interface AliasEntry { - yamlNodeId: string; - interface: string; - aliasNodeId: string; -} - -// ============================================================================ -// Utility Functions -// ============================================================================ - -/** - * Checks if a kind is a bridge type. - */ -export function isBridgeKind(kind: string | undefined): boolean { - return kind === NODE_KIND_BRIDGE || kind === NODE_KIND_OVS_BRIDGE; -} - -/** - * Builds an index of node annotations by ID. - */ -export function buildNodeAnnotationIndex( - annotations?: TopologyAnnotations -): Map { - const m = new Map(); - const nodeAnns = annotations?.nodeAnnotations; - if (!Array.isArray(nodeAnns)) return m; - for (const na of nodeAnns) { - if (typeof na.id === "string") m.set(na.id, na); - } - return m; -} - -/** - * Safely converts a value to a trimmed string. - */ -export function asTrimmedString(val: unknown): string { - return typeof val === "string" ? val.trim() : ""; -} - -/** - * Converts annotation to a position object. - */ -export function toPosition(ann: NodeAnnotation | undefined): { x: number; y: number } { - const pos = ann?.position; - if (pos && typeof pos.x === "number" && typeof pos.y === "number") { - return { x: pos.x, y: pos.y }; - } - return { x: 0, y: 0 }; -} - -// ============================================================================ -// Alias Entry Collection -// ============================================================================ - -/** - * Collects alias entries from node annotations. - */ -export function collectAliasEntriesNew(annotations?: TopologyAnnotations): AliasEntry[] { - const nodeAnns = annotations?.nodeAnnotations; - if (!Array.isArray(nodeAnns)) return []; - const out: AliasEntry[] = []; - for (const na of nodeAnns) { - const aliasId = asTrimmedString(na.id); - const yamlId = asTrimmedString(na.yamlNodeId); - const iface = asTrimmedString(na.yamlInterface); - if (!aliasId || !yamlId || !iface) continue; - if (aliasId === yamlId) continue; - out.push({ yamlNodeId: yamlId, interface: iface, aliasNodeId: aliasId }); - } - return out; -} - -/** - * Lists alias entries from node annotations. - */ -export function listAliasEntriesFromNodeAnnotations( - annotations?: TopologyAnnotations -): AliasEntry[] { - return collectAliasEntriesNew(annotations); -} - -/** - * Normalizes annotations to alias list. - */ -export function normalizeAliasList(annotations?: TopologyAnnotations): AliasEntry[] { - return listAliasEntriesFromNodeAnnotations(annotations); -} - -/** - * Builds a map from yamlNodeId|interface to aliasNodeId. - */ -export function buildAliasMap(list: AliasEntry[]): Map { - const map = new Map(); - for (const a of list) map.set(`${a.yamlNodeId}|${a.interface}`, a.aliasNodeId); - return map; -} - -// ============================================================================ -// Alias Node Creation -// ============================================================================ - -/** - * Derives alias placement from annotations. - */ -export function deriveAliasPlacement( - aliasAnn: NodeAnnotation | undefined, - baseAnn: NodeAnnotation | undefined -): { position: { x: number; y: number } } { - if (aliasAnn) return { position: toPosition(aliasAnn) }; - if (baseAnn) return { position: toPosition(baseAnn) }; - return { position: { x: 0, y: 0 } }; -} - -/** - * Builds a bridge alias element. - */ -export function buildBridgeAliasElement( - aliasId: string, - kind: string, - position: { x: number; y: number }, - yamlRefId: string, - displayName: string -): ParsedElement { - return { - group: "nodes", - data: { - id: aliasId, - weight: "30", - name: displayName, - topoViewerRole: "bridge", - lat: "", - lng: "", - extraData: { - clabServerUsername: "", - fqdn: "", - group: "", - id: aliasId, - image: "", - index: "999", - kind, - type: kind, - labdir: "", - labels: {}, - longname: displayName, - macAddress: "", - mgmtIntf: "", - mgmtIpv4AddressLength: 0, - mgmtIpv4Address: "", - mgmtIpv6Address: "", - mgmtIpv6AddressLength: 0, - mgmtNet: "", - name: displayName, - shortname: displayName, - state: "", - weight: "3", - extYamlNodeId: yamlRefId - } - }, - position, - removed: false, - selected: false, - selectable: true, - locked: false, - grabbed: false, - grabbable: true, - classes: "" - }; -} - -/** - * Creates an alias element. - */ -export function createAliasElement( - nodeMap: Partial>, - aliasId: string, - yamlRefId: string, - nodeAnnById: Map -): ParsedElement | null { - const refNode = nodeMap[yamlRefId]; - if (refNode === undefined || !isBridgeKind(refNode.kind)) return null; - const aliasAnn = nodeAnnById.get(aliasId); - const baseAnn = nodeAnnById.get(yamlRefId); - const { position } = deriveAliasPlacement(aliasAnn, baseAnn); - const aliasDisplayName = - aliasAnn && typeof aliasAnn.label === "string" && aliasAnn.label.trim() - ? aliasAnn.label.trim() - : aliasId; - return buildBridgeAliasElement( - aliasId, - refNode.kind ?? NODE_KIND_BRIDGE, - position, - yamlRefId, - aliasDisplayName - ); -} - -function getAliasElementFromEntry( - entry: AliasEntry, - created: Set, - nodeMap: Partial>, - nodeAnnById: Map -): { aliasId: string; element: ParsedElement } | null { - const aliasId = String(entry.aliasNodeId); - const yamlRefId = String(entry.yamlNodeId); - if (created.has(aliasId)) return null; - if (aliasId === yamlRefId) return null; - const element = createAliasElement(nodeMap, aliasId, yamlRefId, nodeAnnById); - if (!element) return null; - return { aliasId, element }; -} - -function appendAliasElements( - aliasList: AliasEntry[], - created: Set, - nodeMap: Partial>, - nodeAnnById: Map, - result: ParsedElement[] -): void { - for (const entry of aliasList) { - const next = getAliasElementFromEntry(entry, created, nodeMap, nodeAnnById); - if (!next) continue; - result.push(next.element); - created.add(next.aliasId); - } -} - -function applyAliasToEdgeData( - data: { - source?: string; - target?: string; - sourceEndpoint?: string; - targetEndpoint?: string; - extraData?: Record; - }, - srcAlias: string | undefined, - tgtAlias: string | undefined -): void { - const originalSource = data.source; - const originalTarget = data.target; - const extra = { ...(data.extraData ?? {}) }; - if (srcAlias !== undefined && srcAlias.length > 0) { - data.source = srcAlias; - if (originalSource !== undefined && originalSource.length > 0) { - extra.yamlSourceNodeId = originalSource; - } - } - if (tgtAlias !== undefined && tgtAlias.length > 0) { - data.target = tgtAlias; - if (originalTarget !== undefined && originalTarget.length > 0) { - extra.yamlTargetNodeId = originalTarget; - } - } - data.extraData = extra; -} - -/** - * Adds alias nodes from annotations to the elements array. - */ -export function addAliasNodesFromAnnotations( - parsed: ClabTopology, - annotations?: TopologyAnnotations, - elements?: ParsedElement[] -): ParsedElement[] { - const result = elements ?? []; - const nodeMap = parsed.topology?.nodes ?? {}; - const nodeAnnById = buildNodeAnnotationIndex(annotations); - const aliasList = listAliasEntriesFromNodeAnnotations(annotations); - if (aliasList.length === 0) return result; - - const created = new Set(); - appendAliasElements(aliasList, created, nodeMap, nodeAnnById, result); - return result; -} - -// ============================================================================ -// Edge Rewiring -// ============================================================================ - -/** - * Rewires edges to use alias node IDs. - */ -export function rewireEdges(elements: ParsedElement[], mapping: Map): void { - for (const el of elements) { - if (el.group !== "edges") continue; - const data = el.data as { - source?: string; - target?: string; - sourceEndpoint?: string; - targetEndpoint?: string; - extraData?: Record; - }; - const srcAlias = mapping.get(`${data.source}|${data.sourceEndpoint ?? ""}`); - const tgtAlias = mapping.get(`${data.target}|${data.targetEndpoint ?? ""}`); - if ( - (srcAlias === undefined || srcAlias.length === 0) && - (tgtAlias === undefined || tgtAlias.length === 0) - ) { - continue; - } - applyAliasToEdgeData(data, srcAlias, tgtAlias); - } -} - -/** - * Applies alias mappings to edges. - */ -export function applyAliasMappingsToEdges( - annotations?: TopologyAnnotations, - elements?: ParsedElement[] -): void { - if (!elements) return; - const aliasList = normalizeAliasList(annotations); - if (aliasList.length === 0) return; - const mapping = buildAliasMap(aliasList); - rewireEdges(elements, mapping); -} - -// ============================================================================ -// Base Bridge Hiding -// ============================================================================ - -/** - * Collects alias groups from elements. - */ -export function collectAliasGroups(elements: ParsedElement[]): Map { - const groups = new Map(); - for (const el of elements) { - if (el.group !== "nodes") continue; - const data = el.data as { id?: string; extraData?: { extYamlNodeId?: string; kind?: string } }; - const extra = data.extraData ?? {}; - const yamlRef = typeof extra.extYamlNodeId === "string" ? extra.extYamlNodeId.trim() : ""; - const kind = String(extra.kind ?? ""); - if (!yamlRef || yamlRef === data.id) continue; - if (!isBridgeKind(kind)) continue; - const list = groups.get(yamlRef) ?? []; - list.push(String(data.id)); - groups.set(yamlRef, list); - } - return groups; -} - -/** - * Collects base bridges that are still referenced by edges. - */ -export function collectStillReferencedBaseBridges( - elements: ParsedElement[], - aliasGroups: Map -): Set { - const stillReferenced = new Set(); - for (const el of elements) { - if (el.group !== "edges") continue; - const d = el.data as { source?: string; target?: string }; - const s = String(d.source ?? ""); - const t = String(d.target ?? ""); - if (aliasGroups.has(s)) stillReferenced.add(s); - if (aliasGroups.has(t)) stillReferenced.add(t); - } - return stillReferenced; -} - -/** - * Adds a class to a node element. - */ -export function addClass(nodeEl: ParsedElement, className: string): void { - const existing = nodeEl.classes; - if (typeof existing !== "string" || existing.length === 0) { - nodeEl.classes = className; - } else if (!existing.includes(className)) { - nodeEl.classes = `${existing} ${className}`; - } -} - -/** - * Hides base bridge nodes that have aliases. - */ -export function hideBaseBridgeNodesWithAliases( - elements: ParsedElement[], - loggedUnmappedBaseBridges: Set, - logger?: ParserLogger -): void { - const log = logger ?? nullLogger; - const aliasGroups = collectAliasGroups(elements); - if (aliasGroups.size === 0) return; - const stillReferenced = collectStillReferencedBaseBridges(elements, aliasGroups); - - for (const el of elements) { - if (el.group !== "nodes") continue; - const data = el.data as { id?: string; extraData?: { kind?: string } }; - const id = String(data.id ?? ""); - if (!aliasGroups.has(id)) continue; - if (stillReferenced.has(id)) { - if (!loggedUnmappedBaseBridges.has(id)) { - log.info(`Base bridge '${id}' has unmapped links; keeping node visible until mapped.`); - loggedUnmappedBaseBridges.add(id); - } - continue; - } - const kind = data.extraData?.kind; - if (!isBridgeKind(kind)) continue; - addClass(el, CLASS_ALIASED_BASE_BRIDGE); - } -} diff --git a/src/reactTopoViewer/shared/parsing/DistributedSrosMapper.ts b/src/reactTopoViewer/shared/parsing/DistributedSrosMapper.ts deleted file mode 100644 index 288feea36..000000000 --- a/src/reactTopoViewer/shared/parsing/DistributedSrosMapper.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * Handler for distributed SROS (Nokia SR SIM) nodes. - * Pure functions - no VS Code dependencies. - */ - -import type { ClabNode } from "../types/topology"; - -import type { ContainerDataProvider, ContainerInfo, InterfaceInfo } from "./types"; - -function asRecord(value: unknown): Record { - if (value === null || typeof value !== "object" || Array.isArray(value)) return {}; - return Object.fromEntries(Object.entries(value)); -} - -/** - * Checks if a node is a distributed SROS node (nokia_srsim with components). - */ -export function isDistributedSrosNode(node: ClabNode | undefined): boolean { - if (node === undefined) return false; - if (node.kind !== "nokia_srsim") return false; - const components = asRecord(node).components; - return Array.isArray(components) && components.length > 0; -} - -/** - * Maps SROS interface names to their container interface names. - * Converts format like "1/x2/3/c4/5" to "e1-x2-3-c4-5". - */ -export function mapSrosInterfaceName(ifaceName: string): string | undefined { - if (!ifaceName) return undefined; - const trimmed = ifaceName.trim(); - if (!trimmed) return undefined; - if (trimmed.startsWith("eth")) { - return trimmed; - } - const regex = /^(\d+)\/(?:x(\d+)\/)?(\d+)(?:\/c(\d+))?\/(\d+)$/; - const res = regex.exec(trimmed); - if (!res) { - return undefined; - } - const [, card, xiom, mda, connector, port] = res; - if (!card || !mda || !port) { - return undefined; - } - if (xiom && connector) { - return `e${card}-x${xiom}-${mda}-c${connector}-${port}`; - } - if (xiom) { - return `e${card}-x${xiom}-${mda}-${port}`; - } - if (connector) { - return `e${card}-${mda}-c${connector}-${port}`; - } - return `e${card}-${mda}-${port}`; -} - -/** - * Gets candidate interface names for matching. - */ -export function getCandidateInterfaceNames(ifaceName: string): string[] { - const unique = new Set(); - if (ifaceName.length > 0) { - unique.add(ifaceName); - } - const mapped = mapSrosInterfaceName(ifaceName); - if (mapped !== undefined && mapped.length > 0) { - unique.add(mapped); - } - return Array.from(unique); -} - -/** - * Matches an interface in a container. - */ -export function matchInterfaceInContainer( - container: ContainerInfo, - ifaceName: string -): InterfaceInfo | undefined { - const candidates = getCandidateInterfaceNames(ifaceName); - for (const iface of container.interfaces) { - const labelStr = container.label ?? ""; - if ( - candidates.includes(iface.name) || - candidates.includes(iface.alias) || - (labelStr && candidates.includes(labelStr)) - ) { - return iface; - } - } - return undefined; -} - -/** - * Checks if a container belongs to a distributed node. - */ -export function containerBelongsToDistributedNode( - container: ContainerInfo, - baseNodeName: string, - fullPrefix: string -): boolean { - const prefix = fullPrefix ? `${fullPrefix}-${baseNodeName}` : baseNodeName; - const shortPrefix = `${baseNodeName}`; - return ( - container.name.startsWith(`${prefix}-`) || - container.name_short.startsWith(`${shortPrefix}-`) || - (typeof container.label === "string" && container.label.startsWith(`${shortPrefix}-`)) - ); -} - -/** - * Builds candidate container names for distributed SROS nodes. - */ -export function buildDistributedCandidateNames( - baseNodeName: string, - fullPrefix: string, - components: unknown[] -): string[] { - const names: string[] = []; - for (const comp of components) { - const compObj = asRecord(comp); - const slotRaw = typeof compObj.slot === "string" ? compObj.slot.trim() : ""; - if (!slotRaw) continue; - const suffix = slotRaw.toLowerCase(); - const longName = fullPrefix - ? `${fullPrefix}-${baseNodeName}-${suffix}` - : `${baseNodeName}-${suffix}`; - names.push(longName, `${baseNodeName}-${suffix}`); - } - return names; -} - -/** - * Extracts SROS component info from a container. - */ -export function extractSrosComponentInfo( - container: ContainerInfo -): { base: string; slot: string } | undefined { - const candidateNames = [container.name_short, container.name].filter(Boolean); - for (const raw of candidateNames) { - const trimmed = raw.trim(); - if (!trimmed) { - continue; - } - - const lastDash = trimmed.lastIndexOf("-"); - if (lastDash === -1) { - continue; - } - - const base = trimmed.slice(0, lastDash); - const slot = trimmed.slice(lastDash + 1); - if (!base || !slot) { - continue; - } - - return { base, slot }; - } - - return undefined; -} - -/** - * Gets the slot priority for SROS components. - */ -export function srosSlotPriority(slot: string): number { - const normalized = slot.toLowerCase(); - if (normalized === "a") { - return 0; - } - if (normalized === "b") { - return 1; - } - return 2; -} - -// ============================================================================ -// ContainerDataProvider-based functions -// ============================================================================ - -/** - * Finds an interface by candidate names using ContainerDataProvider. - */ -export function findInterfaceByCandidateNames(params: { - candidateNames: string[]; - ifaceName: string; - provider: ContainerDataProvider; - labName: string; -}): { containerName: string; ifaceData?: InterfaceInfo } | undefined { - const { candidateNames, ifaceName, provider, labName } = params; - for (const candidate of candidateNames) { - const container = provider.findContainer(candidate, labName); - if (!container) continue; - const ifaceData = matchInterfaceInContainer(container, ifaceName); - if (ifaceData) { - return { containerName: container.name, ifaceData }; - } - } - return undefined; -} - -/** - * Finds a distributed SROS interface using ContainerDataProvider. - */ -export function findDistributedSrosInterface(params: { - baseNodeName: string; - ifaceName: string; - fullPrefix: string; - labName: string; - provider?: ContainerDataProvider; - components: unknown[]; -}): { containerName: string; ifaceData?: InterfaceInfo } | undefined { - const { baseNodeName, ifaceName, fullPrefix, labName, provider, components } = params; - if (!provider || !Array.isArray(components) || components.length === 0) { - return undefined; - } - - // Check if provider has specialized method - if (provider.findDistributedSrosInterface) { - return provider.findDistributedSrosInterface({ - baseNodeName, - ifaceName, - fullPrefix, - labName, - components - }); - } - - // Fallback to direct lookup - const candidateNames = buildDistributedCandidateNames(baseNodeName, fullPrefix, components); - return findInterfaceByCandidateNames({ - candidateNames, - ifaceName, - provider, - labName - }); -} - -/** - * Finds a distributed SROS container using ContainerDataProvider. - */ -export function findDistributedSrosContainer(params: { - baseNodeName: string; - fullPrefix: string; - labName: string; - provider?: ContainerDataProvider; - components: unknown[]; -}): ContainerInfo | undefined { - const { baseNodeName, fullPrefix, labName, provider, components } = params; - if (!provider) { - return undefined; - } - - // Check if provider has specialized method - if (provider.findDistributedSrosContainer) { - return provider.findDistributedSrosContainer({ - baseNodeName, - fullPrefix, - labName, - components - }); - } - - // Fallback to checking candidate names - const candidateNames = buildDistributedCandidateNames(baseNodeName, fullPrefix, components); - for (const name of candidateNames) { - const container = provider.findContainer(name, labName); - if (container) { - return container; - } - } - - return undefined; -} diff --git a/src/reactTopoViewer/shared/parsing/EdgeElementBuilder.ts b/src/reactTopoViewer/shared/parsing/EdgeElementBuilder.ts deleted file mode 100644 index 5bab84fa1..000000000 --- a/src/reactTopoViewer/shared/parsing/EdgeElementBuilder.ts +++ /dev/null @@ -1,778 +0,0 @@ -/** - * Edge element builder for creating parsed edge elements. - * Pure functions - no VS Code dependencies. - */ - -import type { ClabNode, ParsedElement, ClabTopology, TopologyAnnotations } from "../types/topology"; - -import { resolveNodeConfig } from "./NodeConfigResolver"; -import { - TYPES, - PREFIX_VXLAN_STITCH, - NODE_KIND_BRIDGE, - NODE_KIND_OVS_BRIDGE, - SINGLE_ENDPOINT_TYPE_LIST, - splitEndpoint, - normalizeLinkToTwoEndpoints, - resolveActualNode, - buildContainerName, - shouldOmitEndpoint, - extractEndpointMac -} from "./LinkNormalizer"; -import { isDistributedSrosNode, findDistributedSrosInterface } from "./DistributedSrosMapper"; -import type { - ContainerDataProvider, - InterfaceInfo, - DummyContext, - SpecialNodeInfo, - ParserLogger, - NetemState -} from "./types"; -import { nullLogger } from "./types"; - -// ============================================================================ -// Build Options -// ============================================================================ - -export interface EdgeBuildOptions { - /** Include container runtime data */ - includeContainerData?: boolean; - /** Container data provider for runtime enrichment */ - containerDataProvider?: ContainerDataProvider; - /** Annotations */ - annotations?: TopologyAnnotations; - /** Logger */ - logger?: ParserLogger; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isEndpointObject(value: unknown): value is { node: string; interface?: string } { - if (!isRecord(value)) return false; - if (typeof value.node !== "string") return false; - return value.interface === undefined || typeof value.interface === "string"; -} - -function isEndpointInput(value: unknown): value is string | { node: string; interface?: string } { - return typeof value === "string" || isEndpointObject(value); -} - -// ============================================================================ -// Special Node Detection -// ============================================================================ - -/** - * Checks if a node is a special node type. - */ -export function isSpecialNode(nodeData: ClabNode | undefined, nodeName: string): boolean { - return ( - nodeData?.kind === NODE_KIND_BRIDGE || - nodeData?.kind === NODE_KIND_OVS_BRIDGE || - nodeName === "host" || - nodeName === "mgmt-net" || - nodeName.startsWith("macvlan:") || - nodeName.startsWith("vxlan:") || - nodeName.startsWith(PREFIX_VXLAN_STITCH) || - nodeName.startsWith("dummy") - ); -} - -// ============================================================================ -// Edge Class Computation -// ============================================================================ - -/** - * Gets edge class from interface state. - */ -export function classFromState(ifaceData: { state?: string } | undefined): string { - if (ifaceData?.state === undefined || ifaceData.state === "") return ""; - return ifaceData.state === "up" ? "link-up" : "link-down"; -} - -/** - * Computes edge class for special nodes. - */ -export function edgeClassForSpecial( - sourceIsSpecial: boolean, - targetIsSpecial: boolean, - sourceIfaceData: { state?: string } | undefined, - targetIfaceData: { state?: string } | undefined -): string { - if (sourceIsSpecial && !targetIsSpecial) { - return classFromState(targetIfaceData); - } - if (!sourceIsSpecial && targetIsSpecial) { - return classFromState(sourceIfaceData); - } - return "link-up"; -} - -/** - * Checks if nodes are special and computes edge class for special nodes. - */ -function computeSpecialNodeEdgeClass( - topology: NonNullable, - sourceNode: string, - targetNode: string, - sourceIfaceData: { state?: string } | undefined, - targetIfaceData: { state?: string } | undefined -): string | null { - const sourceNodeData = topology.nodes?.[sourceNode]; - const targetNodeData = topology.nodes?.[targetNode]; - const sourceIsSpecial = isSpecialNode(sourceNodeData, sourceNode); - const targetIsSpecial = isSpecialNode(targetNodeData, targetNode); - if (sourceIsSpecial || targetIsSpecial) { - return edgeClassForSpecial(sourceIsSpecial, targetIsSpecial, sourceIfaceData, targetIfaceData); - } - return null; -} - -/** - * Computes edge class based on interface states. - */ -export function computeEdgeClass( - sourceNode: string, - targetNode: string, - sourceIfaceData: { state?: string } | undefined, - targetIfaceData: { state?: string } | undefined, - topology: NonNullable -): string { - const specialClass = computeSpecialNodeEdgeClass( - topology, - sourceNode, - targetNode, - sourceIfaceData, - targetIfaceData - ); - if (specialClass !== null) { - return specialClass; - } - if ( - sourceIfaceData?.state !== undefined && - sourceIfaceData.state !== "" && - targetIfaceData?.state !== undefined && - targetIfaceData.state !== "" - ) { - return sourceIfaceData.state === "up" && targetIfaceData.state === "up" - ? "link-up" - : "link-down"; - } - return ""; -} - -/** - * Computes edge class from states (public API). - */ -export function computeEdgeClassFromStates( - topology: NonNullable, - sourceNode: string, - targetNode: string, - sourceState?: string, - targetState?: string -): string { - const specialClass = computeSpecialNodeEdgeClass( - topology, - sourceNode, - targetNode, - { state: sourceState }, - { state: targetState } - ); - if (specialClass !== null) { - return specialClass; - } - if ( - sourceState !== undefined && - sourceState !== "" && - targetState !== undefined && - targetState !== "" - ) { - return sourceState === "up" && targetState === "up" ? "link-up" : "link-down"; - } - return ""; -} - -// ============================================================================ -// Link Validation -// ============================================================================ - -/** - * Validates a veth link. - */ -export function validateVethLink(linkObj: Record): string[] { - const eps: unknown[] = Array.isArray(linkObj.endpoints) ? linkObj.endpoints : []; - const first = eps[0]; - const second = eps[1]; - const ok = - eps.length >= 2 && - isEndpointObject(first) && - isEndpointObject(second) && - first.node !== "" && - first.interface !== undefined && - second.node !== "" && - second.interface !== undefined; - return ok ? [] : ["invalid-veth-endpoints"]; -} - -/** - * Validates a special link type. - */ -export function validateSpecialLink(linkType: string, linkObj: Record): string[] { - const errors: string[] = []; - const ep = linkObj.endpoint; - const hostInterface = linkObj["host-interface"]; - const remote = linkObj.remote; - if (!(isEndpointObject(ep) && ep.node !== "" && ep.interface !== undefined)) { - errors.push("invalid-endpoint"); - } - if ( - ["mgmt-net", "host", "macvlan"].includes(linkType) && - (typeof hostInterface !== "string" || hostInterface === "") - ) { - errors.push("missing-host-interface"); - } - if (linkType === TYPES.VXLAN || linkType === TYPES.VXLAN_STITCH) { - if (typeof remote !== "string" || remote === "") errors.push("missing-remote"); - if (linkObj.vni === undefined || linkObj.vni === "") errors.push("missing-vni"); - if (linkObj["dst-port"] === undefined || linkObj["dst-port"] === "") - errors.push("missing-dst-port"); - } - return errors; -} - -/** - * Validates an extended link. - */ -export function validateExtendedLink(linkObj: Record): string[] { - const linkType = typeof linkObj.type === "string" ? linkObj.type : ""; - if (linkType === "") return []; - - if (linkType === "veth") { - return validateVethLink(linkObj); - } - - if (SINGLE_ENDPOINT_TYPE_LIST.includes(linkType)) { - return validateSpecialLink(linkType, linkObj); - } - - return []; -} - -// ============================================================================ -// Container/Interface Resolution -// ============================================================================ - -/** - * Resolves container and interface for an endpoint. - */ -export function resolveContainerAndInterface(params: { - parsed: ClabTopology; - nodeName: string; - actualNodeName: string; - ifaceName: string; - fullPrefix: string; - labName: string; - includeContainerData?: boolean; - containerDataProvider?: ContainerDataProvider; - logger?: ParserLogger; -}): { containerName: string; ifaceData?: InterfaceInfo } { - const { - parsed, - nodeName, - actualNodeName, - ifaceName, - fullPrefix, - labName, - includeContainerData, - containerDataProvider, - logger - } = params; - - const log = logger ?? nullLogger; - const containerName = buildContainerName(nodeName, actualNodeName, fullPrefix); - - if (includeContainerData !== true || containerDataProvider === undefined) { - return { containerName }; - } - - const directIface = containerDataProvider.findInterface(containerName, ifaceName, labName); - if (directIface) { - return { containerName, ifaceData: directIface }; - } - - log.debug(`[EdgeBuilder] Interface not found: ${containerName}:${ifaceName} in lab ${labName}`); - - const topologyNode = parsed.topology?.nodes?.[nodeName] ?? {}; - const resolvedNode = resolveNodeConfig(parsed, topologyNode); - - if (isDistributedSrosNode(resolvedNode)) { - const components = Array.isArray(resolvedNode.components) ? resolvedNode.components : []; - const distributedMatch = findDistributedSrosInterface({ - baseNodeName: nodeName, - ifaceName, - fullPrefix, - labName, - provider: containerDataProvider, - components - }); - if (distributedMatch) { - return distributedMatch; - } - } - - return { containerName }; -} - -// ============================================================================ -// Interface Stats Extraction -// ============================================================================ - -/** - * Extracts interface stats for an edge. - */ -export function extractEdgeInterfaceStats(ifaceData: unknown): Record | undefined { - if (!isRecord(ifaceData)) { - return undefined; - } - - const sourceStats = isRecord(ifaceData.stats) ? ifaceData.stats : ifaceData; - - const keys = [ - "rxBps", - "rxPps", - "rxBytes", - "rxPackets", - "txBps", - "txPps", - "txBytes", - "txPackets", - "statsIntervalSeconds" - ]; - - const stats: Record = {}; - for (const key of keys) { - const value = sourceStats[key]; - if (typeof value === "number" && Number.isFinite(value)) { - stats[key] = value; - } - } - - return Object.keys(stats).length > 0 ? stats : undefined; -} - -// ============================================================================ -// Edge Info Building -// ============================================================================ - -/** - * Extracts interface properties with defaults. - */ -function extractIfaceProps(ifaceData?: InterfaceInfo): { - mac: string; - state: string; - mtu: string | number; - type: string; - netemState: NetemState; -} { - return { - mac: ifaceData?.mac ?? "", - state: ifaceData?.state ?? "", - mtu: ifaceData?.mtu ?? "", - type: ifaceData?.type ?? "", - netemState: ifaceData?.netemState ?? {} - }; -} - -/** - * Creates clab info for an edge. - */ -export function createClabInfo(params: { - sourceContainerName: string; - targetContainerName: string; - sourceIface: string; - targetIface: string; - sourceIfaceData?: InterfaceInfo; - targetIfaceData?: InterfaceInfo; -}): Record { - const { - sourceContainerName, - targetContainerName, - sourceIface, - targetIface, - sourceIfaceData, - targetIfaceData - } = params; - - const src = extractIfaceProps(sourceIfaceData); - const tgt = extractIfaceProps(targetIfaceData); - const sourceStats = extractEdgeInterfaceStats(sourceIfaceData); - const targetStats = extractEdgeInterfaceStats(targetIfaceData); - - const info: Record = { - clabServerUsername: "asad", - clabSourceLongName: sourceContainerName, - clabTargetLongName: targetContainerName, - clabSourcePort: sourceIface, - clabTargetPort: targetIface, - clabSourceMacAddress: src.mac, - clabTargetMacAddress: tgt.mac, - clabSourceInterfaceState: src.state, - clabTargetInterfaceState: tgt.state, - clabSourceMtu: src.mtu, - clabTargetMtu: tgt.mtu, - clabSourceType: src.type, - clabTargetType: tgt.type, - clabSourceNetem: src.netemState, - clabTargetNetem: tgt.netemState - }; - - if (sourceStats) info.clabSourceStats = sourceStats; - if (targetStats) info.clabTargetStats = targetStats; - - return info; -} - -/** - * Extracts extended link properties. - */ -export function extractExtLinkProps(linkObj: Record): Record { - const { - type: extType = "", - mtu: extMtu = "", - vars: extVars, - labels: extLabels, - "host-interface": extHostInterface = "", - mode: extMode = "", - remote: extRemote = "", - vni: extVni = "", - "dst-port": extDstPort = "", - "src-port": extSrcPort = "" - } = linkObj; - - return { - extType, - extMtu, - extVars, - extLabels, - extHostInterface, - extMode, - extRemote, - extVni, - extDstPort, - extSrcPort - }; -} - -/** - * Extracts MAC addresses from endpoints. - */ -export function extractExtMacs( - linkObj: Record, - endA: unknown, - endB: unknown -): Record { - const endpoint = isRecord(linkObj.endpoint) ? linkObj.endpoint : undefined; - return { - extSourceMac: extractEndpointMac(endA), - extTargetMac: extractEndpointMac(endB), - extMac: endpoint?.mac ?? "" - }; -} - -function endpointIp(endpoint: unknown, key: "ipv4" | "ipv6"): string { - if (!isRecord(endpoint)) return ""; - const value = endpoint[key]; - if (typeof value === "string") return value; - if (typeof value === "number") return String(value); - return ""; -} - -function indexedIp(linkObj: Record, key: "ipv4" | "ipv6", index: number): string { - const values: unknown[] = Array.isArray(linkObj[key]) ? linkObj[key] : []; - if (values.length === 0) return ""; - const value = values[index]; - if (typeof value === "string") return value; - if (typeof value === "number") return String(value); - return ""; -} - -/** - * Extracts endpoint IP addresses from endpoint objects (extended format) - * with fallback to ordered ipv4/ipv6 arrays (brief format). - */ -export function extractExtIps( - linkObj: Record, - endA: unknown, - endB: unknown -): Record { - return { - extSourceIpv4: endpointIp(endA, "ipv4") || indexedIp(linkObj, "ipv4", 0), - extSourceIpv6: endpointIp(endA, "ipv6") || indexedIp(linkObj, "ipv6", 0), - extTargetIpv4: endpointIp(endB, "ipv4") || indexedIp(linkObj, "ipv4", 1), - extTargetIpv6: endpointIp(endB, "ipv6") || indexedIp(linkObj, "ipv6", 1) - }; -} - -/** - * Creates extended info for an edge. - */ -export function createExtInfo(params: { - linkObj: Record; - endA: unknown; - endB: unknown; -}): Record { - const { linkObj, endA, endB } = params; - const base = extractExtLinkProps(linkObj); - const macs = extractExtMacs(linkObj, endA, endB); - const ips = extractExtIps(linkObj, endA, endB); - return { ...base, ...macs, ...ips }; -} - -// ============================================================================ -// Edge Element Building -// ============================================================================ - -/** - * Builds edge classes string. - */ -export function buildEdgeClasses( - edgeClass: string, - specialNodes: Map, - actualSourceNode: string, - actualTargetNode: string -): string { - const stub = - specialNodes.has(actualSourceNode) || specialNodes.has(actualTargetNode) ? " stub-link" : ""; - return edgeClass + stub; -} - -/** - * Builds edge extra data. - */ -export function buildEdgeExtraData(params: { - linkObj: Record; - endA: unknown; - endB: unknown; - sourceContainerName: string; - targetContainerName: string; - sourceIface: string; - targetIface: string; - sourceIfaceData: InterfaceInfo | undefined; - targetIfaceData: InterfaceInfo | undefined; - extValidationErrors: string[]; - sourceNodeId: string; - targetNodeId: string; -}): Record { - const { - linkObj, - endA, - endB, - sourceContainerName, - targetContainerName, - sourceIface, - targetIface, - sourceIfaceData, - targetIfaceData, - extValidationErrors, - sourceNodeId, - targetNodeId - } = params; - - const yamlFormat = typeof linkObj.type === "string" && linkObj.type !== "" ? "extended" : "short"; - const extErrors = extValidationErrors.length ? extValidationErrors : undefined; - - const clabInfo = createClabInfo({ - sourceContainerName, - targetContainerName, - sourceIface, - targetIface, - sourceIfaceData, - targetIfaceData - }); - - const extInfo = createExtInfo({ linkObj, endA, endB }); - - return { - ...clabInfo, - ...extInfo, - yamlFormat, - extValidationErrors: extErrors, - yamlSourceNodeId: sourceNodeId, - yamlTargetNodeId: targetNodeId - }; -} - -/** - * Builds a single edge element. - */ -export function buildEdgeElement(params: { - linkObj: Record; - endA: unknown; - endB: unknown; - sourceNode: string; - targetNode: string; - sourceIface: string; - targetIface: string; - actualSourceNode: string; - actualTargetNode: string; - sourceContainerName: string; - targetContainerName: string; - sourceIfaceData: InterfaceInfo | undefined; - targetIfaceData: InterfaceInfo | undefined; - edgeId: string; - edgeClass: string; - specialNodes: Map; -}): ParsedElement { - const { - linkObj, - endA, - endB, - sourceNode, - targetNode, - sourceIface, - targetIface, - actualSourceNode, - actualTargetNode, - sourceContainerName, - targetContainerName, - sourceIfaceData, - targetIfaceData, - edgeId, - edgeClass, - specialNodes - } = params; - - const sourceEndpoint = shouldOmitEndpoint(sourceNode) ? "" : sourceIface; - const targetEndpoint = shouldOmitEndpoint(targetNode) ? "" : targetIface; - const classes = buildEdgeClasses(edgeClass, specialNodes, actualSourceNode, actualTargetNode); - const extValidationErrors = validateExtendedLink(linkObj); - const extraData = buildEdgeExtraData({ - linkObj, - endA, - endB, - sourceContainerName, - targetContainerName, - sourceIface, - targetIface, - sourceIfaceData, - targetIfaceData, - extValidationErrors, - sourceNodeId: sourceNode, - targetNodeId: targetNode - }); - - return { - group: "edges", - data: { - id: edgeId, - weight: "3", - name: edgeId, - parent: "", - topoViewerRole: "link", - sourceEndpoint, - targetEndpoint, - lat: "", - lng: "", - source: actualSourceNode, - target: actualTargetNode, - extraData - }, - position: { x: 0, y: 0 }, - removed: false, - selected: false, - selectable: true, - locked: false, - grabbed: false, - grabbable: true, - classes - }; -} - -/** - * Adds edge elements to the elements array. - */ -export function addEdgeElements( - parsed: ClabTopology, - opts: EdgeBuildOptions, - fullPrefix: string, - labName: string, - specialNodes: Map, - ctx: DummyContext, - elements: ParsedElement[] -): void { - const log = opts.logger ?? nullLogger; - const topology = parsed.topology; - if (!topology?.links) return; - - let linkIndex = 0; - for (const linkObj of topology.links) { - if (!isRecord(linkObj)) { - log.warn("Link is not an object. Skipping."); - continue; - } - const norm = normalizeLinkToTwoEndpoints(linkObj, ctx); - if (!norm) { - log.warn("Link does not have both endpoints. Skipping."); - continue; - } - const { endA, endB } = norm; - if (!isEndpointInput(endA) || !isEndpointInput(endB)) { - log.warn("Link endpoints are not in a recognized format. Skipping."); - continue; - } - const { node: sourceNode, iface: sourceIface } = splitEndpoint(endA); - const { node: targetNode, iface: targetIface } = splitEndpoint(endB); - const actualSourceNode = resolveActualNode(sourceNode, sourceIface); - const actualTargetNode = resolveActualNode(targetNode, targetIface); - const sourceInfo = resolveContainerAndInterface({ - parsed, - nodeName: sourceNode, - actualNodeName: actualSourceNode, - ifaceName: sourceIface, - fullPrefix, - labName, - includeContainerData: opts.includeContainerData, - containerDataProvider: opts.containerDataProvider, - logger: opts.logger - }); - const targetInfo = resolveContainerAndInterface({ - parsed, - nodeName: targetNode, - actualNodeName: actualTargetNode, - ifaceName: targetIface, - fullPrefix, - labName, - includeContainerData: opts.includeContainerData, - containerDataProvider: opts.containerDataProvider, - logger: opts.logger - }); - const { containerName: sourceContainerName, ifaceData: sourceIfaceData } = sourceInfo; - const { containerName: targetContainerName, ifaceData: targetIfaceData } = targetInfo; - const edgeId = `Clab-Link${linkIndex}`; - const edgeClass = - opts.includeContainerData === true - ? computeEdgeClass(sourceNode, targetNode, sourceIfaceData, targetIfaceData, topology) - : ""; - const edgeEl = buildEdgeElement({ - linkObj, - endA, - endB, - sourceNode, - targetNode, - sourceIface, - targetIface, - actualSourceNode, - actualTargetNode, - sourceContainerName, - targetContainerName, - sourceIfaceData, - targetIfaceData, - edgeId, - edgeClass, - specialNodes - }); - elements.push(edgeEl); - linkIndex++; - } -} diff --git a/src/reactTopoViewer/shared/parsing/GraphLabelMigrator.ts b/src/reactTopoViewer/shared/parsing/GraphLabelMigrator.ts deleted file mode 100644 index 5a26d0ee8..000000000 --- a/src/reactTopoViewer/shared/parsing/GraphLabelMigrator.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Graph label migrator for detecting and converting graph-* YAML labels to annotations. - * Pure functions - no VS Code dependencies, no I/O. - */ - -import type { ClabTopology, TopologyAnnotations, NodeAnnotation } from "../types/topology"; -import { createEmptyAnnotations } from "../annotations/types"; -import { getRecordUnknown, getString } from "../utilities/typeHelpers"; - -import type { GraphLabelMigration } from "./types"; - -// ============================================================================ -// Detection -// ============================================================================ - -/** - * Checks if a node has graph-* labels that need migration. - */ -export function nodeHasGraphLabels(labels: Record | undefined): boolean { - if (labels === undefined) return false; - const relevantKeys = [ - "graph-posX", - "graph-posY", - "graph-icon", - "graph-group", - "graph-level", - "graph-groupLabelPos", - "graph-geoCoordinateLat", - "graph-geoCoordinateLng" - ] as const; - return relevantKeys.some((key) => labels[key] !== undefined && labels[key] !== null); -} - -/** - * Checks if a topology has any nodes with graph-* labels. - */ -export function topologyHasGraphLabels(parsed: ClabTopology): boolean { - const nodes = parsed.topology?.nodes; - if (nodes === undefined) return false; - return Object.values(nodes).some((node) => { - const nodeRecord = getRecordUnknown(node); - if (nodeRecord === undefined) return false; - return nodeHasGraphLabels(getRecordUnknown(nodeRecord.labels)); - }); -} - -function getNonEmptyLabel(labels: Record, key: string): string | undefined { - const value = getString(labels[key]); - if (value === undefined || value === "") return undefined; - return value; -} - -function parseNumericPair( - first: string | undefined, - second: string | undefined -): { first: number; second: number } | undefined { - if (first === undefined || second === undefined) return undefined; - return { - first: Number.parseFloat(first) || 0, - second: Number.parseFloat(second) || 0 - }; -} - -// ============================================================================ -// Migration Building -// ============================================================================ - -/** - * Builds an annotation object from graph-* labels. - */ -export function buildAnnotationFromLabels( - nodeName: string, - labels: Record -): GraphLabelMigration | null { - if (!nodeHasGraphLabels(labels)) return null; - const posPair = parseNumericPair( - getNonEmptyLabel(labels, "graph-posX"), - getNonEmptyLabel(labels, "graph-posY") - ); - const geoPair = parseNumericPair( - getNonEmptyLabel(labels, "graph-geoCoordinateLat"), - getNonEmptyLabel(labels, "graph-geoCoordinateLng") - ); - const icon = getNonEmptyLabel(labels, "graph-icon"); - const group = getNonEmptyLabel(labels, "graph-group"); - const level = getNonEmptyLabel(labels, "graph-level"); - const groupLabelPos = getNonEmptyLabel(labels, "graph-groupLabelPos"); - - return { - nodeId: nodeName, - ...(posPair !== undefined ? { position: { x: posPair.first, y: posPair.second } } : {}), - ...(icon !== undefined ? { icon } : {}), - ...(group !== undefined ? { group } : {}), - ...(level !== undefined ? { level } : {}), - ...(groupLabelPos !== undefined ? { groupLabelPos } : {}), - ...(geoPair !== undefined - ? { geoCoordinates: { lat: geoPair.first, lng: geoPair.second } } - : {}) - }; -} - -/** - * Converts a GraphLabelMigration to a NodeAnnotation. - */ -export function migrationToNodeAnnotation(migration: GraphLabelMigration): NodeAnnotation { - const annotation: NodeAnnotation = { id: migration.nodeId }; - if (migration.position) { - annotation.position = migration.position; - } - if (migration.icon !== undefined && migration.icon !== "") { - annotation.icon = migration.icon; - } - if (migration.group !== undefined && migration.group !== "") { - annotation.group = migration.group; - } - if (migration.level !== undefined && migration.level !== "") { - annotation.level = migration.level; - } - if (migration.groupLabelPos !== undefined && migration.groupLabelPos !== "") { - annotation.groupLabelPos = migration.groupLabelPos; - } - if (migration.geoCoordinates) { - annotation.geoCoordinates = migration.geoCoordinates; - } - return annotation; -} - -// ============================================================================ -// Detection and Collection -// ============================================================================ - -/** - * Detects graph-* label migrations needed for a topology. - * Returns migrations for nodes that have graph-* labels but no existing annotation. - */ -export function detectGraphLabelMigrations( - parsed: ClabTopology, - annotations?: TopologyAnnotations -): GraphLabelMigration[] { - const migrations: GraphLabelMigration[] = []; - const nodes = parsed.topology?.nodes; - if (!nodes) return migrations; - - const existingAnnotations = new Set(annotations?.nodeAnnotations?.map((na) => na.id) ?? []); - - for (const [nodeName, nodeObj] of Object.entries(nodes)) { - // Skip if node already has an annotation - if (existingAnnotations.has(nodeName)) continue; - // Skip if node has no graph-* labels - const nodeRecord = getRecordUnknown(nodeObj); - if (nodeRecord === undefined) continue; - const labels = getRecordUnknown(nodeRecord.labels); - if (labels === undefined) continue; - if (!nodeHasGraphLabels(labels)) continue; - - const migration = buildAnnotationFromLabels(nodeName, labels); - if (migration) { - migrations.push(migration); - } - } - - return migrations; -} - -/** - * Creates base annotations from existing annotations. - */ -function createBaseAnnotations(annotations: TopologyAnnotations | undefined): TopologyAnnotations { - const base = createEmptyAnnotations(); - if (!annotations) return base; - const nodeAnnotations = annotations.nodeAnnotations ?? base.nodeAnnotations ?? []; - return { - ...base, - ...annotations, - nodeAnnotations: [...nodeAnnotations] - }; -} - -/** - * Applies graph label migrations to annotations. - * Returns a new annotations object with migrations applied. - */ -export function applyGraphLabelMigrations( - annotations: TopologyAnnotations | undefined, - migrations: GraphLabelMigration[] -): TopologyAnnotations { - const result = createBaseAnnotations(annotations); - const nodeAnnotations = result.nodeAnnotations ?? []; - - for (const migration of migrations) { - const newAnnotation = migrationToNodeAnnotation(migration); - nodeAnnotations.push(newAnnotation); - } - result.nodeAnnotations = nodeAnnotations; - - return result; -} - -/** - * Checks if migrations were applied and returns the result. - * This is a convenience function that combines detection and application. - */ -export function processGraphLabelMigrations( - parsed: ClabTopology, - annotations?: TopologyAnnotations -): { annotations: TopologyAnnotations; migrations: GraphLabelMigration[]; needsSave: boolean } { - const migrations = detectGraphLabelMigrations(parsed, annotations); - if (migrations.length === 0) { - return { - annotations: annotations ?? { - freeTextAnnotations: [], - freeShapeAnnotations: [], - groupStyleAnnotations: [], - networkNodeAnnotations: [], - nodeAnnotations: [], - edgeAnnotations: [], - aliasEndpointAnnotations: [], - viewerSettings: {} - }, - migrations: [], - needsSave: false - }; - } - - const updatedAnnotations = applyGraphLabelMigrations(annotations, migrations); - return { - annotations: updatedAnnotations, - migrations, - needsSave: true - }; -} diff --git a/src/reactTopoViewer/shared/parsing/InterfacePatternResolver.ts b/src/reactTopoViewer/shared/parsing/InterfacePatternResolver.ts deleted file mode 100644 index 956691d40..000000000 --- a/src/reactTopoViewer/shared/parsing/InterfacePatternResolver.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Interface pattern resolver for node interface naming patterns. - * Pure functions - no VS Code dependencies, no I/O. - */ - -import type { NodeAnnotation } from "../types/topology"; -import { DEFAULT_INTERFACE_PATTERNS } from "../constants/interfacePatterns"; - -import type { InterfacePatternMigration } from "./types"; - -// ============================================================================ -// Interface Pattern Resolution -// ============================================================================ - -/** - * Result of resolving an interface pattern. - */ -export interface InterfacePatternResult { - /** The resolved pattern (e.g., "e1-{n}") */ - pattern: string | undefined; - /** True if pattern was resolved from kind mapping (needs migration to annotations) */ - needsMigration: boolean; -} - -/** - * Resolves the interface pattern for a node. - * Priority: annotation > kind-based mapping - * - * @param nodeAnn - The node annotation (may have interfacePattern) - * @param kind - The node kind (for kind-based pattern lookup) - * @param customPatterns - Optional custom patterns to use instead of defaults - * @returns The resolved pattern and whether it needs migration - */ -export function resolveInterfacePattern( - nodeAnn: NodeAnnotation | undefined, - kind: string, - customPatterns?: Record -): InterfacePatternResult { - // First check if the annotation has an interface pattern (node-specific) - const annPattern = nodeAnn?.interfacePattern; - if (typeof annPattern === "string" && annPattern) { - return { pattern: annPattern, needsMigration: false }; - } - - // Fall back to kind-based mapping - this needs migration - const patterns = customPatterns ?? DEFAULT_INTERFACE_PATTERNS; - const kindPattern = patterns[kind]; - return { pattern: kindPattern, needsMigration: Boolean(kindPattern) }; -} - -/** - * Gets the default interface patterns. - */ -export function getDefaultInterfacePatterns(): Record { - return { ...DEFAULT_INTERFACE_PATTERNS }; -} - -/** - * Checks if a node needs interface pattern migration. - */ -export function needsInterfacePatternMigration( - nodeAnn: NodeAnnotation | undefined, - kind: string -): boolean { - // Node already has pattern in annotation - no migration needed - const annPattern = nodeAnn?.interfacePattern; - if (typeof annPattern === "string" && annPattern) { - return false; - } - - // Check if kind has a default pattern that should be migrated - return kind in DEFAULT_INTERFACE_PATTERNS; -} - -/** - * Creates an interface pattern migration entry. - */ -export function createInterfacePatternMigration( - nodeId: string, - kind: string -): InterfacePatternMigration | undefined { - const pattern = DEFAULT_INTERFACE_PATTERNS[kind]; - if (!pattern) return undefined; - return { nodeId, interfacePattern: pattern }; -} - -/** - * Collects all interface pattern migrations for a topology. - */ -export function collectInterfacePatternMigrations( - nodes: Record, - nodeAnnotations?: NodeAnnotation[] -): InterfacePatternMigration[] { - const migrations: InterfacePatternMigration[] = []; - const annotationMap = new Map(); - - if (nodeAnnotations) { - for (const ann of nodeAnnotations) { - annotationMap.set(ann.id, ann); - } - } - - for (const [nodeId, node] of Object.entries(nodes)) { - const kind = node.kind ?? ""; - const ann = annotationMap.get(nodeId); - - if (needsInterfacePatternMigration(ann, kind)) { - const migration = createInterfacePatternMigration(nodeId, kind); - if (migration) { - migrations.push(migration); - } - } - } - - return migrations; -} diff --git a/src/reactTopoViewer/shared/parsing/LinkNormalizer.ts b/src/reactTopoViewer/shared/parsing/LinkNormalizer.ts deleted file mode 100644 index 0fc893fce..000000000 --- a/src/reactTopoViewer/shared/parsing/LinkNormalizer.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * Link normalization utilities for parsing containerlab links. - * Pure functions - no VS Code dependencies. - */ - -import { - STR_HOST, - STR_MGMT_NET, - PREFIX_MACVLAN, - PREFIX_VXLAN, - PREFIX_VXLAN_STITCH, - SINGLE_ENDPOINT_TYPES, - HOSTY_TYPES, - splitEndpointLike, - isSpecialEndpointId -} from "../utilities/LinkTypes"; - -// Re-export for convenience -export { - STR_HOST, - STR_MGMT_NET, - PREFIX_MACVLAN, - PREFIX_VXLAN, - PREFIX_VXLAN_STITCH, - splitEndpointLike, - isSpecialEndpointId -}; - -import type { DummyContext } from "./types"; - -// ============================================================================ -// Constants -// ============================================================================ - -export const TYPES = { - HOST: "host", - MGMT_NET: "mgmt-net", - MACVLAN: "macvlan", - VXLAN: "vxlan", - VXLAN_STITCH: "vxlan-stitch", - BRIDGE: "bridge", - OVS_BRIDGE: "ovs-bridge", - DUMMY: "dummy" -} as const; - -export type SpecialNodeType = (typeof TYPES)[keyof typeof TYPES]; - -export const NODE_KIND_BRIDGE = TYPES.BRIDGE; -export const NODE_KIND_OVS_BRIDGE = TYPES.OVS_BRIDGE; - -export const SINGLE_ENDPOINT_TYPE_LIST: string[] = [ - STR_HOST, - STR_MGMT_NET, - TYPES.MACVLAN, - TYPES.DUMMY, - TYPES.VXLAN, - TYPES.VXLAN_STITCH -]; - -// ============================================================================ -// Types -// ============================================================================ - -export interface EndpointParts { - node: string; - iface: string; -} - -export interface NormalizedLink { - endA: unknown; - endB: unknown; - type?: string; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -// ============================================================================ -// Endpoint Parsing -// ============================================================================ - -/** - * Splits an endpoint string into node and interface components. - */ -export function splitEndpoint( - endpoint: string | { node: string; interface?: string } -): EndpointParts { - return splitEndpointLike(endpoint); -} - -// ============================================================================ -// Special Node ID Generation -// ============================================================================ - -/** - * Builds a host/mgmt-net/macvlan ID. - */ -function buildHostyId(t: string, linkObj: Record): string { - return `${t}:${linkObj["host-interface"] ?? ""}`; -} - -/** - * Builds a vxlan ID using the context counter. - * Uses counter-based ID like "vxlan:vxlan0" to match UI-created nodes. - */ -function buildVxlanId(linkObj: unknown, ctx: DummyContext): string { - const cached = ctx.vxlanLinkMap.get(linkObj); - if (cached !== undefined && cached !== "") return cached; - const vxlanId = `vxlan:vxlan${ctx.vxlanCounter}`; - ctx.vxlanCounter += 1; - ctx.vxlanLinkMap.set(linkObj, vxlanId); - return vxlanId; -} - -/** - * Builds a vxlan-stitch ID using the context counter. - * Uses counter-based ID like "vxlan-stitch:vxlan0" to match UI-created nodes. - */ -function buildVxlanStitchId(linkObj: unknown, ctx: DummyContext): string { - const cached = ctx.vxlanStitchLinkMap.get(linkObj); - if (cached !== undefined && cached !== "") return cached; - const vxlanId = `vxlan-stitch:vxlan${ctx.vxlanStitchCounter}`; - ctx.vxlanStitchCounter += 1; - ctx.vxlanStitchLinkMap.set(linkObj, vxlanId); - return vxlanId; -} - -/** - * Builds a dummy ID using the context counter. - * Uses counter then increments to match UI behavior (dummy0, dummy1, ...). - */ -function buildDummyId(linkObj: unknown, ctx: DummyContext): string { - const cached = ctx.dummyLinkMap.get(linkObj); - if (cached !== undefined && cached !== "") return cached; - const dummyId = `dummy${ctx.dummyCounter}`; - ctx.dummyCounter += 1; - ctx.dummyLinkMap.set(linkObj, dummyId); - return dummyId; -} - -/** - * Normalizes a single-endpoint type to a special node ID. - */ -export function normalizeSingleTypeToSpecialId( - t: string, - linkObj: Record, - ctx: DummyContext -): string { - if (HOSTY_TYPES.has(t)) return buildHostyId(t, linkObj); - if (t === "vxlan") return buildVxlanId(linkObj, ctx); - if (t === "vxlan-stitch") return buildVxlanStitchId(linkObj, ctx); - if (t === "dummy") return buildDummyId(linkObj, ctx); - return ""; -} - -function toEndpointPair(endpoints: unknown[]): { endA: unknown; endB: unknown } | null { - const [a, b] = endpoints; - if (a === undefined || a === null || b === undefined || b === null) return null; - return { endA: a, endB: b }; -} - -// ============================================================================ -// Link Normalization -// ============================================================================ - -/** - * Normalizes a link object to a consistent two-endpoint format. - */ -export function normalizeLinkToTwoEndpoints( - linkObj: Record, - ctx: DummyContext -): NormalizedLink | null { - const t = typeof linkObj.type === "string" ? linkObj.type : undefined; - if (t === "veth") { - const endpoints: unknown[] = Array.isArray(linkObj.endpoints) ? linkObj.endpoints : []; - const pair = toEndpointPair(endpoints); - return pair ? { ...pair, type: t } : null; - } - - if (SINGLE_ENDPOINT_TYPES.has(t ?? "")) { - const a = linkObj.endpoint; - if (a === undefined || a === null) return null; - const special = normalizeSingleTypeToSpecialId(t ?? "", linkObj, ctx); - return { endA: a, endB: special, type: t }; - } - - const endpoints: unknown[] = Array.isArray(linkObj.endpoints) ? linkObj.endpoints : []; - const pair = toEndpointPair(endpoints); - return pair ? { ...pair, type: t } : null; -} - -// ============================================================================ -// Node Resolution -// ============================================================================ - -/** - * Resolves the actual node ID for special endpoint types. - */ -export function resolveActualNode(node: string, iface: string): string { - if (node === "host") return `host:${iface}`; - if (node === "mgmt-net") return `mgmt-net:${iface}`; - if (node.startsWith(PREFIX_MACVLAN)) return node; - if (node.startsWith(PREFIX_VXLAN_STITCH)) return node; - if (node.startsWith("vxlan:")) return node; - if (node.startsWith("dummy")) return node; - return node; -} - -/** - * Builds the container name for a node. - */ -export function buildContainerName(node: string, actualNode: string, fullPrefix: string): string { - if ( - node === "host" || - node === "mgmt-net" || - node.startsWith(PREFIX_MACVLAN) || - node.startsWith("vxlan:") || - node.startsWith(PREFIX_VXLAN_STITCH) || - node.startsWith("dummy") - ) { - return actualNode; - } - return fullPrefix ? `${fullPrefix}-${node}` : node; -} - -/** - * Checks if an endpoint should omit interface info. - */ -export function shouldOmitEndpoint(node: string): boolean { - return ( - node === "host" || - node === "mgmt-net" || - node.startsWith(PREFIX_MACVLAN) || - node.startsWith("dummy") - ); -} - -/** - * Extracts MAC address from an endpoint object. - */ -export function extractEndpointMac(endpoint: unknown): string { - if (!isRecord(endpoint)) return ""; - return typeof endpoint.mac === "string" ? endpoint.mac : ""; -} - -// ============================================================================ -// Context Creation -// ============================================================================ - -/** - * Creates a new DummyContext for link processing. - */ -export function createDummyContext(): DummyContext { - return { - dummyCounter: 0, - dummyLinkMap: new Map(), - vxlanCounter: 0, - vxlanLinkMap: new Map(), - vxlanStitchCounter: 0, - vxlanStitchLinkMap: new Map() - }; -} diff --git a/src/reactTopoViewer/shared/parsing/NodeConfigResolver.ts b/src/reactTopoViewer/shared/parsing/NodeConfigResolver.ts deleted file mode 100644 index 3bc9e06a2..000000000 --- a/src/reactTopoViewer/shared/parsing/NodeConfigResolver.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Node configuration resolver for Containerlab inheritance. - * Pure functions - no VS Code dependencies. - */ - -import type { ClabTopology, ClabNode } from "../types/topology"; - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -/** - * Gets a section from the topology configuration. - */ -function getSection( - source: Record | undefined, - key: string | undefined -): ClabNode { - if (source === undefined || key === undefined || key.length === 0) return {}; - return source[key] ?? {}; -} - -/** - * Resolves the kind name through inheritance. - */ -function resolveKindName( - node: ClabNode, - groupCfg: ClabNode, - defaults: ClabNode -): string | undefined { - return node.kind ?? groupCfg.kind ?? defaults.kind; -} - -/** - * Merges node labels from multiple sources. - */ -function mergeNodeLabels(...labels: unknown[]): Record { - const merged: Record = {}; - for (const label of labels) { - if (!isRecord(label)) continue; - Object.assign(merged, label); - } - return merged; -} - -/** - * Applies Containerlab inheritance rules to compute the effective - * configuration for a node. The precedence order is: - * node -> group -> kind -> defaults. - */ -export function resolveNodeConfig(parsed: ClabTopology, node: ClabNode): ClabNode { - const { defaults = {}, groups, kinds } = parsed.topology ?? {}; - - const groupCfg = getSection(groups, node.group); - const kindName = resolveKindName(node, groupCfg, defaults); - const kindCfg = getSection(kinds, kindName); - - return { - ...defaults, - ...kindCfg, - ...groupCfg, - ...node, - kind: kindName, - labels: mergeNodeLabels(defaults.labels, kindCfg.labels, groupCfg.labels, node.labels) - }; -} diff --git a/src/reactTopoViewer/shared/parsing/NodeElementBuilder.ts b/src/reactTopoViewer/shared/parsing/NodeElementBuilder.ts deleted file mode 100644 index 475e6c6b4..000000000 --- a/src/reactTopoViewer/shared/parsing/NodeElementBuilder.ts +++ /dev/null @@ -1,449 +0,0 @@ -// Node element builder — pure functions, no VS Code dependencies. - -import type { - ClabNode, - ParsedElement, - ClabTopology, - NodeAnnotation, - NetworkNodeAnnotation, - TopologyAnnotations -} from "../types/topology"; -import { DEFAULT_INTERFACE_PATTERNS } from "../constants/interfacePatterns"; - -import { resolveNodeConfig } from "./NodeConfigResolver"; -import { NODE_KIND_BRIDGE, NODE_KIND_OVS_BRIDGE } from "./LinkNormalizer"; -import { isDistributedSrosNode, findDistributedSrosContainer } from "./DistributedSrosMapper"; -import { extractIconVisuals, sanitizeLabels, getNodeLatLng, computeLongname } from "./utils"; -import type { - ContainerDataProvider, - ContainerInfo, - InterfacePatternMigration, - ParserLogger -} from "./types"; - -// ============================================================================ -// Build Options -// ============================================================================ - -export interface NodeBuildOptions { - /** Include container runtime data (IPs, state) */ - includeContainerData?: boolean; - /** Container data provider for runtime enrichment */ - containerDataProvider?: ContainerDataProvider; - /** Annotations */ - annotations?: TopologyAnnotations; - /** Logger */ - logger?: ParserLogger; -} - -// ============================================================================ -// Interface Pattern Resolution -// ============================================================================ - -/** - * Build interface pattern mapping from built-in defaults only. - */ -function buildInterfacePatternMapping(): Record { - return { ...DEFAULT_INTERFACE_PATTERNS }; -} - -/** Result of resolving interface pattern for a node */ -interface InterfacePatternResult { - pattern: string | undefined; - /** True if pattern was resolved from kind mapping (needs migration to annotations) */ - needsMigration: boolean; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function getNodeComponents(node: ClabNode): unknown[] { - return Array.isArray(node.components) ? node.components : []; -} - -/** - * Resolve interface pattern for a node. - * Priority: annotation > kind-based mapping - */ -function resolveInterfacePattern( - nodeAnn: NodeAnnotation | undefined, - kind: string, - interfacePatternMapping: Record -): InterfacePatternResult { - // First check if the annotation has an interface pattern (node-specific) - const annPattern = nodeAnn?.interfacePattern; - if (typeof annPattern === "string" && annPattern) { - return { pattern: annPattern, needsMigration: false }; - } - // Fall back to kind-based mapping - this needs migration - const kindPattern = interfacePatternMapping[kind]; - return { pattern: kindPattern, needsMigration: Boolean(kindPattern) }; -} - -// ============================================================================ -// Container Data Functions -// ============================================================================ - -/** - * Gets container data for a node using the provider. - */ -export function getContainerData( - opts: { - includeContainerData?: boolean; - containerDataProvider?: ContainerDataProvider; - }, - fullPrefix: string, - nodeName: string, - labName: string, - resolvedNode: ClabNode -): ContainerInfo | undefined { - if (opts.includeContainerData !== true || opts.containerDataProvider === undefined) { - return undefined; - } - - const containerName = fullPrefix ? `${fullPrefix}-${nodeName}` : nodeName; - const direct = opts.containerDataProvider.findContainer(containerName, labName); - if (direct) { - return direct; - } - - if (!isDistributedSrosNode(resolvedNode)) { - return undefined; - } - - return findDistributedSrosContainer({ - baseNodeName: nodeName, - fullPrefix, - labName, - provider: opts.containerDataProvider, - components: getNodeComponents(resolvedNode) - }); -} - -// ============================================================================ -// Extra Data Building -// ============================================================================ - -/** - * Build container-dependent data for extraData - */ -function buildContainerFields( - includeContainerData: boolean, - containerData: ContainerInfo | undefined -): { mgmtIpv4Address: string; mgmtIpv6Address: string; state: string } { - if (!includeContainerData || !containerData) { - return { mgmtIpv4Address: "", mgmtIpv6Address: "", state: "" }; - } - return { - mgmtIpv4Address: containerData.IPv4Address, - mgmtIpv6Address: containerData.IPv6Address, - state: containerData.state - }; -} - -/** Result of creating node extraData */ -interface NodeExtraDataResult { - extraData: Record; - /** If set, this node's interfacePattern needs to be migrated to annotations */ - migrationPattern?: string; -} - -/** - * Creates the extraData object for a node element. - */ -export function createNodeExtraData(params: { - mergedNode: ClabNode; - inheritedProps: string[]; - nodeName: string; - labName: string; - nodeIndex: number; - fullPrefix: string; - containerData: ContainerInfo | undefined; - cleanedLabels: Record; - includeContainerData: boolean; - interfacePatternMapping: Record; - nodeAnn?: NodeAnnotation; -}): NodeExtraDataResult { - const { - mergedNode, - inheritedProps, - nodeName, - labName, - nodeIndex, - fullPrefix, - containerData, - cleanedLabels, - includeContainerData, - interfacePatternMapping, - nodeAnn - } = params; - - const kind = mergedNode.kind ?? ""; - const { pattern: interfacePattern, needsMigration } = resolveInterfacePattern( - nodeAnn, - kind, - interfacePatternMapping - ); - const containerFields = buildContainerFields(includeContainerData, containerData); - - const extraData: Record = { - ...mergedNode, - inherited: inheritedProps, - clabServerUsername: "asad", - fqdn: `${nodeName}.${labName}.io`, - group: mergedNode.group ?? "", - id: nodeName, - image: mergedNode.image ?? "", - index: nodeIndex.toString(), - kind, - type: mergedNode.type ?? "", - labdir: fullPrefix ? `${fullPrefix}/` : "", - labels: cleanedLabels, - longname: computeLongname(containerData?.name, fullPrefix, nodeName), - macAddress: "", - mgmtIntf: "", - mgmtIpv4AddressLength: 0, - mgmtIpv4Address: containerFields.mgmtIpv4Address, - mgmtIpv6Address: containerFields.mgmtIpv6Address, - mgmtIpv6AddressLength: 0, - mgmtNet: "", - name: nodeName, - shortname: nodeName, - state: containerFields.state, - weight: "3" - }; - if (interfacePattern !== undefined && interfacePattern.length > 0) { - extraData.interfacePattern = interfacePattern; - } - - return { - extraData, - migrationPattern: needsMigration ? interfacePattern : undefined - }; -} - -// ============================================================================ -// Node Element Building -// ============================================================================ - -/** Result of building a node element */ -interface NodeElementResult { - element: ParsedElement; - /** If set, this node's interfacePattern needs to be migrated to annotations */ - migrationPattern?: string; -} - -function resolveTopoViewerRole( - mergedNode: ClabNode, - nodeAnn: NodeAnnotation | undefined, - labels: Record | undefined -): string { - const labelRole = labels?.["topoViewer-role"]; - const parsedLabelRole = typeof labelRole === "string" ? labelRole : undefined; - return ( - nodeAnn?.icon ?? - parsedLabelRole ?? - (mergedNode.kind === NODE_KIND_BRIDGE || mergedNode.kind === NODE_KIND_OVS_BRIDGE - ? NODE_KIND_BRIDGE - : "pe") - ); -} - -function resolveDisplayName( - nodeName: string, - nodeAnn: NodeAnnotation | undefined, - isBridgeNode: boolean -): string { - const annotatedLabel = nodeAnn?.label?.trim(); - return isBridgeNode && annotatedLabel !== undefined && annotatedLabel.length > 0 - ? annotatedLabel - : nodeName; -} - -function buildNodeAnnotationLookup( - nodeAnnotations?: NodeAnnotation[], - networkNodeAnnotations?: NetworkNodeAnnotation[] -): Map { - const lookup = new Map(); - if (nodeAnnotations) { - for (const annotation of nodeAnnotations) { - lookup.set(annotation.id, annotation); - } - } - if (networkNodeAnnotations) { - for (const annotation of networkNodeAnnotations) { - if (!lookup.has(annotation.id)) { - lookup.set(annotation.id, { id: annotation.id, position: annotation.position }); - } - } - } - return lookup; -} - -function shouldSkipAliasBridgeNode( - nodeName: string, - nodeAnn: NodeAnnotation | undefined, - nodeObj: ClabNode -): boolean { - const yamlNodeId = nodeAnn?.yamlNodeId; - return ( - yamlNodeId !== undefined && - yamlNodeId.length > 0 && - yamlNodeId !== nodeName && - (nodeObj.kind === NODE_KIND_BRIDGE || nodeObj.kind === NODE_KIND_OVS_BRIDGE) - ); -} - -function normalizeNodeObject( - nodeName: string, - nodeObj: unknown, - hasDefaults: boolean, - logger: ParserLogger | undefined -): ClabNode | undefined { - if (isRecord(nodeObj)) { - return nodeObj; - } - const isShorthandNode = (nodeObj === null || nodeObj === undefined) && hasDefaults; - if (isShorthandNode) { - return {}; - } - logger?.warn(`Node '${nodeName}' is not an object. Skipping.`); - return undefined; -} - -/** - * Builds a single node element. - */ -export function buildNodeElement(params: { - parsed: ClabTopology; - nodeName: string; - nodeObj: ClabNode; - opts: NodeBuildOptions; - fullPrefix: string; - labName: string; - nodeAnn: NodeAnnotation | undefined; - nodeIndex: number; - interfacePatternMapping: Record; -}): NodeElementResult { - const { - parsed, - nodeName, - nodeObj, - opts, - fullPrefix, - labName, - nodeAnn, - nodeIndex, - interfacePatternMapping - } = params; - const mergedNode = resolveNodeConfig(parsed, nodeObj); - const nodePropKeys = new Set(Object.keys(nodeObj)); - const inheritedProps = Object.keys(mergedNode).filter((k) => !nodePropKeys.has(k)); - const containerData = getContainerData(opts, fullPrefix, nodeName, labName, mergedNode); - const cleanedLabels = sanitizeLabels(isRecord(mergedNode.labels) ? mergedNode.labels : {}); - const pos = nodeAnn?.position; - const position = pos ? { x: pos.x, y: pos.y } : { x: 0, y: 0 }; - const { lat, lng } = getNodeLatLng(nodeAnn); - const { extraData, migrationPattern } = createNodeExtraData({ - mergedNode, - inheritedProps, - nodeName, - labName, - nodeIndex, - fullPrefix, - containerData, - cleanedLabels, - includeContainerData: opts.includeContainerData ?? false, - interfacePatternMapping, - nodeAnn - }); - - const labels = mergedNode.labels; - const topoViewerRole = resolveTopoViewerRole(mergedNode, nodeAnn, labels); - - const iconVisuals = extractIconVisuals(nodeAnn); - const isBridgeNode = - mergedNode.kind === NODE_KIND_BRIDGE || mergedNode.kind === NODE_KIND_OVS_BRIDGE; - const displayName = resolveDisplayName(nodeName, nodeAnn, isBridgeNode); - const element: ParsedElement = { - group: "nodes", - data: { - id: nodeName, - weight: "30", - name: displayName, - topoViewerRole, - ...iconVisuals, - lat, - lng, - extraData - }, - position, - removed: false, - selected: false, - selectable: true, - locked: false, - grabbed: false, - grabbable: true, - classes: "" - }; - - return { element, migrationPattern }; -} - -/** - * Adds node elements to the elements array. - * Returns list of nodes that need interfacePattern migrated to annotations. - */ -export function addNodeElements( - parsed: ClabTopology, - opts: NodeBuildOptions, - fullPrefix: string, - labName: string, - elements: ParsedElement[] -): InterfacePatternMigration[] { - const migrations: InterfacePatternMigration[] = []; - const topology = parsed.topology; - if (!topology?.nodes) return migrations; - const hasDefaults = isRecord(topology.defaults) && Object.keys(topology.defaults).length > 0; - - const nodeAnnotations = opts.annotations?.nodeAnnotations; - const networkNodeAnnotations = opts.annotations?.networkNodeAnnotations; - const nodeAnnotationLookup = buildNodeAnnotationLookup(nodeAnnotations, networkNodeAnnotations); - const interfacePatternMapping = buildInterfacePatternMapping(); - let nodeIndex = 0; - - for (const [nodeName, nodeObj] of Object.entries(topology.nodes as Record)) { - const normalizedNodeObj = normalizeNodeObject(nodeName, nodeObj, hasDefaults, opts.logger); - if (normalizedNodeObj === undefined) { - continue; - } - - // Check nodeAnnotations first, then fallback to networkNodeAnnotations for bridges - // (backwards compatibility - bridges were previously saved to networkNodeAnnotations) - const nodeAnn = nodeAnnotationLookup.get(nodeName); - // If this bridge node is configured as an alias (yamlNodeId points elsewhere), - // skip rendering the base YAML node to avoid duplicate visuals. - if (shouldSkipAliasBridgeNode(nodeName, nodeAnn, normalizedNodeObj)) { - continue; - } - const { element, migrationPattern } = buildNodeElement({ - parsed, - nodeName, - nodeObj: normalizedNodeObj, - opts, - fullPrefix, - labName, - nodeAnn, - nodeIndex, - interfacePatternMapping - }); - elements.push(element); - // Track migrations for nodes that need interfacePattern written to annotations - if (migrationPattern !== undefined && migrationPattern.length > 0) { - migrations.push({ nodeId: nodeName, interfacePattern: migrationPattern }); - } - nodeIndex++; - } - return migrations; -} diff --git a/src/reactTopoViewer/shared/parsing/SpecialNodeHandler.ts b/src/reactTopoViewer/shared/parsing/SpecialNodeHandler.ts deleted file mode 100644 index 0645a6f0f..000000000 --- a/src/reactTopoViewer/shared/parsing/SpecialNodeHandler.ts +++ /dev/null @@ -1,456 +0,0 @@ -/** - * Special node handler for host, mgmt-net, macvlan, vxlan, etc. - * Pure functions - no VS Code dependencies. - */ - -import type { - ClabNode, - ParsedElement, - ClabTopology, - NetworkNodeAnnotation, - TopologyAnnotations -} from "../types/topology"; - -import type { SpecialNodeType } from "./LinkNormalizer"; -import { - TYPES, - PREFIX_MACVLAN, - PREFIX_VXLAN_STITCH, - NODE_KIND_BRIDGE, - NODE_KIND_OVS_BRIDGE, - splitEndpoint, - normalizeLinkToTwoEndpoints, - createDummyContext -} from "./LinkNormalizer"; -import type { DummyContext, SpecialNodeInfo } from "./types"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isEndpointInput(value: unknown): value is string | { node: string; interface?: string } { - if (typeof value === "string") return true; - if (!isRecord(value)) return false; - if (typeof value.node !== "string") return false; - return value.interface === undefined || typeof value.interface === "string"; -} - -// ============================================================================ -// Special Node Initialization -// ============================================================================ - -/** - * Initializes special nodes from the topology nodes (bridges). - */ -export function initSpecialNodes(nodes?: Record): Map { - const specialNodes = new Map(); - if (!nodes) return specialNodes; - for (const [nodeName, nodeData] of Object.entries(nodes)) { - if (!isRecord(nodeData)) continue; - const kind = typeof nodeData.kind === "string" ? nodeData.kind : undefined; - if (kind === NODE_KIND_BRIDGE || kind === NODE_KIND_OVS_BRIDGE) { - specialNodes.set(nodeName, { - id: nodeName, - type: kind === NODE_KIND_BRIDGE ? NODE_KIND_BRIDGE : NODE_KIND_OVS_BRIDGE, - label: nodeName - }); - } - } - return specialNodes; -} - -// ============================================================================ -// Special Node Detection -// ============================================================================ - -/** - * Determines if a node is a special endpoint type. - */ -export function determineSpecialNode( - node: string, - iface: string -): { id: string; type: SpecialNodeType; label: string } | null { - if (node === "host") - return { id: `host:${iface}`, type: "host", label: `host:${iface || "host"}` }; - if (node === "mgmt-net") - return { id: `mgmt-net:${iface}`, type: "mgmt-net", label: `mgmt-net:${iface || "mgmt-net"}` }; - if (node.startsWith(PREFIX_MACVLAN)) return { id: node, type: "macvlan", label: node }; - if (node.startsWith(PREFIX_VXLAN_STITCH)) - return { id: node, type: TYPES.VXLAN_STITCH as SpecialNodeType, label: node }; - if (node.startsWith("vxlan:")) return { id: node, type: "vxlan", label: node }; - if (node.startsWith("dummy")) return { id: node, type: "dummy", label: node }; - return null; -} - -/** - * Registers an endpoint as a special node if applicable. - * Bare endpoints (no interface) that aren't topology nodes are treated as implicit bridges. - */ -export function registerEndpoint( - specialNodes: Map, - end: unknown, - topologyNodeNames?: Set -): void { - if (!isEndpointInput(end)) return; - const { node, iface } = splitEndpoint(end); - const info = determineSpecialNode(node, iface); - if (info) { - specialNodes.set(info.id, { id: info.id, type: info.type, label: info.label }); - } else if ( - iface === "" && - (topologyNodeNames === undefined || !topologyNodeNames.has(node)) && - !specialNodes.has(node) - ) { - // Bare endpoint not in topology nodes - treat as implicit bridge - specialNodes.set(node, { - id: node, - type: NODE_KIND_BRIDGE, - label: node - }); - } -} - -/** - * Gets the special ID for an endpoint. - */ -export function getSpecialId(end: unknown): string | null { - if (!isEndpointInput(end)) return null; - const { node, iface } = splitEndpoint(end); - if (node === "host") return `host:${iface}`; - if (node === "mgmt-net") return `mgmt-net:${iface}`; - if (node.startsWith(PREFIX_MACVLAN)) return node; - if (node.startsWith(PREFIX_VXLAN_STITCH)) return node; - if (node.startsWith("vxlan:")) return node; - if (node.startsWith("dummy")) return node; - return null; -} - -// ============================================================================ -// Link Property Extraction -// ============================================================================ - -/** - * Converts a value to string. Handles numbers, strings, and other types. - */ -function toStr(val: unknown): string { - if (val === undefined || val === null) return ""; - if (typeof val === "string") return val; - if (typeof val === "number") return String(val); - return String(val); -} - -/** - * Assigns common link properties to base props. - * MTU is converted to string for consistent editor handling. - */ -export function assignCommonLinkProps( - linkObj: Record, - baseProps: Record -): void { - if (linkObj.mtu !== undefined) baseProps.extMtu = toStr(linkObj.mtu); - if (linkObj.vars !== undefined) baseProps.extVars = linkObj.vars; - if (linkObj.labels !== undefined) baseProps.extLabels = linkObj.labels; -} - -/** - * Assigns host/mgmt-net specific properties. - */ -export function assignHostMgmtProps( - linkType: string, - linkObj: Record, - baseProps: Record -): void { - if (!["host", "mgmt-net", "macvlan"].includes(linkType)) return; - if (linkObj["host-interface"] !== undefined) - baseProps.extHostInterface = linkObj["host-interface"]; - if (linkType === "macvlan" && linkObj.mode !== undefined) baseProps.extMode = linkObj.mode; -} - -/** - * Assigns vxlan/vxlan-stitch specific properties. - * Converts all values to strings for consistent handling in the editor. - */ -export function assignVxlanProps( - linkType: string, - linkObj: Record, - baseProps: Record -): void { - if (linkType !== TYPES.VXLAN && linkType !== TYPES.VXLAN_STITCH) return; - if (linkObj.remote !== undefined) baseProps.extRemote = toStr(linkObj.remote); - if (linkObj.vni !== undefined) baseProps.extVni = toStr(linkObj.vni); - if (linkObj["dst-port"] !== undefined) baseProps.extDstPort = toStr(linkObj["dst-port"]); - if (linkObj["src-port"] !== undefined) baseProps.extSrcPort = toStr(linkObj["src-port"]); -} - -/** - * Builds base properties for a special node. - */ -export function buildBaseProps( - linkObj: Record, - linkType: string -): Record { - const baseProps: Record = { extType: linkType }; - assignCommonLinkProps(linkObj, baseProps); - assignHostMgmtProps(linkType, linkObj, baseProps); - assignVxlanProps(linkType, linkObj, baseProps); - const endpoint = isRecord(linkObj.endpoint) ? linkObj.endpoint : undefined; - const epMac = endpoint?.mac; - if (epMac !== undefined) baseProps.extMac = epMac; - return baseProps; -} - -/** - * Merges special node properties from a link object. - */ -export function mergeSpecialNodeProps( - linkObj: Record, - endA: unknown, - endB: unknown, - specialNodeProps: Map> -): void { - const linkType = typeof linkObj.type === "string" ? String(linkObj.type) : ""; - if (linkType === "" || linkType === "veth") return; - - const ids = [getSpecialId(endA), getSpecialId(endB)]; - const baseProps = buildBaseProps(linkObj, linkType); - ids.forEach((id) => { - if (id === null || id === "") return; - const prev = specialNodeProps.get(id) ?? {}; - specialNodeProps.set(id, { ...prev, ...baseProps }); - }); -} - -// ============================================================================ -// Special Node Collection -// ============================================================================ - -/** - * Collects special nodes from the topology. - */ -export function collectSpecialNodes( - parsed: ClabTopology, - ctx?: DummyContext -): { - specialNodes: Map; - specialNodeProps: Map>; -} { - const dummyCtx = ctx ?? createDummyContext(); - const specialNodes = initSpecialNodes(parsed.topology?.nodes); - const specialNodeProps: Map> = new Map(); - const links = parsed.topology?.links; - if (!links) return { specialNodes, specialNodeProps }; - - // Create set of topology node names to distinguish explicit nodes from implicit bridges - const topologyNodeNames = new Set(Object.keys(parsed.topology?.nodes ?? {})); - - for (const linkObj of links) { - if (!isRecord(linkObj)) continue; - const norm = normalizeLinkToTwoEndpoints(linkObj, dummyCtx); - if (!norm) continue; - const { endA, endB } = norm; - registerEndpoint(specialNodes, endA, topologyNodeNames); - registerEndpoint(specialNodes, endB, topologyNodeNames); - mergeSpecialNodeProps(linkObj, endA, endB, specialNodeProps); - } - - return { specialNodes, specialNodeProps }; -} - -// ============================================================================ -// Network Node Creation -// ============================================================================ - -interface PlacementResult { - position: { x: number; y: number }; - label?: string; - geoCoordinates?: { lat: number; lng: number }; - group?: string; - level?: string; -} - -/** - * Checks if a special node should be skipped (already created by addNodeElements). - */ -function shouldSkipNetworkNode( - nodeId: string, - nodeInfo: SpecialNodeInfo, - yamlNodeIds?: Set -): boolean { - const isBridgeType = nodeInfo.type === NODE_KIND_BRIDGE || nodeInfo.type === NODE_KIND_OVS_BRIDGE; - return isBridgeType && (yamlNodeIds?.has(nodeId) ?? false); -} - -/** - * Extract placement from a network annotation. - */ -function extractNetworkPlacement(saved: NetworkNodeAnnotation): PlacementResult { - return { - position: saved.position, - label: saved.label, - geoCoordinates: saved.geoCoordinates, - group: saved.group, - level: saved.level - }; -} - -/** - * Resolves position and label from network node annotations. - */ -function resolveNetworkNodePlacement( - nodeId: string, - annotations?: TopologyAnnotations -): PlacementResult { - const networkSaved = annotations?.networkNodeAnnotations?.find((nn) => nn.id === nodeId); - if (networkSaved) { - return extractNetworkPlacement(networkSaved); - } - - return { position: { x: 0, y: 0 } }; -} - -/** - * Creates a network node element. - */ -function createNetworkNodeElement( - nodeId: string, - nodeInfo: SpecialNodeInfo, - placement: PlacementResult, - extraProps: Record -): ParsedElement { - const displayLabel = placement.label ?? nodeInfo.label ?? nodeId; - return { - group: "nodes", - data: { - id: nodeId, - weight: "30", - name: displayLabel, - topoViewerRole: nodeInfo.type as string, - lat: placement.geoCoordinates?.lat.toString() ?? "", - lng: placement.geoCoordinates?.lng.toString() ?? "", - extraData: { - clabServerUsername: "", - fqdn: "", - group: placement.group ?? "", - level: placement.level ?? "", - id: nodeId, - image: "", - index: "999", - kind: nodeInfo.type, - type: nodeInfo.type, - labdir: "", - labels: {}, - longname: nodeId, - macAddress: "", - mgmtIntf: "", - mgmtIpv4AddressLength: 0, - mgmtIpv4Address: "", - mgmtIpv6Address: "", - mgmtIpv6AddressLength: 0, - mgmtNet: "", - name: displayLabel, - shortname: displayLabel, - state: "", - weight: "3", - ...extraProps - } - }, - position: placement.position, - removed: false, - selected: false, - selectable: true, - locked: false, - grabbed: false, - grabbable: true, - classes: "special-endpoint" - }; -} - -/** - * Creates a SpecialNodeInfo from a network annotation type. - */ -function networkTypeToSpecialInfo( - id: string, - type: SpecialNodeInfo["type"], - label?: string -): SpecialNodeInfo { - return { - id, - type, - label: label ?? id - }; -} - -/** - * Adds network nodes from networkNodeAnnotations that don't have corresponding YAML links. - * This allows network nodes to persist even when their links are deleted. - */ -function addOrphanedNetworkNodes( - result: ParsedElement[], - annotations?: TopologyAnnotations, - specialNodes?: Map, - specialNodeProps?: Map> -): void { - const networkAnnotations = annotations?.networkNodeAnnotations; - if (networkAnnotations === undefined || networkAnnotations.length === 0) return; - - // Track which node IDs are already in result - const existingIds = new Set( - result.map((el) => (typeof el.data.id === "string" ? el.data.id : "")).filter((id) => id !== "") - ); - - for (const annotation of networkAnnotations) { - // Skip if already created from YAML links - if (existingIds.has(annotation.id)) continue; - if (specialNodes !== undefined && specialNodes.has(annotation.id)) continue; - - // Create network node from annotation - const nodeInfo = networkTypeToSpecialInfo(annotation.id, annotation.type, annotation.label); - const placement = extractNetworkPlacement(annotation); - const extraProps = specialNodeProps?.get(annotation.id) ?? {}; - const networkNodeEl = createNetworkNodeElement(annotation.id, nodeInfo, placement, extraProps); - result.push(networkNodeEl); - } -} - -/** - * Adds network nodes (special nodes) to the elements array. - */ -export function addNetworkNodes( - specialNodes: Map, - specialNodeProps: Map>, - annotations?: TopologyAnnotations, - elements?: ParsedElement[], - yamlNodeIds?: Set -): ParsedElement[] { - const result = elements ?? []; - for (const [nodeId, nodeInfo] of specialNodes) { - if (shouldSkipNetworkNode(nodeId, nodeInfo, yamlNodeIds)) continue; - - const placement = resolveNetworkNodePlacement(nodeId, annotations); - const extraProps = specialNodeProps.get(nodeId) ?? {}; - const networkNodeEl = createNetworkNodeElement(nodeId, nodeInfo, placement, extraProps); - result.push(networkNodeEl); - } - - // Also add network nodes from annotations that don't have YAML links - addOrphanedNetworkNodes(result, annotations, specialNodes, specialNodeProps); - - return result; -} - -/** - * Checks if a node is a special node type (bridge, host, etc.). - */ -export function isSpecialNode( - nodeId: string, - specialNodes?: Map -): boolean { - if (specialNodes !== undefined && specialNodes.has(nodeId)) return true; - if (nodeId.startsWith("host:")) return true; - if (nodeId.startsWith("mgmt-net:")) return true; - if (nodeId.startsWith(PREFIX_MACVLAN)) return true; - if (nodeId.startsWith(PREFIX_VXLAN_STITCH)) return true; - if (nodeId.startsWith("vxlan:")) return true; - if (nodeId.startsWith("dummy")) return true; - return false; -} diff --git a/src/reactTopoViewer/shared/parsing/TopologyParser.ts b/src/reactTopoViewer/shared/parsing/TopologyParser.ts deleted file mode 100644 index be97fa4d5..000000000 --- a/src/reactTopoViewer/shared/parsing/TopologyParser.ts +++ /dev/null @@ -1,313 +0,0 @@ -/** - * Main topology parser - orchestrates all parsing operations. - * Pure functions - no VS Code dependencies. - */ - -import * as YAML from "yaml"; - -import type { ClabTopology, ParsedElement, TopologyAnnotations } from "../types/topology"; -import { convertElementsToTopologyData } from "../utilities/elementConversions"; - -import type { - ParseOptions, - ParseResult, - ParseResultRF, - ContainerDataProvider, - DummyContext, - InterfacePatternMigration, - GraphLabelMigration, - ParserLogger -} from "./types"; -import { nullLogger } from "./types"; -import { computeFullPrefix, getLabName, getTopologyNodeIds, isPresetLayout } from "./utils"; -import { addNodeElements } from "./NodeElementBuilder"; -import { addEdgeElements } from "./EdgeElementBuilder"; -import { collectSpecialNodes, addNetworkNodes } from "./SpecialNodeHandler"; -import { - addAliasNodesFromAnnotations, - applyAliasMappingsToEdges, - hideBaseBridgeNodesWithAliases -} from "./AliasNodeHandler"; -import { createDummyContext } from "./LinkNormalizer"; -import { detectGraphLabelMigrations, applyGraphLabelMigrations } from "./GraphLabelMigrator"; - -// ============================================================================ -// Main Parser Class -// ============================================================================ - -/** - * Topology parser for converting Containerlab YAML to ReactFlow elements. - * - * @example Basic usage (dev server) - * ```typescript - * const result = TopologyParser.parseToReactFlow(yamlContent, { annotations }); - * ``` - * - * @example With container enrichment (VS Code extension) - * ```typescript - * const adapter = new ContainerDataAdapter(clabTreeData); - * const result = TopologyParser.parseToReactFlow(yamlContent, { - * annotations, - * containerDataProvider: adapter, - * logger: vscodeLogger - * }); - * ``` - */ -export class TopologyParser { - /** - * Parses YAML content into parsed elements (internal format). - * - * @param yamlContent - The YAML content to parse - * @param options - Parse options including annotations and container data - * @returns Parse result with elements, migrations, and metadata - */ - static parse(yamlContent: string, options: ParseOptions = {}): ParseResult { - const doc = YAML.parseDocument(yamlContent); - const parsed = normalizeParsedTopology(doc.toJS()); - return TopologyParser.parseFromParsed(parsed, options); - } - - /** - * Parses a pre-parsed topology object into parsed elements (internal format). - * Use this to avoid redundant YAML parsing when a document has already been parsed. - */ - static parseFromParsed( - parsed: ClabTopology | null | undefined, - options: ParseOptions = {} - ): ParseResult { - const log = options.logger ?? nullLogger; - const normalizedParsed = normalizeParsedTopology(parsed); - - // Get basic info - const labName = options.labName ?? getLabName(normalizedParsed); - const prefix = computeFullPrefix(normalizedParsed, labName); - - // Handle annotations - detect graph label migrations - let annotations = options.annotations; - let graphLabelMigrations: GraphLabelMigration[] = []; - - if (normalizedParsed.topology?.nodes) { - const migrations = detectGraphLabelMigrations(normalizedParsed, annotations); - if (migrations.length > 0) { - graphLabelMigrations = migrations; - annotations = applyGraphLabelMigrations(annotations, migrations); - migrations.forEach((m) => { - log.info(`Detected graph-* labels for node ${m.nodeId} that need migration`); - }); - } - } - - // Build elements - const result = TopologyParser.buildElements(normalizedParsed, { - annotations, - containerDataProvider: options.containerDataProvider, - logger: log, - labName, - prefix - }); - - return { - elements: result.elements, - labName, - prefix, - isPresetLayout: result.isPresetLayout, - pendingMigrations: result.interfacePatternMigrations, - graphLabelMigrations - }; - } - - /** - * Parses YAML content into ReactFlow nodes and edges. - * Use this for new code instead of parse(). - * - * @param yamlContent - The YAML content to parse - * @param options - Parse options including annotations and container data - * @returns Parse result with ReactFlow-format nodes and edges - */ - static parseToReactFlow(yamlContent: string, options: ParseOptions = {}): ParseResultRF { - const result = TopologyParser.parse(yamlContent, options); - return TopologyParser.toReactFlowResult(result); - } - - /** - * Parses a pre-parsed topology object into ReactFlow nodes/edges. - */ - static parseToReactFlowFromParsed( - parsed: ClabTopology | null | undefined, - options: ParseOptions = {} - ): ParseResultRF { - const result = TopologyParser.parseFromParsed(parsed, options); - return TopologyParser.toReactFlowResult(result); - } - - private static toReactFlowResult(result: ParseResult): ParseResultRF { - const topology = convertElementsToTopologyData(result.elements); - - return { - topology, - labName: result.labName, - prefix: result.prefix, - isPresetLayout: result.isPresetLayout, - pendingMigrations: result.pendingMigrations, - graphLabelMigrations: result.graphLabelMigrations - }; - } - - /** - * Parses YAML for editor mode and returns ReactFlow format. - */ - static parseForEditorRF(yamlContent: string, annotations?: TopologyAnnotations): ParseResultRF { - return TopologyParser.parseToReactFlow(yamlContent, { annotations }); - } - - /** - * Parses a pre-parsed topology object for editor mode and returns ReactFlow format. - */ - static parseForEditorRFParsed( - parsed: ClabTopology | null | undefined, - annotations?: TopologyAnnotations - ): ParseResultRF { - return TopologyParser.parseToReactFlowFromParsed(parsed, { annotations }); - } - - // ============================================================================ - // Internal Methods - // ============================================================================ - - /** - * Builds parsed elements from topology YAML. - */ - private static buildElements( - parsed: ClabTopology, - options: { - annotations?: TopologyAnnotations; - containerDataProvider?: ContainerDataProvider; - logger?: ParserLogger; - labName: string; - prefix: string; - } - ): { - elements: ParsedElement[]; - isPresetLayout: boolean; - interfacePatternMigrations: InterfacePatternMigration[]; - } { - const log = options.logger ?? nullLogger; - const elements: ParsedElement[] = []; - - if (!parsed.topology) { - log.warn("Parsed YAML does not contain 'topology' object."); - return { elements, isPresetLayout: false, interfacePatternMigrations: [] }; - } - - // Check preset layout - const preset = isPresetLayout(parsed, options.annotations); - log.info(`Preset layout status: ${preset}`); - - // Build options for node/edge builders - const buildOpts = { - includeContainerData: Boolean(options.containerDataProvider), - containerDataProvider: options.containerDataProvider, - annotations: options.annotations, - logger: options.logger - }; - - // Add node elements - const migrations = addNodeElements( - parsed, - buildOpts, - options.prefix, - options.labName, - elements - ); - - // Collect and add special nodes - const ctx: DummyContext = createDummyContext(); - const { specialNodes, specialNodeProps } = collectSpecialNodes(parsed, ctx); - const yamlNodeIds = getTopologyNodeIds(parsed); - addNetworkNodes(specialNodes, specialNodeProps, options.annotations, elements, yamlNodeIds); - - // Add edge elements - addEdgeElements( - parsed, - buildOpts, - options.prefix, - options.labName, - specialNodes, - ctx, - elements - ); - - // Track logged bridges for alias handling - const loggedUnmappedBaseBridges = new Set(); - - // Add alias nodes - addAliasNodesFromAnnotations(parsed, options.annotations, elements); - - // Rewire edges to alias nodes - applyAliasMappingsToEdges(options.annotations, elements); - - // Hide base bridge nodes that have aliases - hideBaseBridgeNodesWithAliases(elements, loggedUnmappedBaseBridges, options.logger); - - log.info(`Transformed YAML to graph elements. Total elements: ${elements.length}`); - - return { - elements, - isPresetLayout: preset, - interfacePatternMigrations: migrations - }; - } -} - -// ============================================================================ -// Convenience Functions -// ============================================================================ - -/** - * Parses a topology YAML string to ReactFlow format. - * Convenience function that wraps TopologyParser.parseToReactFlow(). - */ -export function parseTopologyToReactFlow( - yamlContent: string, - options?: ParseOptions -): ParseResultRF { - return TopologyParser.parseToReactFlow(yamlContent, options); -} - -/** - * Parses a pre-parsed topology object to ReactFlow format. - */ -export function parseTopologyToReactFlowFromParsed( - parsed: ClabTopology | null | undefined, - options?: ParseOptions -): ParseResultRF { - return TopologyParser.parseToReactFlowFromParsed(parsed, options); -} - -/** - * Parses a topology for editor mode to ReactFlow format. - * Convenience function that wraps TopologyParser.parseForEditorRF(). - */ -export function parseTopologyForEditorRF( - yamlContent: string, - annotations?: TopologyAnnotations -): ParseResultRF { - return TopologyParser.parseForEditorRF(yamlContent, annotations); -} - -/** - * Parses a pre-parsed topology object for editor mode. - */ -export function parseTopologyForEditorRFParsed( - parsed: ClabTopology | null | undefined, - annotations?: TopologyAnnotations -): ParseResultRF { - return TopologyParser.parseForEditorRFParsed(parsed, annotations); -} - -function normalizeParsedTopology(parsed: unknown): ClabTopology { - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - return parsed as ClabTopology; - } - return {}; -} diff --git a/src/reactTopoViewer/shared/parsing/index.ts b/src/reactTopoViewer/shared/parsing/index.ts deleted file mode 100644 index 60ec433ef..000000000 --- a/src/reactTopoViewer/shared/parsing/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Shared Topology Parser - * - * This module provides a VS Code-free topology parser that can be used by both - * the production extension and the dev server. It converts containerlab YAML - * topologies to ReactFlow nodes and edges. - * - * @example Basic usage (dev server) - * ```typescript - * import { TopologyParser } from '@shared/parsing'; - * const result = TopologyParser.parseToReactFlow(yamlContent, { annotations }); - * // result.topology contains { nodes: TopoNode[], edges: TopoEdge[] } - * ``` - * - * @example With container enrichment (VS Code extension) - * ```typescript - * import { TopologyParser } from '@shared/parsing'; - * import { ContainerDataAdapter } from './ContainerDataAdapter'; - * - * const adapter = new ContainerDataAdapter(clabTreeData); - * const result = TopologyParser.parseToReactFlow(yamlContent, { - * annotations, - * containerDataProvider: adapter, - * logger: vscodeLogger - * }); - * ``` - * - * For internal utilities, import directly from sub-modules: - * - `./NodeElementBuilder` - node element building - * - `./EdgeElementBuilder` - edge element building - * - `./SpecialNodeHandler` - special node handling (host, mgmt, vxlan) - * - `./AliasNodeHandler` - bridge alias handling - * - `./LinkNormalizer` - link endpoint normalization - * - `./DistributedSrosMapper` - SR OS distributed interface mapping - * - `./GraphLabelMigrator` - graph label migration - * - `./InterfacePatternResolver` - interface pattern resolution - */ - -// Main parser API -export { - TopologyParser, - parseTopologyToReactFlow, - parseTopologyToReactFlowFromParsed, - parseTopologyForEditorRF, - parseTopologyForEditorRFParsed -} from "./TopologyParser"; - -// Core types -export type { - ParseOptions, - ParseResultRF, - ContainerDataProvider, - ContainerInfo, - InterfaceInfo, - ParserLogger, - InterfacePatternMigration, - GraphLabelMigration, - NodeBuildContext, - EdgeBuildContext, - NodeRole, - TopologyData -} from "./types"; - -// Re-export topology types for convenience -export type { - ClabTopology, - ParsedElement, - TopologyAnnotations, - NodeAnnotation, - NetemState -} from "./types"; - -// Constants and utilities from types -export { nullLogger, ROUTER_KINDS, CLIENT_KINDS, detectRole } from "./types"; - -// Node config resolver (commonly used) -export { resolveNodeConfig } from "./NodeConfigResolver"; - -// Commonly used utilities -export { computeFullPrefix, getLabName, getTopologyNodeIds, isPresetLayout } from "./utils"; - -// Edge stats (used by EdgeStatsBuilder) -export { extractEdgeInterfaceStats, computeEdgeClassFromStates } from "./EdgeElementBuilder"; - -// Interface patterns -export { DEFAULT_INTERFACE_PATTERNS } from "../constants/interfacePatterns"; diff --git a/src/reactTopoViewer/shared/parsing/types.ts b/src/reactTopoViewer/shared/parsing/types.ts deleted file mode 100644 index a29963ac3..000000000 --- a/src/reactTopoViewer/shared/parsing/types.ts +++ /dev/null @@ -1,388 +0,0 @@ -/** - * Parser-specific type definitions for the shared topology parser. - * These types abstract away VS Code dependencies to enable use in both - * the production extension and the dev server. - */ - -import type { - ClabTopology, - ParsedElement, - TopologyAnnotations, - NodeAnnotation, - InterfaceStatsPayload -} from "../types/topology"; -import type { TopologyData } from "../types/graph"; - -// Re-export commonly used types for convenience -export type { ClabTopology, ParsedElement, TopologyAnnotations, NodeAnnotation, TopologyData }; - -// ============================================================================ -// Parser Options and Results -// ============================================================================ - -/** - * Options for parsing a topology. - */ -export interface ParseOptions { - /** Annotations to merge with topology */ - annotations?: TopologyAnnotations; - /** Container data provider for runtime enrichment (VS Code extension provides this) */ - containerDataProvider?: ContainerDataProvider; - /** Logger interface (optional) */ - logger?: ParserLogger; - /** Lab name for container lookups (if different from topology name) */ - labName?: string; -} - -/** - * Result from parsing a topology (internal ParsedElement format). - * For external consumers, use ParseResultRF with TopologyData (ReactFlow format). - */ -export interface ParseResult { - /** Parsed elements (nodes and edges) */ - elements: ParsedElement[]; - /** Lab name from topology */ - labName: string; - /** Container name prefix (e.g., "clab-labname") */ - prefix: string; - /** Whether all nodes have preset positions from annotations */ - isPresetLayout: boolean; - /** Interface pattern migrations that need to be persisted */ - pendingMigrations: InterfacePatternMigration[]; - /** Graph-label migrations detected (need YAML modification) */ - graphLabelMigrations: GraphLabelMigration[]; -} - -/** - * Result from parsing a topology (ReactFlow format). - * Use this for new code instead of ParseResult. - */ -export interface ParseResultRF { - /** Topology data with nodes and edges in ReactFlow format */ - topology: TopologyData; - /** Lab name from topology */ - labName: string; - /** Container name prefix (e.g., "clab-labname") */ - prefix: string; - /** Whether all nodes have preset positions from annotations */ - isPresetLayout: boolean; - /** Interface pattern migrations that need to be persisted */ - pendingMigrations: InterfacePatternMigration[]; - /** Graph-label migrations detected (need YAML modification) */ - graphLabelMigrations: GraphLabelMigration[]; -} - -// ============================================================================ -// Container Data Abstraction (VS Code-free interface) -// ============================================================================ - -/** - * Abstract interface for container data. - * VS Code extension implements this to provide runtime container info. - * Dev server doesn't use this (passes undefined). - */ -export interface ContainerDataProvider { - /** - * Find a container by name. - */ - findContainer(containerName: string, labName: string): ContainerInfo | undefined; - - /** - * Find an interface within a container. - */ - findInterface( - containerName: string, - ifaceName: string, - labName: string - ): InterfaceInfo | undefined; - - /** - * Find a distributed SROS interface across component containers. - */ - findDistributedSrosInterface?(params: { - baseNodeName: string; - ifaceName: string; - fullPrefix: string; - labName: string; - components: unknown[]; - }): { containerName: string; ifaceData?: InterfaceInfo } | undefined; - - /** - * Find a distributed SROS container. - */ - findDistributedSrosContainer?(params: { - baseNodeName: string; - fullPrefix: string; - labName: string; - components: unknown[]; - }): ContainerInfo | undefined; -} - -/** - * Container info without VS Code dependencies. - * Maps to ClabContainerTreeNode fields. - */ -export interface ContainerInfo { - /** Full container name (e.g., "clab-labname-node1") */ - name: string; - /** Short name without prefix (e.g., "node1") */ - name_short: string; - /** Root node name for grouped/distributed containers */ - rootNodeName?: string; - /** Container state (e.g., "running", "stopped") */ - state: string; - /** Node kind (e.g., "nokia_srlinux") */ - kind: string; - /** Container image */ - image: string; - /** IPv4 address without CIDR mask */ - IPv4Address: string; - /** IPv6 address without CIDR mask */ - IPv6Address: string; - /** Node type */ - nodeType?: string; - /** Node group */ - nodeGroup?: string; - /** Container interfaces */ - interfaces: InterfaceInfo[]; - /** Container label (may be TreeItemLabel in VS Code, but string here) */ - label?: string; -} - -/** - * Netem fields - */ - -export interface NetemState { - delay?: string; - jitter?: string; - loss?: string; - rate?: string; - corruption?: string; -} - -/** - * Interface info without VS Code dependencies. - * Maps to ClabInterfaceTreeNode fields. - */ -export interface InterfaceInfo { - /** Interface name (e.g., "eth1") */ - name: string; - /** Interface alias (e.g., "ge-0/0/1") */ - alias: string; - /** MAC address */ - mac: string; - /** MTU */ - mtu: number; - /** Interface state (e.g., "up", "down") */ - state: string; - /** Interface type (e.g., "veth", "dummy") */ - type: string; - /** Interface index */ - ifIndex?: number; - /** Traffic statistics */ - stats?: InterfaceStatsPayload; - /** Netem states */ - netemState?: NetemState; -} - -// ============================================================================ -// Logger Abstraction -// ============================================================================ - -/** - * Logger interface for optional logging. - * VS Code extension can provide a logger that writes to output channel. - * Dev server can use console or no-op logger. - */ -export interface ParserLogger { - info(msg: string): void; - warn(msg: string): void; - debug(msg: string): void; - error(msg: string): void; -} - -/** - * No-op logger for when logging is not needed. - */ -export const nullLogger: ParserLogger = { - info: () => {}, - warn: () => {}, - debug: () => {}, - error: () => {} -}; - -// ============================================================================ -// Migration Types -// ============================================================================ - -/** - * Represents an interface pattern that needs to be migrated to annotations. - */ -export interface InterfacePatternMigration { - /** Node ID in the topology */ - nodeId: string; - /** Interface pattern to save (e.g., "eth{port}") */ - interfacePattern: string; -} - -/** - * Represents a graph-* label migration from YAML to annotations. - */ -export interface GraphLabelMigration { - /** Node ID in the topology */ - nodeId: string; - /** Position from graph-x/graph-y labels */ - position?: { x: number; y: number }; - /** Icon from graph-icon label */ - icon?: string; - /** Group from graph-group label */ - group?: string; - /** Level from graph-level label */ - level?: string; - /** Group label position from graph-group-label-pos */ - groupLabelPos?: string; - /** Geographic coordinates from graph-lat/graph-lng */ - geoCoordinates?: { lat: number; lng: number }; -} - -// ============================================================================ -// Build Context Types -// ============================================================================ - -/** - * Context for building node elements. - */ -export interface NodeBuildContext { - /** Parsed topology object */ - topology: ClabTopology; - /** Container name prefix */ - fullPrefix: string; - /** Lab name */ - labName: string; - /** Node annotations map (nodeId -> annotation) */ - nodeAnnotationsMap: Map; - /** Container data provider (optional) */ - containerDataProvider?: ContainerDataProvider; - /** Logger (optional) */ - logger?: ParserLogger; -} - -/** - * Context for building edge elements. - */ -export interface EdgeBuildContext { - /** Parsed topology object */ - topology: ClabTopology; - /** Container name prefix */ - fullPrefix: string; - /** Lab name */ - labName: string; - /** Node annotations map (nodeId -> annotation) */ - nodeAnnotationsMap: Map; - /** Container data provider (optional) */ - containerDataProvider?: ContainerDataProvider; - /** Logger (optional) */ - logger?: ParserLogger; - /** Set of node IDs that exist in the topology */ - nodeIds: Set; -} - -/** - * Context for tracking special nodes (dummy, vxlan, etc.) across link processing. - */ -export interface DummyContext { - /** Counter for generating unique dummy IDs */ - dummyCounter: number; - /** Map from link object to generated dummy ID */ - dummyLinkMap: Map; - /** Counter for generating unique vxlan IDs */ - vxlanCounter: number; - /** Map from link object to generated vxlan ID */ - vxlanLinkMap: Map; - /** Counter for generating unique vxlan-stitch IDs */ - vxlanStitchCounter: number; - /** Map from link object to generated vxlan-stitch ID */ - vxlanStitchLinkMap: Map; -} - -// ============================================================================ -// Special Node Types -// ============================================================================ - -/** - * Information about a special node (host, mgmt-net, bridge, etc.). - */ -export interface SpecialNodeInfo { - /** Node ID (e.g., "host:eth0", "mgmt-net:eth1") */ - id: string; - /** Node type */ - type: - | "host" - | "mgmt-net" - | "macvlan" - | "vxlan" - | "vxlan-stitch" - | "dummy" - | "bridge" - | "ovs-bridge"; - /** Display label */ - label?: string; - /** Position from annotations */ - position?: { x: number; y: number }; - /** Geographic coordinates */ - geoCoordinates?: { lat: number; lng: number }; - /** Group membership */ - group?: string; - /** Level within group */ - level?: string; -} - -// ============================================================================ -// Role Detection -// ============================================================================ - -/** - * Node role for visual styling. - */ -export type NodeRole = "router" | "client" | "default" | "cloud"; - -/** - * Kinds that are considered routers. - */ -export const ROUTER_KINDS = new Set([ - "nokia_srlinux", - "nokia_sros", - "nokia_srsim", - "arista_ceos", - "arista_veos", - "cisco_xrd", - "cisco_xrv", - "cisco_xrv9k", - "juniper_crpd", - "juniper_vjunos_router", - "juniper_vjunos_switch", - "juniper_vmx", - "juniper_vqfx", - "juniper_vsrx", - "frr", - "gobgp", - "bird", - "openbgpd" -]); - -/** - * Kinds that are considered clients. - */ -export const CLIENT_KINDS = new Set(["linux", "alpine", "debian", "ubuntu", "centos", "rocky"]); - -/** - * Detect the role of a node based on its kind. - */ -export function detectRole(kind: string | undefined): NodeRole { - if (kind === undefined || kind.length === 0) return "default"; - const k = kind.toLowerCase(); - if (ROUTER_KINDS.has(k)) return "router"; - if (CLIENT_KINDS.has(k)) return "client"; - return "default"; -} diff --git a/src/reactTopoViewer/shared/parsing/utils.ts b/src/reactTopoViewer/shared/parsing/utils.ts deleted file mode 100644 index 1195da4d7..000000000 --- a/src/reactTopoViewer/shared/parsing/utils.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Utility functions for topology parsing. - * Pure functions - no VS Code dependencies. - */ - -import type { ClabTopology, NodeAnnotation, TopologyAnnotations } from "../types/topology"; - -/** - * Computes the full prefix for container names. - * - * @param parsed - The parsed topology - * @param clabName - The lab name - * @returns The full prefix (e.g., "clab-labname") - */ -export function computeFullPrefix(parsed: ClabTopology, clabName: string): string { - if (parsed.prefix === undefined) { - return `clab-${clabName}`; - } - if (parsed.prefix === "" || parsed.prefix.trim() === "") { - return ""; - } - return `${parsed.prefix.trim()}-${clabName}`; -} - -/** - * Checks if the topology has preset layout (all nodes have positions). - * - * @param parsed - The parsed topology - * @param annotations - Optional annotations object - * @returns True if all nodes have positions in annotations - */ -export function isPresetLayout(parsed: ClabTopology, annotations?: TopologyAnnotations): boolean { - const topology = parsed.topology; - if (!topology || !topology.nodes) return false; - const annotationMap = createNodeAnnotationsMap(annotations); - return Object.keys(topology.nodes).every((nodeName) => { - const ann = annotationMap.get(nodeName); - return ann?.position !== undefined; - }); -} - -/** - * Extracts node visual properties from node annotation. - * - * @param nodeAnn - The node annotation - * @returns Object with visual properties if present - */ -export function extractIconVisuals(nodeAnn: NodeAnnotation | undefined): Record { - const visuals: Record = {}; - if (typeof nodeAnn?.iconColor === "string") { - visuals.iconColor = nodeAnn.iconColor; - } - if (typeof nodeAnn?.iconCornerRadius === "number") { - visuals.iconCornerRadius = nodeAnn.iconCornerRadius; - } - if (typeof nodeAnn?.labelPosition === "string") { - visuals.labelPosition = nodeAnn.labelPosition; - } - if (typeof nodeAnn?.direction === "string") { - visuals.direction = nodeAnn.direction; - } - if (typeof nodeAnn?.labelBackgroundColor === "string") { - visuals.labelBackgroundColor = nodeAnn.labelBackgroundColor; - } - return visuals; -} - -/** - * Sanitizes labels by removing graph-* properties. - * These properties are migrated to annotations and should not be kept in labels. - * - * @param labels - The labels object - * @returns A new labels object without graph-* properties - */ -export function sanitizeLabels( - labels: Record | undefined -): Record { - const cleaned = { ...(labels ?? {}) }; - delete cleaned["graph-posX"]; - delete cleaned["graph-posY"]; - delete cleaned["graph-icon"]; - delete cleaned["graph-geoCoordinateLat"]; - delete cleaned["graph-geoCoordinateLng"]; - delete cleaned["graph-groupLabelPos"]; - delete cleaned["graph-group"]; - delete cleaned["graph-level"]; - return cleaned; -} - -/** - * Gets lat/lng from node annotation. - * - * @param nodeAnn - The node annotation - * @returns Object with lat and lng strings (empty if not present) - */ -export function getNodeLatLng(nodeAnn: NodeAnnotation | undefined): { lat: string; lng: string } { - const geoCoords = nodeAnn?.geoCoordinates; - const lat = geoCoords?.lat !== undefined ? String(geoCoords.lat) : ""; - const lng = geoCoords?.lng !== undefined ? String(geoCoords.lng) : ""; - return { lat, lng }; -} - -/** - * Compute the long name for a node. - * - * @param containerName - Container name if available - * @param fullPrefix - The full prefix - * @param nodeName - The node name - * @returns The long name - */ -export function computeLongname( - containerName: string | undefined, - fullPrefix: string, - nodeName: string -): string { - if (containerName !== undefined && containerName.length > 0) return containerName; - return fullPrefix.length > 0 ? `${fullPrefix}-${nodeName}` : nodeName; -} - -/** - * Creates a map from node ID to NodeAnnotation for fast lookups. - * - * @param annotations - The topology annotations - * @returns Map from node ID to annotation - */ -export function createNodeAnnotationsMap( - annotations?: TopologyAnnotations -): Map { - const map = new Map(); - if (!annotations?.nodeAnnotations) return map; - for (const na of annotations.nodeAnnotations) { - map.set(na.id, na); - } - return map; -} - -/** - * Gets the lab name from topology, with fallback. - * - * @param parsed - The parsed topology - * @returns The lab name or 'topology' as fallback - */ -export function getLabName(parsed: ClabTopology): string { - return parsed.name ?? "topology"; -} - -/** - * Creates a set of node IDs from the topology. - * - * @param parsed - The parsed topology - * @returns Set of node IDs - */ -export function getTopologyNodeIds(parsed: ClabTopology): Set { - return new Set(Object.keys(parsed.topology?.nodes ?? {})); -} diff --git a/src/reactTopoViewer/shared/schema/SchemaParser.ts b/src/reactTopoViewer/shared/schema/SchemaParser.ts deleted file mode 100644 index c7df775bc..000000000 --- a/src/reactTopoViewer/shared/schema/SchemaParser.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * SchemaParser - Utilities for parsing containerlab JSON schema - * - * Shared between VS Code extension and dev server (browser environment). - * Contains only pure parsing functions with no environment-specific dependencies. - */ - -/** - * SROS component types for nokia_srsim nodes - */ -export interface SrosComponentTypes { - sfm: string[]; - cpm: string[]; - card: string[]; - mda: string[]; - xiom: string[]; - xiomMda: string[]; -} - -/** - * Schema data interface for kind/type options - */ -export interface SchemaData { - kinds: string[]; - typesByKind: Record; - srosComponentTypes: SrosComponentTypes; -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function toRecord(value: unknown): Record | undefined { - return isRecord(value) ? value : undefined; -} - -function toString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function toStringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === "string") - : []; -} - -/** - * Extract sorted kinds from schema (Nokia first, then alphabetical) - */ -export function extractKindsFromSchema(schema: Record): string[] { - const definitions = toRecord(schema.definitions); - const nodeConfig = definitions ? toRecord(definitions["node-config"]) : undefined; - const properties = nodeConfig ? toRecord(nodeConfig.properties) : undefined; - const kindProp = properties ? toRecord(properties.kind) : undefined; - const kindsEnum = toStringArray(kindProp?.enum); - const nokiaKinds = kindsEnum.filter((k) => k.startsWith("nokia_")).sort(); - const otherKinds = kindsEnum.filter((k) => !k.startsWith("nokia_")).sort(); - return [...nokiaKinds, ...otherKinds]; -} - -/** - * Get kind name from schema condition pattern - * Patterns can be "(nokia_srlinux)" or "^(nokia_srlinux)$" - */ -function getKindFromPattern(pattern: string | undefined): string | null { - if (pattern === undefined || pattern === "") return null; - const start = pattern.indexOf("("); - const end = pattern.indexOf(")", start + 1); - if (start < 0 || end < 0) return null; - return pattern.slice(start + 1, end); -} - -/** - * Extract type enum values from schema type property - */ -function getTypeEnumValues(typeProp: Record): string[] { - const enumValues = toStringArray(typeProp.enum); - if (enumValues.length > 0) return enumValues; - if (!Array.isArray(typeProp.anyOf)) return []; - return typeProp.anyOf.filter(isRecord).flatMap((opt) => toStringArray(opt.enum)); -} - -/** - * Extract type options for a single condition item - */ -function extractTypesFromCondition( - item: Record -): { kind: string; types: string[] } | null { - const ifConfig = toRecord(item.if); - const ifProps = ifConfig ? toRecord(ifConfig.properties) : undefined; - const kindConfig = ifProps ? toRecord(ifProps.kind) : undefined; - const kindPattern = toString(kindConfig?.pattern); - const kind = getKindFromPattern(kindPattern); - if (kind === null) return null; - - const thenConfig = toRecord(item.then); - const thenProps = thenConfig ? toRecord(thenConfig.properties) : undefined; - const typeProp = thenProps ? toRecord(thenProps.type) : undefined; - if (typeProp === undefined) return null; - - const types = getTypeEnumValues(typeProp); - return types.length > 0 ? { kind, types } : null; -} - -/** - * Extract types by kind from schema allOf conditions - */ -export function extractTypesByKindFromSchema( - schema: Record -): Record { - const typesByKind: Record = {}; - const definitions = toRecord(schema.definitions); - const nodeConfig = definitions ? toRecord(definitions["node-config"]) : undefined; - const allOf = Array.isArray(nodeConfig?.allOf) ? nodeConfig.allOf.filter(isRecord) : []; - - for (const item of allOf) { - const result = extractTypesFromCondition(item); - if (result !== null) { - typesByKind[result.kind] = result.types; - } - } - return typesByKind; -} - -/** - * Extract SROS component types from schema definitions - */ -export function extractSrosComponentTypes(schema: Record): SrosComponentTypes { - const defs = toRecord(schema.definitions) ?? {}; - - const getEnumFromDef = (defName: string): string[] => { - const def = toRecord(defs[defName]); - return toStringArray(def?.enum); - }; - - return { - sfm: getEnumFromDef("sros-sfm-types"), - cpm: getEnumFromDef("sros-cpm-types"), - card: getEnumFromDef("sros-card-types"), - mda: getEnumFromDef("sros-mda-types"), - xiom: getEnumFromDef("sros-xiom-types"), - xiomMda: getEnumFromDef("sros-xiom-mda-types") - }; -} - -/** - * Parse schema and return SchemaData - */ -export function parseSchemaData(schema: Record): SchemaData { - return { - kinds: extractKindsFromSchema(schema), - typesByKind: extractTypesByKindFromSchema(schema), - srosComponentTypes: extractSrosComponentTypes(schema) - }; -} diff --git a/src/reactTopoViewer/shared/schema/index.ts b/src/reactTopoViewer/shared/schema/index.ts deleted file mode 100644 index 46d411cb9..000000000 --- a/src/reactTopoViewer/shared/schema/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Shared schema module - */ -export type { CustomNodeTemplate } from "../types/editors.js"; -export type { SchemaData, SrosComponentTypes } from "./SchemaParser"; -export { - extractKindsFromSchema, - extractTypesByKindFromSchema, - extractSrosComponentTypes, - parseSchemaData -} from "./SchemaParser"; diff --git a/src/reactTopoViewer/shared/types/annotationStyles.ts b/src/reactTopoViewer/shared/types/annotationStyles.ts deleted file mode 100644 index e28d0d491..000000000 --- a/src/reactTopoViewer/shared/types/annotationStyles.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Shared style properties for text annotations and nodes. - */ -export interface TextStyle { - fontSize?: number; - fontColor?: string; - backgroundColor?: string; - fontWeight?: "normal" | "bold"; - fontStyle?: "normal" | "italic"; - textDecoration?: "none" | "underline"; - textAlign?: "left" | "center" | "right"; - fontFamily?: string; - rotation?: number; - width?: number; - height?: number; - roundedBackground?: boolean; -} - -/** - * Shared style properties for box/group containers. - */ -export interface BoxStyle { - backgroundColor?: string; - backgroundOpacity?: number; - borderColor?: string; - borderWidth?: number; - borderStyle?: "solid" | "dotted" | "dashed" | "double"; - borderRadius?: number; - labelColor?: string; - labelPosition?: string; -} - -/** - * Shared style properties for traffic-rate annotations and nodes. - */ -export interface TrafficRateStyle { - backgroundColor?: string; - backgroundOpacity?: number; - borderColor?: string; - borderWidth?: number; - borderStyle?: "solid" | "dashed" | "dotted" | "double"; - borderRadius?: number; - titleColor?: string; - textColor?: string; - zIndex?: number; -} diff --git a/src/reactTopoViewer/shared/types/editors.ts b/src/reactTopoViewer/shared/types/editors.ts deleted file mode 100644 index 8e53006bb..000000000 --- a/src/reactTopoViewer/shared/types/editors.ts +++ /dev/null @@ -1,361 +0,0 @@ -/** - * Editor type definitions - shared between webview and extension - * - * These types are used by both the webview editor panels and the - * shared conversion utilities. Keeping them in shared/ ensures - * proper dependency direction (shared does not import from webview). - */ - -// ============================================================================ -// Node Editor Types -// ============================================================================ - -export type NodeEditorTabId = - | "basic" - | "components" - | "config" - | "runtime" - | "network" - | "advanced"; - -/** - * Integrated SROS types (simpler chassis with just MDA slots) - */ -export const INTEGRATED_SROS_TYPES = new Set([ - "sr-1", - "sr-1s", - "ixr-r6", - "ixr-ec", - "ixr-e2", - "ixr-e2c" -]); - -/** - * Health check configuration - */ -export interface HealthCheckConfig { - test?: string; - startPeriod?: number; - interval?: number; - timeout?: number; - retries?: number; -} - -/** - * SROS MDA (Media Dependent Adapter) configuration - */ -export interface SrosMda { - slot?: number; - type?: string; -} - -/** - * SROS XIOM (Extension I/O Module) configuration - */ -export interface SrosXiom { - slot?: number; - type?: string; - mda?: SrosMda[]; -} - -/** - * SROS Component configuration (CPM, Card) - */ -export interface SrosComponent { - slot?: string | number; - type?: string; - sfm?: string; - mda?: SrosMda[]; - xiom?: SrosXiom[]; -} - -/** - * Advanced node fields shared between NodeEditorData and CustomTemplateEditorData - */ -export interface AdvancedNodeFields { - cpu?: number; - cpuSet?: string; - memory?: string; - shmSize?: string; - capAdd?: string[]; - sysctls?: Record; - devices?: string[]; - certIssue?: boolean; - certKeySize?: string; - certValidity?: string; - sans?: string[]; - healthCheck?: HealthCheckConfig; - imagePullPolicy?: string; - runtime?: string; -} - -/** - * Fields shared by node editor and custom template representations. - */ -interface SharedTemplateFields extends AdvancedNodeFields { - // Configuration - startupConfig?: string; - enforceStartupConfig?: boolean; - suppressStartupConfig?: boolean; - license?: string; - binds?: string[]; - env?: Record; - envFiles?: string[]; - labels?: Record; - // Runtime - user?: string; - entrypoint?: string; - cmd?: string; - exec?: string[]; - restartPolicy?: string; - autoRemove?: boolean; - startupDelay?: number; - // Network - mgmtIpv4?: string; - mgmtIpv6?: string; - networkMode?: string; - ports?: string[]; - dnsServers?: string[]; - aliases?: string[]; - // Components (SROS) - isDistributed?: boolean; - components?: SrosComponent[]; -} - -/** - * Node editor data structure - */ -export interface NodeEditorData extends SharedTemplateFields { - id: string; - name: string; - /** Whether this is a custom node template (temp-custom-node or edit-custom-node) */ - isCustomTemplate?: boolean; - kind?: string; - type?: string; - image?: string; - version?: string; - icon?: string; - iconColor?: string; - iconCornerRadius?: number; - labelPosition?: string; - direction?: string; - labelBackgroundColor?: string; - // Custom node settings - customName?: string; - baseName?: string; - interfacePattern?: string; - isDefaultCustomNode?: boolean; -} - -// ============================================================================ -// Network Editor Types -// ============================================================================ - -/** - * Network endpoint types supported by containerlab - */ -export type NetworkType = - | "host" - | "mgmt-net" - | "macvlan" - | "vxlan" - | "vxlan-stitch" - | "dummy" - | "bridge" - | "ovs-bridge"; - -/** - * Data structure for editing network nodes - */ -export interface NetworkEditorData { - /** Node ID in the graph */ - id: string; - /** Type of network endpoint */ - networkType: NetworkType; - /** Interface or bridge name */ - interfaceName: string; - /** Display label/alias */ - label: string; - /** VXLAN remote endpoint address */ - vxlanRemote?: string; - /** VXLAN Network Identifier */ - vxlanVni?: string; - /** VXLAN destination port */ - vxlanDstPort?: string; - /** VXLAN source port */ - vxlanSrcPort?: string; - /** MACVLAN mode (bridge, vepa, private, passthru) */ - macvlanMode?: string; - /** MAC address */ - mac?: string; - /** MTU value */ - mtu?: string; - /** Custom variables */ - vars?: Record; - /** Custom labels */ - labels?: Record; -} - -/** All available network types */ -export const NETWORK_TYPES: NetworkType[] = [ - "host", - "mgmt-net", - "macvlan", - "vxlan", - "vxlan-stitch", - "dummy", - "bridge", - "ovs-bridge" -]; - -/** VXLAN network types */ -export const VXLAN_TYPES: NetworkType[] = ["vxlan", "vxlan-stitch"]; - -/** Bridge network types */ -export const BRIDGE_TYPES: NetworkType[] = ["bridge", "ovs-bridge"]; - -/** Host-like network types (use host interface) */ -export const HOST_TYPES: NetworkType[] = ["host", "mgmt-net", "macvlan"]; - -/** MACVLAN mode options */ -export const MACVLAN_MODES = ["bridge", "vepa", "private", "passthru"] as const; - -/** - * Get the interface field label based on network type - */ -export function getInterfaceLabel(networkType: NetworkType): string { - if (BRIDGE_TYPES.includes(networkType)) { - return "Bridge Name"; - } - if (HOST_TYPES.includes(networkType)) { - return "Host Interface"; - } - return "Interface"; -} - -/** - * Get the interface field placeholder based on network type - */ -export function getInterfacePlaceholder(networkType: NetworkType): string { - if (BRIDGE_TYPES.includes(networkType)) { - return "Enter bridge name"; - } - if (networkType === "macvlan") { - return "Parent interface (e.g., eth0)"; - } - if (HOST_TYPES.includes(networkType)) { - return "e.g., eth0, eth1"; - } - return "Enter interface name"; -} - -/** - * Check if interface field should be shown for the network type - */ -export function showInterfaceField(networkType: NetworkType): boolean { - return networkType !== "dummy" && !VXLAN_TYPES.includes(networkType); -} - -/** - * Check if the network type supports extended properties (mtu, vars, labels) - * Note: bridge and ovs-bridge are node kinds, not link endpoint types, - * so they don't support these properties in the containerlab schema - */ -export function supportsExtendedProps(type: NetworkType): boolean { - return !BRIDGE_TYPES.includes(type); -} - -// ============================================================================ -// Link Editor Types -// ============================================================================ - -export type LinkEditorTabId = "basic" | "extended"; - -/** - * Link endpoint data structure - */ -export interface LinkEndpoint { - node: string; - interface: string; - mac?: string; -} - -/** - * Link editor data structure - */ -export interface LinkEditorData { - id: string; - source: string; - target: string; - sourceEndpoint: string; - targetEndpoint: string; - type?: "veth" | "host" | "mgmt-net" | "macvlan" | "dummy" | "vxlan" | "vxlan-stitch" | string; - // Extended properties - sourceMac?: string; - targetMac?: string; - sourceIpv4?: string; - sourceIpv6?: string; - targetIpv4?: string; - targetIpv6?: string; - mtu?: number | string; - vars?: Record; - labels?: Record; - endpointLabelOffsetEnabled?: boolean; - endpointLabelOffset?: number; - // Original values for finding the link when endpoints change - originalSource?: string; - originalTarget?: string; - originalSourceEndpoint?: string; - originalTargetEndpoint?: string; - // Network endpoint flags (for read-only handling) - sourceIsNetwork?: boolean; - targetIsNetwork?: boolean; -} - -// ============================================================================ -// Custom Node Template Types -// ============================================================================ - -/** - * Custom node template definition - stored configuration for reusable node types - */ -export interface CustomNodeTemplate extends SharedTemplateFields { - name: string; - kind: string; - type?: string; - image?: string; - icon?: string; - iconColor?: string; - iconCornerRadius?: number; - baseName?: string; - interfacePattern?: string; - setDefault?: boolean; - [key: string]: unknown; -} - -/** - * Custom template editor data - used when editing custom node templates. - * Has 'id' field to track whether it's a new template or editing existing. - * Includes all NodeEditorData fields so templates can have default values - * for license, startup-config, env, binds, etc. - */ -export interface CustomTemplateEditorData extends SharedTemplateFields { - id: string; // 'temp-custom-node' for new, 'edit-custom-node' for editing - isCustomTemplate: true; - customName: string; - kind: string; - /** When editing, track the original name to find and update it */ - originalName?: string; - - // Basic tab fields - type?: string; - image?: string; - icon?: string; - iconColor?: string; - iconCornerRadius?: number; - - // Custom template specific - baseName?: string; - interfacePattern?: string; - isDefaultCustomNode?: boolean; -} diff --git a/src/reactTopoViewer/shared/types/endpoint.ts b/src/reactTopoViewer/shared/types/endpoint.ts deleted file mode 100644 index 13219358e..000000000 --- a/src/reactTopoViewer/shared/types/endpoint.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Common endpoint result type for service handlers - */ -export interface EndpointResult { - result: unknown; - error: string | null; -} diff --git a/src/reactTopoViewer/shared/types/graph.ts b/src/reactTopoViewer/shared/types/graph.ts deleted file mode 100644 index cd8522d75..000000000 --- a/src/reactTopoViewer/shared/types/graph.ts +++ /dev/null @@ -1,267 +0,0 @@ -// ReactFlow type definitions for the topology viewer. -import type { Node, Edge } from "@xyflow/react"; - -import type { TextStyle, BoxStyle, TrafficRateStyle } from "./annotationStyles"; - -// ============================================================================ -// Node Data Types -// ============================================================================ - -/** - * Node data for topology nodes (routers, switches, etc.) - */ -export interface TopologyNodeData { - label: string; - role: string; - kind?: string; - image?: string; - iconColor?: string; - iconCornerRadius?: number; - labelPosition?: string; - direction?: string; - labelBackgroundColor?: string; - state?: string; - mgmtIpv4Address?: string; - mgmtIpv6Address?: string; - longname?: string; - geoCoordinates?: { lat: number; lng: number }; - extraData?: Record; - [key: string]: unknown; -} - -/** - * Node data for network endpoint nodes (host, mgmt-net, macvlan, vxlan, bridge) - */ -export interface NetworkNodeData { - label: string; - nodeType: - | "host" - | "mgmt-net" - | "macvlan" - | "vxlan" - | "vxlan-stitch" - | "dummy" - | "bridge" - | "ovs-bridge" - | string; - labelPosition?: string; - direction?: string; - labelBackgroundColor?: string; - geoCoordinates?: { lat: number; lng: number }; - extraData?: Record; - [key: string]: unknown; -} - -/** - * Node data for group container nodes - */ -export interface GroupNodeData extends BoxStyle { - label: string; - name: string; - level: string; - parentId?: string; - groupId?: string; - width: number; - height: number; - [key: string]: unknown; -} - -/** - * Node data for free text annotations - */ -export interface FreeTextNodeData extends TextStyle { - text: string; - [key: string]: unknown; -} - -/** - * Node data for free shape annotations - */ -export interface FreeShapeNodeData { - shapeType: "rectangle" | "circle" | "line"; - width?: number; - height?: number; - /** Absolute end position for lines (for updating annotation state) */ - endPosition?: { x: number; y: number }; - /** Relative end position for lines (end - start) */ - relativeEndPosition?: { x: number; y: number }; - /** Start position for lines (absolute, for handle updates) */ - startPosition?: { x: number; y: number }; - /** Line start position within the node's bounding box */ - lineStartInNode?: { x: number; y: number }; - fillColor?: string; - fillOpacity?: number; - borderColor?: string; - borderWidth?: number; - borderStyle?: "solid" | "dashed" | "dotted"; - rotation?: number; - lineStartArrow?: boolean; - lineEndArrow?: boolean; - lineArrowSize?: number; - cornerRadius?: number; - [key: string]: unknown; -} - -/** - * Node data for traffic-rate annotations. - */ -export interface TrafficRateNodeData extends TrafficRateStyle { - nodeId?: string; - interfaceName?: string; - mode?: "chart" | "text"; - textMetric?: "combined" | "rx" | "tx"; - showLegend?: boolean; - width?: number; - height?: number; - groupId?: string; - geoCoordinates?: { lat: number; lng: number }; - [key: string]: unknown; -} - -// ============================================================================ -// Edge Data Types -// ============================================================================ - -/** - * Edge data for topology edges (links between nodes) - */ -export interface TopologyEdgeData { - sourceEndpoint: string; - targetEndpoint: string; - linkStatus?: "up" | "down" | "unknown"; - endpointLabelOffsetEnabled?: boolean; - endpointLabelOffset?: number; - extraData?: Record; - [key: string]: unknown; -} - -// ============================================================================ -// Union Types -// ============================================================================ - -/** - * Union type for all node data types - */ -export type RFNodeData = - | TopologyNodeData - | NetworkNodeData - | GroupNodeData - | FreeTextNodeData - | FreeShapeNodeData - | TrafficRateNodeData; - -/** - * Custom node type string literals - */ -export type RFNodeType = - | "topology-node" - | "network-node" - | "group-node" - | "free-text-node" - | "free-shape-node" - | "traffic-rate-node"; - -// ============================================================================ -// Typed Node Aliases -// ============================================================================ - -/** - * React Flow node with topology data - */ -export type TopologyRFNode = Node; -export type NetworkRFNode = Node; -export type GroupRFNode = Node; -export type FreeTextRFNode = Node; -export type FreeShapeRFNode = Node; -export type TrafficRateRFNode = Node; - -/** - * Union of all typed nodes - */ -export type TopoNode = - | TopologyRFNode - | NetworkRFNode - | GroupRFNode - | FreeTextRFNode - | FreeShapeRFNode - | TrafficRateRFNode; - -/** - * React Flow edge with topology data - */ -export type TopoEdge = Edge; - -// ============================================================================ -// Topology Data Structure -// ============================================================================ - -/** - * Topology data structure with ReactFlow nodes and edges. - */ -export interface TopologyData { - nodes: TopoNode[]; - edges: TopoEdge[]; -} - -// ============================================================================ -// Handler Callback Types -// ============================================================================ - -/** - * Edge creation data passed to handler callbacks - */ -export interface EdgeCreatedData { - id: string; - source: string; - target: string; - sourceEndpoint: string; - targetEndpoint: string; -} - -/** - * Handler callback for edge creation - */ -export type EdgeCreatedHandler = ( - sourceId: string, - targetId: string, - edgeData: EdgeCreatedData -) => void; - -/** - * Handler callback for node creation - */ -export type NodeCreatedHandler = ( - nodeId: string, - nodeElement: TopoNode, - position: { x: number; y: number } -) => void; - -// ============================================================================ -// Constants -// ============================================================================ - -// Default node icon color -export const DEFAULT_ICON_COLOR = "#005aff"; - -/** - * Role to SVG node type mapping - */ -export const ROLE_SVG_MAP: Record = { - router: "pe", - default: "pe", - pe: "pe", - p: "pe", - controller: "controller", - pon: "pon", - dcgw: "dcgw", - leaf: "leaf", - switch: "switch", - rgw: "rgw", - "super-spine": "super-spine", - spine: "spine", - server: "server", - bridge: "bridge", - ue: "ue", - cloud: "cloud", - client: "client" -}; diff --git a/src/reactTopoViewer/shared/types/icons.ts b/src/reactTopoViewer/shared/types/icons.ts deleted file mode 100644 index f84635b34..000000000 --- a/src/reactTopoViewer/shared/types/icons.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Custom icon type definitions for React TopoViewer. - * Shared between extension and webview. - */ - -/** - * Information about a custom icon - */ -export interface CustomIconInfo { - /** Icon name without extension (e.g., "my-router") */ - name: string; - /** Where the icon was loaded from */ - source: "workspace" | "global"; - /** Base64 data URI for rendering */ - dataUri: string; - /** Image format */ - format: "svg" | "png"; -} - -/** - * Built-in icon names that ship with the extension - */ -export const BUILTIN_ICON_NAMES = new Set([ - "pe", - "dcgw", - "leaf", - "switch", - "bridge", - "spine", - "super-spine", - "server", - "pon", - "controller", - "rgw", - "ue", - "cloud", - "client" -]); - -/** - * Check if an icon name is a built-in icon - */ -export function isBuiltInIcon(name: string): boolean { - return BUILTIN_ICON_NAMES.has(name); -} - -/** - * Supported icon file extensions - */ -export const SUPPORTED_ICON_EXTENSIONS = new Set([".svg", ".png"]); - -/** - * Check if a file extension is a supported icon format - */ -export function isSupportedIconExtension(ext: string): boolean { - return SUPPORTED_ICON_EXTENSIONS.has(ext.toLowerCase()); -} - -/** - * Get MIME type for an icon file extension - */ -export function getIconMimeType(ext: string): string { - const lower = ext.toLowerCase(); - if (lower === ".svg") return "image/svg+xml"; - if (lower === ".png") return "image/png"; - return "application/octet-stream"; -} - -/** - * Get icon format from file extension - */ -export function getIconFormat(ext: string): "svg" | "png" { - return ext.toLowerCase() === ".png" ? "png" : "svg"; -} - -/** - * Extract unique custom icon names used by nodes in an element list. - * Filters out built-in icons, returning only custom icon names. - * - * @param elements - Array of graph elements (nodes and edges) - * @returns Array of unique custom icon names - */ -export function extractUsedCustomIcons( - elements: T[] -): string[] { - const usedIcons = new Set(); - for (const el of elements) { - const role = el.data?.topoViewerRole; - if (role !== undefined && role.length > 0 && !isBuiltInIcon(role)) { - usedIcons.add(role); - } - } - return Array.from(usedIcons); -} diff --git a/src/reactTopoViewer/shared/types/index.ts b/src/reactTopoViewer/shared/types/index.ts deleted file mode 100644 index 5323551b4..000000000 --- a/src/reactTopoViewer/shared/types/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { TopoEdge, TopoNode, TopologyEdgeData } from "./graph"; -export type { TopologyHostCommand } from "./messages"; diff --git a/src/reactTopoViewer/shared/types/labSettings.ts b/src/reactTopoViewer/shared/types/labSettings.ts deleted file mode 100644 index a93cc5840..000000000 --- a/src/reactTopoViewer/shared/types/labSettings.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Shared Lab Settings types. - * - * These reflect Containerlab's top-level "name", "prefix", and "mgmt" fields. - * Kept in shared/types so both extension host and webview can use the same shape. - */ - -export interface LabSettings { - name?: string; - prefix?: string | null; - mgmt?: MgmtSettings | null; -} - -export interface MgmtSettings { - network?: string; - "ipv4-subnet"?: string; - "ipv4-gw"?: string; - "ipv4-range"?: string; - "ipv6-subnet"?: string; - "ipv6-gw"?: string; - mtu?: number; - bridge?: string; - "external-access"?: boolean; - "driver-opts"?: Record; -} diff --git a/src/reactTopoViewer/shared/types/messages.ts b/src/reactTopoViewer/shared/types/messages.ts deleted file mode 100644 index b7a3eb4fd..000000000 --- a/src/reactTopoViewer/shared/types/messages.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Shared message types between extension and webview. - * - * Topology state is authoritative in the host via the TopologyHost protocol. - */ - -import type { NodeSaveData } from "../io/NodePersistenceIO"; -import type { LinkSaveData } from "../io/LinkPersistenceIO"; - -import type { TopologyAnnotations, EdgeAnnotation, DeploymentState } from "./topology"; -import type { TopoNode, TopoEdge } from "./graph"; -import type { LabSettings } from "./labSettings"; - -/** - * Base message interface for all messages - */ -export interface BaseMessage { - type: string; - requestId?: string; -} - -/** - * Mode changed message - */ -export interface ModeChangedMessage extends BaseMessage { - type: "topo-mode-changed"; - data: { - mode: "editor" | "viewer"; - deploymentState: "deployed" | "undeployed" | "unknown"; - }; -} - -// ============================================================================ -// TopologyHost Protocol (authoritative host state) -// ============================================================================ - -export const TOPOLOGY_HOST_PROTOCOL_VERSION = 1; - -export type TopologyHostCommand = - | { command: "addNode"; payload: NodeSaveData } - | { command: "editNode"; payload: NodeSaveData } - | { command: "deleteNode"; payload: { id: string } } - | { command: "addLink"; payload: LinkSaveData } - | { command: "editLink"; payload: LinkSaveData } - | { command: "deleteLink"; payload: LinkSaveData } - | { - /** Replace the entire YAML topology file content. */ - command: "setYamlContent"; - payload: { content: string }; - /** Skip undo/redo history for live typing updates. */ - skipHistory?: boolean; - } - | { - /** Replace the entire annotations JSON file content. */ - command: "setAnnotationsContent"; - payload: { content: string }; - /** Skip undo/redo history for live typing updates. */ - skipHistory?: boolean; - } - | { - command: "savePositions"; - payload: Array<{ - id: string; - position?: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - }>; - /** Skip undo/redo history for internal, non-user-initiated updates. */ - skipHistory?: boolean; - } - | { - command: "savePositionsAndAnnotations"; - payload: { - positions: Array<{ - id: string; - position?: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - }>; - annotations?: Partial; - }; - /** Skip undo/redo history for internal, non-user-initiated updates. */ - skipHistory?: boolean; - } - | { command: "setAnnotations"; payload: Partial } - | { command: "setEdgeAnnotations"; payload: EdgeAnnotation[] } - | { command: "setViewerSettings"; payload: NonNullable } - | { command: "setNodeGroupMembership"; payload: { nodeId: string; groupId: string | null } } - | { - command: "setNodeGroupMemberships"; - payload: Array<{ nodeId: string; groupId: string | null }>; - } - | { - command: "setAnnotationsWithMemberships"; - payload: { - annotations: Partial; - memberships: Array<{ nodeId: string; groupId: string | null }>; - }; - } - | { command: "batch"; payload: { commands: TopologyHostCommand[] } } - | { command: "setLabSettings"; payload: LabSettings } - | { command: "undo" } - | { command: "redo" }; - -export interface TopologySnapshot { - revision: number; - nodes: TopoNode[]; - edges: TopoEdge[]; - annotations: TopologyAnnotations; - /** Source file name for the YAML topology (e.g. mylab.clab.yml). */ - yamlFileName: string; - /** Source file name for the annotations JSON (e.g. mylab.clab.yml.annotations.json). */ - annotationsFileName: string; - /** Raw YAML content as read from disk. */ - yamlContent: string; - /** Raw annotations JSON content as read from disk (or a generated default if missing). */ - annotationsContent: string; - labName: string; - mode: "edit" | "view"; - deploymentState: DeploymentState; - labSettings?: LabSettings; - canUndo: boolean; - canRedo: boolean; -} - -export interface TopologyPatch { - revision: number; - nodes?: TopoNode[]; - edges?: TopoEdge[]; - annotations?: Partial; - labName?: string; - mode?: "edit" | "view"; - deploymentState?: DeploymentState; - labSettings?: LabSettings; - canUndo?: boolean; - canRedo?: boolean; -} - -export interface TopologyHostSnapshotRequestMessage extends BaseMessage { - type: "topology-host:get-snapshot"; - protocolVersion: number; - requestId: string; -} - -export interface TopologyHostCommandMessage extends BaseMessage { - type: "topology-host:command"; - protocolVersion: number; - requestId: string; - baseRevision: number; - command: TopologyHostCommand; -} - -export interface TopologyHostAckMessage extends BaseMessage { - type: "topology-host:ack"; - protocolVersion: number; - requestId: string; - revision: number; - snapshot?: TopologySnapshot; - patch?: TopologyPatch; -} - -export interface TopologyHostRejectMessage extends BaseMessage { - type: "topology-host:reject"; - protocolVersion: number; - requestId: string; - revision: number; - snapshot: TopologySnapshot; - reason: "stale"; -} - -export interface TopologyHostErrorMessage extends BaseMessage { - type: "topology-host:error"; - protocolVersion: number; - requestId: string; - error: string; -} - -export interface TopologyHostSnapshotMessage extends BaseMessage { - type: "topology-host:snapshot"; - protocolVersion: number; - snapshot: TopologySnapshot; - reason?: "init" | "external-change" | "resync"; -} - -export type TopologyHostResponseMessage = - | TopologyHostAckMessage - | TopologyHostRejectMessage - | TopologyHostErrorMessage - | TopologyHostSnapshotMessage; diff --git a/src/reactTopoViewer/shared/types/topology.ts b/src/reactTopoViewer/shared/types/topology.ts deleted file mode 100644 index 2c367bab2..000000000 --- a/src/reactTopoViewer/shared/types/topology.ts +++ /dev/null @@ -1,304 +0,0 @@ -/** - * Topology-related type definitions for React TopoViewer. - * These types define the structure for Containerlab topologies and ReactFlow elements. - */ - -import type { TextStyle, BoxStyle, TrafficRateStyle } from "./annotationStyles"; - -// ============================================================================ -// Containerlab YAML Types -// ============================================================================ - -/** - * Represents a Containerlab node definition as specified in the YAML configuration. - */ -export interface ClabNode { - [key: string]: unknown; - kind?: string; - image?: string; - type?: string; - group?: string; - labels?: Record; -} - -/** - * Represents a Containerlab link endpoint in map format. - */ -export interface ClabLinkEndpointMap { - node: string; - interface?: string; - mac?: string; -} - -/** - * Represents a Containerlab link definition as specified in the YAML configuration. - */ -export interface ClabLink { - endpoints?: (string | ClabLinkEndpointMap)[]; - endpoint?: ClabLinkEndpointMap; - type?: "veth" | "host" | "mgmt-net" | "macvlan" | "dummy" | "vxlan" | "vxlan-stitch" | string; - mtu?: number | string; - vars?: unknown; - labels?: unknown; - "host-interface"?: string; - mode?: string; - remote?: string; - vni?: number | string; - "dst-port"?: number | string; - "src-port"?: number | string; -} - -/** - * Represents the main Containerlab topology structure as defined in the YAML configuration. - */ -export interface ClabTopology { - name?: string; - prefix?: string; - topology?: { - defaults?: ClabNode; - kinds?: Record; - groups?: Record; - nodes?: Record; - links?: ClabLink[]; - }; -} - -// ============================================================================ -// Parsed Element Types (Internal intermediate format) -// ============================================================================ - -/** - * Represents a parsed element (node or edge) from YAML parsing. - * This is an internal intermediate format that gets converted to ReactFlow types. - */ -export interface ParsedElement { - group: "nodes" | "edges"; - data: Record; - position?: { x: number; y: number }; - removed?: boolean; - selected?: boolean; - selectable?: boolean; - locked?: boolean; - grabbed?: boolean; - grabbable?: boolean; - classes?: string; -} - -// ============================================================================ -// Annotation Types -// ============================================================================ - -/** - * Free text annotation for canvas text overlays. - */ -export interface FreeTextAnnotation extends TextStyle { - id: string; - text: string; - position: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - groupId?: string; // Parent group ID for hierarchy membership - zIndex?: number; - [key: string]: unknown; -} - -/** - * Free shape annotation for canvas shapes. - */ -export interface FreeShapeAnnotation { - id: string; - shapeType: "rectangle" | "circle" | "line"; - position: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - groupId?: string; // Parent group ID for hierarchy membership - width?: number; - height?: number; - endPosition?: { x: number; y: number }; - endGeoCoordinates?: { lat: number; lng: number }; - fillColor?: string; - fillOpacity?: number; - borderColor?: string; - borderWidth?: number; - borderStyle?: "solid" | "dashed" | "dotted"; - rotation?: number; - zIndex?: number; - lineStartArrow?: boolean; - lineEndArrow?: boolean; - lineArrowSize?: number; - cornerRadius?: number; - [key: string]: unknown; -} - -/** - * Traffic rate annotation for monitoring a node interface on the canvas. - */ -export interface TrafficRateAnnotation extends TrafficRateStyle { - id: string; - position: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - groupId?: string; // Parent group ID for hierarchy membership - nodeId?: string; - interfaceName?: string; - mode?: "chart" | "text"; - textMetric?: "combined" | "rx" | "tx"; - showLegend?: boolean; - width?: number; - height?: number; - [key: string]: unknown; -} - -/** - * Group annotation for overlay groups (rendered as HTML/SVG overlays). - * Members are tracked via NodeAnnotation.groupId (preferred) and group/level for legacy display. - * Groups can be nested via parentId for hierarchical organization. - */ -export interface GroupStyleAnnotation extends BoxStyle { - id: string; - name: string; - level: string; - parentId?: string; // Parent group ID for nested groups - groupId?: string; // Parent group ID (legacy/alternate field for nested groups) - // Geometry - position: { x: number; y: number }; - width: number; - height: number; - // Geo coordinates for geomap mode - geoCoordinates?: { lat: number; lng: number }; - // Style - color?: string; - zIndex?: number; - [key: string]: unknown; -} - -/** - * Network node annotation for external network endpoints. - * Networks are endpoints that connect to external resources like host interfaces, - * management networks, VXLANs, etc. - */ -export interface NetworkNodeAnnotation { - id: string; - type: - | "host" - | "mgmt-net" - | "macvlan" - | "vxlan" - | "vxlan-stitch" - | "dummy" - | "bridge" - | "ovs-bridge"; - label?: string; - position: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - group?: string; - level?: string; -} - -/** - * Node annotation for position, icon, and other visual settings. - */ -export interface NodeAnnotation { - id: string; - label?: string; - copyFrom?: string; - yamlNodeId?: string; - yamlInterface?: string; - position?: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - icon?: string; - iconColor?: string; - iconCornerRadius?: number; - labelPosition?: string; - direction?: string; - labelBackgroundColor?: string; - groupLabelPos?: string; - /** Internal group ID for membership (preferred). */ - groupId?: string; - group?: string; - level?: string; - interfacePattern?: string; -} - -/** - * Edge annotation for per-link visual settings. - */ -export interface EdgeAnnotation { - id?: string; - source?: string; - target?: string; - sourceEndpoint?: string; - targetEndpoint?: string; - endpointLabelOffsetEnabled?: boolean; - endpointLabelOffset?: number; -} - -/** - * Alias endpoint annotation for mapping YAML nodes to visual aliases. - */ -export interface AliasEndpointAnnotation { - yamlNodeId: string; - interface: string; - aliasNodeId: string; -} - -/** - * Container for all topology annotations. - */ -export interface TopologyAnnotations { - freeTextAnnotations?: FreeTextAnnotation[]; - freeShapeAnnotations?: FreeShapeAnnotation[]; - trafficRateAnnotations?: TrafficRateAnnotation[]; - groupStyleAnnotations?: GroupStyleAnnotation[]; - networkNodeAnnotations?: NetworkNodeAnnotation[]; - nodeAnnotations?: NodeAnnotation[]; - edgeAnnotations?: EdgeAnnotation[]; - aliasEndpointAnnotations?: AliasEndpointAnnotation[]; - viewerSettings?: { - gridLineWidth?: number; - gridStyle?: "dotted" | "quadratic"; - endpointLabelOffset?: number; - gridColor?: string | null; - gridBgColor?: string | null; - style?: "default" | "telemetry-style"; - linkLabelMode?: "show-all" | "on-select" | "hide" | "telemetry-style"; - lastNonTelemetryLinkLabelMode?: "show-all" | "on-select" | "hide"; - telemetryNodeSizePx?: number; - telemetryInterfaceSizePercent?: number; - }; - [key: string]: unknown; -} - -// ============================================================================ -// Deployment State -// ============================================================================ - -export type DeploymentState = "deployed" | "undeployed" | "unknown"; - -// ============================================================================ -// Interface Statistics Types -// ============================================================================ - -/** - * Interface statistics payload for traffic rate display. - * Contains RX/TX rates in bits per second and packets per second. - */ -export interface InterfaceStatsPayload { - rxBps?: number; - txBps?: number; - rxPps?: number; - txPps?: number; - rxBytes?: number; - txBytes?: number; - rxPackets?: number; - txPackets?: number; - statsIntervalSeconds?: number; -} - -/** - * Endpoint statistics history for rolling chart display. - */ -export interface EndpointStatsHistory { - timestamps: number[]; - rxBps: number[]; - txBps: number[]; - rxPps: number[]; - txPps: number[]; -} diff --git a/src/reactTopoViewer/shared/types/topologyHost.ts b/src/reactTopoViewer/shared/types/topologyHost.ts deleted file mode 100644 index 6b16fef49..000000000 --- a/src/reactTopoViewer/shared/types/topologyHost.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * TopologyHost interface (shared contract). - * - * Implementations live in: - * - VS Code extension host (production) - * - Dev server (standalone) - */ - -import type { - TopologySnapshot, - TopologyHostCommand, - TopologyHostResponseMessage -} from "./messages"; - -export interface TopologyHost { - getSnapshot(): Promise; - applyCommand( - command: TopologyHostCommand, - baseRevision: number - ): Promise; - onExternalChange(): Promise; - dispose(): void; -} diff --git a/src/reactTopoViewer/shared/utilities/LinkTypes.ts b/src/reactTopoViewer/shared/utilities/LinkTypes.ts deleted file mode 100644 index 586b17749..000000000 --- a/src/reactTopoViewer/shared/utilities/LinkTypes.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Common link/node type constants and helpers for React TopoViewer. - */ - -export const STR_HOST = "host" as const; -export const STR_MGMT_NET = "mgmt-net" as const; -export const PREFIX_MACVLAN = "macvlan:" as const; -export const PREFIX_VXLAN = "vxlan:" as const; -export const PREFIX_VXLAN_STITCH = "vxlan-stitch:" as const; -export const PREFIX_DUMMY = "dummy" as const; -export const PREFIX_BRIDGE = "bridge:" as const; -export const PREFIX_OVS_BRIDGE = "ovs-bridge:" as const; - -export const TYPE_DUMMY = "dummy" as const; - -export const SINGLE_ENDPOINT_TYPES = new Set([ - STR_HOST, - STR_MGMT_NET, - "macvlan", - TYPE_DUMMY, - "vxlan", - "vxlan-stitch" -]); - -export const VX_TYPES = new Set(["vxlan", "vxlan-stitch"]); -export const HOSTY_TYPES = new Set([STR_HOST, STR_MGMT_NET, "macvlan"]); - -type CytoscapeNodeLike = { - length: number; - data: (key: string) => unknown; -}; - -type CytoscapeLike = { - getElementById: (id: string) => CytoscapeNodeLike; -}; - -function asRecord(value: unknown): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; - return Object.fromEntries(Object.entries(value)); -} - -function isCytoscapeLike(value: unknown): value is CytoscapeLike { - const record = asRecord(value); - return typeof record.getElementById === "function"; -} - -/** - * Determines if a node ID represents a special endpoint. - */ -export function isSpecialEndpointId(nodeId: string): boolean { - return ( - nodeId.startsWith(`${STR_HOST}:`) || - nodeId.startsWith(`${STR_MGMT_NET}:`) || - nodeId.startsWith(PREFIX_MACVLAN) || - nodeId.startsWith(PREFIX_VXLAN) || - nodeId.startsWith(PREFIX_VXLAN_STITCH) || - nodeId.startsWith("dummy") || - nodeId.startsWith(PREFIX_BRIDGE) || - nodeId.startsWith(PREFIX_OVS_BRIDGE) - ); -} - -/** - * Determines if a node ID represents a special endpoint or bridge node. - */ -export function isSpecialNodeOrBridge(nodeId: string, cy?: unknown): boolean { - if (isSpecialEndpointId(nodeId)) { - return true; - } - - if (cy !== undefined && isCytoscapeLike(cy)) { - const node = cy.getElementById(nodeId); - if (node.length > 0) { - const extraData = asRecord(node.data("extraData")); - const kind = typeof extraData.kind === "string" ? extraData.kind : undefined; - return kind === "bridge" || kind === "ovs-bridge"; - } - } - - return false; -} - -/** - * Splits an endpoint string or object into node and interface components. - */ -export function splitEndpointLike(endpoint: string | { node: string; interface?: string }): { - node: string; - iface: string; -} { - if (typeof endpoint === "string") { - if ( - endpoint.startsWith(PREFIX_MACVLAN) || - endpoint.startsWith(PREFIX_DUMMY) || - endpoint.startsWith(PREFIX_VXLAN) || - endpoint.startsWith(PREFIX_VXLAN_STITCH) - ) { - return { node: endpoint, iface: "" }; - } - const parts = endpoint.split(":"); - if (parts.length === 2) return { node: parts[0], iface: parts[1] }; - return { node: endpoint, iface: "" }; - } - return { node: endpoint.node, iface: endpoint.interface ?? "" }; -} diff --git a/src/reactTopoViewer/shared/utilities/annotationMigrations.ts b/src/reactTopoViewer/shared/utilities/annotationMigrations.ts deleted file mode 100644 index 18b5d1a66..000000000 --- a/src/reactTopoViewer/shared/utilities/annotationMigrations.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Shared utilities for annotation migrations - */ - -import type { TopologyAnnotations } from "../types/topology"; - -/** - * Migration data for interface patterns - */ -export interface InterfacePatternMigration { - nodeId: string; - interfacePattern: string; -} - -/** - * Applies interface pattern migrations to annotations. - * Returns the modified annotations and whether any changes were made. - */ -export function applyInterfacePatternMigrations( - annotations: TopologyAnnotations, - migrations: InterfacePatternMigration[] -): { annotations: TopologyAnnotations; modified: boolean } { - if (migrations.length === 0) { - return { annotations, modified: false }; - } - - annotations.nodeAnnotations ??= []; - - let modified = false; - for (const { nodeId, interfacePattern } of migrations) { - const existing = annotations.nodeAnnotations.find((n) => n.id === nodeId); - if (existing) { - if (existing.interfacePattern === undefined || existing.interfacePattern.length === 0) { - existing.interfacePattern = interfacePattern; - modified = true; - } - } else { - annotations.nodeAnnotations.push({ id: nodeId, interfacePattern }); - modified = true; - } - } - - return { annotations, modified }; -} diff --git a/src/reactTopoViewer/shared/utilities/conversions/index.ts b/src/reactTopoViewer/shared/utilities/conversions/index.ts deleted file mode 100644 index e4171c6d9..000000000 --- a/src/reactTopoViewer/shared/utilities/conversions/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Conversion utilities barrel file - */ - -// Node editor conversions -export { - convertToEditorData, - convertEditorDataToYaml, - convertEditorDataToNodeSaveData -} from "../nodeEditorConversions"; -export type { YamlExtraData } from "../nodeEditorConversions"; - -// Network editor conversions -export { - convertToNetworkEditorData, - convertNetworkEditorDataToYaml -} from "../networkEditorConversions"; - -// Custom node conversions -export { - convertCustomTemplateToEditorData, - convertEditorDataToSaveData, - convertTemplateToEditorData, - createNewTemplateEditorData -} from "../customNodeConversions"; -export type { SaveCustomNodeData } from "../customNodeConversions"; - -// Element conversions (ParsedElement <-> ReactFlow) -export { - parsedElementToTopoNode, - parsedElementToTopoEdge, - convertElementsToTopologyData, - topoNodeToParsedElement, - topoEdgeToParsedElement, - convertTopologyDataToElements -} from "../elementConversions"; diff --git a/src/reactTopoViewer/shared/utilities/customNodeConversions.ts b/src/reactTopoViewer/shared/utilities/customNodeConversions.ts deleted file mode 100644 index fa11092c7..000000000 --- a/src/reactTopoViewer/shared/utilities/customNodeConversions.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * Utility functions for converting between CustomTemplateEditorData and NodeEditorData/SaveCustomNodeData - */ - -import type { - NodeEditorData, - CustomTemplateEditorData, - CustomNodeTemplate -} from "../types/editors"; - -/** - * Data format for saving custom node to extension. - * Extends CustomNodeTemplate with oldName for update operations. - */ -export interface SaveCustomNodeData extends Omit { - name: string; - oldName?: string; -} - -function buildCommonTemplateFields(data: NodeEditorData) { - return { - // Basic tab fields - type: data.type, - image: data.image, - icon: data.icon, - iconColor: data.iconColor, - iconCornerRadius: data.iconCornerRadius, - - // Custom template specific - baseName: data.baseName, - interfacePattern: data.interfacePattern, - - // Configuration tab fields - license: data.license, - startupConfig: data.startupConfig, - enforceStartupConfig: data.enforceStartupConfig, - suppressStartupConfig: data.suppressStartupConfig, - binds: data.binds, - env: data.env, - envFiles: data.envFiles, - labels: data.labels, - - // Runtime tab fields - user: data.user, - entrypoint: data.entrypoint, - cmd: data.cmd, - exec: data.exec, - restartPolicy: data.restartPolicy, - autoRemove: data.autoRemove, - startupDelay: data.startupDelay, - - // Network tab fields - mgmtIpv4: data.mgmtIpv4, - mgmtIpv6: data.mgmtIpv6, - networkMode: data.networkMode, - ports: data.ports, - dnsServers: data.dnsServers, - aliases: data.aliases, - - // Advanced tab fields - cpu: data.cpu, - cpuSet: data.cpuSet, - memory: data.memory, - shmSize: data.shmSize, - capAdd: data.capAdd, - sysctls: data.sysctls, - devices: data.devices, - certIssue: data.certIssue, - certKeySize: data.certKeySize, - certValidity: data.certValidity, - sans: data.sans, - healthCheck: data.healthCheck, - imagePullPolicy: data.imagePullPolicy, - runtime: data.runtime, - - // Components tab fields (SROS) - isDistributed: data.isDistributed, - components: data.components - }; -} - -/** - * Convert CustomTemplateEditorData to NodeEditorData for the node editor panel. - * Includes all configurable fields so they appear in the editor. - */ -function buildTemplateEditorFields( - template: CustomTemplateEditorData | CustomNodeTemplate -): Partial { - const fromEditorData = - "isDefaultCustomNode" in template && typeof template.isDefaultCustomNode === "boolean" - ? template.isDefaultCustomNode - : undefined; - const fromTemplateData = - "setDefault" in template && typeof template.setDefault === "boolean" - ? template.setDefault - : undefined; - const isDefaultCustomNode = fromEditorData ?? fromTemplateData; - - return { - // Basic tab fields - type: template.type, - image: template.image, - icon: template.icon, - iconColor: template.iconColor, - iconCornerRadius: template.iconCornerRadius, - - // Custom template specific - baseName: template.baseName, - interfacePattern: template.interfacePattern, - isDefaultCustomNode, - - // Configuration tab fields - license: template.license, - startupConfig: template.startupConfig, - enforceStartupConfig: template.enforceStartupConfig, - suppressStartupConfig: template.suppressStartupConfig, - binds: template.binds, - env: template.env, - envFiles: template.envFiles, - labels: template.labels, - - // Runtime tab fields - user: template.user, - entrypoint: template.entrypoint, - cmd: template.cmd, - exec: template.exec, - restartPolicy: template.restartPolicy, - autoRemove: template.autoRemove, - startupDelay: template.startupDelay, - - // Network tab fields - mgmtIpv4: template.mgmtIpv4, - mgmtIpv6: template.mgmtIpv6, - networkMode: template.networkMode, - ports: template.ports, - dnsServers: template.dnsServers, - aliases: template.aliases, - - // Advanced tab fields - cpu: template.cpu, - cpuSet: template.cpuSet, - memory: template.memory, - shmSize: template.shmSize, - capAdd: template.capAdd, - sysctls: template.sysctls, - devices: template.devices, - certIssue: template.certIssue, - certKeySize: template.certKeySize, - certValidity: template.certValidity, - sans: template.sans, - healthCheck: template.healthCheck, - imagePullPolicy: template.imagePullPolicy, - runtime: template.runtime, - - // Components tab fields (SROS) - isDistributed: template.isDistributed, - components: template.components - }; -} - -export function convertCustomTemplateToEditorData( - template: CustomTemplateEditorData -): NodeEditorData { - return { - id: template.id, - name: "", // Not used for custom templates - isCustomTemplate: true, - customName: template.customName, - kind: template.kind, - ...buildTemplateEditorFields(template) - }; -} - -/** - * Convert NodeEditorData back to SaveCustomNodeData format for extension. - * Includes all configurable node properties so custom templates can have - * default values for license, startup-config, env, binds, etc. - */ -export function convertEditorDataToSaveData( - data: NodeEditorData, - originalName?: string -): SaveCustomNodeData { - return { - // Required fields - name: data.customName ?? "", - oldName: originalName, - kind: data.kind ?? "", - - setDefault: data.isDefaultCustomNode, - ...buildCommonTemplateFields(data) - }; -} - -/** - * Convert NodeEditorData back to CustomTemplateEditorData for editor state. - * This keeps the custom template editor in sync after Apply. - */ -export function convertEditorDataToTemplateData( - data: NodeEditorData, - originalTemplate: CustomTemplateEditorData | null -): CustomTemplateEditorData { - return { - id: originalTemplate?.id ?? data.id, - isCustomTemplate: true, - customName: data.customName ?? "", - kind: data.kind ?? "", - originalName: originalTemplate?.originalName, - isDefaultCustomNode: data.isDefaultCustomNode, - ...buildCommonTemplateFields(data) - }; -} - -/** - * Convert CustomNodeTemplate to CustomTemplateEditorData for editing. - * Loads all saved template properties so they appear in the editor. - */ -export function convertTemplateToEditorData( - template: CustomNodeTemplate -): CustomTemplateEditorData { - return { - id: "edit-custom-node", - ...buildTemplateEditorFields(template), - isCustomTemplate: true, - customName: template.name, - kind: template.kind, - originalName: template.name - }; -} - -/** - * Create a new empty CustomTemplateEditorData - */ -export function createNewTemplateEditorData( - defaultKind = "nokia_srlinux" -): CustomTemplateEditorData { - return { - id: "temp-custom-node", - isCustomTemplate: true, - customName: "", - kind: defaultKind, - type: "", - image: "", - icon: "pe", - baseName: "", - interfacePattern: "", - isDefaultCustomNode: false - }; -} diff --git a/src/reactTopoViewer/shared/utilities/elementConversions.ts b/src/reactTopoViewer/shared/utilities/elementConversions.ts deleted file mode 100644 index 25a4dd75b..000000000 --- a/src/reactTopoViewer/shared/utilities/elementConversions.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Element conversion utilities for converting between ParsedElement and ReactFlow formats. - * These are pure functions with no dependencies on React or VS Code. - */ - -import type { ParsedElement } from "../types/topology"; -import type { - TopoNode, - TopoEdge, - TopologyData, - TopologyNodeData, - NetworkNodeData, - TopologyEdgeData -} from "../types/graph"; -import { getNumber, getRecordUnknown, getString } from "./typeHelpers"; - -// ============================================================================ -// ParsedElement to ReactFlow Conversion -// ============================================================================ - -/** - * Converts a ParsedElement node to a ReactFlow Node (TopoNode). - */ -const NETWORK_NODE_ROLES = new Set([ - "host", - "mgmt-net", - "macvlan", - "vxlan", - "vxlan-stitch", - "dummy", - "bridge", - "ovs-bridge" -]); - -function getGeoCoordinates( - data: Record -): { lat: number; lng: number } | undefined { - const latValue = data.lat; - const lngValue = data.lng; - const latRaw = - latValue === "" || latValue === null || latValue === undefined ? NaN : Number(latValue); - const lngRaw = - lngValue === "" || lngValue === null || lngValue === undefined ? NaN : Number(lngValue); - if (!Number.isFinite(latRaw) || !Number.isFinite(lngRaw)) { - return undefined; - } - return { lat: latRaw, lng: lngRaw }; -} - -function getNodeLabel(data: Record): string { - return getString(data.name) ?? getString(data.id) ?? ""; -} - -export function parsedElementToTopoNode(element: ParsedElement): TopoNode { - if (element.group !== "nodes") { - throw new Error("Cannot convert edge element to node"); - } - - const data = element.data; - const extraData = getRecordUnknown(data.extraData) ?? {}; - const role = getString(data.topoViewerRole) ?? "pe"; - const geoCoordinates = getGeoCoordinates(data); - const id = getString(data.id) ?? ""; - - // Determine node type based on role - const isNetworkNode = NETWORK_NODE_ROLES.has(role); - - if (isNetworkNode) { - const networkNodeData: NetworkNodeData = { - label: getNodeLabel(data), - nodeType: role, - labelPosition: getString(data.labelPosition), - direction: getString(data.direction), - labelBackgroundColor: getString(data.labelBackgroundColor), - ...(geoCoordinates ? { geoCoordinates } : {}), - extraData - }; - - const node: TopoNode = { - id, - type: "network-node", - position: element.position ?? { x: 0, y: 0 }, - data: networkNodeData - }; - return node; - } - - // Regular topology node - const nodeData: TopologyNodeData = { - label: getNodeLabel(data), - role, - kind: getString(extraData.kind), - image: getString(extraData.image), - iconColor: getString(data.iconColor), - iconCornerRadius: getNumber(data.iconCornerRadius), - labelPosition: getString(data.labelPosition), - direction: getString(data.direction), - labelBackgroundColor: getString(data.labelBackgroundColor), - state: getString(extraData.state), - mgmtIpv4Address: getString(extraData.mgmtIpv4Address), - mgmtIpv6Address: getString(extraData.mgmtIpv6Address), - longname: getString(extraData.longname), - ...(geoCoordinates ? { geoCoordinates } : {}), - extraData - }; - - const node: TopoNode = { - id, - type: "topology-node", - position: element.position ?? { x: 0, y: 0 }, - data: nodeData - }; - return node; -} - -/** - * Converts a ParsedElement edge to a ReactFlow Edge (TopoEdge). - */ -export function parsedElementToTopoEdge(element: ParsedElement): TopoEdge { - if (element.group !== "edges") { - throw new Error("Cannot convert node element to edge"); - } - - const data = element.data; - const extraData = getRecordUnknown(data.extraData) ?? {}; - const classes = element.classes ?? ""; - const sourceEndpoint = getString(data.sourceEndpoint) ?? ""; - const targetEndpoint = getString(data.targetEndpoint) ?? ""; - const edgeId = getString(data.id) ?? ""; - const source = getString(data.source) ?? ""; - const target = getString(data.target) ?? ""; - - // Compute link status from CSS classes - let linkStatus: "up" | "down" | undefined; - if (classes.includes("link-up")) { - linkStatus = "up"; - } else if (classes.includes("link-down")) { - linkStatus = "down"; - } - - const edgeData: TopologyEdgeData = { - sourceEndpoint, - targetEndpoint, - linkStatus, - extraData - }; - - return { - id: edgeId, - source, - target, - type: "topology-edge", - data: edgeData - }; -} - -/** - * Converts an array of ParsedElements to TopologyData (nodes and edges). - */ -export function convertElementsToTopologyData(elements: ParsedElement[]): TopologyData { - const nodes: TopoNode[] = []; - const edges: TopoEdge[] = []; - - for (const element of elements) { - if (element.group === "nodes") { - nodes.push(parsedElementToTopoNode(element)); - } else { - edges.push(parsedElementToTopoEdge(element)); - } - } - - return { nodes, edges }; -} - -// ============================================================================ -// ReactFlow to ParsedElement Conversion (for backwards compatibility) -// ============================================================================ - -/** - * Converts a TopoNode back to ParsedElement format. - */ -export function topoNodeToParsedElement(node: TopoNode): ParsedElement { - const data = node.data; - const extraData = getRecordUnknown(data.extraData); - const geoRaw = data.geoCoordinates ?? extraData?.geoCoordinates; - const geo = getRecordUnknown(geoRaw); - const lat = typeof geo?.lat === "number" ? String(geo.lat) : ""; - const lng = typeof geo?.lng === "number" ? String(geo.lng) : ""; - const topoViewerRole = getString(data.role) ?? getString(data.nodeType) ?? "pe"; - - return { - group: "nodes", - data: { - id: node.id, - weight: "30", - name: data.label ?? node.id, - topoViewerRole, - iconColor: data.iconColor, - iconCornerRadius: data.iconCornerRadius, - labelPosition: data.labelPosition, - direction: data.direction, - labelBackgroundColor: data.labelBackgroundColor, - lat, - lng, - extraData: extraData ?? {} - }, - position: node.position, - removed: false, - selected: false, - selectable: true, - locked: false, - grabbed: false, - grabbable: true, - classes: "" - }; -} - -/** - * Converts a TopoEdge back to ParsedElement format. - */ -export function topoEdgeToParsedElement(edge: TopoEdge): ParsedElement { - const data = edge.data; - const linkStatus = data?.linkStatus; - let classes = ""; - if (linkStatus === "up") classes = "link-up"; - else if (linkStatus === "down") classes = "link-down"; - - return { - group: "edges", - data: { - id: edge.id, - weight: "3", - name: edge.id, - parent: "", - topoViewerRole: "link", - sourceEndpoint: data?.sourceEndpoint ?? "", - targetEndpoint: data?.targetEndpoint ?? "", - lat: "", - lng: "", - source: edge.source, - target: edge.target, - extraData: data?.extraData ?? {} - }, - position: { x: 0, y: 0 }, - removed: false, - selected: false, - selectable: true, - locked: false, - grabbed: false, - grabbable: true, - classes - }; -} - -/** - * Converts TopologyData back to ParsedElement array. - */ -export function convertTopologyDataToElements(data: TopologyData): ParsedElement[] { - const elements: ParsedElement[] = []; - - for (const node of data.nodes) { - elements.push(topoNodeToParsedElement(node)); - } - - for (const edge of data.edges) { - elements.push(topoEdgeToParsedElement(edge)); - } - - return elements; -} diff --git a/src/reactTopoViewer/shared/utilities/helpers/index.ts b/src/reactTopoViewer/shared/utilities/helpers/index.ts deleted file mode 100644 index 3b8ffdd35..000000000 --- a/src/reactTopoViewer/shared/utilities/helpers/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Helper utilities barrel file - */ - -// Type helpers -export { - getString, - getStringOrEmpty, - getNumber, - getBoolean, - getStringArray, - getRecord -} from "../typeHelpers"; - -// Annotation migrations -export { applyInterfacePatternMigrations } from "../annotationMigrations"; -export type { InterfacePatternMigration } from "../annotationMigrations"; diff --git a/src/reactTopoViewer/shared/utilities/idUtils.ts b/src/reactTopoViewer/shared/utilities/idUtils.ts deleted file mode 100644 index 209dd3ca4..000000000 --- a/src/reactTopoViewer/shared/utilities/idUtils.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Utility functions for generating unique IDs for nodes - * Mirrors the logic from legacy topoViewer IdUtils.ts - */ - -import { isSpecialEndpointId } from "./LinkTypes"; - -/** - * Generate unique ID for dummy nodes (dummy1, dummy2, etc.) - */ -export function generateDummyId(baseName: string, usedIds: Set): string { - const re = /^(dummy)(\d*)$/; - const match = re.exec(baseName); - const base = match?.[1] ?? "dummy"; - let num = parseInt(match?.[2] ?? "1") || 1; - while (usedIds.has(`${base}${num}`)) num++; - return `${base}${num}`; -} - -/** - * Generate unique ID for adapter nodes (host:eth1, macvlan:eth1, etc.) - */ -export function generateAdapterNodeId(baseName: string, usedIds: Set): string { - const [nodeType, adapter] = baseName.split(":"); - const adapterRe = /^([a-zA-Z]+)(\d+)$/; - const adapterMatch = adapterRe.exec(adapter); - if (adapterMatch) { - const adapterBase = adapterMatch[1]; - let adapterNum = parseInt(adapterMatch[2]); - let name = baseName; - while (usedIds.has(name)) { - adapterNum++; - name = `${nodeType}:${adapterBase}${adapterNum}`; - } - return name; - } - let name = baseName; - let counter = 1; - while (usedIds.has(name)) { - name = `${nodeType}:${adapter}${counter}`; - counter++; - } - return name; -} - -/** - * Generate unique ID for special nodes by incrementing trailing number - */ -export function generateSpecialNodeId(baseName: string, usedIds: Set): string { - let name = baseName; - while (usedIds.has(name)) { - let i = name.length - 1; - while (i >= 0 && name[i] >= "0" && name[i] <= "9") i--; - const base = name.slice(0, i + 1) || name; - const digits = name.slice(i + 1); - let num = digits ? parseInt(digits, 10) : 0; - num += 1; - name = `${base}${num}`; - } - return name; -} - -/** - * Generate unique ID for regular nodes - * If baseName ends with digits, treat the entire baseName as the base and use "-N" suffix. - * Otherwise, append just "N" directly. - * E.g., "srl" → srl1, srl2; "iol-l2" → iol-l2-1, iol-l2-2 - */ -export function generateRegularNodeId(baseName: string, usedIds: Set): string { - // Check if baseName ends with a digit - const endsWithDigit = /\d$/.test(baseName); - - if (endsWithDigit) { - // Use baseName as-is with "-N" suffix pattern - const separator = "-"; - let num = 1; - while (usedIds.has(`${baseName}${separator}${num}`)) { - num++; - } - return `${baseName}${separator}${num}`; - } - - // Original behavior for names not ending with digits - // Find the highest number used for this base across ALL existing IDs - let maxNum = 0; - for (const id of usedIds) { - // Check if this ID starts with the same base - if (id.startsWith(baseName)) { - const suffix = id.slice(baseName.length); - // Check if suffix is entirely digits - if (suffix.length > 0 && /^\d+$/.test(suffix)) { - const num = parseInt(suffix, 10); - if (num > maxNum) { - maxNum = num; - } - } - } - } - - // Return the next number in sequence - return `${baseName}${maxNum + 1}`; -} - -/** - * Get a unique ID based on baseName type - * Handles special endpoints (dummy, host:eth, macvlan:eth, etc.) and regular nodes - */ -export function getUniqueId(baseName: string, usedIds: Set): string { - if (isSpecialEndpointId(baseName)) { - if (baseName.startsWith("dummy")) { - return generateDummyId(baseName, usedIds); - } - if (baseName.includes(":")) { - return generateAdapterNodeId(baseName, usedIds); - } - return generateSpecialNodeId(baseName, usedIds); - } - return generateRegularNodeId(baseName, usedIds); -} diff --git a/src/reactTopoViewer/shared/utilities/identifiers/index.ts b/src/reactTopoViewer/shared/utilities/identifiers/index.ts deleted file mode 100644 index b002d2341..000000000 --- a/src/reactTopoViewer/shared/utilities/identifiers/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Identifier utilities barrel file - */ - -// ID utilities -export { - generateDummyId, - generateAdapterNodeId, - generateSpecialNodeId, - generateRegularNodeId, - getUniqueId -} from "../idUtils"; - -// Link types and utilities -export { - STR_HOST, - STR_MGMT_NET, - PREFIX_MACVLAN, - PREFIX_VXLAN, - PREFIX_VXLAN_STITCH, - PREFIX_DUMMY, - PREFIX_BRIDGE, - PREFIX_OVS_BRIDGE, - TYPE_DUMMY, - SINGLE_ENDPOINT_TYPES, - VX_TYPES, - HOSTY_TYPES, - isSpecialEndpointId, - isSpecialNodeOrBridge, - splitEndpointLike -} from "../LinkTypes"; diff --git a/src/reactTopoViewer/shared/utilities/index.ts b/src/reactTopoViewer/shared/utilities/index.ts deleted file mode 100644 index 291e2bd82..000000000 --- a/src/reactTopoViewer/shared/utilities/index.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Shared utilities barrel file - * - * For large imports, prefer importing from sub-barrels directly: - * - './conversions' - Node/network/element conversion utilities - * - './identifiers' - ID generation and link type utilities - * - './helpers' - Type helpers and migration utilities - */ - -// Re-export from conversions -export { - convertToEditorData, - convertEditorDataToYaml, - convertEditorDataToNodeSaveData, - convertToNetworkEditorData, - convertNetworkEditorDataToYaml, - convertCustomTemplateToEditorData, - convertEditorDataToSaveData, - convertTemplateToEditorData, - createNewTemplateEditorData, - parsedElementToTopoNode, - parsedElementToTopoEdge, - convertElementsToTopologyData, - topoNodeToParsedElement, - topoEdgeToParsedElement, - convertTopologyDataToElements -} from "./conversions"; -export type { YamlExtraData, SaveCustomNodeData } from "./conversions"; - -// Re-export from identifiers -export { - generateDummyId, - generateAdapterNodeId, - generateSpecialNodeId, - generateRegularNodeId, - getUniqueId, - STR_HOST, - STR_MGMT_NET, - PREFIX_MACVLAN, - PREFIX_VXLAN, - PREFIX_VXLAN_STITCH, - PREFIX_DUMMY, - PREFIX_BRIDGE, - PREFIX_OVS_BRIDGE, - TYPE_DUMMY, - SINGLE_ENDPOINT_TYPES, - VX_TYPES, - HOSTY_TYPES, - isSpecialEndpointId, - isSpecialNodeOrBridge, - splitEndpointLike -} from "./identifiers"; - -// Re-export from helpers -export { - getString, - getStringOrEmpty, - getNumber, - getBoolean, - getStringArray, - getRecord, - applyInterfacePatternMigrations -} from "./helpers"; -export type { InterfacePatternMigration } from "./helpers"; diff --git a/src/reactTopoViewer/shared/utilities/loggerUtils.ts b/src/reactTopoViewer/shared/utilities/loggerUtils.ts deleted file mode 100644 index 118637104..000000000 --- a/src/reactTopoViewer/shared/utilities/loggerUtils.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Shared logging utilities used by both extension and webview loggers - */ - -export type LogLevel = "info" | "debug" | "warn" | "error"; - -/** - * Format message for logging - */ -export function formatMessage(msg: unknown): string { - if (typeof msg === "string") return msg; - if (typeof msg === "object" && msg !== null) { - try { - return JSON.stringify(msg); - } catch { - return String(msg); - } - } - return String(msg); -} - -/** - * Extract file name and line number from caller stack. - * @param skipFrames Number of additional stack frames to skip (default 0) - */ -export function getCallerFileLine(skipFrames = 0): string { - const obj: { stack?: string } = {}; - Error.captureStackTrace(obj, getCallerFileLine); - - const stack = obj.stack; - if (stack === undefined || stack.length === 0) return "unknown:0"; - - const lines = stack.split("\n"); - const baseIndex = 3 + skipFrames; - const callSite = lines[baseIndex] || lines[baseIndex + 1] || ""; - - const reParen = /\(([^():]+):(\d+):\d+\)/; - const reAt = /at ([^():]+):(\d+):\d+/; - const match = reParen.exec(callSite) ?? reAt.exec(callSite); - if (!match) return "unknown:0"; - - const filePath = match[1]; - const lineNum = match[2]; - const fileName = filePath.split(/[\\/]/).pop() ?? "unknown"; - return `${fileName}:${lineNum}`; -} - -/** - * Create a standard logger object from a logging function - */ -export function createLogger(logFn: (level: LogLevel, message: unknown) => void) { - return { - info(msg: unknown): void { - logFn("info", msg); - }, - debug(msg: unknown): void { - logFn("debug", msg); - }, - warn(msg: unknown): void { - logFn("warn", msg); - }, - error(msg: unknown): void { - logFn("error", msg); - } - }; -} diff --git a/src/reactTopoViewer/shared/utilities/networkEditorConversions.ts b/src/reactTopoViewer/shared/utilities/networkEditorConversions.ts deleted file mode 100644 index 9f00a2d29..000000000 --- a/src/reactTopoViewer/shared/utilities/networkEditorConversions.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Utility functions for converting between NetworkEditorData and - * Node data format for network nodes - */ - -import type { NetworkEditorData, NetworkType } from "../types/editors"; - -import { getStringOrEmpty, getRecord } from "./typeHelpers"; - -/** - * Parse network type from node data - * Checks in order: extraData.kind, top-level kind, top-level type, parse from ID - * Network node IDs follow patterns like: "host:eth0", "mgmt-net:eth1", "bridge:br0" - */ -function parseNetworkType( - nodeId: string, - rawData: Record, - extraData: Record -): NetworkType { - // Check extraData.kind first (new network creation format) - const extraKind = getStringOrEmpty(extraData.kind); - if (extraKind && isValidNetworkType(extraKind)) { - return extraKind; - } - - // Check top-level kind field - const topKind = getStringOrEmpty(rawData.kind); - if (topKind && isValidNetworkType(topKind)) { - return topKind; - } - - // Check top-level type field (mock data format) - const topType = getStringOrEmpty(rawData.type); - if (topType && isValidNetworkType(topType)) { - return topType; - } - - // Fall back to parsing from node ID - const parts = nodeId.split(":"); - const prefix = parts[0]; - if (isValidNetworkType(prefix)) { - return prefix; - } - - // Default to host if we can't determine - return "host"; -} - -/** - * Check if a string is a valid network type - */ -function isValidNetworkType(type: string): type is NetworkType { - return [ - "host", - "mgmt-net", - "macvlan", - "vxlan", - "vxlan-stitch", - "dummy", - "bridge", - "ovs-bridge" - ].includes(type); -} - -/** Host-like types that have host-interface property */ -const HOST_INTERFACE_TYPES = new Set(["host", "macvlan", "mgmt-net"]); - -/** - * Parse interface name from node ID or extraData - * For network nodes, prefers extHostInterface from extraData if available, - * otherwise falls back to extracting from node ID (e.g., "host:eth0" -> "eth0") - * For bridges, it might be the full ID or from extYamlNodeId - */ -function parseInterfaceName( - nodeId: string, - networkType: NetworkType, - extraData: Record -): string { - // For bridges, prefer extYamlNodeId if available - if (networkType === "bridge" || networkType === "ovs-bridge") { - const yamlId = getStringOrEmpty(extraData.extYamlNodeId); - if (yamlId) return yamlId; - return nodeId; - } - - // For dummy, there's no interface - if (networkType === "dummy") { - return ""; - } - - // For host-interface types, check extHostInterface first (saved by network editor) - if (HOST_INTERFACE_TYPES.has(networkType)) { - const hostInterface = getStringOrEmpty(extraData.extHostInterface); - if (hostInterface) return hostInterface; - } - - // Fall back to extracting from node ID - const parts = nodeId.split(":"); - return parts[1] || "eth1"; -} - -/** - * Converts raw network node data (from the graph) to NetworkEditorData format - */ -export function convertToNetworkEditorData( - rawData: Record | null -): NetworkEditorData | null { - if (!rawData) return null; - - const nodeId = getStringOrEmpty(rawData.id); - const extra = getRecord(rawData.extraData) ?? {}; - const networkType = parseNetworkType(nodeId, rawData, extra); - - return { - id: nodeId, - networkType, - interfaceName: parseInterfaceName(nodeId, networkType, extra), - label: getStringOrEmpty(rawData.name) || getStringOrEmpty(rawData.label) || nodeId, - // VXLAN fields - vxlanRemote: getStringOrEmpty(extra.extRemote), - vxlanVni: getStringOrEmpty(extra.extVni), - vxlanDstPort: getStringOrEmpty(extra.extDstPort), - vxlanSrcPort: getStringOrEmpty(extra.extSrcPort), - // MACVLAN mode - macvlanMode: getStringOrEmpty(extra.extMode), - // MAC address - mac: getStringOrEmpty(extra.extMac), - // MTU - mtu: getStringOrEmpty(extra.extMtu), - // Optional metadata (check extVars/extLabels first, fall back to vars/labels) - vars: getRecord(extra.extVars) ?? getRecord(extra.vars), - labels: getRecord(extra.extLabels) ?? getRecord(extra.labels) - }; -} - -/** - * Convert NetworkEditorData back to extraData format for saving - */ -export function convertNetworkEditorDataToYaml(data: NetworkEditorData): Record { - const hasNonEmptyString = (value: string | undefined): value is string => - value !== undefined && value.length > 0; - - const result: Record = { - kind: data.networkType - }; - - // VXLAN-specific fields - if (hasNonEmptyString(data.vxlanRemote)) result.extRemote = data.vxlanRemote; - if (hasNonEmptyString(data.vxlanVni)) result.extVni = data.vxlanVni; - if (hasNonEmptyString(data.vxlanDstPort)) result.extDstPort = data.vxlanDstPort; - if (hasNonEmptyString(data.vxlanSrcPort)) result.extSrcPort = data.vxlanSrcPort; - - // MACVLAN mode - if (hasNonEmptyString(data.macvlanMode)) result.extMode = data.macvlanMode; - - // MAC address - if (hasNonEmptyString(data.mac)) result.extMac = data.mac; - - // MTU - if (hasNonEmptyString(data.mtu)) result.extMtu = data.mtu; - - // Metadata - if (data.vars !== undefined && Object.keys(data.vars).length > 0) result.vars = data.vars; - if (data.labels !== undefined && Object.keys(data.labels).length > 0) result.labels = data.labels; - - return result; -} diff --git a/src/reactTopoViewer/shared/utilities/nodeEditorConversions.ts b/src/reactTopoViewer/shared/utilities/nodeEditorConversions.ts deleted file mode 100644 index df6523eb4..000000000 --- a/src/reactTopoViewer/shared/utilities/nodeEditorConversions.ts +++ /dev/null @@ -1,691 +0,0 @@ -/** - * Utility functions for converting between NodeEditorData (camelCase) and - * YAML extraData format (kebab-case) - */ - -import type { NodeEditorData, HealthCheckConfig, SrosComponent } from "../types/editors"; -import type { NodeSaveData } from "../../shared/io/NodePersistenceIO"; - -import { - getString, - getNumber, - getBoolean, - getStringArray, - getRecord, - getRecordUnknown -} from "./typeHelpers"; - -// ============================================================================ -// YAML -> NodeEditorData (for loading into editor) -// ============================================================================ - -/** Parse basic properties from extraData (with fallback to top-level data) */ -function parseBasicProps( - rawData: Record, - extra: Record -): Pick< - NodeEditorData, - | "id" - | "name" - | "kind" - | "type" - | "image" - | "icon" - | "iconColor" - | "iconCornerRadius" - | "labelPosition" - | "direction" - | "labelBackgroundColor" -> { - return { - id: getString(rawData.id) ?? "", - name: getString(rawData.name) ?? getString(rawData.id) ?? "", - // Check extraData first, then fall back to top-level data (for mock/dev mode) - kind: getString(extra.kind) ?? getString(rawData.kind), - type: getString(extra.type) ?? getString(rawData.type), - image: getString(extra.image) ?? getString(rawData.image), - // ReactFlow node data uses "role", parsed element format uses "topoViewerRole" - icon: getString(rawData.role) ?? getString(rawData.topoViewerRole) ?? "", - iconColor: getString(rawData.iconColor), - iconCornerRadius: getNumber(rawData.iconCornerRadius), - labelPosition: getString(rawData.labelPosition) ?? getString(extra.labelPosition), - direction: getString(rawData.direction) ?? getString(extra.direction), - labelBackgroundColor: - getString(rawData.labelBackgroundColor) ?? getString(extra.labelBackgroundColor) - }; -} - -/** Parse configuration properties from extraData */ -function parseConfigProps( - extra: Record -): Pick< - NodeEditorData, - | "startupConfig" - | "enforceStartupConfig" - | "suppressStartupConfig" - | "license" - | "binds" - | "env" - | "envFiles" - | "labels" -> { - return { - startupConfig: getString(extra["startup-config"]), - enforceStartupConfig: getBoolean(extra["enforce-startup-config"]), - suppressStartupConfig: getBoolean(extra["suppress-startup-config"]), - license: getString(extra.license), - binds: getStringArray(extra.binds), - env: getRecord(extra.env), - envFiles: getStringArray(extra["env-files"]), - labels: getRecord(extra.labels) - }; -} - -/** Parse runtime properties from extraData */ -function parseRuntimeProps( - extra: Record -): Pick< - NodeEditorData, - "user" | "entrypoint" | "cmd" | "exec" | "restartPolicy" | "autoRemove" | "startupDelay" -> { - return { - user: getString(extra.user), - entrypoint: getString(extra.entrypoint), - cmd: getString(extra.cmd), - exec: getStringArray(extra.exec), - restartPolicy: getString(extra["restart-policy"]), - autoRemove: getBoolean(extra["auto-remove"]), - startupDelay: getNumber(extra["startup-delay"]) - }; -} - -/** Parse network properties from extraData */ -function parseNetworkProps( - extra: Record -): Pick< - NodeEditorData, - "mgmtIpv4" | "mgmtIpv6" | "networkMode" | "ports" | "dnsServers" | "aliases" -> { - return { - mgmtIpv4: getString(extra["mgmt-ipv4"]), - mgmtIpv6: getString(extra["mgmt-ipv6"]), - networkMode: getString(extra["network-mode"]), - ports: getStringArray(extra.ports), - dnsServers: getStringArray(extra.dns), - aliases: getStringArray(extra.aliases) - }; -} - -/** Parse resource/advanced properties from extraData */ -function parseAdvancedProps( - extra: Record -): Pick< - NodeEditorData, - | "cpu" - | "cpuSet" - | "memory" - | "shmSize" - | "capAdd" - | "sysctls" - | "devices" - | "imagePullPolicy" - | "runtime" -> { - return { - cpu: getNumber(extra.cpu), - cpuSet: getString(extra["cpu-set"]), - memory: getString(extra.memory), - shmSize: getString(extra["shm-size"]), - capAdd: getStringArray(extra["cap-add"]), - sysctls: getRecord(extra.sysctls), - devices: getStringArray(extra.devices), - imagePullPolicy: getString(extra["image-pull-policy"]), - runtime: getString(extra.runtime) - }; -} - -/** Parse certificate properties from extraData */ -function parseCertProps( - extra: Record -): Pick { - const certRaw = getRecordUnknown(extra.certificate); - if (!certRaw) return {}; - - return { - certIssue: certRaw.issue !== undefined ? Boolean(certRaw.issue) : undefined, - certKeySize: getString(certRaw["key-size"]), - certValidity: getString(certRaw["validity-duration"]), - sans: getStringArray(certRaw.SANs) - }; -} - -/** Parse healthcheck properties from extraData */ -function parseHealthCheckProps(extra: Record): { - healthCheck?: HealthCheckConfig; -} { - const healthcheckRaw = getRecordUnknown(extra.healthcheck); - if (!healthcheckRaw) return {}; - - return { - healthCheck: { - test: getString(healthcheckRaw.test), - startPeriod: getNumber(healthcheckRaw["start-period"]), - interval: getNumber(healthcheckRaw.interval), - timeout: getNumber(healthcheckRaw.timeout), - retries: getNumber(healthcheckRaw.retries) - } - }; -} - -/** Parse MDA items from an array */ -function parseMdaItems(arr: unknown[]): { slot?: number; type?: string }[] { - return arr - .filter((m): m is Record => m !== null && typeof m === "object") - .map((m) => ({ - slot: getNumber(m.slot), - type: getString(m.type) - })); -} - -/** Parse SROS components from extraData */ -function parseComponentsProps(extra: Record): { components?: SrosComponent[] } { - const componentsRaw = extra.components; - if (!Array.isArray(componentsRaw)) return {}; - - const components: SrosComponent[] = componentsRaw - .filter((c): c is Record => c !== null && typeof c === "object") - .map((c: Record) => { - const slot = c.slot; - return { - slot: typeof slot === "string" || typeof slot === "number" ? slot : undefined, - type: getString(c.type), - sfm: getString(c.sfm), - mda: Array.isArray(c.mda) ? parseMdaItems(c.mda) : undefined, - xiom: Array.isArray(c.xiom) - ? c.xiom - .filter((x): x is Record => x !== null && typeof x === "object") - .map((x) => ({ - slot: getNumber(x.slot), - type: getString(x.type), - mda: Array.isArray(x.mda) ? parseMdaItems(x.mda) : undefined - })) - : undefined - }; - }); - - return components.length > 0 ? { components } : {}; -} - -/** - * Converts raw node data (from the graph/YAML) to NodeEditorData format - * Maps from YAML kebab-case properties (in extraData) to camelCase NodeEditorData - */ -export function convertToEditorData( - rawData: Record | null -): NodeEditorData | null { - if (!rawData) return null; - const extra = getRecordUnknown(rawData.extraData) ?? {}; - - return { - ...parseBasicProps(rawData, extra), - ...parseConfigProps(extra), - ...parseRuntimeProps(extra), - ...parseNetworkProps(extra), - ...parseAdvancedProps(extra), - ...parseCertProps(extra), - ...parseHealthCheckProps(extra), - ...parseComponentsProps(extra) - }; -} - -// ============================================================================ -// NodeEditorData -> YAML extraData (for saving to YAML) -// ============================================================================ - -/** YAML extraData type matching TopologyIO */ -export interface YamlExtraData { - kind?: string; - type?: string | null; - image?: string | null; - group?: string | null; - "startup-config"?: string | null; - "enforce-startup-config"?: boolean | null; - "suppress-startup-config"?: boolean | null; - license?: string | null; - binds?: string[] | null; - env?: Record | null; - "env-files"?: string[] | null; - labels?: Record | null; - user?: string | null; - entrypoint?: string | null; - cmd?: string | null; - exec?: string[] | null; - "restart-policy"?: string | null; - "auto-remove"?: boolean | null; - "startup-delay"?: number | null; - "mgmt-ipv4"?: string | null; - "mgmt-ipv6"?: string | null; - "network-mode"?: string | null; - ports?: string[] | null; - dns?: string[] | null; - aliases?: string[] | null; - cpu?: number | null; - "cpu-set"?: string | null; - memory?: string | null; - "shm-size"?: string | null; - "cap-add"?: string[] | null; - sysctls?: Record | null; - devices?: string[] | null; - certificate?: Record | null; - healthcheck?: Record | null; - "image-pull-policy"?: string | null; - runtime?: string | null; - components?: unknown[] | null; - [key: string]: unknown; -} - -// ============================================================================ -// Helper functions for clearable field conversion -// These helpers reduce cognitive complexity by encapsulating the null-or-value pattern -// ============================================================================ - -/** Convert a string field: returns value if truthy, null if empty (for deletion) */ -function toStringOrNull(value: unknown): string | null { - if (typeof value === "string") { - return value === "" ? null : value; - } - if (typeof value === "number") { - return String(value); - } - return null; -} - -/** Convert an array field: returns value if non-empty, null if empty (for deletion) */ -function toArrayOrNull(arr: T[]): T[] | null { - return arr.length > 0 ? arr : null; -} - -/** Convert a record field: returns value if non-empty, null if empty (for deletion) */ -function toRecordOrNull(obj: Record): Record | null { - return Object.keys(obj).length > 0 ? obj : null; -} - -/** - * Convert a boolean field: returns true only if explicitly true, null otherwise (for deletion). - * This matches containerlab behavior where most boolean fields default to false, - * so explicit false is redundant and should be omitted. - */ -function toBooleanOrNull(value: unknown): true | null { - return value === true ? true : null; -} - -/** - * Convert a number field: returns value if it's a valid positive number, null otherwise. - * Empty strings, 0, NaN, undefined all result in null (deletion). - */ -function toNumberOrNull(value: unknown): number | null { - if (value === undefined || value === null || value === "") return null; - const num = Number(value); - // Only return if it's a valid non-NaN number (allow 0 for explicit zero values) - // But for most fields, 0 is the default, so we delete it - return !isNaN(num) && num !== 0 ? num : null; -} - -/** Convert basic properties to YAML format */ -function convertBasicToYaml(data: Record, extraData: YamlExtraData): void { - const kind = toStringOrNull(data.kind); - if (kind !== null) extraData.kind = kind; - // String fields: set value if non-empty, null if empty string (to trigger deletion) - // Use 'in' check to detect when user explicitly cleared the field (set to undefined) - if ("type" in data) extraData.type = toStringOrNull(data.type); - if ("image" in data) extraData.image = toStringOrNull(data.image); - if ("group" in data) extraData.group = toStringOrNull(data.group); -} - -/** Convert startup config properties to YAML format */ -function convertStartupConfigToYaml(data: Record, extraData: YamlExtraData): void { - if ("startupConfig" in data) extraData["startup-config"] = toStringOrNull(data.startupConfig); - // Boolean fields: only write true, otherwise delete (null) - if ("enforceStartupConfig" in data) { - extraData["enforce-startup-config"] = toBooleanOrNull(data.enforceStartupConfig); - } - if ("suppressStartupConfig" in data) { - extraData["suppress-startup-config"] = toBooleanOrNull(data.suppressStartupConfig); - } - if ("license" in data) extraData.license = toStringOrNull(data.license); -} - -/** Convert container config properties to YAML format */ -function convertContainerConfigToYaml( - data: Record, - extraData: YamlExtraData -): void { - const binds = getStringArray(data.binds); - if (binds !== undefined) extraData.binds = toArrayOrNull(binds); - const env = getRecordUnknown(data.env); - if (env !== undefined) { - extraData.env = toRecordOrNull(env); - } - const envFiles = getStringArray(data.envFiles); - if (envFiles !== undefined) extraData["env-files"] = toArrayOrNull(envFiles); - const labels = getRecordUnknown(data.labels); - if (labels !== undefined) { - extraData.labels = toRecordOrNull(labels); - } -} - -/** Convert configuration properties to YAML format */ -function convertConfigToYaml(data: Record, extraData: YamlExtraData): void { - convertStartupConfigToYaml(data, extraData); - convertContainerConfigToYaml(data, extraData); -} - -/** Convert runtime properties to YAML format */ -function convertRuntimeToYaml(data: Record, extraData: YamlExtraData): void { - if ("user" in data) extraData.user = toStringOrNull(data.user); - if ("entrypoint" in data) extraData.entrypoint = toStringOrNull(data.entrypoint); - if ("cmd" in data) extraData.cmd = toStringOrNull(data.cmd); - const exec = getStringArray(data.exec); - if (exec !== undefined) extraData.exec = toArrayOrNull(exec); - if ("restartPolicy" in data) extraData["restart-policy"] = toStringOrNull(data.restartPolicy); - // Boolean field: only write true, otherwise delete (null) - if ("autoRemove" in data) { - extraData["auto-remove"] = toBooleanOrNull(data.autoRemove); - } - // Number field: only write if non-zero, otherwise delete (null) - // Use 'in' check to detect when user explicitly cleared the field (set to undefined) - if ("startupDelay" in data) { - extraData["startup-delay"] = toNumberOrNull(data.startupDelay); - } -} - -/** Convert network properties to YAML format */ -function convertNetworkToYaml(data: Record, extraData: YamlExtraData): void { - if ("mgmtIpv4" in data) extraData["mgmt-ipv4"] = toStringOrNull(data.mgmtIpv4); - if ("mgmtIpv6" in data) extraData["mgmt-ipv6"] = toStringOrNull(data.mgmtIpv6); - if ("networkMode" in data) extraData["network-mode"] = toStringOrNull(data.networkMode); - const ports = getStringArray(data.ports); - if (ports !== undefined) extraData.ports = toArrayOrNull(ports); - const dnsServers = getStringArray(data.dnsServers); - if (dnsServers !== undefined) extraData.dns = toArrayOrNull(dnsServers); - const aliases = getStringArray(data.aliases); - if (aliases !== undefined) extraData.aliases = toArrayOrNull(aliases); -} - -/** Convert resource limit properties to YAML format */ -function convertResourceLimitsToYaml( - data: Record, - extraData: YamlExtraData -): void { - // Number field: only write if non-zero, otherwise delete (null) - if ("cpu" in data) { - extraData.cpu = toNumberOrNull(data.cpu); - } - if ("cpuSet" in data) extraData["cpu-set"] = toStringOrNull(data.cpuSet); - if ("memory" in data) extraData.memory = toStringOrNull(data.memory); - if ("shmSize" in data) extraData["shm-size"] = toStringOrNull(data.shmSize); -} - -/** Convert container capabilities and sysctls to YAML format */ -function convertCapabilitiesToYaml(data: Record, extraData: YamlExtraData): void { - const capAdd = getStringArray(data.capAdd); - if (capAdd !== undefined) extraData["cap-add"] = toArrayOrNull(capAdd); - const sysctls = getRecordUnknown(data.sysctls); - if (sysctls !== undefined) { - extraData.sysctls = toRecordOrNull(sysctls); - } - const devices = getStringArray(data.devices); - if (devices !== undefined) extraData.devices = toArrayOrNull(devices); -} - -/** Convert advanced/resource properties to YAML format */ -function convertAdvancedToYaml(data: Record, extraData: YamlExtraData): void { - convertResourceLimitsToYaml(data, extraData); - convertCapabilitiesToYaml(data, extraData); - if ("imagePullPolicy" in data) - extraData["image-pull-policy"] = toStringOrNull(data.imagePullPolicy); - if ("runtime" in data) extraData.runtime = toStringOrNull(data.runtime); -} - -/** Convert certificate properties to YAML format */ -function convertCertToYaml(data: Record, extraData: YamlExtraData): void { - // Check if ANY certificate field is defined (even if empty - we need to know if user touched them) - const hasCertFields = - data.certIssue !== undefined || - data.certKeySize !== undefined || - data.certValidity !== undefined || - data.sans !== undefined; - if (!hasCertFields) return; - - const cert: Record = {}; - // Boolean field: only write true - if (data.certIssue === true) cert.issue = true; - // String fields - const keySize = toStringOrNull(data.certKeySize); - if (keySize !== null) cert["key-size"] = keySize; - const validity = toStringOrNull(data.certValidity); - if (validity !== null) cert["validity-duration"] = validity; - // Array field - const sans = getStringArray(data.sans); - const sansArr = sans !== undefined ? toArrayOrNull(sans) : null; - if (sansArr !== null) cert.SANs = sansArr; - - // If all fields are empty, signal deletion; otherwise set the certificate object - if (Object.keys(cert).length > 0) { - extraData.certificate = cert; - } else { - // All certificate fields were cleared - delete the certificate - extraData.certificate = null; - } -} - -/** Convert healthcheck properties to YAML format */ -function convertHealthcheckToYaml(data: Record, extraData: YamlExtraData): void { - const hc = getRecordUnknown(data.healthCheck); - if (hc === undefined) return; - - const healthcheck: Record = {}; - // String field - const test = toStringOrNull(hc.test); - if (test !== null) healthcheck.test = test; - // Number fields - use toNumberOrNull for proper empty/zero handling - const startPeriod = toNumberOrNull(hc.startPeriod); - if (startPeriod !== null) healthcheck["start-period"] = startPeriod; - const interval = toNumberOrNull(hc.interval); - if (interval !== null) healthcheck.interval = interval; - const timeout = toNumberOrNull(hc.timeout); - if (timeout !== null) healthcheck.timeout = timeout; - const retries = toNumberOrNull(hc.retries); - if (retries !== null) healthcheck.retries = retries; - - // If all fields are empty, signal deletion; otherwise set the healthcheck object - if (Object.keys(healthcheck).length > 0) { - extraData.healthcheck = healthcheck; - } else { - // All healthcheck fields were cleared - delete the healthcheck - extraData.healthcheck = null; - } -} - -/** Check if a component object has any meaningful properties */ -function isNonEmptyComponent(comp: Record): boolean { - return Object.keys(comp).length > 0; -} - -/** Convert MDA array to YAML format */ -function convertMdaArray( - mdaList: Array<{ slot?: string | number; type?: string }> -): Array> { - return mdaList - .map((m) => { - const mda: Record = {}; - if (m.slot !== undefined) mda.slot = m.slot; - if (m.type !== undefined && m.type !== "") mda.type = m.type; - return mda; - }) - .filter(isNonEmptyComponent); -} - -/** Convert a single SROS component to YAML format, returns null if empty */ -function convertSingleComponent(c: SrosComponent): Record | null { - const comp: Record = {}; - - if (c.slot !== undefined && c.slot !== "") comp.slot = c.slot; - if (c.type !== undefined && c.type !== "") comp.type = c.type; - if (c.sfm !== undefined && c.sfm !== "") comp.sfm = c.sfm; - - if (c.mda && c.mda.length > 0) { - const mdaList = convertMdaArray(c.mda); - if (mdaList.length > 0) comp.mda = mdaList; - } - - if (c.xiom && c.xiom.length > 0) { - const xiomList = c.xiom - .map((x) => { - const xiom: Record = {}; - if (x.slot !== undefined) xiom.slot = x.slot; - if (x.type !== undefined && x.type !== "") xiom.type = x.type; - if (x.mda && x.mda.length > 0) { - const xMdaList = convertMdaArray(x.mda); - if (xMdaList.length > 0) xiom.mda = xMdaList; - } - return xiom; - }) - .filter(isNonEmptyComponent); - if (xiomList.length > 0) comp.xiom = xiomList; - } - - return isNonEmptyComponent(comp) ? comp : null; -} - -/** Convert SROS components to YAML format */ -function convertComponentsToYaml(data: Record, extraData: YamlExtraData): void { - const kind = getString(data.kind); - const components = Array.isArray(data.components) - ? data.components.filter( - (entry): entry is SrosComponent => entry !== null && typeof entry === "object" - ) - : undefined; - - // If kind is not nokia_srsim, delete any existing components - if (kind !== undefined && kind !== "" && kind !== "nokia_srsim") { - extraData.components = null; - return; - } - - // If components is explicitly set to empty array, signal deletion - if (Array.isArray(components) && components.length === 0) { - extraData.components = null; - return; - } - - if (components === undefined) return; - - // Convert components, filtering out empty ones - const converted = components - .map(convertSingleComponent) - .filter((c): c is Record => c !== null); - - // If all components were empty, signal deletion - if (converted.length === 0) { - extraData.components = null; - return; - } - - extraData.components = converted; -} - -/** - * Convert NodeEditorData (camelCase) to extraData format (kebab-case) for YAML - */ -export function convertEditorDataToYaml(data: Record): YamlExtraData { - const extraData: YamlExtraData = {}; - - convertBasicToYaml(data, extraData); - convertConfigToYaml(data, extraData); - convertRuntimeToYaml(data, extraData); - convertNetworkToYaml(data, extraData); - convertAdvancedToYaml(data, extraData); - convertCertToYaml(data, extraData); - convertHealthcheckToYaml(data, extraData); - convertComponentsToYaml(data, extraData); - - return extraData; -} - -// ============================================================================ -// NodeEditorData -> NodeSaveData (for TopologyIO service) -// ============================================================================ - -function mapDefaultToNull( - value: string | undefined, - defaultValue: string -): string | null | undefined { - if (value === undefined) { - return undefined; - } - - return value === defaultValue ? null : value; -} - -function normalizeLabelBackgroundColor(value: string | undefined): string | null | undefined { - if (value === undefined) { - return undefined; - } - - const trimmedValue = value.trim(); - if (trimmedValue === "") { - return null; - } - - const normalizedValue = trimmedValue.replace(/\s+/g, "").toLowerCase(); - if (normalizedValue === "rgba(0,0,0,0.7)") { - return null; - } - - return trimmedValue; -} - -/** - * Convert NodeEditorData to NodeSaveData format for TopologyIO. - * This is used when saving node editor changes via the services. - * - * @param data - NodeEditorData from the editor panel - * @param oldName - Optional original name if node is being renamed - * @returns NodeSaveData for TopologyIO.editNode() - */ -export function convertEditorDataToNodeSaveData( - data: NodeEditorData, - oldName?: string -): NodeSaveData { - const yamlExtraData = convertEditorDataToYaml({ ...data }); - const labelPosition = mapDefaultToNull(data.labelPosition, "bottom"); - const direction = mapDefaultToNull(data.direction, "right"); - const labelBackgroundColor = normalizeLabelBackgroundColor(data.labelBackgroundColor); - - // Build the extraData with annotation props. - const extraData: NodeSaveData["extraData"] = { - ...yamlExtraData, - // Annotation properties (saved to annotations.json, not YAML) - topoViewerRole: data.icon, - iconColor: data.iconColor, - iconCornerRadius: data.iconCornerRadius, - interfacePattern: data.interfacePattern, - labelPosition, - direction, - labelBackgroundColor - }; - - const saveData: NodeSaveData = { - id: data.id, - name: data.name, - extraData - }; - - // If renaming, include the old name so TopologyIO can find and rename the node - if (oldName !== undefined && oldName !== "" && oldName !== data.name) { - (saveData as NodeSaveData & { oldName?: string }).oldName = oldName; - } - - return saveData; -} diff --git a/src/reactTopoViewer/shared/utilities/typeHelpers.ts b/src/reactTopoViewer/shared/utilities/typeHelpers.ts deleted file mode 100644 index cb3238bed..000000000 --- a/src/reactTopoViewer/shared/utilities/typeHelpers.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Shared type helper functions for safe value extraction - */ - -/** - * Safely get string value, returns undefined if not a string - */ -export function getString(val: unknown): string | undefined { - return typeof val === "string" ? val : undefined; -} - -/** - * Runtime guard for plain objects. - */ -export function isRecord(val: unknown): val is Record { - return val !== null && typeof val === "object" && !Array.isArray(val); -} - -/** - * Safely get string value with empty string default. - * Also converts numbers to strings for fields that may be stored as numbers. - */ -export function getStringOrEmpty(val: unknown): string { - if (typeof val === "string") return val; - if (typeof val === "number") return String(val); - return ""; -} - -/** - * Safely get number value - */ -export function getNumber(val: unknown): number | undefined { - return typeof val === "number" ? val : undefined; -} - -/** - * Safely get boolean value - */ -export function getBoolean(val: unknown): boolean | undefined { - return typeof val === "boolean" ? val : undefined; -} - -/** - * Safely get string array - */ -export function getStringArray(val: unknown): string[] | undefined { - return Array.isArray(val) ? val.filter((v): v is string => typeof v === "string") : undefined; -} - -/** - * Safely get record (object) value with unknown values. - */ -export function getRecordUnknown(val: unknown): Record | undefined { - return isRecord(val) ? val : undefined; -} - -/** - * Safely get record (object) value with only string values. - */ -export function getRecord(val: unknown): Record | undefined { - if (!isRecord(val)) return undefined; - return Object.fromEntries( - Object.entries(val).filter((entry): entry is [string, string] => typeof entry[1] === "string") - ); -} diff --git a/src/reactTopoViewer/webview/App.tsx b/src/reactTopoViewer/webview/App.tsx deleted file mode 100644 index e0bf52aa8..000000000 --- a/src/reactTopoViewer/webview/App.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/** - * React TopoViewer Main Application Component - * - * Uses Zustand stores for state management. - * Graph state is managed by graphStore (React Flow is source of truth). - */ -import React from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; - -import type { CanvasRef } from "./hooks/ui"; -import type { ReactFlowCanvasRef } from "./components/canvas"; -import { useLayoutControls } from "./hooks/ui"; -import { - type InitialGraphData, - useStoreInitialization, - useGraphMessageSubscription, - useTopoViewerMessageSubscription, - useTopologyHostInitialization -} from "./hooks/app"; -import { AppContent } from "./AppContent"; - -/** Main App component - initializes stores and subscriptions */ -export const App: React.FC<{ initialData?: InitialGraphData }> = ({ initialData }) => { - const reactFlowRef = React.useRef(null); - const [rfInstance, setRfInstance] = React.useState(null); - const layoutCanvasRef: React.RefObject = reactFlowRef; - const layoutControls = useLayoutControls(layoutCanvasRef); - - // Initialize stores with initial data - useStoreInitialization({ initialData }); - - // Set up message subscriptions (side effects) - useGraphMessageSubscription(); - useTopoViewerMessageSubscription(); - useTopologyHostInitialization(); - - return ( - - ); -}; diff --git a/src/reactTopoViewer/webview/AppContent.tsx b/src/reactTopoViewer/webview/AppContent.tsx deleted file mode 100644 index a4b146d14..000000000 --- a/src/reactTopoViewer/webview/AppContent.tsx +++ /dev/null @@ -1,1689 +0,0 @@ -// App content — UI composition for the React TopoViewer. -/* eslint-disable import-x/max-dependencies */ -import React from "react"; -import type { Edge, Node, ReactFlowInstance } from "@xyflow/react"; -import Box from "@mui/material/Box"; - -import { ContainerlabExplorerView } from "../../webviews/explorer/containerlabExplorerView.webview"; -import type { NetemState } from "../shared/parsing"; -import type { TopoEdge, TopoNode, TopologyHostCommand } from "../shared/types"; - -import { MuiThemeProvider } from "./theme"; -import { - FREE_TEXT_NODE_TYPE, - FREE_SHAPE_NODE_TYPE, - TRAFFIC_RATE_NODE_TYPE, - GROUP_NODE_TYPE, - findEdgeAnnotationInLookup, - nodesToAnnotations, - collectNodeGroupMemberships, - parseEndpointLabelOffset -} from "./annotations"; -import { - buildEdgeAnnotationLookup, - type EdgeAnnotationLookup -} from "./annotations/edgeAnnotations"; -import type { ReactFlowCanvasRef } from "./components/canvas"; -import { ReactFlowCanvas } from "./components/canvas"; -import { Navbar } from "./components/navbar/Navbar"; -import { AboutModal, type LinkImpairmentData } from "./components/panels"; -import { ContextPanel } from "./components/panels/context-panel"; -import { LabSettingsModal } from "./components/panels/lab-settings/LabSettingsModal"; -import { LifecycleProgressModal } from "./components/panels/LifecycleProgressModal"; -import { ShortcutsModal } from "./components/panels/ShortcutsModal"; -import { SvgExportModal } from "./components/panels/SvgExportModal"; -import { BulkLinkModal } from "./components/panels/BulkLinkModal"; -import { FindNodePopover } from "./components/panels/FindNodePopover"; -import { ShortcutDisplay, ToastContainer } from "./components/ui"; -import { EasterEggRenderer, useEasterEgg } from "./easter-eggs"; -import { - useAppEditorBindings, - useAppE2EExposure, - useAppGraphHandlers, - useAppKeyboardShortcuts, - useAppToasts, - useClipboardHandlers, - useCustomNodeCommands, - useDevMockTrafficStats, - useGraphCreation, - useIconReconciliation, - useUndoRedoControls -} from "./hooks/app"; -import { useFilteredGraphElements, useSelectionData } from "./hooks/app/useAppContentHelpers"; -import { useAnnotations, useDerivedAnnotations, type AnnotationContextValue } from "./hooks/canvas"; -import { - useAppHandlers, - useContextMenuHandlers, - usePanelVisibility, - useShakeAnimation, - useShortcutDisplay, - type useLayoutControls -} from "./hooks/ui"; -import { - useAnnotationUIActions, - useAnnotationUIState, - useGraphActions, - useGraphState, - useGraphStore, - useTopoViewerActions, - useTopoViewerState -} from "./stores"; -import { sendCancelLabLifecycle } from "./messaging/extensionMessaging"; -import { - executeTopologyCommand, - toLinkSaveData, - getCustomIconMap, - saveViewerSettings -} from "./services"; -import { - PENDING_NETEM_KEY, - areNetemEquivalent, - createPendingNetemOverride -} from "./utils/netemOverrides"; - -type LayoutControls = ReturnType; -const DEV_EXPLORER_MIN_WIDTH = 280; -const DEV_EXPLORER_DEFAULT_WIDTH = 360; - -const TOPO_NODE_TYPES = new Set([ - "topology-node", - "network-node", - GROUP_NODE_TYPE, - FREE_TEXT_NODE_TYPE, - FREE_SHAPE_NODE_TYPE, - TRAFFIC_RATE_NODE_TYPE -]); -const NETWORK_TYPE_VALUES = new Set([ - "host", - "mgmt-net", - "macvlan", - "vxlan", - "vxlan-stitch", - "dummy", - "bridge", - "ovs-bridge" -]); - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function toRecord(value: unknown): Record { - return isRecord(value) ? value : {}; -} - -function toNetemState(value: unknown): NetemState | undefined { - if (!isRecord(value)) return undefined; - const state: NetemState = {}; - if (typeof value.delay === "string") state.delay = value.delay; - if (typeof value.jitter === "string") state.jitter = value.jitter; - if (typeof value.loss === "string") state.loss = value.loss; - if (typeof value.rate === "string") state.rate = value.rate; - if (typeof value.corruption === "string") state.corruption = value.corruption; - return Object.keys(state).length > 0 ? state : undefined; -} - -function isTopoNode(node: Node): node is TopoNode { - return TOPO_NODE_TYPES.has(node.type ?? ""); -} - -function isTopoEdge(edge: Edge): edge is TopoEdge { - const data = edge.data; - return ( - isRecord(data) && - typeof data.sourceEndpoint === "string" && - typeof data.targetEndpoint === "string" - ); -} - -function isNetworkTypeValue( - value: string -): value is Parameters["createNetworkAtPosition"]>[1] { - return NETWORK_TYPE_VALUES.has(value); -} - -function getDevExplorerMaxWidth(): number { - return Math.max(DEV_EXPLORER_MIN_WIDTH, Math.floor(window.innerWidth / 2)); -} - -interface DeleteMenuHandlers { - handleDeleteNode: (nodeId: string) => void; - handleDeleteLink: (edgeId: string) => void; -} - -interface DeleteGraphActions { - removeNodeAndEdges: (nodeId: string) => void; - removeEdge: (edgeId: string) => void; -} - -function collectSelectedIds( - nodes: Array<{ id: string; selected?: boolean }>, - edges: Array<{ id: string; selected?: boolean }>, - selectedNodeId?: string | null, - selectedEdgeId?: string | null -): { nodeIds: Set; edgeIds: Set } { - const nodeIds = new Set(nodes.filter((node) => node.selected === true).map((node) => node.id)); - const edgeIds = new Set(edges.filter((edge) => edge.selected === true).map((edge) => edge.id)); - - if (selectedNodeId != null && selectedNodeId.length > 0) nodeIds.add(selectedNodeId); - if (selectedEdgeId != null && selectedEdgeId.length > 0) edgeIds.add(selectedEdgeId); - - return { nodeIds, edgeIds }; -} - -function splitNodeIdsByType( - nodeIds: Set, - nodesById: Map -): { - graphNodeIds: string[]; - groupIds: string[]; - textIds: string[]; - shapeIds: string[]; - trafficRateIds: string[]; -} { - const graphNodeIds: string[] = []; - const groupIds: string[] = []; - const textIds: string[] = []; - const shapeIds: string[] = []; - const trafficRateIds: string[] = []; - - for (const nodeId of nodeIds) { - const node = nodesById.get(nodeId); - if (!node) continue; - switch (node.type) { - case GROUP_NODE_TYPE: - groupIds.push(nodeId); - break; - case FREE_TEXT_NODE_TYPE: - textIds.push(nodeId); - break; - case FREE_SHAPE_NODE_TYPE: - shapeIds.push(nodeId); - break; - case TRAFFIC_RATE_NODE_TYPE: - trafficRateIds.push(nodeId); - break; - default: - graphNodeIds.push(nodeId); - } - } - - return { graphNodeIds, groupIds, textIds, shapeIds, trafficRateIds }; -} - -function applyGraphDeletions( - graphActions: DeleteGraphActions, - menuHandlers: DeleteMenuHandlers, - graphNodeIds: string[], - edgeIds: Set -): void { - for (const nodeId of graphNodeIds) { - graphActions.removeNodeAndEdges(nodeId); - menuHandlers.handleDeleteNode(nodeId); - } - - for (const edgeId of edgeIds) { - graphActions.removeEdge(edgeId); - menuHandlers.handleDeleteLink(edgeId); - } -} - -function buildDeleteCommands( - graphNodeIds: string[], - edgeIds: Set, - edgesById: Map -): TopologyHostCommand[] { - const commands: TopologyHostCommand[] = []; - - for (const nodeId of graphNodeIds) { - commands.push({ command: "deleteNode", payload: { id: nodeId } }); - } - - for (const edgeId of edgeIds) { - const edge = edgesById.get(edgeId); - if (!edge) continue; - commands.push({ command: "deleteLink", payload: toLinkSaveData(edge) }); - } - - return commands; -} - -function buildAnnotationSaveCommand(graphNodesForSave: TopoNode[]): TopologyHostCommand { - const { freeTextAnnotations, freeShapeAnnotations, trafficRateAnnotations, groups } = - nodesToAnnotations(graphNodesForSave); - const memberships = collectNodeGroupMemberships(graphNodesForSave); - - return { - command: "setAnnotationsWithMemberships", - payload: { - annotations: { - freeTextAnnotations, - freeShapeAnnotations, - trafficRateAnnotations, - groupStyleAnnotations: groups - }, - memberships: memberships.map((entry) => ({ - nodeId: entry.id, - groupId: entry.groupId - })) - } - }; -} - -function getInteractionMode(mode: "view" | "edit", isProcessing: boolean): "view" | "edit" { - if (isProcessing) return "view"; - return mode; -} - -function getInteractionLockState(isLocked: boolean, isProcessing: boolean): boolean { - return isLocked || isProcessing; -} - -function isDevMockWebview(): boolean { - return window.vscode?.__isDevMock__ === true; -} - -function isDevExplorerDisabledByUrl(): boolean { - const params = new URLSearchParams(window.location.search); - const rawValue = params.get("devExplorer"); - if (rawValue == null || rawValue.length === 0) return false; - const normalized = rawValue.trim().toLowerCase(); - return normalized === "0" || normalized === "false" || normalized === "off"; -} - -function shouldDumpCssVars(): boolean { - const params = new URLSearchParams(window.location.search); - const rawValue = params.get("dumpCssVars"); - if (rawValue == null || rawValue.length === 0) return false; - const normalized = rawValue.trim().toLowerCase(); - return normalized === "1" || normalized === "true" || normalized === "on"; -} - -function shouldCollectDevMockTrafficStats( - isDevMock: boolean, - interactionMode: "view" | "edit" -): boolean { - return isDevMock && interactionMode === "view"; -} - -function shouldShowBulkLinkModal(isRequested: boolean, isProcessing: boolean): boolean { - return isRequested && !isProcessing; -} - -interface DevExplorerPaneState { - layoutRef: React.RefObject; - devExplorerWidth: number; - isDevExplorerDragging: boolean; - handleDevExplorerResizeStart: (event: React.MouseEvent) => void; -} - -function useDevExplorerPane(showDevExplorer: boolean): DevExplorerPaneState { - const layoutRef = React.useRef(null); - const [devExplorerWidth, setDevExplorerWidth] = React.useState(DEV_EXPLORER_DEFAULT_WIDTH); - const [isDevExplorerDragging, setIsDevExplorerDragging] = React.useState(false); - const isDevExplorerDraggingRef = React.useRef(false); - - const handleDevExplorerResizeStart = React.useCallback( - (event: React.MouseEvent) => { - if (!showDevExplorer) { - return; - } - - event.preventDefault(); - isDevExplorerDraggingRef.current = true; - setIsDevExplorerDragging(true); - - const onMouseMove = (moveEvent: MouseEvent) => { - if (!isDevExplorerDraggingRef.current) { - return; - } - - const layoutLeft = layoutRef.current?.getBoundingClientRect().left ?? 0; - const nextWidth = moveEvent.clientX - layoutLeft; - const clampedWidth = Math.min( - getDevExplorerMaxWidth(), - Math.max(DEV_EXPLORER_MIN_WIDTH, nextWidth) - ); - setDevExplorerWidth(clampedWidth); - }; - - const onMouseUp = () => { - isDevExplorerDraggingRef.current = false; - setIsDevExplorerDragging(false); - document.removeEventListener("mousemove", onMouseMove); - document.removeEventListener("mouseup", onMouseUp); - }; - - document.addEventListener("mousemove", onMouseMove); - document.addEventListener("mouseup", onMouseUp); - }, - [showDevExplorer] - ); - - React.useEffect(() => { - if (!showDevExplorer) { - return; - } - - const handleWindowResize = () => { - setDevExplorerWidth((currentWidth) => - Math.min(getDevExplorerMaxWidth(), Math.max(DEV_EXPLORER_MIN_WIDTH, currentWidth)) - ); - }; - - handleWindowResize(); - window.addEventListener("resize", handleWindowResize); - return () => { - window.removeEventListener("resize", handleWindowResize); - }; - }, [showDevExplorer]); - - return { - layoutRef, - devExplorerWidth, - isDevExplorerDragging, - handleDevExplorerResizeStart - }; -} - -interface ContextSelectionState { - selectedNode: unknown; - selectedEdge: unknown; - editingNode: unknown; - editingEdge: unknown; - editingNetwork: unknown; - editingImpairment: unknown; -} - -interface ContextAnnotationState { - editingTextAnnotation: unknown; - editingShapeAnnotation: unknown; - editingTrafficRateAnnotation: unknown; - editingGroup: unknown; -} - -function hasContextContentState( - state: ContextSelectionState, - annotations: ContextAnnotationState -): boolean { - const candidates = [ - state.selectedNode, - state.selectedEdge, - state.editingNode, - state.editingEdge, - state.editingNetwork, - state.editingImpairment, - annotations.editingTextAnnotation, - annotations.editingShapeAnnotation, - annotations.editingTrafficRateAnnotation, - annotations.editingGroup - ]; - return candidates.some((value) => value !== null && value !== undefined); -} - -export interface AppContentProps { - reactFlowRef: React.RefObject; - rfInstance: ReactFlowInstance | null; - layoutControls: LayoutControls; - onInit: (instance: ReactFlowInstance) => void; -} - -interface StoreSelectionState { - selectedNode: string | null; - selectedEdge: string | null; - editingImpairment: string | null; - editingNode: string | null; - editingEdge: string | null; - editingNetwork: string | null; - endpointLabelOffset: number; -} - -type CanvasPropsWithoutGraph = Omit< - React.ComponentPropsWithoutRef, - "nodes" | "edges" ->; - -interface GraphCanvasMainProps { - canvasRef: React.RefObject; - canvasProps: CanvasPropsWithoutGraph; - showDummyLinks: boolean; - edgeAnnotationLookup: EdgeAnnotationLookup; - endpointLabelOffsetEnabled: boolean; - endpointLabelOffset: number; -} - -function areSelectedNodesEqual(left: TopoNode | null, right: TopoNode | null): boolean { - if (left === right) return true; - if (!left || !right) return false; - return left.id === right.id && left.data === right.data; -} - -function areSelectedEdgesEqual(left: TopoEdge | null, right: TopoEdge | null): boolean { - if (left === right) return true; - if (!left || !right) return false; - return ( - left.id === right.id && - left.source === right.source && - left.target === right.target && - left.data === right.data - ); -} - -function useGraphNodeById(nodeId: string | null): TopoNode | null { - return useGraphStore( - React.useCallback( - (graphState) => - nodeId != null && nodeId.length > 0 - ? (graphState.nodes.find( - (node): node is TopoNode => node.id === nodeId && isTopoNode(node) - ) ?? null) - : null, - [nodeId] - ), - areSelectedNodesEqual - ); -} - -function useGraphEdgeById(edgeId: string | null): TopoEdge | null { - return useGraphStore( - React.useCallback( - (graphState) => - edgeId != null && edgeId.length > 0 - ? (graphState.edges.find( - (edge): edge is TopoEdge => edge.id === edgeId && isTopoEdge(edge) - ) ?? null) - : null, - [edgeId] - ), - areSelectedEdgesEqual - ); -} - -function useStoreBackedSelectionData( - state: StoreSelectionState, - edgeAnnotationLookup: EdgeAnnotationLookup -) { - const selectedNode = useGraphNodeById(state.selectedNode); - const editingNode = useGraphNodeById(state.editingNode); - const editingNetwork = useGraphNodeById(state.editingNetwork); - const selectedEdge = useGraphEdgeById(state.selectedEdge); - const editingImpairment = useGraphEdgeById(state.editingImpairment); - const editingEdge = useGraphEdgeById(state.editingEdge); - - const selectionNodes = React.useMemo(() => { - const deduped = new Map(); - for (const node of [selectedNode, editingNode, editingNetwork]) { - if (!node) continue; - deduped.set(node.id, node); - } - return Array.from(deduped.values()); - }, [selectedNode, editingNode, editingNetwork]); - - const selectionEdges = React.useMemo(() => { - const deduped = new Map(); - for (const edge of [selectedEdge, editingImpairment, editingEdge]) { - if (!edge) continue; - deduped.set(edge.id, edge); - } - return Array.from(deduped.values()); - }, [selectedEdge, editingImpairment, editingEdge]); - - return useSelectionData(state, selectionNodes, selectionEdges, edgeAnnotationLookup); -} - -const GraphCanvasMain: React.FC = React.memo( - ({ - canvasRef, - canvasProps, - showDummyLinks, - edgeAnnotationLookup, - endpointLabelOffsetEnabled, - endpointLabelOffset - }) => { - const { nodes, edges } = useGraphState(); - const graphNodes = React.useMemo(() => nodes.filter(isTopoNode), [nodes]); - const graphEdges = React.useMemo(() => edges.filter(isTopoEdge), [edges]); - useIconReconciliation(); - - const { filteredNodes, filteredEdges } = useFilteredGraphElements( - graphNodes, - graphEdges, - showDummyLinks - ); - - const renderedEdges = React.useMemo(() => { - if (filteredEdges.length === 0) return filteredEdges; - return filteredEdges.map((edge) => { - const data = edge.data; - if (data == null) return edge; - const sourceEndpoint = data.sourceEndpoint; - const targetEndpoint = data.targetEndpoint; - const annotation = findEdgeAnnotationInLookup(edgeAnnotationLookup, { - id: edge.id, - source: edge.source, - target: edge.target, - sourceEndpoint, - targetEndpoint - }); - const annotationOffset = parseEndpointLabelOffset(annotation?.endpointLabelOffset); - const annotationEnabled = - annotation?.endpointLabelOffsetEnabled ?? - (annotation?.endpointLabelOffset !== undefined ? true : undefined); - const enabled = annotationEnabled ?? endpointLabelOffsetEnabled; - const resolvedOffset = enabled ? (annotationOffset ?? endpointLabelOffset) : 0; - - if ( - data.endpointLabelOffsetEnabled === enabled && - data.endpointLabelOffset === resolvedOffset - ) { - return edge; - } - - return { - ...edge, - data: { - ...data, - endpointLabelOffsetEnabled: enabled, - endpointLabelOffset: resolvedOffset - } - }; - }); - }, [filteredEdges, edgeAnnotationLookup, endpointLabelOffset, endpointLabelOffsetEnabled]); - - return ( - - ); - } -); -GraphCanvasMain.displayName = "GraphCanvasMain"; - -interface AnnotationRuntimeBridgeProps { - rfInstance: ReactFlowInstance | null; - onLockedAction: () => void; - runtimeRef: { current: AnnotationContextValue | null }; -} - -const AnnotationRuntimeBridge: React.FC = ({ - rfInstance, - onLockedAction, - runtimeRef -}) => { - const annotations = useAnnotations({ rfInstance, onLockedAction }); - runtimeRef.current = annotations; - - React.useEffect( - () => () => { - runtimeRef.current = null; - }, - [runtimeRef] - ); - - return null; -}; - -type SvgExportModalContainerProps = Pick< - React.ComponentPropsWithoutRef, - "onClose" | "rfInstance" | "customIcons" | "labName" ->; - -const SvgExportModalContainer: React.FC = React.memo( - ({ onClose, rfInstance, customIcons, labName }) => { - const { textAnnotations, shapeAnnotations, groups } = useDerivedAnnotations(); - - return ( - - ); - } -); -SvgExportModalContainer.displayName = "SvgExportModalContainer"; - -export const AppContent: React.FC = ({ - reactFlowRef, - rfInstance, - layoutControls, - onInit -}) => { - const state = useTopoViewerState(); - const topoActions = useTopoViewerActions(); - const graphActions = useGraphActions(); - const annotationUiActions = useAnnotationUIActions(); - const isProcessing = state.isProcessing; - const isInteractionLocked = getInteractionLockState(state.isLocked, isProcessing); - const interactionMode = getInteractionMode(state.mode, isProcessing); - const isDevMock = React.useMemo(() => isDevMockWebview(), []); - const showDevExplorer = React.useMemo( - () => isDevMock && !isDevExplorerDisabledByUrl(), - [isDevMock] - ); - useDevMockTrafficStats(shouldCollectDevMockTrafficStats(isDevMock, interactionMode)); - const { layoutRef, devExplorerWidth, isDevExplorerDragging, handleDevExplorerResizeStart } = - useDevExplorerPane(showDevExplorer); - - React.useEffect(() => { - if (!shouldDumpCssVars()) return; - const htmlStyle = document.querySelector("html")?.getAttribute("style"); - if (htmlStyle == null || htmlStyle.length === 0) return; - const vars: Record = {}; - for (const part of htmlStyle.split(";")) { - const trimmed = part.trim(); - if (!trimmed.startsWith("--vscode-")) continue; - const colonIdx = trimmed.indexOf(":"); - if (colonIdx === -1) continue; - vars[trimmed.slice(0, colonIdx).trim()] = trimmed.slice(colonIdx + 1).trim(); - } - if (Object.keys(vars).length === 0) return; - const sorted = Object.fromEntries(Object.entries(vars).sort(([a], [b]) => a.localeCompare(b))); - window.vscode?.postMessage({ command: "dump-css-vars", vars: sorted }); - }, []); - - const undoRedo = useUndoRedoControls(state.canUndo, state.canRedo); - const { trigger: triggerLockShake } = useShakeAnimation(); - - const { toasts, dismissToast, addToast } = useAppToasts({ - customNodeError: state.customNodeError, - clearCustomNodeError: topoActions.clearCustomNodeError - }); - - const handleLockedAction = React.useCallback(() => { - triggerLockShake(); - addToast("Lab is locked (read-only)", "error", 2000); - }, [triggerLockShake, addToast]); - - const annotationRuntimeRef = React.useRef(null); - const annotationUiState = useAnnotationUIState(); - - const annotationMode = React.useMemo( - () => ({ - isAddTextMode: annotationUiState.isAddTextMode, - isAddShapeMode: annotationUiState.isAddShapeMode, - pendingShapeType: annotationUiState.isAddShapeMode - ? annotationUiState.pendingShapeType - : undefined - }), - [ - annotationUiState.isAddTextMode, - annotationUiState.isAddShapeMode, - annotationUiState.pendingShapeType - ] - ); - - const annotationActions = React.useMemo( - () => ({ - handleAddGroup: () => { - annotationRuntimeRef.current?.handleAddGroup(); - }, - handleAddText: () => { - annotationRuntimeRef.current?.handleAddText(); - }, - handleAddShapes: (shapeType?: string) => { - annotationRuntimeRef.current?.handleAddShapes(shapeType); - }, - createTextAtPosition: (position: { x: number; y: number }) => { - annotationRuntimeRef.current?.createTextAtPosition(position); - }, - createGroupAtPosition: (position: { x: number; y: number }) => { - annotationRuntimeRef.current?.createGroupAtPosition(position); - }, - createShapeAtPosition: (position: { x: number; y: number }, shapeType?: string) => { - annotationRuntimeRef.current?.createShapeAtPosition(position, shapeType); - }, - createTrafficRateAtPosition: (position: { x: number; y: number }) => { - annotationRuntimeRef.current?.createTrafficRateAtPosition(position); - }, - getNodeMembership: (nodeId: string) => - annotationRuntimeRef.current?.getNodeMembership(nodeId) ?? null, - addNodeToGroup: (nodeId: string, groupId: string) => { - annotationRuntimeRef.current?.addNodeToGroup(nodeId, groupId); - }, - deleteAllSelected: () => { - annotationRuntimeRef.current?.deleteAllSelected(); - }, - deleteSelectedForBatch: ( - options?: Parameters[0] - ) => - annotationRuntimeRef.current?.deleteSelectedForBatch(options) ?? { - didDelete: false, - membersCleared: false - }, - saveTextAnnotation: (...args: Parameters) => { - annotationRuntimeRef.current?.saveTextAnnotation(...args); - }, - updateTextAnnotation: ( - ...args: Parameters - ) => { - annotationRuntimeRef.current?.updateTextAnnotation(...args); - }, - previewTextAnnotation: ( - ...args: Parameters - ) => { - annotationRuntimeRef.current?.previewTextAnnotation(...args); - }, - removePreviewTextAnnotation: ( - ...args: Parameters - ) => { - annotationRuntimeRef.current?.removePreviewTextAnnotation(...args); - }, - deleteTextAnnotation: ( - ...args: Parameters - ) => { - annotationRuntimeRef.current?.deleteTextAnnotation(...args); - }, - saveShapeAnnotation: (...args: Parameters) => { - annotationRuntimeRef.current?.saveShapeAnnotation(...args); - }, - updateShapeAnnotation: ( - ...args: Parameters - ) => { - annotationRuntimeRef.current?.updateShapeAnnotation(...args); - }, - previewShapeAnnotation: ( - ...args: Parameters - ) => { - annotationRuntimeRef.current?.previewShapeAnnotation(...args); - }, - removePreviewShapeAnnotation: ( - ...args: Parameters - ) => { - annotationRuntimeRef.current?.removePreviewShapeAnnotation(...args); - }, - deleteShapeAnnotation: ( - ...args: Parameters - ) => { - annotationRuntimeRef.current?.deleteShapeAnnotation(...args); - }, - saveTrafficRateAnnotation: ( - ...args: Parameters - ) => { - annotationRuntimeRef.current?.saveTrafficRateAnnotation(...args); - }, - updateTrafficRateAnnotation: ( - ...args: Parameters - ) => { - annotationRuntimeRef.current?.updateTrafficRateAnnotation(...args); - }, - deleteTrafficRateAnnotation: ( - ...args: Parameters - ) => { - annotationRuntimeRef.current?.deleteTrafficRateAnnotation(...args); - }, - saveGroup: (...args: Parameters) => { - annotationRuntimeRef.current?.saveGroup(...args); - }, - deleteGroup: (...args: Parameters) => { - annotationRuntimeRef.current?.deleteGroup(...args); - }, - updateGroup: (...args: Parameters) => { - annotationRuntimeRef.current?.updateGroup(...args); - } - }), - [] - ); - - const canvasAnnotationHandlers = React.useMemo< - NonNullable - >( - () => ({ - onAddTextClick: (position) => { - annotationRuntimeRef.current?.handleTextCanvasClick(position); - }, - onAddShapeClick: (position) => { - annotationRuntimeRef.current?.handleShapeCanvasClick(position); - }, - disableAddTextMode: () => { - annotationRuntimeRef.current?.disableAddTextMode(); - }, - disableAddShapeMode: () => { - annotationRuntimeRef.current?.disableAddShapeMode(); - }, - onEditFreeText: (id) => { - annotationRuntimeRef.current?.editTextAnnotation(id); - }, - onEditFreeShape: (id) => { - annotationRuntimeRef.current?.editShapeAnnotation(id); - }, - onEditTrafficRate: (id) => { - annotationRuntimeRef.current?.editTrafficRateAnnotation(id); - }, - onDeleteFreeText: (id) => { - annotationRuntimeRef.current?.deleteTextAnnotation(id); - }, - onDeleteFreeShape: (id) => { - annotationRuntimeRef.current?.deleteShapeAnnotation(id); - }, - onDeleteTrafficRate: (id) => { - annotationRuntimeRef.current?.deleteTrafficRateAnnotation(id); - }, - onUpdateFreeTextSize: (id, width, height) => { - annotationRuntimeRef.current?.updateTextSize(id, width, height); - }, - onUpdateFreeShapeSize: (id, width, height) => { - annotationRuntimeRef.current?.updateShapeSize(id, width, height); - }, - onUpdateTrafficRateSize: (id, width, height) => { - annotationRuntimeRef.current?.updateTrafficRateSize(id, width, height); - }, - onUpdateFreeTextRotation: (id, rotation) => { - annotationRuntimeRef.current?.updateTextRotation(id, rotation); - }, - onUpdateFreeShapeRotation: (id, rotation) => { - annotationRuntimeRef.current?.updateShapeRotation(id, rotation); - }, - onFreeTextRotationStart: (id) => { - annotationRuntimeRef.current?.onTextRotationStart(id); - }, - onFreeTextRotationEnd: (id) => { - annotationRuntimeRef.current?.onTextRotationEnd(id); - }, - onFreeShapeRotationStart: (id) => { - annotationRuntimeRef.current?.onShapeRotationStart(id); - }, - onFreeShapeRotationEnd: (id) => { - annotationRuntimeRef.current?.onShapeRotationEnd(id); - }, - onUpdateFreeShapeStartPosition: (id, startPosition) => { - annotationRuntimeRef.current?.updateShapeStartPosition(id, startPosition); - }, - onUpdateFreeShapeEndPosition: (id, endPosition) => { - annotationRuntimeRef.current?.updateShapeEndPosition(id, endPosition); - }, - onPersistAnnotations: () => { - annotationRuntimeRef.current?.persistAnnotations(); - }, - onNodeDropped: (nodeId, position) => { - annotationRuntimeRef.current?.onNodeDropped(nodeId, position); - }, - onUpdateGroupSize: (id, width, height) => { - annotationRuntimeRef.current?.updateGroupSize(id, width, height); - }, - onEditGroup: (id) => { - annotationRuntimeRef.current?.editGroup(id); - }, - onDeleteGroup: (id) => { - annotationRuntimeRef.current?.deleteGroup(id); - }, - getGroupMembers: (groupId, options) => - annotationRuntimeRef.current?.getGroupMembers(groupId, options) ?? [] - }), - [] - ); - - const getAnnotationGroups = React.useCallback( - () => annotationRuntimeRef.current?.groups ?? [], - [] - ); - - const edgeAnnotationLookup = React.useMemo( - () => buildEdgeAnnotationLookup(state.edgeAnnotations), - [state.edgeAnnotations] - ); - const selectionData = useStoreBackedSelectionData( - { - selectedNode: state.selectedNode, - selectedEdge: state.selectedEdge, - editingImpairment: state.editingImpairment, - editingNode: state.editingNode, - editingEdge: state.editingEdge, - editingNetwork: state.editingNetwork, - endpointLabelOffset: state.endpointLabelOffset - }, - edgeAnnotationLookup - ); - const enableSelectedNodeVisualEditor = - interactionMode === "view" && - isInteractionLocked === false && - selectionData.editingNodeData === null && - selectionData.selectedNodeEditorData !== null; - const effectiveNodeEditorData = - selectionData.editingNodeData ?? - (enableSelectedNodeVisualEditor ? selectionData.selectedNodeEditorData : null); - - const [paletteTabRequest, setPaletteTabRequest] = React.useState<{ tabId: string } | undefined>( - undefined - ); - const customNodeCommands = useCustomNodeCommands( - state.customNodes, - topoActions.editCustomTemplate - ); - - const menuHandlers = useContextMenuHandlers({ - selectNode: topoActions.selectNode, - selectEdge: topoActions.selectEdge, - editNode: topoActions.editNode, - editEdge: topoActions.editEdge, - editNetwork: topoActions.editNetwork, - onDeleteNode: topoActions.clearSelectionForDeletedNode, - onDeleteEdge: topoActions.clearSelectionForDeletedEdge - }); - - const graphHandlers = useAppGraphHandlers({ - rfInstance, - menuHandlers, - actions: { - addNode: graphActions.addNode, - addEdge: graphActions.addEdge, - removeNodeAndEdges: graphActions.removeNodeAndEdges, - removeEdge: graphActions.removeEdge, - updateNodeData: graphActions.updateNodeData, - updateEdge: graphActions.updateEdge, - renameNode: graphActions.renameNode - } - }); - - const updateEdgeNetemData = React.useCallback( - (data: LinkImpairmentData) => { - const { edges } = useGraphStore.getState(); - const edge = edges.find((item) => item.id === data.id); - if (!edge) return; - const edgeData = edge.data; - const extraData = toRecord(edgeData?.extraData); - const currentSourceNetem = toNetemState(extraData.clabSourceNetem); - const currentTargetNetem = toNetemState(extraData.clabTargetNetem); - const hasNetemChanges = - !areNetemEquivalent(currentSourceNetem, data.sourceNetem) || - !areNetemEquivalent(currentTargetNetem, data.targetNetem); - const nextExtraData: Record = { - ...extraData, - clabSourceNetem: data.sourceNetem, - clabTargetNetem: data.targetNetem - }; - if (hasNetemChanges) { - nextExtraData[PENDING_NETEM_KEY] = createPendingNetemOverride( - data.sourceNetem, - data.targetNetem - ); - } - graphActions.updateEdgeData(data.id, { - extraData: nextExtraData - }); - }, - [graphActions] - ); - - const handleLinkImpairmentSave = React.useCallback( - (data: LinkImpairmentData) => { - updateEdgeNetemData(data); - topoActions.editImpairment(null); - }, - [topoActions, updateEdgeNetemData] - ); - - const handleLinkImpairmentApply = React.useCallback( - (data: LinkImpairmentData) => { - updateEdgeNetemData(data); - }, - [updateEdgeNetemData] - ); - - const handleLinkImpairmentError = React.useCallback( - (error: string) => { - addToast(error, "error"); - }, - [addToast] - ); - - const { nodeEditorHandlers, linkEditorHandlers, networkEditorHandlers } = useAppEditorBindings({ - selectionData, - effectiveNodeEditorData, - state: { - edgeAnnotations: state.edgeAnnotations - }, - actions: { - editNode: topoActions.editNode, - editEdge: topoActions.editEdge, - editNetwork: topoActions.editNetwork, - setEdgeAnnotations: topoActions.setEdgeAnnotations, - refreshEditorData: topoActions.refreshEditorData - }, - renameNodeInGraph: graphHandlers.renameNodeInGraph, - handleUpdateNodeData: graphHandlers.handleUpdateNodeData, - handleUpdateEdgeData: graphHandlers.handleUpdateEdgeData - }); - - const getGraphNodes = React.useCallback( - () => useGraphStore.getState().nodes.filter(isTopoNode), - [] - ); - - const graphCreation = useGraphCreation({ - rfInstance, - onLockedAction: handleLockedAction, - state: { - mode: interactionMode, - isLocked: isInteractionLocked, - customNodes: state.customNodes, - defaultNode: state.defaultNode, - getNodes: getGraphNodes - }, - onEdgeCreated: graphHandlers.handleEdgeCreated, - onNodeCreated: graphHandlers.handleNodeCreatedCallback, - addNode: graphHandlers.addNodeDirect, - onNewCustomNode: customNodeCommands.onNewCustomNode - }); - - // Drag-drop handlers for node palette - const handleDropCreateNode = React.useCallback( - (position: { x: number; y: number }, templateName: string) => { - if (isInteractionLocked) { - handleLockedAction(); - return; - } - // Find the template by name - const template = state.customNodes.find((t) => t.name === templateName); - if (template) { - graphCreation.createNodeAtPosition(position, template); - } - }, - [isInteractionLocked, state.customNodes, graphCreation, handleLockedAction] - ); - - const handleDropCreateNetwork = React.useCallback( - (position: { x: number; y: number }, networkType: string) => { - if (isInteractionLocked) { - handleLockedAction(); - return; - } - if (!isNetworkTypeValue(networkType)) return; - graphCreation.createNetworkAtPosition(position, networkType); - }, - [isInteractionLocked, graphCreation, handleLockedAction] - ); - - useAppE2EExposure({ - state: { - isLocked: isInteractionLocked, - mode: interactionMode, - selectedNode: state.selectedNode, - selectedEdge: state.selectedEdge - }, - actions: { - toggleLock: topoActions.toggleLock, - setMode: topoActions.setMode, - editNode: topoActions.editNode, - editNetwork: topoActions.editNetwork, - selectNode: topoActions.selectNode, - selectEdge: topoActions.selectEdge - }, - undoRedo, - graphHandlers, - annotations: { - handleAddGroup: annotationActions.handleAddGroup, - getGroups: getAnnotationGroups - }, - graphCreation, - layoutControls, - rfInstance - }); - - const { handleDeselectAll } = useAppHandlers({ - selectionCallbacks: { - selectNode: topoActions.selectNode, - selectEdge: topoActions.selectEdge, - editNode: topoActions.editNode, - editEdge: topoActions.editEdge - }, - rfInstance - }); - - const shortcutDisplay = useShortcutDisplay(); - const panelVisibility = usePanelVisibility(); - - const clearAllEditingState = React.useCallback(() => { - topoActions.editNode(null); - topoActions.editEdge(null); - topoActions.editImpairment(null); - topoActions.editNetwork(null); - topoActions.selectNode(null); - topoActions.selectEdge(null); - annotationUiActions.closeTextEditor(); - annotationUiActions.closeShapeEditor(); - annotationUiActions.closeTrafficRateEditor(); - annotationUiActions.closeGroupEditor(); - }, [topoActions, annotationUiActions]); - - const hasContextContent = hasContextContentState(state, annotationUiState); - - const handleEmptyCanvasClick = React.useCallback(() => { - // When dismissing any context (editors/info) via empty canvas click, close the context panel - // instead of falling back to the Nodes/Annotations palette view. - // Exception: if the user opened the panel manually, keep it open until they close it. - const shouldClosePanel = - panelVisibility.isContextPanelOpen && - panelVisibility.contextPanelOpenReason !== "manual" && - hasContextContent; - - clearAllEditingState(); - - if (shouldClosePanel) { - panelVisibility.handleCloseContextPanel(); - } - }, [clearAllEditingState, hasContextContent, panelVisibility]); - - const processingRef = React.useRef(false); - React.useEffect(() => { - if (isProcessing) { - if (processingRef.current) return; - processingRef.current = true; - clearAllEditingState(); - annotationUiActions.disableAddTextMode(); - annotationUiActions.disableAddShapeMode(); - annotationUiActions.clearAllSelections(); - panelVisibility.handleCloseBulkLink(); - panelVisibility.handleCloseLabSettings(); - return; - } - processingRef.current = false; - }, [annotationUiActions, clearAllEditingState, isProcessing, panelVisibility]); - - const clipboardHandlers = useClipboardHandlers({ - annotations: { - getNodeMembership: annotationActions.getNodeMembership, - addNodeToGroup: annotationActions.addNodeToGroup, - deleteAllSelected: annotationActions.deleteAllSelected - }, - rfInstance, - handleNodeCreatedCallback: graphHandlers.handleNodeCreatedCallback, - handleEdgeCreated: graphHandlers.handleEdgeCreated, - handleBatchPaste: graphHandlers.handleBatchPaste - }); - - const handleDeleteSelection = React.useCallback(() => { - const { nodes: currentNodes, edges: currentEdges } = useGraphStore.getState(); - const { nodeIds, edgeIds } = collectSelectedIds( - currentNodes, - currentEdges, - state.selectedNode, - state.selectedEdge - ); - if (nodeIds.size === 0 && edgeIds.size === 0) return; - - const nodesById = new Map(currentNodes.map((node) => [node.id, node])); - const edgesById = new Map(currentEdges.filter(isTopoEdge).map((edge) => [edge.id, edge])); - - const { graphNodeIds, groupIds, textIds, shapeIds, trafficRateIds } = splitNodeIdsByType( - nodeIds, - nodesById - ); - - applyGraphDeletions(graphActions, menuHandlers, graphNodeIds, edgeIds); - - const annotationResult = annotationActions.deleteSelectedForBatch({ - groupIds, - textIds, - shapeIds, - trafficRateIds - }); - - const commands = buildDeleteCommands(graphNodeIds, edgeIds, edgesById); - - if (annotationResult.didDelete || annotationResult.membersCleared) { - const graphNodesForSave = useGraphStore.getState().nodes.filter(isTopoNode); - commands.push(buildAnnotationSaveCommand(graphNodesForSave)); - } - - if (commands.length === 0) return; - - executeTopologyCommand( - { command: "batch", payload: { commands } }, - { applySnapshot: false } - ).catch((err) => { - console.error("[TopoViewer] Failed to batch delete", err); - }); - }, [annotationActions, graphActions, menuHandlers, state.selectedNode, state.selectedEdge]); - - useAppKeyboardShortcuts({ - state: { - mode: interactionMode, - isLocked: isInteractionLocked, - selectedNode: state.selectedNode, - selectedEdge: state.selectedEdge - }, - undoRedo, - annotations: { - selectedTextIds: annotationUiState.selectedTextIds, - selectedShapeIds: annotationUiState.selectedShapeIds, - selectedTrafficRateIds: annotationUiState.selectedTrafficRateIds, - selectedGroupIds: annotationUiState.selectedGroupIds, - clearAllSelections: annotationUiActions.clearAllSelections, - handleAddGroup: annotationActions.handleAddGroup - }, - clipboardHandlers, - deleteHandlers: { - handleDeleteNode: graphHandlers.handleDeleteNode, - handleDeleteLink: graphHandlers.handleDeleteLink, - handleDeleteSelection - }, - handleDeselectAll - }); - - const easterEgg = useEasterEgg({}); - - // Auto-open context panel when selection/editing state changes - React.useEffect(() => { - if (hasContextContent && !isProcessing && !panelVisibility.isContextPanelOpen) { - panelVisibility.handleOpenContextPanel("auto"); - } - }, [hasContextContent, isProcessing, panelVisibility]); - - // close if palette wasn't open, else go back to palette - const handleContextPanelBack = React.useCallback(() => { - const shouldClose = panelVisibility.contextPanelOpenReason === "auto"; - clearAllEditingState(); - if (shouldClose) { - panelVisibility.handleCloseContextPanel(); - } - }, [clearAllEditingState, panelVisibility]); - - const handleZoomToFit = React.useCallback(() => { - if (reactFlowRef.current) { - reactFlowRef.current.fit(); - return; - } - rfInstance?.fitView({ padding: 0.1 }).catch(() => { - /* ignore */ - }); - }, [reactFlowRef, rfInstance]); - - const handleOpenNodePalette = React.useCallback(() => { - handleContextPanelBack(); - panelVisibility.handleOpenContextPanel(); - }, [handleContextPanelBack, panelVisibility]); - - const canvasProps = React.useMemo( - () => ({ - isContextPanelOpen: panelVisibility.isContextPanelOpen, - onPaneClick: handleEmptyCanvasClick, - layout: layoutControls.layout, - isGeoLayout: layoutControls.isGeoLayout, - gridLineWidth: layoutControls.gridLineWidth, - gridStyle: layoutControls.gridStyle, - gridColor: layoutControls.gridColor, - gridBgColor: layoutControls.gridBgColor, - annotationMode, - annotationHandlers: canvasAnnotationHandlers, - linkLabelMode: state.linkLabelMode, - onInit, - onEdgeCreated: graphHandlers.handleEdgeCreated, - onShiftClickCreate: graphCreation.createNodeAtPosition, - onNodeDelete: graphHandlers.handleDeleteNode, - onEdgeDelete: graphHandlers.handleDeleteLink, - onOpenNodePalette: handleOpenNodePalette, - onAddGroup: annotationActions.handleAddGroup, - onAddText: annotationActions.handleAddText, - onAddShapes: annotationActions.handleAddShapes, - onAddTextAtPosition: annotationActions.createTextAtPosition, - onAddGroupAtPosition: annotationActions.createGroupAtPosition, - onAddShapeAtPosition: annotationActions.createShapeAtPosition, - onAddTrafficRateAtPosition: annotationActions.createTrafficRateAtPosition, - onDropCreateNode: handleDropCreateNode, - onDropCreateNetwork: handleDropCreateNetwork, - onLockedAction: handleLockedAction - }), - [ - panelVisibility.isContextPanelOpen, - handleEmptyCanvasClick, - layoutControls.layout, - layoutControls.isGeoLayout, - layoutControls.gridLineWidth, - layoutControls.gridStyle, - layoutControls.gridColor, - layoutControls.gridBgColor, - annotationMode, - canvasAnnotationHandlers, - state.linkLabelMode, - onInit, - graphHandlers.handleEdgeCreated, - graphCreation.createNodeAtPosition, - graphHandlers.handleDeleteNode, - graphHandlers.handleDeleteLink, - handleOpenNodePalette, - annotationActions, - handleDropCreateNode, - handleDropCreateNetwork, - handleLockedAction - ] - ); - - const handleNetworkSave = React.useCallback( - (data: Parameters[0]) => { - networkEditorHandlers.handleSave(data).catch((err) => { - console.error("[TopoViewer] Network editor save failed", err); - }); - }, - [networkEditorHandlers] - ); - - const handleNetworkApply = React.useCallback( - (data: Parameters[0]) => { - networkEditorHandlers.handleApply(data).catch((err) => { - console.error("[TopoViewer] Network editor apply failed", err); - }); - }, - [networkEditorHandlers] - ); - - const handleCloseLifecycleModal = React.useCallback(() => { - topoActions.closeLifecycleModal(); - }, [topoActions]); - - const handleCancelLifecycle = React.useCallback(() => { - sendCancelLabLifecycle(); - }, []); - - const handleToggleSplit = React.useCallback(() => { - panelVisibility.handleOpenContextPanel("manual"); - setPaletteTabRequest({ tabId: "yaml" }); - }, [panelVisibility]); - const isBulkLinkModalOpen = shouldShowBulkLinkModal( - panelVisibility.showBulkLinkModal, - isProcessing - ); - - const handleLinkLabelModeChange = React.useCallback( - (mode: Parameters[0]) => { - topoActions.setLinkLabelMode(mode); - const nextLastNonTelemetryMode = - mode === "telemetry-style" ? state.lastNonTelemetryLinkLabelMode : mode; - const style = mode === "telemetry-style" ? "telemetry-style" : "default"; - void saveViewerSettings({ - style, - linkLabelMode: mode, - lastNonTelemetryLinkLabelMode: nextLastNonTelemetryMode - }); - }, - [topoActions, state.lastNonTelemetryLinkLabelMode] - ); - - return ( - - - - - - {showDevExplorer && ( - - - - - )} - graphHandlers.handleDeleteNode(selectionData.editingNodeData!.id) - : undefined - }, - editingLinkData: selectionData.editingLinkData, - linkEditorHandlers: { - handleClose: linkEditorHandlers.handleClose, - handleSave: linkEditorHandlers.handleSave, - handleApply: linkEditorHandlers.handleApply, - previewOffset: linkEditorHandlers.previewOffset, - revertOffset: linkEditorHandlers.revertOffset, - handleDelete: selectionData.editingLinkData - ? () => graphHandlers.handleDeleteLink(selectionData.editingLinkData!.id) - : undefined - }, - editingNetworkData: selectionData.editingNetworkData, - networkEditorHandlers: { - handleClose: networkEditorHandlers.handleClose, - handleSave: handleNetworkSave, - handleApply: handleNetworkApply - }, - linkImpairmentData: selectionData.selectedLinkImpairmentData, - linkImpairmentHandlers: { - onError: handleLinkImpairmentError, - onApply: handleLinkImpairmentApply, - onSave: handleLinkImpairmentSave, - onClose: () => topoActions.editImpairment(null) - }, - editingTextAnnotation: annotationUiState.editingTextAnnotation, - textAnnotationHandlers: { - onSave: annotationActions.saveTextAnnotation, - onPreview: (annotation) => { - const exists = - annotationRuntimeRef.current?.textAnnotations.some( - (entry) => entry.id === annotation.id - ) ?? false; - if (exists) { - annotationActions.updateTextAnnotation(annotation.id, annotation); - return true; - } - annotationActions.previewTextAnnotation(annotation); - return false; - }, - onPreviewDelete: annotationActions.removePreviewTextAnnotation, - onClose: annotationUiActions.closeTextEditor, - onDelete: annotationActions.deleteTextAnnotation - }, - editingShapeAnnotation: annotationUiState.editingShapeAnnotation, - shapeAnnotationHandlers: { - onSave: annotationActions.saveShapeAnnotation, - onPreview: (annotation) => { - const exists = - annotationRuntimeRef.current?.shapeAnnotations.some( - (entry) => entry.id === annotation.id - ) ?? false; - if (exists) { - annotationActions.updateShapeAnnotation(annotation.id, annotation); - return true; - } - annotationActions.previewShapeAnnotation(annotation); - return false; - }, - onPreviewDelete: annotationActions.removePreviewShapeAnnotation, - onClose: annotationUiActions.closeShapeEditor, - onDelete: annotationActions.deleteShapeAnnotation - }, - editingTrafficRateAnnotation: annotationUiState.editingTrafficRateAnnotation, - trafficRateAnnotationHandlers: { - onSave: annotationActions.saveTrafficRateAnnotation, - onPreview: (annotation) => { - annotationActions.updateTrafficRateAnnotation(annotation.id, annotation); - }, - onClose: annotationUiActions.closeTrafficRateEditor, - onDelete: annotationActions.deleteTrafficRateAnnotation - }, - editingGroup: annotationUiState.editingGroup, - groupHandlers: { - onSave: annotationActions.saveGroup, - onClose: annotationUiActions.closeGroupEditor, - onDelete: annotationActions.deleteGroup, - onStylePreview: annotationActions.updateGroup - } - }} - /> - - - - - - - - - {/* Modals */} - - - - {panelVisibility.showSvgExportModal && ( - - )} - - - - {/* Popovers */} - - - - ); -}; diff --git a/src/reactTopoViewer/webview/annotations/annotationNodeConverters.ts b/src/reactTopoViewer/webview/annotations/annotationNodeConverters.ts deleted file mode 100644 index 65f21dacd..000000000 --- a/src/reactTopoViewer/webview/annotations/annotationNodeConverters.ts +++ /dev/null @@ -1,711 +0,0 @@ -/** - * Bidirectional conversion utilities for annotation data and React Flow nodes. - * - * This module provides functions to convert between: - * - FreeTextAnnotation <-> Node - * - FreeShapeAnnotation <-> Node - * - GroupStyleAnnotation <-> Node - * - * Used for: - * - Loading annotations from JSON into graph store (annotation → node) - * - Persisting annotation nodes to JSON (node → annotation) - */ -import type { Node } from "@xyflow/react"; - -import type { - FreeTextAnnotation, - FreeShapeAnnotation, - TrafficRateAnnotation, - GroupStyleAnnotation -} from "../../shared/types/topology"; -import type { - FreeTextNodeData, - FreeShapeNodeData, - TrafficRateNodeData, - GroupNodeData -} from "../components/canvas/types"; - -import { DEFAULT_LINE_LENGTH } from "./constants"; -import { - isNonEmptyString, - normalizePosition, - parseLegacyGroupIdentity, - toFiniteNumber -} from "./valueParsers"; - -// ============================================================================ -// Constants -// ============================================================================ - -/** Node type constants */ -export const FREE_TEXT_NODE_TYPE = "free-text-node" as const; -export const FREE_SHAPE_NODE_TYPE = "free-shape-node" as const; -export const TRAFFIC_RATE_NODE_TYPE = "traffic-rate-node" as const; -export const GROUP_NODE_TYPE = "group-node" as const; - -/** Set of annotation node types for quick lookup */ -const ANNOTATION_NODE_TYPES: Set = new Set([ - FREE_TEXT_NODE_TYPE, - FREE_SHAPE_NODE_TYPE, - TRAFFIC_RATE_NODE_TYPE, - GROUP_NODE_TYPE -]); - -/** Padding for line bounding box to accommodate arrows and stroke */ -const LINE_PADDING = 20; -/** Default zIndex for shapes so they render behind topology nodes */ -const DEFAULT_SHAPE_Z_INDEX = -1; -const DEFAULT_GROUP_WIDTH = 200; -const DEFAULT_GROUP_HEIGHT = 150; -const DEFAULT_TRAFFIC_RATE_CHART_WIDTH = 280; -const DEFAULT_TRAFFIC_RATE_CHART_HEIGHT = 170; -const DEFAULT_TRAFFIC_RATE_TEXT_WIDTH = 100; -const DEFAULT_TRAFFIC_RATE_TEXT_HEIGHT = 30; - -// ============================================================================ -// Helper Functions -// ============================================================================ - -/** - * Check if a node type is an annotation type - */ -export function isAnnotationNodeType(type: string | undefined): boolean { - return type !== undefined && ANNOTATION_NODE_TYPES.has(type); -} - -/** - * Resolve the parent ID from a group annotation. - * Handles legacy field naming where both parentId and groupId may be used. - */ -export function resolveGroupParentId( - parentId: string | undefined, - groupId: string | undefined -): string | undefined { - if (typeof parentId === "string") return parentId; - if (typeof groupId === "string") return groupId; - return undefined; -} - -function normalizeTrafficRateMode(value: unknown): TrafficRateAnnotation["mode"] | undefined { - if (value === "text") return "text"; - if (value === "chart" || value === "current") return "chart"; - return undefined; -} - -function normalizeTrafficRateTextMetric( - value: unknown -): TrafficRateAnnotation["textMetric"] | undefined { - if (value === "combined" || value === "rx" || value === "tx") return value; - return undefined; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function toOptionalString(value: unknown): string | undefined { - return isNonEmptyString(value) ? value : undefined; -} - -function toOptionalTrafficRateBorderStyle( - value: unknown -): TrafficRateAnnotation["borderStyle"] | undefined { - if (value === "solid" || value === "dashed" || value === "dotted" || value === "double") { - return value; - } - return undefined; -} - -function setFiniteNumberIfPresent( - target: Record, - key: string, - value: unknown -): void { - const parsed = toFiniteNumber(value); - if (parsed !== undefined) { - target[key] = parsed; - } -} - -function toLatLng(value: unknown): { lat: number; lng: number } | undefined { - if (!isRecord(value)) return undefined; - const lat = toFiniteNumber(value.lat); - const lng = toFiniteNumber(value.lng); - if (lat === undefined || lng === undefined) return undefined; - return { lat, lng }; -} - -function resolveTrafficRateDimensions( - annotation: TrafficRateAnnotation, - mode: TrafficRateAnnotation["mode"] | undefined -): { width: number; height: number } { - const width = - toFiniteNumber(annotation.width) ?? - (mode === "text" ? DEFAULT_TRAFFIC_RATE_TEXT_WIDTH : DEFAULT_TRAFFIC_RATE_CHART_WIDTH); - const height = - toFiniteNumber(annotation.height) ?? - (mode === "text" ? DEFAULT_TRAFFIC_RATE_TEXT_HEIGHT : DEFAULT_TRAFFIC_RATE_CHART_HEIGHT); - return { width, height }; -} - -function buildTrafficRateNodeData( - annotation: TrafficRateAnnotation, - mode: TrafficRateAnnotation["mode"] | undefined, - textMetric: TrafficRateAnnotation["textMetric"] | undefined, - width: number, - height: number, - zIndex: number | undefined -): TrafficRateNodeData { - const data: TrafficRateNodeData = { - width, - height, - groupId: annotation.groupId, - geoCoordinates: annotation.geoCoordinates, - backgroundOpacity: toFiniteNumber(annotation.backgroundOpacity), - borderWidth: toFiniteNumber(annotation.borderWidth), - borderRadius: toFiniteNumber(annotation.borderRadius) - }; - - const nodeId = toOptionalString(annotation.nodeId); - if (nodeId !== undefined) data.nodeId = nodeId; - const interfaceName = toOptionalString(annotation.interfaceName); - if (interfaceName !== undefined) data.interfaceName = interfaceName; - if (mode !== undefined) data.mode = mode; - if (textMetric !== undefined) data.textMetric = textMetric; - if (annotation.showLegend === false) data.showLegend = false; - const backgroundColor = toOptionalString(annotation.backgroundColor); - if (backgroundColor !== undefined) data.backgroundColor = backgroundColor; - const borderColor = toOptionalString(annotation.borderColor); - if (borderColor !== undefined) data.borderColor = borderColor; - const borderStyle = toOptionalTrafficRateBorderStyle(annotation.borderStyle); - if (borderStyle !== undefined) data.borderStyle = borderStyle; - const titleColor = toOptionalString(annotation.titleColor); - if (titleColor !== undefined) data.titleColor = titleColor; - const textColor = toOptionalString(annotation.textColor); - if (textColor !== undefined) data.textColor = textColor; - if (zIndex !== undefined) data.zIndex = zIndex; - return data; -} - -function buildTrafficRateAnnotationBase( - node: Node, - mode: TrafficRateAnnotation["mode"] | undefined, - textMetric: TrafficRateAnnotation["textMetric"] | undefined -): TrafficRateAnnotation { - const data = node.data; - const annotation: TrafficRateAnnotation = { - id: node.id, - position: node.position, - geoCoordinates: data.geoCoordinates as { lat: number; lng: number } | undefined - }; - - const nodeId = toOptionalString(data.nodeId); - if (nodeId !== undefined) annotation.nodeId = nodeId; - const interfaceName = toOptionalString(data.interfaceName); - if (interfaceName !== undefined) annotation.interfaceName = interfaceName; - if (mode !== undefined) annotation.mode = mode; - if (textMetric !== undefined) annotation.textMetric = textMetric; - if (data.showLegend === false) annotation.showLegend = false; - const groupId = toOptionalString(data.groupId); - if (groupId !== undefined) annotation.groupId = groupId; - const backgroundColor = toOptionalString(data.backgroundColor); - if (backgroundColor !== undefined) annotation.backgroundColor = backgroundColor; - setFiniteNumberIfPresent(annotation, "backgroundOpacity", data.backgroundOpacity); - const borderColor = toOptionalString(data.borderColor); - if (borderColor !== undefined) annotation.borderColor = borderColor; - setFiniteNumberIfPresent(annotation, "borderWidth", data.borderWidth); - const borderStyle = toOptionalTrafficRateBorderStyle(data.borderStyle); - if (borderStyle !== undefined) annotation.borderStyle = borderStyle; - setFiniteNumberIfPresent(annotation, "borderRadius", data.borderRadius); - const titleColor = toOptionalString(data.titleColor); - if (titleColor !== undefined) annotation.titleColor = titleColor; - const textColor = toOptionalString(data.textColor); - if (textColor !== undefined) annotation.textColor = textColor; - - return annotation; -} - -// ============================================================================ -// Line Bounding Box Computation -// ============================================================================ - -interface LineBounds { - nodePosition: { x: number; y: number }; - width: number; - height: number; - relativeEndPosition: { x: number; y: number }; - lineStartInNode: { x: number; y: number }; -} - -/** - * Compute line bounding box and positioning info for line shapes - */ -function computeLineBounds( - annotation: FreeShapeAnnotation, - startPosition: { x: number; y: number } -): LineBounds { - const startX = startPosition.x; - const startY = startPosition.y; - const endPosition = normalizePosition(annotation.endPosition, { - x: startX + DEFAULT_LINE_LENGTH, - y: startY - }); - const endX = endPosition.x; - const endY = endPosition.y; - - // Compute bounding box with padding - const minX = Math.min(startX, endX) - LINE_PADDING; - const minY = Math.min(startY, endY) - LINE_PADDING; - const maxX = Math.max(startX, endX) + LINE_PADDING; - const maxY = Math.max(startY, endY) + LINE_PADDING; - - const nodePosition = { x: minX, y: minY }; - - return { - nodePosition, - width: maxX - minX, - height: Math.max(maxY - minY, LINE_PADDING * 2), - relativeEndPosition: { x: endX - startX, y: endY - startY }, - lineStartInNode: { x: startX - minX, y: startY - minY } - }; -} - -// ============================================================================ -// Annotation → Node Conversion -// ============================================================================ - -/** - * Convert a FreeTextAnnotation to a React Flow Node - */ -export function freeTextToNode(annotation: FreeTextAnnotation): Node { - const position = normalizePosition(annotation.position); - return { - id: annotation.id, - type: FREE_TEXT_NODE_TYPE, - position, - // Width/height at top level for React Flow's NodeResizer compatibility - width: annotation.width, - height: annotation.height, - draggable: true, - selectable: true, - data: { - text: annotation.text, - fontSize: annotation.fontSize, - fontColor: annotation.fontColor, - backgroundColor: annotation.backgroundColor, - fontWeight: annotation.fontWeight, - fontStyle: annotation.fontStyle, - textDecoration: annotation.textDecoration, - textAlign: annotation.textAlign, - fontFamily: annotation.fontFamily, - rotation: annotation.rotation, - width: annotation.width, - height: annotation.height, - roundedBackground: annotation.roundedBackground, - // Store groupId for membership tracking - groupId: annotation.groupId, - geoCoordinates: annotation.geoCoordinates, - zIndex: annotation.zIndex - } - }; -} - -/** - * Convert a FreeShapeAnnotation to a React Flow Node - * For lines, the node is positioned at the bounding box top-left - */ -export function freeShapeToNode(annotation: FreeShapeAnnotation): Node { - const isLine = annotation.shapeType === "line"; - const startPosition = normalizePosition(annotation.position); - const resolvedZIndex = - typeof annotation.zIndex === "number" ? annotation.zIndex : DEFAULT_SHAPE_Z_INDEX; - - if (isLine) { - const { nodePosition, width, height, relativeEndPosition, lineStartInNode } = computeLineBounds( - annotation, - startPosition - ); - - return { - id: annotation.id, - type: FREE_SHAPE_NODE_TYPE, - position: nodePosition, - width, - height, - zIndex: resolvedZIndex, - draggable: true, - selectable: true, - data: { - shapeType: "line", - width, - height, - endPosition: normalizePosition(annotation.endPosition, { - x: startPosition.x + DEFAULT_LINE_LENGTH, - y: startPosition.y - }), - relativeEndPosition, - startPosition, - // Line start position within the node's bounding box - lineStartInNode, - fillColor: annotation.fillColor, - fillOpacity: annotation.fillOpacity, - borderColor: annotation.borderColor, - borderWidth: annotation.borderWidth, - borderStyle: annotation.borderStyle, - rotation: annotation.rotation, - lineStartArrow: annotation.lineStartArrow, - lineEndArrow: annotation.lineEndArrow, - lineArrowSize: annotation.lineArrowSize, - // Store groupId for membership tracking - groupId: annotation.groupId, - geoCoordinates: annotation.geoCoordinates, - endGeoCoordinates: annotation.endGeoCoordinates, - zIndex: resolvedZIndex - } - }; - } - - // Non-line shapes (rectangle, circle) - return { - id: annotation.id, - type: FREE_SHAPE_NODE_TYPE, - position: startPosition, - width: annotation.width ?? 100, - height: annotation.height ?? 100, - zIndex: resolvedZIndex, - draggable: true, - selectable: true, - data: { - shapeType: annotation.shapeType, - width: annotation.width, - height: annotation.height, - fillColor: annotation.fillColor, - fillOpacity: annotation.fillOpacity, - borderColor: annotation.borderColor, - borderWidth: annotation.borderWidth, - borderStyle: annotation.borderStyle, - rotation: annotation.rotation, - cornerRadius: annotation.cornerRadius, - // Store groupId for membership tracking - groupId: annotation.groupId, - geoCoordinates: annotation.geoCoordinates, - zIndex: resolvedZIndex - } - }; -} - -/** - * Convert a TrafficRateAnnotation to a React Flow Node - */ -export function trafficRateToNode(annotation: TrafficRateAnnotation): Node { - const position = normalizePosition(annotation.position); - const modeRaw = annotation.mode as unknown; - const resolvedMode = normalizeTrafficRateMode(modeRaw); - const resolvedTextMetric = normalizeTrafficRateTextMetric(annotation.textMetric); - const { width: resolvedWidth, height: resolvedHeight } = resolveTrafficRateDimensions( - annotation, - resolvedMode - ); - const resolvedZIndex = toFiniteNumber(annotation.zIndex); - const data = buildTrafficRateNodeData( - annotation, - resolvedMode, - resolvedTextMetric, - resolvedWidth, - resolvedHeight, - resolvedZIndex - ); - const node: Node = { - id: annotation.id, - type: TRAFFIC_RATE_NODE_TYPE, - position, - width: resolvedWidth, - height: resolvedHeight, - draggable: true, - selectable: true, - data - }; - - if (resolvedZIndex !== undefined) node.zIndex = resolvedZIndex; - return node; -} - -/** - * Convert a GroupStyleAnnotation to a React Flow Node - * Groups are rendered with zIndex: -1 so they appear behind topology nodes - */ -export function groupToNode(group: GroupStyleAnnotation): Node { - const resolvedId = isNonEmptyString(group.id) ? group.id : "legacy-group"; - const identity = parseLegacyGroupIdentity(resolvedId); - const resolvedName = isNonEmptyString(group.name) ? group.name : identity.name; - const resolvedLevel = isNonEmptyString(group.level) ? group.level : identity.level; - const resolvedPosition = normalizePosition(group.position); - const resolvedWidth = toFiniteNumber(group.width) ?? DEFAULT_GROUP_WIDTH; - const resolvedHeight = toFiniteNumber(group.height) ?? DEFAULT_GROUP_HEIGHT; - const legacyColor = group.color; - const resolvedLabelColor = - (isNonEmptyString(group.labelColor) ? group.labelColor : undefined) ?? - (isNonEmptyString(legacyColor) ? legacyColor : undefined); - const resolvedParentId = resolveGroupParentId(group.parentId, group.groupId); - const resolvedGroupId = resolveGroupParentId(group.groupId, group.parentId); - return { - id: resolvedId, - type: GROUP_NODE_TYPE, - position: resolvedPosition, - // Width/height at top level for React Flow's NodeResizer compatibility - width: resolvedWidth, - height: resolvedHeight, - // Groups render behind topology nodes - zIndex: group.zIndex ?? -1, - draggable: true, - selectable: true, - data: { - name: resolvedName, - label: resolvedName, - level: resolvedLevel, - width: resolvedWidth, - height: resolvedHeight, - backgroundColor: group.backgroundColor, - backgroundOpacity: group.backgroundOpacity, - borderColor: group.borderColor, - borderWidth: group.borderWidth, - borderStyle: group.borderStyle, - borderRadius: group.borderRadius, - labelColor: resolvedLabelColor, - labelPosition: group.labelPosition, - parentId: resolvedParentId, - groupId: resolvedGroupId, - zIndex: group.zIndex, - geoCoordinates: group.geoCoordinates - } - }; -} - -// ============================================================================ -// Node → Annotation Conversion -// ============================================================================ - -/** - * Convert a React Flow Node back to FreeTextAnnotation - */ -export function nodeToFreeText(node: Node): FreeTextAnnotation { - const data = node.data; - const annotation: FreeTextAnnotation = { - id: node.id, - text: data.text, - position: node.position, - fontSize: data.fontSize, - fontColor: data.fontColor, - backgroundColor: data.backgroundColor, - fontWeight: data.fontWeight, - fontStyle: data.fontStyle, - textDecoration: data.textDecoration, - textAlign: data.textAlign, - fontFamily: data.fontFamily, - rotation: data.rotation, - width: node.width ?? data.width, - height: node.height ?? data.height, - roundedBackground: data.roundedBackground - }; - const groupId = toOptionalString(data.groupId); - if (groupId !== undefined) annotation.groupId = groupId; - const geoCoordinates = toLatLng(data.geoCoordinates); - if (geoCoordinates !== undefined) annotation.geoCoordinates = geoCoordinates; - const zIndex = toFiniteNumber(data.zIndex); - if (zIndex !== undefined) annotation.zIndex = zIndex; - return annotation; -} - -/** - * Convert a React Flow Node back to FreeShapeAnnotation - */ -export function nodeToFreeShape(node: Node): FreeShapeAnnotation { - const data = node.data; - const zIndex = typeof data.zIndex === "number" ? data.zIndex : node.zIndex; - const isLine = data.shapeType === "line"; - - if (isLine) { - // For lines, startPosition in data is the actual annotation position - const annotation: FreeShapeAnnotation = { - id: node.id, - shapeType: "line", - position: data.startPosition ?? node.position, - endPosition: data.endPosition, - fillColor: data.fillColor, - fillOpacity: data.fillOpacity, - borderColor: data.borderColor, - borderWidth: data.borderWidth, - borderStyle: data.borderStyle, - rotation: data.rotation, - lineStartArrow: data.lineStartArrow, - lineEndArrow: data.lineEndArrow, - lineArrowSize: data.lineArrowSize, - zIndex - }; - const groupId = toOptionalString(data.groupId); - if (groupId !== undefined) annotation.groupId = groupId; - const geoCoordinates = toLatLng(data.geoCoordinates); - if (geoCoordinates !== undefined) annotation.geoCoordinates = geoCoordinates; - const endGeoCoordinates = toLatLng(data.endGeoCoordinates); - if (endGeoCoordinates !== undefined) annotation.endGeoCoordinates = endGeoCoordinates; - return annotation; - } - - // Non-line shapes - const annotation: FreeShapeAnnotation = { - id: node.id, - shapeType: data.shapeType, - position: node.position, - width: node.width ?? data.width, - height: node.height ?? data.height, - fillColor: data.fillColor, - fillOpacity: data.fillOpacity, - borderColor: data.borderColor, - borderWidth: data.borderWidth, - borderStyle: data.borderStyle, - rotation: data.rotation, - cornerRadius: data.cornerRadius, - zIndex - }; - const groupId = toOptionalString(data.groupId); - if (groupId !== undefined) annotation.groupId = groupId; - const geoCoordinates = toLatLng(data.geoCoordinates); - if (geoCoordinates !== undefined) annotation.geoCoordinates = geoCoordinates; - return annotation; -} - -/** - * Convert a React Flow Node back to TrafficRateAnnotation - */ -export function nodeToTrafficRate(node: Node): TrafficRateAnnotation { - const data = node.data; - const mode = normalizeTrafficRateMode(data.mode); - const textMetric = normalizeTrafficRateTextMetric(data.textMetric); - const annotation = buildTrafficRateAnnotationBase(node, mode, textMetric); - setFiniteNumberIfPresent(annotation, "width", node.width ?? data.width); - setFiniteNumberIfPresent(annotation, "height", node.height ?? data.height); - const resolvedZIndex = typeof node.zIndex === "number" ? node.zIndex : data.zIndex; - setFiniteNumberIfPresent(annotation, "zIndex", resolvedZIndex); - return annotation; -} - -/** - * Convert a React Flow Node back to GroupStyleAnnotation - */ -export function nodeToGroup(node: Node): GroupStyleAnnotation { - const data = node.data; - const rawParentId = typeof data.parentId === "string" ? data.parentId : undefined; - const rawGroupId = typeof data.groupId === "string" ? data.groupId : undefined; - const parentId = rawParentId ?? rawGroupId; - const groupId = rawGroupId ?? rawParentId; - const zIndex = typeof data.zIndex === "number" ? data.zIndex : node.zIndex; - return { - id: node.id, - name: data.name, - level: data.level, - position: node.position, - width: node.width ?? data.width, - height: node.height ?? data.height, - backgroundColor: data.backgroundColor, - backgroundOpacity: data.backgroundOpacity, - borderColor: data.borderColor, - borderWidth: data.borderWidth, - borderStyle: data.borderStyle, - borderRadius: data.borderRadius, - labelColor: data.labelColor, - labelPosition: data.labelPosition, - parentId, - groupId, - zIndex, - geoCoordinates: toLatLng(data.geoCoordinates) - }; -} - -function isFreeTextNode(node: Node): node is Node { - return node.type === FREE_TEXT_NODE_TYPE; -} - -function isFreeShapeNode(node: Node): node is Node { - return node.type === FREE_SHAPE_NODE_TYPE; -} - -function isTrafficRateNode(node: Node): node is Node { - return node.type === TRAFFIC_RATE_NODE_TYPE; -} - -function isGroupNode(node: Node): node is Node { - return node.type === GROUP_NODE_TYPE; -} - -// ============================================================================ -// Batch Conversion Utilities -// ============================================================================ - -/** - * Convert all annotations to React Flow nodes - */ -export function annotationsToNodes( - freeTextAnnotations: FreeTextAnnotation[], - freeShapeAnnotations: FreeShapeAnnotation[], - groups: GroupStyleAnnotation[], - trafficRateAnnotations: TrafficRateAnnotation[] = [] -): Node[] { - const nodes: Node[] = []; - - // Add group nodes first (they render behind due to zIndex: -1) - for (const group of groups) { - nodes.push(groupToNode(group)); - } - - // Add free text nodes - for (const annotation of freeTextAnnotations) { - nodes.push(freeTextToNode(annotation)); - } - - // Add free shape nodes - for (const annotation of freeShapeAnnotations) { - nodes.push(freeShapeToNode(annotation)); - } - - // Add traffic-rate nodes - for (const annotation of trafficRateAnnotations) { - nodes.push(trafficRateToNode(annotation)); - } - - return nodes; -} - -/** - * Extract annotation data from a mixed array of nodes - */ -export function nodesToAnnotations(nodes: Node[]): { - freeTextAnnotations: FreeTextAnnotation[]; - freeShapeAnnotations: FreeShapeAnnotation[]; - trafficRateAnnotations: TrafficRateAnnotation[]; - groups: GroupStyleAnnotation[]; -} { - const freeTextAnnotations: FreeTextAnnotation[] = []; - const freeShapeAnnotations: FreeShapeAnnotation[] = []; - const trafficRateAnnotations: TrafficRateAnnotation[] = []; - const groups: GroupStyleAnnotation[] = []; - - for (const node of nodes) { - if (isFreeTextNode(node)) { - freeTextAnnotations.push(nodeToFreeText(node)); - continue; - } - if (isFreeShapeNode(node)) { - freeShapeAnnotations.push(nodeToFreeShape(node)); - continue; - } - if (isTrafficRateNode(node)) { - trafficRateAnnotations.push(nodeToTrafficRate(node)); - continue; - } - if (isGroupNode(node)) { - groups.push(nodeToGroup(node)); - } - } - - return { freeTextAnnotations, freeShapeAnnotations, trafficRateAnnotations, groups }; -} diff --git a/src/reactTopoViewer/webview/annotations/constants.ts b/src/reactTopoViewer/webview/annotations/constants.ts deleted file mode 100644 index 3a3e97eda..000000000 --- a/src/reactTopoViewer/webview/annotations/constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Free shape annotation constants (used across panels and SVG helpers). - */ -import type { FreeShapeAnnotation } from "../../shared/types/topology"; - -export const DEFAULT_SHAPE_WIDTH = 50; -export const DEFAULT_SHAPE_HEIGHT = 50; -export const DEFAULT_LINE_LENGTH = 150; -export const DEFAULT_FILL_COLOR = "#ffffff"; -export const DEFAULT_FILL_OPACITY = 0; -export const DEFAULT_BORDER_COLOR = "#646464"; -export const DEFAULT_BORDER_WIDTH = 2; -export const DEFAULT_BORDER_STYLE: NonNullable = "solid"; -export const DEFAULT_ARROW_SIZE = 10; -export const DEFAULT_CORNER_RADIUS = 0; -export const MIN_SHAPE_SIZE = 5; diff --git a/src/reactTopoViewer/webview/annotations/edgeAnnotations.ts b/src/reactTopoViewer/webview/annotations/edgeAnnotations.ts deleted file mode 100644 index b6b723a94..000000000 --- a/src/reactTopoViewer/webview/annotations/edgeAnnotations.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { EdgeAnnotation } from "../../shared/types/topology"; -import type { TopoEdge } from "../../shared/types/graph"; - -import { DEFAULT_ENDPOINT_LABEL_OFFSET, parseEndpointLabelOffset } from "./endpointLabelOffset"; - -export type EdgeIdentity = { - id?: string; - source?: string; - target?: string; - sourceEndpoint?: string; - targetEndpoint?: string; -}; - -export type EdgeAnnotationLookup = { - byId: Map; - byKey: Map; -}; - -export type EdgeOffsetUpdateInput = EdgeIdentity & { - endpointLabelOffsetEnabled?: boolean; - endpointLabelOffset?: number; -}; - -function hasNonEmptyString(value: string | undefined): value is string { - return typeof value === "string" && value.length > 0; -} - -function buildEdgeKey(identity: EdgeIdentity): string | null { - if (!hasNonEmptyString(identity.source) || !hasNonEmptyString(identity.target)) return null; - const sourceEndpoint = identity.sourceEndpoint ?? ""; - const targetEndpoint = identity.targetEndpoint ?? ""; - return `${identity.source}|${sourceEndpoint}|${identity.target}|${targetEndpoint}`; -} - -function resolveEdgeAnnotationMatch( - identity: EdgeIdentity, - getByKey: (key: string) => EdgeAnnotation | undefined, - getById: (id: string) => EdgeAnnotation | undefined -): EdgeAnnotation | undefined { - const key = buildEdgeKey(identity); - if (key !== null) { - const byKey = getByKey(key); - if (byKey) return byKey; - if (hasNonEmptyString(identity.id)) { - const byId = getById(identity.id); - if (!byId) return undefined; - const byIdKey = buildEdgeKey(byId); - return byIdKey !== null && byIdKey === key ? byId : undefined; - } - return undefined; - } - if (!hasNonEmptyString(identity.id)) return undefined; - return getById(identity.id); -} - -/** - * Extract edge identity from a ReactFlow edge - */ -function getEdgeIdentityFromEdge(edge: TopoEdge): EdgeIdentity | null { - const data = edge.data as Record | undefined; - const sourceEndpoint = typeof data?.sourceEndpoint === "string" ? data.sourceEndpoint : undefined; - const targetEndpoint = typeof data?.targetEndpoint === "string" ? data.targetEndpoint : undefined; - if (!edge.id && !edge.source && !edge.target) return null; - return { id: edge.id, source: edge.source, target: edge.target, sourceEndpoint, targetEndpoint }; -} - -export function buildEdgeAnnotationLookup( - annotations: EdgeAnnotation[] | undefined -): EdgeAnnotationLookup { - const byId = new Map(); - const byKey = new Map(); - - (annotations ?? []).forEach((annotation) => { - if (hasNonEmptyString(annotation.id)) { - byId.set(annotation.id, annotation); - } - const key = buildEdgeKey(annotation); - if (key !== null) { - byKey.set(key, annotation); - } - }); - - return { byId, byKey }; -} - -export function pruneEdgeAnnotations( - annotations: EdgeAnnotation[] | undefined, - edges: TopoEdge[] | undefined -): EdgeAnnotation[] { - if (!annotations || annotations.length === 0) return []; - if (!edges) return annotations; - const edgeKeys = new Set(); - const edgeIds = new Set(); - - edges.forEach((edge) => { - const identity = getEdgeIdentityFromEdge(edge); - if (!identity) return; - if (hasNonEmptyString(identity.id)) edgeIds.add(identity.id); - const key = buildEdgeKey(identity); - if (key !== null) edgeKeys.add(key); - }); - - return annotations.filter((annotation) => { - const key = buildEdgeKey(annotation); - if (key !== null) return edgeKeys.has(key); - if (!hasNonEmptyString(annotation.id)) return false; - return edgeIds.has(annotation.id); - }); -} - -export function findEdgeAnnotation( - annotations: EdgeAnnotation[] | undefined, - identity: EdgeIdentity -): EdgeAnnotation | undefined { - if (!annotations || annotations.length === 0) return undefined; - return resolveEdgeAnnotationMatch( - identity, - (key) => annotations.find((annotation) => buildEdgeKey(annotation) === key), - (id) => annotations.find((annotation) => annotation.id === id) - ); -} - -export function findEdgeAnnotationInLookup( - lookup: EdgeAnnotationLookup, - identity: EdgeIdentity -): EdgeAnnotation | undefined { - return resolveEdgeAnnotationMatch( - identity, - (key) => lookup.byKey.get(key), - (id) => lookup.byId.get(id) - ); -} - -export function upsertEdgeAnnotation( - annotations: EdgeAnnotation[], - next: EdgeAnnotation -): EdgeAnnotation[] { - const nextId = next.id; - const nextKey = buildEdgeKey(next); - const matchIndex = annotations.findIndex((existing) => { - const existingKey = buildEdgeKey(existing); - const keyMatches = nextKey !== null && existingKey === nextKey; - const idMatches = nextId !== undefined && existing.id === nextId; - const shouldUpdateById = idMatches && (nextKey === null || existingKey === nextKey); - return keyMatches || shouldUpdateById; - }); - if (matchIndex >= 0) { - return annotations.map((existing, index) => - index === matchIndex ? { ...existing, ...next } : existing - ); - } - return [...annotations, next]; -} - -export function upsertEdgeLabelOffsetAnnotation( - annotations: EdgeAnnotation[], - data: EdgeOffsetUpdateInput -): EdgeAnnotation[] | null { - const existing = findEdgeAnnotation(annotations, data); - const shouldPersist = data.endpointLabelOffsetEnabled === true || existing !== undefined; - if (!shouldPersist) return null; - - const fallbackOffset = - parseEndpointLabelOffset(existing?.endpointLabelOffset) ?? DEFAULT_ENDPOINT_LABEL_OFFSET; - const offset = parseEndpointLabelOffset(data.endpointLabelOffset) ?? fallbackOffset; - - const nextAnnotation: EdgeAnnotation = { - id: data.id, - source: data.source, - target: data.target, - sourceEndpoint: data.sourceEndpoint, - targetEndpoint: data.targetEndpoint, - endpointLabelOffsetEnabled: data.endpointLabelOffsetEnabled === true, - endpointLabelOffset: offset - }; - - return upsertEdgeAnnotation(annotations, nextAnnotation); -} diff --git a/src/reactTopoViewer/webview/annotations/endpointLabelOffset.ts b/src/reactTopoViewer/webview/annotations/endpointLabelOffset.ts deleted file mode 100644 index 7e8067679..000000000 --- a/src/reactTopoViewer/webview/annotations/endpointLabelOffset.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const DEFAULT_ENDPOINT_LABEL_OFFSET = 20; -export const ENDPOINT_LABEL_OFFSET_MIN = 0; -export const ENDPOINT_LABEL_OFFSET_MAX = 60; - -export function clampEndpointLabelOffset(value: number): number { - return Math.min(ENDPOINT_LABEL_OFFSET_MAX, Math.max(ENDPOINT_LABEL_OFFSET_MIN, value)); -} - -export function parseEndpointLabelOffset(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - return clampEndpointLabelOffset(value); - } - if (typeof value === "string") { - const parsed = Number.parseFloat(value); - if (Number.isFinite(parsed)) { - return clampEndpointLabelOffset(parsed); - } - } - return null; -} diff --git a/src/reactTopoViewer/webview/annotations/groupMembership.ts b/src/reactTopoViewer/webview/annotations/groupMembership.ts deleted file mode 100644 index 6cb2d3d67..000000000 --- a/src/reactTopoViewer/webview/annotations/groupMembership.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Group membership helpers for applying nodeAnnotations to graph store nodes. - */ -import type { Node } from "@xyflow/react"; - -import type { TopoNode } from "../../shared/types/graph"; -import type { GroupStyleAnnotation, NodeAnnotation } from "../../shared/types/topology"; -import { getRecordUnknown, getString } from "../../shared/utilities/typeHelpers"; - -import { isAnnotationNodeType } from "./annotationNodeConverters"; - -function buildGroupLookup(groups: GroupStyleAnnotation[]): { - ids: Set; - nameToId: Map; -} { - const ids = new Set(); - const nameToId = new Map(); - for (const group of groups) { - ids.add(group.id); - nameToId.set(group.name, group.id); - } - return { ids, nameToId }; -} - -function resolveGroupId( - annotation: NodeAnnotation, - lookup: { ids: Set; nameToId: Map } -): string | null { - const groupId = getString(annotation.groupId); - if (groupId !== undefined && groupId.length > 0) return groupId; - - const legacy = getString(annotation.group); - if (legacy === undefined || legacy.length === 0) return null; - if (lookup.ids.has(legacy)) return legacy; - return lookup.nameToId.get(legacy) ?? null; -} - -function withGroupId(node: T, groupId: string): T { - return { - ...node, - data: { - ...node.data, - groupId - } - }; -} - -export function applyGroupMembershipToNodes( - nodes: TopoNode[], - nodeAnnotations: NodeAnnotation[] | undefined, - groups: GroupStyleAnnotation[] -): TopoNode[] { - if (!nodeAnnotations || nodeAnnotations.length === 0) return nodes; - - const lookup = buildGroupLookup(groups); - const membership = new Map(); - - for (const annotation of nodeAnnotations) { - const groupId = resolveGroupId(annotation, lookup); - if (groupId !== null) { - membership.set(annotation.id, groupId); - } - } - - if (membership.size === 0) return nodes; - - return nodes.map((node) => { - if (isAnnotationNodeType(node.type)) return node; - const groupId = membership.get(node.id); - if (groupId === undefined) return node; - return withGroupId(node, groupId); - }); -} - -export interface NodeGroupMembership { - id: string; - groupId: string; -} - -export function collectNodeGroupMemberships(nodes: Node[]): NodeGroupMembership[] { - return nodes - .filter((node) => !isAnnotationNodeType(node.type)) - .map((node) => { - const data = getRecordUnknown(node.data); - const groupId = getString(data?.groupId); - if (groupId === undefined || groupId.length === 0) return null; - return { id: node.id, groupId }; - }) - .filter((entry): entry is NodeGroupMembership => entry !== null); -} diff --git a/src/reactTopoViewer/webview/annotations/index.ts b/src/reactTopoViewer/webview/annotations/index.ts deleted file mode 100644 index 260b6ebed..000000000 --- a/src/reactTopoViewer/webview/annotations/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - FREE_TEXT_NODE_TYPE, - FREE_SHAPE_NODE_TYPE, - TRAFFIC_RATE_NODE_TYPE, - GROUP_NODE_TYPE, - annotationsToNodes, - nodesToAnnotations -} from "./annotationNodeConverters"; -export { applyGroupMembershipToNodes, collectNodeGroupMemberships } from "./groupMembership"; -export { findEdgeAnnotationInLookup, pruneEdgeAnnotations } from "./edgeAnnotations"; -export { parseEndpointLabelOffset } from "./endpointLabelOffset"; -export { - isNonEmptyString, - parseLegacyGroupIdentity, - toFiniteNumber, - toPosition -} from "./valueParsers"; diff --git a/src/reactTopoViewer/webview/annotations/valueParsers.ts b/src/reactTopoViewer/webview/annotations/valueParsers.ts deleted file mode 100644 index 61692a445..000000000 --- a/src/reactTopoViewer/webview/annotations/valueParsers.ts +++ /dev/null @@ -1,39 +0,0 @@ -export function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -export function toFiniteNumber(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - } - return undefined; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -export function toPosition(value: unknown): { x: number; y: number } | undefined { - if (!isRecord(value)) return undefined; - const x = toFiniteNumber(value.x); - const y = toFiniteNumber(value.y); - if (x === undefined || y === undefined) return undefined; - return { x, y }; -} - -export function normalizePosition( - value: unknown, - fallback: { x: number; y: number } = { x: 0, y: 0 } -): { x: number; y: number } { - return toPosition(value) ?? { ...fallback }; -} - -export function parseLegacyGroupIdentity(groupId: string): { name: string; level: string } { - const idx = groupId.lastIndexOf(":"); - if (idx > 0 && idx < groupId.length - 1) { - return { name: groupId.slice(0, idx), level: groupId.slice(idx + 1) }; - } - return { name: groupId, level: "1" }; -} diff --git a/src/reactTopoViewer/webview/assets/images/wireshark_bold.svg b/src/reactTopoViewer/webview/assets/images/wireshark_bold.svg deleted file mode 100644 index ff1cd48cd..000000000 --- a/src/reactTopoViewer/webview/assets/images/wireshark_bold.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - \ No newline at end of file diff --git a/src/reactTopoViewer/webview/components/canvas/CanvasOverlays.tsx b/src/reactTopoViewer/webview/components/canvas/CanvasOverlays.tsx deleted file mode 100644 index 40672b0ca..000000000 --- a/src/reactTopoViewer/webview/components/canvas/CanvasOverlays.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React from "react"; -import { useStore } from "@xyflow/react"; -import Box from "@mui/material/Box"; - -import type { HelperLinePositions } from "../../hooks/canvas/useHelperLines"; - -interface HelperLinesProps { - lines: HelperLinePositions; -} - -const HELPER_LINE_COLOR = "#ff6b6b"; -const HELPER_LINE_WIDTH = 1; -const MIDPOINT_LINE_COLOR = "#4ecdc4"; -const MIDPOINT_LINE_WIDTH = 1; - -export const HelperLines: React.FC = React.memo(({ lines }) => { - const transform = useStore((state) => state.transform); - const width = useStore((state) => state.width); - const height = useStore((state) => state.height); - - const { horizontal, vertical, horizontalMidpoint, verticalMidpoint } = lines; - - if ( - horizontal === null && - vertical === null && - horizontalMidpoint === null && - verticalMidpoint === null - ) { - return null; - } - - const [tx, ty, zoom] = transform; - - const horizontalScreenY = horizontal !== null ? horizontal * zoom + ty : null; - const verticalScreenX = vertical !== null ? vertical * zoom + tx : null; - const horizontalMidpointScreenY = - horizontalMidpoint !== null ? horizontalMidpoint * zoom + ty : null; - const verticalMidpointScreenX = verticalMidpoint !== null ? verticalMidpoint * zoom + tx : null; - - return ( - - {horizontalScreenY !== null && ( - - )} - - {verticalScreenX !== null && ( - - )} - - {horizontalMidpointScreenY !== null && ( - - )} - - {verticalMidpointScreenX !== null && ( - - )} - - ); -}); - -HelperLines.displayName = "HelperLines"; - -const OverlayIndicator: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - {children} - -); - -export const AnnotationModeIndicator: React.FC<{ message: string }> = ({ message }) => ( - {message} -); - -export const LinkCreationIndicator: React.FC<{ linkSourceNode: string }> = ({ linkSourceNode }) => ( - - Creating link from {linkSourceNode} — Click on target node or press Escape to - cancel - -); diff --git a/src/reactTopoViewer/webview/components/canvas/LinkPreview.tsx b/src/reactTopoViewer/webview/components/canvas/LinkPreview.tsx deleted file mode 100644 index 1f3bdf346..000000000 --- a/src/reactTopoViewer/webview/components/canvas/LinkPreview.tsx +++ /dev/null @@ -1,647 +0,0 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { useStore } from "@xyflow/react"; -import type { ConnectionLineComponentProps, Edge, Node } from "@xyflow/react"; - -import { useEdges } from "../../stores/graphStore"; -import { allocateEndpointsForLink } from "../../utils/endpointAllocator"; -import { buildEdgeId } from "../../utils/edgeId"; - -import { calculateControlPoint, getEdgePoints, getLabelPosition } from "./edgeGeometry"; - -type RafThrottled = ((...args: Args) => void) & { cancel: () => void }; - -function rafThrottle(func: (...args: Args) => void): RafThrottled { - let rafId: number | null = null; - let lastArgs: Args | null = null; - - const throttled = (...args: Args) => { - lastArgs = args; - rafId ??= window.requestAnimationFrame(() => { - if (lastArgs) { - func(...lastArgs); - lastArgs = null; - } - rafId = null; - }); - }; - - throttled.cancel = () => { - if (rafId !== null) { - window.cancelAnimationFrame(rafId); - rafId = null; - } - }; - - return throttled as RafThrottled; -} - -// Link preview style (match TopologyEdge defaults) -const LINK_PREVIEW_COLOR = "#969799"; -const LINK_PREVIEW_WIDTH = 2.5; -const LINK_PREVIEW_OPACITY = 0.5; -const LINK_PREVIEW_ICON_SIZE = 40; -const LINK_PREVIEW_CONTROL_POINT_STEP_SIZE = 40; -const LINK_PREVIEW_LOOP_EDGE_SIZE = 50; -const LINK_PREVIEW_LOOP_EDGE_OFFSET = 10; -const LINK_LABEL_OFFSET = 30; -const LINK_LABEL_FONT_SIZE = 10; -const LINK_LABEL_BG_COLOR = "var(--topoviewer-edge-label-background)"; -const LINK_LABEL_TEXT_COLOR = "var(--topoviewer-edge-label-foreground)"; -const LINK_LABEL_OUTLINE_COLOR = "var(--topoviewer-edge-label-outline)"; -const LINK_LABEL_PADDING_X = 2; -const LINK_LABEL_PADDING_Y = 0; -const LINK_LABEL_BORDER_RADIUS = 4; -const LINK_LABEL_SHADOW_SMALL = 2; -const LINK_LABEL_SHADOW_LARGE = 3; - -function buildLinkLabelStyle(zoom: number): React.CSSProperties { - const scaledFont = Math.max(1, LINK_LABEL_FONT_SIZE * zoom); - const padX = LINK_LABEL_PADDING_X * zoom; - const padY = LINK_LABEL_PADDING_Y * zoom; - const radius = LINK_LABEL_BORDER_RADIUS * zoom; - const shadowSmall = LINK_LABEL_SHADOW_SMALL * zoom; - const shadowLarge = LINK_LABEL_SHADOW_LARGE * zoom; - - return { - position: "absolute", - top: 0, - left: 0, - fontSize: `${scaledFont}px`, - fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif', - color: LINK_LABEL_TEXT_COLOR, - backgroundColor: LINK_LABEL_BG_COLOR, - padding: `${padY}px ${padX}px`, - borderRadius: radius, - pointerEvents: "none", - whiteSpace: "nowrap", - textShadow: `0 0 ${shadowSmall}px ${LINK_LABEL_OUTLINE_COLOR}, 0 0 ${shadowSmall}px ${LINK_LABEL_OUTLINE_COLOR}, 0 0 ${shadowLarge}px ${LINK_LABEL_OUTLINE_COLOR}`, - lineHeight: 1.2, - zIndex: 1 - }; -} - -function getNodePosition(node: Node): { x: number; y: number } { - const internal = (node as Node & { internals?: { positionAbsolute: { x: number; y: number } } }) - .internals; - return internal?.positionAbsolute ?? node.position; -} - -function buildPath( - sx: number, - sy: number, - tx: number, - ty: number, - controlPoint: { x: number; y: number } | null -): string { - if (!controlPoint) { - return `M ${sx} ${sy} L ${tx} ${ty}`; - } - return `M ${sx} ${sy} Q ${controlPoint.x} ${controlPoint.y} ${tx} ${ty}`; -} - -function isSameEdgePair(edge: Edge, sourceId: string, targetId: string): boolean { - return ( - (edge.source === sourceId && edge.target === targetId) || - (edge.source === targetId && edge.target === sourceId) - ); -} - -function getPreviewParallelInfo( - edges: Edge[], - sourceId: string, - targetId: string, - previewId?: string | null -): { index: number; total: number; isCanonicalDirection: boolean } { - const existingIds = edges - .filter((edge) => edge.source !== edge.target && isSameEdgePair(edge, sourceId, targetId)) - .map((edge) => edge.id); - - if (previewId !== undefined && previewId !== null && previewId.length > 0) { - const ids = [...existingIds, previewId].sort((a, b) => a.localeCompare(b)); - const index = Math.max(0, ids.indexOf(previewId)); - return { - index, - total: ids.length, - isCanonicalDirection: sourceId.localeCompare(targetId) <= 0 - }; - } - - return { - index: existingIds.length, - total: existingIds.length + 1, - isCanonicalDirection: sourceId.localeCompare(targetId) <= 0 - }; -} - -interface LoopPreviewGeometry { - path: string; - sourceLabelPos: { x: number; y: number }; - targetLabelPos: { x: number; y: number }; -} - -function calculateLoopEdgeGeometry( - nodeX: number, - nodeY: number, - nodeSize: number, - loopIndex: number, - scale: number -): LoopPreviewGeometry { - const centerX = nodeX + nodeSize / 2; - const centerY = nodeY + nodeSize / 2; - const size = (LINK_PREVIEW_LOOP_EDGE_SIZE + loopIndex * LINK_PREVIEW_LOOP_EDGE_OFFSET) * scale; - - const startX = centerX + nodeSize / 2; - const startY = centerY - nodeSize / 4; - const endX = centerX + nodeSize / 2; - const endY = centerY + nodeSize / 4; - - const cp1X = startX + size; - const cp1Y = startY - size * 0.5; - const cp2X = endX + size; - const cp2Y = endY + size * 0.5; - - const path = `M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${endX} ${endY}`; - const labelX = centerX + nodeSize / 2 + size * 0.8; - const labelY = centerY; - const labelOffset = 10 * scale; - - return { - path, - sourceLabelPos: { x: labelX, y: labelY - labelOffset }, - targetLabelPos: { x: labelX, y: labelY + labelOffset } - }; -} - -/** - * Custom connection line component - */ -export const CustomConnectionLine: React.FC = ({ - fromX, - fromY, - toX, - toY, - fromNode, - toNode -}) => { - const edges = useEdges(); - - let path = buildPath(fromX, fromY, toX, toY, null); - - if (toNode) { - const sourceId = fromNode.id; - const targetId = toNode.id; - const iconSize = LINK_PREVIEW_ICON_SIZE; - - if (sourceId === targetId) { - const nodeWidth = fromNode.measured.width ?? iconSize; - const nodePos = getNodePosition(fromNode); - const nodeX = nodePos.x + (nodeWidth - iconSize) / 2; - const nodeY = nodePos.y; - const loopIndex = edges.filter( - (edge) => edge.source === sourceId && edge.target === sourceId - ).length; - path = calculateLoopEdgeGeometry(nodeX, nodeY, iconSize, loopIndex, 1).path; - } else { - const sourceWidth = fromNode.measured.width ?? iconSize; - const targetWidth = toNode.measured.width ?? iconSize; - const sourcePos = getNodePosition(fromNode); - const targetPos = getNodePosition(toNode); - - const points = getEdgePoints( - { - x: sourcePos.x + (sourceWidth - iconSize) / 2, - y: sourcePos.y, - width: iconSize, - height: iconSize - }, - { - x: targetPos.x + (targetWidth - iconSize) / 2, - y: targetPos.y, - width: iconSize, - height: iconSize - } - ); - - const parallelInfo = getPreviewParallelInfo(edges, sourceId, targetId); - const controlPoint = calculateControlPoint( - points.sx, - points.sy, - points.tx, - points.ty, - parallelInfo.index, - parallelInfo.total, - parallelInfo.isCanonicalDirection, - LINK_PREVIEW_CONTROL_POINT_STEP_SIZE - ); - path = buildPath(points.sx, points.sy, points.tx, points.ty, controlPoint); - } - } - - return ( - - ); -}; - -interface PreviewLinkInfo { - previewId: string | null; - parallelInfo: { index: number; total: number; isCanonicalDirection: boolean }; - loopIndex: number; - sourceEndpoint: string; - targetEndpoint: string; -} - -function buildPreviewLinkInfo( - nodes: Node[], - edges: Edge[], - linkSourceNodeId: string, - targetNode: Node | null, - linkCreationSeed?: number | null -): PreviewLinkInfo | null { - if (!targetNode) return null; - - const { sourceEndpoint, targetEndpoint } = allocateEndpointsForLink( - nodes, - edges, - linkSourceNodeId, - targetNode.id - ); - const previewId = - linkCreationSeed !== null && linkCreationSeed !== undefined - ? buildEdgeId( - linkSourceNodeId, - targetNode.id, - sourceEndpoint, - targetEndpoint, - linkCreationSeed - ) - : null; - - const parallelInfo = getPreviewParallelInfo(edges, linkSourceNodeId, targetNode.id, previewId); - const loopIndex = edges.filter( - (edge) => edge.source === targetNode.id && edge.target === targetNode.id - ).length; - - return { previewId, parallelInfo, loopIndex, sourceEndpoint, targetEndpoint }; -} - -type PreviewGeometry = { - path: string; - sourceLabelPos: { x: number; y: number } | null; - targetLabelPos: { x: number; y: number } | null; - sourceLabel: string; - targetLabel: string; -}; - -function buildLoopPreviewGeometry(params: { - sourceNode: Node; - viewport: { x: number; y: number; zoom: number }; - iconSize: number; - loopIndex: number; - sourceLabel: string; - targetLabel: string; -}): PreviewGeometry { - const { sourceNode, viewport, iconSize, loopIndex, sourceLabel, targetLabel } = params; - const zoom = viewport.zoom; - const sourcePos = getNodePosition(sourceNode); - const nodeWidth = (sourceNode.measured?.width ?? LINK_PREVIEW_ICON_SIZE) * zoom; - const nodeX = sourcePos.x * zoom + viewport.x + (nodeWidth - iconSize) / 2; - const nodeY = sourcePos.y * zoom + viewport.y; - const loopGeometry = calculateLoopEdgeGeometry(nodeX, nodeY, iconSize, loopIndex, zoom); - - return { - path: loopGeometry.path, - sourceLabelPos: sourceLabel ? loopGeometry.sourceLabelPos : null, - targetLabelPos: targetLabel ? loopGeometry.targetLabelPos : null, - sourceLabel, - targetLabel - }; -} - -function buildEdgePreviewGeometry(params: { - sourceNode: Node; - targetNode: Node; - viewport: { x: number; y: number; zoom: number }; - iconSize: number; - stepSize: number; - labelOffset: number; - edges: Edge[]; - linkSourceNodeId: string; - previewLinkInfo: PreviewLinkInfo | null; - sourceLabel: string; - targetLabel: string; -}): PreviewGeometry { - const { - sourceNode, - targetNode, - viewport, - iconSize, - stepSize, - labelOffset, - edges, - linkSourceNodeId, - previewLinkInfo, - sourceLabel, - targetLabel - } = params; - - const zoom = viewport.zoom; - const sourcePos = getNodePosition(sourceNode); - const targetPos = getNodePosition(targetNode); - const sourceWidth = (sourceNode.measured?.width ?? LINK_PREVIEW_ICON_SIZE) * zoom; - const targetWidth = (targetNode.measured?.width ?? LINK_PREVIEW_ICON_SIZE) * zoom; - const sourceRect = { - x: sourcePos.x * zoom + viewport.x + (sourceWidth - iconSize) / 2, - y: sourcePos.y * zoom + viewport.y, - width: iconSize, - height: iconSize - }; - const targetRect = { - x: targetPos.x * zoom + viewport.x + (targetWidth - iconSize) / 2, - y: targetPos.y * zoom + viewport.y, - width: iconSize, - height: iconSize - }; - - const points = getEdgePoints(sourceRect, targetRect); - const parallelInfo = - previewLinkInfo?.parallelInfo ?? getPreviewParallelInfo(edges, linkSourceNodeId, targetNode.id); - const controlPoint = calculateControlPoint( - points.sx, - points.sy, - points.tx, - points.ty, - parallelInfo.index, - parallelInfo.total, - parallelInfo.isCanonicalDirection, - stepSize - ); - - return { - path: buildPath(points.sx, points.sy, points.tx, points.ty, controlPoint), - sourceLabelPos: sourceLabel - ? getLabelPosition( - points.sx, - points.sy, - points.tx, - points.ty, - labelOffset, - controlPoint ?? undefined - ) - : null, - targetLabelPos: targetLabel - ? getLabelPosition( - points.tx, - points.ty, - points.sx, - points.sy, - labelOffset, - controlPoint ?? undefined - ) - : null, - sourceLabel, - targetLabel - }; -} - -function buildFreePreviewGeometry(params: { - sourcePosition: { x: number; y: number }; - relativeMouseX: number; - relativeMouseY: number; - viewport: { x: number; y: number; zoom: number }; -}): PreviewGeometry { - const { sourcePosition, relativeMouseX, relativeMouseY, viewport } = params; - const screenSourceX = sourcePosition.x * viewport.zoom + viewport.x; - const screenSourceY = sourcePosition.y * viewport.zoom + viewport.y; - return { - path: `M ${screenSourceX} ${screenSourceY} L ${relativeMouseX} ${relativeMouseY}`, - sourceLabelPos: null, - targetLabelPos: null, - sourceLabel: "", - targetLabel: "" - }; -} - -function computePreviewGeometry(params: { - sourceNode: Node | null; - targetNode: Node | null; - mousePosition: { x: number; y: number } | null; - bounds: DOMRect | null; - previewLinkInfo: PreviewLinkInfo | null; - edges: Edge[]; - linkSourceNodeId: string; - sourcePosition: { x: number; y: number } | null; - viewport: { x: number; y: number; zoom: number }; -}): PreviewGeometry | null { - const { - sourceNode, - targetNode, - mousePosition, - bounds, - previewLinkInfo, - edges, - linkSourceNodeId, - sourcePosition, - viewport - } = params; - if (!sourceNode || !mousePosition || !bounds) return null; - - const zoom = viewport.zoom; - const iconSize = LINK_PREVIEW_ICON_SIZE * zoom; - const stepSize = LINK_PREVIEW_CONTROL_POINT_STEP_SIZE * zoom; - const labelOffset = LINK_LABEL_OFFSET * zoom; - - if (targetNode) { - const sourceLabel = previewLinkInfo?.sourceEndpoint ?? ""; - const targetLabel = previewLinkInfo?.targetEndpoint ?? ""; - - if (sourceNode.id === targetNode.id) { - const loopIndex = previewLinkInfo?.loopIndex ?? 0; - return buildLoopPreviewGeometry({ - sourceNode, - viewport, - iconSize, - loopIndex, - sourceLabel, - targetLabel - }); - } - - return buildEdgePreviewGeometry({ - sourceNode, - targetNode, - viewport, - iconSize, - stepSize, - labelOffset, - edges, - linkSourceNodeId, - previewLinkInfo, - sourceLabel, - targetLabel - }); - } - - const relativeMouseX = mousePosition.x - bounds.left; - const relativeMouseY = mousePosition.y - bounds.top; - if (!sourcePosition) return null; - return buildFreePreviewGeometry({ sourcePosition, relativeMouseX, relativeMouseY, viewport }); -} - -export interface LinkCreationLineProps { - linkSourceNodeId: string; - linkTargetNodeId: string | null; - nodes: Node[]; - edges: Edge[]; - sourcePosition: { x: number; y: number } | null; - linkCreationSeed?: number | null; -} - -const LINK_LINE_SVG_STYLE: React.CSSProperties = { - position: "absolute", - top: 0, - left: 0, - width: "100%", - height: "100%", - pointerEvents: "none", - zIndex: 999 -}; - -let cachedContainerBounds: DOMRect | null = null; -let boundsLastUpdated = 0; -const BOUNDS_CACHE_DURATION = 100; - -function getContainerBounds(): DOMRect | null { - const now = Date.now(); - if (cachedContainerBounds && now - boundsLastUpdated < BOUNDS_CACHE_DURATION) { - return cachedContainerBounds; - } - const container = document.querySelector(".react-flow-canvas"); - if (!container) return null; - cachedContainerBounds = container.getBoundingClientRect(); - boundsLastUpdated = now; - return cachedContainerBounds; -} - -export const LinkCreationLine = React.memo( - ({ linkSourceNodeId, linkTargetNodeId, nodes, edges, sourcePosition, linkCreationSeed }) => { - const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null); - - useEffect(() => { - const throttledSetPosition = rafThrottle((x: number, y: number) => { - setMousePosition({ x, y }); - }); - - const handleMouseMove = (e: MouseEvent) => { - throttledSetPosition(e.clientX, e.clientY); - }; - - window.addEventListener("mousemove", handleMouseMove); - return () => { - window.removeEventListener("mousemove", handleMouseMove); - throttledSetPosition.cancel(); - }; - }, []); - - const [viewportX, viewportY, viewportZoom] = useStore((state) => state.transform); - - const sourceNode = useMemo( - () => nodes.find((node) => node.id === linkSourceNodeId) ?? null, - [nodes, linkSourceNodeId] - ); - const targetNode = useMemo( - () => - linkTargetNodeId !== null && linkTargetNodeId.length > 0 - ? (nodes.find((node) => node.id === linkTargetNodeId) ?? null) - : null, - [nodes, linkTargetNodeId] - ); - const viewport = useMemo( - () => ({ x: viewportX, y: viewportY, zoom: viewportZoom }), - [viewportX, viewportY, viewportZoom] - ); - const bounds = getContainerBounds(); - const labelStyle = useMemo(() => buildLinkLabelStyle(viewport.zoom), [viewport.zoom]); - - const previewLinkInfo = useMemo( - () => buildPreviewLinkInfo(nodes, edges, linkSourceNodeId, targetNode, linkCreationSeed), - [nodes, edges, linkSourceNodeId, targetNode, linkCreationSeed] - ); - - const previewGeometry = useMemo( - () => - computePreviewGeometry({ - sourceNode, - targetNode, - mousePosition, - bounds, - previewLinkInfo, - edges, - linkSourceNodeId, - sourcePosition, - viewport - }), - [ - sourceNode, - targetNode, - mousePosition, - bounds, - previewLinkInfo, - edges, - linkSourceNodeId, - sourcePosition, - viewport - ] - ); - - if (!previewGeometry) return null; - - const strokeWidth = LINK_PREVIEW_WIDTH * viewport.zoom; - - return ( -
- - - - {previewGeometry.sourceLabel && previewGeometry.sourceLabelPos && ( -
- {previewGeometry.sourceLabel} -
- )} - {previewGeometry.targetLabel && previewGeometry.targetLabelPos && ( -
- {previewGeometry.targetLabel} -
- )} -
- ); - } -); - -LinkCreationLine.displayName = "LinkCreationLine"; diff --git a/src/reactTopoViewer/webview/components/canvas/ReactFlowCanvas.tsx b/src/reactTopoViewer/webview/components/canvas/ReactFlowCanvas.tsx deleted file mode 100644 index 84014f1ae..000000000 --- a/src/reactTopoViewer/webview/components/canvas/ReactFlowCanvas.tsx +++ /dev/null @@ -1,1685 +0,0 @@ -// Main React Flow canvas component. -import React, { - useRef, - useImperativeHandle, - forwardRef, - useCallback, - useMemo, - useEffect, - useState -} from "react"; -import { flushSync } from "react-dom"; -import { - Background, - BackgroundVariant, - ConnectionMode, - ReactFlow, - ReactFlowProvider, - SelectionMode, - getViewportForBounds, - useReactFlow, - useNodesInitialized, - useStore, - type Edge, - type Node, - type ReactFlowInstance -} from "@xyflow/react"; - -import { - FREE_SHAPE_NODE_TYPE, - FREE_TEXT_NODE_TYPE, - TRAFFIC_RATE_NODE_TYPE, - GROUP_NODE_TYPE, - isAnnotationNodeType -} from "../../annotations/annotationNodeConverters"; -import { - useAnnotationCanvasHandlers, - useCanvasHandlers, - useCanvasRefMethods, - useDeleteHandlers, - useGeoMapLayout, - useHelperLines, - useLinkCreation, - useSourceNodePosition -} from "../../hooks/canvas"; -import { - useCanvasStore, - useFitViewRequestId, - useGraphActions, - useIsLocked, - useMode, - useTopoViewerActions -} from "../../stores"; -import { invertHexColor, resolveComputedColor } from "../../utils/color"; -import { ContextMenu } from "../context-menu/ContextMenu"; - -import { AnnotationModeIndicator, HelperLines, LinkCreationIndicator } from "./CanvasOverlays"; -import { useContextMenuItems } from "./useContextMenuItems"; -import { edgeTypes, edgeTypesLite } from "./edges"; -import { CustomConnectionLine, LinkCreationLine } from "./LinkPreview"; -import { nodeTypes, nodeTypesLite } from "./nodes"; -import type { - AnnotationHandlers, - CanvasDropData, - CanvasDropHandlers, - EdgeLabelMode, - ReactFlowCanvasProps, - ReactFlowCanvasRef -} from "./types"; - -const GRID_SIZE = 20; -const QUADRATIC_GRID_SIZE = 40; -const DEFAULT_GRID_LINE_WIDTH = 0.5; -const MIN_ZOOM = 0.1; -const MAX_FIT_ZOOM = 2; - -/** Hook for wrapped node click handling */ -function resolveAltDeleteHandler( - nodeType: string | undefined, - annotationHandlers?: AnnotationHandlers -): ((nodeId: string) => void) | undefined { - if (!annotationHandlers) return undefined; - switch (nodeType) { - case FREE_TEXT_NODE_TYPE: - return annotationHandlers.onDeleteFreeText; - case FREE_SHAPE_NODE_TYPE: - return annotationHandlers.onDeleteFreeShape; - case GROUP_NODE_TYPE: - return annotationHandlers.onDeleteGroup; - case TRAFFIC_RATE_NODE_TYPE: - return annotationHandlers.onDeleteTrafficRate; - default: - return undefined; - } -} - -function handleAltDelete( - event: React.MouseEvent, - node: { id: string; type?: string }, - mode: "view" | "edit", - isLocked: boolean, - handleDeleteNode: (nodeId: string) => void, - annotationHandlers?: AnnotationHandlers -): boolean { - if (!event.altKey || isLocked) return false; - - // Annotation overlays are editable in unlocked view mode (running labs). - const annotationDelete = resolveAltDeleteHandler(node.type, annotationHandlers); - if (annotationDelete) { - event.stopPropagation(); - annotationDelete(node.id); - return true; - } - - if (mode !== "edit") return false; - - event.stopPropagation(); - handleDeleteNode(node.id); - return true; -} - -function handleLinkCreationClick( - event: React.MouseEvent, - node: Node, - linkSourceNode: string | null, - completeLinkCreation: (nodeId: string) => void -): boolean { - if (linkSourceNode == null || linkSourceNode.length === 0) return false; - const isLoopLink = linkSourceNode === node.id; - const isNetworkNode = node.type === "network-node"; - if (isLoopLink && isNetworkNode) { - return true; - } - event.stopPropagation(); - completeLinkCreation(node.id); - return true; -} - -function isLinkCreationNode(node: Node): boolean { - return node.type === "topology-node" || node.type === "network-node"; -} - -function hasOtherSelectedTopologyNodes( - reactFlowInstanceRef: React.RefObject, - nodeId: string -): boolean { - const instance = reactFlowInstanceRef.current; - if (!instance) return false; - return instance - .getNodes() - .some( - (candidate) => - candidate.selected === true && - candidate.id !== nodeId && - (candidate.type === "topology-node" || candidate.type === "network-node") - ); -} - -function handleShiftStartLinkClick( - event: React.MouseEvent, - node: Node, - linkSourceNode: string | null, - startLinkCreation: (nodeId: string) => void, - mode: "view" | "edit", - isLocked: boolean, - hasOtherSelectedNodes: boolean, - onLockedAction?: () => void -): boolean { - if (!event.shiftKey) return false; - if (linkSourceNode != null && linkSourceNode.length > 0) return false; - if (mode !== "edit") return false; - if (!isLinkCreationNode(node)) return false; - // Preserve multi-select flows: if additional nodes are already selected, - // treat Shift+Click as selection expansion, not link creation. - if (hasOtherSelectedNodes) return false; - - event.stopPropagation(); - if (isLocked) { - onLockedAction?.(); - return true; - } - startLinkCreation(node.id); - return true; -} - -function openAnnotationEditor( - node: Node, - clearContextForAnnotationEdit: () => void, - annotationHandlers?: AnnotationHandlers -): boolean { - if (!annotationHandlers) return false; - - if (node.type === FREE_TEXT_NODE_TYPE) { - clearContextForAnnotationEdit(); - annotationHandlers.onEditFreeText(node.id); - return true; - } - if (node.type === FREE_SHAPE_NODE_TYPE) { - clearContextForAnnotationEdit(); - annotationHandlers.onEditFreeShape(node.id); - return true; - } - if (node.type === GROUP_NODE_TYPE && annotationHandlers.onEditGroup) { - clearContextForAnnotationEdit(); - annotationHandlers.onEditGroup(node.id); - return true; - } - if (node.type === TRAFFIC_RATE_NODE_TYPE && annotationHandlers.onEditTrafficRate) { - clearContextForAnnotationEdit(); - annotationHandlers.onEditTrafficRate(node.id); - return true; - } - - return false; -} - -interface WrappedNodeClickConfig { - linkSourceNode: string | null; - startLinkCreation: (nodeId: string) => void; - completeLinkCreation: (nodeId: string) => void; - reactFlowInstanceRef: React.RefObject; - onNodeClick: ReturnType["onNodeClick"]; - mode: "view" | "edit"; - isLocked: boolean; - handleDeleteNode: (nodeId: string) => void; - clearContextForAnnotationEdit: () => void; - onLockedAction?: () => void; - annotationHandlers?: AnnotationHandlers; -} - -function useWrappedNodeClick(config: WrappedNodeClickConfig) { - const { - linkSourceNode, - startLinkCreation, - completeLinkCreation, - reactFlowInstanceRef, - onNodeClick, - mode, - isLocked, - handleDeleteNode, - clearContextForAnnotationEdit, - onLockedAction, - annotationHandlers - } = config; - - return useCallback( - (event: React.MouseEvent, node: Node) => { - if (handleAltDelete(event, node, mode, isLocked, handleDeleteNode, annotationHandlers)) - return; - if (handleLinkCreationClick(event, node, linkSourceNode, completeLinkCreation)) return; - const hasOtherSelectedNodes = hasOtherSelectedTopologyNodes(reactFlowInstanceRef, node.id); - if ( - handleShiftStartLinkClick( - event, - node, - linkSourceNode, - startLinkCreation, - mode, - isLocked, - hasOtherSelectedNodes, - onLockedAction - ) - ) { - return; - } - const didOpenAnnotationEditor = openAnnotationEditor( - node, - clearContextForAnnotationEdit, - annotationHandlers - ); - if (didOpenAnnotationEditor) return; - onNodeClick(event, node); - }, - [ - linkSourceNode, - startLinkCreation, - completeLinkCreation, - reactFlowInstanceRef, - onNodeClick, - mode, - isLocked, - handleDeleteNode, - clearContextForAnnotationEdit, - onLockedAction, - annotationHandlers - ] - ); -} - -function useWrappedEdgeClick( - onEdgeClick: ReturnType["onEdgeClick"], - mode: "view" | "edit", - isLocked: boolean, - handleDeleteEdge: (edgeId: string) => void -) { - return useCallback( - (event: React.MouseEvent, edge: Edge) => { - if (event.altKey && mode === "edit" && !isLocked) { - event.stopPropagation(); - handleDeleteEdge(edge.id); - return; - } - onEdgeClick(event, edge); - }, - [onEdgeClick, mode, isLocked, handleDeleteEdge] - ); -} - -/** CSS styles for the canvas */ -const canvasStyle: React.CSSProperties = { - width: "100%", - height: "100%", - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0 -}; - -// Constants -const proOptions = { hideAttribution: true }; -const defaultViewport = { x: 0, y: 0, zoom: 1 }; -const fitViewOptions = { padding: 0.2 }; -const LOW_DETAIL_ZOOM_THRESHOLD = 0.5; -const LARGE_GRAPH_NODE_THRESHOLD = 600; -const LARGE_GRAPH_EDGE_THRESHOLD = 900; - -type CanvasOcclusion = { - side: "left" | "right" | null; - width: number; -}; - -function getContextPanelOcclusion( - canvasContainer: HTMLDivElement | null, - isContextPanelOpen: boolean -): CanvasOcclusion { - if (!isContextPanelOpen || !canvasContainer) { - return { side: null, width: 0 }; - } - - const panel = document.querySelector( - "[data-testid='context-panel'] .MuiDrawer-paper" - ); - if (!panel) { - return { side: null, width: 0 }; - } - - const canvasRect = canvasContainer.getBoundingClientRect(); - const panelRect = panel.getBoundingClientRect(); - const overlapWidth = Math.max( - 0, - Math.min(canvasRect.right, panelRect.right) - Math.max(canvasRect.left, panelRect.left) - ); - if (overlapWidth <= 0) { - return { side: null, width: 0 }; - } - - const side: "left" | "right" = panelRect.left <= canvasRect.left ? "left" : "right"; - return { side, width: overlapWidth }; -} - -function hasFiniteViewport(viewport: { x: number; y: number; zoom: number }): boolean { - return ( - Number.isFinite(viewport.x) && Number.isFinite(viewport.y) && Number.isFinite(viewport.zoom) - ); -} - -// ============================================================================ -// Hooks extracted for complexity reduction -// ============================================================================ - -/** Hook for render configuration based on graph size and zoom level */ -function useRenderConfig( - nodeCount: number, - edgeCount: number, - linkLabelMode: EdgeLabelMode, - disableZoomTracking = false -) { - const isLargeGraph = - nodeCount >= LARGE_GRAPH_NODE_THRESHOLD || edgeCount >= LARGE_GRAPH_EDGE_THRESHOLD; - - const isLowDetail = useStore( - useCallback( - (store) => { - if (disableZoomTracking) return false; - const zoom = store.transform[2]; - return isLargeGraph && zoom <= LOW_DETAIL_ZOOM_THRESHOLD; - }, - [isLargeGraph, disableZoomTracking] - ), - (left, right) => left === right - ); - - const edgeRenderConfig = useMemo( - () => ({ - labelMode: linkLabelMode, - suppressLabels: isLowDetail && linkLabelMode !== "telemetry-style", - suppressHitArea: isLowDetail - }), - [linkLabelMode, isLowDetail] - ); - - const nodeRenderConfig = useMemo( - () => ({ - suppressLabels: isLowDetail - }), - [isLowDetail] - ); - - return { isLargeGraph, isLowDetail, edgeRenderConfig, nodeRenderConfig }; -} - -/** Hook for node drag handler wrappers with helper line support */ -function useDragHandlers( - onNodeDrag: (event: React.MouseEvent, node: Node) => void, - wrappedOnNodeDragStart: (event: React.MouseEvent, node: Node) => void, - wrappedOnNodeDragStop: (event: React.MouseEvent, node: Node) => void, - helperLineHandlers?: { - updateHelperLines: (node: Node, allNodes: Node[]) => void; - clearHelperLines: () => void; - allNodes: Node[]; - isGeoLayout: boolean; - } -) { - const handleNodeDragStart = useCallback( - (event: React.MouseEvent, node: Node) => { - wrappedOnNodeDragStart(event, node); - }, - [wrappedOnNodeDragStart] - ); - - const handleNodeDrag = useCallback( - (event: React.MouseEvent, node: Node) => { - onNodeDrag(event, node); - // Update helper lines during drag (skip in geo layout). - if (helperLineHandlers && !helperLineHandlers.isGeoLayout) { - helperLineHandlers.updateHelperLines(node, helperLineHandlers.allNodes); - } - }, - [onNodeDrag, helperLineHandlers] - ); - - const handleNodeDragStop = useCallback( - (event: React.MouseEvent, node: Node) => { - wrappedOnNodeDragStop(event, node); - // Clear helper lines when drag ends - if (helperLineHandlers) { - helperLineHandlers.clearHelperLines(); - } - }, - [wrappedOnNodeDragStop, helperLineHandlers] - ); - - return { handleNodeDragStart, handleNodeDrag, handleNodeDragStop }; -} - -/** Hook to wrap onInit with additional callback */ -function useWrappedOnInit( - handlersOnInit: (instance: ReactFlowInstance) => void, - onInitProp?: (instance: ReactFlowInstance) => void -) { - return useCallback( - (instance: ReactFlowInstance) => { - handlersOnInit(instance); - onInitProp?.(instance); - }, - [handlersOnInit, onInitProp] - ); -} - -const CANVAS_DROP_MIME_TYPE = "application/reactflow-node"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isCanvasDropDataType(value: unknown): value is CanvasDropData["type"] { - return value === "node" || value === "network" || value === "annotation"; -} - -function isCanvasDropData(value: unknown): value is CanvasDropData { - if (!isRecord(value)) return false; - if (!isCanvasDropDataType(value.type)) return false; - if (value.templateName !== undefined && typeof value.templateName !== "string") return false; - if (value.networkType !== undefined && typeof value.networkType !== "string") return false; - if ( - value.annotationType !== undefined && - value.annotationType !== "text" && - value.annotationType !== "shape" && - value.annotationType !== "group" && - value.annotationType !== "traffic-rate" - ) { - return false; - } - if (value.shapeType !== undefined && typeof value.shapeType !== "string") return false; - return true; -} - -function parseCanvasDropData(event: React.DragEvent): CanvasDropData | null { - const dataStr = event.dataTransfer.getData(CANVAS_DROP_MIME_TYPE); - if (!dataStr) return null; - try { - const parsed: unknown = JSON.parse(dataStr); - return isCanvasDropData(parsed) ? parsed : null; - } catch { - return null; - } -} - -function getSnappedDropPosition( - reactFlowInstance: ReactFlowInstance, - event: React.DragEvent -): { x: number; y: number } { - const position = reactFlowInstance.screenToFlowPosition({ - x: event.clientX, - y: event.clientY - }); - return { - x: Math.round(position.x / GRID_SIZE) * GRID_SIZE, - y: Math.round(position.y / GRID_SIZE) * GRID_SIZE - }; -} - -function handleNodeDrop( - data: CanvasDropData, - snappedPosition: { x: number; y: number }, - handlers: CanvasDropHandlers -) { - if (data.templateName == null || data.templateName.length === 0 || !handlers.onDropCreateNode) { - return; - } - handlers.onDropCreateNode(snappedPosition, data.templateName); -} - -function handleNetworkDrop( - data: CanvasDropData, - snappedPosition: { x: number; y: number }, - handlers: CanvasDropHandlers -) { - if (data.networkType == null || data.networkType.length === 0 || !handlers.onDropCreateNetwork) { - return; - } - handlers.onDropCreateNetwork(snappedPosition, data.networkType); -} - -function handleAnnotationDrop( - data: CanvasDropData, - snappedPosition: { x: number; y: number }, - handlers: CanvasDropHandlers -) { - if (data.annotationType === "text") { - handlers.onAddTextAtPosition?.(snappedPosition); - return; - } - if (data.annotationType === "shape") { - handlers.onAddShapeAtPosition?.(snappedPosition, data.shapeType); - return; - } - if (data.annotationType === "group") { - handlers.onAddGroupAtPosition?.(snappedPosition); - return; - } - if (data.annotationType === "traffic-rate") { - handlers.onAddTrafficRateAtPosition?.(snappedPosition); - } -} - -function handleCanvasDrop( - data: CanvasDropData, - snappedPosition: { x: number; y: number }, - handlers: CanvasDropHandlers -) { - if (data.type === "node") { - handleNodeDrop(data, snappedPosition, handlers); - return; - } - if (data.type === "network") { - handleNetworkDrop(data, snappedPosition, handlers); - return; - } - handleAnnotationDrop(data, snappedPosition, handlers); -} - -function handleCanvasDropEvent(params: { - event: React.DragEvent; - mode: "view" | "edit"; - isLocked: boolean; - reactFlowInstanceRef: React.RefObject; - handlers: CanvasDropHandlers; -}) { - const { event, mode, isLocked, reactFlowInstanceRef, handlers } = params; - event.preventDefault(); - - if (isLocked) return; - - const data = parseCanvasDropData(event); - if (!data) return; - // Deployed labs run in view mode, but unlocked users can still place annotation overlays. - if (mode !== "edit" && data.type !== "annotation") return; - - const rfInstance = reactFlowInstanceRef.current; - if (!rfInstance) return; - - const snappedPosition = getSnappedDropPosition(rfInstance, event); - handleCanvasDrop(data, snappedPosition, handlers); -} - -function shouldRunFitView(params: { - fitViewRequestId: number; - lastFitViewRequestId: number; - isReactFlowReady: boolean; - areNodesInitialized: boolean; - reactFlowInstance: ReactFlowInstance | null; - fitNodeCount: number; -}): boolean { - const { - fitViewRequestId, - lastFitViewRequestId, - isReactFlowReady, - areNodesInitialized, - reactFlowInstance, - fitNodeCount - } = params; - return ( - fitViewRequestId > lastFitViewRequestId && - isReactFlowReady && - areNodesInitialized && - Boolean(reactFlowInstance) && - fitNodeCount > 0 - ); -} - -function getRenderableNodes(allNodes: Node[], nodesDraggable: boolean): Node[] { - if (nodesDraggable) return allNodes; - - let changed = false; - const nextNodes: Node[] = []; - for (const node of allNodes) { - if (!isAnnotationNodeType(node.type) || node.draggable === false) { - nextNodes.push(node); - continue; - } - changed = true; - nextNodes.push({ ...node, draggable: false }); - } - return changed ? nextNodes : allNodes; -} - -function relayBackdropContextMenu(event: React.MouseEvent, closeContextMenu: () => void): void { - const { clientX, clientY } = event; - flushSync(() => { - closeContextMenu(); - }); - const target = document.elementFromPoint(clientX, clientY); - if (!target) { - return; - } - target.dispatchEvent( - new MouseEvent("contextmenu", { - bubbles: true, - cancelable: true, - clientX, - clientY, - button: 2, - buttons: 2 - }) - ); -} - -/** Hook for node and edge refs that update each render */ -function useGraphRefs(nodes: Node[], edges: Edge[]) { - const nodesRef = useRef(nodes); - const edgesRef = useRef(edges); - nodesRef.current = nodes; - edgesRef.current = edges; - return { nodesRef, edgesRef }; -} - -function useLinkTargetHover(linkSourceNode: string | null) { - const [linkTargetNodeId, setLinkTargetNodeId] = useState(null); - - useEffect(() => { - if (linkSourceNode == null || linkSourceNode.length === 0) { - setLinkTargetNodeId(null); - } - }, [linkSourceNode]); - - const handleNodeMouseEnter = useCallback( - (_event: React.MouseEvent, node: Node) => { - if (linkSourceNode == null || linkSourceNode.length === 0) return; - setLinkTargetNodeId(node.id); - }, - [linkSourceNode] - ); - - const handleNodeMouseLeave = useCallback( - (_event: React.MouseEvent, node: Node) => { - if (linkSourceNode == null || linkSourceNode.length === 0) return; - setLinkTargetNodeId((current) => (current === node.id ? null : current)); - }, - [linkSourceNode] - ); - - return { linkTargetNodeId, handleNodeMouseEnter, handleNodeMouseLeave }; -} - -function useGeoWheelZoom( - geoLayout: ReturnType, - isGeoLayout: boolean, - isGeoEdit: boolean, - canvasContainerRef: React.RefObject -) { - useEffect(() => { - if (!isGeoLayout || !isGeoEdit) return; - const map = geoLayout.mapRef.current; - const container = canvasContainerRef.current; - if (!map || !container) return; - - const handleWheel = (event: WheelEvent) => { - const mapCanvas = map.getCanvas(); - if (event.target instanceof Element && mapCanvas.contains(event.target)) { - // Let native MapLibre scroll-zoom handle direct map-canvas wheel events. - return; - } - - event.preventDefault(); - mapCanvas.dispatchEvent( - new WheelEvent("wheel", { - bubbles: true, - cancelable: true, - deltaX: event.deltaX, - deltaY: event.deltaY, - deltaZ: event.deltaZ, - deltaMode: event.deltaMode, - clientX: event.clientX, - clientY: event.clientY, - ctrlKey: event.ctrlKey, - shiftKey: event.shiftKey, - altKey: event.altKey, - metaKey: event.metaKey - }) - ); - }; - - container.addEventListener("wheel", handleWheel, { passive: false }); - return () => container.removeEventListener("wheel", handleWheel); - }, [geoLayout.mapRef, isGeoLayout, isGeoEdit, canvasContainerRef]); -} - -function useSyncCanvasStore(params: { - linkSourceNode: string | null; - setLinkSourceNode: (id: string | null) => void; - edgeRenderConfig: { labelMode: EdgeLabelMode; suppressLabels: boolean; suppressHitArea: boolean }; - setEdgeRenderConfig: (config: { - labelMode: EdgeLabelMode; - suppressLabels: boolean; - suppressHitArea: boolean; - }) => void; - nodeRenderConfig: { suppressLabels: boolean }; - setNodeRenderConfig: (config: { suppressLabels: boolean }) => void; - annotationHandlers?: AnnotationHandlers; - setAnnotationHandlers: (handlers: AnnotationHandlers | null) => void; -}) { - const { - linkSourceNode, - setLinkSourceNode, - edgeRenderConfig, - setEdgeRenderConfig, - nodeRenderConfig, - setNodeRenderConfig, - annotationHandlers, - setAnnotationHandlers - } = params; - - useEffect(() => { - setLinkSourceNode(linkSourceNode); - }, [linkSourceNode, setLinkSourceNode]); - - useEffect(() => { - setEdgeRenderConfig(edgeRenderConfig); - }, [edgeRenderConfig, setEdgeRenderConfig]); - - useEffect(() => { - setNodeRenderConfig(nodeRenderConfig); - }, [nodeRenderConfig, setNodeRenderConfig]); - - useEffect(() => { - setAnnotationHandlers(annotationHandlers ?? null); - }, [annotationHandlers, setAnnotationHandlers]); -} - -function getCanvasInteractionConfig(params: { - mode: "view" | "edit"; - isLocked: boolean; - isGeoLayout: boolean; - isGeoEdit: boolean; - isInAddMode: boolean; -}): { - allowPanOnDrag: boolean; - allowSelectionOnDrag: boolean; - nodesDraggable: boolean; - nodesConnectable: boolean; - reactFlowStyle: React.CSSProperties | undefined; -} { - const { mode, isLocked, isGeoLayout, isGeoEdit, isInAddMode } = params; - const allowPanOnDrag = !isInAddMode && !isGeoLayout; - const allowSelectionOnDrag = !isInAddMode && !isGeoLayout; - const nodesDraggable = !isLocked && (!isGeoLayout || isGeoEdit); - const nodesConnectable = mode === "edit" && !isLocked; - const reactFlowStyle: React.CSSProperties | undefined = isGeoLayout - ? { - background: "transparent", - // Let MapLibre receive pane drags in geo layout; node/edge elements stay interactive. - pointerEvents: "none", - position: "absolute", - top: 0, - left: 0, - width: "100%", - height: "100%", - zIndex: 1 - } - : undefined; - return { allowPanOnDrag, allowSelectionOnDrag, nodesDraggable, nodesConnectable, reactFlowStyle }; -} - -function getGeoEditableState(isGeoLayout: boolean, isLocked: boolean): boolean { - return isGeoLayout && !isLocked; -} - -function getEffectiveEdgeRenderConfig(edgeRenderConfig: { - labelMode: EdgeLabelMode; - suppressLabels: boolean; - suppressHitArea: boolean; -}): { labelMode: EdgeLabelMode; suppressLabels: boolean; suppressHitArea: boolean } { - return edgeRenderConfig; -} - -function getCanvasContainerClassName(isGeoLayout: boolean, isGeoInteracting: boolean): string { - let className = "react-flow-canvas canvas-container"; - if (isGeoLayout) { - className += " maplibre-active"; - } - if (isGeoInteracting) { - className += " maplibre-moving"; - } - return className; -} - -function getGeoInteractingState(isGeoLayout: boolean, isInteracting: boolean): boolean { - return isGeoLayout && isInteracting; -} - -function shouldOnlyRenderVisibleElements(isLowDetail: boolean, isGeoLayout: boolean): boolean { - return isLowDetail && !isGeoLayout; -} - -function renderGeoMapLayer( - geoContainerRef: React.RefObject -): React.ReactElement { - return ( -
- ); -} - -function renderBackgroundLayer(params: { - gridLineWidth: number; - gridStyle: "dotted" | "quadratic"; - effectiveGridColor: string; - gridBgColor: string | null; -}): React.ReactElement { - const { gridLineWidth, gridStyle, effectiveGridColor, gridBgColor } = params; - const isQuadraticGrid = gridStyle === "quadratic"; - const backgroundStyle = - gridBgColor != null && gridBgColor.length > 0 ? { backgroundColor: gridBgColor } : undefined; - return ( - - ); -} - -function renderLinkCreationLine(params: { - linkSourceNode: string; - linkTargetNodeId: string | null; - nodes: Node[]; - edges: Edge[]; - sourcePosition: { x: number; y: number } | null; - linkCreationSeed: number | null | undefined; -}): React.ReactElement { - const { linkSourceNode, linkTargetNodeId, nodes, edges, sourcePosition, linkCreationSeed } = - params; - return ( - - ); -} - -function renderLinkIndicator(linkSourceNode: string): React.ReactElement { - return ; -} - -function renderAnnotationIndicator(message: string): React.ReactElement { - return ; -} - -function buildCanvasOverlays(params: { - isGeoLayout: boolean; - isLowDetail: boolean; - geoContainerRef: React.RefObject; - linkSourceNode: string | null; - linkTargetNodeId: string | null; - nodes: Node[]; - edges: Edge[]; - sourcePosition: { x: number; y: number } | null; - linkCreationSeed: number | null | undefined; - isInAddMode: boolean; - addModeMessage?: string | null; - gridLineWidth: number; - gridStyle: "dotted" | "quadratic"; - effectiveGridColor: string; - gridBgColor: string | null; -}): { - geoMapLayer: React.ReactNode; - backgroundLayer: React.ReactNode; - linkCreationLine: React.ReactNode; - linkIndicator: React.ReactNode; - annotationIndicator: React.ReactNode; -} { - const { - isGeoLayout, - isLowDetail, - geoContainerRef, - linkSourceNode, - linkTargetNodeId, - nodes, - edges, - sourcePosition, - linkCreationSeed, - isInAddMode, - addModeMessage, - gridLineWidth, - gridStyle, - effectiveGridColor, - gridBgColor - } = params; - - const canShowGeoMap = isGeoLayout; - const canShowBackground = !isLowDetail && !isGeoLayout; - const hasLinkSourceNode = linkSourceNode != null && linkSourceNode.length > 0; - const hasAddModeMessage = addModeMessage != null && addModeMessage.length > 0; - const canShowLinkCreation = hasLinkSourceNode; - const canShowLinkIndicator = hasLinkSourceNode; - const canShowAnnotationIndicator = isInAddMode && hasAddModeMessage; - - const geoMapLayer = canShowGeoMap ? renderGeoMapLayer(geoContainerRef) : null; - const backgroundLayer = canShowBackground - ? renderBackgroundLayer({ gridLineWidth, gridStyle, effectiveGridColor, gridBgColor }) - : null; - const linkCreationLine = canShowLinkCreation - ? renderLinkCreationLine({ - linkSourceNode: linkSourceNode, - linkTargetNodeId, - nodes, - edges, - sourcePosition, - linkCreationSeed - }) - : null; - const linkIndicator = canShowLinkIndicator ? renderLinkIndicator(linkSourceNode) : null; - const annotationIndicator = canShowAnnotationIndicator - ? renderAnnotationIndicator(addModeMessage) - : null; - - return { geoMapLayer, backgroundLayer, linkCreationLine, linkIndicator, annotationIndicator }; -} - -// ============================================================================ -// Main Component -// ============================================================================ - -/** - * Inner component that uses useStore (requires ReactFlowProvider ancestor) - * Now fully controlled - nodes/edges come from the graph store (unified source of truth). - * All nodes (topology + annotation) are in the same array. - */ -const ReactFlowCanvasInner = forwardRef( - ( - { - nodes: propNodes, - edges: propEdges, - isContextPanelOpen = false, - layout = "preset", - isGeoLayout = false, - gridLineWidth = DEFAULT_GRID_LINE_WIDTH, - gridStyle = "dotted", - gridColor = null, - gridBgColor = null, - annotationMode, - annotationHandlers, - onNodeDelete, - onEdgeDelete, - onPaneClick, - linkLabelMode = "show-all", - onInit: onInitProp, - onEdgeCreated, - onShiftClickCreate, - onOpenNodePalette, - onAddGroup, - onAddText, - onAddShapes, - onAddTextAtPosition, - onAddGroupAtPosition, - onAddShapeAtPosition, - onAddTrafficRateAtPosition, - onDropCreateNode, - onDropCreateNetwork, - onLockedAction - }, - ref - ) => { - const mode = useMode(); - const isLocked = useIsLocked(); - const { - selectNode, - selectEdge, - editNode, - editNetwork, - editEdge, - editImpairment, - editCustomTemplate - } = useTopoViewerActions(); - - // Get setters from graph store - these update the single source of truth - const { setNodes, setEdges, onNodesChange, onEdgesChange } = useGraphActions(); - const reactFlowInstanceRef = useRef(null); - const canvasContainerRef = useRef(null); - const fitViewRequestId = useFitViewRequestId(); - const lastFitViewRequestRef = useRef(0); - const [isReactFlowReady, setIsReactFlowReady] = useState(false); - const areNodesInitialized = useNodesInitialized({ includeHiddenNodes: false }); - const { getNodesBounds } = useReactFlow(); - const suppressSelectionSyncUntilRef = useRef(0); - - const topoState = useMemo(() => ({ mode, isLocked }), [mode, isLocked]); - - // Import canvas store actions - const { setEdgeRenderConfig, setNodeRenderConfig, setAnnotationHandlers, setLinkSourceNode } = - useCanvasStore(); - - // All nodes (topology + annotation) are now unified in propNodes - const allNodes = useMemo(() => propNodes ?? [], [propNodes]); - const allEdges = useMemo(() => propEdges ?? [], [propEdges]); - const visibleNodeCount = useMemo( - () => allNodes.reduce((count, node) => (node.hidden === true ? count : count + 1), 0), - [allNodes] - ); - - const handleEdgeCreatedWithContextPanel = useCallback( - ( - sourceId: string, - targetId: string, - edgeData: { - id: string; - source: string; - target: string; - sourceEndpoint: string; - targetEndpoint: string; - } - ) => { - // React Flow may transiently select the target node/edge during connect. - // Suppress syncing that selection into the app store to avoid auto-opening the panel. - suppressSelectionSyncUntilRef.current = Date.now() + 250; - - onEdgeCreated?.(sourceId, targetId, edgeData); - - // If the panel is already open, switch directly to the link editor for the newly created link. - if (mode === "edit" && isContextPanelOpen) { - editEdge(edgeData.id); - } - }, - [editEdge, isContextPanelOpen, mode, onEdgeCreated] - ); - - const { - linkSourceNode, - startLinkCreation, - completeLinkCreation, - cancelLinkCreation, - linkCreationSeed - } = useLinkCreation(handleEdgeCreatedWithContextPanel); - const linkSourceNodeRef = useRef(null); - linkSourceNodeRef.current = linkSourceNode; - const shouldSuppressSelectionSync = useCallback( - () => - Boolean(linkSourceNodeRef.current) || Date.now() < suppressSelectionSyncUntilRef.current, - [] - ); - - const isGeoEditable = getGeoEditableState(isGeoLayout, isLocked); - - const geoLayout = useGeoMapLayout({ - isGeoLayout, - isEditable: isGeoEditable, - nodes: allNodes, - setNodes, - reactFlowInstanceRef, - canvasContainerRef, - restoreOnExit: layout === "preset" - }); - const isGeoEdit = isGeoEditable; - useGeoWheelZoom(geoLayout, isGeoLayout, isGeoEdit, canvasContainerRef); - - const fitCanvasToVisibleViewport = useCallback( - async (options: { padding: number; duration: number }) => { - const instance = reactFlowInstanceRef.current; - const canvasContainer = canvasContainerRef.current; - const visibleNodes = allNodes.filter((node) => node.hidden !== true); - - if (!instance || !canvasContainer || visibleNodes.length === 0) { - return; - } - - const occlusion = getContextPanelOcclusion(canvasContainer, isContextPanelOpen); - if (occlusion.width <= 0) { - await instance.fitView(options); - return; - } - - const canvasRect = canvasContainer.getBoundingClientRect(); - const availableWidth = Math.max(1, canvasRect.width - occlusion.width); - const availableHeight = Math.max(1, canvasRect.height); - const bounds = getNodesBounds(visibleNodes); - const viewport = getViewportForBounds( - bounds, - availableWidth, - availableHeight, - MIN_ZOOM, - MAX_FIT_ZOOM, - options.padding - ); - if (!hasFiniteViewport(viewport)) { - await instance.fitView(options); - return; - } - - const adjustedX = occlusion.side === "left" ? viewport.x + occlusion.width : viewport.x; - await instance.setViewport({ x: adjustedX, y: viewport.y, zoom: viewport.zoom }, options); - }, - [allNodes, getNodesBounds, isContextPanelOpen] - ); - - useEffect(() => { - if ( - !shouldRunFitView({ - fitViewRequestId, - lastFitViewRequestId: lastFitViewRequestRef.current, - isReactFlowReady, - areNodesInitialized, - reactFlowInstance: reactFlowInstanceRef.current, - fitNodeCount: visibleNodeCount - }) - ) { - return; - } - - let cancelled = false; - const requestedFitId = fitViewRequestId; - let completedPasses = 0; - const requiredPasses = 2; - let retryCount = 0; - const MAX_FIT_RETRIES = 120; - - const scheduleRetry = () => { - if (cancelled) return; - retryCount += 1; - if (retryCount > MAX_FIT_RETRIES) { - lastFitViewRequestRef.current = requestedFitId; - return; - } - window.requestAnimationFrame(tryFit); - }; - - const tryFit = () => { - if (cancelled) return; - if (requestedFitId <= lastFitViewRequestRef.current) return; - - if (isGeoLayout) { - if (!geoLayout.isReady) { - scheduleRetry(); - return; - } - geoLayout.fitToViewport({ duration: 0 }); - completedPasses += 1; - if (completedPasses < requiredPasses) { - scheduleRetry(); - return; - } - lastFitViewRequestRef.current = requestedFitId; - return; - } - - const canvasContainer = canvasContainerRef.current; - const canvasRect = canvasContainer?.getBoundingClientRect(); - const hasCanvasArea = Boolean(canvasRect && canvasRect.width > 1 && canvasRect.height > 1); - if (!hasCanvasArea) { - scheduleRetry(); - return; - } - - void fitCanvasToVisibleViewport({ padding: 0.2, duration: 0 }) - .then(() => { - if (cancelled) return; - if (requestedFitId <= lastFitViewRequestRef.current) return; - completedPasses += 1; - if (completedPasses < requiredPasses) { - scheduleRetry(); - return; - } - lastFitViewRequestRef.current = requestedFitId; - }) - .catch(() => { - if (cancelled) return; - scheduleRetry(); - }); - }; - - tryFit(); - - return () => { - cancelled = true; - }; - }, [ - fitCanvasToVisibleViewport, - fitViewRequestId, - visibleNodeCount, - geoLayout, - isGeoLayout, - isReactFlowReady, - areNodesInitialized - ]); - - // Refs for context menu (to avoid re-renders) - const { nodesRef, edgesRef } = useGraphRefs(allNodes, allEdges); - - const handlers = useCanvasHandlers({ - selectNode, - selectEdge, - editNode, - editNetwork, - editEdge, - mode, - isLocked, - onNodesChangeBase: onNodesChange, - onLockedAction, - onPaneClickExtra: onPaneClick, - shouldSuppressSelectionSync, - nodes: allNodes, - setNodes, - onEdgeCreated: handleEdgeCreatedWithContextPanel, - groupMemberHandlers: { - getGroupMembers: annotationHandlers?.getGroupMembers, - onNodeDropped: annotationHandlers?.onNodeDropped - }, - reactFlowInstanceRef, - geoLayout: { - isGeoLayout, - isEditable: isGeoEditable, - getGeoUpdateForNode: geoLayout.getGeoUpdateForNode - } - }); - const { closeContextMenu } = handlers; - - const { handleDeleteNode, handleDeleteEdge } = useDeleteHandlers( - selectNode, - selectEdge, - closeContextMenu, - onNodeDelete, - onEdgeDelete - ); - const sourceNodePosition = useSourceNodePosition(linkSourceNode, allNodes); - const { linkTargetNodeId, handleNodeMouseEnter, handleNodeMouseLeave } = - useLinkTargetHover(linkSourceNode); - - // Helper lines for node alignment during drag - const { helperLines, updateHelperLines, clearHelperLines } = useHelperLines(); - - // Use extracted hooks for render config and drag handlers - const { isLowDetail, edgeRenderConfig, nodeRenderConfig } = useRenderConfig( - allNodes.length, - allEdges.length, - linkLabelMode, - isGeoLayout - ); - const isGeoInteracting = getGeoInteractingState(isGeoLayout, geoLayout.isInteracting); - const effectiveEdgeRenderConfig = useMemo( - () => getEffectiveEdgeRenderConfig(edgeRenderConfig), - [edgeRenderConfig] - ); - const activeNodeTypes = useMemo( - () => (isLowDetail && !isGeoLayout ? nodeTypesLite : nodeTypes), - [isLowDetail, isGeoLayout] - ); - const activeEdgeTypes = useMemo( - // Geo layout should keep full edge geometry for visual quality. - () => (isLowDetail ? edgeTypesLite : edgeTypes), - [isLowDetail] - ); - useSyncCanvasStore({ - linkSourceNode, - setLinkSourceNode, - edgeRenderConfig: effectiveEdgeRenderConfig, - setEdgeRenderConfig, - nodeRenderConfig, - setNodeRenderConfig, - annotationHandlers, - setAnnotationHandlers - }); - - // Note: Keyboard delete handling is done by useAppKeyboardShortcuts in App.tsx - // which uses handleDeleteNode for proper undo/redo support. - // Do NOT add useKeyboardDeleteHandlers here as it bypasses the undo system. - - const refMethods = useCanvasRefMethods( - handlers.reactFlowInstance, - allNodes, - allEdges, - setNodes, - setEdges - ); - const fitCanvas = useCallback(() => { - if (isGeoLayout) { - geoLayout.fitToViewport(); - return; - } - Promise.resolve(fitCanvasToVisibleViewport({ padding: 0.2, duration: 200 })).catch(() => { - /* ignore */ - }); - }, [fitCanvasToVisibleViewport, geoLayout, isGeoLayout]); - const refHandle = useMemo( - () => ({ - ...refMethods, - fit: fitCanvas - }), - [refMethods, fitCanvas] - ); - useImperativeHandle(ref, () => refHandle, [refHandle]); - - const wrappedOnNodeClick = useWrappedNodeClick({ - linkSourceNode, - startLinkCreation, - completeLinkCreation, - reactFlowInstanceRef: handlers.reactFlowInstance, - onNodeClick: handlers.onNodeClick, - mode, - isLocked, - handleDeleteNode, - clearContextForAnnotationEdit: () => { - // Switch the context panel from node/link editors to annotation editors. - // This is intentionally destructive to any in-progress node/link edits. - editNode(null); - editNetwork(null); - editEdge(null); - editImpairment(null); - editCustomTemplate(null); - selectNode(null); - selectEdge(null); - }, - onLockedAction, - annotationHandlers - }); - const wrappedOnEdgeClick = useWrappedEdgeClick( - handlers.onEdgeClick, - mode, - isLocked, - handleDeleteEdge - ); - const contextMenuItems = useContextMenuItems({ - handlers, - state: topoState, - editNode, - editNetwork, - editEdge, - handleDeleteNode, - handleDeleteEdge, - showNodeInfo: selectNode, - showLinkInfo: selectEdge, - showLinkImpairment: editImpairment, - nodesRef, - edgesRef, - linkSourceNode, - startLinkCreation, - cancelLinkCreation, - annotationHandlers, - onOpenNodePalette, - onAddDefaultNode: onShiftClickCreate, - onAddGroup, - onAddText, - onAddTextAtPosition, - onAddShapes, - onAddShapeAtPosition, - onAddTrafficRateAtPosition - }); - - const { - wrappedOnPaneClick, - wrappedOnNodeDoubleClick, - wrappedOnNodeDragStart, - wrappedOnNodeDragStop, - isInAddMode, - addModeMessage - } = useAnnotationCanvasHandlers({ - mode, - isLocked, - annotationMode, - annotationHandlers, - reactFlowInstanceRef: handlers.reactFlowInstance, - baseOnPaneClick: handlers.onPaneClick, - baseOnNodeDoubleClick: handlers.onNodeDoubleClick, - baseOnNodeDragStart: handlers.onNodeDragStart, - baseOnNodeDragStop: handlers.onNodeDragStop, - onShiftClickCreate - }); - - useEffect(() => { - if (!isGeoLayout || !geoLayout.isReady) return; - const map = geoLayout.mapRef.current; - if (!map) return; - - const handleMapClick = (event: { originalEvent?: MouseEvent }) => { - const originalEvent = event.originalEvent; - if (!originalEvent) return; - const pane = canvasContainerRef.current?.querySelector(".react-flow__pane"); - if (!pane) return; - pane.dispatchEvent( - new MouseEvent("click", { - bubbles: true, - cancelable: true, - clientX: originalEvent.clientX, - clientY: originalEvent.clientY, - shiftKey: originalEvent.shiftKey - }) - ); - }; - - map.on("click", handleMapClick); - return () => { - map.off("click", handleMapClick); - }; - }, [isGeoLayout, geoLayout.isReady, geoLayout.mapRef, wrappedOnPaneClick]); - - const { reactFlowInstance: handlersReactFlowInstance } = handlers; - - const { handleNodeDragStart, handleNodeDrag, handleNodeDragStop } = useDragHandlers( - handlers.onNodeDrag, - wrappedOnNodeDragStart, - wrappedOnNodeDragStop, - { - updateHelperLines, - clearHelperLines, - allNodes, - isGeoLayout - } - ); - - const { onInit: handleOnInit } = handlers; - - const handleCanvasInit = useCallback( - (instance: ReactFlowInstance) => { - handleOnInit(instance); - setIsReactFlowReady(true); - }, - [handleOnInit] - ); - - const wrappedOnInit = useWrappedOnInit(handleCanvasInit, onInitProp); - - // Drag-drop handlers for node palette - const handleDragOver = useCallback((event: React.DragEvent) => { - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - }, []); - - const handleDrop = useCallback( - (event: React.DragEvent) => { - handleCanvasDropEvent({ - event, - mode, - isLocked, - reactFlowInstanceRef: handlersReactFlowInstance, - handlers: { - onDropCreateNode, - onDropCreateNetwork, - onAddTextAtPosition, - onAddShapeAtPosition, - onAddGroupAtPosition, - onAddTrafficRateAtPosition - } - }); - }, - [ - mode, - isLocked, - handlersReactFlowInstance, - onDropCreateNode, - onDropCreateNetwork, - onAddTextAtPosition, - onAddShapeAtPosition, - onAddGroupAtPosition, - onAddTrafficRateAtPosition - ] - ); - - const interactionConfig = getCanvasInteractionConfig({ - mode, - isLocked, - isGeoLayout, - isGeoEdit, - isInAddMode - }); - const { - allowPanOnDrag, - allowSelectionOnDrag, - nodesDraggable, - nodesConnectable, - reactFlowStyle - } = interactionConfig; - const renderNodes = useMemo( - () => getRenderableNodes(allNodes, nodesDraggable), - [allNodes, nodesDraggable] - ); - const effectiveGridColor = useMemo(() => { - if (gridColor != null && gridColor.length > 0) return gridColor; - const bg = gridBgColor ?? resolveComputedColor("--vscode-editor-background", "#1e1e1e"); - return invertHexColor(bg); - }, [gridColor, gridBgColor]); - - const overlays = buildCanvasOverlays({ - isGeoLayout, - isLowDetail, - geoContainerRef: geoLayout.containerRef, - linkSourceNode, - linkTargetNodeId, - nodes: allNodes, - edges: allEdges, - sourcePosition: sourceNodePosition, - linkCreationSeed: linkCreationSeed ?? null, - isInAddMode, - addModeMessage, - gridLineWidth, - gridStyle, - effectiveGridColor, - gridBgColor - }); - const contextMenuVisible = handlers.contextMenu.type !== null; - - const handleBackdropContextMenu = useCallback( - (event: React.MouseEvent) => { - relayBackdropContextMenu(event, closeContextMenu); - }, - [closeContextMenu] - ); - - const handleCanvasContextMenu = useCallback((event: React.MouseEvent) => { - event.preventDefault(); - }, []); - - return ( -
- {overlays.geoMapLayer} - - {overlays.backgroundLayer} - - - - - {/* Helper lines for node alignment during drag */} - - - {overlays.linkCreationLine} - - {overlays.linkIndicator} - - {overlays.annotationIndicator} -
- ); - } -); - -ReactFlowCanvasInner.displayName = "ReactFlowCanvasInner"; - -/** - * Outer wrapper that provides ReactFlowProvider context. - */ -const ReactFlowCanvasComponent = forwardRef( - (props, ref) => ( - - - - ) -); - -ReactFlowCanvasComponent.displayName = "ReactFlowCanvas"; - -export const ReactFlowCanvas = ReactFlowCanvasComponent; diff --git a/src/reactTopoViewer/webview/components/canvas/contextMenuBuilders.ts b/src/reactTopoViewer/webview/components/canvas/contextMenuBuilders.ts deleted file mode 100644 index eabc3bed6..000000000 --- a/src/reactTopoViewer/webview/components/canvas/contextMenuBuilders.ts +++ /dev/null @@ -1,691 +0,0 @@ -// Context menu item builders for ReactFlowCanvas. -import React from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; -import { - Add as AddIcon, - Article as ArticleIcon, - Category as CategoryIcon, - CircleOutlined as CircleOutlinedIcon, - Close as CloseIcon, - CropSquare as CropSquareIcon, - Dashboard as DashboardIcon, - Delete as DeleteIcon, - Edit as EditIcon, - Info as InfoIcon, - Lan as LanIcon, - Layers as LayersIcon, - Link as LinkIcon, - Remove as RemoveIcon, - Speed as SpeedIcon, - Terminal as TerminalIcon, - TextFields as TextFieldsIcon, - Tune as TuneIcon -} from "@mui/icons-material"; - -import type { ContextMenuItem } from "../context-menu/ContextMenu"; -import { WiresharkIcon } from "../context-menu/WiresharkIcon"; -import { getViewportCenter } from "../../utils/viewportUtils"; -import { sendCommandToExtension } from "../../messaging/extensionMessaging"; -import { - FREE_TEXT_NODE_TYPE, - FREE_SHAPE_NODE_TYPE, - TRAFFIC_RATE_NODE_TYPE, - GROUP_NODE_TYPE -} from "../../annotations/annotationNodeConverters"; - -import type { ReactFlowCanvasProps } from "./types"; - -const DIVIDER_ID = "divider-1"; - -interface MenuBuilderContext { - targetId: string; - targetNodeType?: string; - isEditMode: boolean; - isLocked: boolean; - closeContextMenu: () => void; - editNode: (id: string) => void; - editNetwork?: (id: string) => void; - handleDeleteNode: (id: string) => void; - showNodeInfo?: (id: string) => void; - /** Node ID that link creation started from (if in link creation mode) */ - linkSourceNode?: string | null; - /** Start link creation from this node */ - startLinkCreation?: (nodeId: string) => void; - /** Cancel link creation mode */ - cancelLinkCreation?: () => void; - /** Edit free text annotation */ - editFreeText?: (id: string) => void; - /** Edit free shape annotation */ - editFreeShape?: (id: string) => void; - /** Delete free text annotation */ - deleteFreeText?: (id: string) => void; - /** Delete free shape annotation */ - deleteFreeShape?: (id: string) => void; - /** Edit group annotation */ - editGroup?: (id: string) => void; - /** Delete group annotation */ - deleteGroup?: (id: string) => void; - /** Edit traffic-rate annotation */ - editTrafficRate?: (id: string) => void; - /** Delete traffic-rate annotation */ - deleteTrafficRate?: (id: string) => void; -} - -interface EdgeMenuBuilderContext { - targetId: string; - sourceNode?: string; - targetNode?: string; - sourceEndpoint?: string; - targetEndpoint?: string; - extraData?: Record; - isEditMode: boolean; - isLocked: boolean; - closeContextMenu: () => void; - editEdge: (id: string) => void; - handleDeleteEdge: (id: string) => void; - showLinkInfo?: (id: string) => void; - showLinkImpairment?: (id: string) => void; -} - -type PaneMenuActions = Pick< - ReactFlowCanvasProps, - | "onOpenNodePalette" - | "onAddGroup" - | "onAddText" - | "onAddTextAtPosition" - | "onAddShapes" - | "onAddShapeAtPosition" - | "onAddTrafficRateAtPosition" ->; - -interface PaneMenuBuilderContext extends PaneMenuActions { - isEditMode: boolean; - isLocked: boolean; - closeContextMenu: () => void; - reactFlowInstance: React.RefObject; - /** Callback to add a default node at a position */ - onAddDefaultNode?: (position: { x: number; y: number }) => void; - /** Context menu screen position for coordinate conversion */ - menuPosition?: { x: number; y: number }; -} - -function isNonEmptyString(value: string | undefined | null): value is string { - return typeof value === "string" && value.length > 0; -} - -function getExtraDataString( - extraData: Record | undefined, - key: string -): string | undefined { - if (extraData === undefined) return undefined; - const value = extraData[key]; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -/** - * Build context menu for free text annotations - */ -function buildFreeTextContextMenu(ctx: MenuBuilderContext): ContextMenuItem[] { - const { targetId, isLocked, closeContextMenu, editFreeText, deleteFreeText } = ctx; - - const items: ContextMenuItem[] = [ - { - id: "edit-text", - label: "Edit Text", - icon: React.createElement(EditIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - editFreeText?.(targetId); - closeContextMenu(); - } - } - ]; - if (!isLocked) { - items.push( - { id: DIVIDER_ID, label: "", divider: true }, - { - id: "delete-text", - label: "Delete Text", - icon: React.createElement(DeleteIcon, { fontSize: "small" }), - disabled: isLocked, - danger: true, - onClick: () => { - deleteFreeText?.(targetId); - closeContextMenu(); - } - } - ); - } - return items; -} - -/** - * Build context menu for free shape annotations - */ -function buildFreeShapeContextMenu(ctx: MenuBuilderContext): ContextMenuItem[] { - const { targetId, isLocked, closeContextMenu, editFreeShape, deleteFreeShape } = ctx; - - const items: ContextMenuItem[] = [ - { - id: "edit-shape", - label: "Edit Shape", - icon: React.createElement(EditIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - editFreeShape?.(targetId); - closeContextMenu(); - } - } - ]; - if (!isLocked) { - items.push( - { id: DIVIDER_ID, label: "", divider: true }, - { - id: "delete-shape", - label: "Delete Shape", - icon: React.createElement(DeleteIcon, { fontSize: "small" }), - disabled: isLocked, - danger: true, - onClick: () => { - deleteFreeShape?.(targetId); - closeContextMenu(); - } - } - ); - } - return items; -} - -/** - * Build context menu for group annotations - */ -function buildGroupContextMenu(ctx: MenuBuilderContext): ContextMenuItem[] { - const { targetId, isLocked, closeContextMenu, editGroup, deleteGroup } = ctx; - - const items: ContextMenuItem[] = [ - { - id: "edit-group", - label: "Edit Group", - icon: React.createElement(EditIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - editGroup?.(targetId); - closeContextMenu(); - } - } - ]; - if (!isLocked) { - items.push( - { id: DIVIDER_ID, label: "", divider: true }, - { - id: "delete-group", - label: "Delete Group", - icon: React.createElement(DeleteIcon, { fontSize: "small" }), - disabled: isLocked, - danger: true, - onClick: () => { - deleteGroup?.(targetId); - closeContextMenu(); - } - } - ); - } - return items; -} - -/** - * Build context menu for traffic-rate annotations - */ -function buildTrafficRateContextMenu(ctx: MenuBuilderContext): ContextMenuItem[] { - const { targetId, isLocked, closeContextMenu, editTrafficRate, deleteTrafficRate } = ctx; - - const items: ContextMenuItem[] = [ - { - id: "edit-traffic-rate", - label: "Edit Traffic Rate", - icon: React.createElement(EditIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - editTrafficRate?.(targetId); - closeContextMenu(); - } - } - ]; - - if (!isLocked) { - items.push( - { id: DIVIDER_ID, label: "", divider: true }, - { - id: "delete-traffic-rate", - label: "Delete Traffic Rate", - icon: React.createElement(DeleteIcon, { fontSize: "small" }), - disabled: isLocked, - danger: true, - onClick: () => { - deleteTrafficRate?.(targetId); - closeContextMenu(); - } - } - ); - } - - return items; -} - -function buildNodeViewContextMenu(ctx: MenuBuilderContext): ContextMenuItem[] { - const { targetId, closeContextMenu, showNodeInfo } = ctx; - return [ - { - id: "ssh-node", - label: "SSH", - icon: React.createElement(TerminalIcon, { fontSize: "small" }), - onClick: () => { - sendCommandToExtension("clab-node-connect-ssh", { nodeName: targetId }); - closeContextMenu(); - } - }, - { - id: "shell-node", - label: "Shell", - icon: React.createElement(TerminalIcon, { fontSize: "small" }), - onClick: () => { - sendCommandToExtension("clab-node-attach-shell", { nodeName: targetId }); - closeContextMenu(); - } - }, - { - id: "logs-node", - label: "Logs", - icon: React.createElement(ArticleIcon, { fontSize: "small" }), - onClick: () => { - sendCommandToExtension("clab-node-view-logs", { nodeName: targetId }); - closeContextMenu(); - } - }, - { id: DIVIDER_ID, label: "", divider: true }, - { - id: "info-node", - label: "Info", - icon: React.createElement(InfoIcon, { fontSize: "small" }), - onClick: () => { - showNodeInfo?.(targetId); - closeContextMenu(); - } - } - ]; -} - -/** - * Build node context menu items - */ -export function buildNodeContextMenu(ctx: MenuBuilderContext): ContextMenuItem[] { - const { - targetId, - targetNodeType, - isEditMode, - isLocked, - closeContextMenu, - editNode, - editNetwork, - handleDeleteNode, - linkSourceNode, - startLinkCreation, - cancelLinkCreation - } = ctx; - - // Handle annotation nodes with specific menus - if (targetNodeType === FREE_TEXT_NODE_TYPE) { - return buildFreeTextContextMenu(ctx); - } - if (targetNodeType === FREE_SHAPE_NODE_TYPE) { - return buildFreeShapeContextMenu(ctx); - } - if (targetNodeType === TRAFFIC_RATE_NODE_TYPE) { - return buildTrafficRateContextMenu(ctx); - } - if (targetNodeType === GROUP_NODE_TYPE) { - return buildGroupContextMenu(ctx); - } - - if (!isEditMode) { - return buildNodeViewContextMenu(ctx); - } - - const items: ContextMenuItem[] = []; - const isNetworkNode = targetNodeType === "network-node"; - - // If in link creation mode, show cancel option - if (isNonEmptyString(linkSourceNode)) { - items.push({ - id: "cancel-link", - label: "Cancel Link Creation", - icon: React.createElement(CloseIcon, { fontSize: "small" }), - onClick: () => { - cancelLinkCreation?.(); - closeContextMenu(); - } - }); - items.push({ id: "divider-link", label: "", divider: true }); - } - - // Show "Create Link" if not already in link creation mode - if (!isNonEmptyString(linkSourceNode)) { - items.push({ - id: "create-link", - label: "Create Link", - icon: React.createElement(LinkIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - startLinkCreation?.(targetId); - closeContextMenu(); - } - }); - } - - items.push({ id: "divider-edit", label: "", divider: true }); - items.push({ - id: "edit-node", - label: isNetworkNode ? "Edit Network" : "Edit Node", - icon: isNetworkNode - ? React.createElement(LanIcon, { fontSize: "small" }) - : React.createElement(EditIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - if (isNetworkNode) { - editNetwork?.(targetId); - closeContextMenu(); - return; - } - editNode(targetId); - closeContextMenu(); - } - }); - - items.push({ id: DIVIDER_ID, label: "", divider: true }); - items.push({ - id: "delete-node", - label: "Delete Node", - icon: React.createElement(DeleteIcon, { fontSize: "small" }), - disabled: isLocked, - danger: true, - onClick: () => handleDeleteNode(targetId) - }); - - return items; -} - -/** - * Build edge context menu items - */ -export function buildEdgeContextMenu(ctx: EdgeMenuBuilderContext): ContextMenuItem[] { - const { - targetId, - sourceNode, - targetNode, - sourceEndpoint, - targetEndpoint, - extraData, - isEditMode, - isLocked, - closeContextMenu, - editEdge, - handleDeleteEdge, - showLinkInfo, - showLinkImpairment - } = ctx; - - // Build capture items for each endpoint - const captureItems: ContextMenuItem[] = []; - const srcName = getExtraDataString(extraData, "clabSourceLongName") ?? sourceNode; - const dstName = getExtraDataString(extraData, "clabTargetLongName") ?? targetNode; - if (isNonEmptyString(srcName) && isNonEmptyString(sourceEndpoint)) { - captureItems.push({ - id: "capture-source", - label: `${srcName} - ${sourceEndpoint}`, - icon: React.createElement(WiresharkIcon, { fontSize: "small" }), - onClick: () => { - sendCommandToExtension("clab-interface-capture", { - nodeName: srcName, - interfaceName: sourceEndpoint - }); - closeContextMenu(); - } - }); - } - if (isNonEmptyString(dstName) && isNonEmptyString(targetEndpoint)) { - captureItems.push({ - id: "capture-target", - label: `${dstName} - ${targetEndpoint}`, - icon: React.createElement(WiresharkIcon, { fontSize: "small" }), - onClick: () => { - sendCommandToExtension("clab-interface-capture", { - nodeName: dstName, - interfaceName: targetEndpoint - }); - closeContextMenu(); - } - }); - } - - const impairmentItem: ContextMenuItem = { - id: "impair-edge", - label: "Link Impairments", - icon: React.createElement(TuneIcon, { fontSize: "small" }), - onClick: () => { - showLinkImpairment?.(targetId); - closeContextMenu(); - } - }; - const linkInfoItem: ContextMenuItem = { - id: "info-edge", - label: "Info", - icon: React.createElement(InfoIcon, { fontSize: "small" }), - onClick: () => { - showLinkInfo?.(targetId); - closeContextMenu(); - } - }; - if (!isEditMode) { - return [ - ...captureItems, - ...(captureItems.length > 0 ? [{ id: "divider-capture", label: "", divider: true }] : []), - impairmentItem, - { id: "divider-info", label: "", divider: true }, - linkInfoItem - ]; - } - return [ - { - id: "edit-edge", - label: "Edit Link", - icon: React.createElement(EditIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - editEdge(targetId); - closeContextMenu(); - } - }, - { id: DIVIDER_ID, label: "", divider: true }, - { - id: "delete-edge", - label: "Delete Link", - icon: React.createElement(DeleteIcon, { fontSize: "small" }), - disabled: isLocked, - danger: true, - onClick: () => handleDeleteEdge(targetId) - } - ]; -} - -/** - * Build pane context menu items - */ -export function buildPaneContextMenu(ctx: PaneMenuBuilderContext): ContextMenuItem[] { - const { - isEditMode, - isLocked, - closeContextMenu, - reactFlowInstance, - onOpenNodePalette, - onAddDefaultNode, - onAddGroup, - onAddText, - onAddTextAtPosition, - onAddShapes, - onAddShapeAtPosition, - onAddTrafficRateAtPosition, - menuPosition - } = ctx; - const items: ContextMenuItem[] = []; - - // Add Node is only available in edit mode (not when deployed) - if (isEditMode) { - items.push( - { - id: "add-node", - label: "Add Node", - icon: React.createElement(AddIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - if (onAddDefaultNode && menuPosition && reactFlowInstance.current) { - const flowPosition = reactFlowInstance.current.screenToFlowPosition(menuPosition); - onAddDefaultNode(flowPosition); - } - closeContextMenu(); - } - }, - { id: "divider-additions", label: "", divider: true } - ); - } - - const editorItems: ContextMenuItem[] = []; - - const getFlowPosition = () => { - const instance = reactFlowInstance.current; - if (!instance) return null; - if (menuPosition) { - return instance.screenToFlowPosition(menuPosition); - } - return getViewportCenter(instance); - }; - - if (onAddGroup) { - editorItems.push({ - id: "add-group", - label: "Add Group", - icon: React.createElement(LayersIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - onAddGroup(); - closeContextMenu(); - } - }); - } - if (onAddText || onAddTextAtPosition) { - editorItems.push({ - id: "add-text", - label: "Add Text", - icon: React.createElement(TextFieldsIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - const flowPosition = getFlowPosition(); - if (onAddTextAtPosition && flowPosition) { - onAddTextAtPosition(flowPosition); - } else { - onAddText?.(); - } - closeContextMenu(); - } - }); - } - - if (onAddShapes || onAddShapeAtPosition) { - editorItems.push({ - id: "add-shape", - label: "Add Shape", - icon: React.createElement(CategoryIcon, { fontSize: "small" }), - disabled: isLocked, - children: [ - { - id: "add-shape-rectangle", - label: "Rectangle", - icon: React.createElement(CropSquareIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - const flowPosition = getFlowPosition(); - if (onAddShapeAtPosition && flowPosition) { - onAddShapeAtPosition(flowPosition, "rectangle"); - } else { - onAddShapes?.("rectangle"); - } - closeContextMenu(); - } - }, - { - id: "add-shape-circle", - label: "Circle", - icon: React.createElement(CircleOutlinedIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - const flowPosition = getFlowPosition(); - if (onAddShapeAtPosition && flowPosition) { - onAddShapeAtPosition(flowPosition, "circle"); - } else { - onAddShapes?.("circle"); - } - closeContextMenu(); - } - }, - { - id: "add-shape-line", - label: "Line", - icon: React.createElement(RemoveIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - const flowPosition = getFlowPosition(); - if (onAddShapeAtPosition && flowPosition) { - onAddShapeAtPosition(flowPosition, "line"); - } else { - onAddShapes?.("line"); - } - closeContextMenu(); - } - } - ] - }); - } - if (onAddTrafficRateAtPosition) { - editorItems.push({ - id: "add-traffic-rate", - label: "Add Traffic Rate", - icon: React.createElement(SpeedIcon, { fontSize: "small" }), - disabled: isLocked, - onClick: () => { - const flowPosition = getFlowPosition(); - if (flowPosition) { - onAddTrafficRateAtPosition(flowPosition); - } - closeContextMenu(); - } - }); - } - if (editorItems.length > 0) { - items.push(...editorItems); - } - - const paletteItem: ContextMenuItem = { - id: "open-node-palette", - label: "Open Palette", - icon: React.createElement(DashboardIcon, { fontSize: "small" }), - onClick: () => { - onOpenNodePalette?.(); - closeContextMenu(); - } - }; - if (items.length > 0) { - items.push({ id: "divider-palette", label: "", divider: true }); - } - items.push(paletteItem); - - return items; -} diff --git a/src/reactTopoViewer/webview/components/canvas/edgeGeometry.ts b/src/reactTopoViewer/webview/components/canvas/edgeGeometry.ts deleted file mode 100644 index 0ba7e5ca7..000000000 --- a/src/reactTopoViewer/webview/components/canvas/edgeGeometry.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { XYPosition } from "@xyflow/react"; - -export interface NodeRect { - x: number; - y: number; - width: number; - height: number; -} - -/** - * Calculate the intersection point of a line from center to target with a rectangle - */ -export function getNodeIntersection( - nodeX: number, - nodeY: number, - nodeWidth: number, - nodeHeight: number, - targetX: number, - targetY: number -): XYPosition { - const w = nodeWidth / 2; - const h = nodeHeight / 2; - const dx = targetX - nodeX; - const dy = targetY - nodeY; - - if (dx === 0 && dy === 0) { - return { x: nodeX, y: nodeY }; - } - - const absDx = Math.abs(dx); - const absDy = Math.abs(dy); - - if (absDx * h > absDy * w) { - const sign = dx > 0 ? 1 : -1; - return { - x: nodeX + sign * w, - y: nodeY + (dy * w) / absDx - }; - } - - const sign = dy > 0 ? 1 : -1; - return { - x: nodeX + (dx * h) / absDy, - y: nodeY + sign * h - }; -} - -/** - * Get edge connection points between two nodes (between source and target nodes) - */ -export function getEdgePoints(sourceNode: NodeRect, targetNode: NodeRect) { - const sourceCenter = { - x: sourceNode.x + sourceNode.width / 2, - y: sourceNode.y + sourceNode.height / 2 - }; - const targetCenter = { - x: targetNode.x + targetNode.width / 2, - y: targetNode.y + targetNode.height / 2 - }; - - const sourcePoint = getNodeIntersection( - sourceCenter.x, - sourceCenter.y, - sourceNode.width, - sourceNode.height, - targetCenter.x, - targetCenter.y - ); - - const targetPoint = getNodeIntersection( - targetCenter.x, - targetCenter.y, - targetNode.width, - targetNode.height, - sourceCenter.x, - sourceCenter.y - ); - - return { - sx: sourcePoint.x, - sy: sourcePoint.y, - tx: targetPoint.x, - ty: targetPoint.y - }; -} - -/** - * Calculate label position along the edge (supports curved paths) - */ -export function getLabelPosition( - startX: number, - startY: number, - endX: number, - endY: number, - offset: number, - controlPoint?: { x: number; y: number } -): XYPosition { - const dx = endX - startX; - const dy = endY - startY; - const length = Math.sqrt(dx * dx + dy * dy); - - if (length === 0) return { x: startX, y: startY }; - - const baseRatio = Math.min(offset / length, 0.4); - const ratio = controlPoint ? Math.max(baseRatio, 0.15) : baseRatio; - - if (controlPoint) { - const t = ratio; - const oneMinusT = 1 - t; - return { - x: oneMinusT * oneMinusT * startX + 2 * oneMinusT * t * controlPoint.x + t * t * endX, - y: oneMinusT * oneMinusT * startY + 2 * oneMinusT * t * controlPoint.y + t * t * endY - }; - } - - return { - x: startX + dx * ratio, - y: startY + dy * ratio - }; -} - -/** - * Calculate the bezier control point for a curved edge. - */ -export function calculateControlPoint( - sx: number, - sy: number, - tx: number, - ty: number, - edgeIndex: number, - totalEdges: number, - isCanonicalDirection: boolean, - stepSize: number -): XYPosition | null { - if (totalEdges <= 1) return null; - - const midX = (sx + tx) / 2; - const midY = (sy + ty) / 2; - - const dx = tx - sx; - const dy = ty - sy; - const length = Math.sqrt(dx * dx + dy * dy); - - if (length === 0) return null; - - const normalX = -dy / length; - const normalY = dx / length; - - let offset = (edgeIndex - (totalEdges - 1) / 2) * stepSize; - if (!isCanonicalDirection) { - offset = -offset; - } - - return { - x: midX + normalX * offset, - y: midY + normalY * offset - }; -} diff --git a/src/reactTopoViewer/webview/components/canvas/edges/TopologyEdge.tsx b/src/reactTopoViewer/webview/components/canvas/edges/TopologyEdge.tsx deleted file mode 100644 index da7016543..000000000 --- a/src/reactTopoViewer/webview/components/canvas/edges/TopologyEdge.tsx +++ /dev/null @@ -1,1189 +0,0 @@ -/** - * TopologyEdge - Custom React Flow edge with endpoint labels - * Uses floating/straight edge style for network topology visualization - */ -import React, { memo, useMemo, useCallback } from "react"; -import { EdgeLabelRenderer, useStore, type EdgeProps, type Edge, type Node } from "@xyflow/react"; - -import { SELECTION_COLOR, type EdgeLabelMode } from "../types"; -import { useEdgeInfo, useEdgeRenderConfig } from "../../../stores/canvasStore"; -import { useEdges, useGraphStore } from "../../../stores/graphStore"; -import { useTelemetryLabelSettings, useMode } from "../../../stores/topoViewerStore"; -import { - calculateControlPoint, - getEdgePoints, - getLabelPosition, - getNodeIntersection -} from "../edgeGeometry"; -import { DEFAULT_ENDPOINT_LABEL_OFFSET } from "../../../annotations/endpointLabelOffset"; -import { - clampTelemetryInterfaceSizePercent, - clampTelemetryNodeSizePx, - resolveTelemetryInterfaceLabel -} from "../../../utils/telemetryInterfaceLabels"; - -// Edge style constants -const EDGE_COLOR_DEFAULT = "#969799"; -const EDGE_COLOR_UP = "#00df2b"; -const EDGE_COLOR_DOWN = "#df2b00"; -const EDGE_WIDTH_NORMAL = 4; -const EDGE_WIDTH_SELECTED = 5.5; -const EDGE_OPACITY_NORMAL = 0.5; -const EDGE_OPACITY_SELECTED = 1; - -// Label style constants -const LABEL_FONT_SIZE = "10px"; -const LABEL_BG_COLOR = "var(--topoviewer-edge-label-background)"; -const LABEL_TEXT_COLOR = "var(--topoviewer-edge-label-foreground)"; -const LABEL_OUTLINE_COLOR = "var(--topoviewer-edge-label-outline)"; -const LABEL_PADDING = "0px 2px"; -const LABEL_FONT_FAMILY = '"Helvetica Neue", Helvetica, Arial, sans-serif'; -const TELEMETRY_LABEL_FONT_FAMILY = "Helvetica, Arial, sans-serif"; - -const TELEMETRY_LABEL_FONT_SIZE_PX = 10; -const TELEMETRY_LABEL_TEXT_COLOR = "#FFFFFF"; -const TELEMETRY_LABEL_BG_COLOR = "#bec8d2"; -const TELEMETRY_LABEL_STROKE_COLOR = "rgba(0, 0, 0, 0.95)"; -const TELEMETRY_LABEL_BORDER_COLOR = "rgba(0, 0, 0, 0.25)"; -const TELEMETRY_LABEL_TEXT_STROKE_WIDTH_PX = 0.6; -const TELEMETRY_LABEL_MIN_RADIUS_PX = 7; -const TELEMETRY_LABEL_HORIZONTAL_PADDING_PX = 2; -const TELEMETRY_LABEL_CHAR_WIDTH_RATIO = 0.58; -const TELEMETRY_LABEL_OFFSET_PADDING_PX = 1; -const TELEMETRY_LOOP_LABEL_OFFSET = 10; -const TELEMETRY_INTERFACE_UP_BG_COLOR = EDGE_COLOR_UP; -const TELEMETRY_INTERFACE_DOWN_BG_COLOR = EDGE_COLOR_DOWN; -// Bezier curve constants for parallel edges -const CONTROL_POINT_STEP_SIZE = 40; // Spacing between parallel edges (more curvy for label space) - -// Loop edge constants -const LOOP_EDGE_SIZE = 50; // Size of the loop curve -const LOOP_EDGE_OFFSET = 10; // Offset between multiple loop edges - -// Node icon dimensions (edges connect to icon center, not the label) -const NODE_ICON_SIZE = 40; - -interface NodeGeometry { - position: { x: number; y: number }; - width: number; - height: number; -} - -interface EdgeDataLike { - sourceEndpoint?: string; - targetEndpoint?: string; - linkStatus?: string; - sourceInterfaceState?: string; - targetInterfaceState?: string; - endpointLabelOffsetEnabled?: boolean; - endpointLabelOffset?: number; -} - -type EdgeLabelVariant = "default" | "telemetry-style"; - -interface EdgeLabelOffsets { - source: number; - target: number; - loop: number; -} - -type InterfaceSide = "top" | "right" | "bottom" | "left"; - -interface InterfaceAnchor { - x: number; - y: number; -} - -interface EndpointVector { - dx: number; - dy: number; - samples: number; -} - -interface EndpointAssignment { - endpoint: string; - sortKey: number; - radius: number; -} - -type NodeInterfaceAnchorMap = Map>; - -interface TelemetryLabelRenderConfig { - nodeIconSize: number; - interfaceScale: number; - globalInterfaceOverrideSelection: string; - interfaceLabelOverrides: Record; -} - -const EMPTY_GRAPH_NODES: Node[] = []; -const HORIZONTAL_SLOPE_THRESHOLD = 0.25; - -function asObjectRecord(value: unknown): Record | null { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - return null; - } - return Object.fromEntries(Object.entries(value)); -} - -function getStringField(record: Record, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} - -function getBooleanField(record: Record, key: string): boolean | undefined { - const value = record[key]; - return typeof value === "boolean" ? value : undefined; -} - -function getNumberField(record: Record, key: string): number | undefined { - const value = record[key]; - return typeof value === "number" ? value : undefined; -} - -function toEdgeData(value: unknown): EdgeDataLike { - const record = asObjectRecord(value); - if (!record) return {}; - const extraDataRecord = asObjectRecord(record.extraData); - return { - sourceEndpoint: getStringField(record, "sourceEndpoint"), - targetEndpoint: getStringField(record, "targetEndpoint"), - linkStatus: getStringField(record, "linkStatus"), - sourceInterfaceState: extraDataRecord - ? getStringField(extraDataRecord, "clabSourceInterfaceState") - : undefined, - targetInterfaceState: extraDataRecord - ? getStringField(extraDataRecord, "clabTargetInterfaceState") - : undefined, - endpointLabelOffsetEnabled: getBooleanField(record, "endpointLabelOffsetEnabled"), - endpointLabelOffset: getNumberField(record, "endpointLabelOffset") - }; -} - -function normalizeInterfaceState(value: unknown): "up" | "down" | "unknown" | undefined { - if (typeof value !== "string") return undefined; - const normalized = value.trim().toLowerCase(); - if (normalized === "up") return "up"; - if (normalized === "down") return "down"; - if (normalized.length === 0 || normalized === "unknown") return "unknown"; - return "unknown"; -} - -function getTelemetryInterfaceBackgroundColor( - interfaceState: "up" | "down" | "unknown" | undefined, - colorByInterfaceState: boolean -): string { - if (!colorByInterfaceState) return TELEMETRY_LABEL_BG_COLOR; - if (interfaceState === "up") return TELEMETRY_INTERFACE_UP_BG_COLOR; - if (interfaceState === "down") return TELEMETRY_INTERFACE_DOWN_BG_COLOR; - return TELEMETRY_LABEL_BG_COLOR; -} - -function normalizeEndpoint(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function getNodeRect( - node: Node, - nodeIconSize: number -): { x: number; y: number; width: number; height: number } { - const measuredNodeWidth = typeof node.width === "number" ? node.width : nodeIconSize; - return { - x: node.position.x + (measuredNodeWidth - nodeIconSize) / 2, - y: node.position.y, - width: nodeIconSize, - height: nodeIconSize - }; -} - -function getRectCenter(rect: { x: number; y: number; width: number; height: number }): { - x: number; - y: number; -} { - return { - x: rect.x + rect.width / 2, - y: rect.y + rect.height / 2 - }; -} - -function getTelemetryLabelMetrics( - labelText: string, - interfaceScale = 1 -): { - text: string; - radius: number; - fontSize: number; - textStrokeWidth: number; -} { - const text = labelText.trim(); - const fontSize = TELEMETRY_LABEL_FONT_SIZE_PX * interfaceScale; - const textWidth = Math.max( - fontSize * 0.8, - text.length * fontSize * TELEMETRY_LABEL_CHAR_WIDTH_RATIO - ); - const radius = Math.max( - TELEMETRY_LABEL_MIN_RADIUS_PX * interfaceScale, - textWidth / 2 + TELEMETRY_LABEL_HORIZONTAL_PADDING_PX * interfaceScale - ); - return { - text, - radius, - fontSize, - textStrokeWidth: TELEMETRY_LABEL_TEXT_STROKE_WIDTH_PX * interfaceScale - }; -} - -function sideBuckets(): Record { - return { top: [], right: [], bottom: [], left: [] }; -} - -function classifyInterfaceSide(vector: EndpointVector | undefined): InterfaceSide { - if (!vector || vector.samples <= 0) return "bottom"; - - const dx = vector.dx / vector.samples; - const dy = vector.dy / vector.samples; - const absDx = Math.abs(dx); - const absDy = Math.abs(dy); - - if (absDx > 0.001 && absDy <= absDx * HORIZONTAL_SLOPE_THRESHOLD) { - return dx >= 0 ? "right" : "left"; - } - return dy >= 0 ? "bottom" : "top"; -} - -function getInterfaceSortKey(side: InterfaceSide, vector: EndpointVector | undefined): number { - if (!vector || vector.samples <= 0) return 0; - const avgDx = vector.dx / vector.samples; - const avgDy = vector.dy / vector.samples; - return side === "top" || side === "bottom" ? avgDx : avgDy; -} - -function positionInterfaceAnchor( - rect: { x: number; y: number; width: number; height: number }, - side: InterfaceSide, - index: number, - total: number, - radius: number -): InterfaceAnchor { - const slot = (index + 1) / (total + 1); - const out = radius + 1; - - switch (side) { - case "top": - return { x: rect.x + rect.width * slot, y: rect.y - out }; - case "right": - return { x: rect.x + rect.width + out, y: rect.y + rect.height * slot }; - case "bottom": - return { x: rect.x + rect.width * slot, y: rect.y + rect.height + out }; - case "left": - return { x: rect.x - out, y: rect.y + rect.height * slot }; - } -} - -function sortEndpointAssignments(assignments: EndpointAssignment[]): void { - assignments.sort((a, b) => { - const bySort = a.sortKey - b.sortKey; - if (bySort !== 0) return bySort; - return a.endpoint.localeCompare(b.endpoint); - }); -} - -function buildNodeSideAssignments( - endpoints: Set, - nodeVectors: Map | undefined, - telemetryConfig: TelemetryLabelRenderConfig -): Record { - const buckets = sideBuckets(); - for (const endpoint of endpoints) { - const vector = nodeVectors?.get(endpoint); - const side = classifyInterfaceSide(vector); - const sortKey = getInterfaceSortKey(side, vector); - const labelText = resolveTelemetryInterfaceLabel( - endpoint, - telemetryConfig.globalInterfaceOverrideSelection, - telemetryConfig.interfaceLabelOverrides - ); - const { radius } = getTelemetryLabelMetrics(labelText, telemetryConfig.interfaceScale); - buckets[side].push({ endpoint, sortKey, radius }); - } - return buckets; -} - -function assignNodeAnchors( - rect: { x: number; y: number; width: number; height: number }, - buckets: Record -): Map { - const endpointAnchors = new Map(); - for (const side of ["top", "right", "bottom", "left"] as const) { - const assignments = buckets[side]; - sortEndpointAssignments(assignments); - for (let i = 0; i < assignments.length; i++) { - const assignment = assignments[i]; - endpointAnchors.set( - assignment.endpoint, - positionInterfaceAnchor(rect, side, i, assignments.length, assignment.radius) - ); - } - } - return endpointAnchors; -} - -function getOrCreateEndpointSet( - endpointsByNode: Map>, - nodeId: string -): Set { - const existing = endpointsByNode.get(nodeId); - if (existing) return existing; - const created = new Set(); - endpointsByNode.set(nodeId, created); - return created; -} - -function getOrCreateNodeVectors( - vectorsByNode: Map>, - nodeId: string -): Map { - const existing = vectorsByNode.get(nodeId); - if (existing) return existing; - const created = new Map(); - vectorsByNode.set(nodeId, created); - return created; -} - -function addEndpointVector( - vectorsByNode: Map>, - nodeId: string, - endpoint: string, - dx: number, - dy: number -): void { - const nodeVectors = getOrCreateNodeVectors(vectorsByNode, nodeId); - const existing = nodeVectors.get(endpoint) ?? { dx: 0, dy: 0, samples: 0 }; - existing.dx += dx; - existing.dy += dy; - existing.samples += 1; - nodeVectors.set(endpoint, existing); -} - -function trackNodeEndpoint( - endpointsByNode: Map>, - nodeId: string, - endpoint: string | null -): void { - if (endpoint === null) return; - getOrCreateEndpointSet(endpointsByNode, nodeId).add(endpoint); -} - -function collectEdgeEndpointVectors( - edge: Edge, - sourceEndpoint: string | null, - targetEndpoint: string | null, - nodeMap: Map, - vectorsByNode: Map>, - nodeIconSize: number -): void { - if (sourceEndpoint === null && targetEndpoint === null) return; - if (edge.source === edge.target) return; - - const sourceNode = nodeMap.get(edge.source); - const targetNode = nodeMap.get(edge.target); - if (!sourceNode || !targetNode) return; - - const sourceCenter = getRectCenter(getNodeRect(sourceNode, nodeIconSize)); - const targetCenter = getRectCenter(getNodeRect(targetNode, nodeIconSize)); - const forwardDx = targetCenter.x - sourceCenter.x; - const forwardDy = targetCenter.y - sourceCenter.y; - - if (sourceEndpoint !== null) { - addEndpointVector(vectorsByNode, edge.source, sourceEndpoint, forwardDx, forwardDy); - } - if (targetEndpoint !== null) { - addEndpointVector(vectorsByNode, edge.target, targetEndpoint, -forwardDx, -forwardDy); - } -} - -function buildInterfaceAnchorMap( - edges: Edge[], - nodes: Node[], - telemetryConfig: TelemetryLabelRenderConfig -): NodeInterfaceAnchorMap { - const nodeMap = new Map(nodes.map((node) => [node.id, node])); - const endpointsByNode = new Map>(); - const vectorsByNode = new Map>(); - - for (const edge of edges) { - const edgeData = toEdgeData(edge.data); - const sourceEndpoint = normalizeEndpoint(edgeData.sourceEndpoint); - const targetEndpoint = normalizeEndpoint(edgeData.targetEndpoint); - trackNodeEndpoint(endpointsByNode, edge.source, sourceEndpoint); - trackNodeEndpoint(endpointsByNode, edge.target, targetEndpoint); - collectEdgeEndpointVectors( - edge, - sourceEndpoint, - targetEndpoint, - nodeMap, - vectorsByNode, - telemetryConfig.nodeIconSize - ); - } - - const anchorsByNode: NodeInterfaceAnchorMap = new Map(); - for (const [nodeId, endpoints] of endpointsByNode) { - const node = nodeMap.get(nodeId); - if (!node) continue; - const buckets = buildNodeSideAssignments(endpoints, vectorsByNode.get(nodeId), telemetryConfig); - const endpointAnchors = assignNodeAnchors( - getNodeRect(node, telemetryConfig.nodeIconSize), - buckets - ); - anchorsByNode.set(nodeId, endpointAnchors); - } - - return anchorsByNode; -} - -let interfaceAnchorMapCache: { - edgesRef: Edge[] | null; - nodesRef: Node[] | null; - nodeIconSize: number | null; - interfaceScale: number | null; - globalInterfaceOverrideSelection: string | null; - interfaceLabelOverridesRef: Record | null; - anchorsByNode: NodeInterfaceAnchorMap | null; -} = { - edgesRef: null, - nodesRef: null, - nodeIconSize: null, - interfaceScale: null, - globalInterfaceOverrideSelection: null, - interfaceLabelOverridesRef: null, - anchorsByNode: null -}; - -function getCachedInterfaceAnchorMap( - edges: Edge[], - nodes: Node[], - telemetryConfig: TelemetryLabelRenderConfig -): NodeInterfaceAnchorMap { - if ( - interfaceAnchorMapCache.edgesRef === edges && - interfaceAnchorMapCache.nodesRef === nodes && - interfaceAnchorMapCache.nodeIconSize === telemetryConfig.nodeIconSize && - interfaceAnchorMapCache.interfaceScale === telemetryConfig.interfaceScale && - interfaceAnchorMapCache.globalInterfaceOverrideSelection === - telemetryConfig.globalInterfaceOverrideSelection && - interfaceAnchorMapCache.interfaceLabelOverridesRef === - telemetryConfig.interfaceLabelOverrides && - interfaceAnchorMapCache.anchorsByNode - ) { - return interfaceAnchorMapCache.anchorsByNode; - } - - const anchorsByNode = buildInterfaceAnchorMap(edges, nodes, telemetryConfig); - interfaceAnchorMapCache = { - edgesRef: edges, - nodesRef: nodes, - nodeIconSize: telemetryConfig.nodeIconSize, - interfaceScale: telemetryConfig.interfaceScale, - globalInterfaceOverrideSelection: telemetryConfig.globalInterfaceOverrideSelection, - interfaceLabelOverridesRef: telemetryConfig.interfaceLabelOverrides, - anchorsByNode - }; - return anchorsByNode; -} - -function resolveEdgeLabelOffsets( - edgeData: EdgeDataLike | undefined, - labelMode: EdgeLabelMode, - interfaceScale = 1, - sourceLabel?: string, - targetLabel?: string -): EdgeLabelOffsets { - if (edgeData?.endpointLabelOffsetEnabled === false) { - return { source: 0, target: 0, loop: 0 }; - } - - if (labelMode === "telemetry-style") { - const hasSourceLabel = typeof sourceLabel === "string" && sourceLabel.length > 0; - const hasTargetLabel = typeof targetLabel === "string" && targetLabel.length > 0; - const sourceOffset = hasSourceLabel - ? getTelemetryLabelMetrics(sourceLabel, interfaceScale).radius + - TELEMETRY_LABEL_OFFSET_PADDING_PX * interfaceScale - : DEFAULT_ENDPOINT_LABEL_OFFSET; - const targetOffset = hasTargetLabel - ? getTelemetryLabelMetrics(targetLabel, interfaceScale).radius + - TELEMETRY_LABEL_OFFSET_PADDING_PX * interfaceScale - : DEFAULT_ENDPOINT_LABEL_OFFSET; - return { - source: sourceOffset, - target: targetOffset, - loop: TELEMETRY_LOOP_LABEL_OFFSET * interfaceScale - }; - } - - const defaultOffset = - typeof edgeData?.endpointLabelOffset === "number" - ? edgeData.endpointLabelOffset - : DEFAULT_ENDPOINT_LABEL_OFFSET; - const scaledDefaultOffset = defaultOffset * interfaceScale; - return { - source: scaledDefaultOffset, - target: scaledDefaultOffset, - loop: scaledDefaultOffset - }; -} - -function areNodeGeometriesEqual(left: NodeGeometry | null, right: NodeGeometry | null): boolean { - if (left === right) return true; - if (!left || !right) return false; - return ( - left.position.x === right.position.x && - left.position.y === right.position.y && - left.width === right.width && - left.height === right.height - ); -} - -function useNodeGeometry(nodeId: string, defaultNodeWidth: number): NodeGeometry | null { - return useStore( - useCallback( - (state) => { - const node = state.nodeLookup.get(nodeId); - if (!node) return null; - const position = node.internals.positionAbsolute; - return { - position: { x: position.x, y: position.y }, - width: node.measured.width ?? defaultNodeWidth, - height: node.measured.height ?? defaultNodeWidth - }; - }, - [nodeId, defaultNodeWidth] - ), - areNodeGeometriesEqual - ); -} - -/** - * Get stroke color based on link status - */ -function getStrokeColor(linkStatus: string | undefined, selected: boolean): string { - if (selected) return SELECTION_COLOR; - switch (linkStatus) { - case "up": - return EDGE_COLOR_UP; - case "down": - return EDGE_COLOR_DOWN; - default: - return EDGE_COLOR_DEFAULT; - } -} - -// Parallel edge info is now provided via CanvasContext - -/** - * Calculate loop edge geometry for self-referencing edges - * Creates a curved path that loops back to the same node - */ -interface LoopEdgeGeometry { - path: string; - sourceLabelPos: { x: number; y: number }; - targetLabelPos: { x: number; y: number }; -} - -function calculateLoopEdgeGeometry( - nodeX: number, - nodeY: number, - nodeWidth: number, - nodeHeight: number, - loopIndex: number, - labelOffset: number -): LoopEdgeGeometry { - // Calculate node center - const centerX = nodeX + nodeWidth / 2; - const centerY = nodeY + nodeHeight / 2; - - // Loop starts from top-right corner and returns to right side - // Size increases with each additional loop edge - const size = LOOP_EDGE_SIZE + loopIndex * LOOP_EDGE_OFFSET; - - // Start point: right edge of node, slightly up - const startX = centerX + nodeWidth / 2; - const startY = centerY - nodeHeight / 4; - - // End point: right edge of node, slightly down - const endX = centerX + nodeWidth / 2; - const endY = centerY + nodeHeight / 4; - - // Control points for cubic bezier - creates a loop to the right - const cp1X = startX + size; - const cp1Y = startY - size * 0.5; - const cp2X = endX + size; - const cp2Y = endY + size * 0.5; - - // Create cubic bezier path - const path = `M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${endX} ${endY}`; - - // Label positions - at the rightmost point of the loop - const labelX = centerX + nodeWidth / 2 + size * 0.8; - const labelY = centerY; - - return { - path, - sourceLabelPos: { x: labelX, y: labelY - labelOffset }, - targetLabelPos: { x: labelX, y: labelY + labelOffset } - }; -} - -// Loop edge info is now pre-computed in CanvasContext - -// Constant label style (extracted for performance - avoids object creation per render) -const LABEL_STYLE_BASE: React.CSSProperties = { - position: "absolute", - fontSize: LABEL_FONT_SIZE, - fontFamily: LABEL_FONT_FAMILY, - color: LABEL_TEXT_COLOR, - backgroundColor: LABEL_BG_COLOR, - padding: LABEL_PADDING, - borderRadius: 4, - pointerEvents: "none", - whiteSpace: "nowrap", - textShadow: `0 0 2px ${LABEL_OUTLINE_COLOR}, 0 0 2px ${LABEL_OUTLINE_COLOR}, 0 0 3px ${LABEL_OUTLINE_COLOR}`, - lineHeight: 1.2, - zIndex: 1 -}; - -const TELEMETRY_LABEL_STYLE_BASE: React.CSSProperties = { - position: "absolute", - display: "flex", - alignItems: "center", - justifyContent: "center", - fontFamily: TELEMETRY_LABEL_FONT_FAMILY, - fontWeight: 600, - color: TELEMETRY_LABEL_TEXT_COLOR, - backgroundColor: TELEMETRY_LABEL_BG_COLOR, - borderRadius: "999px", - border: `0.7px solid ${TELEMETRY_LABEL_BORDER_COLOR}`, - boxSizing: "border-box", - pointerEvents: "none", - whiteSpace: "nowrap", - lineHeight: 1, - zIndex: 1 -}; - -/** - * Label component for endpoint text - * Uses CSS transform for positioning (only dynamic part) - */ -const EndpointLabel = memo(function EndpointLabel({ - text, - x, - y, - variant, - interfaceState, - colorByInterfaceState, - telemetryInterfaceScale -}: Readonly<{ - text: string; - x: number; - y: number; - variant: EdgeLabelVariant; - interfaceState?: "up" | "down" | "unknown"; - colorByInterfaceState: boolean; - telemetryInterfaceScale: number; -}>) { - const telemetryMetrics = useMemo( - () => - variant === "telemetry-style" - ? getTelemetryLabelMetrics(text, telemetryInterfaceScale) - : null, - [text, variant, telemetryInterfaceScale] - ); - const telemetryBackgroundColor = useMemo( - () => getTelemetryInterfaceBackgroundColor(interfaceState, colorByInterfaceState), - [interfaceState, colorByInterfaceState] - ); - - const renderedText = telemetryMetrics?.text ?? text; - const style = useMemo((): React.CSSProperties => { - if (variant === "telemetry-style" && telemetryMetrics) { - const diameter = telemetryMetrics.radius * 2; - return { - ...TELEMETRY_LABEL_STYLE_BASE, - backgroundColor: telemetryBackgroundColor, - width: `${diameter}px`, - minWidth: `${diameter}px`, - height: `${diameter}px`, - fontSize: `${telemetryMetrics.fontSize}px`, - textShadow: `0 0 ${telemetryMetrics.textStrokeWidth}px ${TELEMETRY_LABEL_STROKE_COLOR}, 0 0 ${telemetryMetrics.textStrokeWidth}px ${TELEMETRY_LABEL_STROKE_COLOR}`, - transform: `translate(-50%, -50%) translate(${x}px, ${y}px)` - }; - } - const scaledFontSize = Math.max(8, TELEMETRY_LABEL_FONT_SIZE_PX * telemetryInterfaceScale); - const scaledHorizontalPadding = Math.max( - 2, - TELEMETRY_LABEL_HORIZONTAL_PADDING_PX * telemetryInterfaceScale - ); - return { - ...LABEL_STYLE_BASE, - fontSize: `${scaledFontSize}px`, - padding: `0px ${scaledHorizontalPadding}px`, - transform: `translate(-50%, -50%) translate(${x}px, ${y}px)` - }; - }, [variant, telemetryMetrics, telemetryBackgroundColor, telemetryInterfaceScale, x, y]); - - if (renderedText.length === 0) { - return null; - } - - return ( -
- {renderedText} -
- ); -}); - -/** Edge geometry result type */ -interface EdgeGeometry { - points: { sx: number; sy: number; tx: number; ty: number }; - path: string; - controlPoint: { x: number; y: number } | null; - sourceLabelPos: { x: number; y: number }; - targetLabelPos: { x: number; y: number }; -} - -/** Calculate loop edge geometry */ -function computeLoopGeometry( - sourcePos: { x: number; y: number }, - sourceNodeWidth: number, - loopIndex: number, - labelOffset: number, - nodeIconSize: number -): EdgeGeometry { - const loopGeometry = calculateLoopEdgeGeometry( - sourcePos.x + (sourceNodeWidth - nodeIconSize) / 2, - sourcePos.y, - nodeIconSize, - nodeIconSize, - loopIndex, - labelOffset - ); - return { - points: { sx: 0, sy: 0, tx: 0, ty: 0 }, - path: loopGeometry.path, - controlPoint: null, - sourceLabelPos: loopGeometry.sourceLabelPos, - targetLabelPos: loopGeometry.targetLabelPos - }; -} - -function resolveEdgePointsWithInterfaceAnchors( - sourceRect: { x: number; y: number; width: number; height: number }, - targetRect: { x: number; y: number; width: number; height: number }, - sourceAnchor?: InterfaceAnchor, - targetAnchor?: InterfaceAnchor -): { sx: number; sy: number; tx: number; ty: number } { - if (sourceAnchor && targetAnchor) { - return { sx: sourceAnchor.x, sy: sourceAnchor.y, tx: targetAnchor.x, ty: targetAnchor.y }; - } - - if (sourceAnchor) { - const targetCenter = getRectCenter(targetRect); - const targetPoint = getNodeIntersection( - targetCenter.x, - targetCenter.y, - targetRect.width, - targetRect.height, - sourceAnchor.x, - sourceAnchor.y - ); - return { sx: sourceAnchor.x, sy: sourceAnchor.y, tx: targetPoint.x, ty: targetPoint.y }; - } - - if (targetAnchor) { - const sourceCenter = getRectCenter(sourceRect); - const sourcePoint = getNodeIntersection( - sourceCenter.x, - sourceCenter.y, - sourceRect.width, - sourceRect.height, - targetAnchor.x, - targetAnchor.y - ); - return { sx: sourcePoint.x, sy: sourcePoint.y, tx: targetAnchor.x, ty: targetAnchor.y }; - } - - return getEdgePoints(sourceRect, targetRect); -} - -function getRegularEdgeLabelPositions(params: { - points: { sx: number; sy: number; tx: number; ty: number }; - controlPoint: { x: number; y: number } | null; - labelOffsets: Pick; - nodeProximateLabels: boolean; - sourceAnchor?: InterfaceAnchor; - targetAnchor?: InterfaceAnchor; -}): { sourceLabelPos: { x: number; y: number }; targetLabelPos: { x: number; y: number } } { - const { points, controlPoint, labelOffsets, nodeProximateLabels, sourceAnchor, targetAnchor } = - params; - if (nodeProximateLabels && sourceAnchor && targetAnchor) { - return { - sourceLabelPos: sourceAnchor, - targetLabelPos: targetAnchor - }; - } - - return { - sourceLabelPos: getLabelPosition( - points.sx, - points.sy, - points.tx, - points.ty, - labelOffsets.source, - controlPoint ?? undefined - ), - targetLabelPos: getLabelPosition( - points.tx, - points.ty, - points.sx, - points.sy, - labelOffsets.target, - controlPoint ?? undefined - ) - }; -} - -/** Calculate regular edge geometry with parallel edge support */ -function computeRegularGeometry( - sourcePos: { x: number; y: number }, - targetPos: { x: number; y: number }, - sourceNodeWidth: number, - targetNodeWidth: number, - nodeIconSize: number, - parallelInfo: { index: number; total: number; isCanonicalDirection: boolean } | null, - labelOffsets: Pick, - sourceAnchor?: InterfaceAnchor, - targetAnchor?: InterfaceAnchor, - nodeProximateLabels = false -): EdgeGeometry { - const sourceRect = { - x: sourcePos.x + (sourceNodeWidth - nodeIconSize) / 2, - y: sourcePos.y, - width: nodeIconSize, - height: nodeIconSize - }; - const targetRect = { - x: targetPos.x + (targetNodeWidth - nodeIconSize) / 2, - y: targetPos.y, - width: nodeIconSize, - height: nodeIconSize - }; - - const points = resolveEdgePointsWithInterfaceAnchors( - sourceRect, - targetRect, - sourceAnchor, - targetAnchor - ); - - const index = parallelInfo?.index ?? 0; - const total = parallelInfo?.total ?? 1; - const isCanonicalDirection = parallelInfo?.isCanonicalDirection ?? true; - - const controlPoint = calculateControlPoint( - points.sx, - points.sy, - points.tx, - points.ty, - index, - total, - isCanonicalDirection, - CONTROL_POINT_STEP_SIZE - ); - - const path = controlPoint - ? `M ${points.sx} ${points.sy} Q ${controlPoint.x} ${controlPoint.y} ${points.tx} ${points.ty}` - : `M ${points.sx} ${points.sy} L ${points.tx} ${points.ty}`; - const { sourceLabelPos, targetLabelPos } = getRegularEdgeLabelPositions({ - points, - controlPoint, - labelOffsets, - nodeProximateLabels, - sourceAnchor, - targetAnchor - }); - - return { - points, - path, - controlPoint, - sourceLabelPos, - targetLabelPos - }; -} - -/** Hook for calculating edge geometry with bezier curves for parallel edges */ -function useEdgeGeometry( - edgeId: string, - source: string, - target: string, - labelOffsets: EdgeLabelOffsets, - edgeData: EdgeDataLike | undefined, - labelMode: EdgeLabelMode, - telemetryConfig: TelemetryLabelRenderConfig | null -) { - const nodeIconSize = telemetryConfig?.nodeIconSize ?? NODE_ICON_SIZE; - const sourceNode = useNodeGeometry(source, nodeIconSize); - const targetNode = useNodeGeometry(target, nodeIconSize); - const edges = useEdges(); - const nodeListForAnchors = useGraphStore( - useCallback( - (state) => (labelMode === "telemetry-style" ? state.nodes : EMPTY_GRAPH_NODES), - [labelMode] - ) - ); - const { getParallelInfo, getLoopInfo } = useEdgeInfo(edges); - const interfaceAnchorMap = useMemo( - () => - labelMode === "telemetry-style" && telemetryConfig - ? getCachedInterfaceAnchorMap(edges, nodeListForAnchors, telemetryConfig) - : undefined, - [labelMode, edges, nodeListForAnchors, telemetryConfig] - ); - - const parallelInfo = getParallelInfo(edgeId); - const loopInfo = getLoopInfo(edgeId); - - return useMemo((): EdgeGeometry | null => { - if (!sourceNode) return null; - - const sourcePos = sourceNode.position; - const sourceNodeWidth = sourceNode.width; - - // Handle loop edges (source === target) - if (source === target && loopInfo) { - return computeLoopGeometry( - sourcePos, - sourceNodeWidth, - loopInfo.loopIndex, - labelOffsets.loop, - nodeIconSize - ); - } - - if (!targetNode) return null; - - const targetPos = targetNode.position; - const targetNodeWidth = targetNode.width; - const sourceEndpoint = normalizeEndpoint(edgeData?.sourceEndpoint); - const targetEndpoint = normalizeEndpoint(edgeData?.targetEndpoint); - const sourceAnchor = - sourceEndpoint !== null ? interfaceAnchorMap?.get(source)?.get(sourceEndpoint) : undefined; - const targetAnchor = - targetEndpoint !== null ? interfaceAnchorMap?.get(target)?.get(targetEndpoint) : undefined; - - return computeRegularGeometry( - sourcePos, - targetPos, - sourceNodeWidth, - targetNodeWidth, - nodeIconSize, - parallelInfo, - { source: labelOffsets.source, target: labelOffsets.target }, - sourceAnchor, - targetAnchor, - labelMode === "telemetry-style" - ); - }, [ - sourceNode, - targetNode, - parallelInfo, - loopInfo, - source, - target, - labelOffsets.source, - labelOffsets.target, - labelOffsets.loop, - edgeData?.sourceEndpoint, - edgeData?.targetEndpoint, - interfaceAnchorMap, - labelMode, - nodeIconSize - ]); -} - -/** Get stroke styling based on selection and link status */ -function getStrokeStyle( - linkStatus: string | undefined, - selected: boolean, - useLinkStatusColor = true -) { - const resolvedLinkStatus = useLinkStatusColor ? linkStatus : undefined; - return { - color: getStrokeColor(resolvedLinkStatus, selected), - width: selected ? EDGE_WIDTH_SELECTED : EDGE_WIDTH_NORMAL, - opacity: selected ? EDGE_OPACITY_SELECTED : EDGE_OPACITY_NORMAL - }; -} - -function shouldRenderEdgeLabels( - labelMode: EdgeLabelMode, - suppressLabels: boolean, - selected: boolean -): boolean { - if (suppressLabels) return false; - if (labelMode === "hide") return false; - if (labelMode === "on-select") return selected; - return true; -} - -/** - * TopologyEdge - Floating edge that connects nodes between source and target nodes - * Supports bezier curves for parallel edges between the same node pair - */ -const TopologyEdgeComponent: React.FC = ({ id, source, target, data, selected }) => { - const mode = useMode(); - const telemetryLabelSettings = useTelemetryLabelSettings(); - const edgeData = useMemo(() => toEdgeData(data), [data]); - const { labelMode, suppressLabels, suppressHitArea } = useEdgeRenderConfig(); - const telemetryConfig = useMemo( - () => ({ - nodeIconSize: clampTelemetryNodeSizePx(telemetryLabelSettings.nodeSizePx), - interfaceScale: - clampTelemetryInterfaceSizePercent(telemetryLabelSettings.interfaceSizePercent) / 100, - globalInterfaceOverrideSelection: telemetryLabelSettings.globalInterfaceOverrideSelection, - interfaceLabelOverrides: telemetryLabelSettings.interfaceLabelOverrides - }), - [ - telemetryLabelSettings.nodeSizePx, - telemetryLabelSettings.interfaceSizePercent, - telemetryLabelSettings.globalInterfaceOverrideSelection, - telemetryLabelSettings.interfaceLabelOverrides - ] - ); - const sourceEndpoint = normalizeEndpoint(edgeData.sourceEndpoint); - const targetEndpoint = normalizeEndpoint(edgeData.targetEndpoint); - const sourceRenderedLabel = useMemo(() => { - if (sourceEndpoint === null) return null; - if (labelMode !== "telemetry-style") return sourceEndpoint; - return resolveTelemetryInterfaceLabel( - sourceEndpoint, - telemetryConfig.globalInterfaceOverrideSelection, - telemetryConfig.interfaceLabelOverrides - ); - }, [ - sourceEndpoint, - labelMode, - telemetryConfig.globalInterfaceOverrideSelection, - telemetryConfig.interfaceLabelOverrides - ]); - const targetRenderedLabel = useMemo(() => { - if (targetEndpoint === null) return null; - if (labelMode !== "telemetry-style") return targetEndpoint; - return resolveTelemetryInterfaceLabel( - targetEndpoint, - telemetryConfig.globalInterfaceOverrideSelection, - telemetryConfig.interfaceLabelOverrides - ); - }, [ - targetEndpoint, - labelMode, - telemetryConfig.globalInterfaceOverrideSelection, - telemetryConfig.interfaceLabelOverrides - ]); - const labelOffsets = useMemo( - () => - resolveEdgeLabelOffsets( - edgeData, - labelMode, - telemetryConfig.interfaceScale, - sourceRenderedLabel ?? undefined, - targetRenderedLabel ?? undefined - ), - [edgeData, labelMode, telemetryConfig.interfaceScale, sourceRenderedLabel, targetRenderedLabel] - ); - const geometry = useEdgeGeometry( - id, - source, - target, - labelOffsets, - edgeData, - labelMode, - telemetryConfig - ); - - if (!geometry) return null; - const shouldRenderLabels = shouldRenderEdgeLabels(labelMode, suppressLabels, selected === true); - const labelVariant: EdgeLabelVariant = - labelMode === "telemetry-style" ? "telemetry-style" : "default"; - const colorInterfacesByState = labelMode === "telemetry-style" && mode === "view"; - - const stroke = getStrokeStyle(edgeData.linkStatus, selected === true, !colorInterfacesByState); - const sourceInterfaceState = normalizeInterfaceState(edgeData.sourceInterfaceState); - const targetInterfaceState = normalizeInterfaceState(edgeData.targetInterfaceState); - - return ( - <> - {!suppressHitArea && ( - - )} - - {shouldRenderLabels && ( - - {sourceRenderedLabel !== null && sourceRenderedLabel.length > 0 && ( - - )} - {targetRenderedLabel !== null && targetRenderedLabel.length > 0 && ( - - )} - - )} - - ); -}; - -function areTopologyEdgePropsEqual(prev: EdgeProps, next: EdgeProps): boolean { - return ( - prev.id === next.id && - prev.source === next.source && - prev.target === next.target && - prev.selected === next.selected && - prev.data === next.data - ); -} - -export const TopologyEdge = memo(TopologyEdgeComponent, areTopologyEdgePropsEqual); diff --git a/src/reactTopoViewer/webview/components/canvas/edges/TopologyEdgeLite.tsx b/src/reactTopoViewer/webview/components/canvas/edges/TopologyEdgeLite.tsx deleted file mode 100644 index 525e83860..000000000 --- a/src/reactTopoViewer/webview/components/canvas/edges/TopologyEdgeLite.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/** - * TopologyEdgeLite - Lightweight edge renderer for large/zoomed-out graphs - */ -import React, { memo } from "react"; -import { getStraightPath, type EdgeProps } from "@xyflow/react"; - -import { SELECTION_COLOR } from "../types"; - -const EDGE_COLOR_DEFAULT = "#969799"; -const EDGE_COLOR_UP = "#00df2b"; -const EDGE_COLOR_DOWN = "#df2b00"; -const EDGE_WIDTH_NORMAL = 3.5; -const EDGE_WIDTH_SELECTED = 4.5; -const EDGE_OPACITY_NORMAL = 0.4; -const EDGE_OPACITY_SELECTED = 0.9; - -function getLinkStatus(value: unknown): string | undefined { - if (typeof value !== "object" || value === null) return undefined; - const linkStatus: unknown = Reflect.get(value, "linkStatus"); - if (linkStatus === "up" || linkStatus === "down" || linkStatus === "unknown") { - return linkStatus; - } - return undefined; -} - -function getStrokeColor(linkStatus: string | undefined, selected: boolean): string { - if (selected) return SELECTION_COLOR; - switch (linkStatus) { - case "up": - return EDGE_COLOR_UP; - case "down": - return EDGE_COLOR_DOWN; - default: - return EDGE_COLOR_DEFAULT; - } -} - -const TopologyEdgeLiteComponent: React.FC = ({ - id, - sourceX, - sourceY, - targetX, - targetY, - selected, - data -}) => { - const isSelected = selected === true; - const linkStatus = getLinkStatus(data); - const [path] = getStraightPath({ sourceX, sourceY, targetX, targetY }); - const stroke = getStrokeColor(linkStatus, isSelected); - - return ( - - ); -}; - -function areTopologyEdgeLitePropsEqual(prev: EdgeProps, next: EdgeProps): boolean { - return ( - prev.selected === next.selected && - prev.data === next.data && - prev.sourceX === next.sourceX && - prev.sourceY === next.sourceY && - prev.targetX === next.targetX && - prev.targetY === next.targetY - ); -} - -export const TopologyEdgeLite = memo(TopologyEdgeLiteComponent, areTopologyEdgeLitePropsEqual); diff --git a/src/reactTopoViewer/webview/components/canvas/edges/edgeTypes.ts b/src/reactTopoViewer/webview/components/canvas/edges/edgeTypes.ts deleted file mode 100644 index f0c60484e..000000000 --- a/src/reactTopoViewer/webview/components/canvas/edges/edgeTypes.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Edge type registry for React Flow - */ -import type { EdgeTypes } from "@xyflow/react"; - -import { TopologyEdge } from "./TopologyEdge"; -import { TopologyEdgeLite } from "./TopologyEdgeLite"; - -/** - * Registry of all custom edge types for React Flow - */ -export const edgeTypes: EdgeTypes = { - "topology-edge": TopologyEdge -}; - -/** - * Lightweight edge registry for large/zoomed-out graphs. - */ -export const edgeTypesLite: EdgeTypes = { - "topology-edge": TopologyEdgeLite -}; diff --git a/src/reactTopoViewer/webview/components/canvas/edges/index.ts b/src/reactTopoViewer/webview/components/canvas/edges/index.ts deleted file mode 100644 index 24ca2d7d8..000000000 --- a/src/reactTopoViewer/webview/components/canvas/edges/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Edge components barrel export - */ -export { TopologyEdge } from "./TopologyEdge"; -export { TopologyEdgeLite } from "./TopologyEdgeLite"; -export { edgeTypes, edgeTypesLite } from "./edgeTypes"; diff --git a/src/reactTopoViewer/webview/components/canvas/index.ts b/src/reactTopoViewer/webview/components/canvas/index.ts deleted file mode 100644 index 7002eb0b3..000000000 --- a/src/reactTopoViewer/webview/components/canvas/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -// React Flow Canvas barrel export. -export { ReactFlowCanvas } from "./ReactFlowCanvas"; - -// Types -// Note: Groups are rendered via GroupLayer, not as React Flow nodes -export type { - ReactFlowCanvasRef, - ReactFlowCanvasProps, - TopologyNodeData, - NetworkNodeData, - FreeTextNodeData, - FreeShapeNodeData, - TopologyEdgeData, - RFNodeData, - RFNodeType, - TopologyRFNode, - NetworkRFNode, - FreeTextRFNode, - FreeShapeRFNode, - TopologyRFEdge, - AnnotationModeState, - AnnotationHandlers, - MovePositionEntry -} from "./types"; -export { SELECTION_COLOR, DEFAULT_ICON_COLOR, ROLE_SVG_MAP } from "./types"; - -// Layout utilities -export type { LayoutName, LayoutOptions } from "./layout"; -export { hasPresetPositions, applyForceLayout, applyLayout, getLayoutOptions } from "./layout"; - -// Node and edge types -export { nodeTypes } from "./nodes"; -export { edgeTypes } from "./edges"; diff --git a/src/reactTopoViewer/webview/components/canvas/layout.ts b/src/reactTopoViewer/webview/components/canvas/layout.ts deleted file mode 100644 index 7b7e16edb..000000000 --- a/src/reactTopoViewer/webview/components/canvas/layout.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Layout algorithms for React Flow topology viewer - */ -import type { Node, Edge } from "@xyflow/react"; -import { - forceSimulation, - forceLink, - forceManyBody, - forceCenter, - forceCollide, - type SimulationNodeDatum, - type SimulationLinkDatum -} from "d3-force"; - -/** - * Node types that participate in layout algorithms - */ -const LAYOUTABLE_NODE_TYPES = ["topology-node", "network-node"]; - -/** - * Check if a node should be included in layout - */ -function isLayoutableNode(node: Node): boolean { - return LAYOUTABLE_NODE_TYPES.includes(node.type ?? ""); -} - -function applyPositionMap(nodes: Node[], positions: Map): Node[] { - if (positions.size === 0) return nodes; - return nodes.map((node) => { - const newPos = positions.get(node.id); - if (!newPos) return node; - return { - ...node, - position: newPos - }; - }); -} - -/** - * Available layout types - */ -export type LayoutName = "preset" | "force"; - -/** - * Layout options - */ -export interface LayoutOptions { - animate?: boolean; - padding?: number; - nodeSpacing?: number; -} - -/** - * D3 simulation node extending SimulationNodeDatum - */ -interface SimNode extends SimulationNodeDatum { - id: string; - x: number; - y: number; - width?: number; - height?: number; -} - -/** - * D3 simulation link - */ -interface SimLink extends SimulationLinkDatum { - source: string | SimNode; - target: string | SimNode; -} - -/** - * Check if layoutable nodes have preset positions (non-zero coordinates) - */ -export function hasPresetPositions(nodes: Node[]): boolean { - const layoutNodes = nodes.filter(isLayoutableNode); - if (layoutNodes.length === 0) return false; - return layoutNodes.some((node) => node.position.x !== 0 || node.position.y !== 0); -} - -/** - * Apply force-directed layout using d3-force - */ -export function applyForceLayout( - nodes: Node[], - edges: Edge[], - options: LayoutOptions = {} -): Node[] { - const { padding = 50, nodeSpacing = 100 } = options; - - if (nodes.length === 0) return nodes; - - // Filter out annotation nodes (groups, free text, free shapes) - const layoutNodes = nodes.filter(isLayoutableNode); - - if (layoutNodes.length === 0) return nodes; - - // Create simulation nodes with deterministic initial positions - const simNodes: SimNode[] = layoutNodes.map((node, index) => ({ - id: node.id, - x: node.position.x || (index * 50) % 500, - y: node.position.y || Math.floor(index / 10) * 50, - width: 50, - height: 50 - })); - - // Create simulation links - const nodeIds = new Set(simNodes.map((n) => n.id)); - const simLinks: SimLink[] = edges - .filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target)) - .map((edge) => ({ - source: edge.source, - target: edge.target - })); - - // Calculate center - const centerX = 400; - const centerY = 300; - - // Create force simulation - const simulation = forceSimulation(simNodes) - .force( - "link", - forceLink(simLinks) - .id((d) => d.id) - .distance(nodeSpacing * 1.5) - .strength(0.5) - ) - .force( - "charge", - forceManyBody() - .strength(-300) - .distanceMax(nodeSpacing * 5) - ) - .force("center", forceCenter(centerX, centerY)) - .force( - "collision", - forceCollide() - .radius(nodeSpacing / 2) - .strength(0.7) - ) - .stop(); - - // Run simulation synchronously - const iterations = 300; - for (let i = 0; i < iterations; i++) { - simulation.tick(); - } - - // Create node map for position updates - const nodePositions = new Map(); - for (const simNode of simNodes) { - nodePositions.set(simNode.id, { - x: simNode.x + padding, - y: simNode.y + padding - }); - } - - // Return nodes with updated positions - return applyPositionMap(nodes, nodePositions); -} - -/** - * Apply layout to nodes - */ -export function applyLayout( - layoutName: LayoutName, - nodes: Node[], - edges: Edge[], - options: LayoutOptions = {} -): Node[] { - switch (layoutName) { - case "force": - return applyForceLayout(nodes, edges, options); - case "preset": - default: - // Preset layout uses existing positions - return nodes; - } -} - -/** - * Get layout options for a given layout name - * (For compatibility with existing code) - */ -export function getLayoutOptions(layoutName: string): { name: string } { - return { name: layoutName }; -} diff --git a/src/reactTopoViewer/webview/components/canvas/nodes/AnnotationHandles.tsx b/src/reactTopoViewer/webview/components/canvas/nodes/AnnotationHandles.tsx deleted file mode 100644 index 2b473c524..000000000 --- a/src/reactTopoViewer/webview/components/canvas/nodes/AnnotationHandles.tsx +++ /dev/null @@ -1,420 +0,0 @@ -// Annotation handles for rotation and line resize. -import React, { useCallback, useRef, useState, useEffect } from "react"; -import { useReactFlow, useUpdateNodeInternals } from "@xyflow/react"; - -import { SELECTION_COLOR } from "../types"; - -// ============================================================================ -// Global Line Handle Drag State -// ============================================================================ - -/** - * Global state to track which line handle is being dragged. - * Stored at module level to survive component remounts during drag. - */ -interface LineHandleDragState { - nodeId: string; - mode: "start" | "end"; - startClientX: number; - startClientY: number; - startHandleX: number; - startHandleY: number; - zoom: number; -} - -let activeLineDrag: LineHandleDragState | null = null; - -/** Check if a line handle drag is in progress */ -export function isLineHandleActive(): boolean { - return activeLineDrag !== null; -} - -/** Get active drag state for a specific handle */ -function getActiveLineDrag(nodeId: string, mode: "start" | "end"): LineHandleDragState | null { - if (activeLineDrag && activeLineDrag.nodeId === nodeId && activeLineDrag.mode === mode) { - return activeLineDrag; - } - return null; -} - -/** Set the line handle drag state */ -function setActiveLineDrag(state: LineHandleDragState | null): void { - activeLineDrag = state; -} - -// ============================================================================ -// Constants -// ============================================================================ - -const HANDLE_SIZE = 8; -const ROTATION_HANDLE_OFFSET = 24; -const HANDLE_BOX_SHADOW = "0 2px 4px rgba(0,0,0,0.3)"; -const CENTER_TRANSFORM = "translate(-50%, -50%)"; - -/** Custom rotation cursor (SVG data URL) - white with black outline for visibility */ -const ROTATE_CURSOR = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8' stroke='%23000' stroke-width='3'/%3E%3Cpath d='M21 3v5h-5' stroke='%23000' stroke-width='3'/%3E%3Cpath d='M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8' stroke='%23fff' stroke-width='2'/%3E%3Cpath d='M21 3v5h-5' stroke='%23fff' stroke-width='2'/%3E%3C/svg%3E") 12 12, crosshair`; -const ROTATE_CURSOR_ACTIVE = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8' stroke='%23000' stroke-width='3'/%3E%3Cpath d='M21 3v5h-5' stroke='%23000' stroke-width='3'/%3E%3Cpath d='M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8' stroke='%2300bfff' stroke-width='2'/%3E%3Cpath d='M21 3v5h-5' stroke='%2300bfff' stroke-width='2'/%3E%3C/svg%3E") 12 12, crosshair`; - -// ============================================================================ -// Rotation Handle -// ============================================================================ - -interface RotationHandleProps { - readonly nodeId: string; - readonly currentRotation: number; - /** Called during rotation with live rotation value */ - readonly onRotationChange: (id: string, rotation: number) => void; - /** Called when rotation starts (for undo/redo snapshot capture) */ - readonly onRotationStart?: () => void; - /** Called when rotation ends (for undo/redo commit) */ - readonly onRotationEnd?: () => void; -} - -/** Calculate angle from center to mouse position */ -function calculateAngle(centerX: number, centerY: number, mouseX: number, mouseY: number): number { - const deltaX = mouseX - centerX; - const deltaY = mouseY - centerY; - return Math.atan2(deltaY, deltaX) * (180 / Math.PI); -} - -/** Normalize rotation to 0-360 range */ -function normalizeRotation(rotation: number): number { - return ((rotation % 360) + 360) % 360; -} - -/** - * Returns a callback to sync node internals with React Flow. - * Should be called AFTER rotation completes (on mouseup), not during drag, - * to avoid interrupting the rotation interaction. - */ -function useRotationInternalsSync(nodeId: string): () => void { - const updateNodeInternals = useUpdateNodeInternals(); - - return useCallback(() => { - // Refresh node internals so the selection box and resize handles keep in - // sync with the rotated element, matching React Flow's rotatable example. - updateNodeInternals(nodeId); - }, [nodeId, updateNodeInternals]); -} - -export const RotationHandle: React.FC = ({ - nodeId, - currentRotation, - onRotationChange, - onRotationStart, - onRotationEnd -}) => { - const [isRotating, setIsRotating] = useState(false); - const lastEmittedRotationRef = useRef(currentRotation); - const dragStartRef = useRef<{ - startAngle: number; - centerX: number; - centerY: number; - startRotation: number; - } | null>(null); - const handleRef = useRef(null); - - // Get callback to sync node internals - only called after rotation completes - const syncNodeInternals = useRotationInternalsSync(nodeId); - - useEffect(() => { - if (!isRotating) { - lastEmittedRotationRef.current = currentRotation; - } - }, [currentRotation, isRotating]); - - useEffect(() => { - if (!isRotating) return; - - const handleMouseMove = (e: MouseEvent) => { - if (!dragStartRef.current) return; - const { centerX, centerY, startAngle, startRotation } = dragStartRef.current; - const currentAngle = calculateAngle(centerX, centerY, e.clientX, e.clientY); - const angleDelta = currentAngle - startAngle; - let newRotation = normalizeRotation(startRotation + angleDelta); - - // Snap to 15-degree increments if shift is held - if (e.shiftKey) { - newRotation = Math.round(newRotation / 15) * 15; - } - - const roundedRotation = Math.round(newRotation); - if (roundedRotation === lastEmittedRotationRef.current) return; - lastEmittedRotationRef.current = roundedRotation; - onRotationChange(nodeId, roundedRotation); - }; - - const handleMouseUp = () => { - setIsRotating(false); - dragStartRef.current = null; - // Sync node internals AFTER rotation completes to update selection box - syncNodeInternals(); - // Notify parent that rotation ended (for undo/redo commit) - onRotationEnd?.(); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [isRotating, nodeId, onRotationChange, onRotationEnd, syncNodeInternals]); - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); - - const handle = handleRef.current; - if (!handle) return; - - // Calculate the rotation center from the parent node container. - // This keeps rotation stable regardless of node size or handle offset. - const parent = handle.parentElement; - if (!parent) return; - const parentRect = parent.getBoundingClientRect(); - const centerX = parentRect.left + parentRect.width / 2; - const centerY = parentRect.top + parentRect.height / 2; - - const startAngle = calculateAngle(centerX, centerY, e.clientX, e.clientY); - - setIsRotating(true); - lastEmittedRotationRef.current = currentRotation; - // Notify parent that rotation started (for undo/redo snapshot and keeping handles visible) - onRotationStart?.(); - dragStartRef.current = { - startAngle, - centerX, - centerY, - startRotation: currentRotation - }; - }, - [currentRotation, onRotationStart] - ); - - const handleClick = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - }, []); - - const handlePointerDown = useCallback((e: React.PointerEvent) => { - e.stopPropagation(); - }, []); - - return ( - <> - {/* Connecting line */} -
- {/* Rotation handle */} - - - - - setPendingCandidates(null)} - onConfirm={() => void handleConfirmCreate()} - /> - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/FindNodePopover.tsx b/src/reactTopoViewer/webview/components/panels/FindNodePopover.tsx deleted file mode 100644 index 86bded227..000000000 --- a/src/reactTopoViewer/webview/components/panels/FindNodePopover.tsx +++ /dev/null @@ -1,36 +0,0 @@ -// Find node popover. -import React from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; -import Box from "@mui/material/Box"; -import Popover from "@mui/material/Popover"; - -import { FindNodeSearchWidget } from "./find-node/FindNodeSearchWidget"; - -interface FindNodePopoverProps { - anchorPosition: { top: number; left: number } | null; - onClose: () => void; - rfInstance: ReactFlowInstance | null; -} - -export const FindNodePopover: React.FC = ({ - anchorPosition, - onClose, - rfInstance -}) => { - const open = Boolean(anchorPosition); - - return ( - - - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/GridSettingsPopover.tsx b/src/reactTopoViewer/webview/components/panels/GridSettingsPopover.tsx deleted file mode 100644 index 637fa9b97..000000000 --- a/src/reactTopoViewer/webview/components/panels/GridSettingsPopover.tsx +++ /dev/null @@ -1,160 +0,0 @@ -// Grid settings popover. -import React, { useCallback, useEffect, useState } from "react"; -import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; -import Divider from "@mui/material/Divider"; -import Typography from "@mui/material/Typography"; -import Slider from "@mui/material/Slider"; -import ToggleButton from "@mui/material/ToggleButton"; -import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; -import Popover from "@mui/material/Popover"; -import RestartAltIcon from "@mui/icons-material/RestartAlt"; - -import type { GridStyle } from "../../hooks/ui"; -import { ColorField } from "../ui/form/ColorField"; -import { invertHexColor, resolveComputedColor } from "../../utils/color"; - -export interface GridSettingsControlsProps { - gridLineWidth: number; - onGridLineWidthChange: (width: number) => void; - gridStyle: GridStyle; - onGridStyleChange: (style: GridStyle) => void; - gridColor: string | null; - onGridColorChange: (color: string | null) => void; - gridBgColor: string | null; - onGridBgColorChange: (color: string | null) => void; - onResetGridColors: () => void; -} - -interface GridSettingsPopoverProps extends GridSettingsControlsProps { - anchorPosition: { top: number; left: number } | null; - onClose: () => void; -} - -export const GridSettingsPopover: React.FC = ({ - anchorPosition, - onClose, - gridLineWidth, - onGridLineWidthChange, - gridStyle, - onGridStyleChange, - gridColor, - onGridColorChange, - gridBgColor, - onGridBgColorChange, - onResetGridColors -}) => { - const open = Boolean(anchorPosition); - const isGridStyle = (value: unknown): value is GridStyle => - value === "dotted" || value === "quadratic"; - - const [themeBgColor, setThemeBgColor] = useState("#1e1e1e"); - - useEffect(() => { - if (open) { - setThemeBgColor(resolveComputedColor("--vscode-editor-background", "#1e1e1e")); - } - }, [open]); - - const effectiveBg = gridBgColor ?? themeBgColor; - const defaultGridColor = invertHexColor(effectiveBg); - - const hasCustomColors = gridColor !== null || gridBgColor !== null; - - const handleGridColorChange = useCallback( - (value: string) => onGridColorChange(value), - [onGridColorChange] - ); - - const handleBgColorChange = useCallback( - (value: string) => onGridBgColorChange(value), - [onGridBgColorChange] - ); - - return ( - - - - Grid Settings - - - - - Stroke Width - - onGridLineWidthChange(value)} - min={0.00001} - max={2} - step={0.1} - valueLabelDisplay="auto" - /> - - - - - - - Grid Style - - { - if (isGridStyle(value)) onGridStyleChange(value); - }} - size="small" - fullWidth - > - Dotted - Quadratic - - - - - - - - Grid Color - - - - - - - - - Background Color - - - - - {hasCustomColors && ( - <> - - - - - - )} - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/LifecycleProgressModal.tsx b/src/reactTopoViewer/webview/components/panels/LifecycleProgressModal.tsx deleted file mode 100644 index 35c35a9cf..000000000 --- a/src/reactTopoViewer/webview/components/panels/LifecycleProgressModal.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import React from "react"; -import { - Autorenew as AutorenewIcon, - CheckCircleOutline as CheckCircleOutlineIcon, - ErrorOutline as ErrorOutlineIcon -} from "@mui/icons-material"; -import { - Box, - Button, - Chip, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - LinearProgress, - Typography -} from "@mui/material"; - -import type { - LifecycleLogEntry, - LifecycleStatus, - ProcessingMode -} from "../../stores/topoViewerStore"; -import { calculateElapsedSeconds, formatElapsedSeconds } from "../../utils/lifecycleTimer"; - -interface LifecycleProgressModalProps { - isOpen: boolean; - isProcessing: boolean; - mode: ProcessingMode; - status: LifecycleStatus; - statusMessage?: string | null; - labName: string; - logs: LifecycleLogEntry[]; - onClose: () => void; - onCancel: () => void; -} - -function getModeLabel(mode: ProcessingMode): string { - if (mode === "destroy") { - return "Destroying"; - } - return "Deploying"; -} - -function getStatusLabel(status: LifecycleStatus): string { - if (status === "success") { - return "Completed"; - } - if (status === "error") { - return "Failed"; - } - return "In Progress"; -} - -function getStatusColor(status: LifecycleStatus): "primary" | "success" | "error" { - if (status === "success") { - return "success"; - } - if (status === "error") { - return "error"; - } - return "primary"; -} - -function renderStatusIcon(status: LifecycleStatus, isProcessing: boolean): React.ReactElement { - if (isProcessing) { - return ; - } - if (status === "success") { - return ; - } - if (status === "error") { - return ; - } - return ; -} - -function renderStatusChipIcon(status: LifecycleStatus, isProcessing: boolean): React.ReactElement { - if (isProcessing) { - return ; - } - if (status === "success") { - return ; - } - if (status === "error") { - return ; - } - return ; -} - -export const LifecycleProgressModal: React.FC = ({ - isOpen, - isProcessing, - mode, - status, - statusMessage, - labName, - logs, - onClose, - onCancel -}) => { - const logContainerRef = React.useRef(null); - const wasProcessingRef = React.useRef(isProcessing); - const [startedAtMs, setStartedAtMs] = React.useState(null); - const [elapsedSeconds, setElapsedSeconds] = React.useState(0); - - React.useEffect(() => { - if (!isOpen) { - setStartedAtMs(null); - setElapsedSeconds(0); - return; - } - - if (!isProcessing || status !== "running") { - return; - } - - const startedAt = Date.now(); - setStartedAtMs(startedAt); - setElapsedSeconds(0); - - const intervalId = window.setInterval(() => { - setElapsedSeconds(calculateElapsedSeconds(startedAt)); - }, 1000); - - return () => { - window.clearInterval(intervalId); - }; - }, [isOpen, isProcessing, status]); - - React.useEffect(() => { - if (isOpen && wasProcessingRef.current && !isProcessing && startedAtMs !== null) { - setElapsedSeconds(calculateElapsedSeconds(startedAtMs)); - } - wasProcessingRef.current = isProcessing; - }, [isOpen, isProcessing, startedAtMs]); - - React.useEffect(() => { - if (!isOpen) { - return; - } - const container = logContainerRef.current; - if (!container) { - return; - } - container.scrollTop = container.scrollHeight; - }, [isOpen, logs]); - - const modeLabel = getModeLabel(mode); - const statusLabel = getStatusLabel(status); - const statusColor = getStatusColor(status); - const timerLabel = isProcessing ? "Elapsed" : "Duration"; - const formattedDuration = formatElapsedSeconds(elapsedSeconds); - - return ( - undefined : onClose} - disableEscapeKeyDown={isProcessing} - maxWidth="md" - fullWidth - data-testid="lifecycle-progress-modal" - slotProps={{ - backdrop: { - sx: { - backdropFilter: "blur(2px)", - backgroundColor: "rgba(0, 0, 0, 0.35)" - } - }, - paper: { - sx: { - overflow: "hidden" - } - } - }} - > - - {renderStatusIcon(status, isProcessing)} - - - - {modeLabel} lab - - - - - {labName || "Containerlab topology"} - - - {timerLabel}: {formattedDuration} - - - - {isProcessing && } - - {statusMessage !== undefined && statusMessage !== null && statusMessage.length > 0 && ( - - {statusMessage} - - )} - - Live command output - - - {logs.length === 0 && ( - - Waiting for command output... - - )} - {logs.map((entry, index) => ( - - {entry.line} - - ))} - - - - {isProcessing ? ( - - ) : ( - - )} - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/ShortcutsModal.tsx b/src/reactTopoViewer/webview/components/panels/ShortcutsModal.tsx deleted file mode 100644 index a0ec78bb3..000000000 --- a/src/reactTopoViewer/webview/components/panels/ShortcutsModal.tsx +++ /dev/null @@ -1,30 +0,0 @@ -// Shortcuts reference dialog. -import React from "react"; -import Dialog from "@mui/material/Dialog"; -import DialogTitle from "@mui/material/DialogTitle"; -import DialogContent from "@mui/material/DialogContent"; -import IconButton from "@mui/material/IconButton"; -import CloseIcon from "@mui/icons-material/Close"; - -import { ShortcutsSection } from "./lab-drawer/ShortcutsSection"; - -interface ShortcutsModalProps { - isOpen: boolean; - onClose: () => void; -} - -export const ShortcutsModal: React.FC = ({ isOpen, onClose }) => ( - - - Shortcuts & Interactions - - - - - - - - -); diff --git a/src/reactTopoViewer/webview/components/panels/SvgExportModal.tsx b/src/reactTopoViewer/webview/components/panels/SvgExportModal.tsx deleted file mode 100644 index a132ecfc0..000000000 --- a/src/reactTopoViewer/webview/components/panels/SvgExportModal.tsx +++ /dev/null @@ -1,1276 +0,0 @@ -// SVG export dialog. -import React, { useState, useCallback, useMemo } from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; -import { - AccountTree as AccountTreeIcon, - Download as DownloadIcon, - Lightbulb as LightbulbIcon, - Settings as SettingsIcon -} from "@mui/icons-material"; -import { - Alert, - Box, - Button, - Checkbox, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - Divider, - FormControlLabel, - InputAdornment, - MenuItem, - Paper, - Radio, - RadioGroup, - Tab, - Tabs, - TextField, - Typography -} from "@mui/material"; - -import type { - FreeTextAnnotation, - FreeShapeAnnotation, - GroupStyleAnnotation -} from "../../../shared/types/topology"; -import { EXPORT_COMMANDS } from "../../../shared/messages/extension"; -import { MSG_SVG_EXPORT_RESULT } from "../../../shared/messages/webview"; -import { - FREE_TEXT_NODE_TYPE, - FREE_SHAPE_NODE_TYPE, - GROUP_NODE_TYPE -} from "../../annotations/annotationNodeConverters"; -import { sendCommandToExtension } from "../../messaging/extensionMessaging"; -import { subscribeToWebviewMessages } from "../../messaging/webviewMessageBus"; -import { log } from "../../utils/logger"; -import { ColorField, PREVIEW_GRID_BG_SX } from "../ui/form"; -import { DialogTitleWithClose } from "../ui/dialog/DialogChrome"; - -import { - applyPadding, - buildGraphSvg, - collectGrafanaEdgeCellMappings, - collectLinkedNodeIds, - sanitizeSvgForGrafana, - removeUnlinkedNodesFromSvg, - trimGrafanaSvgToTopologyContent, - addGrafanaTrafficLegend, - makeGrafanaSvgResponsive, - applyGrafanaCellIdsToSvg, - buildGrafanaPanelYaml, - buildGrafanaDashboardJson, - DEFAULT_GRAFANA_TRAFFIC_THRESHOLDS, - getViewportSize, - compositeAnnotationsIntoSvg, - addBackgroundRect -} from "./svg-export"; -import type { - CustomIconMap, - GrafanaTrafficThresholds, - GraphSvgResult, - GraphSvgRenderOptions -} from "./svg-export"; - -export interface SvgExportModalProps { - isOpen: boolean; - onClose: () => void; - labName?: string; - textAnnotations?: FreeTextAnnotation[]; - shapeAnnotations?: FreeShapeAnnotation[]; - groups?: GroupStyleAnnotation[]; - rfInstance: ReactFlowInstance | null; - customIcons?: CustomIconMap; -} - -const ANNOTATION_NODE_TYPES: Set = new Set([ - FREE_TEXT_NODE_TYPE, - FREE_SHAPE_NODE_TYPE, - GROUP_NODE_TYPE -]); - -function downloadSvg(content: string, filename: string): void { - const blob = new Blob([content], { type: "image/svg+xml;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - link.click(); - URL.revokeObjectURL(url); -} - -type BackgroundOption = "transparent" | "custom"; -const DEFAULT_GRAFANA_NODE_SIZE_PX = 40; -const DEFAULT_GRAFANA_INTERFACE_SIZE_PERCENT = 100; -type TrafficThresholdUnit = "kbit" | "mbit" | "gbit"; -const DEFAULT_TRAFFIC_THRESHOLD_UNIT: TrafficThresholdUnit = "mbit"; -type GrafanaSettingsTab = "general" | "interface-names"; - -interface EdgeInterfaceRow { - edgeId: string; - source: string; - target: string; - sourceEndpoint: string; - targetEndpoint: string; -} - -const INTERFACE_SELECT_AUTO = "__auto__"; -const INTERFACE_SELECT_FULL = "__full__"; -const INTERFACE_SELECT_TOKEN_PREFIX = "__token__:"; -const GLOBAL_INTERFACE_PART_INDEX_PREFIX = "__part-index__:"; - -interface SvgExportResultMessage { - type: typeof MSG_SVG_EXPORT_RESULT; - requestId: string; - success: boolean; - error?: string; - files?: string[]; -} - -interface GrafanaBundlePayload { - requestId: string; - baseName: string; - svgContent: string; - dashboardJson: string; - panelYaml: string; -} - -interface PreparedSvgExport { - baseName: string; - finalSvg: string; - graphSvg: GraphSvgResult; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function createRequestId(): string { - const bytes = new Uint8Array(8); - globalThis.crypto.getRandomValues(bytes); - const random = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); - return `svg-export-${Date.now()}-${random}`; -} - -function getThresholdUnitMultiplier(unit: TrafficThresholdUnit): number { - switch (unit) { - case "kbit": - return 1_000; - case "gbit": - return 1_000_000_000; - default: - return 1_000_000; - } -} - -function formatThresholdForUnit(valueBps: number, unit: TrafficThresholdUnit): string { - const multiplier = getThresholdUnitMultiplier(unit); - if (!Number.isFinite(valueBps) || multiplier <= 0) return "0"; - const scaled = valueBps / multiplier; - return Number(scaled.toFixed(4)).toString(); -} - -function parseThreshold(value: string, unit: TrafficThresholdUnit): number { - const parsed = Number.parseFloat(value); - if (!Number.isFinite(parsed)) return 0; - const multiplier = getThresholdUnitMultiplier(unit); - return Math.max(0, Math.round(parsed * multiplier)); -} - -function getThresholdUnitStep(unit: TrafficThresholdUnit): number { - switch (unit) { - case "kbit": - return 1; - case "gbit": - return 0.01; - default: - return 0.1; - } -} - -function parseBoundedNumber(value: string, min: number, max: number, fallback: number): number { - const parsed = Number.parseFloat(value); - if (!Number.isFinite(parsed)) return fallback; - return Math.max(min, Math.min(max, parsed)); -} - -function asNonEmptyString(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function resolveDefaultExportBaseName(labName?: string): string { - const trimmed = labName?.trim(); - return trimmed !== undefined && trimmed.length > 0 ? trimmed : "topology"; -} - -function extractEdgeInterfaceRows(rfInstance: ReactFlowInstance | null): EdgeInterfaceRow[] { - if (!rfInstance) return []; - - const edges = rfInstance.getEdges(); - const rows: EdgeInterfaceRow[] = []; - - for (const edge of edges) { - const data = edge.data; - const sourceEndpoint = asNonEmptyString(data?.sourceEndpoint); - const targetEndpoint = asNonEmptyString(data?.targetEndpoint); - if (sourceEndpoint === null || targetEndpoint === null) continue; - - rows.push({ - edgeId: edge.id, - source: edge.source, - target: edge.target, - sourceEndpoint, - targetEndpoint - }); - } - - return rows; -} - -function splitInterfaceParts(endpoint: string): string[] { - const baseParts = endpoint - .split(/[^A-Za-z0-9]+/g) - .map((part) => part.trim()) - .filter((part) => part.length > 0); - - const uniqueParts: string[] = []; - const seen = new Set(); - const addUnique = (part: string): void => { - if (seen.has(part)) return; - seen.add(part); - uniqueParts.push(part); - }; - - for (const part of baseParts) { - addUnique(part); - - const numericSegments = part.match(/\d+/g); - if (!numericSegments) continue; - for (const numeric of numericSegments) { - addUnique(numeric); - } - } - - return uniqueParts; -} - -function getInterfaceSelectionValue( - endpoint: string, - interfaceLabelOverrides: Record -): string { - const override = interfaceLabelOverrides[endpoint]; - if (typeof override !== "string" || override.length === 0) { - return INTERFACE_SELECT_AUTO; - } - if (override === endpoint) return INTERFACE_SELECT_FULL; - return `${INTERFACE_SELECT_TOKEN_PREFIX}${override}`; -} - -function parseBackgroundOption(value: string): BackgroundOption { - return value === "custom" ? "custom" : "transparent"; -} - -function parseGrafanaSettingsTab(value: unknown): GrafanaSettingsTab { - return value === "interface-names" ? "interface-names" : "general"; -} - -function parseTrafficThresholdUnit(value: string): TrafficThresholdUnit { - if (value === "kbit" || value === "mbit" || value === "gbit") return value; - return DEFAULT_TRAFFIC_THRESHOLD_UNIT; -} - -function isSvgExportResultMessage(value: unknown): value is SvgExportResultMessage { - if (!isRecord(value)) return false; - if (value.type !== MSG_SVG_EXPORT_RESULT) return false; - if (asNonEmptyString(value.requestId) === null) return false; - if (typeof value.success !== "boolean") return false; - if (value.error !== undefined && typeof value.error !== "string") return false; - if (value.files !== undefined && !Array.isArray(value.files)) return false; - return true; -} - -function resolveInterfaceOverrideValue(endpoint: string, selectedValue: string): string | null { - if (selectedValue === INTERFACE_SELECT_AUTO) return null; - if (selectedValue === INTERFACE_SELECT_FULL) return endpoint; - if (selectedValue.startsWith(INTERFACE_SELECT_TOKEN_PREFIX)) { - const token = selectedValue.slice(INTERFACE_SELECT_TOKEN_PREFIX.length).trim(); - return token.length > 0 ? token : null; - } - return null; -} - -function parseGlobalInterfacePartIndex(selectedValue: string): number | null { - if (!selectedValue.startsWith(GLOBAL_INTERFACE_PART_INDEX_PREFIX)) return null; - const raw = selectedValue.slice(GLOBAL_INTERFACE_PART_INDEX_PREFIX.length); - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed) || parsed < 1) return null; - return parsed; -} - -function resolveGlobalInterfaceOverrideValue( - endpoint: string, - selectedValue: string -): string | null { - if (selectedValue === INTERFACE_SELECT_AUTO) return null; - if (selectedValue === INTERFACE_SELECT_FULL) return endpoint; - - const partIndex = parseGlobalInterfacePartIndex(selectedValue); - if (partIndex === null) return null; - - const parts = splitInterfaceParts(endpoint); - return parts[partIndex - 1] ?? null; -} - -function hasStrictlyAscendingThresholds(thresholds: GrafanaTrafficThresholds): boolean { - return ( - thresholds.green < thresholds.yellow && - thresholds.yellow < thresholds.orange && - thresholds.orange < thresholds.red - ); -} - -function requestGrafanaBundleExport(payload: GrafanaBundlePayload): Promise { - return new Promise((resolve, reject) => { - let unsubscribe = () => { - /* no-op until subscription is active */ - }; - - const timeoutId = window.setTimeout(() => { - unsubscribe(); - reject(new Error("Timed out waiting for export confirmation")); - }, 30_000); - - unsubscribe = subscribeToWebviewMessages((event) => { - const message = event.data; - if (!isSvgExportResultMessage(message)) return; - if (message.requestId !== payload.requestId) return; - - unsubscribe(); - window.clearTimeout(timeoutId); - - if (!message.success) { - reject(new Error(message.error ?? "Grafana bundle export failed")); - return; - } - - const files = Array.isArray(message.files) - ? message.files.filter((file): file is string => typeof file === "string") - : []; - resolve(files); - }); - - sendCommandToExtension(EXPORT_COMMANDS.EXPORT_SVG_GRAFANA_BUNDLE, { - requestId: payload.requestId, - baseName: payload.baseName, - svgContent: payload.svgContent, - dashboardJson: payload.dashboardJson, - panelYaml: payload.panelYaml - }); - }); -} - -export const SvgExportModal: React.FC = ({ - isOpen, - onClose, - labName, - textAnnotations = [], - shapeAnnotations = [], - groups = [], - rfInstance, - customIcons -}) => { - const [borderZoom, setBorderZoom] = useState(100); - const [borderPadding, setBorderPadding] = useState(0); - const [isExporting, setIsExporting] = useState(false); - const [exportStatus, setExportStatus] = useState<{ - type: "success" | "error"; - message: string; - } | null>(null); - const [grafanaSettingsTab, setGrafanaSettingsTab] = useState("general"); - const [includeAnnotations, setIncludeAnnotations] = useState(true); - const [includeEdgeLabels, setIncludeEdgeLabels] = useState(true); - const [exportGrafanaBundle, setExportGrafanaBundle] = useState(false); - const [isGrafanaSettingsOpen, setIsGrafanaSettingsOpen] = useState(false); - const [excludeNodesWithoutLinks, setExcludeNodesWithoutLinks] = useState(true); - const [includeGrafanaLegend, setIncludeGrafanaLegend] = useState(false); - const [trafficRatesOnHoverOnly, setTrafficRatesOnHoverOnly] = useState(false); - const [includeHideRatesLegendToggle, setIncludeHideRatesLegendToggle] = useState(true); - const [trafficThresholds, setTrafficThresholds] = useState({ - ...DEFAULT_GRAFANA_TRAFFIC_THRESHOLDS - }); - const [trafficThresholdUnit, setTrafficThresholdUnit] = useState( - DEFAULT_TRAFFIC_THRESHOLD_UNIT - ); - const [grafanaNodeSizePx, setGrafanaNodeSizePx] = useState(DEFAULT_GRAFANA_NODE_SIZE_PX); - const [grafanaInterfaceSizePercent, setGrafanaInterfaceSizePercent] = useState( - DEFAULT_GRAFANA_INTERFACE_SIZE_PERCENT - ); - const [globalInterfaceOverrideSelection, setGlobalInterfaceOverrideSelection] = - useState(INTERFACE_SELECT_AUTO); - const [interfaceLinkFilter, setInterfaceLinkFilter] = useState(""); - const [interfaceLabelOverrides, setInterfaceLabelOverrides] = useState>( - {} - ); - const [backgroundOption, setBackgroundOption] = useState("transparent"); - const [customBackgroundColor, setCustomBackgroundColor] = useState("#1e1e1e"); - const defaultBaseName = useMemo(() => resolveDefaultExportBaseName(labName), [labName]); - const [filename, setFilename] = useState(defaultBaseName); - - const isExportAvailable = rfInstance ? Boolean(getViewportSize()) : false; - const totalAnnotations = groups.length + textAnnotations.length + shapeAnnotations.length; - const interfaceRows = extractEdgeInterfaceRows(rfInstance); - const filteredInterfaceRows = useMemo(() => { - const filterValue = interfaceLinkFilter.trim().toLowerCase(); - if (!filterValue) return interfaceRows; - - return interfaceRows.filter((row) => - [row.edgeId, row.source, row.target, row.sourceEndpoint, row.targetEndpoint] - .join(" ") - .toLowerCase() - .includes(filterValue) - ); - }, [interfaceRows, interfaceLinkFilter]); - const interfaceEndpoints = useMemo(() => { - const unique = new Set(); - for (const row of interfaceRows) { - unique.add(row.sourceEndpoint); - unique.add(row.targetEndpoint); - } - return Array.from(unique.values()); - }, [interfaceRows]); - const maxInterfacePartCount = useMemo(() => { - let maxCount = 1; - for (const endpoint of interfaceEndpoints) { - maxCount = Math.max(maxCount, splitInterfaceParts(endpoint).length); - } - return maxCount; - }, [interfaceEndpoints]); - const effectiveInterfaceLabelOverrides = useMemo(() => { - const merged: Record = {}; - - for (const endpoint of interfaceEndpoints) { - const globalOverride = resolveGlobalInterfaceOverrideValue( - endpoint, - globalInterfaceOverrideSelection - ); - if (globalOverride !== null) { - merged[endpoint] = globalOverride; - } - } - - for (const [endpoint, override] of Object.entries(interfaceLabelOverrides)) { - if (typeof override !== "string" || override.trim().length === 0) { - delete merged[endpoint]; - } else { - merged[endpoint] = override.trim(); - } - } - - return merged; - }, [interfaceEndpoints, globalInterfaceOverrideSelection, interfaceLabelOverrides]); - - const updateTrafficThreshold = useCallback( - (threshold: keyof GrafanaTrafficThresholds, rawValue: string) => { - const nextValue = parseThreshold(rawValue, trafficThresholdUnit); - setTrafficThresholds((prev) => ({ - ...prev, - [threshold]: nextValue - })); - }, - [trafficThresholdUnit] - ); - - const updateInterfaceOverride = useCallback((endpoint: string, selectedValue: string) => { - const override = resolveInterfaceOverrideValue(endpoint, selectedValue); - setInterfaceLabelOverrides((prev) => { - if (override === null) { - if (!(endpoint in prev)) return prev; - const next = { ...prev }; - delete next[endpoint]; - return next; - } - if (prev[endpoint] === override) return prev; - return { ...prev, [endpoint]: override }; - }); - }, []); - - const prepareSvgExport = useCallback((): PreparedSvgExport => { - if (!rfInstance) { - throw new Error("SVG export is not yet available"); - } - - const grafanaRenderOptions: GraphSvgRenderOptions | undefined = exportGrafanaBundle - ? { - nodeIconSize: grafanaNodeSizePx, - interfaceScale: grafanaInterfaceSizePercent / 100, - interfaceLabelOverrides: effectiveInterfaceLabelOverrides - } - : undefined; - const graphSvg = buildGraphSvg( - rfInstance, - borderZoom, - customIcons, - includeEdgeLabels, - ANNOTATION_NODE_TYPES, - exportGrafanaBundle, - grafanaRenderOptions - ); - if (!graphSvg) { - throw new Error("Unable to capture viewport for SVG export"); - } - - let finalSvg = graphSvg.svg; - if (borderPadding > 0) finalSvg = applyPadding(finalSvg, borderPadding); - if (includeAnnotations && totalAnnotations > 0) { - finalSvg = compositeAnnotationsIntoSvg( - finalSvg, - { groups, textAnnotations, shapeAnnotations }, - borderZoom / 100 - ); - } - if (backgroundOption === "custom") { - finalSvg = addBackgroundRect(finalSvg, customBackgroundColor); - } - - const baseName = filename.trim() || defaultBaseName; - return { baseName, finalSvg, graphSvg }; - }, [ - exportGrafanaBundle, - grafanaNodeSizePx, - grafanaInterfaceSizePercent, - effectiveInterfaceLabelOverrides, - rfInstance, - borderZoom, - customIcons, - includeEdgeLabels, - borderPadding, - includeAnnotations, - totalAnnotations, - groups, - textAnnotations, - shapeAnnotations, - backgroundOption, - customBackgroundColor, - filename, - defaultBaseName - ]); - - const exportPlainSvg = useCallback((prepared: PreparedSvgExport): void => { - downloadSvg(prepared.finalSvg, `${prepared.baseName}.svg`); - setExportStatus({ - type: "success", - message: "SVG exported successfully" - }); - }, []); - - const exportGrafanaBundleFiles = useCallback( - async (prepared: PreparedSvgExport): Promise => { - if (!hasStrictlyAscendingThresholds(trafficThresholds)) { - throw new Error( - "Traffic thresholds must be strictly ascending (green < yellow < orange < red)" - ); - } - - const mappings = collectGrafanaEdgeCellMappings( - prepared.graphSvg.edges, - prepared.graphSvg.nodes, - ANNOTATION_NODE_TYPES - ); - let grafanaBaseSvg = sanitizeSvgForGrafana(prepared.finalSvg); - if (excludeNodesWithoutLinks) { - const linkedNodeIds = collectLinkedNodeIds( - prepared.graphSvg.edges, - prepared.graphSvg.nodes, - ANNOTATION_NODE_TYPES - ); - grafanaBaseSvg = removeUnlinkedNodesFromSvg(grafanaBaseSvg, linkedNodeIds); - grafanaBaseSvg = trimGrafanaSvgToTopologyContent( - grafanaBaseSvg, - Math.max(6, borderPadding) - ); - } - - let grafanaSvg = applyGrafanaCellIdsToSvg(grafanaBaseSvg, mappings, { - trafficRatesOnHoverOnly - }); - if (includeGrafanaLegend) { - grafanaSvg = addGrafanaTrafficLegend(grafanaSvg, trafficThresholds, trafficThresholdUnit); - } - grafanaSvg = makeGrafanaSvgResponsive(grafanaSvg); - const panelYaml = buildGrafanaPanelYaml(mappings, { - trafficThresholds, - includeHideRatesLegendToggle - }); - const dashboardJson = buildGrafanaDashboardJson(panelYaml, grafanaSvg, prepared.baseName); - - const requestId = createRequestId(); - const files = await requestGrafanaBundleExport({ - requestId, - baseName: prepared.baseName, - svgContent: grafanaSvg, - dashboardJson, - panelYaml - }); - const suffix = - files.length > 0 ? ` (${files.map((file) => file.split("/").pop()).join(", ")})` : ""; - setExportStatus({ - type: "success", - message: `Grafana bundle exported successfully${suffix}` - }); - }, - [ - trafficThresholds, - excludeNodesWithoutLinks, - borderPadding, - includeGrafanaLegend, - trafficRatesOnHoverOnly, - includeHideRatesLegendToggle, - trafficThresholdUnit - ] - ); - - const handleExport = useCallback(async () => { - if (!isExportAvailable || !rfInstance) { - setExportStatus({ - type: "error", - message: "SVG export is not yet available" - }); - return; - } - setIsExporting(true); - setExportStatus(null); - try { - log.info(`[SvgExport] Export requested: zoom=${borderZoom}%, padding=${borderPadding}px`); - const prepared = prepareSvgExport(); - if (!exportGrafanaBundle) { - exportPlainSvg(prepared); - return; - } - await exportGrafanaBundleFiles(prepared); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error(`[SvgExport] Export failed: ${errorMessage}`); - setExportStatus({ type: "error", message: `Export failed: ${errorMessage}` }); - } finally { - setIsExporting(false); - } - }, [ - isExportAvailable, - borderZoom, - borderPadding, - exportGrafanaBundle, - rfInstance, - prepareSvgExport, - exportPlainSvg, - exportGrafanaBundleFiles - ]); - - const previewBackgroundSx = (() => { - if (backgroundOption === "transparent") { - return { - backgroundImage: - "linear-gradient(45deg, #444 25%, transparent 25%), linear-gradient(-45deg, #444 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #444 75%), linear-gradient(-45deg, transparent 75%, #444 75%)", - backgroundSize: "8px 8px", - backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px" - } as const; - } - return { backgroundColor: customBackgroundColor } as const; - })(); - - let exportButtonLabel = "Export SVG"; - if (isExporting) { - exportButtonLabel = "Exporting..."; - } else if (exportGrafanaBundle) { - exportButtonLabel = "Export Grafana Bundle"; - } - - return ( - <> - - - - - setFilename(e.target.value)} - placeholder={defaultBaseName} - data-testid="svg-export-filename" - slotProps={{ - input: { - endAdornment: ( - - - .svg - - - ) - } - }} - /> - - - - - Quality & Size - - - - - - setBorderZoom(Math.max(10, Math.min(300, parseFloat(e.target.value) || 0))) - } - slotProps={{ - htmlInput: { min: 10, max: 300, step: 1 }, - input: { - endAdornment: % - } - }} - /> - setBorderPadding(Math.max(0, parseFloat(e.target.value) || 0))} - slotProps={{ - htmlInput: { min: 0, max: 500, step: 1 }, - input: { - endAdornment: px - } - }} - /> - - - - - - Background - - - - setBackgroundOption(parseBackgroundOption(e.target.value))} - > - } - label="Transparent" - /> - } label="Custom" /> - - {backgroundOption === "custom" && ( - - - - )} - - - - - Include - - - - setIncludeAnnotations(e.target.checked)} - /> - } - label="Annotations" - /> - setIncludeEdgeLabels(e.target.checked)} - /> - } - label="Edge labels" - /> - - setExportGrafanaBundle(e.target.checked)} - /> - } - label="Grafana bundle" - data-testid="svg-export-grafana-bundle" - /> - - - - - - - Preview - - - - - - - - - - - - - - - - - - - - Tips - - - -
  • Higher zoom = better quality, larger file
  • -
  • SVG files scale without quality loss
  • -
  • Transparent background for layering
  • -
    -
    -
    - - {exportStatus && ( - - - {exportStatus.message} - - - )} -
    - - - -
    - - setIsGrafanaSettingsOpen(false)} - maxWidth="sm" - fullWidth - data-testid="svg-export-grafana-settings-modal" - > - setIsGrafanaSettingsOpen(false)} - /> - - setGrafanaSettingsTab(parseGrafanaSettingsTab(value))} - variant="fullWidth" - > - - - - - {grafanaSettingsTab === "general" && ( - <> - - Configure thresholds and topology sizing used in the exported Grafana panel. - - - - setGrafanaNodeSizePx( - parseBoundedNumber(e.target.value, 12, 240, DEFAULT_GRAFANA_NODE_SIZE_PX) - ) - } - slotProps={{ - htmlInput: { min: 12, max: 240, step: 1 }, - input: { - endAdornment: px - } - }} - /> - - setGrafanaInterfaceSizePercent( - parseBoundedNumber( - e.target.value, - 40, - 400, - DEFAULT_GRAFANA_INTERFACE_SIZE_PERCENT - ) - ) - } - slotProps={{ - htmlInput: { min: 40, max: 400, step: 5 }, - input: { - endAdornment: % - } - }} - /> - - - Use larger values for dense topologies with many interfaces. - - - setTrafficThresholdUnit(parseTrafficThresholdUnit(e.target.value))} - > - kbit/s - Mbit/s - Gbit/s - - - updateTrafficThreshold("green", e.target.value)} - slotProps={{ - htmlInput: { - min: 0, - step: getThresholdUnitStep(trafficThresholdUnit) - } - }} - /> - updateTrafficThreshold("yellow", e.target.value)} - slotProps={{ - htmlInput: { - min: 0, - step: getThresholdUnitStep(trafficThresholdUnit) - } - }} - /> - updateTrafficThreshold("orange", e.target.value)} - slotProps={{ - htmlInput: { - min: 0, - step: getThresholdUnitStep(trafficThresholdUnit) - } - }} - /> - updateTrafficThreshold("red", e.target.value)} - slotProps={{ - htmlInput: { - min: 0, - step: getThresholdUnitStep(trafficThresholdUnit) - } - }} - /> - - - Values must be strictly ascending: green < yellow < orange < red (within - selected unit). - - setExcludeNodesWithoutLinks(e.target.checked)} - /> - } - label="Exclude nodes without any links" - /> - setIncludeGrafanaLegend(e.target.checked)} - /> - } - label="Add traffic legend (top-left)" - /> - setTrafficRatesOnHoverOnly(e.target.checked)} - /> - } - label="Show traffic rates on hover only" - /> - setIncludeHideRatesLegendToggle(e.target.checked)} - /> - } - label='Add "hide-rates" legend toggle for rate labels' - /> - - )} - - {grafanaSettingsTab === "interface-names" && ( - <> - - Filter links and choose which interface segment should be shown in endpoint bubbles. - - setGlobalInterfaceOverrideSelection(e.target.value)} - > - Auto - Full interface name - {Array.from({ length: maxInterfacePartCount }, (_, index) => index + 1).map( - (partIndex) => ( - - Part {partIndex} - - ) - )} - - - Default for every interface; per-link overrides below take precedence. - - setInterfaceLinkFilter(e.target.value)} - /> - - {filteredInterfaceRows.length} of {interfaceRows.length} links shown - - - {filteredInterfaceRows.length === 0 ? ( - - - No links match the current filter. - - - ) : ( - filteredInterfaceRows.map((row) => { - const sourceParts = splitInterfaceParts(row.sourceEndpoint); - const targetParts = splitInterfaceParts(row.targetEndpoint); - - return ( - - - {row.source} ↔ {row.target} - - - - updateInterfaceOverride(row.sourceEndpoint, e.target.value) - } - > - Auto (use global) - - Full: {row.sourceEndpoint} - - {sourceParts.map((part, idx) => ( - - Part {idx + 1}: {part} - - ))} - - - updateInterfaceOverride(row.targetEndpoint, e.target.value) - } - > - Auto (use global) - - Full: {row.targetEndpoint} - - {targetParts.map((part, idx) => ( - - Part {idx + 1}: {part} - - ))} - - - - ); - }) - )} - - - )} - - - - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/TrafficChart.tsx b/src/reactTopoViewer/webview/components/panels/TrafficChart.tsx deleted file mode 100644 index 29ce109b4..000000000 --- a/src/reactTopoViewer/webview/components/panels/TrafficChart.tsx +++ /dev/null @@ -1,423 +0,0 @@ -// Real-time traffic chart for link endpoints. -import React, { useRef, useMemo } from "react"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import type { SxProps, Theme } from "@mui/material/styles"; -import { LineChart } from "@mui/x-charts/LineChart"; - -import type { InterfaceStatsPayload, EndpointStatsHistory } from "../../../shared/types/topology"; - -const MAX_GRAPH_POINTS = 60; -const MIN_TIMESTAMP_STEP_SECONDS = 0.001; -const DELAY_FALLBACK_MULTIPLIER = 3; - -interface BpsUnit { - divisor: number; - label: string; -} - -interface TrafficChartProps { - stats: InterfaceStatsPayload | undefined; - endpointKey: string; - height?: number | "100%"; - compact?: boolean; - showLegend?: boolean; - /** Scale factor for compact legend sizing (1 = default). */ - scale?: number; - emptyMessage?: string | null; -} - -/** - * Determine the appropriate unit for BPS display based on max value - */ -function determineBpsUnit(maxBps: number): BpsUnit { - if (maxBps >= 1_000_000_000) { - return { divisor: 1_000_000_000, label: "Gbps" }; - } else if (maxBps >= 1_000_000) { - return { divisor: 1_000_000, label: "Mbps" }; - } else if (maxBps >= 1_000) { - return { divisor: 1_000, label: "Kbps" }; - } - return { divisor: 1, label: "bps" }; -} - -/** - * Create initial empty history - */ -function createEmptyHistory(): EndpointStatsHistory { - return { - timestamps: [], - rxBps: [], - txBps: [], - rxPps: [], - txPps: [] - }; -} - -function resolveValidIntervalSeconds(stats: InterfaceStatsPayload | undefined): number | undefined { - const interval = stats?.statsIntervalSeconds; - if (typeof interval !== "number" || !Number.isFinite(interval) || interval <= 0) { - return undefined; - } - return interval; -} - -function resolveNextTimestampSeconds( - history: EndpointStatsHistory, - stats: InterfaceStatsPayload | undefined, - nowSeconds: number -): number { - const prev = history.timestamps[history.timestamps.length - 1]; - if (typeof prev !== "number" || !Number.isFinite(prev)) { - return nowSeconds; - } - - const interval = resolveValidIntervalSeconds(stats); - if (interval === undefined) { - return Math.max(nowSeconds, prev + MIN_TIMESTAMP_STEP_SECONDS); - } - - const expected = prev + interval; - if (nowSeconds - expected > interval * DELAY_FALLBACK_MULTIPLIER) { - return nowSeconds; - } - - return expected; -} - -function resolveChartMargin(compact: boolean, showLegend: boolean, scale: number) { - if (!compact) { - return { top: 8, right: 48, bottom: 24, left: 48 }; - } - - // The legend is rendered inside the SVG bottom margin. - const legendHeight = showLegend ? Math.round(14 * scale) : 0; - return { top: 6, right: 2, bottom: legendHeight, left: 0 }; -} - -function resolveLegendSlotProps(compact: boolean, showLegend: boolean, scale: number) { - if (!showLegend) { - return undefined; - } - - if (!compact) { - return { - legend: { - direction: "horizontal" as const, - position: { vertical: "bottom" as const, horizontal: "center" as const } - } - }; - } - - const fontSize = `${(0.6 * scale).toFixed(3)}rem`; - const markSize = `${(0.65 * scale).toFixed(2)}em`; - - return { - legend: { - direction: "horizontal" as const, - position: { vertical: "bottom" as const, horizontal: "center" as const }, - sx: { - fontSize, - lineHeight: 1, - padding: 0, - gap: 0.25, - "& .MuiChartsLegend-series": { - gap: 0.25 - }, - "& .MuiChartsLegend-mark": { - fontSize: markSize - } - } - } - }; -} - -// Global history store per endpoint key -const historyStore = new Map(); - -function getOrCreateHistory(endpointKey: string): EndpointStatsHistory { - let history = historyStore.get(endpointKey); - if (!history) { - history = createEmptyHistory(); - historyStore.set(endpointKey, history); - } - return history; -} - -function trimHistory(history: EndpointStatsHistory): void { - while (history.timestamps.length > MAX_GRAPH_POINTS) { - history.timestamps.shift(); - history.rxBps.shift(); - history.txBps.shift(); - history.rxPps.shift(); - history.txPps.shift(); - } -} - -function appendStatsSample( - history: EndpointStatsHistory, - stats: InterfaceStatsPayload | undefined, - lastStatsRef: { current: InterfaceStatsPayload | undefined } -): void { - if (!stats || stats === lastStatsRef.current) { - return; - } - - lastStatsRef.current = stats; - history.timestamps.push(resolveNextTimestampSeconds(history, stats, Date.now() / 1000)); - history.rxBps.push(stats.rxBps ?? 0); - history.txBps.push(stats.txBps ?? 0); - history.rxPps.push(stats.rxPps ?? 0); - history.txPps.push(stats.txPps ?? 0); - trimHistory(history); -} - -function resolveWindowIntervalSeconds( - history: EndpointStatsHistory, - stats: InterfaceStatsPayload | undefined -): number { - const configuredInterval = resolveValidIntervalSeconds(stats); - if (configuredInterval !== undefined) { - return configuredInterval; - } - - if (history.timestamps.length < 2) { - return 1; - } - - const nextToLast = history.timestamps[history.timestamps.length - 2]; - const last = history.timestamps[history.timestamps.length - 1]; - return Math.max(last - nextToLast, MIN_TIMESTAMP_STEP_SECONDS); -} - -function resolveXAxisWindow( - history: EndpointStatsHistory, - stats: InterfaceStatsPayload | undefined -): { xMin: Date | undefined; xMax: Date | undefined } { - const lastTimestamp = history.timestamps[history.timestamps.length - 1]; - if (typeof lastTimestamp !== "number" || !Number.isFinite(lastTimestamp)) { - return { xMin: undefined, xMax: undefined }; - } - - const intervalSeconds = resolveWindowIntervalSeconds(history, stats); - const visiblePointSpan = Math.max( - 1, - Math.min(history.timestamps.length - 1, MAX_GRAPH_POINTS - 1) - ); - const windowSeconds = intervalSeconds * visiblePointSpan; - return { - xMin: new Date((lastTimestamp - windowSeconds) * 1000), - xMax: new Date(lastTimestamp * 1000) - }; -} - -function buildChartData( - history: EndpointStatsHistory, - stats: InterfaceStatsPayload | undefined -): { - xData: Date[]; - rxBpsData: number[]; - txBpsData: number[]; - rxPpsData: number[]; - txPpsData: number[]; - unitLabel: string; - xMin: Date | undefined; - xMax: Date | undefined; -} { - const maxBps = Math.max(...history.rxBps, ...history.txBps, 1); - const unit = determineBpsUnit(maxBps); - const divisor = unit.divisor; - const xData = history.timestamps.map((ts) => new Date(ts * 1000)); - const { xMin, xMax } = resolveXAxisWindow(history, stats); - return { - xData, - rxBpsData: history.rxBps.map((v) => v / divisor), - txBpsData: history.txBps.map((v) => v / divisor), - rxPpsData: [...history.rxPps], - txPpsData: [...history.txPps], - unitLabel: unit.label, - xMin, - xMax - }; -} - -function buildXAxis( - compact: boolean, - xData: Date[], - xMin: Date | undefined, - xMax: Date | undefined -) { - const baseAxis = { - data: xData, - scaleType: "time" as const, - min: xMin, - max: xMax, - disableLine: true, - disableTicks: true, - valueFormatter: (value: Date) => - value.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) - }; - if (compact) { - return [{ ...baseAxis, position: "none" as const, height: 0 }]; - } - return [{ ...baseAxis, tickLabelStyle: { fontSize: 10, fill: "#cccccc" }, height: 20 }]; -} - -function buildYAxis(compact: boolean, scale: number, unitLabel: string) { - if (compact) { - return [ - { - id: "bps", - position: "left" as const, - disableTicks: true, - tickLabelStyle: { fontSize: Math.round(8 * scale), fill: "#9aa0a6" }, - valueFormatter: (value: number) => `${value} ${unitLabel}` - }, - { - id: "pps", - position: "none" as const, - width: 0 - } - ]; - } - - return [ - { - id: "bps", - label: unitLabel, - labelStyle: { fontSize: 11 }, - tickLabelStyle: { fontSize: 10, fill: "#cccccc" } - }, - { - id: "pps", - label: "PPS", - labelStyle: { fontSize: 11 }, - tickLabelStyle: { fontSize: 10, fill: "#cccccc" } - } - ]; -} - -function resolveSeriesLabel( - compact: boolean, - showLegend: boolean, - label: string -): string | undefined { - return compact && !showLegend ? undefined : label; -} - -function buildSeries(params: { - compact: boolean; - showLegend: boolean; - unitLabel: string; - rxBpsData: number[]; - txBpsData: number[]; - rxPpsData: number[]; - txPpsData: number[]; -}) { - return [ - { - data: params.rxBpsData, - label: resolveSeriesLabel(params.compact, params.showLegend, `RX ${params.unitLabel}`), - color: "#4ec9b0", - showMark: false, - curve: "linear" as const, - yAxisId: "bps" - }, - { - data: params.txBpsData, - label: resolveSeriesLabel(params.compact, params.showLegend, `TX ${params.unitLabel}`), - color: "#569cd6", - showMark: false, - curve: "linear" as const, - yAxisId: "bps" - }, - { - data: params.rxPpsData, - label: resolveSeriesLabel(params.compact, params.showLegend, "RX PPS"), - color: "#b5cea8", - showMark: false, - curve: "linear" as const, - yAxisId: "pps" - }, - { - data: params.txPpsData, - label: resolveSeriesLabel(params.compact, params.showLegend, "TX PPS"), - color: "#9cdcfe", - showMark: false, - curve: "linear" as const, - yAxisId: "pps" - } - ]; -} - -export const TrafficChart: React.FC = ({ - stats, - endpointKey, - height, - compact = false, - showLegend = !compact, - scale = 1, - emptyMessage = "No traffic data available" -}) => { - const resolvedHeight = height ?? (compact ? "100%" : 240); - // Track last-seen stats to avoid double-push in Strict Mode - const lastStatsRef = useRef(undefined); - - const { xData, rxBpsData, txBpsData, rxPpsData, txPpsData, unitLabel, xMin, xMax } = - useMemo(() => { - const history = getOrCreateHistory(endpointKey); - appendStatsSample(history, stats, lastStatsRef); - return buildChartData(history, stats); - }, [stats, endpointKey]); - const margin = resolveChartMargin(compact, showLegend, scale); - const legendSlotProps = resolveLegendSlotProps(compact, showLegend, scale); - const xAxis = buildXAxis(compact, xData, xMin, xMax); - const yAxis = buildYAxis(compact, scale, unitLabel); - const series = buildSeries({ - compact, - showLegend, - unitLabel, - rxBpsData, - txBpsData, - rxPpsData, - txPpsData - }); - const chartSx: SxProps = compact - ? { - "& .MuiChartsGrid-line": { stroke: "#3e3e42" }, - "& .MuiChartsAxis-line": { stroke: "#3e3e42" } - } - : { - "& .MuiChartsGrid-line": { stroke: "#3e3e42" }, - "& .MuiChartsAxis-line": { stroke: "#cccccc" }, - "& .MuiChartsAxis-tick": { stroke: "#3e3e42" }, - "& .MuiChartsAxisHighlight-root": { stroke: "#cccccc" }, - "& .MuiChartsAxis-label": { fill: "#cccccc" } - }; - - if (xData.length === 0) { - if (emptyMessage === null) return null; - return ( - - {emptyMessage} - - ); - } - - return ( - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/bulk-link/ConfirmBulkLinksModal.tsx b/src/reactTopoViewer/webview/components/panels/bulk-link/ConfirmBulkLinksModal.tsx deleted file mode 100644 index 2798d2a13..000000000 --- a/src/reactTopoViewer/webview/components/panels/bulk-link/ConfirmBulkLinksModal.tsx +++ /dev/null @@ -1,70 +0,0 @@ -// Confirmation dialog for bulk link creation. -import React from "react"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; -import DialogTitle from "@mui/material/DialogTitle"; -import DialogContent from "@mui/material/DialogContent"; -import DialogActions from "@mui/material/DialogActions"; -import IconButton from "@mui/material/IconButton"; -import CloseIcon from "@mui/icons-material/Close"; - -interface ConfirmBulkLinksModalProps { - isOpen: boolean; - count: number; - sourcePattern: string; - targetPattern: string; - onCancel: () => void; - onConfirm: () => void; -} - -export const ConfirmBulkLinksModal: React.FC = ({ - isOpen, - count, - sourcePattern, - targetPattern, - onCancel, - onConfirm -}) => ( - - - Bulk Link Creation - - - - - - - - Create {count} new link{count === 1 ? "" : "s"}? - - - - Source: {sourcePattern} - -
    - - Target: {targetPattern} - -
    -
    -
    - - - - -
    -); diff --git a/src/reactTopoViewer/webview/components/panels/bulk-link/CopyableCode.tsx b/src/reactTopoViewer/webview/components/panels/bulk-link/CopyableCode.tsx deleted file mode 100644 index c3d8fe92f..000000000 --- a/src/reactTopoViewer/webview/components/panels/bulk-link/CopyableCode.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// Inline code with click-to-copy. -import React from "react"; -import Box from "@mui/material/Box"; - -import { copyToClipboard } from "../../../utils/clipboard"; - -interface CopyableCodeProps { - children: string; -} - -export const CopyableCode: React.FC = ({ children }) => { - const [copied, setCopied] = React.useState(false); - - const handleCopy = React.useCallback(async () => { - const success = await copyToClipboard(children); - if (success) { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } - }, [children]); - - return ( - void handleCopy()} - title="Click to copy" - sx={{ - cursor: "pointer", - userSelect: "text", - borderRadius: 0.5, - px: 0.5, - py: 0.25, - fontFamily: "monospace", - transition: (theme) => theme.transitions.create("backgroundColor"), - ...(copied ? { outline: "1px solid" } : {}) - }} - > - {copied ? "Copied!" : children} - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/bulk-link/bulkLinkHandlers.ts b/src/reactTopoViewer/webview/components/panels/bulk-link/bulkLinkHandlers.ts deleted file mode 100644 index 5562b17aa..000000000 --- a/src/reactTopoViewer/webview/components/panels/bulk-link/bulkLinkHandlers.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Handler functions for bulk link operations - * Uses React Flow nodes/edges arrays for graph queries. - */ -import type { TopoNode, TopoEdge } from "../../../../shared/types/graph"; -import { executeTopologyCommand } from "../../../services"; -import { toLinkSaveData } from "../../../services/linkSaveData"; - -import { buildBulkEdges, computeCandidates, type LinkCandidate } from "./bulkLinkUtils"; - -type SetStatus = (status: string | null) => void; -type SetCandidates = (candidates: LinkCandidate[] | null) => void; - -export function computeAndValidateCandidates( - nodes: TopoNode[], - edges: TopoEdge[], - sourcePattern: string, - targetPattern: string, - setStatus: SetStatus, - setPendingCandidates: SetCandidates -): void { - if (nodes.length === 0) { - setStatus("Topology not ready yet."); - return; - } - if (!sourcePattern.trim() || !targetPattern.trim()) { - setStatus("Enter both Source Pattern and Target Pattern."); - return; - } - - const candidates = computeCandidates(nodes, edges, sourcePattern.trim(), targetPattern.trim()); - if (candidates.length === 0) { - setStatus("No new links would be created with the specified patterns."); - return; - } - - setPendingCandidates(candidates); - setStatus(null); -} - -interface ConfirmCreateParams { - nodes: TopoNode[]; - edges: TopoEdge[]; - pendingCandidates: LinkCandidate[] | null; - canApply: boolean; - addEdge?: (edge: TopoEdge) => void; - setStatus: SetStatus; - setPendingCandidates: SetCandidates; - onClose: () => void; -} - -export async function confirmAndCreateLinks({ - nodes, - edges, - pendingCandidates, - canApply, - addEdge, - setStatus, - setPendingCandidates, - onClose -}: ConfirmCreateParams): Promise { - if (nodes.length === 0 || !pendingCandidates) return; - if (!canApply) { - setStatus("Unlock the lab to create links."); - setPendingCandidates(null); - return; - } - - const builtEdges = buildBulkEdges(nodes, edges, pendingCandidates); - if (builtEdges.length === 0) { - setStatus("No new links to create."); - setPendingCandidates(null); - return; - } - - if (addEdge) { - builtEdges.forEach((edge) => addEdge(edge)); - } - - const commands = builtEdges.map((edge) => ({ - command: "addLink" as const, - payload: toLinkSaveData(edge) - })); - - if (commands.length > 0) { - await executeTopologyCommand({ command: "batch", payload: { commands } }); - } - - setPendingCandidates(null); - setStatus(null); - onClose(); -} diff --git a/src/reactTopoViewer/webview/components/panels/bulk-link/bulkLinkUtils.ts b/src/reactTopoViewer/webview/components/panels/bulk-link/bulkLinkUtils.ts deleted file mode 100644 index 967648fb5..000000000 --- a/src/reactTopoViewer/webview/components/panels/bulk-link/bulkLinkUtils.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Utility functions for bulk link creation - * Uses React Flow nodes/edges arrays for graph queries. - */ -import { FilterUtils } from "../../../../../helpers/filterUtils"; -import { isSpecialEndpointId } from "../../../../shared/utilities/LinkTypes"; -import type { TopoNode, TopoEdge } from "../../../../shared/types/graph"; -import { hasEdgeBetween as hasEdgeBetweenUtil } from "../../../utils/graphQueryUtils"; -import { allocateEndpoint, type EndpointAllocator } from "../../../utils/endpointAllocator"; - -export type LinkCandidate = { sourceId: string; targetId: string }; - -function getNodeLabel(node: TopoNode): string { - const data = node.data; - const label: unknown = Reflect.get(data, "label"); - return typeof label === "string" && label.length > 0 ? label : node.id; -} - -function applyBackreferences(pattern: string, match: RegExpMatchArray | null): string { - if (!pattern) return pattern; - - return pattern.replace( - /\$\$|\$<([^>]+)>|\$(\d+)/g, - (fullMatch: string, namedGroup?: string, numberedGroup?: string) => { - if (fullMatch === "$$") return "$"; - if (!match) return fullMatch; - - if (fullMatch.startsWith("$<")) { - if ( - namedGroup !== undefined && - namedGroup.length > 0 && - match.groups && - Object.prototype.hasOwnProperty.call(match.groups, namedGroup) - ) { - return match.groups[namedGroup] ?? ""; - } - return fullMatch; - } - - if (numberedGroup !== undefined && numberedGroup.length > 0) { - const index = Number(numberedGroup); - if (!Number.isNaN(index) && index < match.length) { - return match[index] ?? ""; - } - return fullMatch; - } - - return fullMatch; - } - ); -} - -function getSourceMatch( - name: string, - sourceRegex: RegExp | null, - fallbackFilter: ReturnType | null -): RegExpMatchArray | null | undefined { - if (sourceRegex) { - const execResult = sourceRegex.exec(name); - return execResult ?? undefined; - } - - if (!fallbackFilter) return null; - return fallbackFilter(name) ? null : undefined; -} - -/** Check if target name matches filter with backreference support */ -function matchTargetWithBackrefs( - targetName: string, - targetFilterText: string, - targetRegex: RegExp | null, - sourceMatch: RegExpMatchArray | null -): boolean { - if (targetRegex && sourceMatch) { - // Apply backreferences from source match - const expandedPattern = applyBackreferences(targetFilterText, sourceMatch); - const expandedRegex = FilterUtils.tryCreateRegExp(expandedPattern); - if (expandedRegex) { - return expandedRegex.test(targetName); - } - return false; - } - const targetFilter = FilterUtils.createFilter(targetFilterText); - return targetFilter(targetName); -} - -/** Process a single target node for potential link candidate */ -function processTargetNode( - sourceId: string, - targetNode: TopoNode, - targetFilterText: string, - targetRegex: RegExp | null, - sourceMatch: RegExpMatchArray | null, - edges: TopoEdge[], - candidates: LinkCandidate[] -): void { - const targetId = targetNode.id; - if (sourceId === targetId) return; // Skip self-loops - - const targetName = getNodeLabel(targetNode); - if (!matchTargetWithBackrefs(targetName, targetFilterText, targetRegex, sourceMatch)) return; - if (hasEdgeBetweenUtil(edges, sourceId, targetId)) return; - - candidates.push({ sourceId, targetId }); -} - -/** - * Compute candidate link pairs between source and target nodes. - * Uses React Flow nodes/edges arrays for graph queries. - */ -export function computeCandidates( - nodes: TopoNode[], - edges: TopoEdge[], - sourceFilterText: string, - targetFilterText: string -): LinkCandidate[] { - const candidates: LinkCandidate[] = []; - - // Build source filter - const sourceRegex = FilterUtils.tryCreateRegExp(sourceFilterText); - const sourceFallbackFilter = sourceRegex ? null : FilterUtils.createFilter(sourceFilterText); - - // Build target filter (with backreference support) - const targetRegex = FilterUtils.tryCreateRegExp(targetFilterText); - - // Filter topology nodes (exclude network nodes) - const topologyNodes = nodes.filter((node) => node.type === "topology-node"); - - for (const sourceNode of topologyNodes) { - const sourceId = sourceNode.id; - const sourceName = getNodeLabel(sourceNode); - - // Check if source matches filter - const sourceMatch = getSourceMatch(sourceName, sourceRegex, sourceFallbackFilter); - if (sourceMatch === undefined) continue; // No match - - // Process all potential target nodes - for (const targetNode of topologyNodes) { - processTargetNode( - sourceId, - targetNode, - targetFilterText, - targetRegex, - sourceMatch, - edges, - candidates - ); - } - } - - return candidates; -} - -/** - * Build edge elements for bulk link creation. - * Uses React Flow nodes/edges arrays for endpoint allocation. - */ -export function buildBulkEdges( - nodes: TopoNode[], - edges: TopoEdge[], - candidates: LinkCandidate[] -): TopoEdge[] { - const allocators = new Map(); - const result: TopoEdge[] = []; - - for (const { sourceId, targetId } of candidates) { - const sourceEndpoint = allocateEndpoint(allocators, nodes, edges, sourceId); - const targetEndpoint = allocateEndpoint(allocators, nodes, edges, targetId); - - const edgeId = `${sourceId}:${sourceEndpoint}--${targetId}:${targetEndpoint}`; - const isSpecialLink = isSpecialEndpointId(sourceId) || isSpecialEndpointId(targetId); - result.push({ - id: edgeId, - source: sourceId, - target: targetId, - type: "topology-edge", - data: { - sourceEndpoint, - targetEndpoint, - isSpecialLink - } - }); - } - - return result; -} diff --git a/src/reactTopoViewer/webview/components/panels/bulk-link/index.ts b/src/reactTopoViewer/webview/components/panels/bulk-link/index.ts deleted file mode 100644 index a6fb7725b..000000000 --- a/src/reactTopoViewer/webview/components/panels/bulk-link/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Bulk Link exports - */ -export { CopyableCode } from "./CopyableCode"; -export { ConfirmBulkLinksModal } from "./ConfirmBulkLinksModal"; -export { computeCandidates, buildBulkEdges } from "./bulkLinkUtils"; -export type { LinkCandidate } from "./bulkLinkUtils"; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/ContextPanel.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/ContextPanel.tsx deleted file mode 100644 index 2927a1582..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/ContextPanel.tsx +++ /dev/null @@ -1,411 +0,0 @@ -// Context-sensitive panel with palette, info, and editor tabs. -import React, { useCallback, useRef, useState } from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; -import { - ChevronLeft as ChevronLeftIcon, - ChevronRight as ChevronRightIcon, - ErrorOutline as ErrorOutlineIcon, - Lock as LockIcon, - SwapHoriz as SwapHorizIcon -} from "@mui/icons-material"; -import { Box, Button, Divider, Drawer, Tooltip, Typography } from "@mui/material"; - -import { useIsLocked } from "../../../stores/topoViewerStore"; -import type { NodeData, LinkData } from "../../../hooks/ui"; -import { useContextPanelContent } from "../../../hooks/ui/useContextPanelContent"; - -import type { - ContextPanelEditorState, - EditorFooterRef, - EditorBannerRef -} from "./views/editorTypes"; -import { PaletteView } from "./views"; - -const MIN_WIDTH = 500; -function getMaxWidth() { - return Math.floor(window.innerWidth / 2); -} -const TEXT_SECONDARY = "text.secondary"; -const ACTION_HOVER = "action.hover"; - -type BannerRef = EditorBannerRef; -type FooterRef = EditorFooterRef; - -interface SideConfig { - isLeft: boolean; - tooltipPlacement: "left" | "right"; - positionProp: "left" | "right"; - openIcon: typeof ChevronRightIcon; - closeIcon: typeof ChevronLeftIcon; - borderRadius: string; - borderZeroProp: "borderLeft" | "borderRight"; - moveTargetLabel: "left" | "right"; -} - -function getSideConfig(side: "left" | "right"): SideConfig { - if (side === "left") { - return { - isLeft: true, - tooltipPlacement: "right", - positionProp: "left", - openIcon: ChevronRightIcon, - closeIcon: ChevronLeftIcon, - borderRadius: "0 4px 4px 0", - borderZeroProp: "borderLeft", - moveTargetLabel: "right" - }; - } - - return { - isLeft: false, - tooltipPlacement: "left", - positionProp: "right", - openIcon: ChevronLeftIcon, - closeIcon: ChevronRightIcon, - borderRadius: "4px 0 0 4px", - borderZeroProp: "borderRight", - moveTargetLabel: "left" - }; -} - -export interface ContextPanelPaletteProps { - mode?: "edit" | "view"; - requestedTab?: { tabId: string }; - onEditCustomNode: (name: string) => void; - onDeleteCustomNode: (name: string) => void; - onSetDefaultCustomNode: (name: string) => void; -} - -export interface ContextPanelViewProps { - selectedNodeData: NodeData | null; - selectedLinkData: (LinkData & { extraData?: Record }) | null; -} - -export interface ContextPanelEditorProps extends ContextPanelEditorState {} - -function renderContextPanelContent( - palette: ContextPanelPaletteProps, - view: ContextPanelViewProps, - editor: ContextPanelEditorProps, - isLocked: boolean, - setFooterRef: (ref: FooterRef | null) => void, - setBannerRef: (ref: BannerRef | null) => void -): React.ReactElement { - return ( - - ); -} - -const ToggleHandle: React.FC<{ - isOpen: boolean; - panelWidth: number; - isDragging: boolean; - side: "left" | "right"; - onOpen: () => void; - onClose: () => void; - onBack: () => void; - onToggleSide: () => void; -}> = ({ isOpen, panelWidth, isDragging, onOpen, onClose, onBack, side, onToggleSide }) => { - const sideConfig = getSideConfig(side); - const anchorOffset = isOpen ? panelWidth : 0; - const toggleTitle = isOpen ? "Close panel" : "Open panel"; - const ActiveIcon = isOpen ? sideConfig.closeIcon : sideConfig.openIcon; - const handleToggle = useCallback(() => { - if (isOpen) { - onBack(); - onClose(); - return; - } - - onOpen(); - }, [isOpen, onBack, onClose, onOpen]); - - const handleStyle = { - display: "flex", - alignItems: "center", - justifyContent: "center", - width: 20, - cursor: "pointer", - borderRadius: sideConfig.borderRadius, - border: 1, - [sideConfig.borderZeroProp]: 0, - borderColor: "divider", - bgcolor: "background.paper", - "&:hover": { bgcolor: ACTION_HOVER } - }; - - return ( - - theme.transitions.create(sideConfig.positionProp, { - duration: theme.transitions.duration.short - }), - zIndex: 15, - display: "flex", - flexDirection: "column", - alignItems: "center", - gap: 0.5 - }} - > - - - - - - {isOpen && ( - - - - - - )} - - ); -}; - -function usePanelResize(sideRef: React.RefObject) { - const [panelWidth, setPanelWidth] = useState(MIN_WIDTH); - const [isDragging, setIsDragging] = useState(false); - const isDraggingRef = useRef(false); - - const handleResizeStart = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - isDraggingRef.current = true; - setIsDragging(true); - const onMouseMove = (ev: MouseEvent) => { - if (!isDraggingRef.current) return; - const newWidth = sideRef.current === "left" ? ev.clientX : window.innerWidth - ev.clientX; - setPanelWidth(Math.min(getMaxWidth(), Math.max(MIN_WIDTH, newWidth))); - }; - const onMouseUp = () => { - isDraggingRef.current = false; - setIsDragging(false); - document.removeEventListener("mousemove", onMouseMove); - document.removeEventListener("mouseup", onMouseUp); - }; - document.addEventListener("mousemove", onMouseMove); - document.addEventListener("mouseup", onMouseUp); - }, - [sideRef] - ); - - return { panelWidth, isDragging, handleResizeStart }; -} - -function haveFooterHandlersChanged(prev: FooterRef | null, next: FooterRef | null): boolean { - return ( - prev?.handleApply !== next?.handleApply || - prev?.handleSave !== next?.handleSave || - prev?.handleDiscard !== next?.handleDiscard - ); -} - -function hasFooterRefChanged(prev: FooterRef | null, next: FooterRef | null): boolean { - if (Boolean(prev) !== Boolean(next)) { - return true; - } - if ((prev?.hasChanges ?? false) !== (next?.hasChanges ?? false)) { - return true; - } - return haveFooterHandlersChanged(prev, next); -} - -export interface ContextPanelProps { - isOpen: boolean; - side: "left" | "right"; - onOpen: () => void; - onClose: () => void; - onBack: () => void; - onToggleSide: () => void; - rfInstance: ReactFlowInstance | null; - palette: ContextPanelPaletteProps; - view: ContextPanelViewProps; - editor: ContextPanelEditorProps; -} - -export const ContextPanel: React.FC = ({ - isOpen, - side, - onOpen, - onClose, - onBack, - onToggleSide, - palette, - view, - editor -}) => { - const panelView = useContextPanelContent(); - const isLocked = useIsLocked(); - const isReadOnly = isLocked && panelView.hasFooter; - const footerRef = useRef(null); - const bannerRef = useRef(null); - const [, forceUpdate] = useState(0); - const isLeft = side === "left"; - const sideRef = useRef(side); - sideRef.current = side; - const { panelWidth, isDragging, handleResizeStart } = usePanelResize(sideRef); - - const setFooterRef = useCallback((ref: FooterRef | null) => { - const changed = hasFooterRefChanged(footerRef.current, ref); - footerRef.current = ref; - if (changed) { - forceUpdate((n) => n + 1); - } - }, []); - - const setBannerRef = useCallback((ref: BannerRef | null) => { - bannerRef.current = ref; - forceUpdate((n) => n + 1); - }, []); - - const sideLayout = isLeft - ? { border: "borderRight", resize: "right" } - : { border: "borderLeft", resize: "left" }; - - const content = renderContextPanelContent( - palette, - view, - editor, - isLocked, - setFooterRef, - setBannerRef - ); - - const footer = footerRef.current; - const showFooter = panelView.hasFooter && footer?.hasChanges === true; - - return ( - <> - - - {isLocked && ( - <> - - - Read-only — unlock lab to edit - - - - )} - - {bannerRef.current && - bannerRef.current.errors.map((err, i) => ( - - - - {err} - - - - ))} - - - {content} - - - {showFooter === true && !isReadOnly && ( - <> - - - - - - )} - - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/ContextPanelScrollArea.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/ContextPanelScrollArea.tsx deleted file mode 100644 index 9923352e4..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/ContextPanelScrollArea.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from "react"; -import Box from "@mui/material/Box"; - -/** Shared style that resets browser fieldset chrome while preserving the disabled state. */ -export const FIELDSET_RESET_STYLE: React.CSSProperties = { - border: 0, - margin: 0, - padding: 0, - minInlineSize: 0 -}; - -export const ContextPanelScrollArea: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - {children} -); - -/** Scroll area with a reset fieldset that disables inputs when readOnly. */ -export const EditorFieldset: React.FC<{ readOnly: boolean; children: React.ReactNode }> = ({ - readOnly, - children -}) => ( - -
    - {children} -
    -
    -); diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/index.ts b/src/reactTopoViewer/webview/components/panels/context-panel/index.ts deleted file mode 100644 index 57712858b..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { ContextPanel } from "./ContextPanel"; -export type { - ContextPanelProps, - ContextPanelPaletteProps, - ContextPanelViewProps, - ContextPanelEditorProps -} from "./ContextPanel"; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/EditorTabContent.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/views/EditorTabContent.tsx deleted file mode 100644 index bd6633083..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/EditorTabContent.tsx +++ /dev/null @@ -1,176 +0,0 @@ -// Editor content for the Edit tab. -import React from "react"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; - -import { useContextPanelContent } from "../../../../hooks/ui/useContextPanelContent"; -import { useIsLocked } from "../../../../stores/topoViewerStore"; -import { PanelEmptyState } from "../../../ui/form"; - -import type { ContextPanelEditorState, EditorFooterRef, EditorBannerRef } from "./editorTypes"; -import { editorViews } from "./editorViews"; - -const { - FreeShapeEditorView, - FreeTextEditorView, - GroupEditorView, - LinkEditorView, - LinkImpairmentView, - NetworkEditorView, - NodeEditorView, - TrafficRateEditorView -} = editorViews; - -export interface EditorTabContentProps extends ContextPanelEditorState { - onFooterRef: (ref: EditorFooterRef | null) => void; - onBannerRef: (ref: EditorBannerRef | null) => void; -} - -/** Placeholder shown when no editor is active */ -const EditorPlaceholder: React.FC = () => ( - } - message="Select a node, link, or annotation and click edit to modify it here." - /> -); - -export const EditorTabContent: React.FC = ({ - editingNodeData, - editingNodeInheritedProps, - selectedNodeVisualData, - selectedNodeVisualInheritedProps, - enableSelectedNodeVisualEditor, - nodeEditorHandlers, - editingLinkData, - linkEditorHandlers, - editingNetworkData, - networkEditorHandlers, - linkImpairmentData, - linkImpairmentHandlers, - editingTextAnnotation, - textAnnotationHandlers, - editingShapeAnnotation, - shapeAnnotationHandlers, - editingTrafficRateAnnotation, - trafficRateAnnotationHandlers, - editingGroup, - groupHandlers, - onFooterRef, - onBannerRef -}) => { - const panelView = useContextPanelContent(); - const isLocked = useIsLocked(); - const isReadOnly = isLocked && panelView.hasFooter; - const shouldUseSelectedNodeVisualEditor = - enableSelectedNodeVisualEditor && panelView.kind === "nodeInfo"; - const nodeEditorData = shouldUseSelectedNodeVisualEditor - ? selectedNodeVisualData - : editingNodeData; - const nodeEditorInheritedProps = shouldUseSelectedNodeVisualEditor - ? selectedNodeVisualInheritedProps - : editingNodeInheritedProps; - - // Render the appropriate editor based on current state - switch (panelView.kind) { - case "nodeEditor": - case "nodeInfo": - return ( - - ); - case "linkEditor": - return ( - - ); - case "networkEditor": - return ( - - ); - case "linkImpairment": - return ( - - ); - case "freeTextEditor": - return ( - - ); - case "freeShapeEditor": - return ( - - ); - case "groupEditor": - return ( - - ); - case "trafficRateEditor": - return ( - - ); - default: - return ; - } -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/FreeShapeEditorView.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/views/FreeShapeEditorView.tsx deleted file mode 100644 index 0a540735c..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/FreeShapeEditorView.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// Shape annotation editor for the ContextPanel. -import React from "react"; -import Box from "@mui/material/Box"; - -import type { FreeShapeAnnotation } from "../../../../../shared/types/topology"; -import { useGenericFormState, useEditorHandlersWithFooterRef } from "../../../../hooks/editor"; -import { normalizeShapeAnnotationColors } from "../../../../utils/color"; -import { FIELDSET_RESET_STYLE } from "../ContextPanelScrollArea"; -import { FreeShapeFormContent } from "../../free-shape-editor/FreeShapeFormContent"; -import { useAnnotationPreviewCommit } from "./useAnnotationPreviewCommit"; - -export interface FreeShapeEditorViewProps { - annotation: FreeShapeAnnotation | null; - onSave: (annotation: FreeShapeAnnotation) => void; - /** Live-preview changes on the canvas (visual only, no persist) */ - onPreview?: (annotation: FreeShapeAnnotation) => boolean; - /** Remove preview-only annotation (used when discarding a new annotation). */ - onPreviewDelete?: (id: string) => void; - onClose: () => void; - onDelete?: (id: string) => void; - /** Disable editing, but keep scrolling available */ - readOnly?: boolean; - onFooterRef?: (ref: FreeShapeEditorFooterRef | null) => void; -} - -export interface FreeShapeEditorFooterRef { - handleApply: () => void; - handleSave: () => void; - handleDiscard: () => void; - hasChanges: boolean; -} - -export const FreeShapeEditorView: React.FC = ({ - annotation, - onSave, - onPreview, - onPreviewDelete, - onClose, - onDelete, - readOnly = false, - onFooterRef -}) => { - const { formData, updateField, hasChanges, resetInitialData, discardChanges } = - useGenericFormState(annotation, { - transformData: normalizeShapeAnnotationColors - }); - - const { saveWithCommit, discardWithRevert } = useAnnotationPreviewCommit({ - annotation, - formData, - readOnly, - onPreview, - onPreviewDelete, - onSave, - discardChanges, - snapshot: normalizeShapeAnnotationColors - }); - - useEditorHandlersWithFooterRef({ - formData, - onSave: saveWithCommit, - onClose, - onDelete, - resetInitialData, - discardChanges: discardWithRevert, - onFooterRef, - hasChangesForFooter: hasChanges - }); - - if (!formData) return null; - - const effectiveUpdateField: typeof updateField = readOnly ? () => {} : updateField; - - return ( - -
    - -
    -
    - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/FreeTextEditorView.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/views/FreeTextEditorView.tsx deleted file mode 100644 index c959da6b9..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/FreeTextEditorView.tsx +++ /dev/null @@ -1,90 +0,0 @@ -// Text annotation editor for the ContextPanel. -import React from "react"; -import Box from "@mui/material/Box"; - -import type { FreeTextAnnotation } from "../../../../../shared/types/topology"; -import { useGenericFormState, useEditorHandlersWithFooterRef } from "../../../../hooks/editor"; -import { FIELDSET_RESET_STYLE } from "../ContextPanelScrollArea"; -import { FreeTextFormContent } from "../../free-text-editor/FreeTextFormContent"; -import { useAnnotationPreviewCommit } from "./useAnnotationPreviewCommit"; - -export interface FreeTextEditorViewProps { - annotation: FreeTextAnnotation | null; - onSave: (annotation: FreeTextAnnotation) => void; - /** Live-preview changes on the canvas (visual only, no persist) */ - onPreview?: (annotation: FreeTextAnnotation) => boolean; - /** Remove preview-only annotation (used when discarding a new annotation). */ - onPreviewDelete?: (id: string) => void; - onClose: () => void; - onDelete?: (id: string) => void; - /** Disable editing, but keep scrolling available */ - readOnly?: boolean; - onFooterRef?: (ref: FreeTextEditorFooterRef | null) => void; -} - -export interface FreeTextEditorFooterRef { - handleApply: () => void; - handleSave: () => void; - handleDiscard: () => void; - hasChanges: boolean; -} - -function canSave(data: FreeTextAnnotation): boolean { - return data.text.trim().length > 0; -} - -function cloneAnnotation(annotation: FreeTextAnnotation): FreeTextAnnotation { - return { ...annotation }; -} - -export const FreeTextEditorView: React.FC = ({ - annotation, - onSave, - onPreview, - onPreviewDelete, - onClose, - onDelete, - readOnly = false, - onFooterRef -}) => { - const { formData, updateField, hasChanges, resetInitialData, discardChanges } = - useGenericFormState(annotation, { - getIsNew: (a) => a?.text === "" - }); - - const { saveWithCommit, discardWithRevert } = useAnnotationPreviewCommit({ - annotation, - formData, - readOnly, - onPreview, - onPreviewDelete, - onSave, - discardChanges, - snapshot: cloneAnnotation - }); - - const canSaveNow = formData ? canSave(formData) : false; - useEditorHandlersWithFooterRef({ - formData, - onSave: saveWithCommit, - onClose, - onDelete, - resetInitialData, - discardChanges: discardWithRevert, - onFooterRef, - canSave, - hasChangesForFooter: hasChanges && canSaveNow - }); - - if (!formData) return null; - - const effectiveUpdateField: typeof updateField = readOnly ? () => {} : updateField; - - return ( - -
    - -
    -
    - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/GroupEditorView.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/views/GroupEditorView.tsx deleted file mode 100644 index 317ef6e91..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/GroupEditorView.tsx +++ /dev/null @@ -1,134 +0,0 @@ -// Group editor for the ContextPanel. -import React, { useCallback, useEffect, useRef } from "react"; -import Box from "@mui/material/Box"; - -import type { GroupStyleAnnotation } from "../../../../../shared/types/topology"; -import type { GroupEditorData } from "../../../../hooks/canvas"; -import { useGenericFormState, useEditorHandlersWithFooterRef } from "../../../../hooks/editor"; -import { FIELDSET_RESET_STYLE } from "../ContextPanelScrollArea"; -import { GroupFormContent } from "../../group-editor/GroupFormContent"; - -export interface GroupEditorViewProps { - groupData: GroupEditorData | null; - onSave: (data: GroupEditorData) => void; - onClose: () => void; - onDelete?: (groupId: string) => void; - /** Live-preview style changes on the canvas (visual only, no persist) */ - onStylePreview?: (groupId: string, style: Partial) => void; - /** Disable editing, but keep scrolling available */ - readOnly?: boolean; - onFooterRef?: (ref: GroupEditorFooterRef | null) => void; -} - -export interface GroupEditorFooterRef { - handleApply: () => void; - handleSave: () => void; - handleDiscard: () => void; - hasChanges: boolean; -} - -export const GroupEditorView: React.FC = ({ - groupData, - onSave, - onClose, - onDelete, - onStylePreview, - readOnly = false, - onFooterRef -}) => { - // Stable refs for unmount cleanup - const previewRef = useRef(onStylePreview); - previewRef.current = onStylePreview; - const initialStyleRef = useRef | null>(null); - const groupIdRef = useRef(null); - const hasPreviewRef = useRef(false); - - // Track initial style for revert - useEffect(() => { - if (groupData) { - initialStyleRef.current = { ...groupData.style }; - groupIdRef.current = groupData.id; - hasPreviewRef.current = false; - } - }, [groupData]); - - // Revert on unmount if there are uncommitted preview changes - useEffect(() => { - return () => { - if ( - hasPreviewRef.current && - groupIdRef.current !== null && - initialStyleRef.current !== null - ) { - previewRef.current?.(groupIdRef.current, initialStyleRef.current); - } - }; - }, []); - - const transformData = useCallback( - (data: GroupEditorData) => ({ ...data, style: { ...data.style } }), - [] - ); - - const { formData, updateField, hasChanges, resetInitialData, discardChanges, setFormData } = - useGenericFormState(groupData, { transformData }); - - const updateStyle = useCallback( - (field: K, value: GroupStyleAnnotation[K]) => { - if (readOnly) return; - setFormData((prev) => { - if (!prev) return null; - previewRef.current?.(prev.id, { [field]: value }); - hasPreviewRef.current = true; - return { ...prev, style: { ...prev.style, [field]: value } }; - }); - }, - [setFormData, readOnly] - ); - - // Wrap discard to also revert the canvas preview - const discardWithRevert = useCallback(() => { - discardChanges(); - if (groupIdRef.current !== null && initialStyleRef.current !== null) { - previewRef.current?.(groupIdRef.current, initialStyleRef.current); - hasPreviewRef.current = false; - } - }, [discardChanges]); - - // Wrap save to mark preview as committed - const saveWithCommit = useCallback( - (data: GroupEditorData) => { - hasPreviewRef.current = false; - initialStyleRef.current = { ...data.style }; - onSave(data); - }, - [onSave] - ); - - const effectiveUpdateField: typeof updateField = readOnly ? () => {} : updateField; - - useEditorHandlersWithFooterRef({ - formData, - onSave: saveWithCommit, - onClose, - onDelete, - resetInitialData, - discardChanges: discardWithRevert, - onFooterRef, - hasChangesForFooter: hasChanges - }); - - if (!formData) return null; - - return ( - -
    - -
    -
    - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/InfoTabContent.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/views/InfoTabContent.tsx deleted file mode 100644 index ffe7e7999..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/InfoTabContent.tsx +++ /dev/null @@ -1,39 +0,0 @@ -// Info content for the Info tab. -import React from "react"; -import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; - -import { useContextPanelContent } from "../../../../hooks/ui/useContextPanelContent"; -import type { NodeData, LinkData } from "../../../../hooks/ui"; -import { PanelEmptyState } from "../../../ui/form"; - -import { NodeInfoView } from "./NodeInfoView"; -import { LinkInfoView } from "./LinkInfoView"; - -export interface InfoTabContentProps { - selectedNodeData: NodeData | null; - selectedLinkData: (LinkData & { extraData?: Record }) | null; -} - -/** Placeholder shown when no info view is active */ -const InfoPlaceholder: React.FC = () => ( - } - message="Select a node or link to view its properties." - /> -); - -export const InfoTabContent: React.FC = ({ - selectedNodeData, - selectedLinkData -}) => { - const panelView = useContextPanelContent(); - - switch (panelView.kind) { - case "nodeInfo": - return ; - case "linkInfo": - return ; - default: - return ; - } -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/LinkEditorView.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/views/LinkEditorView.tsx deleted file mode 100644 index 809f91222..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/LinkEditorView.tsx +++ /dev/null @@ -1,165 +0,0 @@ -// Link editor content for the ContextPanel. -import React, { useCallback, useEffect, useRef, useMemo } from "react"; - -import { EditorPanel } from "../../../ui/editor/EditorPanel"; -import type { TabConfig } from "../../../ui/editor/EditorPanel"; -import { useFooterControlsRef } from "../../../../hooks/ui"; -import { useLinkEditorForm } from "../../../../hooks/editor/useLinkEditorForm"; -import type { LinkEditorData, LinkEditorTabId, LinkTabProps } from "../../link-editor/types"; -import { validateLinkEditorData, ExtendedTab } from "../../link-editor/ExtendedTab"; -import { BasicTab } from "../../link-editor/BasicTab"; - -export interface LinkEditorBannerRef { - errors: string[]; -} - -export interface LinkEditorViewProps { - linkData: LinkEditorData | null; - onSave: (data: LinkEditorData) => void; - onApply: (data: LinkEditorData) => void; - /** Live-preview offset changes on the canvas (visual only, no persist) */ - previewOffset?: (data: LinkEditorData) => void; - /** Revert offset preview to initial state */ - revertOffset?: () => void; - /** Disable editing, but keep scrolling and tab navigation available */ - readOnly?: boolean; - onFooterRef?: (ref: LinkEditorFooterRef | null) => void; - onBannerRef?: (ref: LinkEditorBannerRef | null) => void; -} - -export interface LinkEditorFooterRef { - handleApply: () => void; - handleSave: () => void; - handleDiscard: () => void; - hasChanges: boolean; -} - -const ALL_TABS: Array> = [ - { id: "basic", label: "Basic", component: BasicTab }, - { id: "extended", label: "Extended", component: ExtendedTab } -]; - -const BASIC_ONLY_TABS: Array> = [ - { id: "basic", label: "Basic", component: BasicTab } -]; - -function isLinkEditorTabId(value: string): value is LinkEditorTabId { - return value === "basic" || value === "extended"; -} - -export const LinkEditorView: React.FC = ({ - linkData, - onSave, - onApply, - previewOffset, - revertOffset, - readOnly = false, - onFooterRef, - onBannerRef -}) => { - const { - activeTab, - setActiveTab, - formData, - handleChange, - hasChanges, - resetAfterApply, - discardChanges - } = useLinkEditorForm(linkData, readOnly); - - // Stable refs for unmount cleanup - const revertRef = useRef(revertOffset); - revertRef.current = revertOffset; - const hasOffsetPreviewRef = useRef(false); - - // Reset preview flag when the edited link changes - useEffect(() => { - hasOffsetPreviewRef.current = false; - }, [linkData?.id]); - - // Revert on unmount if there are uncommitted offset preview changes - useEffect(() => { - return () => { - if (hasOffsetPreviewRef.current) revertRef.current?.(); - }; - }, []); - - const validationErrors = useMemo(() => { - if (!formData) return []; - return validateLinkEditorData(formData); - }, [formData]); - - // Expose validation errors to ContextPanel banner - useEffect(() => { - onBannerRef?.({ errors: validationErrors }); - return () => onBannerRef?.(null); - }, [validationErrors, onBannerRef]); - - const handleApply = useCallback(() => { - if (formData && validationErrors.length === 0) { - onApply(formData); - resetAfterApply(); - hasOffsetPreviewRef.current = false; - } - }, [formData, validationErrors, onApply, resetAfterApply]); - - const handleSave = useCallback(() => { - if (formData && validationErrors.length === 0) { - onSave(formData); - hasOffsetPreviewRef.current = false; - } - }, [formData, validationErrors, onSave]); - - // Wrap discard to also revert offset preview - const handleDiscard = useCallback(() => { - discardChanges(); - if (hasOffsetPreviewRef.current) { - revertRef.current?.(); - hasOffsetPreviewRef.current = false; - } - }, [discardChanges]); - - // Wrap previewOffset to track preview state - const handlePreviewOffset = useCallback( - (data: LinkEditorData) => { - previewOffset?.(data); - hasOffsetPreviewRef.current = true; - }, - [previewOffset] - ); - - useFooterControlsRef( - onFooterRef, - Boolean(formData), - handleApply, - handleSave, - hasChanges, - handleDiscard - ); - - if (!formData) return null; - - const isVethLink = formData.sourceIsNetwork !== true && formData.targetIsNetwork !== true; - const tabs = isVethLink ? ALL_TABS : BASIC_ONLY_TABS; - const effectiveActiveTab = !isVethLink && activeTab === "extended" ? "basic" : activeTab; - - const tabProps = { - data: formData, - onChange: handleChange, - onPreviewOffset: handlePreviewOffset - }; - - return ( - { - if (isLinkEditorTabId(id)) { - setActiveTab(id); - } - }} - tabProps={tabProps} - readOnly={readOnly} - /> - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/LinkImpairmentView.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/views/LinkImpairmentView.tsx deleted file mode 100644 index 32249c356..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/LinkImpairmentView.tsx +++ /dev/null @@ -1,139 +0,0 @@ -// Link impairment editor content for the ContextPanel. -import React, { useCallback, useMemo } from "react"; - -import { EditorPanel } from "../../../ui/editor/EditorPanel"; -import type { TabConfig } from "../../../ui/editor/EditorPanel"; -import { useFooterControlsRef } from "../../../../hooks/ui"; -import { useLinkImpairmentForm } from "../../../../hooks/editor/useLinkImpairmentForm"; -import type { LinkImpairmentData, LinkImpairmentTabId } from "../../link-impairment/types"; -import { applyNetemSettings } from "../../link-impairment/LinkImpairmentUtils"; -import { - LinkImpairmentTab, - type LinkImpairmentTabProps -} from "../../link-impairment/LinkImpairmentTab"; - -export interface LinkImpairmentViewProps { - linkData: LinkImpairmentData | null; - onError: (error: string) => void; - onSave: (data: LinkImpairmentData) => void; - onApply: (data: LinkImpairmentData) => void; - onClose: () => void; - /** Disable editing, but keep scrolling and tab navigation available */ - readOnly?: boolean; - onFooterRef?: (ref: LinkImpairmentFooterRef | null) => void; -} - -export interface LinkImpairmentFooterRef { - handleApply: () => void; - handleSave: () => void; - handleDiscard: () => void; - hasChanges: boolean; -} - -function isLinkImpairmentTabId(value: string): value is LinkImpairmentTabId { - return value === "source" || value === "target"; -} - -export const LinkImpairmentView: React.FC = ({ - linkData, - onError, - onSave, - onApply, - onClose, - readOnly = false, - onFooterRef -}) => { - const { - activeTab, - setActiveTab, - formData, - handleChange, - hasChanges, - resetAfterApply, - discardChanges, - validationErrors - } = useLinkImpairmentForm(linkData, readOnly); - - const handleSave = useCallback(() => { - if (readOnly) return; - if (!formData) return; - if (validationErrors.length > 0) { - validationErrors.forEach(onError); - return; - } - applyNetemSettings(formData); - onSave(formData); - onClose(); - }, [formData, onClose, onError, onSave, readOnly, validationErrors]); - - const handleApply = useCallback(() => { - if (readOnly) return; - if (!formData) return; - if (validationErrors.length > 0) { - validationErrors.forEach(onError); - return; - } - applyNetemSettings(formData); - onApply(formData); - resetAfterApply(); - }, [formData, onApply, onError, readOnly, resetAfterApply, validationErrors]); - - // Dynamic tabs based on endpoint names - const tabs: Array> = useMemo( - () => - formData - ? [ - { - id: "source", - label: `${formData.source}:${formData.sourceEndpoint}`, - component: LinkImpairmentTab - }, - { - id: "target", - label: `${formData.target}:${formData.targetEndpoint}`, - component: LinkImpairmentTab - } - ] - : [], - [formData] - ); - - // Props specific to the active tab's endpoint - const tabProps = useMemo( - () => - formData - ? { - key: formData.id + activeTab, - data: - activeTab === "source" ? (formData.sourceNetem ?? {}) : (formData.targetNetem ?? {}), - onChange: handleChange - } - : { key: "", data: {}, onChange: handleChange }, - [formData, activeTab, handleChange] - ); - - useFooterControlsRef( - onFooterRef, - Boolean(formData), - handleApply, - handleSave, - hasChanges, - discardChanges - ); - - if (!formData) return null; - - return ( - { - if (isLinkImpairmentTabId(id)) { - setActiveTab(id); - } - }} - tabProps={tabProps} - readOnly={readOnly} - /> - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/LinkInfoView.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/views/LinkInfoView.tsx deleted file mode 100644 index c37e639a2..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/LinkInfoView.tsx +++ /dev/null @@ -1,123 +0,0 @@ -// Link info view with endpoint tabs. -import React, { useState } from "react"; -import Box from "@mui/material/Box"; - -import type { LinkData } from "../../../../hooks/ui"; -import type { InterfaceStatsPayload } from "../../../../../shared/types/topology"; -import { getString } from "../../../../../shared/utilities/typeHelpers"; -import { TrafficChart } from "../../TrafficChart"; -import type { TabDefinition } from "../../../ui/editor"; -import { TabNavigation } from "../../../ui/editor/TabNavigation"; -import { PanelSectionHeader, ReadOnlyCopyField } from "../../../ui/form"; - -interface EndpointData { - node?: string; - interface?: string; - mac?: string; - mtu?: string | number; - type?: string; - stats?: InterfaceStatsPayload; -} - -type LinkInfoData = LinkData & { - endpointA?: EndpointData; - endpointB?: EndpointData; - extraData?: { - clabSourceStats?: InterfaceStatsPayload; - clabTargetStats?: InterfaceStatsPayload; - clabSourceMacAddress?: string; - clabTargetMacAddress?: string; - clabSourceMtu?: string | number; - clabTargetMtu?: string | number; - clabSourceType?: string; - clabTargetType?: string; - [key: string]: unknown; - }; -}; - -export interface LinkInfoViewProps { - linkData: LinkInfoData | null; -} - -type EndpointTab = "a" | "b"; - -function toEndpointTab(id: string): EndpointTab { - return id === "b" ? "b" : "a"; -} - -function getEndpoints(linkData: LinkInfoData): { a: EndpointData; b: EndpointData } { - const extraData = linkData.extraData ?? {}; - const sourceMac = getString(extraData.clabSourceMacAddress) ?? ""; - const sourceType = getString(extraData.clabSourceType) ?? ""; - const targetMac = getString(extraData.clabTargetMacAddress) ?? ""; - const targetType = getString(extraData.clabTargetType) ?? ""; - - const a: EndpointData = linkData.endpointA ?? { - node: linkData.source, - interface: linkData.sourceEndpoint ?? "", - mac: sourceMac, - mtu: extraData.clabSourceMtu ?? "", - type: sourceType, - stats: extraData.clabSourceStats - }; - - const b: EndpointData = linkData.endpointB ?? { - node: linkData.target, - interface: linkData.targetEndpoint ?? "", - mac: targetMac, - mtu: extraData.clabTargetMtu ?? "", - type: targetType, - stats: extraData.clabTargetStats - }; - - return { a, b }; -} - -export const LinkInfoView: React.FC = ({ linkData }) => { - const [activeTab, setActiveTab] = useState("a"); - - if (!linkData) return null; - - const endpoints = getEndpoints(linkData); - const currentEndpoint = activeTab === "a" ? endpoints.a : endpoints.b; - const endpointKey = `${activeTab}:${currentEndpoint.node}:${currentEndpoint.interface}`; - - const endpointTabs: TabDefinition[] = [ - { id: "a", label: `${linkData.source}:${linkData.sourceEndpoint ?? "eth"}` }, - { id: "b", label: `${linkData.target}:${linkData.targetEndpoint ?? "eth"}` } - ]; - - return ( - - setActiveTab(toEndpointTab(id))} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/NetworkEditorView.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/views/NetworkEditorView.tsx deleted file mode 100644 index 1aa82739a..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/NetworkEditorView.tsx +++ /dev/null @@ -1,253 +0,0 @@ -// Network editor content for the ContextPanel. -import React, { useCallback } from "react"; -import Box from "@mui/material/Box"; - -import { - InputField, - FilterableDropdown, - Section, - KeyValueList, - PanelSection -} from "../../../ui/form"; -import { EditorPanel } from "../../../ui/editor/EditorPanel"; -import { useApplySaveHandlers, useFooterControlsRef } from "../../../../hooks/ui"; -import { useNetworkEditorForm } from "../../../../hooks/editor/useNetworkEditorForm"; -import type { NetworkEditorData, NetworkType } from "../../network-editor/types"; -import { - NETWORK_TYPES, - VXLAN_TYPES, - BRIDGE_TYPES, - MACVLAN_MODES, - getInterfaceLabel, - getInterfacePlaceholder, - showInterfaceField, - supportsExtendedProps -} from "../../network-editor/types"; - -export interface NetworkEditorViewProps { - nodeData: NetworkEditorData | null; - onSave: (data: NetworkEditorData) => void; - onApply: (data: NetworkEditorData) => void; - /** Disable editing, but keep scrolling available */ - readOnly?: boolean; - onFooterRef?: (ref: NetworkEditorFooterRef | null) => void; -} - -export interface NetworkEditorFooterRef { - handleApply: () => void; - handleSave: () => void; - handleDiscard: () => void; - hasChanges: boolean; -} - -const NETWORK_TYPE_OPTIONS = NETWORK_TYPES.map((type) => ({ value: type, label: type })); -const MACVLAN_MODE_OPTIONS = MACVLAN_MODES.map((mode) => ({ value: mode, label: mode })); - -function isNetworkType(value: string): value is NetworkType { - switch (value) { - case "host": - case "mgmt-net": - case "macvlan": - case "vxlan": - case "vxlan-stitch": - case "dummy": - case "bridge": - case "ovs-bridge": - return true; - default: - return false; - } -} - -// This form is intentionally dense (conditional sections depend on network type). -/* eslint-disable complexity */ -const NetworkEditorContent: React.FC<{ - formData: NetworkEditorData; - onChange: (updates: Partial) => void; -}> = ({ formData, onChange }) => { - const handleNetworkTypeChange = useCallback( - (newType: NetworkType) => { - const updates: Partial = { networkType: newType }; - if (!VXLAN_TYPES.includes(newType)) { - updates.vxlanRemote = undefined; - updates.vxlanVni = undefined; - updates.vxlanDstPort = undefined; - updates.vxlanSrcPort = undefined; - } - if (newType !== "macvlan") updates.macvlanMode = undefined; - onChange(updates); - }, - [onChange] - ); - - return ( - - - { - if (isNetworkType(v)) { - handleNetworkTypeChange(v); - } - }} - placeholder="Select network type..." - allowFreeText={false} - /> - - {showInterfaceField(formData.networkType) && ( - onChange({ interfaceName: v })} - placeholder={getInterfacePlaceholder(formData.networkType)} - /> - )} - - {(BRIDGE_TYPES.includes(formData.networkType) || - VXLAN_TYPES.includes(formData.networkType)) && ( - onChange({ label: v })} - placeholder="Display label for the network node" - /> - )} - - {formData.networkType === "macvlan" && ( - onChange({ macvlanMode: v })} - placeholder="Select mode..." - allowFreeText={false} - /> - )} - - onChange({ mac: v })} - placeholder="e.g., 00:11:22:33:44:55 (optional)" - /> - - - {/* VXLAN Settings */} - {VXLAN_TYPES.includes(formData.networkType) && ( - - - onChange({ vxlanRemote: v })} - placeholder="Remote endpoint IP address" - /> - - onChange({ vxlanVni: v })} - placeholder="e.g., 100" - /> - onChange({ vxlanDstPort: v })} - placeholder="e.g., 4789" - /> - onChange({ vxlanSrcPort: v })} - placeholder="e.g., 0" - /> - - - - )} - - {/* Extended Properties */} - {supportsExtendedProps(formData.networkType) && ( - - - onChange({ mtu: v })} - placeholder="e.g., 9000" - min={1} - max={65535} - /> -
    - onChange({ vars: items })} - keyPlaceholder="Variable name" - valuePlaceholder="Value" - addLabel="Add Variable" - /> -
    -
    - onChange({ labels: items })} - keyPlaceholder="Label name" - valuePlaceholder="Value" - addLabel="Add Label" - /> -
    -
    -
    - )} -
    - ); -}; -/* eslint-enable complexity */ - -export const NetworkEditorView: React.FC = ({ - nodeData, - onSave, - onApply, - readOnly = false, - onFooterRef -}) => { - const { formData, handleChange, hasChanges, resetInitialData, discardChanges } = - useNetworkEditorForm(nodeData, readOnly); - - const { handleApply, handleSave } = useApplySaveHandlers( - formData, - onApply, - onSave, - resetInitialData - ); - - useFooterControlsRef( - onFooterRef, - Boolean(formData), - handleApply, - handleSave, - hasChanges, - discardChanges - ); - - if (!formData) return null; - - return ( - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/NodeEditorView.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/views/NodeEditorView.tsx deleted file mode 100644 index d5a31bfbd..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/NodeEditorView.tsx +++ /dev/null @@ -1,158 +0,0 @@ -// Node editor content for the ContextPanel. -import React, { useEffect, useMemo } from "react"; - -import { EditorPanel } from "../../../ui/editor/EditorPanel"; -import type { TabConfig } from "../../../ui/editor/EditorPanel"; -import { useApplySaveHandlers, useFooterControlsRef } from "../../../../hooks/ui"; -import { useNodeEditorForm, hasFieldChanged } from "../../../../hooks/editor/useNodeEditorForm"; -import type { - NodeEditorData, - NodeEditorTabId, - TabProps as NodeEditorTabProps -} from "../../node-editor/types"; -import { BasicTab } from "../../node-editor/BasicTab"; -import { ComponentsTab } from "../../node-editor/ComponentsTab"; -import { ConfigTab } from "../../node-editor/ConfigTab"; -import { RuntimeTab } from "../../node-editor/RuntimeTab"; -import { NetworkTab } from "../../node-editor/NetworkTab"; -import { AdvancedTab } from "../../node-editor/AdvancedTab"; - -export interface NodeEditorViewProps { - nodeData: NodeEditorData | null; - onSave: (data: NodeEditorData) => void; - onApply: (data: NodeEditorData) => void; - onPreview?: (data: NodeEditorData) => void; - inheritedProps?: string[]; - /** Show only visual node controls (Icon + Label & Direction). */ - visualOnly?: boolean; - /** Disable editing, but keep scrolling and tab navigation available */ - readOnly?: boolean; - /** Exposed for ContextPanel footer */ - onFooterRef?: (ref: NodeEditorFooterRef | null) => void; -} - -export interface NodeEditorFooterRef { - handleApply: () => void; - handleSave: () => void; - handleDiscard: () => void; - hasChanges: boolean; -} - -const BASE_TABS: Array> = [ - { id: "basic", label: "Basic", component: BasicTab }, - { id: "config", label: "Configuration", component: ConfigTab }, - { id: "runtime", label: "Runtime", component: RuntimeTab }, - { id: "network", label: "Network", component: NetworkTab }, - { id: "advanced", label: "Advanced", component: AdvancedTab } -]; - -const COMPONENTS_TAB: TabConfig = { - id: "components", - label: "Components", - component: ComponentsTab -}; - -function getTabsForNode(kind: string | undefined): Array> { - if (kind === "nokia_srsim") { - return [BASE_TABS[0], COMPONENTS_TAB, ...BASE_TABS.slice(1)]; - } - return BASE_TABS; -} - -function isNodeEditorTabId(value: string): value is NodeEditorTabId { - switch (value) { - case "basic": - case "components": - case "config": - case "runtime": - case "network": - case "advanced": - return true; - default: - return false; - } -} - -export const NodeEditorView: React.FC = ({ - nodeData, - onSave, - onApply, - onPreview, - inheritedProps = [], - visualOnly = false, - readOnly = false, - onFooterRef -}) => { - const { - activeTab, - setActiveTab, - formData, - handleChange, - hasChanges, - resetAfterApply, - discardChanges, - originalData - } = useNodeEditorForm(nodeData, readOnly); - - const tabs = useMemo(() => { - if (visualOnly) { - return [BASE_TABS[0]]; - } - return getTabsForNode(formData?.kind); - }, [formData?.kind, visualOnly]); - - const effectiveInheritedProps = useMemo(() => { - if (!formData || !originalData) return inheritedProps; - return inheritedProps.filter((prop) => !hasFieldChanged(prop, formData, originalData)); - }, [inheritedProps, formData, originalData]); - - const { handleApply, handleSave } = useApplySaveHandlers( - formData, - onApply, - onSave, - resetAfterApply - ); - - useEffect(() => { - if (!formData || readOnly || !onPreview) return; - onPreview(formData); - }, [formData, readOnly, onPreview]); - - useEffect(() => { - if (visualOnly && activeTab !== "basic") { - setActiveTab("basic"); - } - }, [activeTab, setActiveTab, visualOnly]); - - useFooterControlsRef( - onFooterRef, - Boolean(formData), - handleApply, - handleSave, - hasChanges, - discardChanges - ); - - if (!formData) return null; - - const tabProps = { - data: formData, - onChange: handleChange, - inheritedProps: effectiveInheritedProps, - visualOnly - }; - - return ( - { - if (isNodeEditorTabId(id)) { - setActiveTab(id); - } - }} - tabProps={tabProps} - readOnly={readOnly} - /> - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/NodeInfoView.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/views/NodeInfoView.tsx deleted file mode 100644 index 4dce35ad8..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/NodeInfoView.tsx +++ /dev/null @@ -1,85 +0,0 @@ -// Node info view with read-only fields. -import React from "react"; -import Box from "@mui/material/Box"; - -import { getRecordUnknown, getString } from "../../../../../shared/utilities/typeHelpers"; -import type { NodeData } from "../../../../hooks/ui"; -import { PanelSectionHeader, ReadOnlyCopyField } from "../../../ui/form"; - -export interface NodeInfoViewProps { - nodeData: NodeData | null; -} - -function getNodeProperty( - extraData: Record | undefined, - nodeData: NodeData, - extraKey: string, - topLevelKey: keyof NodeData -): string { - const fromExtra = getString(extraData?.[extraKey]); - if (fromExtra !== undefined && fromExtra.length > 0) return fromExtra; - - const fromTopLevel = getString(nodeData[topLevelKey]); - if (fromTopLevel !== undefined && fromTopLevel.length > 0) return fromTopLevel; - - return ""; -} - -function extractNodeDisplayProps(nodeData: NodeData) { - const extraData = getRecordUnknown(nodeData.extraData); - const label = getString(nodeData.label); - const name = getString(nodeData.name); - const nodeId = nodeData.id; - let nodeName = "Unknown"; - if (label !== undefined && label.length > 0) { - nodeName = label; - } else if (name !== undefined && name.length > 0) { - nodeName = name; - } else if (nodeId.length > 0) { - nodeName = nodeId; - } - - return { - nodeName, - kind: getNodeProperty(extraData, nodeData, "kind", "kind"), - state: getNodeProperty(extraData, nodeData, "state", "state"), - image: getNodeProperty(extraData, nodeData, "image", "image"), - mgmtIpv4: getNodeProperty(extraData, nodeData, "mgmtIpv4Address", "mgmtIpv4"), - mgmtIpv6: getNodeProperty(extraData, nodeData, "mgmtIpv6Address", "mgmtIpv6"), - fqdn: getNodeProperty(extraData, nodeData, "fqdn", "fqdn") - }; -} - -export const NodeInfoView: React.FC = ({ nodeData }) => { - if (!nodeData) return null; - - const { nodeName, kind, state, image, mgmtIpv4, mgmtIpv6, fqdn } = - extractNodeDisplayProps(nodeData); - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/PaletteView.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/views/PaletteView.tsx deleted file mode 100644 index c6a215f9a..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/PaletteView.tsx +++ /dev/null @@ -1,165 +0,0 @@ -// Palette view with editor and info tab content. -import React, { useCallback, useMemo, useRef } from "react"; - -import { PaletteSection } from "../../lab-drawer/PaletteSection"; -import { NodeTemplateModal } from "../../node-editor/NodeTemplateModal"; -import { usePanelTabVisibility } from "../../../../hooks/ui/usePanelTabVisibility"; -import type { NodeData, LinkData } from "../../../../hooks/ui"; - -import type { ContextPanelEditorState, EditorFooterRef, EditorBannerRef } from "./editorTypes"; -import { EditorTabContent } from "./EditorTabContent"; -import { InfoTabContent } from "./InfoTabContent"; - -type FooterRefLocal = EditorFooterRef | null; - -export interface PaletteViewProps { - mode?: "edit" | "view"; - isLocked?: boolean; - requestedTab?: { tabId: string }; - onEditCustomNode: (name: string) => void; - onDeleteCustomNode: (name: string) => void; - onSetDefaultCustomNode: (name: string) => void; - selectedNodeData?: NodeData | null; - selectedLinkData?: (LinkData & { extraData?: Record }) | null; - editor?: ContextPanelEditorState; - onFooterRef?: (ref: EditorFooterRef | null) => void; - onBannerRef?: (ref: EditorBannerRef | null) => void; -} - -const NOOP_BANNER = () => {}; - -export const PaletteView: React.FC = ({ - mode, - isLocked, - requestedTab, - onEditCustomNode, - onDeleteCustomNode, - onSetDefaultCustomNode, - selectedNodeData, - selectedLinkData, - editor, - onFooterRef, - onBannerRef -}) => { - const localFooterRef = useRef(null); - - const wrappedOnFooterRef = useCallback( - (ref: EditorFooterRef | null) => { - localFooterRef.current = ref; - onFooterRef?.(ref); - }, - [onFooterRef] - ); - - const { showInfoTab, showEditTab, infoTabTitle, editTabTitle } = usePanelTabVisibility(); - - const editDeleteHandler = useMemo(() => { - if (!editor) return undefined; - if (editor.editingNodeData && editor.nodeEditorHandlers.handleDelete) { - const del = editor.nodeEditorHandlers.handleDelete; - const close = editor.nodeEditorHandlers.handleClose; - return () => { - del(); - close(); - }; - } - if (editor.editingLinkData && editor.linkEditorHandlers.handleDelete) { - const del = editor.linkEditorHandlers.handleDelete; - const close = editor.linkEditorHandlers.handleClose; - return () => { - del(); - close(); - }; - } - if (editor.editingTextAnnotation) { - const id = editor.editingTextAnnotation.id; - return () => { - editor.textAnnotationHandlers.onDelete(id); - editor.textAnnotationHandlers.onClose(); - }; - } - if (editor.editingShapeAnnotation) { - const id = editor.editingShapeAnnotation.id; - return () => { - editor.shapeAnnotationHandlers.onDelete(id); - editor.shapeAnnotationHandlers.onClose(); - }; - } - if (editor.editingTrafficRateAnnotation) { - const id = editor.editingTrafficRateAnnotation.id; - return () => { - editor.trafficRateAnnotationHandlers.onDelete(id); - editor.trafficRateAnnotationHandlers.onClose(); - }; - } - if (editor.editingGroup) { - const id = editor.editingGroup.id; - return () => { - editor.groupHandlers.onDelete(id); - editor.groupHandlers.onClose(); - }; - } - return undefined; - }, [editor]); - - const handleEditTabLeave = useCallback(() => { - localFooterRef.current?.handleDiscard(); - wrappedOnFooterRef(null); - }, [wrappedOnFooterRef]); - - const infoTabContent = ( - - ); - - const editTabContent = editor ? ( - - ) : null; - - return ( - <> - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/TrafficRateEditorView.tsx b/src/reactTopoViewer/webview/components/panels/context-panel/views/TrafficRateEditorView.tsx deleted file mode 100644 index f1fee2cbe..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/TrafficRateEditorView.tsx +++ /dev/null @@ -1,667 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef } from "react"; -import Box from "@mui/material/Box"; - -import type { TrafficRateAnnotation } from "../../../../../shared/types/topology"; -import { useGenericFormState, useEditorHandlersWithFooterRef } from "../../../../hooks/editor"; -import { useGraphStore } from "../../../../stores/graphStore"; -import { resolveComputedColor } from "../../../../utils/color"; -import { getTrafficMonitorOptions } from "../../../../utils/trafficRateAnnotation"; -import { CheckboxField, ColorField, InputField, PanelSection, SelectField } from "../../../ui/form"; -import { FIELDSET_RESET_STYLE } from "../ContextPanelScrollArea"; - -export interface TrafficRateEditorViewProps { - annotation: TrafficRateAnnotation | null; - onSave: (annotation: TrafficRateAnnotation) => void; - onPreview?: (annotation: TrafficRateAnnotation) => void; - onClose: () => void; - onDelete?: (id: string) => void; - readOnly?: boolean; - onFooterRef?: (ref: TrafficRateEditorFooterRef | null) => void; -} - -export interface TrafficRateEditorFooterRef { - handleApply: () => void; - handleSave: () => void; - handleDiscard: () => void; - hasChanges: boolean; -} - -const DEFAULT_WIDTH = 280; -const DEFAULT_HEIGHT = 170; -const DEFAULT_TEXT_WIDTH = 100; -const DEFAULT_TEXT_HEIGHT = 30; -const DEFAULT_BACKGROUND_OPACITY = 20; -const DEFAULT_BORDER_WIDTH = 1; -const DEFAULT_BORDER_RADIUS_CHART = 8; -const DEFAULT_BORDER_RADIUS_TEXT = 4; -const FALLBACK_BACKGROUND_COLOR = "#1e1e1e"; -const FALLBACK_BORDER_COLOR = "#3f3f46"; -const FALLBACK_TEXT_COLOR = "#9aa0a6"; - -type TrafficRateMode = "chart" | "text"; -type TrafficRateTextMetric = "combined" | "rx" | "tx"; - -interface TrafficRateSizeConfig { - defaultWidthForMode: number; - defaultHeightForMode: number; - defaultBorderRadiusForMode: number; - width: number; - height: number; - widthMin: number; - heightMin: number; -} - -interface TrafficRateEditorResolvedFields { - nodeIdValue: string; - interfaceNameValue: string; - backgroundColorValue: string; - borderColorValue: string; - borderStyleValue: NonNullable; - textColorValue: string; - opacityValue: string; - borderWidthValue: string; - borderRadiusValue: string; - showLegendChecked: boolean; -} - -function getThemeTrafficRateDefaults(): { - backgroundColor: string; - borderColor: string; - textColor: string; -} { - return { - backgroundColor: resolveComputedColor("--vscode-editor-background", FALLBACK_BACKGROUND_COLOR), - borderColor: resolveComputedColor("--vscode-panel-border", FALLBACK_BORDER_COLOR), - textColor: resolveComputedColor("--vscode-descriptionForeground", FALLBACK_TEXT_COLOR) - }; -} - -function canSave(annotation: TrafficRateAnnotation): boolean { - return ( - typeof annotation.nodeId === "string" && - annotation.nodeId.trim().length > 0 && - typeof annotation.interfaceName === "string" && - annotation.interfaceName.trim().length > 0 - ); -} - -function parseOptionalNumber(value: string): number | undefined { - const trimmed = value.trim(); - if (!trimmed) return undefined; - const parsed = Number(trimmed); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function parseClampedOrDefault(value: string, fallback: number, min: number, max: number): number { - const parsed = parseOptionalNumber(value); - if (parsed === undefined) return fallback; - return clamp(parsed, min, max); -} - -function resolveNodeInterfaceOptions( - nodeId: string | undefined, - interfacesByNode: Map -): string[] { - if (nodeId === undefined || nodeId.length === 0) return []; - return interfacesByNode.get(nodeId) ?? []; -} - -function resolveCurrentInterfaceName( - interfaceName: TrafficRateAnnotation["interfaceName"] -): string { - return typeof interfaceName === "string" ? interfaceName : ""; -} - -function resolveNextInterfaceName(availableInterfaces: string[], currentInterface: string): string { - if (availableInterfaces.includes(currentInterface)) return currentInterface; - return availableInterfaces[0] ?? ""; -} - -function resolveShowLegendValue(checked: boolean): boolean | undefined { - if (!checked) return false; - return undefined; -} - -function resolveTrafficRateMode(mode: string | undefined): TrafficRateMode { - return mode === "text" ? "text" : "chart"; -} - -function resolveTrafficRateTextMetric(textMetric: string | undefined): TrafficRateTextMetric { - if (textMetric === "rx" || textMetric === "tx") return textMetric; - return "combined"; -} - -function resolveBorderStyle(style: string): NonNullable { - return style === "dashed" || style === "dotted" || style === "double" ? style : "solid"; -} - -function resolveTrafficRateSizeConfig( - formData: TrafficRateAnnotation, - mode: TrafficRateMode -): TrafficRateSizeConfig { - const defaultWidthForMode = mode === "text" ? DEFAULT_TEXT_WIDTH : DEFAULT_WIDTH; - const defaultHeightForMode = mode === "text" ? DEFAULT_TEXT_HEIGHT : DEFAULT_HEIGHT; - const defaultBorderRadiusForMode = - mode === "text" ? DEFAULT_BORDER_RADIUS_TEXT : DEFAULT_BORDER_RADIUS_CHART; - const width = formData.width ?? defaultWidthForMode; - const height = formData.height ?? defaultHeightForMode; - const widthMin = mode === "text" ? 1 : 180; - const heightMin = mode === "text" ? 1 : 120; - return { - defaultWidthForMode, - defaultHeightForMode, - defaultBorderRadiusForMode, - width, - height, - widthMin, - heightMin - }; -} - -function resolveModeFieldOverrides( - formData: TrafficRateAnnotation, - nextMode: TrafficRateMode -): Partial> { - if (nextMode === "text") { - return { - width: - formData.width === undefined || formData.width === DEFAULT_WIDTH - ? DEFAULT_TEXT_WIDTH - : undefined, - height: - formData.height === undefined || formData.height === DEFAULT_HEIGHT - ? DEFAULT_TEXT_HEIGHT - : undefined, - borderRadius: - formData.borderRadius === undefined || formData.borderRadius === DEFAULT_BORDER_RADIUS_CHART - ? DEFAULT_BORDER_RADIUS_TEXT - : undefined - }; - } - - return { - width: - formData.width === undefined || formData.width === DEFAULT_TEXT_WIDTH - ? DEFAULT_WIDTH - : undefined, - height: - formData.height === undefined || formData.height === DEFAULT_TEXT_HEIGHT - ? DEFAULT_HEIGHT - : undefined, - borderRadius: - formData.borderRadius === undefined || formData.borderRadius === DEFAULT_BORDER_RADIUS_TEXT - ? DEFAULT_BORDER_RADIUS_CHART - : undefined - }; -} - -function buildNodeSelectOptions(nodeOptions: string[]): Array<{ value: string; label: string }> { - return [ - { value: "", label: "Select node" }, - ...nodeOptions.map((nodeId) => ({ - value: nodeId, - label: nodeId - })) - ]; -} - -function buildInterfaceSelectOptions( - nodeId: string | undefined, - interfaceOptions: string[] -): Array<{ value: string; label: string }> { - const hasNodeId = nodeId !== undefined && nodeId.length > 0; - return [ - { value: "", label: hasNodeId ? "Select interface" : "Select node first" }, - ...interfaceOptions.map((interfaceName) => ({ - value: interfaceName, - label: interfaceName - })) - ]; -} - -function useTrafficRatePreviewLifecycle(params: { - annotation: TrafficRateAnnotation | null; - formData: TrafficRateAnnotation | null; - readOnly: boolean; - onPreview: ((annotation: TrafficRateAnnotation) => void) | undefined; -}) { - const previewRef = useRef(params.onPreview); - previewRef.current = params.onPreview; - const initialAnnotationRef = useRef(null); - const initialSerializedRef = useRef(null); - const hasPreviewRef = useRef(false); - - useEffect(() => { - if (!params.annotation) { - initialAnnotationRef.current = null; - initialSerializedRef.current = null; - hasPreviewRef.current = false; - return; - } - - initialAnnotationRef.current = { ...params.annotation }; - initialSerializedRef.current = JSON.stringify(params.annotation); - hasPreviewRef.current = false; - }, [params.annotation]); - - useEffect(() => { - if (params.readOnly || !params.formData || !initialAnnotationRef.current) return; - if (!previewRef.current) return; - - const serialized = JSON.stringify(params.formData); - if (serialized === initialSerializedRef.current) return; - - previewRef.current(params.formData); - hasPreviewRef.current = true; - }, [params.formData, params.readOnly]); - - // Revert live preview when leaving editor without apply/save. - useEffect(() => { - return () => { - if (!hasPreviewRef.current || !initialAnnotationRef.current) return; - previewRef.current?.(initialAnnotationRef.current); - }; - }, []); - - return { previewRef, initialAnnotationRef, initialSerializedRef, hasPreviewRef }; -} - -function resolveEditorResolvedFields( - formData: TrafficRateAnnotation, - themeDefaults: { backgroundColor: string; borderColor: string; textColor: string }, - sizeConfig: TrafficRateSizeConfig -): TrafficRateEditorResolvedFields { - return { - nodeIdValue: formData.nodeId ?? "", - interfaceNameValue: formData.interfaceName ?? "", - backgroundColorValue: formData.backgroundColor ?? themeDefaults.backgroundColor, - borderColorValue: formData.borderColor ?? themeDefaults.borderColor, - borderStyleValue: formData.borderStyle ?? "solid", - textColorValue: formData.textColor ?? themeDefaults.textColor, - opacityValue: String(formData.backgroundOpacity ?? DEFAULT_BACKGROUND_OPACITY), - borderWidthValue: String(formData.borderWidth ?? DEFAULT_BORDER_WIDTH), - borderRadiusValue: String(formData.borderRadius ?? sizeConfig.defaultBorderRadiusForMode), - showLegendChecked: formData.showLegend !== false - }; -} - -type TrafficRateUpdateField = ( - field: K, - value: TrafficRateAnnotation[K] -) => void; - -function useTrafficRateNodeChangeHandler( - formData: TrafficRateAnnotation | null, - updateField: TrafficRateUpdateField, - interfacesByNode: Map -) { - return useCallback( - (nodeId: string) => { - if (!formData) return; - updateField("nodeId", nodeId); - const availableInterfaces = interfacesByNode.get(nodeId) ?? []; - const currentInterface = resolveCurrentInterfaceName(formData.interfaceName); - updateField("interfaceName", resolveNextInterfaceName(availableInterfaces, currentInterface)); - }, - [formData, interfacesByNode, updateField] - ); -} - -function applyModeOverrides( - updateField: TrafficRateUpdateField, - overrides: Partial> -): void { - if (overrides.width !== undefined) updateField("width", overrides.width); - if (overrides.height !== undefined) updateField("height", overrides.height); - if (overrides.borderRadius !== undefined) updateField("borderRadius", overrides.borderRadius); -} - -function useTrafficRateModeChangeHandler( - formData: TrafficRateAnnotation | null, - updateField: TrafficRateUpdateField -) { - return useCallback( - (value: string) => { - if (!formData) return; - const nextMode = resolveTrafficRateMode(value); - updateField("mode", nextMode); - applyModeOverrides(updateField, resolveModeFieldOverrides(formData, nextMode)); - }, - [formData, updateField] - ); -} - -function useTrafficRateCommitHandlers(params: { - onSave: (annotation: TrafficRateAnnotation) => void; - discardChanges: () => void; - previewRef: { current: ((annotation: TrafficRateAnnotation) => void) | undefined }; - initialAnnotationRef: { current: TrafficRateAnnotation | null }; - initialSerializedRef: { current: string | null }; - hasPreviewRef: { current: boolean }; -}) { - const { - onSave, - discardChanges, - previewRef, - initialAnnotationRef, - initialSerializedRef, - hasPreviewRef - } = params; - - const saveWithCommit = useCallback( - (next: TrafficRateAnnotation) => { - hasPreviewRef.current = false; - initialAnnotationRef.current = { ...next }; - initialSerializedRef.current = JSON.stringify(next); - onSave(next); - }, - [hasPreviewRef, initialAnnotationRef, initialSerializedRef, onSave] - ); - - const discardWithRevert = useCallback(() => { - discardChanges(); - if (initialAnnotationRef.current) { - previewRef.current?.(initialAnnotationRef.current); - } - hasPreviewRef.current = false; - }, [discardChanges, initialAnnotationRef, previewRef, hasPreviewRef]); - - return { saveWithCommit, discardWithRevert }; -} - -function resolveCanSaveNow(formData: TrafficRateAnnotation | null): boolean { - if (!formData) return false; - return canSave(formData); -} - -export const TrafficRateEditorView: React.FC = ({ - annotation, - onSave, - onPreview, - onClose, - onDelete, - readOnly = false, - onFooterRef -}) => { - const graphNodes = useGraphStore((state) => state.nodes); - const edges = useGraphStore((state) => state.edges); - - const { formData, updateField, hasChanges, resetInitialData, discardChanges } = - useGenericFormState(annotation); - const { previewRef, initialAnnotationRef, initialSerializedRef, hasPreviewRef } = - useTrafficRatePreviewLifecycle({ - annotation, - formData, - readOnly, - onPreview - }); - - const topologyNodeIds = useMemo(() => { - return graphNodes - .filter((node) => node.type === "topology-node") - .map((node) => node.id) - .sort((a, b) => a.localeCompare(b)); - }, [graphNodes]); - - const trafficOptions = useMemo(() => getTrafficMonitorOptions(edges), [edges]); - - const nodeOptions = useMemo(() => { - const ids = new Set([...topologyNodeIds, ...trafficOptions.nodeIds]); - return Array.from(ids).sort((a, b) => a.localeCompare(b)); - }, [topologyNodeIds, trafficOptions.nodeIds]); - - const interfaceOptions = useMemo(() => { - return resolveNodeInterfaceOptions(formData?.nodeId, trafficOptions.interfacesByNode); - }, [trafficOptions.interfacesByNode, formData?.nodeId]); - - const nodeSelectOptions = useMemo(() => buildNodeSelectOptions(nodeOptions), [nodeOptions]); - - const interfaceSelectOptions = useMemo( - () => buildInterfaceSelectOptions(formData?.nodeId, interfaceOptions), - [formData?.nodeId, interfaceOptions] - ); - - const handleNodeChange = useTrafficRateNodeChangeHandler( - formData, - updateField, - trafficOptions.interfacesByNode - ); - const handleModeChange = useTrafficRateModeChangeHandler(formData, updateField); - - const canSaveNow = resolveCanSaveNow(formData); - const { saveWithCommit, discardWithRevert } = useTrafficRateCommitHandlers({ - onSave, - discardChanges, - previewRef, - initialAnnotationRef, - initialSerializedRef, - hasPreviewRef - }); - - useEditorHandlersWithFooterRef({ - formData, - onSave: saveWithCommit, - onClose, - onDelete, - resetInitialData, - discardChanges: discardWithRevert, - onFooterRef, - canSave, - hasChangesForFooter: hasChanges && canSaveNow - }); - - if (!formData) return null; - - const mode = resolveTrafficRateMode(formData.mode); - const textMetric = resolveTrafficRateTextMetric(formData.textMetric); - const themeDefaults = getThemeTrafficRateDefaults(); - const sizeConfig = resolveTrafficRateSizeConfig(formData, mode); - const resolvedFields = resolveEditorResolvedFields(formData, themeDefaults, sizeConfig); - - return ( - -
    - - - <> - - {mode === "text" && ( - - updateField("textMetric", resolveTrafficRateTextMetric(value)) - } - options={[ - { value: "combined", label: "Combined (RX + TX)" }, - { value: "rx", label: "RX only" }, - { value: "tx", label: "TX only" } - ]} - /> - )} - - updateField("interfaceName", value)} - options={interfaceSelectOptions} - disabled={formData.nodeId === undefined || formData.nodeId.length === 0} - /> - - - - - - { - updateField( - "width", - parseClampedOrDefault( - value, - sizeConfig.defaultWidthForMode, - sizeConfig.widthMin, - 2000 - ) - ); - }} - min={sizeConfig.widthMin} - max={2000} - suffix="px" - /> - { - updateField( - "height", - parseClampedOrDefault( - value, - sizeConfig.defaultHeightForMode, - sizeConfig.heightMin, - 1200 - ) - ); - }} - min={sizeConfig.heightMin} - max={1200} - suffix="px" - /> - - - - - <> - updateField("backgroundColor", value)} - /> - { - updateField( - "backgroundOpacity", - parseClampedOrDefault(value, DEFAULT_BACKGROUND_OPACITY, 0, 100) - ); - }} - min={0} - max={100} - suffix="%" - clearable - /> - - - - - - updateField("borderColor", value)} - /> - { - updateField( - "borderWidth", - parseClampedOrDefault(value, DEFAULT_BORDER_WIDTH, 0, 20) - ); - }} - min={0} - max={20} - step={0.5} - suffix="px" - clearable - /> - - - updateField("borderStyle", resolveBorderStyle(value))} - options={[ - { value: "solid", label: "Solid" }, - { value: "dashed", label: "Dashed" }, - { value: "dotted", label: "Dotted" }, - { value: "double", label: "Double" } - ]} - /> - { - updateField( - "borderRadius", - parseClampedOrDefault(value, sizeConfig.defaultBorderRadiusForMode, 0, 50) - ); - }} - min={0} - max={50} - suffix="px" - clearable - /> - - - - - updateField("textColor", value)} - /> - - - {mode === "chart" && ( - - updateField("showLegend", resolveShowLegendValue(checked))} - /> - - )} - -
    -
    - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/editorTypes.ts b/src/reactTopoViewer/webview/components/panels/context-panel/views/editorTypes.ts deleted file mode 100644 index faeea0854..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/editorTypes.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { - FreeTextAnnotation, - FreeShapeAnnotation, - TrafficRateAnnotation, - GroupStyleAnnotation -} from "../../../../../shared/types/topology"; -import type { GroupEditorData } from "../../../../hooks/canvas"; -import type { LinkImpairmentData } from "../../link-impairment/types"; -import type { LinkEditorData } from "../../link-editor/types"; -import type { NetworkEditorData } from "../../network-editor/types"; -import type { NodeEditorData } from "../../node-editor/types"; - -export interface EditorFooterRef { - handleApply: () => void; - handleSave: () => void; - handleDiscard: () => void; - hasChanges: boolean; -} - -export interface EditorBannerRef { - errors: string[]; -} - -export interface ContextPanelEditorState { - editingNodeData: NodeEditorData | null; - editingNodeInheritedProps: string[]; - selectedNodeVisualData: NodeEditorData | null; - selectedNodeVisualInheritedProps: string[]; - enableSelectedNodeVisualEditor: boolean; - nodeEditorHandlers: { - handleClose: () => void; - handleSave: (data: NodeEditorData) => void; - handleApply: (data: NodeEditorData) => void; - previewVisuals: (data: NodeEditorData) => void; - handleDelete?: () => void; - }; - editingLinkData: LinkEditorData | null; - linkEditorHandlers: { - handleClose: () => void; - handleSave: (data: LinkEditorData) => void; - handleApply: (data: LinkEditorData) => void; - previewOffset: (data: LinkEditorData) => void; - revertOffset: () => void; - handleDelete?: () => void; - }; - editingNetworkData: NetworkEditorData | null; - networkEditorHandlers: { - handleClose: () => void; - handleSave: (data: NetworkEditorData) => void; - handleApply: (data: NetworkEditorData) => void; - }; - linkImpairmentData: LinkImpairmentData | null; - linkImpairmentHandlers: { - onError: (error: string) => void; - onApply: (data: LinkImpairmentData) => void; - onSave: (data: LinkImpairmentData) => void; - onClose: () => void; - }; - editingTextAnnotation: FreeTextAnnotation | null; - textAnnotationHandlers: { - onSave: (annotation: FreeTextAnnotation) => void; - onPreview?: (annotation: FreeTextAnnotation) => boolean; - onPreviewDelete?: (id: string) => void; - onClose: () => void; - onDelete: (id: string) => void; - }; - editingShapeAnnotation: FreeShapeAnnotation | null; - shapeAnnotationHandlers: { - onSave: (annotation: FreeShapeAnnotation) => void; - onPreview?: (annotation: FreeShapeAnnotation) => boolean; - onPreviewDelete?: (id: string) => void; - onClose: () => void; - onDelete: (id: string) => void; - }; - editingTrafficRateAnnotation: TrafficRateAnnotation | null; - trafficRateAnnotationHandlers: { - onSave: (annotation: TrafficRateAnnotation) => void; - onPreview?: (annotation: TrafficRateAnnotation) => void; - onClose: () => void; - onDelete: (id: string) => void; - }; - editingGroup: GroupEditorData | null; - groupHandlers: { - onSave: (data: GroupEditorData) => void; - onClose: () => void; - onDelete: (groupId: string) => void; - onStylePreview: (groupId: string, style: Partial) => void; - }; -} diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/editorViews.ts b/src/reactTopoViewer/webview/components/panels/context-panel/views/editorViews.ts deleted file mode 100644 index 4db5cdebb..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/editorViews.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { FreeShapeEditorView } from "./FreeShapeEditorView"; -import { FreeTextEditorView } from "./FreeTextEditorView"; -import { GroupEditorView } from "./GroupEditorView"; -import { LinkEditorView } from "./LinkEditorView"; -import { LinkImpairmentView } from "./LinkImpairmentView"; -import { NetworkEditorView } from "./NetworkEditorView"; -import { NodeEditorView } from "./NodeEditorView"; -import { TrafficRateEditorView } from "./TrafficRateEditorView"; - -export const editorViews = { - FreeShapeEditorView, - FreeTextEditorView, - GroupEditorView, - LinkEditorView, - LinkImpairmentView, - NetworkEditorView, - NodeEditorView, - TrafficRateEditorView -} as const; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/index.ts b/src/reactTopoViewer/webview/components/panels/context-panel/views/index.ts deleted file mode 100644 index c7a69f799..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -export { PaletteView } from "./PaletteView"; -export type { PaletteViewProps } from "./PaletteView"; -export { NodeInfoView } from "./NodeInfoView"; -export type { NodeInfoViewProps } from "./NodeInfoView"; -export { LinkInfoView } from "./LinkInfoView"; -export type { LinkInfoViewProps } from "./LinkInfoView"; -export { NodeEditorView } from "./NodeEditorView"; -export type { NodeEditorViewProps, NodeEditorFooterRef } from "./NodeEditorView"; -export { LinkEditorView } from "./LinkEditorView"; -export type { LinkEditorViewProps, LinkEditorFooterRef } from "./LinkEditorView"; -export { NetworkEditorView } from "./NetworkEditorView"; -export type { NetworkEditorViewProps, NetworkEditorFooterRef } from "./NetworkEditorView"; -export { LinkImpairmentView } from "./LinkImpairmentView"; -export type { LinkImpairmentViewProps, LinkImpairmentFooterRef } from "./LinkImpairmentView"; -export { FreeTextEditorView } from "./FreeTextEditorView"; -export type { FreeTextEditorViewProps, FreeTextEditorFooterRef } from "./FreeTextEditorView"; -export { FreeShapeEditorView } from "./FreeShapeEditorView"; -export type { FreeShapeEditorViewProps, FreeShapeEditorFooterRef } from "./FreeShapeEditorView"; -export { TrafficRateEditorView } from "./TrafficRateEditorView"; -export type { - TrafficRateEditorViewProps, - TrafficRateEditorFooterRef -} from "./TrafficRateEditorView"; -export { GroupEditorView } from "./GroupEditorView"; -export type { GroupEditorViewProps, GroupEditorFooterRef } from "./GroupEditorView"; -export { InfoTabContent } from "./InfoTabContent"; -export type { InfoTabContentProps } from "./InfoTabContent"; diff --git a/src/reactTopoViewer/webview/components/panels/context-panel/views/useAnnotationPreviewCommit.ts b/src/reactTopoViewer/webview/components/panels/context-panel/views/useAnnotationPreviewCommit.ts deleted file mode 100644 index bcb48e301..000000000 --- a/src/reactTopoViewer/webview/components/panels/context-panel/views/useAnnotationPreviewCommit.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; - -interface AnnotationWithId { - id: string; -} - -interface UseAnnotationPreviewCommitParams { - annotation: T | null; - formData: T | null; - readOnly: boolean; - onPreview?: (annotation: T) => boolean; - onPreviewDelete?: (id: string) => void; - onSave: (annotation: T) => void; - discardChanges: () => void; - snapshot: (annotation: T) => T; -} - -interface UseAnnotationPreviewCommitResult { - saveWithCommit: (next: T) => void; - discardWithRevert: () => void; -} - -export function useAnnotationPreviewCommit( - params: UseAnnotationPreviewCommitParams -): UseAnnotationPreviewCommitResult { - const previewRef = useRef(params.onPreview); - previewRef.current = params.onPreview; - const previewDeleteRef = useRef(params.onPreviewDelete); - previewDeleteRef.current = params.onPreviewDelete; - const initialAnnotationRef = useRef(null); - const initialSerializedRef = useRef(null); - const hasPreviewRef = useRef(false); - const previewCreatedRef = useRef(false); - - useEffect(() => { - if (!params.annotation) { - initialAnnotationRef.current = null; - initialSerializedRef.current = null; - hasPreviewRef.current = false; - previewCreatedRef.current = false; - return; - } - - const snapshot = params.snapshot(params.annotation); - initialAnnotationRef.current = snapshot; - initialSerializedRef.current = JSON.stringify(snapshot); - hasPreviewRef.current = false; - previewCreatedRef.current = false; - }, [params.annotation, params.snapshot]); - - useEffect(() => { - if (params.readOnly || !params.formData || !initialAnnotationRef.current) return; - if (!previewRef.current) return; - - const snapshot = params.snapshot(params.formData); - const serialized = JSON.stringify(snapshot); - if (serialized === initialSerializedRef.current) return; - - const existedBeforePreview = previewRef.current(snapshot); - if (existedBeforePreview === false) { - previewCreatedRef.current = true; - } - hasPreviewRef.current = true; - }, [params.formData, params.readOnly, params.snapshot]); - - // Revert live preview when closing editor without apply/save. - useEffect(() => { - return () => { - if (!hasPreviewRef.current || !initialAnnotationRef.current) return; - if (previewCreatedRef.current) { - previewDeleteRef.current?.(initialAnnotationRef.current.id); - return; - } - previewRef.current?.(initialAnnotationRef.current); - }; - }, []); - - const saveWithCommit = useCallback( - (next: T) => { - hasPreviewRef.current = false; - previewCreatedRef.current = false; - const snapshot = params.snapshot(next); - initialAnnotationRef.current = snapshot; - initialSerializedRef.current = JSON.stringify(snapshot); - params.onSave(next); - }, - [params.onSave, params.snapshot] - ); - - const discardWithRevert = useCallback(() => { - params.discardChanges(); - if (previewCreatedRef.current && initialAnnotationRef.current) { - previewDeleteRef.current?.(initialAnnotationRef.current.id); - hasPreviewRef.current = false; - previewCreatedRef.current = false; - return; - } - if (initialAnnotationRef.current) { - previewRef.current?.(initialAnnotationRef.current); - } - hasPreviewRef.current = false; - previewCreatedRef.current = false; - }, [params.discardChanges]); - - return { saveWithCommit, discardWithRevert }; -} diff --git a/src/reactTopoViewer/webview/components/panels/find-node/FindNodeSearchWidget.tsx b/src/reactTopoViewer/webview/components/panels/find-node/FindNodeSearchWidget.tsx deleted file mode 100644 index 1918288c5..000000000 --- a/src/reactTopoViewer/webview/components/panels/find-node/FindNodeSearchWidget.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import TextField from "@mui/material/TextField"; -import InputAdornment from "@mui/material/InputAdornment"; -import IconButton from "@mui/material/IconButton"; -import SearchIcon from "@mui/icons-material/Search"; -import ClearIcon from "@mui/icons-material/Clear"; - -import { useGraphStore } from "../../../stores/graphStore"; -import { getNodesBoundingBox, isTopoNodeLike } from "../../../utils/graphQueryUtils"; - -import { formatMatchCountText, getCombinedMatches } from "./findNodeSearchUtils"; - -export interface FindNodeSearchWidgetProps { - rfInstance: ReactFlowInstance | null; - isActive: boolean; - description?: React.ReactNode; - dense?: boolean; - showTipsHeader?: boolean; -} - -export const FindNodeSearchWidget: React.FC = ({ - rfInstance, - isActive, - description, - dense = false, - showTipsHeader = false -}) => { - const inputRef = useRef(null); - const [searchTerm, setSearchTerm] = useState(""); - const [matchCount, setMatchCount] = useState(null); - const getCurrentNodes = useCallback( - () => useGraphStore.getState().nodes.filter((node) => isTopoNodeLike(node)), - [] - ); - - useEffect(() => { - if (isActive) { - setTimeout(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, 50); - } - }, [isActive]); - - useEffect(() => { - if (!isActive) setMatchCount(null); - }, [isActive]); - - const handleSearch = useCallback(() => { - if (!searchTerm.trim()) { - setMatchCount(null); - return; - } - - const currentNodes = rfInstance - ? rfInstance.getNodes().filter((node) => isTopoNodeLike(node)) - : getCurrentNodes(); - const combinedMatches = getCombinedMatches(currentNodes, searchTerm); - setMatchCount(combinedMatches.length); - - if (combinedMatches.length > 0 && rfInstance) { - const bounds = getNodesBoundingBox(combinedMatches); - if (bounds) { - rfInstance - .fitBounds( - { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, - { padding: 0.2, duration: 300 } - ) - .catch(() => { - /* ignore */ - }); - } - } - }, [searchTerm, rfInstance, getCurrentNodes]); - - const handleClear = useCallback(() => { - setSearchTerm(""); - setMatchCount(null); - inputRef.current?.focus(); - }, []); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - handleSearch(); - } - }, - [handleSearch] - ); - - const mbInput = dense ? 1.5 : 2; - const mbActions = dense ? 1.5 : 2; - const hasDescription = description !== undefined && description !== null; - - return ( - <> - - Find Node - - - {hasDescription ? ( - - {description} - - ) : null} - - - setSearchTerm(e.target.value)} - onKeyDown={handleKeyDown} - data-testid="find-node-input" - slotProps={{ - input: { - endAdornment: searchTerm ? ( - - - - - - ) : undefined - } - }} - /> - - - - - - {matchCount !== null && ( - - 0 ? "success.main" : "warning.main" }} - data-testid="find-node-match-count" - > - {formatMatchCountText(matchCount)} - - - )} - - - {showTipsHeader ? ( - - Search tips: - - ) : null} - -
  • - Use * for wildcard (e.g., srl*) -
  • -
  • - Use + prefix for starts-with -
  • -
  • - Press Enter to search -
  • -
    -
    - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/find-node/findNodeSearchUtils.ts b/src/reactTopoViewer/webview/components/panels/find-node/findNodeSearchUtils.ts deleted file mode 100644 index abc7b19b4..000000000 --- a/src/reactTopoViewer/webview/components/panels/find-node/findNodeSearchUtils.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { TopoNode } from "../../../../shared/types/graph"; -import { searchNodes as searchNodesUtil } from "../../../utils/graphQueryUtils"; - -function wildcardToRegex(pattern: string): RegExp { - let out = "^"; - for (const ch of pattern) { - if (ch === "*") { - out += ".*"; - continue; - } - if (ch === "?") { - out += "."; - continue; - } - // Escape regex special chars so matching is literal (except '*' and '?'). - if (/[.*+?^${}()|[\]\\]/.test(ch)) out += `\\${ch}`; - else out += ch; - } - out += "$"; - return new RegExp(out, "i"); -} - -function createWildcardFilter(trimmed: string): (value: string) => boolean { - const regex = wildcardToRegex(trimmed); - return (value: string) => regex.test(value); -} - -function createPrefixFilter(trimmed: string): (value: string) => boolean { - const prefix = trimmed.slice(1).toLowerCase(); - return (value: string) => value.toLowerCase().startsWith(prefix); -} - -function createContainsFilter(lower: string): (value: string) => boolean { - return (value: string) => value.toLowerCase().includes(lower); -} - -export function createFilter(pattern: string): (value: string) => boolean { - const trimmed = pattern.trim(); - if (!trimmed) return () => true; - if (trimmed.includes("*") || trimmed.includes("?")) return createWildcardFilter(trimmed); - if (trimmed.startsWith("+")) return createPrefixFilter(trimmed); - return createContainsFilter(trimmed.toLowerCase()); -} - -export function filterNodes(nodes: TopoNode[], searchTerm: string): TopoNode[] { - const filter = createFilter(searchTerm); - return nodes.filter((node) => { - if (filter(node.id)) return true; - const data = node.data as Record; - const label = data.label; - if (typeof label === "string" && filter(label)) return true; - return false; - }); -} - -export function getCombinedMatches(nodes: TopoNode[], searchTerm: string): TopoNode[] { - const basicMatches = searchNodesUtil(nodes, searchTerm); - const filterMatches = filterNodes(nodes, searchTerm); - const matchedIds = new Set(); - const combined: TopoNode[] = []; - for (const node of [...basicMatches, ...filterMatches]) { - if (!matchedIds.has(node.id)) { - matchedIds.add(node.id); - combined.push(node); - } - } - return combined; -} - -export function formatMatchCountText(count: number): string { - if (count === 0) return "No nodes found"; - const suffix = count === 1 ? "" : "s"; - return `Found ${count} node${suffix}`; -} diff --git a/src/reactTopoViewer/webview/components/panels/free-shape-editor/FreeShapeFormContent.tsx b/src/reactTopoViewer/webview/components/panels/free-shape-editor/FreeShapeFormContent.tsx deleted file mode 100644 index 18f9dccc1..000000000 --- a/src/reactTopoViewer/webview/components/panels/free-shape-editor/FreeShapeFormContent.tsx +++ /dev/null @@ -1,251 +0,0 @@ -// Shape annotation editor form. -import React from "react"; -import Box from "@mui/material/Box"; - -import type { FreeShapeAnnotation } from "../../../../shared/types/topology"; -import { - DEFAULT_SHAPE_WIDTH, - DEFAULT_SHAPE_HEIGHT, - DEFAULT_FILL_COLOR, - DEFAULT_FILL_OPACITY, - DEFAULT_BORDER_COLOR, - DEFAULT_BORDER_WIDTH, - DEFAULT_BORDER_STYLE, - DEFAULT_ARROW_SIZE, - DEFAULT_CORNER_RADIUS -} from "../../../annotations/constants"; -import { InputField, SelectField, Toggle, ColorField, PanelSection } from "../../ui/form"; - -interface Props { - formData: FreeShapeAnnotation; - updateField: ( - field: K, - value: FreeShapeAnnotation[K] - ) => void; -} - -interface SectionProps extends Props { - isLine: boolean; -} - -interface BorderSectionProps extends SectionProps { - isRectangle: boolean; -} - -function toShapeType(value: string): FreeShapeAnnotation["shapeType"] { - if (value === "rectangle" || value === "circle" || value === "line") { - return value; - } - return "rectangle"; -} - -function toBorderStyle(value: string): FreeShapeAnnotation["borderStyle"] { - if (value === "solid" || value === "dashed" || value === "dotted") { - return value; - } - return DEFAULT_BORDER_STYLE; -} - -const ShapeSection: React.FC = ({ formData, updateField, isLine }) => { - return ( - - <> - updateField("shapeType", toShapeType(v))} - options={[ - { value: "rectangle", label: "Rectangle" }, - { value: "circle", label: "Circle" }, - { value: "line", label: "Line" } - ]} - /> - {!isLine && ( - - updateField("width", Number(v))} - min={5} - max={2000} - suffix="px" - /> - updateField("height", Number(v))} - min={5} - max={2000} - suffix="px" - /> - updateField("rotation", Number(v))} - min={-360} - max={360} - suffix="deg" - /> - - )} - - - ); -}; - -const FillSection: React.FC = ({ formData, updateField, isLine }) => { - if (isLine) return null; - - const opacity = formData.fillOpacity ?? DEFAULT_FILL_OPACITY; - - return ( - - <> - updateField("fillColor", v)} - /> - updateField("fillOpacity", v ? Number(v) / 100 : 0)} - min={0} - max={100} - suffix="%" - clearable - /> - - - ); -}; - -const BorderSection: React.FC = ({ - formData, - updateField, - isLine, - isRectangle -}) => { - const borderWidth = formData.borderWidth ?? DEFAULT_BORDER_WIDTH; - - return ( - - - - updateField("borderColor", v)} - /> - updateField("borderWidth", v ? Number(v) : 0)} - min={0} - max={20} - suffix="px" - clearable - /> - - - {isRectangle && ( - updateField("cornerRadius", Number(v))} - min={0} - max={100} - suffix="px" - /> - )} - updateField("borderStyle", toBorderStyle(v))} - options={[ - { value: "solid", label: "Solid" }, - { value: "dashed", label: "Dashed" }, - { value: "dotted", label: "Dotted" } - ]} - /> - - - - ); -}; - -const ArrowSection: React.FC = ({ formData, updateField, isLine }) => { - if (!isLine) return null; - - const hasArrows = Boolean(formData.lineStartArrow ?? formData.lineEndArrow); - - return ( - - - - updateField("lineStartArrow", formData.lineStartArrow !== true)} - > - Start Arrow - - updateField("lineEndArrow", formData.lineEndArrow !== true)} - > - End Arrow - - - {hasArrows && ( - updateField("lineArrowSize", Number(v))} - min={5} - max={50} - suffix="px" - /> - )} - - - ); -}; - -// Main component -export const FreeShapeFormContent: React.FC = ({ formData, updateField }) => { - const isLine = formData.shapeType === "line"; - const isRectangle = formData.shapeType === "rectangle"; - - return ( - - - - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/free-shape-editor/index.ts b/src/reactTopoViewer/webview/components/panels/free-shape-editor/index.ts deleted file mode 100644 index a06f4d792..000000000 --- a/src/reactTopoViewer/webview/components/panels/free-shape-editor/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Free Shape Editor exports - */ -export { FreeShapeFormContent } from "./FreeShapeFormContent"; diff --git a/src/reactTopoViewer/webview/components/panels/free-text-editor/FreeTextFormContent.tsx b/src/reactTopoViewer/webview/components/panels/free-text-editor/FreeTextFormContent.tsx deleted file mode 100644 index e296acbd0..000000000 --- a/src/reactTopoViewer/webview/components/panels/free-text-editor/FreeTextFormContent.tsx +++ /dev/null @@ -1,244 +0,0 @@ -// Text annotation editor form. -import React from "react"; -import { - FormatAlignCenter as FormatAlignCenterIcon, - FormatAlignLeft as FormatAlignLeftIcon, - FormatAlignRight as FormatAlignRightIcon, - FormatBold as FormatBoldIcon, - FormatItalic as FormatItalicIcon, - FormatUnderlined as FormatUnderlinedIcon -} from "@mui/icons-material"; -import { - Box, - Checkbox, - Divider, - FormControlLabel, - IconButton as MuiIconButton, - MenuItem, - TextField -} from "@mui/material"; - -import type { FreeTextAnnotation } from "../../../../shared/types/topology"; -import { ColorField, InputField, PanelSection } from "../../ui/form"; - -// Helper functions to avoid duplicate calculations -const isBackgroundTransparent = (bg: string | undefined): boolean => bg === "transparent"; - -const FONTS = [ - "monospace", - "sans-serif", - "serif", - "Arial", - "Courier New", - "Georgia", - "Helvetica", - "Times New Roman", - "Verdana" -]; - -interface Props { - formData: FreeTextAnnotation; - updateField: (field: K, value: FreeTextAnnotation[K]) => void; -} - -// Icon button for toolbar -const IconBtn: React.FC<{ - active: boolean; - onClick: () => void; - children: React.ReactNode; - title?: string; -}> = ({ active, onClick, children, title }) => ( - - {children} - -); - -// Formatting toolbar -const Toolbar: React.FC<{ formData: FreeTextAnnotation; updateField: Props["updateField"] }> = ({ - formData, - updateField -}) => { - const isBold = formData.fontWeight === "bold"; - const isItalic = formData.fontStyle === "italic"; - const isUnderline = formData.textDecoration === "underline"; - const align = formData.textAlign ?? "left"; - - return ( - - updateField("fontWeight", isBold ? "normal" : "bold")} - title="Bold" - > - - - updateField("fontStyle", isItalic ? "normal" : "italic")} - title="Italic" - > - - - updateField("textDecoration", isUnderline ? "none" : "underline")} - title="Underline" - > - - - - updateField("textAlign", "left")} - title="Align Left" - > - - - updateField("textAlign", "center")} - title="Align Center" - > - - - updateField("textAlign", "right")} - title="Align Right" - > - - - - ); -}; - -// Font controls -const FontControls: React.FC<{ - formData: FreeTextAnnotation; - updateField: Props["updateField"]; -}> = ({ formData, updateField }) => ( - - updateField("fontFamily", e.target.value)} - sx={{ flex: 7 }} - > - {FONTS.map((f) => ( - - {f} - - ))} - - - updateField("fontSize", parseInt(v) || 14)} - min={1} - max={72} - suffix="px" - /> - - -); - -// Style options (colors, toggles, rotation) -const StyleOptions: React.FC<{ - formData: FreeTextAnnotation; - updateField: Props["updateField"]; -}> = ({ formData, updateField }) => { - const isTransparent = isBackgroundTransparent(formData.backgroundColor); - return ( - - - - updateField("fontColor", v)} - /> - - - updateField("backgroundColor", v)} - disabled={isTransparent} - /> - - updateField("backgroundColor", isTransparent ? "#000000" : "transparent") - } - /> - } - label="No fill" - slotProps={{ typography: { variant: "caption" } }} - /> - - - updateField("rotation", parseInt(v) || 0)} - min={-360} - max={360} - suffix="deg" - /> - - ); -}; - -// Main component -export const FreeTextFormContent: React.FC = ({ formData, updateField }) => ( - - - - updateField("text", e.target.value)} - placeholder="Enter your text... (Markdown and fenced code blocks supported)" - sx={{ "& textarea": { resize: "vertical", overflow: "auto" } }} - /> - - - - - - - - - - -); diff --git a/src/reactTopoViewer/webview/components/panels/free-text-editor/index.ts b/src/reactTopoViewer/webview/components/panels/free-text-editor/index.ts deleted file mode 100644 index 00eb96270..000000000 --- a/src/reactTopoViewer/webview/components/panels/free-text-editor/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Free Text Editor exports - */ -export { FreeTextFormContent } from "./FreeTextFormContent"; diff --git a/src/reactTopoViewer/webview/components/panels/group-editor/GroupFormContent.tsx b/src/reactTopoViewer/webview/components/panels/group-editor/GroupFormContent.tsx deleted file mode 100644 index 2b876b162..000000000 --- a/src/reactTopoViewer/webview/components/panels/group-editor/GroupFormContent.tsx +++ /dev/null @@ -1,142 +0,0 @@ -// Group editor form. -import React from "react"; -import Box from "@mui/material/Box"; - -import type { GroupStyleAnnotation } from "../../../../shared/types/topology"; -import type { GroupEditorData } from "../../../hooks/canvas"; -import { GROUP_LABEL_POSITIONS } from "../../../hooks/canvas"; -import { InputField, SelectField, ColorField, PanelSection } from "../../ui/form"; - -interface Props { - formData: GroupEditorData; - updateField: (field: K, value: GroupEditorData[K]) => void; - updateStyle: ( - field: K, - value: GroupStyleAnnotation[K] - ) => void; -} - -function isBorderStyle(value: string): value is NonNullable { - return value === "solid" || value === "dashed" || value === "dotted" || value === "double"; -} - -// Main component -export const GroupFormContent: React.FC = ({ formData, updateField, updateStyle }) => { - const style = formData.style; - - return ( - - - updateField("name", v)} - placeholder="e.g., rack1" - /> - - updateField("style", { ...formData.style, labelPosition: v })} - options={GROUP_LABEL_POSITIONS.map((pos) => ({ - value: pos, - label: pos - .split("-") - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" ") - }))} - /> - updateField("level", v)} - min={0} - /> - - - - - updateStyle("backgroundColor", v)} - /> - updateStyle("backgroundOpacity", v ? Number(v) : 0)} - min={0} - max={100} - suffix="%" - /> - - - - - updateStyle("borderColor", v)} - /> - updateStyle("borderWidth", v ? Number(v) : 0)} - min={0} - max={20} - step={0.5} - suffix="px" - clearable - /> - - - updateStyle("borderRadius", Number(v))} - min={0} - max={50} - suffix="px" - /> - { - if (isBorderStyle(v)) { - updateStyle("borderStyle", v); - } - }} - options={[ - { value: "solid", label: "Solid" }, - { value: "dashed", label: "Dashed" }, - { value: "dotted", label: "Dotted" }, - { value: "double", label: "Double" } - ]} - /> - - - - - updateStyle("labelColor", v)} - /> - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/group-editor/index.ts b/src/reactTopoViewer/webview/components/panels/group-editor/index.ts deleted file mode 100644 index 7ea6b4a70..000000000 --- a/src/reactTopoViewer/webview/components/panels/group-editor/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Group Editor exports - */ -export { GroupFormContent } from "./GroupFormContent"; diff --git a/src/reactTopoViewer/webview/components/panels/index.ts b/src/reactTopoViewer/webview/components/panels/index.ts deleted file mode 100644 index cd8ad8914..000000000 --- a/src/reactTopoViewer/webview/components/panels/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Panel components barrel file - */ - -export { AboutModal } from "./AboutModal"; -export type { LinkImpairmentData } from "./link-impairment/types"; diff --git a/src/reactTopoViewer/webview/components/panels/lab-drawer/FindNodeSection.tsx b/src/reactTopoViewer/webview/components/panels/lab-drawer/FindNodeSection.tsx deleted file mode 100644 index 46613f81f..000000000 --- a/src/reactTopoViewer/webview/components/panels/lab-drawer/FindNodeSection.tsx +++ /dev/null @@ -1,24 +0,0 @@ -// Find node section for the settings drawer. -import React from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; -import Box from "@mui/material/Box"; - -import { FindNodeSearchWidget } from "../find-node/FindNodeSearchWidget"; - -interface FindNodeSectionProps { - rfInstance: ReactFlowInstance | null; - isVisible: boolean; -} - -export const FindNodeSection: React.FC = ({ rfInstance, isVisible }) => { - return ( - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/lab-drawer/GridSettingsSection.tsx b/src/reactTopoViewer/webview/components/panels/lab-drawer/GridSettingsSection.tsx deleted file mode 100644 index ff5a517f8..000000000 --- a/src/reactTopoViewer/webview/components/panels/lab-drawer/GridSettingsSection.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// Grid settings controls for the settings drawer. -import React from "react"; -import Box from "@mui/material/Box"; -import Divider from "@mui/material/Divider"; -import Slider from "@mui/material/Slider"; -import ToggleButton from "@mui/material/ToggleButton"; -import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; -import Typography from "@mui/material/Typography"; - -import type { GridStyle } from "../../../hooks/ui"; - -interface GridSettingsSectionProps { - gridLineWidth: number; - onGridLineWidthChange: (width: number) => void; - gridStyle: GridStyle; - onGridStyleChange: (style: GridStyle) => void; -} - -export const GridSettingsSection: React.FC = ({ - gridLineWidth, - onGridLineWidthChange, - gridStyle, - onGridStyleChange -}) => { - const handleSliderChange = (_event: Event, value: number | number[]) => { - const width = Array.isArray(value) ? value[0] : value; - onGridLineWidthChange(width); - }; - - const handleStyleChange = (_event: React.MouseEvent, newStyle: GridStyle | null) => { - if (newStyle !== null) { - onGridStyleChange(newStyle); - } - }; - - return ( - - - Grid Settings - - - {/* Grid Line Width */} - - - Line Width - - - - value.toFixed(1)} - size="small" - /> - - - {/* Grid Style */} - - - Style - - - - - Dotted - Quadratic - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/lab-drawer/LabSettingsSection.tsx b/src/reactTopoViewer/webview/components/panels/lab-drawer/LabSettingsSection.tsx deleted file mode 100644 index 8f88ff8ce..000000000 --- a/src/reactTopoViewer/webview/components/panels/lab-drawer/LabSettingsSection.tsx +++ /dev/null @@ -1,158 +0,0 @@ -// Lab settings with Basic and Management tabs. -import React, { useState } from "react"; -import Box from "@mui/material/Box"; -import Divider from "@mui/material/Divider"; -import Tabs from "@mui/material/Tabs"; -import Tab from "@mui/material/Tab"; - -import { useLabSettingsState } from "../../../hooks/editor"; -import { saveViewerSettings } from "../../../services"; -import { useTopoViewerStore } from "../../../stores/topoViewerStore"; -import type { GridSettingsControlsProps } from "../GridSettingsPopover"; -import { BasicTab } from "../lab-settings/BasicTab"; -import { MgmtTab } from "../lab-settings/MgmtTab"; -import { AppearanceTab } from "../lab-settings/AppearanceTab"; -import type { LabSettings } from "../lab-settings/types"; - -export interface LabSettingsSectionProps extends GridSettingsControlsProps { - mode: "view" | "edit"; - isLocked: boolean; - labSettings?: LabSettings; - onClose: () => void; - saveRef?: React.RefObject<(() => Promise) | null>; -} - -export const LabSettingsSection: React.FC = ({ - mode, - isLocked, - labSettings, - onClose, - saveRef, - gridLineWidth, - onGridLineWidthChange, - gridStyle, - onGridStyleChange, - gridColor, - onGridColorChange, - gridBgColor, - onGridBgColorChange, - onResetGridColors -}) => { - const [activeTab, setActiveTab] = useState("basic"); - const areTopologySettingsReadOnly = mode === "view" || isLocked; - const isAppearanceReadOnly = isLocked; - - const state = useLabSettingsState(labSettings); - const linkLabelMode = useTopoViewerStore((store) => store.linkLabelMode); - const lastNonTelemetryLinkLabelMode = useTopoViewerStore( - (store) => store.lastNonTelemetryLinkLabelMode - ); - const telemetryNodeSizePx = useTopoViewerStore((store) => store.telemetryNodeSizePx); - const telemetryInterfaceSizePercent = useTopoViewerStore( - (store) => store.telemetryInterfaceSizePercent - ); - - const handleSave = async () => { - if (!areTopologySettingsReadOnly) { - await state.handleSave(); - } - const style = linkLabelMode === "telemetry-style" ? "telemetry-style" : "default"; - const nextLastNonTelemetryLinkLabelMode = - linkLabelMode === "telemetry-style" ? lastNonTelemetryLinkLabelMode : linkLabelMode; - await saveViewerSettings({ - style, - linkLabelMode, - lastNonTelemetryLinkLabelMode: nextLastNonTelemetryLinkLabelMode, - telemetryNodeSizePx, - telemetryInterfaceSizePercent, - gridLineWidth, - gridStyle, - gridColor, - gridBgColor - }); - onClose(); - }; - - if (saveRef) saveRef.current = handleSave; - - const handleTabChange = (_event: React.SyntheticEvent, newValue: string) => { - setActiveTab(newValue); - }; - - return ( - - - - - - - - - {activeTab === "basic" && ( - - - - )} - - {activeTab === "mgmt" && ( - - )} - - {activeTab === "appearance" && ( - - - - )} - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/lab-drawer/PaletteSection.tsx b/src/reactTopoViewer/webview/components/panels/lab-drawer/PaletteSection.tsx deleted file mode 100644 index 94361adb2..000000000 --- a/src/reactTopoViewer/webview/components/panels/lab-drawer/PaletteSection.tsx +++ /dev/null @@ -1,787 +0,0 @@ -// Node and annotation palette for the context panel. -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { - AccountTree as AccountTreeIcon, - Add as AddIcon, - Cable as CableIcon, - CircleOutlined as CircleOutlinedIcon, - Clear as ClearIcon, - CropSquare as CropSquareIcon, - Delete as DeleteIcon, - Save as SaveIcon, - DeviceHub as DeviceHubIcon, - Dns as DnsIcon, - Edit as EditIcon, - Hub as HubIcon, - Lan as LanIcon, - Power as PowerIcon, - Remove as RemoveIcon, - Search as SearchIcon, - SelectAll as SelectAllIcon, - Speed as SpeedIcon, - Star as StarIcon, - StarOutline as StarOutlineIcon, - TextFields as TextFieldsIcon -} from "@mui/icons-material"; -import { - Box, - Button, - Card, - Divider, - IconButton, - InputAdornment, - TextField, - Tooltip, - Typography -} from "@mui/material"; - -import type { CustomNodeTemplate } from "../../../../shared/types/editors"; -import { ROLE_SVG_MAP, DEFAULT_ICON_COLOR } from "../../../../shared/types/graph"; -import { generateEncodedSVG, type NodeType } from "../../../icons/SvgGenerator"; -import { - useCustomIcons, - useCustomNodes, - useTopoViewerStore -} from "../../../stores/topoViewerStore"; -import { buildCustomIconMap } from "../../../utils/iconUtils"; -import type { TabDefinition } from "../../ui/editor"; -import { TabNavigation } from "../../ui/editor/TabNavigation"; -import { IconPreview } from "../../ui/form"; -import { MonacoCodeEditor } from "../../monaco/MonacoCodeEditor"; -import { executeTopologyCommand } from "../../../services/topologyHostCommands"; -import clabSchema from "../../../../../../schema/clab.schema.json"; - -interface PaletteSectionProps { - mode?: "edit" | "view"; - isLocked?: boolean; - requestedTab?: { tabId: string }; - onEditCustomNode?: (nodeName: string) => void; - onDeleteCustomNode?: (nodeName: string) => void; - onSetDefaultCustomNode?: (nodeName: string) => void; - editTabContent?: React.ReactNode; - showEditTab?: boolean; - editTabTitle?: string; - onEditDelete?: () => void; - onEditTabLeave?: () => void; - infoTabContent?: React.ReactNode; - showInfoTab?: boolean; - infoTabTitle?: string; -} - -interface NetworkTypeDefinition { - readonly type: string; - readonly label: string; - readonly icon: React.ReactNode; -} - -const NETWORK_TYPE_DEFINITIONS: readonly NetworkTypeDefinition[] = [ - { type: "host", label: "Host", icon: }, - { type: "mgmt-net", label: "Mgmt Net", icon: }, - { type: "macvlan", label: "Macvlan", icon: }, - { type: "vxlan", label: "VXLAN", icon: }, - { type: "vxlan-stitch", label: "VXLAN Stitch", icon: }, - { type: "dummy", label: "Dummy", icon: }, - { type: "bridge", label: "Bridge", icon: }, - { type: "ovs-bridge", label: "OVS Bridge", icon: } -]; - -const VALID_NODE_TYPES: Record = { - pe: true, - dcgw: true, - leaf: true, - switch: true, - spine: true, - "super-spine": true, - server: true, - pon: true, - controller: true, - rgw: true, - ue: true, - cloud: true, - client: true, - bridge: true -}; - -function isNodeType(value: string): value is NodeType { - return Object.prototype.hasOwnProperty.call(VALID_NODE_TYPES, value); -} - -function getRoleSvgType(role: string): NodeType { - if (Object.prototype.hasOwnProperty.call(ROLE_SVG_MAP, role)) { - const mapped = ROLE_SVG_MAP[role]; - if (isNodeType(mapped)) return mapped; - } - return "pe"; -} - -function getTemplateIconUrl( - template: CustomNodeTemplate, - customIconMap: Map -): string { - const role = template.icon ?? "pe"; - const customDataUri = customIconMap.get(role); - if (customDataUri !== undefined && customDataUri.length > 0) { - return customDataUri; - } - const color = template.iconColor ?? DEFAULT_ICON_COLOR; - const svgType = getRoleSvgType(role); - return generateEncodedSVG(svgType, color); -} - -const REACTFLOW_NODE_MIME_TYPE = "application/reactflow-node"; -const ACTION_HOVER_BG = "action.hover"; -const TEXT_SECONDARY = "text.secondary"; - -const SourceEditorTab: React.FC<{ - readOnly: boolean; - error: string | null; - language: "yaml" | "json"; - value: string; - jsonSchema?: object; - onChange: (next: string) => void; -}> = ({ readOnly, error, language, value, jsonSchema, onChange }) => ( - - {error !== null && error.length > 0 && ( - - {error} - - )} - - - - -); - -const PaletteDraggableCard: React.FC<{ - onDragStart: (event: React.DragEvent) => void; - children: React.ReactNode; -}> = ({ onDragStart, children }) => ( - - - {children} - - -); - -const SectionHeader: React.FC<{ title: string; action?: React.ReactNode }> = ({ - title, - action -}) => ( - <> - - - {title} - {action} - - - -); - -type AnnotationPayload = { - annotationType: "text" | "shape" | "group" | "traffic-rate"; - shapeType?: string; -}; - -interface DraggableNodeProps { - template: CustomNodeTemplate; - customIconMap: Map; - isDefault?: boolean; - onEdit?: (name: string) => void; - onDelete?: (name: string) => void; - onSetDefault?: (name: string) => void; -} - -const DraggableNode: React.FC = ({ - template, - customIconMap, - isDefault, - onEdit, - onDelete, - onSetDefault -}) => { - const isDefaultNode = isDefault === true; - const onDragStart = useCallback( - (event: React.DragEvent) => { - event.dataTransfer.setData( - REACTFLOW_NODE_MIME_TYPE, - JSON.stringify({ - type: "node", - templateName: template.name - }) - ); - event.dataTransfer.effectAllowed = "move"; - }, - [template.name] - ); - - const iconUrl = useMemo( - () => getTemplateIconUrl(template, customIconMap), - [template, customIconMap] - ); - - return ( - - - - - - theme.typography.fontWeightMedium }} - > - {template.name} - - - {template.kind} - - - - - { - e.stopPropagation(); - if (!isDefaultNode) onSetDefault?.(template.name); - }} - sx={{ color: isDefaultNode ? "warning.main" : TEXT_SECONDARY }} - > - {isDefaultNode ? : } - - - - { - e.stopPropagation(); - onEdit?.(template.name); - }} - > - - - - - { - e.stopPropagation(); - onDelete?.(template.name); - }} - sx={{ "&:hover": { color: "error.main" } }} - > - - - - - - ); -}; - -interface DraggableAnnotationProps { - label: string; - kind: string; - icon: React.ReactNode; - payload: AnnotationPayload; -} - -interface PaletteSimpleDraggableProps { - dragPayload: Record; - icon: React.ReactNode; - label: string; - subtitle: string; -} - -const PaletteSimpleDraggable: React.FC = ({ - dragPayload, - icon, - label, - subtitle -}) => { - const onDragStart = useCallback( - (event: React.DragEvent) => { - event.dataTransfer.setData(REACTFLOW_NODE_MIME_TYPE, JSON.stringify(dragPayload)); - event.dataTransfer.effectAllowed = "move"; - }, - [dragPayload] - ); - - return ( - - {icon} - - theme.typography.fontWeightMedium }} - > - {label} - - - {subtitle} - - - - ); -}; - -const DraggableNetwork: React.FC<{ network: NetworkTypeDefinition }> = ({ network }) => ( - -); - -const DraggableAnnotation: React.FC = ({ - label, - kind, - icon, - payload -}) => ( - -); - -export const PALETTE_TABS: TabDefinition[] = [ - { id: "info", label: "Info" }, - { id: "edit", label: "Edit" }, - { id: "nodes", label: "Nodes" }, - { id: "annotations", label: "Annotations" }, - { id: "yaml", label: "YAML" }, - { id: "json", label: "JSON" } -]; - -/* eslint-disable complexity */ -export const PaletteSection: React.FC = ({ - mode = "edit", - isLocked = false, - requestedTab, - onEditCustomNode, - onDeleteCustomNode, - onSetDefaultCustomNode, - editTabContent, - showEditTab = false, - editTabTitle, - onEditDelete, - onEditTabLeave, - infoTabContent, - showInfoTab = false, - infoTabTitle -}) => { - const customNodes = useCustomNodes(); - const customIcons = useCustomIcons(); - const defaultNode = useTopoViewerStore((state) => state.defaultNode); - const yamlFileName = useTopoViewerStore((state) => state.yamlFileName); - const annotationsFileName = useTopoViewerStore((state) => state.annotationsFileName); - const yamlContent = useTopoViewerStore((state) => state.yamlContent); - const annotationsContent = useTopoViewerStore((state) => state.annotationsContent); - const [filter, setFilter] = useState(""); - const isViewMode = mode === "view"; - - const visibleTabs = useMemo( - () => - PALETTE_TABS.filter((t) => { - if (t.id === "info" && !showInfoTab) return false; - if (t.id === "edit" && !showEditTab) return false; - return true; - }), - [showInfoTab, showEditTab] - ); - - const [userTab, setUserTab] = useState("nodes"); - - useEffect(() => { - const requestedTabId = requestedTab?.tabId; - if ( - requestedTabId !== undefined && - requestedTabId.length > 0 && - visibleTabs.some((t) => t.id === requestedTabId) - ) { - setUserTab(requestedTabId); - } - }, [requestedTab, visibleTabs]); - - // Auto-switch when edit/info tab appears (one-time, not forced) - useEffect(() => { - if (showEditTab && !(isViewMode && showInfoTab)) setUserTab("edit"); - }, [showEditTab, isViewMode, showInfoTab]); - - useEffect(() => { - if (showInfoTab && (isViewMode || !showEditTab)) setUserTab("info"); - }, [showInfoTab, showEditTab, isViewMode]); - - // Fall back to "nodes" when current tab is no longer visible - useEffect(() => { - if (visibleTabs.some((t) => t.id === userTab)) return; - if (showEditTab) { - setUserTab("edit"); - return; - } - if (showInfoTab) { - setUserTab("info"); - return; - } - setUserTab("nodes"); - }, [visibleTabs, userTab, showEditTab, showInfoTab]); - - const activeTab = userTab; - - const [yamlError, setYamlError] = useState(null); - const [annotationsError, setAnnotationsError] = useState(null); - const [yamlDraft, setYamlDraft] = useState(yamlContent); - const [annotationsDraft, setAnnotationsDraft] = useState(annotationsContent); - const [yamlDirty, setYamlDirty] = useState(false); - const [annotationsDirty, setAnnotationsDirty] = useState(false); - const isSourceReadOnly = isLocked; - - // Sync drafts with host unless user has local edits - useEffect(() => { - if (!yamlDirty) { - setYamlDraft(yamlContent); - } - }, [yamlContent, yamlDirty]); - - useEffect(() => { - if (!annotationsDirty) { - setAnnotationsDraft(annotationsContent); - } - }, [annotationsContent, annotationsDirty]); - - useEffect(() => { - setYamlDirty(false); - setYamlError(null); - }, [yamlFileName]); - - useEffect(() => { - setAnnotationsDirty(false); - setAnnotationsError(null); - }, [annotationsFileName]); - - const filteredNodes = useMemo(() => { - if (!filter) return customNodes; - const search = filter.toLowerCase(); - return customNodes.filter((node) => { - const nodeIcon = typeof node.icon === "string" ? node.icon : undefined; - return ( - node.name.toLowerCase().includes(search) || - node.kind.toLowerCase().includes(search) || - (nodeIcon !== undefined && nodeIcon.toLowerCase().includes(search)) - ); - }); - }, [customNodes, filter]); - const customIconMap = useMemo(() => buildCustomIconMap(customIcons), [customIcons]); - - const filteredNetworks = useMemo(() => { - if (!filter) return NETWORK_TYPE_DEFINITIONS; - const search = filter.toLowerCase(); - return NETWORK_TYPE_DEFINITIONS.filter( - (net) => net.label.toLowerCase().includes(search) || net.type.toLowerCase().includes(search) - ); - }, [filter]); - - const handleAddNewNode = useCallback(() => { - onEditCustomNode?.("__new__"); - }, [onEditCustomNode]); - - const drawerTitle = useMemo(() => { - if (activeTab === "info") return infoTabTitle ?? "Properties"; - if (activeTab === "edit") return editTabTitle ?? "Editor"; - if (activeTab === "nodes" || activeTab === "annotations") return "Palette"; - if (activeTab === "yaml") return yamlFileName || "Topology"; - if (activeTab === "json") return annotationsFileName || "Annotations"; - return ""; - }, [activeTab, yamlFileName, annotationsFileName, editTabTitle, infoTabTitle]); - - const handleSaveYaml = useCallback(async () => { - try { - await executeTopologyCommand({ command: "setYamlContent", payload: { content: yamlDraft } }); - setYamlDirty(false); - setYamlError(null); - } catch (err) { - setYamlError(err instanceof Error ? err.message : String(err)); - } - }, [yamlDraft]); - - const handleSaveAnnotations = useCallback(async () => { - try { - await executeTopologyCommand({ - command: "setAnnotationsContent", - payload: { content: annotationsDraft } - }); - setAnnotationsDirty(false); - setAnnotationsError(null); - } catch (err) { - setAnnotationsError(err instanceof Error ? err.message : String(err)); - } - }, [annotationsDraft]); - - return ( - - - theme.typography.fontWeightBold }} - > - {drawerTitle} - - {activeTab === "edit" && onEditDelete && ( - - - - )} - {activeTab === "yaml" && !isSourceReadOnly && ( - { - handleSaveYaml().catch(() => undefined); - }} - disabled={!yamlDirty} - title="Save" - > - - - )} - {activeTab === "json" && !isSourceReadOnly && ( - { - handleSaveAnnotations().catch(() => undefined); - }} - disabled={!annotationsDirty} - title="Save" - > - - - )} - - - { - if (activeTab === "edit" && id !== "edit") { - onEditTabLeave?.(); - } - setUserTab(id); - }} - /> - {(activeTab === "nodes" || activeTab === "annotations") && ( - - {activeTab === "nodes" && ( - - - setFilter(e.target.value)} - slotProps={{ - input: { - startAdornment: ( - - - - ), - endAdornment: filter ? ( - - setFilter("")}> - - - - ) : undefined - } - }} - /> - - - } - onClick={handleAddNewNode} - sx={{ py: 0 }} - > - Add - - ) : undefined - } - /> - - {filteredNodes.length === 0 && ( - - {filter ? "No matching templates" : "No node templates defined"} - - )} - {filteredNodes.map((template) => ( - - ))} - - - - - {filteredNetworks.length === 0 ? ( - - No matching networks - - ) : ( - filteredNetworks.map((network) => ( - - )) - )} - - - )} - - {activeTab === "annotations" && ( - - - - } - payload={{ annotationType: "text" }} - /> - - - - - } - payload={{ annotationType: "shape", shapeType: "rectangle" }} - /> - } - payload={{ annotationType: "shape", shapeType: "circle" }} - /> - } - payload={{ annotationType: "shape", shapeType: "line" }} - /> - - - - - } - payload={{ annotationType: "group" }} - /> - - - - - } - payload={{ annotationType: "traffic-rate" }} - /> - - - )} - - )} - - {activeTab === "yaml" && ( - { - setYamlDraft(next); - setYamlDirty(true); - }} - /> - )} - - {activeTab === "json" && ( - { - setAnnotationsDraft(next); - setAnnotationsDirty(true); - }} - /> - )} - - {activeTab === "info" && ( - {infoTabContent} - )} - - {activeTab === "edit" && ( - {editTabContent} - )} - - ); -}; -/* eslint-enable complexity */ diff --git a/src/reactTopoViewer/webview/components/panels/lab-drawer/ShortcutsSection.tsx b/src/reactTopoViewer/webview/components/panels/lab-drawer/ShortcutsSection.tsx deleted file mode 100644 index d9a28bbca..000000000 --- a/src/reactTopoViewer/webview/components/panels/lab-drawer/ShortcutsSection.tsx +++ /dev/null @@ -1,103 +0,0 @@ -// Keyboard shortcuts section for the settings drawer. -import React from "react"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Chip from "@mui/material/Chip"; - -/** Platform detection for keyboard symbols */ -const isMac = - typeof window !== "undefined" && - typeof window.navigator !== "undefined" && - /macintosh/i.test(window.navigator.userAgent); - -/** Converts modifier keys based on platform */ -function formatKey(key: string): string { - if (!isMac) return key; - return key.replace(/Ctrl/g, "Cmd").replace(/Alt/g, "Option"); -} - -interface ShortcutRowProps { - label: string; - shortcut: string; -} - -const ShortcutRow: React.FC = ({ label, shortcut }) => ( - - {label} - - -); - -interface ShortcutSectionProps { - title: string; - color: string; - children: React.ReactNode; -} - -const ShortcutSection: React.FC = ({ title, color, children }) => ( - - - {title} - - {children} - -); - -export const ShortcutsSection: React.FC = () => { - return ( - - {/* Viewer Mode */} - - - - - - - - {/* Editor Mode */} - - - - - - - - - - - - - - - - - {/* Navigation */} - - - - - {/* Tips */} - - -
  • Use layout algorithms to auto-arrange
  • -
  • - Box select nodes, then Ctrl+G to group or Del to delete -
  • -
  • Double-click any item to directly edit
  • -
  • Shift+Click a node to start creating a link
  • -
    -
    -
    - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/lab-drawer/index.ts b/src/reactTopoViewer/webview/components/panels/lab-drawer/index.ts deleted file mode 100644 index c52fd1b2b..000000000 --- a/src/reactTopoViewer/webview/components/panels/lab-drawer/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Lab Drawer section components - Barrel exports - */ -export { LabSettingsSection } from "./LabSettingsSection"; -export { PaletteSection } from "./PaletteSection"; -export { ShortcutsSection } from "./ShortcutsSection"; -export { GridSettingsSection } from "./GridSettingsSection"; -export { FindNodeSection } from "./FindNodeSection"; diff --git a/src/reactTopoViewer/webview/components/panels/lab-settings/AppearanceTab.tsx b/src/reactTopoViewer/webview/components/panels/lab-settings/AppearanceTab.tsx deleted file mode 100644 index fde26a7b6..000000000 --- a/src/reactTopoViewer/webview/components/panels/lab-settings/AppearanceTab.tsx +++ /dev/null @@ -1,478 +0,0 @@ -import React, { useEffect, useMemo, useState } from "react"; -import type { Edge } from "@xyflow/react"; -import CheckIcon from "@mui/icons-material/Check"; -import RestartAltIcon from "@mui/icons-material/RestartAlt"; -import { - Box, - Button, - MenuItem, - Paper, - Slider, - Tab, - Tabs, - TextField, - ToggleButton, - ToggleButtonGroup, - Typography -} from "@mui/material"; - -import { useEdges } from "../../../stores/graphStore"; -import { useTopoViewerStore } from "../../../stores/topoViewerStore"; -import { - DEFAULT_TELEMETRY_INTERFACE_SIZE_PERCENT, - DEFAULT_TELEMETRY_NODE_SIZE_PX, - GLOBAL_INTERFACE_PART_INDEX_PREFIX, - INTERFACE_SELECT_AUTO, - INTERFACE_SELECT_FULL, - INTERFACE_SELECT_TOKEN_PREFIX, - clampTelemetryInterfaceSizePercent, - clampTelemetryNodeSizePx, - getInterfaceSelectionValue, - parseBoundedNumber, - resolveInterfaceOverrideValue, - splitInterfaceParts -} from "../../../utils/telemetryInterfaceLabels"; -import { invertHexColor, resolveComputedColor } from "../../../utils/color"; -import type { GridSettingsControlsProps } from "../GridSettingsPopover"; -import { ColorField, InputField } from "../../ui/form"; - -interface EdgeInterfaceRow { - edgeId: string; - source: string; - target: string; - sourceEndpoint: string; - targetEndpoint: string; -} - -interface AppearanceTabProps extends GridSettingsControlsProps { - isReadOnly: boolean; -} - -type TelemetryStyleValue = "default" | "telemetry-style"; -type AppearanceSubTab = "style" | "grid"; - -function asNonEmptyString(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function isGridStyle(value: unknown): value is GridSettingsControlsProps["gridStyle"] { - return value === "dotted" || value === "quadratic"; -} - -function extractEdgeInterfaceRows(edges: Edge[]): EdgeInterfaceRow[] { - const rows: EdgeInterfaceRow[] = []; - for (const edge of edges) { - const sourceEndpoint = asNonEmptyString(edge.data?.sourceEndpoint); - const targetEndpoint = asNonEmptyString(edge.data?.targetEndpoint); - if (sourceEndpoint === null || targetEndpoint === null) continue; - - rows.push({ - edgeId: edge.id, - source: edge.source, - target: edge.target, - sourceEndpoint, - targetEndpoint - }); - } - return rows; -} - -export const AppearanceTab: React.FC = ({ - gridLineWidth, - onGridLineWidthChange, - gridStyle, - onGridStyleChange, - gridColor, - onGridColorChange, - gridBgColor, - onGridBgColorChange, - onResetGridColors, - isReadOnly -}) => { - const edges = useEdges(); - const [activeSubTab, setActiveSubTab] = useState("style"); - const [interfaceLinkFilter, setInterfaceLinkFilter] = useState(""); - const [themeBgColor, setThemeBgColor] = useState("#1e1e1e"); - - const linkLabelMode = useTopoViewerStore((state) => state.linkLabelMode); - const lastNonTelemetryLinkLabelMode = useTopoViewerStore( - (state) => state.lastNonTelemetryLinkLabelMode - ); - - const telemetryNodeSizePx = useTopoViewerStore((state) => state.telemetryNodeSizePx); - const telemetryInterfaceSizePercent = useTopoViewerStore( - (state) => state.telemetryInterfaceSizePercent - ); - const globalInterfaceOverrideSelection = useTopoViewerStore( - (state) => state.telemetryGlobalInterfaceOverrideSelection - ); - const interfaceLabelOverrides = useTopoViewerStore( - (state) => state.telemetryInterfaceLabelOverrides - ); - - const setLinkLabelMode = useTopoViewerStore((state) => state.setLinkLabelMode); - const setTelemetryNodeSizePx = useTopoViewerStore((state) => state.setTelemetryNodeSizePx); - const setTelemetryInterfaceSizePercent = useTopoViewerStore( - (state) => state.setTelemetryInterfaceSizePercent - ); - const setTelemetryGlobalInterfaceOverrideSelection = useTopoViewerStore( - (state) => state.setTelemetryGlobalInterfaceOverrideSelection - ); - const setTelemetryInterfaceLabelOverride = useTopoViewerStore( - (state) => state.setTelemetryInterfaceLabelOverride - ); - - const telemetryStyleValue: TelemetryStyleValue = - linkLabelMode === "telemetry-style" ? "telemetry-style" : "default"; - const isTelemetryStyleEnabled = telemetryStyleValue === "telemetry-style"; - const hasCustomGridColors = gridColor !== null || gridBgColor !== null; - - useEffect(() => { - setThemeBgColor(resolveComputedColor("--vscode-editor-background", "#1e1e1e")); - }, []); - - const interfaceRows = useMemo(() => extractEdgeInterfaceRows(edges), [edges]); - - const filteredInterfaceRows = useMemo(() => { - const filterValue = interfaceLinkFilter.trim().toLowerCase(); - if (filterValue.length === 0) return interfaceRows; - return interfaceRows.filter((row) => - [row.edgeId, row.source, row.target, row.sourceEndpoint, row.targetEndpoint] - .join(" ") - .toLowerCase() - .includes(filterValue) - ); - }, [interfaceRows, interfaceLinkFilter]); - - const interfaceEndpoints = useMemo(() => { - const unique = new Set(); - for (const row of interfaceRows) { - unique.add(row.sourceEndpoint); - unique.add(row.targetEndpoint); - } - return Array.from(unique.values()); - }, [interfaceRows]); - - const maxInterfacePartCount = useMemo(() => { - let maxCount = 1; - for (const endpoint of interfaceEndpoints) { - maxCount = Math.max(maxCount, splitInterfaceParts(endpoint).length); - } - return maxCount; - }, [interfaceEndpoints]); - - const effectiveGridBgColor = gridBgColor ?? themeBgColor; - const defaultGridColor = invertHexColor(effectiveGridBgColor); - - return ( - - setActiveSubTab(value === "grid" ? "grid" : "style")} - variant="fullWidth" - > - - - - - {activeSubTab === "style" ? ( - - { - if (isReadOnly) return; - const value = e.target.value; - const nextLinkLabelMode = - value === "telemetry-style" ? "telemetry-style" : lastNonTelemetryLinkLabelMode; - setLinkLabelMode(nextLinkLabelMode); - }} - data-testid="lab-settings-telemetry-style" - > - Default - Telemetry Style - - - - { - if (isReadOnly) return; - const nextTelemetryNodeSizePx = clampTelemetryNodeSizePx( - parseBoundedNumber(value, 12, 240, DEFAULT_TELEMETRY_NODE_SIZE_PX) - ); - setTelemetryNodeSizePx(nextTelemetryNodeSizePx); - }} - /> - { - if (isReadOnly) return; - const nextTelemetryInterfaceSizePercent = clampTelemetryInterfaceSizePercent( - parseBoundedNumber(value, 40, 400, DEFAULT_TELEMETRY_INTERFACE_SIZE_PERCENT) - ); - setTelemetryInterfaceSizePercent(nextTelemetryInterfaceSizePercent); - }} - /> - - - {isTelemetryStyleEnabled ? ( - <> - { - if (isReadOnly) return; - setTelemetryGlobalInterfaceOverrideSelection(e.target.value); - }} - > - Auto - Full interface name - {Array.from({ length: maxInterfacePartCount }, (_, index) => index + 1).map( - (partIndex) => ( - - Part {partIndex} - - ) - )} - - setInterfaceLinkFilter(e.target.value)} - /> - - {filteredInterfaceRows.length} of {interfaceRows.length} links shown - - - {filteredInterfaceRows.length === 0 ? ( - - - No links match the current filter. - - - ) : ( - filteredInterfaceRows.map((row) => { - const sourceParts = splitInterfaceParts(row.sourceEndpoint); - const targetParts = splitInterfaceParts(row.targetEndpoint); - return ( - - - {row.source} {"<->"} {row.target} - - - { - if (isReadOnly) return; - setTelemetryInterfaceLabelOverride( - row.sourceEndpoint, - resolveInterfaceOverrideValue(row.sourceEndpoint, e.target.value) - ); - }} - > - Auto (use global) - - Full: {row.sourceEndpoint} - - {sourceParts.map((part, idx) => ( - - Part {idx + 1}: {part} - - ))} - - { - if (isReadOnly) return; - setTelemetryInterfaceLabelOverride( - row.targetEndpoint, - resolveInterfaceOverrideValue(row.targetEndpoint, e.target.value) - ); - }} - > - Auto (use global) - - Full: {row.targetEndpoint} - - {targetParts.map((part, idx) => ( - - Part {idx + 1}: {part} - - ))} - - - - ); - }) - )} - - - ) : null} - - ) : null} - - {activeSubTab === "grid" ? ( - - - Stroke Width - - { - if (isReadOnly) return; - const width = Array.isArray(value) ? value[0] : value; - onGridLineWidthChange(width); - }} - min={0.00001} - max={2} - step={0.1} - valueLabelDisplay="auto" - /> - - - Grid Style - - { - if (isReadOnly) return; - if (isGridStyle(value)) { - onGridStyleChange(value); - } - }} - size="small" - fullWidth - sx={{ - "& .MuiToggleButton-root": { - borderColor: "divider" - }, - "& .MuiToggleButton-root.Mui-selected": { - backgroundColor: "action.selected", - borderColor: "primary.main", - color: "text.primary", - fontWeight: 600 - }, - "& .MuiToggleButton-root.Mui-selected:hover": { - backgroundColor: "action.selected" - } - }} - > - - {gridStyle === "dotted" ? : null} - Dotted - - - {gridStyle === "quadratic" ? : null} - Quadratic - - - - - Grid Color - - onGridColorChange(value)} - /> - - - Background Color - - onGridBgColorChange(value)} - /> - - {hasCustomGridColors ? ( - - ) : null} - - ) : null} - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/lab-settings/BasicTab.tsx b/src/reactTopoViewer/webview/components/panels/lab-settings/BasicTab.tsx deleted file mode 100644 index a2123630c..000000000 --- a/src/reactTopoViewer/webview/components/panels/lab-settings/BasicTab.tsx +++ /dev/null @@ -1,71 +0,0 @@ -// Basic settings tab for lab settings. -import React from "react"; -import Box from "@mui/material/Box"; -import TextField from "@mui/material/TextField"; -import FormControl from "@mui/material/FormControl"; -import InputLabel from "@mui/material/InputLabel"; -import Select from "@mui/material/Select"; -import MenuItem from "@mui/material/MenuItem"; - -import type { PrefixType } from "./types"; - -interface BasicTabProps { - labName: string; - prefixType: PrefixType; - customPrefix: string; - isViewMode: boolean; - onLabNameChange: (value: string) => void; - onPrefixTypeChange: (value: PrefixType) => void; - onCustomPrefixChange: (value: string) => void; -} - -export const BasicTab: React.FC = ({ - labName, - prefixType, - customPrefix, - isViewMode, - onLabNameChange, - onPrefixTypeChange, - onCustomPrefixChange -}) => { - return ( - - {/* Lab Name */} - onLabNameChange(e.target.value)} - disabled={isViewMode} - size="small" - fullWidth - /> - - {/* Prefix */} - - Container Name Prefix - - - - {prefixType === "custom" && ( - onCustomPrefixChange(e.target.value)} - disabled={isViewMode} - size="small" - fullWidth - /> - )} - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/lab-settings/LabSettingsModal.tsx b/src/reactTopoViewer/webview/components/panels/lab-settings/LabSettingsModal.tsx deleted file mode 100644 index 18455358a..000000000 --- a/src/reactTopoViewer/webview/components/panels/lab-settings/LabSettingsModal.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// Lab settings dialog. -import React, { useCallback, useRef } from "react"; -import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogTitle from "@mui/material/DialogTitle"; -import DialogContent from "@mui/material/DialogContent"; -import IconButton from "@mui/material/IconButton"; -import CloseIcon from "@mui/icons-material/Close"; - -import { LabSettingsSection } from "../lab-drawer/LabSettingsSection"; -import type { GridSettingsControlsProps } from "../GridSettingsPopover"; - -import type { LabSettings } from "./types"; - -interface LabSettingsModalProps extends GridSettingsControlsProps { - isOpen: boolean; - onClose: () => void; - mode: "view" | "edit"; - isLocked: boolean; - labSettings?: LabSettings; -} - -export const LabSettingsModal: React.FC = ({ - isOpen, - onClose, - mode, - isLocked, - labSettings, - gridLineWidth, - onGridLineWidthChange, - gridStyle, - onGridStyleChange, - gridColor, - onGridColorChange, - gridBgColor, - onGridBgColorChange, - onResetGridColors -}) => { - const saveRef = useRef<(() => Promise) | null>(null); - const canSave = !isLocked; - const handleSaveClick = useCallback(() => { - const save = saveRef.current; - if (!save) { - return; - } - save().catch((error) => { - console.error("Failed to save lab settings", error); - }); - }, []); - - return ( - - - Lab Settings - - - - - - - - {canSave && ( - - - - )} - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/lab-settings/MgmtTab.tsx b/src/reactTopoViewer/webview/components/panels/lab-settings/MgmtTab.tsx deleted file mode 100644 index 03926eb33..000000000 --- a/src/reactTopoViewer/webview/components/panels/lab-settings/MgmtTab.tsx +++ /dev/null @@ -1,276 +0,0 @@ -// Management network settings tab. -import React from "react"; -import AddIcon from "@mui/icons-material/Add"; -import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; -import Checkbox from "@mui/material/Checkbox"; -import Divider from "@mui/material/Divider"; -import FormControl from "@mui/material/FormControl"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import InputLabel from "@mui/material/InputLabel"; -import MenuItem from "@mui/material/MenuItem"; -import Select from "@mui/material/Select"; -import TextField from "@mui/material/TextField"; -import Typography from "@mui/material/Typography"; - -import { KeyValueList } from "../../ui/form"; - -import type { IpType, DriverOption } from "./types"; - -interface MgmtTabProps { - networkName: string; - ipv4Type: IpType; - ipv4Subnet: string; - ipv4Gateway: string; - ipv4Range: string; - ipv6Type: IpType; - ipv6Subnet: string; - ipv6Gateway: string; - mtu: string; - bridge: string; - externalAccess: boolean; - driverOptions: DriverOption[]; - isViewMode: boolean; - onNetworkNameChange: (value: string) => void; - onIpv4TypeChange: (value: IpType) => void; - onIpv4SubnetChange: (value: string) => void; - onIpv4GatewayChange: (value: string) => void; - onIpv4RangeChange: (value: string) => void; - onIpv6TypeChange: (value: IpType) => void; - onIpv6SubnetChange: (value: string) => void; - onIpv6GatewayChange: (value: string) => void; - onMtuChange: (value: string) => void; - onBridgeChange: (value: string) => void; - onExternalAccessChange: (value: boolean) => void; - onAddDriverOption: () => void; - onSetDriverOptions: (options: DriverOption[]) => void; -} - -/** IPv4 settings section */ -const Ipv4Section: React.FC< - Pick< - MgmtTabProps, - | "ipv4Type" - | "ipv4Subnet" - | "ipv4Gateway" - | "ipv4Range" - | "isViewMode" - | "onIpv4TypeChange" - | "onIpv4SubnetChange" - | "onIpv4GatewayChange" - | "onIpv4RangeChange" - > -> = (props) => ( - - - IPv4 Subnet - - - - {props.ipv4Type === "custom" && ( - <> - props.onIpv4SubnetChange(e.target.value)} - disabled={props.isViewMode} - size="small" - fullWidth - /> - props.onIpv4GatewayChange(e.target.value)} - disabled={props.isViewMode} - size="small" - fullWidth - /> - props.onIpv4RangeChange(e.target.value)} - disabled={props.isViewMode} - size="small" - fullWidth - /> - - )} - -); - -/** IPv6 settings section */ -const Ipv6Section: React.FC< - Pick< - MgmtTabProps, - | "ipv6Type" - | "ipv6Subnet" - | "ipv6Gateway" - | "isViewMode" - | "onIpv6TypeChange" - | "onIpv6SubnetChange" - | "onIpv6GatewayChange" - > -> = (props) => ( - - - IPv6 Subnet - - - - {props.ipv6Type === "custom" && ( - <> - props.onIpv6SubnetChange(e.target.value)} - disabled={props.isViewMode} - size="small" - fullWidth - /> - props.onIpv6GatewayChange(e.target.value)} - disabled={props.isViewMode} - size="small" - fullWidth - /> - - )} - -); - -/** Convert DriverOption[] to Record for KeyValueList */ -function driverOptionsToRecord(options: DriverOption[]): Record { - const record: Record = {}; - for (const opt of options) { - record[opt.key] = opt.value; - } - return record; -} - -/** Convert Record back to DriverOption[] */ -function recordToDriverOptions(record: Record): DriverOption[] { - return Object.entries(record).map(([key, value]) => ({ key, value })); -} - -export const MgmtTab: React.FC = (props) => { - const driverRecord = driverOptionsToRecord(props.driverOptions); - - const handleDriverChange = (record: Record) => { - props.onSetDriverOptions(recordToDriverOptions(record)); - }; - - return ( - - - {/* Network Name */} - props.onNetworkNameChange(e.target.value)} - disabled={props.isViewMode} - size="small" - fullWidth - /> - - - - - {/* MTU */} - props.onMtuChange(e.target.value)} - disabled={props.isViewMode} - size="small" - fullWidth - slotProps={{ htmlInput: { min: 0, step: 1 } }} - /> - - {/* Bridge Name */} - props.onBridgeChange(e.target.value)} - disabled={props.isViewMode} - size="small" - fullWidth - /> - - {/* External Access */} - props.onExternalAccessChange(e.target.checked)} - disabled={props.isViewMode} - size="small" - /> - } - label="Enable External Access" - /> - - - {/* Bridge Driver Options */} - - - Bridge Driver Options - {!props.isViewMode && ( - - )} - - - - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/lab-settings/index.ts b/src/reactTopoViewer/webview/components/panels/lab-settings/index.ts deleted file mode 100644 index 600054164..000000000 --- a/src/reactTopoViewer/webview/components/panels/lab-settings/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Lab Settings exports - */ -export { LabSettingsModal } from "./LabSettingsModal"; -export type { LabSettings, MgmtSettings } from "./types"; diff --git a/src/reactTopoViewer/webview/components/panels/lab-settings/types.ts b/src/reactTopoViewer/webview/components/panels/lab-settings/types.ts deleted file mode 100644 index 9b0adf890..000000000 --- a/src/reactTopoViewer/webview/components/panels/lab-settings/types.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Types for Lab Settings Panel - */ -import type { - LabSettings as SharedLabSettings, - MgmtSettings as SharedMgmtSettings -} from "../../../../shared/types/labSettings"; - -export type LabSettings = SharedLabSettings; -export type MgmtSettings = SharedMgmtSettings; - -export type PrefixType = "default" | "custom" | "no-prefix"; -export type IpType = "default" | "auto" | "custom"; -export type TabId = "basic-lab" | "mgmt"; - -export interface DriverOption { - key: string; - value: string; -} - -export interface BasicSettingsState { - labName: string; - prefixType: PrefixType; - customPrefix: string; -} - -export interface MgmtSettingsState { - networkName: string; - ipv4Type: IpType; - ipv4Subnet: string; - ipv4Gateway: string; - ipv4Range: string; - ipv6Type: IpType; - ipv6Subnet: string; - ipv6Gateway: string; - mtu: string; - bridge: string; - externalAccess: boolean; - driverOptions: DriverOption[]; -} diff --git a/src/reactTopoViewer/webview/components/panels/link-editor/BasicTab.tsx b/src/reactTopoViewer/webview/components/panels/link-editor/BasicTab.tsx deleted file mode 100644 index 67c221f9d..000000000 --- a/src/reactTopoViewer/webview/components/panels/link-editor/BasicTab.tsx +++ /dev/null @@ -1,168 +0,0 @@ -// Basic link configuration tab. -import React from "react"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Slider from "@mui/material/Slider"; -import Button from "@mui/material/Button"; - -import { ReadOnlyBadge, InputField, PanelSection } from "../../ui/form"; -import { - DEFAULT_ENDPOINT_LABEL_OFFSET, - ENDPOINT_LABEL_OFFSET_MIN, - ENDPOINT_LABEL_OFFSET_MAX -} from "../../../annotations/endpointLabelOffset"; - -import type { LinkTabProps } from "./types"; - -interface EndpointInterfaceFieldProps { - isNetwork: boolean; - nodeName: string; - inputId: string; - endpoint: string | undefined; - onChange: (value: string) => void; -} - -const EndpointInterfaceField: React.FC = ({ - isNetwork, - nodeName, - inputId, - endpoint, - onChange -}) => { - if (isNetwork) { - return ( - - - {nodeName} Interface - - {nodeName || "Unknown"} - - ); - } - - return ( - - ); -}; - -interface LabelOffsetSectionProps { - endpointOffsetValue: number; - onOffsetChange: (_event: Event, value: number | number[]) => void; - onOffsetReset: () => void; -} - -const LabelOffsetSection: React.FC = ({ - endpointOffsetValue, - onOffsetChange, - onOffsetReset -}) => { - return ( - - <> - - {ENDPOINT_LABEL_OFFSET_MIN} - - - - {ENDPOINT_LABEL_OFFSET_MAX} - - - - - ); -}; - -function resolveEndpointOffsetValue(offset: unknown): number { - if (typeof offset === "number" && Number.isFinite(offset)) { - return offset; - } - - return DEFAULT_ENDPOINT_LABEL_OFFSET; -} - -export const BasicTab: React.FC = ({ data, onChange, onPreviewOffset }) => { - const sourceName = data.source.length > 0 ? data.source : "Source"; - const targetName = data.target.length > 0 ? data.target : "Target"; - const endpointOffsetValue = resolveEndpointOffsetValue(data.endpointLabelOffset); - - const handleOffsetChange = (_event: Event, value: number | number[]) => { - const nextOffset = typeof value === "number" ? value : value[0]; - const nextData = { - ...data, - endpointLabelOffset: nextOffset, - endpointLabelOffsetEnabled: true - }; - onChange({ - endpointLabelOffset: nextOffset, - endpointLabelOffsetEnabled: true - }); - onPreviewOffset?.(nextData); - }; - - const handleOffsetReset = () => { - const nextData = { - ...data, - endpointLabelOffset: DEFAULT_ENDPOINT_LABEL_OFFSET, - endpointLabelOffsetEnabled: true - }; - onChange({ - endpointLabelOffset: DEFAULT_ENDPOINT_LABEL_OFFSET, - endpointLabelOffsetEnabled: true - }); - onPreviewOffset?.(nextData); - }; - - return ( - - - - onChange({ sourceEndpoint: value })} - /> - onChange({ targetEndpoint: value })} - /> - - - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/link-editor/ExtendedTab.tsx b/src/reactTopoViewer/webview/components/panels/link-editor/ExtendedTab.tsx deleted file mode 100644 index 575d7fcdc..000000000 --- a/src/reactTopoViewer/webview/components/panels/link-editor/ExtendedTab.tsx +++ /dev/null @@ -1,192 +0,0 @@ -// Extended link configuration tab. -import React from "react"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Paper from "@mui/material/Paper"; - -import { KeyValueList, PanelAddSection, InputField, PanelSection } from "../../ui/form"; - -import type { LinkTabProps, LinkEditorData } from "./types"; - -/** - * Veth link properties (MAC, MTU, endpoint IPs, vars, labels) - */ -const VethLinkFields: React.FC = ({ data, onChange }) => { - const sourceName = data.source || "Source"; - const targetName = data.target || "Target"; - - const handleAddVar = () => { - const vars = data.vars ?? {}; - onChange({ vars: { ...vars, "": "" } }); - }; - - const handleAddLabel = () => { - const labels = data.labels ?? {}; - onChange({ labels: { ...labels, "": "" } }); - }; - - return ( - - - - onChange({ sourceMac: value })} - placeholder="e.g., 02:42:ac:11:00:01" - /> - onChange({ targetMac: value })} - placeholder="e.g., 02:42:ac:11:00:02" - /> - - - - onChange({ sourceIpv4: value })} - placeholder="e.g., 10.0.0.1/24" - /> - onChange({ targetIpv4: value })} - placeholder="e.g., 10.0.0.2/24" - /> - - - - onChange({ sourceIpv6: value })} - placeholder="e.g., 2001:db8::1/64" - /> - onChange({ targetIpv6: value })} - placeholder="e.g., 2001:db8::2/64" - /> - - - onChange({ mtu: value ? parseInt(value, 10) : undefined })} - placeholder="e.g., 1500" - type="number" - /> - - - - onChange({ vars })} - keyPlaceholder="Variable name" - valuePlaceholder="Value" - hideAddButton - /> - - - - onChange({ labels })} - keyPlaceholder="Label key" - valuePlaceholder="Label value" - hideAddButton - /> - - - ); -}; - -/** - * Info message for non-veth links - */ -const NonVethInfo: React.FC = () => ( - - - Note: This link connects to a network node. Configure extended properties on - the network node itself. - - -); - -export const ExtendedTab: React.FC = ({ data, onChange }) => { - const isVethLink = data.type === undefined || data.type === "veth"; - - return ( - - {isVethLink ? ( - - ) : ( - - - - )} - - ); -}; - -function addRequiredNodeErrors(data: LinkEditorData, errors: string[]): void { - if (data.source.length === 0) { - errors.push("Source node is required"); - } - - if (data.target.length === 0) { - errors.push("Target node is required"); - } -} - -function addInterfaceRequirementErrors( - data: LinkEditorData, - errors: string[], - isSelfLoop: boolean -): void { - const needsSourceInterface = - data.sourceEndpoint.length === 0 && data.sourceIsNetwork !== true && !isSelfLoop; - if (needsSourceInterface) { - errors.push(`${data.source || "Source"} interface is required`); - } - - const needsTargetInterface = - data.targetEndpoint.length === 0 && data.targetIsNetwork !== true && !isSelfLoop; - if (needsTargetInterface) { - errors.push(`${data.target || "Target"} interface is required`); - } -} - -function hasSelfLoopEndpointConflict(data: LinkEditorData, isSelfLoop: boolean): boolean { - if (!isSelfLoop) return false; - if (data.sourceEndpoint.length === 0 || data.targetEndpoint.length === 0) return false; - return data.sourceEndpoint === data.targetEndpoint; -} - -/** - * Validation function for link editor data - */ -export function validateLinkEditorData(data: LinkEditorData): string[] { - const errors: string[] = []; - const isSelfLoop = data.source === data.target; - - addRequiredNodeErrors(data, errors); - addInterfaceRequirementErrors(data, errors, isSelfLoop); - - if (hasSelfLoopEndpointConflict(data, isSelfLoop)) { - errors.push("Source and target interfaces must be different for a self-loop"); - } - - return errors; -} diff --git a/src/reactTopoViewer/webview/components/panels/link-editor/index.ts b/src/reactTopoViewer/webview/components/panels/link-editor/index.ts deleted file mode 100644 index 2dae9ed85..000000000 --- a/src/reactTopoViewer/webview/components/panels/link-editor/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Link editor components - */ -export { BasicTab as LinkBasicTab } from "./BasicTab"; -export { ExtendedTab as LinkExtendedTab, validateLinkEditorData } from "./ExtendedTab"; -export type { LinkEditorData, LinkEditorTabId, LinkEndpoint, LinkTabProps } from "./types"; diff --git a/src/reactTopoViewer/webview/components/panels/link-editor/types.ts b/src/reactTopoViewer/webview/components/panels/link-editor/types.ts deleted file mode 100644 index 4d49bf1ff..000000000 --- a/src/reactTopoViewer/webview/components/panels/link-editor/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Type definitions for link editor - * - * NOTE: Core types are now defined in shared/types/editors.ts - * Types are exported via the index.ts file for backward compatibility. - */ - -import type { - LinkEditorTabId as _LinkEditorTabId, - LinkEndpoint as _LinkEndpoint, - LinkEditorData as _LinkEditorData -} from "../../../../shared/types/editors"; - -// Re-export types (import then export pattern for non-index files) -export type LinkEditorTabId = _LinkEditorTabId; -export type LinkEndpoint = _LinkEndpoint; -export type LinkEditorData = _LinkEditorData; - -/** - * Props for link editor tab components - */ -export interface LinkTabProps { - data: LinkEditorData; - onChange: (updates: Partial) => void; - /** Live-preview offset changes on the canvas */ - onPreviewOffset?: (data: LinkEditorData) => void; -} diff --git a/src/reactTopoViewer/webview/components/panels/link-impairment/LinkImpairmentTab.tsx b/src/reactTopoViewer/webview/components/panels/link-impairment/LinkImpairmentTab.tsx deleted file mode 100644 index 0077bd007..000000000 --- a/src/reactTopoViewer/webview/components/panels/link-impairment/LinkImpairmentTab.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type React from "react"; -import Box from "@mui/material/Box"; - -import { InputField, PanelSection } from "../../ui/form"; - -import type { NetemState } from "./types"; - -export interface LinkImpairmentTabProps { - data: NetemState; - onChange: (updates: Partial) => void; -} - -export const LinkImpairmentTab: React.FC = ({ data, onChange }) => { - return ( - - - onChange({ delay: value })} - placeholder="Delay (with units). i.e. 50ms, 5s" - /> - onChange({ jitter: value })} - placeholder="Jitter (with units). i.e. 10ms, 500ms" - /> - onChange({ loss: value })} - placeholder="Loss in percent. i.e. 5, 0.1" - /> - onChange({ rate: value })} - placeholder="Rate in kbps. i.e. 1000, 10000" - /> - onChange({ corruption: value })} - placeholder="Corruption in percent. i.e. 5, 0.1" - /> - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/link-impairment/LinkImpairmentUtils.ts b/src/reactTopoViewer/webview/components/panels/link-impairment/LinkImpairmentUtils.ts deleted file mode 100644 index f81f8a923..000000000 --- a/src/reactTopoViewer/webview/components/panels/link-impairment/LinkImpairmentUtils.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { postCommand } from "../../../messaging/extensionMessaging"; -import type { NetemState } from "../../../../shared/parsing"; -import { normalizeNetemPercentage } from "../../../utils/netemNormalization"; - -import type { LinkImpairmentData } from "./types"; - -// Field validations -const TIME_UNIT_RE = /^\d+(ms|s)$/; -const ERR_TIME_UNIT = - "Input should be a number and a time unit. Either ms (milliseconds) or s (seconds)"; -const PERCENTAGE_RE = /^(?:100(?:\.0+)?|(?:0|[1-9]\d?)(?:\.\d+)?)$/; -const ERR_PERCENTAGE = "Input should be a number between 0 and 100."; -const NUMBER_RE = /^\d+$/; -const ERR_NUMBER = "Input should be a number."; - -type FormatCheck = { - format: RegExp; - error: string; -}; - -const NETEM_FIELD_FORMAT: Record = { - delay: { format: TIME_UNIT_RE, error: ERR_TIME_UNIT }, - jitter: { format: TIME_UNIT_RE, error: ERR_TIME_UNIT }, - loss: { format: PERCENTAGE_RE, error: ERR_PERCENTAGE }, - rate: { format: NUMBER_RE, error: ERR_NUMBER }, - corruption: { format: PERCENTAGE_RE, error: ERR_PERCENTAGE } -}; - -function isNetemKey(value: string): value is keyof NetemState { - return Object.prototype.hasOwnProperty.call(NETEM_FIELD_FORMAT, value); -} - -/** - * Validate netem state. - * @param netemState to validate - * @returns list of errors - */ -export function validateLinkImpairmentState(netemState: NetemState): string[] { - const errors: string[] = []; - - // Format check - Object.entries(netemState).forEach(([key, value]) => { - if (!isNetemKey(key)) { - return; - } - const normalizedValue = typeof value === "string" ? value : ""; - if (!NETEM_FIELD_FORMAT[key].format.test(normalizedValue)) { - errors.push(`(${key}) - ${NETEM_FIELD_FORMAT[key].error}`); - } - }); - - // Cross-field validations - const delayVal = parseFloat(netemState.delay ?? "") || 0; - const jitterVal = parseFloat(netemState.jitter ?? "") || 0; - if (jitterVal > 0 && delayVal === 0) { - errors.push("cannot set jitter when delay is 0"); - } - return errors; -} - -function stripNetemDataUnit(data: NetemState): NetemState { - return { - delay: data.delay, - jitter: data.jitter, - loss: normalizeNetemPercentage(data.loss), - rate: data.rate, - corruption: normalizeNetemPercentage(data.corruption) - }; -} - -/** - * Strip units for clab netem states. Assign source and target netem from clab state if absent. - * @param data raw link impairment data - */ -export function formatNetemData(data: LinkImpairmentData): LinkImpairmentData { - const formattedData: LinkImpairmentData = { ...data }; - if (formattedData.extraData?.clabSourceNetem) { - formattedData.extraData.clabSourceNetem = stripNetemDataUnit( - formattedData.extraData.clabSourceNetem - ); - formattedData.sourceNetem = formattedData.extraData.clabSourceNetem; - } - if (formattedData.extraData?.clabTargetNetem) { - formattedData.extraData.clabTargetNetem = stripNetemDataUnit( - formattedData.extraData.clabTargetNetem - ); - formattedData.targetNetem = formattedData.extraData.clabTargetNetem; - } - return formattedData; -} - -/** - * Apply netem settings for changed interface. - * @param data retrieved from link impairment panel - */ -export function applyNetemSettings(data: LinkImpairmentData): void { - if (JSON.stringify(data.sourceNetem) !== JSON.stringify(data.extraData?.clabSourceNetem)) { - postCommand("clab-link-impairment", { - nodeName: data.extraData?.clabSourceLongName ?? data.source, - interfaceName: data.sourceEndpoint, - data: data.sourceNetem - }); - } - - if (JSON.stringify(data.targetNetem) !== JSON.stringify(data.extraData?.clabTargetNetem)) { - postCommand("clab-link-impairment", { - nodeName: data.extraData?.clabTargetLongName ?? data.target, - interfaceName: data.targetEndpoint, - data: data.targetNetem - }); - } -} diff --git a/src/reactTopoViewer/webview/components/panels/link-impairment/types.ts b/src/reactTopoViewer/webview/components/panels/link-impairment/types.ts deleted file mode 100644 index a01b71d14..000000000 --- a/src/reactTopoViewer/webview/components/panels/link-impairment/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { NetemState as _NetemState } from "../../../../shared/parsing"; -import type { LinkData } from "../../../hooks/ui"; - -export type NetemState = _NetemState; - -export interface EndpointWithNetem { - node?: string; - iface?: string; - netem?: NetemState; -} - -export type LinkImpairmentTabId = "source" | "target"; - -export type LinkImpairmentData = LinkData & { - sourceNetem?: NetemState; - targetNetem?: NetemState; - extraData?: { - clabSourceNetem?: NetemState; - clabSourceLongName?: string; - clabTargetNetem?: NetemState; - clabTargetLongName?: string; - [key: string]: unknown; - }; -}; diff --git a/src/reactTopoViewer/webview/components/panels/network-editor/index.ts b/src/reactTopoViewer/webview/components/panels/network-editor/index.ts deleted file mode 100644 index 5d7df8c2b..000000000 --- a/src/reactTopoViewer/webview/components/panels/network-editor/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Network Editor exports - */ -export type { NetworkEditorData, NetworkType } from "./types"; -export { NETWORK_TYPES, VXLAN_TYPES, BRIDGE_TYPES, HOST_TYPES } from "./types"; diff --git a/src/reactTopoViewer/webview/components/panels/network-editor/types.ts b/src/reactTopoViewer/webview/components/panels/network-editor/types.ts deleted file mode 100644 index 901e27f6f..000000000 --- a/src/reactTopoViewer/webview/components/panels/network-editor/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Network Editor Types - * - * NOTE: Core types are now defined in shared/types/editors.ts - * Types are exported via the index.ts file for backward compatibility. - */ - -import type { - NetworkType as _NetworkType, - NetworkEditorData as _NetworkEditorData -} from "../../../../shared/types/editors"; -import { - NETWORK_TYPES as _NETWORK_TYPES, - VXLAN_TYPES as _VXLAN_TYPES, - BRIDGE_TYPES as _BRIDGE_TYPES, - HOST_TYPES as _HOST_TYPES, - MACVLAN_MODES as _MACVLAN_MODES, - getInterfaceLabel as _getInterfaceLabel, - getInterfacePlaceholder as _getInterfacePlaceholder, - showInterfaceField as _showInterfaceField, - supportsExtendedProps as _supportsExtendedProps -} from "../../../../shared/types/editors"; - -// Re-export types -export type NetworkType = _NetworkType; -export type NetworkEditorData = _NetworkEditorData; - -// Re-export values -export const NETWORK_TYPES = _NETWORK_TYPES; -export const VXLAN_TYPES = _VXLAN_TYPES; -export const BRIDGE_TYPES = _BRIDGE_TYPES; -export const HOST_TYPES = _HOST_TYPES; -export const MACVLAN_MODES = _MACVLAN_MODES; -export const getInterfaceLabel = _getInterfaceLabel; -export const getInterfacePlaceholder = _getInterfacePlaceholder; -export const showInterfaceField = _showInterfaceField; -export const supportsExtendedProps = _supportsExtendedProps; diff --git a/src/reactTopoViewer/webview/components/panels/node-editor/AdvancedTab.tsx b/src/reactTopoViewer/webview/components/panels/node-editor/AdvancedTab.tsx deleted file mode 100644 index ae6949acc..000000000 --- a/src/reactTopoViewer/webview/components/panels/node-editor/AdvancedTab.tsx +++ /dev/null @@ -1,281 +0,0 @@ -// Advanced tab for node editor. -import React from "react"; -import Box from "@mui/material/Box"; - -import { - InputField, - SelectField, - CheckboxField, - DynamicList, - KeyValueList, - PanelAddSection, - PanelSection -} from "../../ui/form"; - -import type { TabProps, HealthCheckConfig } from "./types"; - -const KEY_SIZE_OPTIONS = [ - { value: "", label: "Default" }, - { value: "2048", label: "2048" }, - { value: "4096", label: "4096" } -]; - -const PULL_POLICY_OPTIONS = [ - { value: "", label: "Default" }, - { value: "always", label: "Always" }, - { value: "never", label: "Never" }, - { value: "if-not-present", label: "If Not Present" } -]; - -const RUNTIME_OPTIONS = [ - { value: "", label: "Default" }, - { value: "runc", label: "runc" }, - { value: "kata", label: "kata" }, - { value: "runsc", label: "runsc (gVisor)" } -]; - -const ResourceLimitsSection: React.FC = ({ data, onChange }) => ( - - - onChange({ cpu: v ? parseFloat(v) : undefined })} - placeholder="e.g., 1.5" - step={0.1} - min={0} - /> - onChange({ cpuSet: v })} - placeholder="e.g., 0-3, 0,3" - /> - onChange({ memory: v })} - placeholder="e.g., 1Gb, 512Mb" - /> - onChange({ shmSize: v })} - placeholder="e.g., 256MB" - /> - - -); - -interface TlsCertificateSectionProps extends TabProps { - certIssue: boolean; -} - -const TlsCertificateSection: React.FC = ({ - data, - onChange, - certIssue -}) => ( - - - onChange({ certIssue: checked })} - /> - {certIssue && ( - <> - onChange({ certKeySize: v })} - options={KEY_SIZE_OPTIONS} - /> - onChange({ certValidity: v })} - placeholder="e.g., 1h, 30d, 1y" - /> - - )} - - -); - -interface SansSectionProps extends TabProps { - certIssue: boolean; - sans: string[]; - onAddSan: () => void; -} - -const SansSection: React.FC = ({ certIssue, sans, onChange, onAddSan }) => { - if (!certIssue) return null; - - return ( - - onChange({ sans: items })} - placeholder="SAN entry" - hideAddButton - /> - - ); -}; - -interface HealthCheckSectionProps { - healthCheck: Partial; - onUpdate: (updates: Partial) => void; -} - -const HealthCheckSection: React.FC = ({ healthCheck, onUpdate }) => ( - - - onUpdate({ test: v })} - placeholder="e.g., CMD-SHELL cat /etc/os-release" - /> - - onUpdate({ startPeriod: v ? parseInt(v, 10) : undefined })} - placeholder="0" - min={0} - /> - onUpdate({ interval: v ? parseInt(v, 10) : undefined })} - placeholder="30" - min={0} - /> - onUpdate({ timeout: v ? parseInt(v, 10) : undefined })} - placeholder="30" - min={0} - /> - onUpdate({ retries: v ? parseInt(v, 10) : undefined })} - placeholder="3" - min={0} - /> - - - -); - -const RuntimeSection: React.FC = ({ data, onChange }) => ( - - - onChange({ imagePullPolicy: v })} - options={PULL_POLICY_OPTIONS} - /> - onChange({ runtime: v })} - options={RUNTIME_OPTIONS} - /> - - -); - -export const AdvancedTab: React.FC = ({ data, onChange }) => { - const healthCheck = data.healthCheck ?? {}; - const capAdd = data.capAdd ?? []; - const sysctls = data.sysctls ?? {}; - const devices = data.devices ?? []; - const sans = data.sans ?? []; - const certIssue = Boolean(data.certIssue); - - const updateHealthCheck = (updates: Partial) => { - onChange({ healthCheck: { ...healthCheck, ...updates } }); - }; - - const handleAddCapability = () => { - onChange({ capAdd: [...capAdd, ""] }); - }; - - const handleAddSysctl = () => { - onChange({ sysctls: { ...sysctls, "": "" } }); - }; - - const handleAddDevice = () => { - onChange({ devices: [...devices, ""] }); - }; - - const handleAddSan = () => { - onChange({ sans: [...sans, ""] }); - }; - - return ( - - - - onChange({ capAdd: items })} - placeholder="e.g., NET_ADMIN" - hideAddButton - /> - - - onChange({ sysctls: items })} - keyPlaceholder="Sysctl key" - valuePlaceholder="Value" - hideAddButton - /> - - - onChange({ devices: items })} - placeholder="host:container[:permissions]" - hideAddButton - /> - - - - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/node-editor/BasicTab.tsx b/src/reactTopoViewer/webview/components/panels/node-editor/BasicTab.tsx deleted file mode 100644 index 6db9e4f56..000000000 --- a/src/reactTopoViewer/webview/components/panels/node-editor/BasicTab.tsx +++ /dev/null @@ -1,537 +0,0 @@ -// Basic tab for node editor. -import React, { useState, useMemo, useCallback, useEffect } from "react"; -import { Box, Button, Typography } from "@mui/material"; -import { - RotateLeft as RotateLeftIcon, - RotateRight as RotateRightIcon, - SwapHoriz as SwapHorizIcon, - SyncAlt as SyncAltIcon -} from "@mui/icons-material"; - -import { - InputField, - FilterableDropdown, - IconPreview, - PanelSection, - SelectField, - ColorField, - CheckboxField -} from "../../ui/form"; -import { IconSelectorModal } from "../../ui/IconSelectorModal"; -import type { NodeType } from "../../../icons/SvgGenerator"; -import { generateEncodedSVG } from "../../../icons/SvgGenerator"; -import { useSchema, useDockerImages } from "../../../hooks/editor"; -import { useCustomIcons } from "../../../stores/topoViewerStore"; -import { buildCustomIconMap } from "../../../utils/iconUtils"; -import { DEFAULT_ICON_COLOR } from "../../canvas/types"; - -import type { TabProps } from "./types"; -import { CustomNodeTemplateFields } from "./CustomNodeTemplateFields"; - -// Icon options for dropdown (static, defined outside component) -const ICON_OPTIONS = [ - { value: "pe", label: "PE Router" }, - { value: "dcgw", label: "DC Gateway" }, - { value: "leaf", label: "Leaf" }, - { value: "switch", label: "Switch" }, - { value: "bridge", label: "Bridge" }, - { value: "spine", label: "Spine" }, - { value: "super-spine", label: "Super Spine" }, - { value: "server", label: "Server" }, - { value: "pon", label: "PON" }, - { value: "controller", label: "Controller" }, - { value: "rgw", label: "RGW" }, - { value: "ue", label: "User Equipment" }, - { value: "cloud", label: "Cloud" }, - { value: "client", label: "Client" } -]; - -const NODE_LABEL_POSITION_OPTIONS = [ - { value: "bottom", label: "Bottom" }, - { value: "top", label: "Top" }, - { value: "left", label: "Left" }, - { value: "right", label: "Right" } -]; - -const NODE_DIRECTION_OPTIONS = [ - { value: "right", label: "Horizontal", icon: }, - { value: "down", label: "Rotate text 90deg", icon: }, - { value: "left", label: "Rotate text 180deg", icon: }, - { value: "up", label: "Rotate text 270deg", icon: } -]; - -const BUILTIN_NODE_TYPES: readonly NodeType[] = [ - "pe", - "dcgw", - "leaf", - "switch", - "spine", - "super-spine", - "server", - "pon", - "controller", - "rgw", - "ue", - "cloud", - "client", - "bridge" -]; - -function isNodeType(icon: string): icon is NodeType { - return BUILTIN_NODE_TYPES.some((type) => type === icon); -} - -/** - * Get icon SVG source with fallback - */ -function getIconSrc(icon: string, color: string): string { - const nodeType: NodeType = isNodeType(icon) ? icon : "pe"; - try { - return generateEncodedSVG(nodeType, color); - } catch { - return generateEncodedSVG("pe", color); - } -} - -/** - * Node Name field - shown only for regular nodes - */ -const NodeNameField: React.FC = ({ data, onChange }) => ( - onChange({ name: value })} - /> -); - -/** - * Kind field with filterable dropdown - options from schema - */ -interface KindFieldProps extends TabProps { - kinds: string[]; - onKindChange: (kind: string) => void; -} - -const KindField: React.FC = ({ data, onChange, kinds, onKindChange }) => { - const kindOptions = useMemo(() => kinds.map((kind) => ({ value: kind, label: kind })), [kinds]); - - const handleKindChange = useCallback( - (value: string) => { - onChange({ kind: value }); - onKindChange(value); - }, - [onChange, onKindChange] - ); - - return ( - - ); -}; - -/** - * Type field with filterable dropdown - options depend on selected kind - */ -interface TypeFieldProps extends TabProps { - availableTypes: string[]; -} - -const TypeField: React.FC = ({ data, onChange, availableTypes }) => { - const typeOptions = useMemo( - () => availableTypes.map((type) => ({ value: type, label: type })), - [availableTypes] - ); - - return ( - onChange({ type: value })} - placeholder={ - availableTypes.length > 0 ? "Search or type..." : "Type value (no predefined types)" - } - allowFreeText={true} - /> - ); -}; - -/** - * Image/Version fields with docker images support - * When docker images are available, shows filterable dropdowns - * Otherwise falls back to simple text inputs - */ -interface ImageVersionFieldsProps extends TabProps { - baseImages: string[]; - hasImages: boolean; - getVersionsForImage: (base: string) => string[]; - parseImageString: (full: string) => { base: string; version: string }; - combineImageVersion: (base: string, version: string) => string; -} - -const ImageVersionFields: React.FC = ({ - data, - onChange, - baseImages, - hasImages, - getVersionsForImage, - parseImageString, - combineImageVersion -}) => { - // Parse the current image into base and version - const { base: currentBase, version: currentVersion } = useMemo(() => { - return parseImageString(data.image ?? ""); - }, [data.image, parseImageString]); - - // Track version separately for better UX when changing base image - const [localVersion, setLocalVersion] = useState(currentVersion); - - // Sync local version when image changes externally - useEffect(() => { - setLocalVersion(currentVersion); - }, [currentVersion]); - - // Get available versions for current base image - const availableVersions = useMemo(() => { - return getVersionsForImage(currentBase); - }, [currentBase, getVersionsForImage]); - - // Build options for base image dropdown - const imageOptions = useMemo( - () => baseImages.map((img) => ({ value: img, label: img })), - [baseImages] - ); - - // Build options for version dropdown - const versionOptions = useMemo( - () => availableVersions.map((v) => ({ value: v, label: v })), - [availableVersions] - ); - - // Handle base image change - const handleBaseChange = useCallback( - (newBase: string) => { - // Get first available version for the new base, or keep current if custom - const versions = getVersionsForImage(newBase); - const newVersion = versions.length > 0 ? versions[0] : localVersion; - setLocalVersion(newVersion); - onChange({ image: combineImageVersion(newBase, newVersion) }); - }, - [getVersionsForImage, localVersion, onChange, combineImageVersion] - ); - - // Handle version change - const handleVersionChange = useCallback( - (newVersion: string) => { - setLocalVersion(newVersion); - onChange({ image: combineImageVersion(currentBase, newVersion) }); - }, - [currentBase, onChange, combineImageVersion] - ); - - // If we have docker images, show dropdowns - if (hasImages) { - return ( - <> - - - - ); - } - - // Fallback to simple text inputs - return ( - <> - - - - ); -}; - -/** - * Icon field with preview, filterable dropdown, and edit modal - * Supports both built-in icons and custom icons from context - */ -const IconField: React.FC = ({ data, onChange }) => { - const [isModalOpen, setIsModalOpen] = useState(false); - const customIcons = useCustomIcons(); - - const color = data.iconColor ?? DEFAULT_ICON_COLOR; - // Don't apply default for dropdown value - show actual value (or empty) - // Only use fallback for preview image rendering - const icon = data.icon ?? ""; - const previewIcon = icon || "pe"; - - // Build custom icon map for efficient lookup - const customIconMap = useMemo(() => buildCustomIconMap(customIcons), [customIcons]); - - // Build combined icon options (built-in + custom) - const allIconOptions = useMemo(() => { - const customOptions = customIcons.map((ci) => ({ - value: ci.name, - label: ci.name + " (custom)" - })); - return [...ICON_OPTIONS, ...customOptions]; - }, [customIcons]); - - // Get icon source - check custom icons first, then built-in - const getIconSource = useCallback( - (iconName: string, iconColor: string): string => { - const customDataUri = customIconMap.get(iconName); - if (customDataUri !== undefined) { - return customDataUri; - } - return getIconSrc(iconName, iconColor); - }, - [customIconMap] - ); - - const handleIconSave = useCallback( - (newIcon: string, newColor: string | null, cornerRadius: number) => { - onChange({ - icon: newIcon, - iconColor: newColor ?? undefined, - iconCornerRadius: cornerRadius - }); - }, - [onChange] - ); - - // Render icon option with preview - const renderOption = useCallback( - (option: { value: string; label: string }) => ( - - - {option.label} - - ), - [color, data.iconCornerRadius, getIconSource] - ); - - return ( - <> - - - - - - onChange({ icon: value })} - placeholder="Select icon..." - allowFreeText={false} - renderOption={renderOption} - /> - - - - - setIsModalOpen(false)} - onSave={handleIconSave} - initialIcon={previewIcon} - initialColor={data.iconColor} - initialCornerRadius={data.iconCornerRadius ?? 0} - /> - - ); -}; - -const LabelAndDirectionFields: React.FC = ({ data, onChange }) => { - const isTransparent = data.labelBackgroundColor?.trim().toLowerCase() === "transparent"; - const labelBackgroundColor = data.labelBackgroundColor; - const pickerColor = - !isTransparent && labelBackgroundColor !== undefined && labelBackgroundColor.length > 0 - ? labelBackgroundColor - : "#000000"; - - return ( - - - onChange({ labelPosition: value })} - options={NODE_LABEL_POSITION_OPTIONS} - /> - onChange({ direction: value })} - options={NODE_DIRECTION_OPTIONS} - /> - - onChange({ labelBackgroundColor: value })} - disabled={isTransparent} - /> - onChange({ labelBackgroundColor: checked ? "transparent" : "" })} - /> - - ); -}; - -export const BasicTab: React.FC = ({ - data, - onChange, - inheritedProps = [], - visualOnly = false -}) => { - const isCustomTemplate = data.isCustomTemplate === true; - - // Get schema data (kinds and types) - const { kinds, getTypesForKind, kindSupportsType, isLoaded } = useSchema(); - - // Get docker images data - const { baseImages, hasImages, getVersionsForImage, parseImageString, combineImageVersion } = - useDockerImages(); - - // Track available types based on selected kind - const availableTypes = useMemo(() => { - return getTypesForKind(data.kind ?? ""); - }, [data.kind, getTypesForKind]); - - // Check if the current kind supports the type field - const showTypeField = useMemo(() => { - return kindSupportsType(data.kind ?? ""); - }, [data.kind, kindSupportsType]); - - // Handler for kind changes - always clear type since different kinds have different type options - const handleKindChange = useCallback( - (_newKind: string) => { - // Always clear type when kind changes - types are kind-specific - if (data.type !== undefined && data.type.length > 0) { - onChange({ type: undefined }); - } - }, - [data.type, onChange] - ); - - return ( - - {isCustomTemplate && !visualOnly && ( - - - - )} - - {!visualOnly && ( - - {!isCustomTemplate && } - - - - {showTypeField && ( - - )} - - - - - - {!isLoaded && ( - - Loading schema... - - )} - - )} - - - - - - {!isCustomTemplate && ( - - - - )} - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/node-editor/ComponentsTab.tsx b/src/reactTopoViewer/webview/components/panels/node-editor/ComponentsTab.tsx deleted file mode 100644 index 1ef1ef0de..000000000 --- a/src/reactTopoViewer/webview/components/panels/node-editor/ComponentsTab.tsx +++ /dev/null @@ -1,985 +0,0 @@ -// Components tab for node editor (Nokia SROS). -import React, { useCallback, useState } from "react"; -import { - Add as AddIcon, - Delete as DeleteIcon, - ExpandLess as ExpandLessIcon, - ExpandMore as ExpandMoreIcon -} from "@mui/icons-material"; -import { - Box, - Button, - Chip, - Collapse, - Divider, - IconButton, - InputAdornment, - Paper, - Tab, - Tabs, - TextField, - Typography -} from "@mui/material"; - -import { InputField, FilterableDropdown } from "../../ui/form"; -import { useSchema, type SrosComponentTypes } from "../../../hooks/editor"; - -import type { TabProps, SrosComponent, SrosMda, SrosXiom } from "./types"; -import { INTEGRATED_SROS_TYPES } from "./types"; - -/** Check if type is integrated mode (simpler chassis) */ -const isIntegratedType = (type: string | undefined): boolean => { - if (type === undefined || type.length === 0) return false; - return INTEGRATED_SROS_TYPES.has(type.toLowerCase()); -}; - -/** Check if slot is a CPM slot (A or B) */ -const isCpmSlot = (slot: string | number | undefined): boolean => { - if (slot === undefined) return false; - const s = String(slot).trim().toUpperCase(); - return s === "A" || s === "B"; -}; - -/** Convert string array to dropdown options */ -const toOptions = (types: string[]): Array<{ value: string; label: string }> => - types.map((t) => ({ value: t, label: t })); - -const JUSTIFY_SPACE_BETWEEN = "space-between"; - -// ============================================================================ -// MDA Entry Component -// ============================================================================ - -interface MdaEntryProps { - mda: SrosMda; - index: number; - mdaTypes: string[]; - onUpdate: (index: number, updates: Partial) => void; - onRemove: (index: number) => void; - slotPrefix?: string; -} - -const MdaEntry: React.FC = ({ - mda, - index, - mdaTypes, - onUpdate, - onRemove, - slotPrefix = "" -}) => ( - - - - onUpdate(index, { slot: e.target.value ? parseInt(e.target.value, 10) : undefined }) - } - placeholder="Slot" - slotProps={{ - htmlInput: { min: 1 }, - input: slotPrefix - ? { - startAdornment: {slotPrefix} - } - : undefined - }} - /> - - - onUpdate(index, { type: v })} - options={toOptions(mdaTypes)} - allowFreeText - /> - - onRemove(index)} color="error" title="Remove MDA"> - - - -); - -// ============================================================================ -// MDA List Section - Shared component for rendering MDA lists -// ============================================================================ - -interface MdaListSectionProps { - mdas: SrosMda[]; - mdaTypes: string[]; - slotPrefix?: string; - onUpdate: (index: number, updates: Partial) => void; - onRemove: (index: number) => void; -} - -const MdaListSection: React.FC = ({ - mdas, - mdaTypes, - slotPrefix, - onUpdate, - onRemove -}) => ( - - {mdas.map((mda, mdaIdx) => ( - - ))} - -); - -// ============================================================================ -// MDA Section Wrapper - Generic MDA list wrapper with subsection -// ============================================================================ - -interface MdaSectionWrapperProps { - mdas: SrosMda[]; - mdaTypes: string[]; - slotPrefix: string; - parentIndex: number; - onAddMda: (parentIndex: number) => void; - onUpdateMda: (parentIndex: number, mdaIndex: number, updates: Partial) => void; - onRemoveMda: (parentIndex: number, mdaIndex: number) => void; -} - -const MdaSectionWrapper: React.FC = ({ - mdas, - mdaTypes, - slotPrefix, - parentIndex, - onAddMda, - onUpdateMda, - onRemoveMda -}) => ( - <> - - MDA Components - - - - - onUpdateMda(parentIndex, idx, updates)} - onRemove={(idx) => onRemoveMda(parentIndex, idx)} - /> - - -); - -// ============================================================================ -// XIOM Entry Component -// ============================================================================ - -/** Content panel for a single XIOM tab */ -const XiomTabContent: React.FC<{ - xiom: SrosXiom; - index: number; - cardSlot: string | number; - srosTypes: SrosComponentTypes; - onUpdate: (index: number, updates: Partial) => void; - onAddMda: (xiomIndex: number) => void; - onUpdateMda: (xiomIndex: number, mdaIndex: number, updates: Partial) => void; - onRemoveMda: (xiomIndex: number, mdaIndex: number) => void; -}> = ({ xiom, index, cardSlot, srosTypes, onUpdate, onAddMda, onUpdateMda, onRemoveMda }) => { - const slotLabel = `${cardSlot}\u00A0/\u00A0x${xiom.slot ?? index + 1}`; - - return ( - <> - - onUpdate(index, { type: v })} - options={toOptions(srosTypes.xiom)} - allowFreeText - /> - - - - - ); -}; - -// ============================================================================ -// Component Entry (CPM or Card) -// ============================================================================ - -// Shared callback types for component operations -interface ComponentCallbacks { - onAddMda: (compIndex: number) => void; - onUpdateMda: (compIndex: number, mdaIndex: number, updates: Partial) => void; - onRemoveMda: (compIndex: number, mdaIndex: number) => void; - onAddXiom: (compIndex: number) => void; - onUpdateXiom: (compIndex: number, xiomIndex: number, updates: Partial) => void; - onRemoveXiom: (compIndex: number, xiomIndex: number) => void; - onAddXiomMda: (compIndex: number, xiomIndex: number) => void; - onUpdateXiomMda: ( - compIndex: number, - xiomIndex: number, - mdaIndex: number, - updates: Partial - ) => void; - onRemoveXiomMda: (compIndex: number, xiomIndex: number, mdaIndex: number) => void; -} - -interface ComponentEntryProps extends ComponentCallbacks { - component: SrosComponent; - index: number; - srosTypes: SrosComponentTypes; - onUpdate: (index: number, updates: Partial) => void; - onRemove: (index: number) => void; -} - -/** Component header with expand/collapse toggle */ -const ComponentHeader: React.FC<{ - slot: string | number | undefined; - isCpm: boolean; - type?: string; - mdaCount: number; - xiomCount: number; - isExpanded: boolean; - onToggle: () => void; - onRemove: () => void; -}> = ({ slot, isCpm, type, mdaCount, xiomCount, isExpanded, onToggle, onRemove }) => ( - - - {isExpanded ? : } - - {isCpm ? "Control Processing Module" : "Line Card"} - {!isCpm && type !== undefined && type.length > 0 && ( - - ({type}) - - )} - {!isCpm && (type === undefined || type.length === 0) && (mdaCount > 0 || xiomCount > 0) && ( - - ({mdaCount} MDA, {xiomCount} XIOM) - - )} - - { - e.stopPropagation(); - onRemove(); - }} - color="error" - title="Remove component" - > - - - -); - -/** MDA section for a component */ -const ComponentMdaSection: React.FC< - { - component: SrosComponent; - index: number; - srosTypes: SrosComponentTypes; - } & Pick -> = ({ component, index, srosTypes, onAddMda, onUpdateMda, onRemoveMda }) => ( - -); - -/** XIOM section for a component — tabbed layout */ -const ComponentXiomSection: React.FC< - { - component: SrosComponent; - index: number; - srosTypes: SrosComponentTypes; - } & Pick< - ComponentCallbacks, - | "onAddXiom" - | "onUpdateXiom" - | "onRemoveXiom" - | "onAddXiomMda" - | "onUpdateXiomMda" - | "onRemoveXiomMda" - > -> = ({ - component, - index, - srosTypes, - onAddXiom, - onUpdateXiom, - onRemoveXiom, - onAddXiomMda, - onUpdateXiomMda, - onRemoveXiomMda -}) => { - const xioms = component.xiom ?? []; - const [activeXiomTab, setActiveXiomTab] = useState(0); - - // Clamp active tab if a XIOM was removed - const clampedTab = Math.min(activeXiomTab, xioms.length - 1); - const activeXiom = xioms[clampedTab]; - - return ( - <> - - XIOM Components - - - - {xioms.length > 0 && ( - <> - - setActiveXiomTab(v)} - variant="scrollable" - scrollButtons="auto" - sx={{ flex: 1 }} - > - {xioms.map((xiom, xiomIdx) => ( - - ))} - - { - onRemoveXiom(index, clampedTab); - setActiveXiomTab(0); - }} - color="error" - title="Remove XIOM" - sx={{ mr: 1 }} - > - - - - - - onUpdateXiom(index, idx, updates)} - onAddMda={(xIdx) => onAddXiomMda(index, xIdx)} - onUpdateMda={(xIdx, mIdx, updates) => onUpdateXiomMda(index, xIdx, mIdx, updates)} - onRemoveMda={(xIdx, mIdx) => onRemoveXiomMda(index, xIdx, mIdx)} - /> - - - )} - - ); -}; - -// ============================================================================ -// Component Section - Shared component for CPM and Card sections -// ============================================================================ - -interface ComponentSectionProps { - title: string; - filteredComponents: SrosComponent[]; - allComponents: SrosComponent[]; - srosTypes: SrosComponentTypes; - updateComponent: (index: number, updates: Partial) => void; - removeComponent: (index: number) => void; - addMda: (compIndex: number) => void; - updateMda: (compIndex: number, mdaIndex: number, updates: Partial) => void; - removeMda: (compIndex: number, mdaIndex: number) => void; - addXiom: (compIndex: number) => void; - updateXiom: (compIndex: number, xiomIndex: number, updates: Partial) => void; - removeXiom: (compIndex: number, xiomIndex: number) => void; - addXiomMda: (compIndex: number, xiomIndex: number) => void; - updateXiomMda: ( - compIndex: number, - xiomIndex: number, - mdaIndex: number, - updates: Partial - ) => void; - removeXiomMda: (compIndex: number, xiomIndex: number, mdaIndex: number) => void; - onAdd: () => void; - addDisabled?: boolean; - addDisabledTitle?: string; -} - -const ComponentSection: React.FC = ({ - title, - filteredComponents, - allComponents, - srosTypes, - updateComponent, - removeComponent, - addMda, - updateMda, - removeMda, - addXiom, - updateXiom, - removeXiom, - addXiomMda, - updateXiomMda, - removeXiomMda, - onAdd, - addDisabled, - addDisabledTitle -}) => ( - <> - - - {title} - - - - - - {filteredComponents.map((comp) => { - const realIndex = allComponents.indexOf(comp); - return ( - - ); - })} - - - -); - -interface ComponentSlotTypeRowProps { - index: number; - component: SrosComponent; - typeOptions: string[]; - slotPlaceholder: string; - onUpdate: (index: number, updates: Partial) => void; - onRemove?: () => void; - removeTitle?: string; - padded?: boolean; -} - -const ComponentSlotTypeRow: React.FC = ({ - index, - component, - typeOptions, - slotPlaceholder, - onUpdate, - onRemove, - removeTitle, - padded = false -}) => ( - - onUpdate(index, { slot: v })} - placeholder={slotPlaceholder} - /> - onUpdate(index, { type: v })} - options={toOptions(typeOptions)} - allowFreeText - /> - {onRemove && ( - - - - )} - -); - -const ComponentEntry: React.FC = (props) => { - const { component, index, srosTypes, onUpdate, onRemove } = props; - const [isExpanded, setIsExpanded] = useState(true); - const isCpm = isCpmSlot(component.slot); - const typeOptions = isCpm ? srosTypes.cpm : srosTypes.card; - - if (isCpm) { - return ( - - onRemove(index)} - removeTitle="Remove CPM" - /> - - ); - } - - const mdaCount = component.mda?.length ?? 0; - const xiomCount = component.xiom?.length ?? 0; - - return ( - - setIsExpanded(!isExpanded)} - onRemove={() => onRemove(index)} - /> - - - - - - - - - - - - - ); -}; - -// ============================================================================ -// Integrated Mode - simpler MDA-only configuration -// ============================================================================ - -interface IntegratedModeSectionProps { - components: SrosComponent[]; - srosTypes: SrosComponentTypes; - onChange: (components: SrosComponent[]) => void; -} - -const IntegratedModeSection: React.FC = ({ - components, - srosTypes, - onChange -}) => { - const integratedComp = components.find((c) => c.slot === undefined || c.slot === "") ?? { - mda: [] - }; - const mdas = integratedComp.mda ?? []; - - const updateMda = (index: number, updates: Partial) => { - const newMdas = [...mdas]; - newMdas[index] = { ...newMdas[index], ...updates }; - onChange([{ ...integratedComp, mda: newMdas }]); - }; - - const removeMda = (index: number) => { - const newMdas = mdas.filter((_, i) => i !== index); - onChange([{ ...integratedComp, mda: newMdas }]); - }; - - const addMda = () => { - const nextSlot = mdas.length > 0 ? Math.max(...mdas.map((m) => m.slot ?? 0)) + 1 : 1; - onChange([{ ...integratedComp, mda: [...mdas, { slot: nextSlot }] }]); - }; - - return ( - <> - - - MDA Configuration - - - - - - {mdas.map((mda, mdaIdx) => ( - - ))} - - - - ); -}; - -// ============================================================================ -// Distributed Mode - full CPM/Card/SFM/MDA/XIOM configuration -// ============================================================================ - -interface DistributedModeSectionProps { - components: SrosComponent[]; - sfmValue: string; - srosTypes: SrosComponentTypes; - onComponentsChange: (components: SrosComponent[]) => void; - onSfmChange: (sfm: string) => void; -} - -const DistributedModeSection: React.FC = ({ - components, - sfmValue, - srosTypes, - onComponentsChange, - onSfmChange -}) => { - const cpmComponents = components.filter((c) => isCpmSlot(c.slot)); - const cardComponents = components.filter( - (c) => !isCpmSlot(c.slot) && c.slot !== undefined && c.slot !== "" - ); - - const updateComponent = useCallback( - (index: number, updates: Partial) => { - const newComponents = [...components]; - newComponents[index] = { ...newComponents[index], ...updates }; - onComponentsChange(newComponents); - }, - [components, onComponentsChange] - ); - - const removeComponent = useCallback( - (index: number) => { - onComponentsChange(components.filter((_, i) => i !== index)); - }, - [components, onComponentsChange] - ); - - const addCpm = () => { - const usedSlots = cpmComponents.map((c) => String(c.slot).toUpperCase()); - let newSlot: string | null = null; - if (!usedSlots.includes("A")) { - newSlot = "A"; - } else if (!usedSlots.includes("B")) { - newSlot = "B"; - } - if (newSlot !== null) { - onComponentsChange([...components, { slot: newSlot }]); - } - }; - - const addCard = () => { - const usedSlots = cardComponents.map((c) => Number(c.slot)).filter((n) => !isNaN(n)); - const newSlot = usedSlots.length > 0 ? Math.max(...usedSlots) + 1 : 1; - onComponentsChange([...components, { slot: newSlot }]); - }; - - // MDA operations - const addMda = (compIndex: number) => { - const comp = components[compIndex]; - const mdas = comp.mda ?? []; - const nextSlot = mdas.length > 0 ? Math.max(...mdas.map((m) => m.slot ?? 0)) + 1 : 1; - updateComponent(compIndex, { mda: [...mdas, { slot: nextSlot }] }); - }; - - const updateMda = (compIndex: number, mdaIndex: number, updates: Partial) => { - const comp = components[compIndex]; - const mdas = [...(comp.mda ?? [])]; - mdas[mdaIndex] = { ...mdas[mdaIndex], ...updates }; - updateComponent(compIndex, { mda: mdas }); - }; - - const removeMda = (compIndex: number, mdaIndex: number) => { - const comp = components[compIndex]; - updateComponent(compIndex, { mda: (comp.mda ?? []).filter((_, i) => i !== mdaIndex) }); - }; - - // XIOM operations - const addXiom = (compIndex: number) => { - const comp = components[compIndex]; - const xioms = comp.xiom ?? []; - const usedSlots = xioms.map((x) => x.slot ?? 0); - const nextSlot = usedSlots.includes(1) && !usedSlots.includes(2) ? 2 : 1; - updateComponent(compIndex, { xiom: [...xioms, { slot: nextSlot }] }); - }; - - const updateXiom = (compIndex: number, xiomIndex: number, updates: Partial) => { - const comp = components[compIndex]; - const xioms = [...(comp.xiom ?? [])]; - xioms[xiomIndex] = { ...xioms[xiomIndex], ...updates }; - updateComponent(compIndex, { xiom: xioms }); - }; - - const removeXiom = (compIndex: number, xiomIndex: number) => { - const comp = components[compIndex]; - updateComponent(compIndex, { xiom: (comp.xiom ?? []).filter((_, i) => i !== xiomIndex) }); - }; - - // XIOM MDA operations - const addXiomMda = (compIndex: number, xiomIndex: number) => { - const comp = components[compIndex]; - const xiom = comp.xiom?.at(xiomIndex); - if (xiom === undefined) return; - const mdas = xiom.mda ?? []; - const nextSlot = mdas.length > 0 ? Math.max(...mdas.map((m) => m.slot ?? 0)) + 1 : 1; - const newXioms = [...(comp.xiom ?? [])]; - newXioms[xiomIndex] = { ...xiom, mda: [...mdas, { slot: nextSlot }] }; - updateComponent(compIndex, { xiom: newXioms }); - }; - - const updateXiomMda = ( - compIndex: number, - xiomIndex: number, - mdaIndex: number, - updates: Partial - ) => { - const comp = components[compIndex]; - const xiom = comp.xiom?.at(xiomIndex); - if (xiom === undefined) return; - const mdas = [...(xiom.mda ?? [])]; - mdas[mdaIndex] = { ...mdas[mdaIndex], ...updates }; - const newXioms = [...(comp.xiom ?? [])]; - newXioms[xiomIndex] = { ...xiom, mda: mdas }; - updateComponent(compIndex, { xiom: newXioms }); - }; - - const removeXiomMda = (compIndex: number, xiomIndex: number, mdaIndex: number) => { - const comp = components[compIndex]; - const xiom = comp.xiom?.at(xiomIndex); - if (xiom === undefined) return; - const newXioms = [...(comp.xiom ?? [])]; - newXioms[xiomIndex] = { ...xiom, mda: (xiom.mda ?? []).filter((_, i) => i !== mdaIndex) }; - updateComponent(compIndex, { xiom: newXioms }); - }; - - // Common props for both CPM and Card sections - const commonSectionProps = { - allComponents: components, - srosTypes, - updateComponent, - removeComponent, - addMda, - updateMda, - removeMda, - addXiom, - updateXiom, - removeXiom, - addXiomMda, - updateXiomMda, - removeXiomMda - }; - - return ( - <> - {/* CPM Components */} - = 2} - addDisabledTitle="CPM slots A and B are already defined" - /> - - {/* Card Components */} - - - {/* SFM Configuration */} - - - Switch Fabric Module (SFM) - - - - - - - ); -}; - -// ============================================================================ -// Main ComponentsTab -// ============================================================================ - -/** - * ComponentsTab - Main component for SROS component configuration - * Only visible for nokia_srsim nodes - */ -export const ComponentsTab: React.FC = ({ data, onChange }) => { - const { srosComponentTypes } = useSchema(); - const isIntegrated = isIntegratedType(data.type); - const components = data.components ?? []; - - // Extract shared SFM value from first component that has it - const sfmValue = components.find((c) => typeof c.sfm === "string" && c.sfm.length > 0)?.sfm ?? ""; - - const handleComponentsChange = (newComponents: SrosComponent[]) => { - onChange({ components: newComponents }); - }; - - const handleSfmChange = (sfm: string) => { - // Apply SFM to all components - const newComponents = components.map((c) => ({ ...c, sfm: sfm || undefined })); - onChange({ components: newComponents.length > 0 ? newComponents : [{ sfm }] }); - }; - - return ( - - {isIntegrated ? ( - - ) : ( - - )} - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/node-editor/ConfigTab.tsx b/src/reactTopoViewer/webview/components/panels/node-editor/ConfigTab.tsx deleted file mode 100644 index 398936fd5..000000000 --- a/src/reactTopoViewer/webview/components/panels/node-editor/ConfigTab.tsx +++ /dev/null @@ -1,135 +0,0 @@ -// Configuration tab for node editor. -import React from "react"; -import Box from "@mui/material/Box"; - -import { - InputField, - SelectField, - DynamicList, - KeyValueList, - PanelAddSection, - PanelSection -} from "../../ui/form"; - -import type { TabProps } from "./types"; - -type StartupConfigMode = "default" | "enforce" | "suppress"; - -const STARTUP_CONFIG_MODE_OPTIONS = [ - { value: "default", label: "Default" }, - { value: "enforce", label: "Enforce startup config" }, - { value: "suppress", label: "Suppress startup config" } -]; - -function isStartupConfigMode(value: string): value is StartupConfigMode { - return value === "default" || value === "enforce" || value === "suppress"; -} - -function getStartupConfigMode(data: { - enforceStartupConfig?: boolean; - suppressStartupConfig?: boolean; -}): StartupConfigMode { - if (data.enforceStartupConfig === true) return "enforce"; - if (data.suppressStartupConfig === true) return "suppress"; - return "default"; -} - -export const ConfigTab: React.FC = ({ data, onChange }) => { - const mode = getStartupConfigMode(data); - - const handleModeChange = (newMode: StartupConfigMode) => { - onChange({ - enforceStartupConfig: newMode === "enforce", - suppressStartupConfig: newMode === "suppress" - }); - }; - - const handleAddBind = () => { - onChange({ binds: [...(data.binds ?? []), ""] }); - }; - - const handleAddEnvVar = () => { - onChange({ env: { ...data.env, "": "" } }); - }; - - const handleAddEnvFile = () => { - onChange({ envFiles: [...(data.envFiles ?? []), ""] }); - }; - - const handleAddLabel = () => { - onChange({ labels: { ...data.labels, "": "" } }); - }; - - return ( - - - onChange({ startupConfig: value })} - placeholder="Path to startup configuration file" - /> - { - if (isStartupConfigMode(value)) { - handleModeChange(value); - } - }} - options={STARTUP_CONFIG_MODE_OPTIONS} - /> - - - - onChange({ license: value })} - placeholder="Path to license file" - /> - - - - onChange({ binds: items })} - placeholder="host:container[:options]" - hideAddButton - /> - - - - onChange({ env: items })} - keyPlaceholder="Variable" - valuePlaceholder="Value" - hideAddButton - /> - - - - onChange({ envFiles: items })} - placeholder="Path to env file" - hideAddButton - /> - - - - onChange({ labels: items })} - keyPlaceholder="Label" - valuePlaceholder="Value" - hideAddButton - /> - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/node-editor/CustomNodeTemplateFields.tsx b/src/reactTopoViewer/webview/components/panels/node-editor/CustomNodeTemplateFields.tsx deleted file mode 100644 index 5b8c1cf95..000000000 --- a/src/reactTopoViewer/webview/components/panels/node-editor/CustomNodeTemplateFields.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// Custom node template fields. -import React from "react"; - -import { InputField } from "../../ui/form"; - -import type { TabProps } from "./types"; - -/** - * Custom Node Template fields - shown only when editing custom node templates - */ -export const CustomNodeTemplateFields: React.FC = ({ data, onChange }) => ( - <> - onChange({ customName: value })} - placeholder="Template name" - /> - onChange({ baseName: value })} - placeholder="e.g., srl (will become srl1, srl2, etc.)" - /> - onChange({ interfacePattern: value })} - placeholder="e.g., e1-{n} or Gi0/0/{n:0}" - /> - -); diff --git a/src/reactTopoViewer/webview/components/panels/node-editor/NetworkTab.tsx b/src/reactTopoViewer/webview/components/panels/node-editor/NetworkTab.tsx deleted file mode 100644 index 2515f913a..000000000 --- a/src/reactTopoViewer/webview/components/panels/node-editor/NetworkTab.tsx +++ /dev/null @@ -1,84 +0,0 @@ -// Network tab for node editor. -import React from "react"; -import Box from "@mui/material/Box"; - -import { InputField, SelectField, DynamicList, PanelAddSection, PanelSection } from "../../ui/form"; - -import type { TabProps } from "./types"; - -const NETWORK_MODE_OPTIONS = [ - { value: "", label: "Default" }, - { value: "bridge", label: "Bridge" }, - { value: "host", label: "Host" }, - { value: "none", label: "None" }, - { value: "container", label: "Container" } -]; - -export const NetworkTab: React.FC = ({ data, onChange }) => { - const handleAddPort = () => { - onChange({ ports: [...(data.ports ?? []), ""] }); - }; - - const handleAddDns = () => { - onChange({ dnsServers: [...(data.dnsServers ?? []), ""] }); - }; - - const handleAddAlias = () => { - onChange({ aliases: [...(data.aliases ?? []), ""] }); - }; - - return ( - - - onChange({ mgmtIpv4: value })} - placeholder="e.g., 172.20.20.100" - /> - onChange({ mgmtIpv6: value })} - placeholder="e.g., 2001:db8::100" - /> - onChange({ networkMode: value })} - options={NETWORK_MODE_OPTIONS} - /> - - - - onChange({ ports: items })} - placeholder="host:container[/protocol]" - hideAddButton - /> - - - - onChange({ dnsServers: items })} - placeholder="DNS server address" - hideAddButton - /> - - - - onChange({ aliases: items })} - placeholder="Alias name" - hideAddButton - /> - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/node-editor/NodeTemplateModal.tsx b/src/reactTopoViewer/webview/components/panels/node-editor/NodeTemplateModal.tsx deleted file mode 100644 index 770a55ec3..000000000 --- a/src/reactTopoViewer/webview/components/panels/node-editor/NodeTemplateModal.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// Modal for creating and editing custom node templates. -import React, { useCallback, useRef, useState } from "react"; -import Box from "@mui/material/Box"; -import Dialog from "@mui/material/Dialog"; -import DialogContent from "@mui/material/DialogContent"; - -import { useTopoViewerStore } from "../../../stores/topoViewerStore"; -import { useCustomTemplateEditor } from "../../../hooks/editor/useCustomTemplateEditor"; -import { DialogCancelSaveActions, DialogTitleWithClose } from "../../ui/dialog/DialogChrome"; -import { NodeEditorView } from "../context-panel/views/NodeEditorView"; -import type { NodeEditorFooterRef } from "../context-panel/views/NodeEditorView"; - -export const NodeTemplateModal: React.FC = () => { - const editingCustomTemplate = useTopoViewerStore((s) => s.editingCustomTemplate); - const editCustomTemplate = useTopoViewerStore((s) => s.editCustomTemplate); - const { editorData, handlers } = useCustomTemplateEditor( - editingCustomTemplate, - editCustomTemplate - ); - const footerRef = useRef(null); - const [, forceUpdate] = useState(0); - - const setFooterRef = useCallback((ref: NodeEditorFooterRef | null) => { - footerRef.current = ref; - forceUpdate((n) => n + 1); - }, []); - - const isOpen = !!editingCustomTemplate; - const isNew = editingCustomTemplate?.id !== "edit-custom-node"; - const title = isNew ? "Create Node Template" : "Edit Node Template"; - - return ( - - - - - - - - footerRef.current?.handleSave()} - /> - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/node-editor/RuntimeTab.tsx b/src/reactTopoViewer/webview/components/panels/node-editor/RuntimeTab.tsx deleted file mode 100644 index 816c193ab..000000000 --- a/src/reactTopoViewer/webview/components/panels/node-editor/RuntimeTab.tsx +++ /dev/null @@ -1,91 +0,0 @@ -// Runtime tab for node editor. -import React from "react"; -import Box from "@mui/material/Box"; - -import { - InputField, - SelectField, - CheckboxField, - DynamicList, - PanelAddSection, - PanelSection -} from "../../ui/form"; - -import type { TabProps } from "./types"; - -const RESTART_POLICY_OPTIONS = [ - { value: "", label: "Default" }, - { value: "no", label: "No" }, - { value: "always", label: "Always" }, - { value: "on-failure", label: "On Failure" }, - { value: "unless-stopped", label: "Unless Stopped" } -]; - -export const RuntimeTab: React.FC = ({ data, onChange }) => { - const handleAddExec = () => { - onChange({ exec: [...(data.exec ?? []), ""] }); - }; - - return ( - - - onChange({ user: value })} - placeholder="Container user" - /> - onChange({ entrypoint: value })} - placeholder="Container entrypoint" - /> - onChange({ cmd: value })} - placeholder="Container command" - /> - - - - onChange({ exec: items })} - placeholder="Command to execute" - hideAddButton - /> - - - - onChange({ startupDelay: value ? parseInt(value, 10) : undefined })} - placeholder="0" - min={0} - suffix="seconds" - /> - onChange({ restartPolicy: value })} - options={RESTART_POLICY_OPTIONS} - /> - onChange({ autoRemove: checked })} - /> - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/panels/node-editor/index.ts b/src/reactTopoViewer/webview/components/panels/node-editor/index.ts deleted file mode 100644 index 01ee0efc9..000000000 --- a/src/reactTopoViewer/webview/components/panels/node-editor/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Node Editor exports - */ -export { ComponentsTab } from "./ComponentsTab"; -export type { NodeEditorData, NodeEditorTabId, SrosComponent, SrosMda, SrosXiom } from "./types"; -export { INTEGRATED_SROS_TYPES } from "./types"; diff --git a/src/reactTopoViewer/webview/components/panels/node-editor/types.ts b/src/reactTopoViewer/webview/components/panels/node-editor/types.ts deleted file mode 100644 index fdee8008b..000000000 --- a/src/reactTopoViewer/webview/components/panels/node-editor/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Type definitions for node editor - * - * NOTE: Core types are now defined in shared/types/editors.ts - * Types are exported via the index.ts file for backward compatibility. - */ - -import type { - NodeEditorTabId as _NodeEditorTabId, - HealthCheckConfig as _HealthCheckConfig, - SrosMda as _SrosMda, - SrosXiom as _SrosXiom, - SrosComponent as _SrosComponent, - NodeEditorData as _NodeEditorData -} from "../../../../shared/types/editors"; -import { INTEGRATED_SROS_TYPES as _INTEGRATED_SROS_TYPES } from "../../../../shared/types/editors"; - -// Re-export types -export type NodeEditorTabId = _NodeEditorTabId; -export type HealthCheckConfig = _HealthCheckConfig; -export type SrosMda = _SrosMda; -export type SrosXiom = _SrosXiom; -export type SrosComponent = _SrosComponent; -export type NodeEditorData = _NodeEditorData; - -// Re-export values -export const INTEGRATED_SROS_TYPES = _INTEGRATED_SROS_TYPES; - -/** - * Props for tab components - */ -export interface TabProps { - data: NodeEditorData; - onChange: (updates: Partial) => void; - /** Array of property names that are inherited from defaults/kinds/groups */ - inheritedProps?: string[]; - /** Visual-only mode used in unlocked view mode node editing */ - visualOnly?: boolean; -} diff --git a/src/reactTopoViewer/webview/components/panels/svg-export/annotationsToSvg.ts b/src/reactTopoViewer/webview/components/panels/svg-export/annotationsToSvg.ts deleted file mode 100644 index 322d3dd3a..000000000 --- a/src/reactTopoViewer/webview/components/panels/svg-export/annotationsToSvg.ts +++ /dev/null @@ -1,810 +0,0 @@ -// Annotation-to-SVG conversion for export. -import type { - FreeTextAnnotation, - FreeShapeAnnotation, - GroupStyleAnnotation -} from "../../../../shared/types/topology"; -import { - DEFAULT_FILL_COLOR, - DEFAULT_FILL_OPACITY, - DEFAULT_BORDER_COLOR, - DEFAULT_BORDER_WIDTH, - DEFAULT_BORDER_STYLE, - DEFAULT_ARROW_SIZE, - DEFAULT_LINE_LENGTH -} from "../../../annotations/constants"; -import { applyAlphaToColor } from "../../../utils/color"; -import { renderMarkdown } from "../../../utils/markdownRenderer"; - -// ============================================================================ -// Constants -// ============================================================================ - -const SVG_NS = "http://www.w3.org/2000/svg"; -const XHTML_NS = "http://www.w3.org/1999/xhtml"; -const SVG_MIME_TYPE = "image/svg+xml"; -const ANNOTATION_GROUPS_LAYER = "annotation-groups-layer"; -const ANNOTATION_SHAPES_LAYER = "annotation-shapes-layer"; -const ANNOTATION_TEXT_LAYER = "annotation-text-layer"; -const DEFAULT_FONT_FAMILY = "sans-serif"; - -function escapeXml(str: string): string { - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function getBorderDashArray(style?: FreeShapeAnnotation["borderStyle"]): string { - switch (style) { - case "dashed": - return "8,4"; // Match FreeShapeNode.tsx getStrokeDasharray() - case "dotted": - return "2,2"; - default: - return ""; - } -} - -function getGroupBorderDashArray(style?: GroupStyleAnnotation["borderStyle"]): string { - switch (style) { - case "dashed": - return "8,4"; // Match FreeShapeNode.tsx getStrokeDasharray() - case "dotted": - return "2,2"; - case "double": - return ""; // Double style not directly supported in SVG dash, render as solid - default: - return ""; - } -} - -interface ShapeStyle { - fillColor: string; - strokeColor: string; - strokeWidth: number; - dashArray: string; -} - -function getShapeStyle(shape: FreeShapeAnnotation): ShapeStyle { - return { - fillColor: applyAlphaToColor( - shape.fillColor ?? DEFAULT_FILL_COLOR, - shape.fillOpacity ?? DEFAULT_FILL_OPACITY - ), - strokeColor: shape.borderColor ?? DEFAULT_BORDER_COLOR, - strokeWidth: shape.borderWidth ?? DEFAULT_BORDER_WIDTH, - dashArray: getBorderDashArray(shape.borderStyle ?? DEFAULT_BORDER_STYLE) - }; -} - -function buildRectAttrs(style: ShapeStyle, cornerRadius: number): string { - let attrs = `fill="${style.fillColor}" stroke="${style.strokeColor}" stroke-width="${style.strokeWidth}" `; - if (cornerRadius > 0) attrs += `rx="${cornerRadius}" ry="${cornerRadius}" `; - if (style.dashArray) attrs += `stroke-dasharray="${style.dashArray}" `; - return attrs; -} - -function buildStrokeAttrs(style: ShapeStyle): string { - let attrs = `stroke="${style.strokeColor}" stroke-width="${style.strokeWidth}" `; - if (style.dashArray) attrs += `stroke-dasharray="${style.dashArray}" `; - return attrs; -} - -// ============================================================================ -// Group to SVG -// ============================================================================ - -interface LabelPosition { - x: number; - y: number; - textAnchor: string; -} - -interface GroupRect { - x: number; - y: number; - width: number; - height: number; -} - -/** - * Calculate label position based on labelPosition property. - * Matches GroupNode.tsx getLabelPositionStyle() CSS offsets: - * - top positions: top: -20, left/right: 8 - * - bottom positions: bottom: -20, left/right: 8 - */ -function calculateLabelPosition( - rect: GroupRect, - labelPosition: string, - labelFontSize: number -): LabelPosition { - const { x, y, width, height } = rect; - // Match CSS offsets from GroupNode.tsx: top: -20, left: 8 - const topOffset = 20; - const sideOffset = 8; - - const positions: Record = { - "top-left": { x: x + sideOffset, y: y - topOffset + labelFontSize, textAnchor: "start" }, - "top-center": { x: x + width / 2, y: y - topOffset + labelFontSize, textAnchor: "middle" }, - "top-right": { x: x + width - sideOffset, y: y - topOffset + labelFontSize, textAnchor: "end" }, - "bottom-left": { x: x + sideOffset, y: y + height + topOffset, textAnchor: "start" }, - "bottom-center": { x: x + width / 2, y: y + height + topOffset, textAnchor: "middle" }, - "bottom-right": { x: x + width - sideOffset, y: y + height + topOffset, textAnchor: "end" } - }; - - return positions[labelPosition] ?? positions["top-left"]; -} - -/** - * Build SVG for group label (no background - matches GroupNode.tsx). - * Uses MODEL coordinates - the parent transform handles scaling. - */ -function buildGroupLabelSvg( - name: string, - labelPos: LabelPosition, - labelColor: string, - labelFontSize: number -): string { - // No background rect - canvas GroupNode.tsx doesn't have one - let svg = ``; - svg += escapeXml(name); - svg += ``; - - return svg; -} - -/** - * Convert a GroupStyleAnnotation to an SVG string. - * Groups are rendered as rectangles with optional label. - * NOTE: Uses MODEL coordinates - the parent transform handles scaling. - * Group position represents the CENTER of the group (same as canvas rendering). - */ -function groupToSvgString(group: GroupStyleAnnotation): string { - const width = group.width; - const height = group.height; - // Group position is CENTER-based, convert to top-left for SVG rect - const x = group.position.x - group.width / 2; - const y = group.position.y - group.height / 2; - - const bgColor = group.backgroundColor ?? "#d9d9d9"; - const bgOpacity = (group.backgroundOpacity ?? 20) / 100; - const fillColor = bgColor === "transparent" ? "none" : applyAlphaToColor(bgColor, bgOpacity); - - const borderColor = group.borderColor ?? "#dddddd"; - const borderWidth = group.borderWidth ?? 0.5; - const borderRadius = group.borderRadius ?? 0; - const dashArray = getGroupBorderDashArray(group.borderStyle); - - let svg = ``; - svg += ` 0) svg += `rx="${borderRadius}" ry="${borderRadius}" `; - if (dashArray) svg += `stroke-dasharray="${dashArray}" `; - svg += `/>`; - - if (group.name) { - // Match GroupNode.tsx: fontSize 12, fontWeight 500, no background - const labelFontSize = 12; - const labelPos = calculateLabelPosition( - { x, y, width, height }, - group.labelPosition ?? "top-left", - labelFontSize - ); - // Use #666 as default label color (matches DEFAULT_LABEL_COLOR in GroupNode.tsx) - svg += buildGroupLabelSvg(group.name, labelPos, group.labelColor ?? "#666", labelFontSize); - } - - svg += ``; - return svg; -} - -// ============================================================================ -// Shape to SVG - Subcomponents -// ============================================================================ - -function makeArrowPoints( - arrowSize: number, - x: number, - y: number, - fromX: number, - fromY: number -): string { - const angle = Math.atan2(y - fromY, x - fromX); - const arrowAngle = Math.PI / 6; - const p1x = x - arrowSize * Math.cos(angle - arrowAngle); - const p1y = y - arrowSize * Math.sin(angle - arrowAngle); - const p3x = x - arrowSize * Math.cos(angle + arrowAngle); - const p3y = y - arrowSize * Math.sin(angle + arrowAngle); - return `${p1x},${p1y} ${x},${y} ${p3x},${p3y}`; -} - -function buildRectangleSvg(shape: FreeShapeAnnotation): string { - const style = getShapeStyle(shape); - const width = shape.width ?? 50; - const height = shape.height ?? 50; - // Shape position is CENTER-based on canvas (uses translate(-50%, -50%)) - // Convert to top-left corner for SVG - const x = shape.position.x - width / 2; - const y = shape.position.y - height / 2; - const cornerRadius = shape.cornerRadius ?? 0; - const rotation = shape.rotation ?? 0; - // Center point for rotation - const cx = shape.position.x; - const cy = shape.position.y; - - let svg = ``; - svg += ``; - svg += ``; - return svg; -} - -function buildCircleSvg(shape: FreeShapeAnnotation): string { - const style = getShapeStyle(shape); - const width = shape.width ?? 50; - const height = shape.height ?? 50; - // Shape position is CENTER-based on canvas (uses translate(-50%, -50%)) - // For ellipse, cx/cy are the center coordinates, which is exactly the position - const cx = shape.position.x; - const cy = shape.position.y; - const rx = width / 2; - const ry = height / 2; - const rotation = shape.rotation ?? 0; - - let svg = ``; - svg += ``; - svg += ``; - return svg; -} - -function buildLineSvg(shape: FreeShapeAnnotation): string { - const style = getShapeStyle(shape); - const startX = shape.position.x; - const startY = shape.position.y; - const endX = shape.endPosition?.x ?? shape.position.x + DEFAULT_LINE_LENGTH; - const endY = shape.endPosition?.y ?? shape.position.y; - const arrowSize = shape.lineArrowSize ?? DEFAULT_ARROW_SIZE; - - // Shorten line ends if arrows are present - const dx = endX - startX; - const dy = endY - startY; - const length = Math.sqrt(dx * dx + dy * dy); - let lineStartX = startX, - lineStartY = startY, - lineEndX = endX, - lineEndY = endY; - - if (length > 0) { - const ux = dx / length; - const uy = dy / length; - if (shape.lineStartArrow === true) { - lineStartX += ux * arrowSize * 0.7; - lineStartY += uy * arrowSize * 0.7; - } - if (shape.lineEndArrow === true) { - lineEndX -= ux * arrowSize * 0.7; - lineEndY -= uy * arrowSize * 0.7; - } - } - - let svg = ``; - svg += ``; - - if (shape.lineStartArrow === true) { - svg += ``; - } - if (shape.lineEndArrow === true) { - svg += ``; - } - - svg += ``; - return svg; -} - -/** - * Convert a FreeShapeAnnotation to an SVG string. - * NOTE: Uses MODEL coordinates - the parent transform handles scaling. - */ -function shapeToSvgString(shape: FreeShapeAnnotation): string { - switch (shape.shapeType) { - case "rectangle": - return buildRectangleSvg(shape); - case "circle": - return buildCircleSvg(shape); - case "line": - default: - return buildLineSvg(shape); - } -} - -// ============================================================================ -// Text to SVG -// ============================================================================ - -interface TextStyle { - fontSize: number; - fontColor: string; - fontWeight: string; - fontStyle: string; - textDecoration: string; - textAlign: string; - fontFamily: string; - backgroundColor: string; - borderRadius: number; - padding: number; -} - -function getTextStyle(text: FreeTextAnnotation): TextStyle { - // Match FreeTextNode.tsx buildTextStyle() defaults - return { - fontSize: text.fontSize ?? 14, - fontColor: text.fontColor ?? "#333", // Match FreeTextNode default - fontWeight: text.fontWeight ?? "normal", - fontStyle: text.fontStyle ?? "normal", - textDecoration: text.textDecoration ?? "none", - textAlign: text.textAlign ?? "left", - fontFamily: text.fontFamily ?? "inherit", // Match FreeTextNode default - backgroundColor: text.backgroundColor ?? "transparent", - borderRadius: text.roundedBackground === true ? 4 : 0, - padding: 4 // Base padding; increases to 8px horizontal when backgroundColor set - }; -} - -function buildTextStyleString(style: TextStyle): string { - let css = `width: 100%; height: 100%; overflow: hidden; `; - css += `font-size: ${style.fontSize}px; color: ${style.fontColor}; `; - css += `font-weight: ${style.fontWeight}; font-style: ${style.fontStyle}; `; - css += `text-decoration: ${style.textDecoration}; text-align: ${style.textAlign}; `; - css += `font-family: ${style.fontFamily}; `; - css += `background-color: ${style.backgroundColor}; `; - if (style.borderRadius > 0) css += `border-radius: ${style.borderRadius}px; `; - css += `box-sizing: border-box; padding: ${style.padding}px;`; - return css; -} - -/** - * Estimate text dimensions based on content and font properties. - * Returns { width, height } in unscaled coordinates. - */ -function estimateTextDimensions( - textContent: string, - fontSize: number, - fontFamily: string, - fontWeight: string -): { width: number; height: number } { - // Split into lines for multi-line text - const lines = textContent.split("\n"); - const lineCount = Math.max(1, lines.length); - - // Find the longest line - const longestLine = lines.reduce((a, b) => (a.length > b.length ? a : b), ""); - - // Character width multiplier based on font family - // Monospace fonts have consistent width, proportional fonts vary - const isMonospace = fontFamily.toLowerCase().includes("mono"); - const charWidthRatio = isMonospace ? 0.6 : 0.55; - - // Bold text is slightly wider - const boldMultiplier = fontWeight === "bold" ? 1.1 : 1.0; - - // Calculate dimensions - const charWidth = fontSize * charWidthRatio * boldMultiplier; - const lineHeight = fontSize * 1.4; // Standard line height - - // Add padding (8px on each side = 16px total) - const padding = 16; - const width = Math.max(50, longestLine.length * charWidth + padding); - const height = Math.max(fontSize + padding, lineCount * lineHeight + padding); - - return { width, height }; -} - -/** - * Convert a FreeTextAnnotation to an SVG string using foreignObject. - * This preserves markdown rendering and styling. - * NOTE: Uses MODEL coordinates - the parent transform handles scaling. - * Text position represents the TOP-LEFT of the annotation (React Flow convention). - */ -function textToSvgString(text: FreeTextAnnotation): string { - // Use explicit dimensions if provided, otherwise estimate from content - let width: number; - let height: number; - - if (text.width !== undefined && text.height !== undefined) { - width = text.width; - height = text.height; - } else { - const estimated = estimateTextDimensions( - text.text || "", - text.fontSize ?? 14, - text.fontFamily ?? "inherit", - text.fontWeight ?? "normal" - ); - width = text.width ?? estimated.width; - height = text.height ?? estimated.height; - } - - // Text position is TOP-LEFT based in React Flow - // Use position directly for SVG foreignObject - const x = text.position.x; - const y = text.position.y; - - const rotation = text.rotation ?? 0; - // Center point for rotation - const cx = text.position.x + width / 2; - const cy = text.position.y + height / 2; - - const style = getTextStyle(text); - const styleStr = buildTextStyleString(style); - const htmlContent = renderMarkdown(text.text || ""); - - let svg = ``; - - svg += ``; - svg += `
    `; - svg += htmlContent; - svg += `
    `; - svg += `
    `; - svg += `
    `; - - return svg; -} - -// ============================================================================ -// Composite into SVG -// ============================================================================ - -export interface AnnotationData { - groups: GroupStyleAnnotation[]; - textAnnotations: FreeTextAnnotation[]; - shapeAnnotations: FreeShapeAnnotation[]; -} - -/** - * Add a background rectangle to the SVG content. - * Handles both SVGs with viewBox and those with only width/height. - */ -export function addBackgroundRect(svgContent: string, color: string): string { - const parser = new DOMParser(); - const doc = parser.parseFromString(svgContent, SVG_MIME_TYPE); - const svgEl = doc.documentElement; - - const viewBox = svgEl.getAttribute("viewBox"); - let x = 0, - y = 0, - width = 0, - height = 0; - - if (viewBox !== null && viewBox.length > 0) { - [x, y, width, height] = viewBox.split(" ").map(parseFloat); - } else { - // Fall back to width/height attributes - width = parseFloat(svgEl.getAttribute("width") ?? "0"); - height = parseFloat(svgEl.getAttribute("height") ?? "0"); - if (width === 0 || height === 0) return svgContent; - } - - const rect = doc.createElementNS(SVG_NS, "rect"); - rect.setAttribute("x", x.toString()); - rect.setAttribute("y", y.toString()); - rect.setAttribute("width", width.toString()); - rect.setAttribute("height", height.toString()); - rect.setAttribute("fill", color); - - svgEl.insertBefore(rect, svgEl.firstChild); - - return new XMLSerializer().serializeToString(svgEl); -} - -function parseAndImportElement(doc: Document, parser: DOMParser, svgStr: string): Element | null { - const tempDoc = parser.parseFromString(`${svgStr}`, SVG_MIME_TYPE); - const element = tempDoc.documentElement.firstChild; - if (!(element instanceof Element)) return null; - const imported = doc.importNode(element, true); - return imported instanceof Element ? imported : null; -} - -/** - * Extract the full transform attribute from the SVG's main group. - * Returns the complete transform string including all translates and scale. - */ -function extractGraphTransform(svgEl: Element): string { - // Find the main content group with transform (should have scale for exports) - const groups = svgEl.querySelectorAll("g[transform]"); - for (let i = 0; i < groups.length; i++) { - const group = groups[i]; - const transform = group.getAttribute("transform") ?? ""; - // Look for the main group which has scale in its transform - if (transform.includes("scale(")) { - return transform; - } - } - - // Fallback: find any group with a translate transform - const firstGroup = svgEl.querySelector("g[transform]"); - return firstGroup?.getAttribute("transform") ?? ""; -} - -/** - * Parse transform to extract scale value for bounds calculation. - */ -function extractScaleFromTransform(transform: string): number { - const scaleMatch = /scale\(\s*([-\d.]+)(?:\s*,\s*([-\d.]+))?\s*\)/.exec(transform); - return scaleMatch ? parseFloat(scaleMatch[1]) : 1; -} - -/** - * Parse transform to extract the total translate values for bounds calculation. - * Sums all translate operations in the transform string. - */ -function extractTranslateFromTransform(transform: string): { tx: number; ty: number } { - let totalTx = 0; - let totalTy = 0; - - const translateRegex = /translate\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/g; - let match; - while ((match = translateRegex.exec(transform)) !== null) { - totalTx += parseFloat(match[1]); - totalTy += parseFloat(match[2]); - } - - return { tx: totalTx, ty: totalTy }; -} - -interface BoundingBox { - minX: number; - minY: number; - maxX: number; - maxY: number; -} - -/** Merge a rect (x1,y1,x2,y2) into bounds */ -function mergeBounds(bounds: BoundingBox, x1: number, y1: number, x2: number, y2: number): void { - bounds.minX = Math.min(bounds.minX, x1); - bounds.minY = Math.min(bounds.minY, y1); - bounds.maxX = Math.max(bounds.maxX, x2); - bounds.maxY = Math.max(bounds.maxY, y2); -} - -/** Calculate bounds for a center-based rect (in model coordinates) */ -function getCenterBasedBounds(cx: number, cy: number, w: number, h: number) { - const halfW = w / 2; - const halfH = h / 2; - return { - x1: cx - halfW, - y1: cy - halfH, - x2: cx + halfW, - y2: cy + halfH - }; -} - -function addGroupBounds(bounds: BoundingBox, groups: GroupStyleAnnotation[]): void { - for (const group of groups) { - const b = getCenterBasedBounds(group.position.x, group.position.y, group.width, group.height); - mergeBounds(bounds, b.x1, b.y1, b.x2, b.y2); - } -} - -function addShapeBounds(bounds: BoundingBox, shapes: FreeShapeAnnotation[]): void { - for (const shape of shapes) { - if (shape.shapeType === "line") { - const x1 = shape.position.x; - const y1 = shape.position.y; - const x2 = shape.endPosition?.x ?? shape.position.x; - const y2 = shape.endPosition?.y ?? shape.position.y; - mergeBounds(bounds, Math.min(x1, x2), Math.min(y1, y2), Math.max(x1, x2), Math.max(y1, y2)); - } else { - const b = getCenterBasedBounds( - shape.position.x, - shape.position.y, - shape.width ?? 50, - shape.height ?? 50 - ); - mergeBounds(bounds, b.x1, b.y1, b.x2, b.y2); - } - } -} - -function getTextDimensions(text: FreeTextAnnotation): { w: number; h: number } { - if (text.width !== undefined && text.height !== undefined) { - return { w: text.width, h: text.height }; - } - const estimated = estimateTextDimensions( - text.text || "", - text.fontSize ?? 14, - text.fontFamily ?? "sans-serif", - text.fontWeight ?? "normal" - ); - return { w: text.width ?? estimated.width, h: text.height ?? estimated.height }; -} - -function addTextBounds(bounds: BoundingBox, texts: FreeTextAnnotation[]): void { - for (const text of texts) { - const { w, h } = getTextDimensions(text); - // Text position is TOP-LEFT based (React Flow convention) - const x1 = text.position.x; - const y1 = text.position.y; - const x2 = text.position.x + w; - const y2 = text.position.y + h; - mergeBounds(bounds, x1, y1, x2, y2); - } -} - -/** - * Calculate bounding box for all annotations (in MODEL coordinates). - * NOTE: All annotation positions are CENTER-based on canvas. - */ -function calculateAnnotationsBounds(annotations: AnnotationData): BoundingBox { - const bounds: BoundingBox = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; - addGroupBounds(bounds, annotations.groups); - addShapeBounds(bounds, annotations.shapeAnnotations); - addTextBounds(bounds, annotations.textAnnotations); - return bounds; -} - -function shiftGroupTransforms(svgEl: Element, shiftX: number, shiftY: number): void { - const children = svgEl.children; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (child.tagName === "g") { - const existingTransform = child.getAttribute("transform") ?? ""; - const newTransform = existingTransform - ? `translate(${shiftX}, ${shiftY}) ${existingTransform}` - : `translate(${shiftX}, ${shiftY})`; - child.setAttribute("transform", newTransform); - } - } -} - -function shiftBackgroundRect( - svgEl: Element, - shiftX: number, - shiftY: number, - newWidth: number, - newHeight: number -): void { - const bgRect = svgEl.querySelector("rect"); - if (bgRect && !bgRect.closest("g")) { - const rectX = parseFloat(bgRect.getAttribute("x") ?? "0"); - const rectY = parseFloat(bgRect.getAttribute("y") ?? "0"); - bgRect.setAttribute("x", (rectX + shiftX).toString()); - bgRect.setAttribute("y", (rectY + shiftY).toString()); - bgRect.setAttribute("width", newWidth.toString()); - bgRect.setAttribute("height", newHeight.toString()); - } -} - -/** - * Expand SVG dimensions to include annotation bounds. - * @param transform - The full transform string from the graph - */ -function expandSvgBounds(svgEl: Element, annotationBounds: BoundingBox, transform: string): void { - const currentWidth = parseFloat(svgEl.getAttribute("width") ?? "0"); - const currentHeight = parseFloat(svgEl.getAttribute("height") ?? "0"); - - // Extract translate and scale from the transform - const { tx, ty } = extractTranslateFromTransform(transform); - const scale = extractScaleFromTransform(transform); - - // Transform annotation bounds from model coordinates to SVG coordinates - // The transform applies scale THEN translate, so: - // SVG_x = model_x * scale + tx - const margin = 20; - const annMinX = annotationBounds.minX * scale + tx - margin; - const annMinY = annotationBounds.minY * scale + ty - margin; - const annMaxX = annotationBounds.maxX * scale + tx + margin; - const annMaxY = annotationBounds.maxY * scale + ty + margin; - - // Combined bounds - const newMinX = Math.min(0, annMinX); - const newMinY = Math.min(0, annMinY); - const newMaxX = Math.max(currentWidth, annMaxX); - const newMaxY = Math.max(currentHeight, annMaxY); - - const newWidth = newMaxX - newMinX; - const newHeight = newMaxY - newMinY; - const needsExpansion = - newMinX < 0 || newMinY < 0 || newWidth > currentWidth || newHeight > currentHeight; - - if (!needsExpansion) return; - - svgEl.setAttribute("width", newWidth.toString()); - svgEl.setAttribute("height", newHeight.toString()); - - // If we expanded to negative coordinates, shift all content - if (newMinX < 0 || newMinY < 0) { - const shiftX = newMinX < 0 ? -newMinX : 0; - const shiftY = newMinY < 0 ? -newMinY : 0; - - shiftGroupTransforms(svgEl, shiftX, shiftY); - shiftBackgroundRect(svgEl, shiftX, shiftY, newWidth, newHeight); - } -} - -/** - * Composite annotations into an existing graph SVG. - * Annotations are inserted in z-order: groups (background), shapes, text (foreground). - * The graph transform is extracted and applied to annotation layers so they - * use the same coordinate system as the graph nodes (model coordinates). - */ -export function compositeAnnotationsIntoSvg( - graphSvg: string, - annotations: AnnotationData, - _scale: number // Kept for API compatibility but not used - scale comes from transform -): string { - const { groups, textAnnotations, shapeAnnotations } = annotations; - - // Skip if no annotations - if (groups.length === 0 && textAnnotations.length === 0 && shapeAnnotations.length === 0) { - return graphSvg; - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(graphSvg, SVG_MIME_TYPE); - const svgEl = doc.documentElement; - - // Extract the FULL transform from graph content (including all translates and scale) - // This ensures annotations use the exact same coordinate system as graph nodes - const transform = extractGraphTransform(svgEl); - - // Create annotation layer groups with the SAME transform as graph content - const groupsLayer = doc.createElementNS(SVG_NS, "g"); - groupsLayer.setAttribute("class", ANNOTATION_GROUPS_LAYER); - groupsLayer.setAttribute("transform", transform); - - const shapesLayer = doc.createElementNS(SVG_NS, "g"); - shapesLayer.setAttribute("class", ANNOTATION_SHAPES_LAYER); - shapesLayer.setAttribute("transform", transform); - - const textLayer = doc.createElementNS(SVG_NS, "g"); - textLayer.setAttribute("class", ANNOTATION_TEXT_LAYER); - textLayer.setAttribute("transform", transform); - - // Sort by zIndex - const sortedGroups = [...groups].sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0)); - const sortedShapes = [...shapeAnnotations].sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0)); - const sortedText = [...textAnnotations].sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0)); - - // Render groups (in model coordinates - the transform handles scaling) - for (const group of sortedGroups) { - const element = parseAndImportElement(doc, parser, groupToSvgString(group)); - if (element) groupsLayer.appendChild(element); - } - - // Render shapes - for (const shape of sortedShapes) { - const element = parseAndImportElement(doc, parser, shapeToSvgString(shape)); - if (element) shapesLayer.appendChild(element); - } - - // Render text annotations - for (const text of sortedText) { - const element = parseAndImportElement(doc, parser, textToSvgString(text)); - if (element) textLayer.appendChild(element); - } - - // Insert layers in z-order - // Groups go at the beginning (behind graph content) - svgEl.insertBefore(groupsLayer, svgEl.firstChild); - // Shapes and text go at the end (in front of graph content) - svgEl.appendChild(shapesLayer); - svgEl.appendChild(textLayer); - - // Calculate annotation bounds (in model coordinates) and expand SVG if needed - const annotationBounds = calculateAnnotationsBounds(annotations); - if (annotationBounds.minX !== Infinity) { - expandSvgBounds(svgEl, annotationBounds, transform); - } - - return new XMLSerializer().serializeToString(svgEl); -} diff --git a/src/reactTopoViewer/webview/components/panels/svg-export/constants.ts b/src/reactTopoViewer/webview/components/panels/svg-export/constants.ts deleted file mode 100644 index 00cf6caf8..000000000 --- a/src/reactTopoViewer/webview/components/panels/svg-export/constants.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * SVG export style constants matching canvas rendering - * These values are extracted from the React Flow node/edge components - */ - -// ============================================================================ -// Node Constants -// ============================================================================ - -/** Node icon size (matches TopologyNode.tsx ICON_SIZE) */ -export const NODE_ICON_SIZE = 40; - -/** Node icon corner radius */ -export const NODE_ICON_RADIUS = 4; - -/** Default icon color (matches graph.ts DEFAULT_ICON_COLOR) */ -export const DEFAULT_ICON_COLOR = "#005aff"; - -// ============================================================================ -// Node Label Constants (matches nodeStyles.ts LABEL_STYLE_BASE) -// ============================================================================ - -export const NODE_LABEL = { - fontWeight: 500, - color: "#FFFFFF", - textShadowColor: "#3C3E41", - textShadowBlur: 3, - backgroundColor: "rgba(0, 0, 0, 0.85)", - textStrokeColor: "rgba(0, 0, 0, 0.95)", - textStrokeWidth: 0.8, - paddingX: 4, - paddingY: 1, - borderRadius: 3, - maxWidth: 80, - fontSize: 11, // 0.7rem ≈ 11px - /** Gap between icon and label */ - marginTop: 2 -} as const; - -// ============================================================================ -// Edge Constants (matches TopologyEdge.tsx) -// ============================================================================ - -export const EDGE_COLOR = { - default: "#969799", - up: "#00df2b", - down: "#df2b00" -} as const; - -export const EDGE_STYLE = { - strokeWidth: 2.5, - opacity: 0.5 -} as const; - -/** Control point step size for parallel edge bezier curves */ -export const CONTROL_POINT_STEP_SIZE = 40; - -// ============================================================================ -// Edge Label Constants (matches TopologyEdge.tsx LABEL_STYLE_BASE) -// ============================================================================ - -export const EDGE_LABEL = { - fontSize: 9, - fontFamily: "Helvetica, Arial, sans-serif", - color: "#FFFFFF", - backgroundColor: "#bec8d2", - textStrokeColor: "rgba(0, 0, 0, 0.95)", - textStrokeWidth: 0.6, - outlineColor: "rgba(0, 0, 0, 0.25)", - paddingX: 3, - paddingY: 1, - borderRadius: 4, - /** Pixels from node edge for label positioning */ - offset: 18 -} as const; - -// ============================================================================ -// Network Node Type Colors (matches NetworkNode.tsx getNodeTypeColor) -// ============================================================================ - -export const NETWORK_TYPE_COLOR: Record = { - host: "#6B7280", - "mgmt-net": "#3B82F6", - macvlan: "#10B981", - vxlan: "#8B5CF6", - bridge: "#F59E0B", - "ovs-bridge": "#F59E0B", - default: "#6B7280" -} as const; - -/** Get network node icon color by type */ -export function getNetworkTypeColor(nodeType: string): string { - return NETWORK_TYPE_COLOR[nodeType] ?? NETWORK_TYPE_COLOR.default; -} - -// ============================================================================ -// Role to SVG Type Mapping (matches graph.ts ROLE_SVG_MAP) -// ============================================================================ - -/** Map node role names to icon types */ -export const ROLE_SVG_MAP: Record = { - router: "pe", - "Provider Edge Router": "pe", - "provider edge router": "pe", - dcgw: "dcgw", - "dcgw-evpn": "dcgw", - leaf: "leaf", - switch: "switch", - bridge: "bridge", - spine: "spine", - "super-spine": "super-spine", - server: "server", - pon: "pon", - controller: "controller", - rgw: "rgw", - ue: "ue", - cloud: "cloud", - client: "client" -} as const; - -/** Get SVG node type from role string */ -export function getRoleSvgType(role: string): string { - return ROLE_SVG_MAP[role] ?? "pe"; -} - -// ============================================================================ -// SVG Filter Definitions -// ============================================================================ - -/** - * SVG filter for text shadow effect (matches nodeStyles.ts textShadow) - */ -export const TEXT_SHADOW_FILTER = ` - - - - - - - - - -`; - -/** - * Generate SVG defs section with all needed filters - */ -export function buildSvgDefs(): string { - return `${TEXT_SHADOW_FILTER}`; -} - -// ============================================================================ -// XML Utilities -// ============================================================================ - -/** - * Escape special XML characters for safe embedding in SVG - */ -export function escapeXml(str: string): string { - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} diff --git a/src/reactTopoViewer/webview/components/panels/svg-export/edgesToSvg.ts b/src/reactTopoViewer/webview/components/panels/svg-export/edgesToSvg.ts deleted file mode 100644 index fb6527d7e..000000000 --- a/src/reactTopoViewer/webview/components/panels/svg-export/edgesToSvg.ts +++ /dev/null @@ -1,896 +0,0 @@ -// Edge-to-SVG conversion for export. -import type { Node, Edge } from "@xyflow/react"; - -import { - getEdgePoints, - calculateControlPoint, - getLabelPosition, - getNodeIntersection -} from "../../canvas/edgeGeometry"; - -import { - NODE_ICON_SIZE, - EDGE_COLOR, - EDGE_STYLE, - EDGE_LABEL, - CONTROL_POINT_STEP_SIZE, - escapeXml -} from "./constants"; -import { getAutoCompactInterfaceLabel } from "../../../utils/telemetryInterfaceLabels"; - -// ============================================================================ -// Types -// ============================================================================ - -interface TopologyEdgeData { - sourceEndpoint?: string; - targetEndpoint?: string; - linkStatus?: "up" | "down"; - [key: string]: unknown; -} - -interface EdgeInfo { - parallelInfo: Map; - loopInfo: Map; -} - -interface NodeRect { - x: number; - y: number; - width: number; - height: number; -} - -export interface EdgeSvgRenderOptions { - nodeIconSize?: number; - interfaceScale?: number; - interfaceLabelOverrides?: Record; -} - -type InterfaceSide = "top" | "right" | "bottom" | "left"; - -interface EndpointVector { - dx: number; - dy: number; - samples: number; -} - -interface InterfaceAnchor { - x: number; - y: number; -} - -type NodeInterfaceAnchorMap = Map>; - -interface EndpointAssignment { - endpoint: string; - sortKey: number; - radius: number; -} - -interface ResolvedEdgeRenderOptions { - nodeIconSize: number; - interfaceScale: number; - interfaceLabelOverrides: Record; -} - -// ============================================================================ -// Helpers -// ============================================================================ - -/** - * Get stroke color based on link status - */ -function getEdgeColor(linkStatus: string | undefined): string { - switch (linkStatus) { - case "up": - return EDGE_COLOR.up; - case "down": - return EDGE_COLOR.down; - default: - return EDGE_COLOR.default; - } -} - -/** - * Create canonical edge key for grouping parallel edges - * Ensures same key regardless of direction - */ -function getCanonicalEdgeKey(source: string, target: string): string { - return source < target ? `${source}:${target}` : `${target}:${source}`; -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function resolveEdgeRenderOptions(renderOptions?: EdgeSvgRenderOptions): ResolvedEdgeRenderOptions { - const nodeIconSizeRaw = renderOptions?.nodeIconSize; - const interfaceScaleRaw = renderOptions?.interfaceScale; - const nodeIconSize = - typeof nodeIconSizeRaw === "number" && Number.isFinite(nodeIconSizeRaw) - ? clamp(nodeIconSizeRaw, 12, 240) - : NODE_ICON_SIZE; - const interfaceScale = - typeof interfaceScaleRaw === "number" && Number.isFinite(interfaceScaleRaw) - ? clamp(interfaceScaleRaw, 0.4, 4) - : 1; - const interfaceLabelOverrides = - renderOptions?.interfaceLabelOverrides && - typeof renderOptions.interfaceLabelOverrides === "object" - ? renderOptions.interfaceLabelOverrides - : {}; - - return { nodeIconSize, interfaceScale, interfaceLabelOverrides }; -} - -function normalizeEndpoint(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function getNodeRect(node: Node, nodeIconSize: number): NodeRect { - return { - x: node.position.x, - y: node.position.y, - width: nodeIconSize, - height: nodeIconSize - }; -} - -function getRectCenter(rect: NodeRect): { x: number; y: number } { - return { - x: rect.x + rect.width / 2, - y: rect.y + rect.height / 2 - }; -} - -function sideBuckets(): Record { - return { top: [], right: [], bottom: [], left: [] }; -} - -function getOrCreateNodeEndpointVectors( - vectorsByNode: Map>, - nodeId: string -): Map { - const existing = vectorsByNode.get(nodeId); - if (existing) return existing; - const created = new Map(); - vectorsByNode.set(nodeId, created); - return created; -} - -function addEndpointVector( - vectorsByNode: Map>, - nodeId: string, - endpoint: string, - dx: number, - dy: number -): void { - const nodeVectors = getOrCreateNodeEndpointVectors(vectorsByNode, nodeId); - const existing = nodeVectors.get(endpoint) ?? { dx: 0, dy: 0, samples: 0 }; - existing.dx += dx; - existing.dy += dy; - existing.samples += 1; - nodeVectors.set(endpoint, existing); -} - -function getOrCreateEndpointSet( - endpointsByNode: Map>, - nodeId: string -): Set { - const existing = endpointsByNode.get(nodeId); - if (existing) return existing; - const created = new Set(); - endpointsByNode.set(nodeId, created); - return created; -} - -function trackNodeEndpoint( - endpointsByNode: Map>, - nodeId: string, - endpoint: string | null -): void { - if (endpoint === null) return; - getOrCreateEndpointSet(endpointsByNode, nodeId).add(endpoint); -} - -function collectEdgeEndpointVectors( - edge: Edge, - sourceEndpoint: string | null, - targetEndpoint: string | null, - nodeMap: Map, - nodeIconSize: number, - vectorsByNode: Map> -): void { - if (sourceEndpoint === null && targetEndpoint === null) return; - if (edge.source === edge.target) return; - - const sourceNode = nodeMap.get(edge.source); - const targetNode = nodeMap.get(edge.target); - if (!sourceNode || !targetNode) return; - - const sourceCenter = getRectCenter(getNodeRect(sourceNode, nodeIconSize)); - const targetCenter = getRectCenter(getNodeRect(targetNode, nodeIconSize)); - const forwardDx = targetCenter.x - sourceCenter.x; - const forwardDy = targetCenter.y - sourceCenter.y; - - if (sourceEndpoint !== null) { - addEndpointVector(vectorsByNode, edge.source, sourceEndpoint, forwardDx, forwardDy); - } - if (targetEndpoint !== null) { - addEndpointVector(vectorsByNode, edge.target, targetEndpoint, -forwardDx, -forwardDy); - } -} - -function collectInterfaceAnchorInputs( - edges: Edge[], - nodeMap: Map, - nodeIconSize: number -): { - endpointsByNode: Map>; - vectorsByNode: Map>; -} { - const endpointsByNode = new Map>(); - const vectorsByNode = new Map>(); - - for (const edge of edges) { - const data = edge.data as TopologyEdgeData | undefined; - const sourceEndpoint = normalizeEndpoint(data?.sourceEndpoint); - const targetEndpoint = normalizeEndpoint(data?.targetEndpoint); - trackNodeEndpoint(endpointsByNode, edge.source, sourceEndpoint); - trackNodeEndpoint(endpointsByNode, edge.target, targetEndpoint); - collectEdgeEndpointVectors( - edge, - sourceEndpoint, - targetEndpoint, - nodeMap, - nodeIconSize, - vectorsByNode - ); - } - - return { endpointsByNode, vectorsByNode }; -} - -const HORIZONTAL_SLOPE_THRESHOLD = 0.25; - -function classifyInterfaceSide(vector: EndpointVector | undefined): InterfaceSide { - if (!vector || vector.samples <= 0) return "bottom"; - - const dx = vector.dx / vector.samples; - const dy = vector.dy / vector.samples; - const absDx = Math.abs(dx); - const absDy = Math.abs(dy); - - // Keep anchors on top/bottom by default; use sides only for near-horizontal links. - if (absDx > 0.001 && absDy <= absDx * HORIZONTAL_SLOPE_THRESHOLD) { - return dx >= 0 ? "right" : "left"; - } - - return dy >= 0 ? "bottom" : "top"; -} - -function getInterfaceSortKey(side: InterfaceSide, vector: EndpointVector | undefined): number { - if (!vector || vector.samples <= 0) return 0; - const avgDx = vector.dx / vector.samples; - const avgDy = vector.dy / vector.samples; - return side === "top" || side === "bottom" ? avgDx : avgDy; -} - -function positionInterfaceAnchor( - rect: NodeRect, - side: InterfaceSide, - index: number, - total: number, - radius: number -): InterfaceAnchor { - const slot = (index + 1) / (total + 1); - const out = radius + 1; - - switch (side) { - case "top": - return { x: rect.x + rect.width * slot, y: rect.y - out }; - case "right": - return { x: rect.x + rect.width + out, y: rect.y + rect.height * slot }; - case "bottom": - return { x: rect.x + rect.width * slot, y: rect.y + rect.height + out }; - case "left": - return { x: rect.x - out, y: rect.y + rect.height * slot }; - } -} - -function buildNodeSideAssignments( - endpoints: Set, - nodeVectors: Map | undefined, - renderOptions: ResolvedEdgeRenderOptions -): Record { - const buckets = sideBuckets(); - for (const endpoint of endpoints) { - const vector = nodeVectors?.get(endpoint); - const side = classifyInterfaceSide(vector); - const sortKey = getInterfaceSortKey(side, vector); - const { radius } = getEndpointLabelMetrics( - endpoint, - renderOptions.interfaceScale, - renderOptions.interfaceLabelOverrides - ); - buckets[side].push({ endpoint, sortKey, radius }); - } - return buckets; -} - -function sortEndpointAssignments(assignments: EndpointAssignment[]): void { - assignments.sort((a, b) => { - const bySort = a.sortKey - b.sortKey; - if (bySort !== 0) return bySort; - return a.endpoint.localeCompare(b.endpoint); - }); -} - -function assignNodeAnchors( - rect: NodeRect, - buckets: Record -): Map { - const endpointAnchors = new Map(); - for (const side of ["top", "right", "bottom", "left"] as const) { - const assignments = buckets[side]; - sortEndpointAssignments(assignments); - for (let i = 0; i < assignments.length; i++) { - const assignment = assignments[i]; - endpointAnchors.set( - assignment.endpoint, - positionInterfaceAnchor(rect, side, i, assignments.length, assignment.radius) - ); - } - } - return endpointAnchors; -} - -function buildInterfaceAnchorMap( - edges: Edge[], - nodeMap: Map, - renderOptions: ResolvedEdgeRenderOptions -): NodeInterfaceAnchorMap { - const { endpointsByNode, vectorsByNode } = collectInterfaceAnchorInputs( - edges, - nodeMap, - renderOptions.nodeIconSize - ); - const anchorsByNode: NodeInterfaceAnchorMap = new Map(); - for (const [nodeId, endpoints] of endpointsByNode) { - const node = nodeMap.get(nodeId); - if (!node) continue; - const rect = getNodeRect(node, renderOptions.nodeIconSize); - const nodeVectors = vectorsByNode.get(nodeId); - const buckets = buildNodeSideAssignments(endpoints, nodeVectors, renderOptions); - const endpointAnchors = assignNodeAnchors(rect, buckets); - anchorsByNode.set(nodeId, endpointAnchors); - } - return anchorsByNode; -} - -function resolveEdgePointsWithInterfaceAnchors( - sourceRect: NodeRect, - targetRect: NodeRect, - sourceAnchor?: InterfaceAnchor, - targetAnchor?: InterfaceAnchor -): { sx: number; sy: number; tx: number; ty: number } { - if (sourceAnchor && targetAnchor) { - return { sx: sourceAnchor.x, sy: sourceAnchor.y, tx: targetAnchor.x, ty: targetAnchor.y }; - } - - if (sourceAnchor) { - const targetCenter = getRectCenter(targetRect); - const targetPoint = getNodeIntersection( - targetCenter.x, - targetCenter.y, - targetRect.width, - targetRect.height, - sourceAnchor.x, - sourceAnchor.y - ); - return { sx: sourceAnchor.x, sy: sourceAnchor.y, tx: targetPoint.x, ty: targetPoint.y }; - } - - if (targetAnchor) { - const sourceCenter = getRectCenter(sourceRect); - const sourcePoint = getNodeIntersection( - sourceCenter.x, - sourceCenter.y, - sourceRect.width, - sourceRect.height, - targetAnchor.x, - targetAnchor.y - ); - return { sx: sourcePoint.x, sy: sourcePoint.y, tx: targetAnchor.x, ty: targetAnchor.y }; - } - - return getEdgePoints(sourceRect, targetRect); -} - -// ============================================================================ -// Edge Info Builder -// ============================================================================ - -/** - * Build edge info for export (parallel edge grouping and loop detection) - * Similar to useEdgeInfo hook but for static export - */ -export function buildEdgeInfoForExport(edges: Edge[]): EdgeInfo { - const parallelInfo = new Map< - string, - { index: number; total: number; isCanonicalDirection: boolean } - >(); - const loopInfo = new Map(); - - // Group edges by canonical key - const edgeGroups = new Map(); - const loopEdges = new Map(); - - for (const edge of edges) { - if (edge.source === edge.target) { - // Loop edge - const existing = loopEdges.get(edge.source) ?? []; - existing.push(edge); - loopEdges.set(edge.source, existing); - } else { - // Regular or parallel edge - const key = getCanonicalEdgeKey(edge.source, edge.target); - const existing = edgeGroups.get(key) ?? []; - existing.push(edge); - edgeGroups.set(key, existing); - } - } - - // Process loop edges - for (const [, nodeLoopEdges] of loopEdges) { - for (let i = 0; i < nodeLoopEdges.length; i++) { - loopInfo.set(nodeLoopEdges[i].id, { loopIndex: i }); - } - } - - // Process parallel edges - for (const [key, groupEdges] of edgeGroups) { - const total = groupEdges.length; - const [canonicalSource] = key.split(":"); - - for (let i = 0; i < groupEdges.length; i++) { - const edge = groupEdges[i]; - const isCanonicalDirection = edge.source === canonicalSource; - parallelInfo.set(edge.id, { index: i, total, isCanonicalDirection }); - } - } - - return { parallelInfo, loopInfo }; -} - -// ============================================================================ -// Edge Label Builder -// ============================================================================ - -/** - * Build SVG for edge endpoint label - */ -function buildEndpointLabelSvg( - text: string, - x: number, - y: number, - interfaceScale: number, - interfaceLabelOverrides: Record -): string { - if (!text) return ""; - - const { compact, radius, fontSize, bubbleStrokeWidth, textStrokeWidth } = getEndpointLabelMetrics( - text, - interfaceScale, - interfaceLabelOverrides - ); - const textY = y; - - let svg = ``; - - svg += ``; - - svg += ``; - svg += escapeXml(compact); - svg += ``; - - svg += ``; - return svg; -} - -function getDisplayInterfaceLabel( - endpoint: string, - interfaceLabelOverrides: Record -): string { - const override = interfaceLabelOverrides[endpoint]; - if (typeof override === "string" && override.trim().length > 0) { - return override.trim(); - } - return getAutoCompactInterfaceLabel(endpoint); -} - -function getEndpointLabelMetrics( - endpoint: string, - interfaceScale: number, - interfaceLabelOverrides: Record -): { - compact: string; - radius: number; - fontSize: number; - bubbleStrokeWidth: number; - textStrokeWidth: number; -} { - const compact = getDisplayInterfaceLabel(endpoint, interfaceLabelOverrides); - const safeScale = clamp(interfaceScale, 0.4, 4); - const fontSize = EDGE_LABEL.fontSize * safeScale; - const charWidth = fontSize * 0.58; - const textWidth = Math.max(fontSize * 0.8, compact.length * charWidth); - const radius = Math.max(6 * safeScale, textWidth / 2 + 2 * safeScale); - const bubbleStrokeWidth = 0.7 * Math.max(0.6, safeScale); - const textStrokeWidth = EDGE_LABEL.textStrokeWidth * Math.max(0.6, safeScale); - - return { compact, radius, fontSize, bubbleStrokeWidth, textStrokeWidth }; -} - -function getLabelOffsetForEndpoint( - endpoint: string | undefined, - nodeProximateLabels: boolean, - interfaceScale: number, - interfaceLabelOverrides: Record -): number { - if (!nodeProximateLabels || endpoint === undefined || endpoint.length === 0) { - return EDGE_LABEL.offset; - } - const { radius } = getEndpointLabelMetrics(endpoint, interfaceScale, interfaceLabelOverrides); - return radius + 1; -} - -function getRegularEdgeLabelPositions( - ctx: EdgeRenderContext, - points: { sx: number; sy: number; tx: number; ty: number }, - controlPoint: { x: number; y: number } | null, - sourceAnchor?: InterfaceAnchor, - targetAnchor?: InterfaceAnchor -): { sourceLabelPos: { x: number; y: number }; targetLabelPos: { x: number; y: number } } { - if (ctx.nodeProximateLabels && sourceAnchor && targetAnchor) { - return { - sourceLabelPos: sourceAnchor, - targetLabelPos: targetAnchor - }; - } - - const sourceOffset = getLabelOffsetForEndpoint( - ctx.edgeData?.sourceEndpoint, - ctx.nodeProximateLabels, - ctx.interfaceScale, - ctx.interfaceLabelOverrides - ); - const targetOffset = getLabelOffsetForEndpoint( - ctx.edgeData?.targetEndpoint, - ctx.nodeProximateLabels, - ctx.interfaceScale, - ctx.interfaceLabelOverrides - ); - - return { - sourceLabelPos: getLabelPosition( - points.sx, - points.sy, - points.tx, - points.ty, - sourceOffset, - controlPoint ?? undefined - ), - targetLabelPos: getLabelPosition( - points.tx, - points.ty, - points.sx, - points.sy, - targetOffset, - controlPoint ?? undefined - ) - }; -} - -// ============================================================================ -// Loop Edge Builder -// ============================================================================ - -const LOOP_EDGE_SIZE = 50; -const LOOP_EDGE_OFFSET = 10; - -/** - * Calculate loop edge geometry for self-referencing edges - */ -function buildLoopEdgePath( - nodeX: number, - nodeY: number, - nodeWidth: number, - nodeHeight: number, - loopIndex: number -): { - path: string; - sourceLabelPos: { x: number; y: number }; - targetLabelPos: { x: number; y: number }; -} { - const centerX = nodeX + nodeWidth / 2; - const centerY = nodeY + nodeHeight / 2; - const size = LOOP_EDGE_SIZE + loopIndex * LOOP_EDGE_OFFSET; - - const startX = centerX + nodeWidth / 2; - const startY = centerY - nodeHeight / 4; - const endX = centerX + nodeWidth / 2; - const endY = centerY + nodeHeight / 4; - - const cp1X = startX + size; - const cp1Y = startY - size * 0.5; - const cp2X = endX + size; - const cp2Y = endY + size * 0.5; - - const path = `M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${endX} ${endY}`; - const labelX = centerX + nodeWidth / 2 + size * 0.8; - - return { - path, - sourceLabelPos: { x: labelX, y: centerY - 10 }, - targetLabelPos: { x: labelX, y: centerY + 10 } - }; -} - -// ============================================================================ -// Single Edge Builder -// ============================================================================ - -interface EdgeRenderContext { - edgeId: string; - strokeColor: string; - edgeData: TopologyEdgeData | undefined; - includeLabels: boolean; - nodeProximateLabels: boolean; - nodeIconSize: number; - interfaceScale: number; - interfaceLabelOverrides: Record; - interfaceAnchors?: NodeInterfaceAnchorMap; -} - -/** - * Build the edge labels SVG if enabled - */ -function buildEdgeLabels( - ctx: EdgeRenderContext, - sourceLabelPos: { x: number; y: number }, - targetLabelPos: { x: number; y: number } -): string { - if (!ctx.includeLabels) return ""; - const sourceEndpoint = normalizeEndpoint(ctx.edgeData?.sourceEndpoint); - const targetEndpoint = normalizeEndpoint(ctx.edgeData?.targetEndpoint); - let svg = ""; - if (sourceEndpoint !== null) { - svg += buildEndpointLabelSvg( - sourceEndpoint, - sourceLabelPos.x, - sourceLabelPos.y, - ctx.interfaceScale, - ctx.interfaceLabelOverrides - ); - } - if (targetEndpoint !== null) { - svg += buildEndpointLabelSvg( - targetEndpoint, - targetLabelPos.x, - targetLabelPos.y, - ctx.interfaceScale, - ctx.interfaceLabelOverrides - ); - } - return svg; -} - -/** - * Render a loop edge (self-referencing) to SVG - */ -function renderLoopEdge(ctx: EdgeRenderContext, sourceNode: Node, loopIndex: number): string { - const nodeX = sourceNode.position.x; - const nodeY = sourceNode.position.y; - - const { path, sourceLabelPos, targetLabelPos } = buildLoopEdgePath( - nodeX, - nodeY, - ctx.nodeIconSize, - ctx.nodeIconSize, - loopIndex - ); - - let svg = ``; - svg += ``; - svg += buildEdgeLabels(ctx, sourceLabelPos, targetLabelPos); - svg += ``; - return svg; -} - -function resolveRegularEdgeAnchors( - ctx: EdgeRenderContext, - sourceNode: Node, - targetNode: Node -): { sourceAnchor?: InterfaceAnchor; targetAnchor?: InterfaceAnchor } { - const sourceEndpoint = normalizeEndpoint(ctx.edgeData?.sourceEndpoint); - const targetEndpoint = normalizeEndpoint(ctx.edgeData?.targetEndpoint); - return { - sourceAnchor: - sourceEndpoint !== null - ? ctx.interfaceAnchors?.get(sourceNode.id)?.get(sourceEndpoint) - : undefined, - targetAnchor: - targetEndpoint !== null - ? ctx.interfaceAnchors?.get(targetNode.id)?.get(targetEndpoint) - : undefined - }; -} - -function buildRegularEdgePath( - points: { sx: number; sy: number; tx: number; ty: number }, - parallelInfo: { index: number; total: number; isCanonicalDirection: boolean } | undefined -): { path: string; controlPoint: { x: number; y: number } | null } { - const controlPoint = calculateControlPoint( - points.sx, - points.sy, - points.tx, - points.ty, - parallelInfo?.index ?? 0, - parallelInfo?.total ?? 1, - parallelInfo?.isCanonicalDirection ?? true, - CONTROL_POINT_STEP_SIZE - ); - const path = controlPoint - ? `M ${points.sx} ${points.sy} Q ${controlPoint.x} ${controlPoint.y} ${points.tx} ${points.ty}` - : `M ${points.sx} ${points.sy} L ${points.tx} ${points.ty}`; - return { path, controlPoint }; -} - -/** - * Render a regular edge (between two different nodes) to SVG - */ -function renderRegularEdge( - ctx: EdgeRenderContext, - sourceNode: Node, - targetNode: Node, - parallelInfo: { index: number; total: number; isCanonicalDirection: boolean } | undefined -): string { - const sourceRect = getNodeRect(sourceNode, ctx.nodeIconSize); - const targetRect = getNodeRect(targetNode, ctx.nodeIconSize); - const { sourceAnchor, targetAnchor } = resolveRegularEdgeAnchors(ctx, sourceNode, targetNode); - const points = resolveEdgePointsWithInterfaceAnchors( - sourceRect, - targetRect, - sourceAnchor, - targetAnchor - ); - const { path, controlPoint } = buildRegularEdgePath(points, parallelInfo); - - let svg = ``; - svg += ``; - const { sourceLabelPos, targetLabelPos } = getRegularEdgeLabelPositions( - ctx, - points, - controlPoint, - sourceAnchor, - targetAnchor - ); - svg += buildEdgeLabels(ctx, sourceLabelPos, targetLabelPos); - svg += ``; - return svg; -} - -/** - * Render a single edge to SVG - */ -export function edgeToSvg( - edge: Edge, - nodeMap: Map, - edgeInfo: EdgeInfo, - includeLabels: boolean, - nodeProximateLabels = false, - interfaceAnchors?: NodeInterfaceAnchorMap, - renderOptions?: ResolvedEdgeRenderOptions -): string { - const sourceNode = nodeMap.get(edge.source); - if (!sourceNode) return ""; - - const resolvedRenderOptions = renderOptions ?? resolveEdgeRenderOptions(); - const edgeData = edge.data as TopologyEdgeData | undefined; - const ctx: EdgeRenderContext = { - edgeId: edge.id, - strokeColor: getEdgeColor(edgeData?.linkStatus), - edgeData, - includeLabels, - nodeProximateLabels, - nodeIconSize: resolvedRenderOptions.nodeIconSize, - interfaceScale: resolvedRenderOptions.interfaceScale, - interfaceLabelOverrides: resolvedRenderOptions.interfaceLabelOverrides, - interfaceAnchors - }; - - // Handle loop edges (self-referencing) - if (edge.source === edge.target) { - const loopData = edgeInfo.loopInfo.get(edge.id); - return renderLoopEdge(ctx, sourceNode, loopData?.loopIndex ?? 0); - } - - // Handle regular edges - const targetNode = nodeMap.get(edge.target); - if (!targetNode) return ""; - - return renderRegularEdge(ctx, sourceNode, targetNode, edgeInfo.parallelInfo.get(edge.id)); -} - -// ============================================================================ -// Batch Renderer -// ============================================================================ - -/** - * Render all edges to SVG - * Filters out edges connected to annotation nodes - */ -export function renderEdgesToSvg( - edges: Edge[], - nodes: Node[], - includeLabels: boolean, - annotationNodeTypes?: Set, - nodeProximateLabels = false, - renderOptions?: EdgeSvgRenderOptions -): string { - const resolvedRenderOptions = resolveEdgeRenderOptions(renderOptions); - const skipTypes = - annotationNodeTypes ?? - new Set(["free-text-annotation", "free-shape-annotation", "group-annotation"]); - - // Build node map for position lookup - const nodeMap = new Map(); - for (const node of nodes) { - nodeMap.set(node.id, node); - } - - // Filter edges - exclude those connected to annotation nodes - const validEdges = edges.filter((edge) => { - const sourceNode = nodeMap.get(edge.source); - const targetNode = nodeMap.get(edge.target); - if (!sourceNode) return false; - if (skipTypes.has(sourceNode.type ?? "")) return false; - if (targetNode && skipTypes.has(targetNode.type ?? "")) return false; - return true; - }); - - // Build edge info for parallel/loop detection - const edgeInfo = buildEdgeInfoForExport(validEdges); - const interfaceAnchors = nodeProximateLabels - ? buildInterfaceAnchorMap(validEdges, nodeMap, resolvedRenderOptions) - : undefined; - - let svg = ""; - for (const edge of validEdges) { - svg += edgeToSvg( - edge, - nodeMap, - edgeInfo, - includeLabels, - nodeProximateLabels, - interfaceAnchors, - resolvedRenderOptions - ); - } - - return svg; -} diff --git a/src/reactTopoViewer/webview/components/panels/svg-export/grafanaExport.ts b/src/reactTopoViewer/webview/components/panels/svg-export/grafanaExport.ts deleted file mode 100644 index d409274d9..000000000 --- a/src/reactTopoViewer/webview/components/panels/svg-export/grafanaExport.ts +++ /dev/null @@ -1,1643 +0,0 @@ -// Grafana Flow-panel export helpers. -import type { Edge, Node } from "@xyflow/react"; - -const SVG_NS = "http://www.w3.org/2000/svg"; -const SVG_MIME_TYPE = "image/svg+xml"; -const CELL_ID_PREAMBLE = "cell-"; -type TrafficThresholdUnit = "kbit" | "mbit" | "gbit"; - -export interface GrafanaEdgeCellMapping { - edgeId: string; - source: string; - sourceEndpoint: string; - target: string; - targetEndpoint: string; - operstateCellId: string; - targetOperstateCellId: string; - trafficCellId: string; - reverseTrafficCellId: string; -} - -export interface GrafanaTrafficThresholds { - green: number; - yellow: number; - orange: number; - red: number; -} - -export interface GrafanaPanelYamlOptions { - trafficThresholds?: GrafanaTrafficThresholds; - includeHideRatesLegendToggle?: boolean; -} - -export interface GrafanaCellIdSvgOptions { - trafficRatesOnHoverOnly?: boolean; -} - -export const DEFAULT_GRAFANA_TRAFFIC_THRESHOLDS: GrafanaTrafficThresholds = { - green: 199999, - yellow: 500000, - orange: 1000000, - red: 5000000 -}; - -interface GrafanaDashboardTargetConfig { - datasource: string; - expr: string; - legendFormat: string; - instant: boolean; - range: boolean; - hide?: boolean; -} - -function getTrafficLabelCellId(trafficCellId: string): string { - return `${trafficCellId}:label`; -} - -const DEFAULT_GRAFANA_TARGETS: GrafanaDashboardTargetConfig[] = [ - { - datasource: "prometheus", - expr: "interface_oper_state", - legendFormat: "oper-state:{{source}}:{{interface_name}}", - instant: false, - range: true, - hide: false - }, - { - datasource: "prometheus", - expr: "interface_traffic_rate_out_bps", - legendFormat: "{{source}}:{{interface_name}}:out", - instant: false, - range: true, - hide: false - }, - { - datasource: "prometheus", - expr: "interface_traffic_rate_in_bps", - legendFormat: "{{source}}:{{interface_name}}:in", - instant: false, - range: true, - hide: false - } -]; - -function asString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function asRecord(value: unknown): Record { - if (typeof value !== "object" || value === null) return {}; - return Object.fromEntries(Object.entries(value)); -} - -function toCellMapping(edge: Edge): GrafanaEdgeCellMapping | null { - const data = asRecord(edge.data); - const sourceEndpoint = asString(data.sourceEndpoint); - const targetEndpoint = asString(data.targetEndpoint); - if (sourceEndpoint === null || targetEndpoint === null) return null; - - const operstateCellId = `${edge.source}:${sourceEndpoint}`; - const targetOperstateCellId = `${edge.target}:${targetEndpoint}`; - const trafficCellId = `link_id:${edge.source}:${sourceEndpoint}:${edge.target}:${targetEndpoint}`; - const reverseTrafficCellId = `link_id:${edge.target}:${targetEndpoint}:${edge.source}:${sourceEndpoint}`; - - return { - edgeId: edge.id, - source: edge.source, - sourceEndpoint, - target: edge.target, - targetEndpoint, - operstateCellId, - targetOperstateCellId, - trafficCellId, - reverseTrafficCellId - }; -} - -function isAnnotationNode( - nodeId: string, - nodeTypesById: Map, - annotationNodeTypes: Set -): boolean { - const nodeType = nodeTypesById.get(nodeId) ?? ""; - return annotationNodeTypes.has(nodeType); -} - -export function collectGrafanaEdgeCellMappings( - edges: Edge[], - nodes: Node[], - annotationNodeTypes: Set -): GrafanaEdgeCellMapping[] { - const nodeTypesById = new Map(nodes.map((node) => [node.id, node.type ?? ""])); - const seenTraffic = new Set(); - const seenOperstate = new Set(); - const mappings: GrafanaEdgeCellMapping[] = []; - - for (const edge of edges) { - if (isAnnotationNode(edge.source, nodeTypesById, annotationNodeTypes)) continue; - if (isAnnotationNode(edge.target, nodeTypesById, annotationNodeTypes)) continue; - - const mapping = toCellMapping(edge); - if (!mapping) continue; - if ( - seenTraffic.has(mapping.trafficCellId) || - seenTraffic.has(mapping.reverseTrafficCellId) || - seenOperstate.has(mapping.operstateCellId) || - seenOperstate.has(mapping.targetOperstateCellId) - ) { - continue; - } - - seenTraffic.add(mapping.trafficCellId); - seenTraffic.add(mapping.reverseTrafficCellId); - seenOperstate.add(mapping.operstateCellId); - seenOperstate.add(mapping.targetOperstateCellId); - mappings.push(mapping); - } - - return mappings; -} - -export function collectLinkedNodeIds( - edges: Edge[], - nodes: Node[], - annotationNodeTypes: Set -): Set { - const nodeTypesById = new Map(nodes.map((node) => [node.id, node.type ?? ""])); - const linkedNodeIds = new Set(); - - for (const edge of edges) { - if (!nodeTypesById.has(edge.source) || !nodeTypesById.has(edge.target)) continue; - if (isAnnotationNode(edge.source, nodeTypesById, annotationNodeTypes)) continue; - if (isAnnotationNode(edge.target, nodeTypesById, annotationNodeTypes)) continue; - - linkedNodeIds.add(edge.source); - linkedNodeIds.add(edge.target); - } - - return linkedNodeIds; -} - -interface GraphTransform { - tx: number; - ty: number; - scale: number; -} - -interface Bounds { - minX: number; - minY: number; - maxX: number; - maxY: number; -} - -function createBounds(): Bounds { - return { - minX: Number.POSITIVE_INFINITY, - minY: Number.POSITIVE_INFINITY, - maxX: Number.NEGATIVE_INFINITY, - maxY: Number.NEGATIVE_INFINITY - }; -} - -function includeBoundsPoint(bounds: Bounds, x: number, y: number): void { - bounds.minX = Math.min(bounds.minX, x); - bounds.minY = Math.min(bounds.minY, y); - bounds.maxX = Math.max(bounds.maxX, x); - bounds.maxY = Math.max(bounds.maxY, y); -} - -function includeBoundsRect( - bounds: Bounds, - x: number, - y: number, - width: number, - height: number -): void { - includeBoundsPoint(bounds, x, y); - includeBoundsPoint(bounds, x + width, y + height); -} - -function hasBounds(bounds: Bounds): boolean { - return ( - Number.isFinite(bounds.minX) && - Number.isFinite(bounds.minY) && - bounds.maxX > bounds.minX && - bounds.maxY > bounds.minY - ); -} - -function applyGraphTransform( - transform: GraphTransform, - x: number, - y: number -): { x: number; y: number } { - return { - x: x * transform.scale + transform.tx, - y: y * transform.scale + transform.ty - }; -} - -function parseNumericAttr(el: Element, attrName: string): number | null { - const raw = el.getAttribute(attrName); - if (raw === null || raw.length === 0) return null; - const parsed = Number.parseFloat(raw); - return Number.isFinite(parsed) ? parsed : null; -} - -function parseTransformFunctionArgs(transformAttr: string, functionName: string): number[] { - const normalizedAttr = transformAttr.toLowerCase(); - const normalizedFunctionName = functionName.toLowerCase(); - const startIdx = normalizedAttr.indexOf(`${normalizedFunctionName}(`); - if (startIdx < 0) return []; - - const argsStart = startIdx + normalizedFunctionName.length + 1; - const argsEnd = transformAttr.indexOf(")", argsStart); - if (argsEnd < 0) return []; - - return transformAttr - .slice(argsStart, argsEnd) - .split(/[,\s]+/) - .map((part) => Number.parseFloat(part)) - .filter((value) => Number.isFinite(value)); -} - -function findGraphTransformGroup(svgEl: Element): Element | null { - return ( - Array.from(svgEl.children).find( - (child) => child.tagName.toLowerCase() === "g" && child.hasAttribute("transform") - ) ?? null - ); -} - -function parseGraphTransform(svgEl: Element): GraphTransform { - const transformedRoot = findGraphTransformGroup(svgEl); - const transformAttr = transformedRoot?.getAttribute("transform") ?? ""; - - const translateArgs = parseTransformFunctionArgs(transformAttr, "translate"); - const scaleArgs = parseTransformFunctionArgs(transformAttr, "scale"); - - const tx = translateArgs[0] ?? 0; - const ty = translateArgs[1] ?? 0; - const scale = scaleArgs[0] ?? 1; - - return { - tx: Number.isFinite(tx) ? tx : 0, - ty: Number.isFinite(ty) ? ty : 0, - scale: Number.isFinite(scale) && scale !== 0 ? scale : 1 - }; -} - -function formatTransformNumber(value: number): string { - return Number(value.toFixed(6)).toString(); -} - -function includePathBounds(bounds: Bounds, transform: GraphTransform, pathData: string): void { - const numberMatches = pathData.match(/[-+]?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? []; - if (numberMatches.length < 2) return; - const points = numberMatches - .map((value) => Number.parseFloat(value)) - .filter((value) => Number.isFinite(value)); - - for (let i = 0; i + 1 < points.length; i += 2) { - const p = applyGraphTransform(transform, points[i], points[i + 1]); - includeBoundsPoint(bounds, p.x, p.y); - } -} - -function includeNodeRectBounds(doc: XMLDocument, bounds: Bounds, transform: GraphTransform): void { - for (const rect of Array.from(doc.querySelectorAll("g.export-node rect[x][y][width][height]"))) { - const x = parseNumericAttr(rect, "x"); - const y = parseNumericAttr(rect, "y"); - const width = parseNumericAttr(rect, "width"); - const height = parseNumericAttr(rect, "height"); - if (x === null || y === null || width === null || height === null) continue; - - const p = applyGraphTransform(transform, x, y); - includeBoundsRect(bounds, p.x, p.y, width * transform.scale, height * transform.scale); - } -} - -function includeEdgeCircleBounds( - doc: XMLDocument, - bounds: Bounds, - transform: GraphTransform -): void { - for (const circle of Array.from(doc.querySelectorAll("g.export-edge circle[cx][cy][r]"))) { - const cx = parseNumericAttr(circle, "cx"); - const cy = parseNumericAttr(circle, "cy"); - const r = parseNumericAttr(circle, "r"); - if (cx === null || cy === null || r === null) continue; - - const p = applyGraphTransform(transform, cx, cy); - const radius = Math.abs(r * transform.scale); - includeBoundsRect(bounds, p.x - radius, p.y - radius, radius * 2, radius * 2); - } -} - -function includeEdgePathBounds(doc: XMLDocument, bounds: Bounds, transform: GraphTransform): void { - for (const edgePath of Array.from(doc.querySelectorAll("g.export-edge path[d]"))) { - const pathData = edgePath.getAttribute("d"); - if (pathData === null || pathData.length === 0) continue; - includePathBounds(bounds, transform, pathData); - } -} - -export function trimGrafanaSvgToTopologyContent(svgContent: string, padding = 12): string { - if (typeof DOMParser === "undefined" || typeof XMLSerializer === "undefined") { - return svgContent; - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(svgContent, SVG_MIME_TYPE); - const svgEl = doc.documentElement; - const transformedRoot = findGraphTransformGroup(svgEl); - const transform = parseGraphTransform(svgEl); - const bounds = createBounds(); - - includeNodeRectBounds(doc, bounds, transform); - includeEdgeCircleBounds(doc, bounds, transform); - includeEdgePathBounds(doc, bounds, transform); - - if (!hasBounds(bounds)) return svgContent; - - const safePadding = Math.max(0, padding); - const minX = bounds.minX - safePadding; - const minY = bounds.minY - safePadding; - const width = Math.max(1, bounds.maxX - bounds.minX + safePadding * 2); - const height = Math.max(1, bounds.maxY - bounds.minY + safePadding * 2); - - if (transformedRoot !== null) { - const normalizedTx = transform.tx - minX; - const normalizedTy = transform.ty - minY; - transformedRoot.setAttribute( - "transform", - `translate(${formatTransformNumber(normalizedTx)}, ${formatTransformNumber(normalizedTy)}) scale(${formatTransformNumber(transform.scale)})` - ); - svgEl.setAttribute("viewBox", `0 0 ${width} ${height}`); - } else { - svgEl.setAttribute("viewBox", `${minX} ${minY} ${width} ${height}`); - } - svgEl.setAttribute("width", Number(width.toFixed(3)).toString()); - svgEl.setAttribute("height", Number(height.toFixed(3)).toString()); - - return new XMLSerializer().serializeToString(svgEl); -} - -function getTrafficThresholdUnitLabel(unit: TrafficThresholdUnit): string { - switch (unit) { - case "kbit": - return "Kbps"; - case "gbit": - return "Gbps"; - default: - return "Mbps"; - } -} - -function getTrafficThresholdUnitDivisor(unit: TrafficThresholdUnit): number { - switch (unit) { - case "kbit": - return 1_000; - case "gbit": - return 1_000_000_000; - default: - return 1_000_000; - } -} - -function formatTrafficUnit(valueBps: number, unit: TrafficThresholdUnit): string { - const divisor = getTrafficThresholdUnitDivisor(unit); - const scaled = Math.max(0, valueBps) / divisor; - if (scaled === 0) return "0"; - - const precision = scaled < 1 ? 2 : 1; - return scaled - .toFixed(precision) - .replace(/\.0+$/, "") - .replace(/(\.\d*[1-9])0+$/, "$1"); -} - -function createLegendTextRows( - thresholds: GrafanaTrafficThresholds, - trafficThresholdUnit: TrafficThresholdUnit -): Array<{ color: string; text: string }> { - const green = formatTrafficUnit(thresholds.green, trafficThresholdUnit); - const yellow = formatTrafficUnit(thresholds.yellow, trafficThresholdUnit); - const orange = formatTrafficUnit(thresholds.orange, trafficThresholdUnit); - const red = formatTrafficUnit(thresholds.red, trafficThresholdUnit); - const unitLabel = getTrafficThresholdUnitLabel(trafficThresholdUnit); - - return [ - { color: "#b8c4d3", text: `0 - ${green} ${unitLabel}` }, - { color: "#5fe15c", text: `${green} - ${yellow} ${unitLabel}` }, - { color: "#ffe24a", text: `${yellow} - ${orange} ${unitLabel}` }, - { color: "#ff9f1a", text: `${orange} - ${red} ${unitLabel}` }, - { color: "#ff4f6b", text: `${red}+ ${unitLabel}` } - ]; -} - -function parseViewBox(svgEl: Element): { - x: number; - y: number; - width: number; - height: number; -} { - const viewBoxAttr = svgEl.getAttribute("viewBox"); - if (viewBoxAttr !== null && viewBoxAttr.length > 0) { - const parts = viewBoxAttr - .split(/[ ,]+/) - .map((part) => Number.parseFloat(part)) - .filter((part) => Number.isFinite(part)); - if (parts.length === 4) { - return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }; - } - } - - const width = Number.parseFloat(svgEl.getAttribute("width") ?? "1"); - const height = Number.parseFloat(svgEl.getAttribute("height") ?? "1"); - return { - x: 0, - y: 0, - width: Number.isFinite(width) && width > 0 ? width : 1, - height: Number.isFinite(height) && height > 0 ? height : 1 - }; -} - -export function addGrafanaTrafficLegend( - svgContent: string, - trafficThresholds: GrafanaTrafficThresholds, - trafficThresholdUnit: TrafficThresholdUnit = "mbit" -): string { - if (typeof DOMParser === "undefined" || typeof XMLSerializer === "undefined") { - return svgContent; - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(svgContent, SVG_MIME_TYPE); - const svgEl = doc.documentElement; - const legendRows = createLegendTextRows(trafficThresholds, trafficThresholdUnit); - const viewBox = parseViewBox(svgEl); - const transform = parseGraphTransform(svgEl); - const legendScale = Math.max(0.1, Math.abs(transform.scale)); - - const legendGroup = doc.createElementNS(SVG_NS, "g"); - legendGroup.setAttribute("class", "grafana-traffic-legend"); - legendGroup.setAttribute("opacity", "0.95"); - - const startX = viewBox.x + 12 * legendScale; - let topNodeY = Number.POSITIVE_INFINITY; - for (const rect of Array.from( - doc.querySelectorAll("g.export-node > g > rect[x][y][width][height]") - )) { - const x = parseNumericAttr(rect, "x"); - const y = parseNumericAttr(rect, "y"); - if (x === null || y === null) continue; - const transformed = applyGraphTransform(transform, x, y); - topNodeY = Math.min(topNodeY, transformed.y); - } - const startY = Number.isFinite(topNodeY) - ? topNodeY + 4 * legendScale - : viewBox.y + 18 * legendScale; - const rowHeight = 16 * legendScale; - - for (let i = 0; i < legendRows.length; i++) { - const row = legendRows[i]; - const rowGroup = doc.createElementNS(SVG_NS, "g"); - rowGroup.setAttribute("transform", `translate(${startX} ${startY + i * rowHeight})`); - - const bullet = doc.createElementNS(SVG_NS, "circle"); - bullet.setAttribute("cx", "0"); - bullet.setAttribute("cy", "0"); - bullet.setAttribute("r", `${4 * legendScale}`); - bullet.setAttribute("fill", row.color); - rowGroup.appendChild(bullet); - - const text = doc.createElementNS(SVG_NS, "text"); - text.setAttribute("x", `${10 * legendScale}`); - text.setAttribute("y", "0"); - text.setAttribute("dy", "0.35em"); - text.setAttribute("font-size", `${11 * legendScale}`); - text.setAttribute("font-family", "Helvetica, Arial, sans-serif"); - text.setAttribute("font-weight", "500"); - text.setAttribute("fill", "#e7edf7"); - text.setAttribute("stroke", "rgba(0, 0, 0, 0.65)"); - text.setAttribute("stroke-width", `${0.45 * legendScale}`); - text.setAttribute("paint-order", "stroke"); - text.textContent = row.text; - rowGroup.appendChild(text); - - legendGroup.appendChild(rowGroup); - } - - svgEl.appendChild(legendGroup); - return new XMLSerializer().serializeToString(svgEl); -} - -export function makeGrafanaSvgResponsive(svgContent: string): string { - if (typeof DOMParser === "undefined" || typeof XMLSerializer === "undefined") { - return svgContent; - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(svgContent, SVG_MIME_TYPE); - const svgEl = doc.documentElement; - - svgEl.setAttribute("width", "100%"); - svgEl.setAttribute("height", "100%"); - svgEl.setAttribute("preserveAspectRatio", "xMidYMid meet"); - - return new XMLSerializer().serializeToString(svgEl); -} - -function setCellIdAttributes(element: Element, shortCellId: string): void { - element.setAttribute("id", `${CELL_ID_PREAMBLE}${shortCellId}`); - element.setAttribute("data-cell-id", shortCellId); -} - -function parsePathStart(pathData: string | null): { x: number; y: number } | null { - if (pathData === null || pathData.length === 0) return null; - const trimmed = pathData.trim(); - if (trimmed.length === 0) return null; - const command = trimmed[0]; - if (command !== "M" && command !== "m") return null; - - const remainder = trimmed.slice(1).replaceAll(",", " ").trim(); - const parts = remainder.split(" ").filter((part) => part.length > 0); - if (parts.length < 2) return null; - - const x = Number.parseFloat(parts[0]); - const y = Number.parseFloat(parts[1]); - if (!Number.isFinite(x) || !Number.isFinite(y)) return null; - return { x, y }; -} - -function createFallbackOperstateMarker(doc: XMLDocument, sourceGroup: Element): SVGElement { - const firstPath = sourceGroup.querySelector("path"); - const startPoint = parsePathStart(firstPath?.getAttribute("d") ?? null); - const marker = doc.createElementNS(SVG_NS, "rect"); - const markerX = startPoint ? startPoint.x - 3 : 0; - const markerY = startPoint ? startPoint.y - 3 : 0; - marker.setAttribute("x", markerX.toString()); - marker.setAttribute("y", markerY.toString()); - marker.setAttribute("width", "6"); - marker.setAttribute("height", "6"); - marker.setAttribute("rx", "1"); - marker.setAttribute("ry", "1"); - marker.setAttribute("fill", "transparent"); - marker.setAttribute("stroke", "none"); - return marker; -} - -function createOperstateCellGroup( - doc: XMLDocument, - sourceGroup: Element, - shortCellId: string -): Element { - const operstateGroup = doc.createElementNS(SVG_NS, "g"); - operstateGroup.setAttribute("class", "export-edge grafana-operstate-cell"); - setCellIdAttributes(operstateGroup, shortCellId); - operstateGroup.appendChild(createFallbackOperstateMarker(doc, sourceGroup)); - - return operstateGroup; -} - -function resolveTrafficCellElement(edgeGroup: Element): Element { - const directPath = Array.from(edgeGroup.children).find( - (child) => child.tagName.toLowerCase() === "path" - ); - if (directPath) return directPath; - - const nestedPath = edgeGroup.querySelector("path"); - if (nestedPath) return nestedPath; - - return edgeGroup; -} - -interface Point { - x: number; - y: number; -} - -interface TrafficLabelPlacement { - point: Point; -} - -function lerp(a: Point, b: Point, t = 0.5): Point { - return { - x: a.x + (b.x - a.x) * t, - y: a.y + (b.y - a.y) * t - }; -} - -function fmt(n: number): string { - return Number(n.toFixed(3)).toString(); -} - -type ParsedPathCommand = - | { sx: number; sy: number; command: "L"; args: [number, number] } - | { - sx: number; - sy: number; - command: "Q"; - args: [number, number, number, number]; - } - | { - sx: number; - sy: number; - command: "C"; - args: [number, number, number, number, number, number]; - }; - -function parseNumericPathArgs( - tokens: string[], - startIndex: number, - count: number -): number[] | null { - const args: number[] = []; - for (let i = 0; i < count; i++) { - const value = Number.parseFloat(tokens[startIndex + i] ?? ""); - if (!Number.isFinite(value)) return null; - args.push(value); - } - return args; -} - -function parsePathCommand(pathData: string): ParsedPathCommand | null { - const tokens = pathData.match(/[A-Za-z]|[-+]?\d*\.?\d+(?:e[-+]?\d+)?/g); - if (!tokens || tokens.length < 6) return null; - if (tokens[0]?.toUpperCase() !== "M") return null; - - const sx = Number.parseFloat(tokens[1]); - const sy = Number.parseFloat(tokens[2]); - if (!Number.isFinite(sx) || !Number.isFinite(sy)) return null; - - switch (tokens[3]?.toUpperCase()) { - case "L": { - const args = parseNumericPathArgs(tokens, 4, 2); - if (!args) return null; - return { sx, sy, command: "L", args: [args[0], args[1]] }; - } - case "Q": { - const args = parseNumericPathArgs(tokens, 4, 4); - if (!args) return null; - return { sx, sy, command: "Q", args: [args[0], args[1], args[2], args[3]] }; - } - case "C": { - const args = parseNumericPathArgs(tokens, 4, 6); - if (!args) return null; - return { - sx, - sy, - command: "C", - args: [args[0], args[1], args[2], args[3], args[4], args[5]] - }; - } - default: - return null; - } -} - -function clamp01(value: number): number { - return Math.min(1, Math.max(0, value)); -} - -function pointForLinePathAtT(sx: number, sy: number, args: [number, number], t: number): Point { - const [tx, ty] = args; - const clampedT = clamp01(t); - return { - x: sx + (tx - sx) * clampedT, - y: sy + (ty - sy) * clampedT - }; -} - -function pointForQuadraticPathAtT( - sx: number, - sy: number, - args: [number, number, number, number], - t: number -): Point { - const [cx, cy, tx, ty] = args; - const clampedT = clamp01(t); - const oneMinusT = 1 - clampedT; - return { - x: oneMinusT * oneMinusT * sx + 2 * oneMinusT * clampedT * cx + clampedT * clampedT * tx, - y: oneMinusT * oneMinusT * sy + 2 * oneMinusT * clampedT * cy + clampedT * clampedT * ty - }; -} - -function pointForCubicPathAtT( - sx: number, - sy: number, - args: [number, number, number, number, number, number], - t: number -): Point { - const [c1x, c1y, c2x, c2y, tx, ty] = args; - const clampedT = clamp01(t); - const oneMinusT = 1 - clampedT; - return { - x: - oneMinusT * oneMinusT * oneMinusT * sx + - 3 * oneMinusT * oneMinusT * clampedT * c1x + - 3 * oneMinusT * clampedT * clampedT * c2x + - clampedT * clampedT * clampedT * tx, - y: - oneMinusT * oneMinusT * oneMinusT * sy + - 3 * oneMinusT * oneMinusT * clampedT * c1y + - 3 * oneMinusT * clampedT * clampedT * c2y + - clampedT * clampedT * clampedT * ty - }; -} - -function pointOnParsedPathAtT(parsed: ParsedPathCommand, t: number): Point { - switch (parsed.command) { - case "L": - return pointForLinePathAtT(parsed.sx, parsed.sy, parsed.args, t); - case "Q": - return pointForQuadraticPathAtT(parsed.sx, parsed.sy, parsed.args, t); - case "C": - return pointForCubicPathAtT(parsed.sx, parsed.sy, parsed.args, t); - } -} - -function normalizeVector(x: number, y: number): Point { - const length = Math.hypot(x, y); - if (!Number.isFinite(length) || length < 0.0001) { - return { x: 1, y: 0 }; - } - return { x: x / length, y: y / length }; -} - -function tangentForLinePath(args: [number, number], sx: number, sy: number): Point { - const [tx, ty] = args; - return normalizeVector(tx - sx, ty - sy); -} - -function tangentForQuadraticPath( - args: [number, number, number, number], - sx: number, - sy: number, - t: number -): Point { - const [cx, cy, tx, ty] = args; - const clampedT = clamp01(t); - const dx = 2 * (1 - clampedT) * (cx - sx) + 2 * clampedT * (tx - cx); - const dy = 2 * (1 - clampedT) * (cy - sy) + 2 * clampedT * (ty - cy); - return normalizeVector(dx, dy); -} - -function tangentForCubicPath( - args: [number, number, number, number, number, number], - sx: number, - sy: number, - t: number -): Point { - const [c1x, c1y, c2x, c2y, tx, ty] = args; - const clampedT = clamp01(t); - const oneMinusT = 1 - clampedT; - const dx = - 3 * oneMinusT * oneMinusT * (c1x - sx) + - 6 * oneMinusT * clampedT * (c2x - c1x) + - 3 * clampedT * clampedT * (tx - c2x); - const dy = - 3 * oneMinusT * oneMinusT * (c1y - sy) + - 6 * oneMinusT * clampedT * (c2y - c1y) + - 3 * clampedT * clampedT * (ty - c2y); - return normalizeVector(dx, dy); -} - -function tangentOnParsedPathAtT(parsed: ParsedPathCommand, t: number): Point { - switch (parsed.command) { - case "L": - return tangentForLinePath(parsed.args, parsed.sx, parsed.sy); - case "Q": - return tangentForQuadraticPath(parsed.args, parsed.sx, parsed.sy, t); - case "C": - return tangentForCubicPath(parsed.args, parsed.sx, parsed.sy, t); - } -} - -function splitLinePath( - sx: number, - sy: number, - args: [number, number] -): { first: string; second: string } { - const [tx, ty] = args; - const p0 = { x: sx, y: sy }; - const p1 = { x: tx, y: ty }; - const m = lerp(p0, p1); - return { - first: `M ${fmt(p0.x)} ${fmt(p0.y)} L ${fmt(m.x)} ${fmt(m.y)}`, - second: `M ${fmt(m.x)} ${fmt(m.y)} L ${fmt(p1.x)} ${fmt(p1.y)}` - }; -} - -function splitQuadraticPath( - sx: number, - sy: number, - args: [number, number, number, number] -): { first: string; second: string } { - const [cx, cy, tx, ty] = args; - const p0 = { x: sx, y: sy }; - const p1 = { x: cx, y: cy }; - const p2 = { x: tx, y: ty }; - const p01 = lerp(p0, p1); - const p12 = lerp(p1, p2); - const mid = lerp(p01, p12); - return { - first: `M ${fmt(p0.x)} ${fmt(p0.y)} Q ${fmt(p01.x)} ${fmt(p01.y)} ${fmt(mid.x)} ${fmt(mid.y)}`, - second: `M ${fmt(mid.x)} ${fmt(mid.y)} Q ${fmt(p12.x)} ${fmt(p12.y)} ${fmt(p2.x)} ${fmt(p2.y)}` - }; -} - -function splitCubicPath( - sx: number, - sy: number, - args: [number, number, number, number, number, number] -): { first: string; second: string } { - const [c1x, c1y, c2x, c2y, tx, ty] = args; - const p0 = { x: sx, y: sy }; - const p1 = { x: c1x, y: c1y }; - const p2 = { x: c2x, y: c2y }; - const p3 = { x: tx, y: ty }; - const p01 = lerp(p0, p1); - const p12 = lerp(p1, p2); - const p23 = lerp(p2, p3); - const p012 = lerp(p01, p12); - const p123 = lerp(p12, p23); - const mid = lerp(p012, p123); - return { - first: - `M ${fmt(p0.x)} ${fmt(p0.y)} C ${fmt(p01.x)} ${fmt(p01.y)} ${fmt(p012.x)} ${fmt(p012.y)} ` + - `${fmt(mid.x)} ${fmt(mid.y)}`, - second: - `M ${fmt(mid.x)} ${fmt(mid.y)} C ${fmt(p123.x)} ${fmt(p123.y)} ${fmt(p23.x)} ${fmt(p23.y)} ` + - `${fmt(p3.x)} ${fmt(p3.y)}` - }; -} - -function splitPathIntoHalves(pathData: string): { first: string; second: string } | null { - const parsed = parsePathCommand(pathData); - if (!parsed) return null; - switch (parsed.command) { - case "L": - return splitLinePath(parsed.sx, parsed.sy, parsed.args); - case "Q": - return splitQuadraticPath(parsed.sx, parsed.sy, parsed.args); - case "C": - return splitCubicPath(parsed.sx, parsed.sy, parsed.args); - } -} - -function estimateParsedPathLength(parsed: ParsedPathCommand): number { - switch (parsed.command) { - case "L": { - const [tx, ty] = parsed.args; - return Math.hypot(tx - parsed.sx, ty - parsed.sy); - } - case "Q": { - const [cx, cy, tx, ty] = parsed.args; - return Math.hypot(cx - parsed.sx, cy - parsed.sy) + Math.hypot(tx - cx, ty - cy); - } - case "C": { - const [c1x, c1y, c2x, c2y, tx, ty] = parsed.args; - return ( - Math.hypot(c1x - parsed.sx, c1y - parsed.sy) + - Math.hypot(c2x - c1x, c2y - c1y) + - Math.hypot(tx - c2x, ty - c2y) - ); - } - } -} - -function buildTrafficLabelCandidateTs( - interfaceSide: "start" | "end", - pathLength: number, - graphScale: number -): number[] { - const safeScale = Math.max(0.05, Math.abs(graphScale)); - const alongStepPx = 20 / safeScale; - const stepT = Math.min(0.22, Math.max(0.06, alongStepPx / Math.max(1, pathLength))); - // Prefer labels away from the shared midpoint area in dense fanouts. - const baseT = interfaceSide === "start" ? 0.38 : 0.62; - const offsets = [ - 0, - stepT, - -stepT, - stepT * 2, - -stepT * 2, - stepT * 3, - -stepT * 3, - stepT * 4, - -stepT * 4 - ]; - - const candidates: number[] = []; - const seen = new Set(); - for (const offset of offsets) { - const candidateT = clamp01(baseT + offset); - if (candidateT < 0.08 || candidateT > 0.92) continue; - const key = candidateT.toFixed(3); - if (seen.has(key)) continue; - seen.add(key); - candidates.push(candidateT); - } - return candidates; -} - -function buildExpandedTrafficLabelCandidateTs(interfaceSide: "start" | "end"): number[] { - const minT = 0.08; - const maxT = 0.92; - const sampleCount = 81; - const preferredT = interfaceSide === "start" ? 0.38 : 0.62; - const samples = Array.from({ length: sampleCount }, (_, index) => { - const ratio = index / (sampleCount - 1); - return minT + (maxT - minT) * ratio; - }); - samples.sort((a, b) => Math.abs(a - preferredT) - Math.abs(b - preferredT)); - return samples; -} - -function getTrafficLabelCollisionThresholds(graphScale: number): { minDx: number; minDy: number } { - const safeScale = Math.max(0.05, Math.abs(graphScale)); - const sampleLabel = "775.3 Mb/s"; - const fontSizePx = 10; - const charWidthPx = fontSizePx * 0.58; - // Keep a tight collision box: approximate glyph bounds plus ~1px gap. - const labelWidthPx = sampleLabel.length * charWidthPx + 2; - const labelHeightPx = fontSizePx + 2; - return { - minDx: labelWidthPx / safeScale, - minDy: labelHeightPx / safeScale - }; -} - -function buildTrafficLabelOffsetPairs(maxStep: number): Array<{ along: number; normal: number }> { - const pairs: Array<{ along: number; normal: number }> = []; - const seen = new Set(); - - const pushPair = (along: number, normal: number) => { - const key = `${along}:${normal}`; - if (seen.has(key)) return; - seen.add(key); - pairs.push({ along, normal }); - }; - - pushPair(0, 0); - for (let step = 1; step <= maxStep; step++) { - pushPair(0, step); - pushPair(0, -step); - pushPair(step, 0); - pushPair(-step, 0); - for (let along = 1; along <= step; along++) { - pushPair(along, step); - pushPair(-along, step); - pushPair(along, -step); - pushPair(-along, -step); - if (along === step) continue; - pushPair(step, along); - pushPair(step, -along); - pushPair(-step, along); - pushPair(-step, -along); - } - } - return pairs; -} - -function isLabelCollision( - point: Point, - occupiedPoints: Point[], - minDx: number, - minDy: number -): boolean { - return occupiedPoints.some((other) => { - const dx = point.x - other.x; - const dy = point.y - other.y; - return Math.abs(dx) < minDx && Math.abs(dy) < minDy; - }); -} - -function isTrafficLabelCollision( - point: Point, - placements: TrafficLabelPlacement[], - minDx: number, - minDy: number -): boolean { - return placements.some((placement) => { - const dx = point.x - placement.point.x; - const dy = point.y - placement.point.y; - return Math.abs(dx) < minDx && Math.abs(dy) < minDy; - }); -} - -function nearestPlacementDistance(point: Point, placements: TrafficLabelPlacement[]): number { - if (placements.length === 0) return Number.POSITIVE_INFINITY; - let minDistance = Number.POSITIVE_INFINITY; - for (const placement of placements) { - const distance = Math.hypot(point.x - placement.point.x, point.y - placement.point.y); - if (distance < minDistance) { - minDistance = distance; - } - } - return minDistance; -} - -function nearestPointDistance(point: Point, points: Point[]): number { - if (points.length === 0) return Number.POSITIVE_INFINITY; - let minDistance = Number.POSITIVE_INFINITY; - for (const other of points) { - const distance = Math.hypot(point.x - other.x, point.y - other.y); - if (distance < minDistance) { - minDistance = distance; - } - } - return minDistance; -} - -function resolveTrafficLabelPoint( - halfPathData: string, - occupiedPlacements: TrafficLabelPlacement[], - interfaceLabelPoints: Point[], - interfaceSide: "start" | "end", - graphScale: number -): Point { - const safeScale = Math.max(0.05, Math.abs(graphScale)); - const trafficThresholds = getTrafficLabelCollisionThresholds(graphScale); - const minimumInterfaceDx = 38 / safeScale; - const minimumInterfaceDy = 14 / safeScale; - const alongStep = 1 / safeScale; - const normalStep = 1 / safeScale; - const offsetPairs = buildTrafficLabelOffsetPairs(10); - const parsed = parsePathCommand(halfPathData); - const preferredT = interfaceSide === "start" ? 0.38 : 0.62; - const base = parsed - ? pointOnParsedPathAtT(parsed, preferredT) - : (parsePathStart(halfPathData) ?? { x: 0, y: 0 }); - - const canPlaceAt = (candidate: Point): boolean => { - const intersectsInterface = isLabelCollision( - candidate, - interfaceLabelPoints, - minimumInterfaceDx, - minimumInterfaceDy - ); - if (intersectsInterface) return false; - return !isTrafficLabelCollision( - candidate, - occupiedPlacements, - trafficThresholds.minDx, - trafficThresholds.minDy - ); - }; - - let fallback = base; - let bestScore = Number.NEGATIVE_INFINITY; - const scoreCandidate = (candidate: Point) => { - const distanceToTrafficLabels = nearestPlacementDistance(candidate, occupiedPlacements); - const distanceToInterfaceLabels = nearestPointDistance(candidate, interfaceLabelPoints); - const score = Math.min(distanceToTrafficLabels, distanceToInterfaceLabels); - if (score > bestScore) { - bestScore = score; - fallback = candidate; - } - }; - - if (!parsed) { - if (!canPlaceAt(base)) { - occupiedPlacements.push({ point: base }); - return base; - } - occupiedPlacements.push({ point: base }); - return base; - } - - const candidateTs = buildTrafficLabelCandidateTs( - interfaceSide, - estimateParsedPathLength(parsed), - graphScale - ); - const allCandidateTs = [...candidateTs, ...buildExpandedTrafficLabelCandidateTs(interfaceSide)]; - const seenCandidateTs = new Set(); - const candidatePoints: Array<{ point: Point; distanceFromPreferred: number }> = []; - const seenCandidatePoints = new Set(); - - for (const candidateT of allCandidateTs) { - const candidateKey = candidateT.toFixed(3); - if (seenCandidateTs.has(candidateKey)) continue; - seenCandidateTs.add(candidateKey); - const anchor = pointOnParsedPathAtT(parsed, candidateT); - const tangent = tangentOnParsedPathAtT(parsed, candidateT); - const normal = { x: -tangent.y, y: tangent.x }; - - for (const offset of offsetPairs) { - const candidate = { - x: anchor.x + tangent.x * offset.along * alongStep + normal.x * offset.normal * normalStep, - y: anchor.y + tangent.y * offset.along * alongStep + normal.y * offset.normal * normalStep - }; - scoreCandidate(candidate); - const pointKey = `${candidate.x.toFixed(2)}:${candidate.y.toFixed(2)}`; - if (seenCandidatePoints.has(pointKey)) continue; - seenCandidatePoints.add(pointKey); - candidatePoints.push({ - point: candidate, - distanceFromPreferred: Math.hypot(candidate.x - base.x, candidate.y - base.y) - }); - } - } - - candidatePoints.sort((a, b) => a.distanceFromPreferred - b.distanceFromPreferred); - for (const candidate of candidatePoints) { - if (!canPlaceAt(candidate.point)) continue; - occupiedPlacements.push({ point: candidate.point }); - return candidate.point; - } - - occupiedPlacements.push({ point: fallback }); - return fallback; -} - -function createTrafficHalfCell( - doc: XMLDocument, - sourcePath: Element, - halfPathData: string, - shortCellId: string, - occupiedLabelPoints: TrafficLabelPlacement[], - interfaceLabelPoints: Point[], - interfaceSide: "start" | "end", - graphScale: number, - trafficRatesOnHoverOnly: boolean -): Element { - const trafficLabelPlaceholder = "rate"; - - const group = doc.createElementNS(SVG_NS, "g"); - group.setAttribute("class", "grafana-traffic-half"); - setCellIdAttributes(group, shortCellId); - - const clonedPath = sourcePath.cloneNode(true); - if (!(clonedPath instanceof Element)) { - throw new Error("Expected cloned traffic path to be an Element"); - } - const path = clonedPath; - path.setAttribute("d", halfPathData); - path.removeAttribute("id"); - path.removeAttribute("data-cell-id"); - group.appendChild(path); - - if (trafficRatesOnHoverOnly) { - const hitboxPath = doc.createElementNS(SVG_NS, "path"); - hitboxPath.setAttribute("class", "grafana-traffic-hitbox"); - hitboxPath.setAttribute("d", halfPathData); - group.appendChild(hitboxPath); - } - - const mid = resolveTrafficLabelPoint( - halfPathData, - occupiedLabelPoints, - interfaceLabelPoints, - interfaceSide, - graphScale - ); - const text = doc.createElementNS(SVG_NS, "text"); - text.setAttribute("x", fmt(mid.x)); - text.setAttribute("y", fmt(mid.y)); - text.setAttribute("font-size", "10"); - text.setAttribute("font-family", "Helvetica, Arial, sans-serif"); - setCellIdAttributes(text, getTrafficLabelCellId(shortCellId)); - text.style.color = "#FFFFFF"; - text.style.filter = "drop-shadow(0 0 1px rgba(0, 0, 0, 0.95))"; - text.setAttribute("fill", "currentColor"); - text.setAttribute("text-anchor", "middle"); - text.setAttribute("dominant-baseline", "middle"); - text.setAttribute("stroke", "none"); - text.textContent = trafficLabelPlaceholder; - group.appendChild(text); - - return group; -} - -function resolveOperstateCellElements(edgeGroup: Element): { - source: Element | null; - target: Element | null; -} { - const labelRects = Array.from(edgeGroup.querySelectorAll("g.edge-label")) - .map((labelGroup) => labelGroup.querySelector("circle,ellipse,rect,path,polygon,polyline")) - .filter((shape): shape is Element => shape !== null); - return { - source: labelRects[0] ?? null, - target: labelRects[1] ?? null - }; -} - -function buildEdgeGroupByDataId(doc: XMLDocument): Map { - const edgeGroupByDataId = new Map(); - for (const group of Array.from(doc.querySelectorAll("g.export-edge"))) { - const edgeDataId = group.getAttribute("data-id"); - if (edgeDataId === null || edgeDataId.length === 0 || edgeGroupByDataId.has(edgeDataId)) { - continue; - } - edgeGroupByDataId.set(edgeDataId, group); - } - return edgeGroupByDataId; -} - -function collectInterfaceLabelPoints(doc: XMLDocument): Point[] { - const interfaceLabelPoints: Point[] = []; - for (const textEl of Array.from(doc.querySelectorAll("g.edge-label text[x][y]"))) { - const x = parseNumericAttr(textEl, "x"); - const y = parseNumericAttr(textEl, "y"); - if (x === null || y === null) continue; - interfaceLabelPoints.push({ x, y }); - } - return interfaceLabelPoints; -} - -function applyTrafficLabelHoverOnlyStyle(doc: XMLDocument): void { - const svgEl = doc.documentElement; - if (svgEl.querySelector("#grafana-traffic-hover-style")) return; - - const styleEl = doc.createElementNS(SVG_NS, "style"); - styleEl.setAttribute("id", "grafana-traffic-hover-style"); - styleEl.setAttribute("type", "text/css"); - styleEl.textContent = [ - ".grafana-traffic-half > path.grafana-traffic-hitbox{fill:none;stroke:transparent !important;stroke-width:14;stroke-linecap:round;stroke-linejoin:round;vector-effect:non-scaling-stroke;pointer-events:stroke;}", - ".grafana-traffic-half > text{opacity:0;pointer-events:none;transition:opacity 120ms ease-in-out;}", - ".grafana-traffic-half:hover > text{opacity:1;}" - ].join(""); - svgEl.insertBefore(styleEl, svgEl.firstChild); -} - -function replaceTrafficPathWithHalfCells( - doc: XMLDocument, - trafficPath: Element, - mapping: GrafanaEdgeCellMapping, - occupiedTrafficLabelPoints: TrafficLabelPlacement[], - interfaceLabelPoints: Point[], - graphScale: number, - trafficRatesOnHoverOnly: boolean -): void { - const parent = trafficPath.parentNode; - if (!parent) return; - - const pathData = trafficPath.getAttribute("d") ?? ""; - const split = splitPathIntoHalves(pathData); - const firstHalfData = split?.first ?? pathData; - const secondHalfData = split?.second ?? pathData; - - const firstHalf = createTrafficHalfCell( - doc, - trafficPath, - firstHalfData, - mapping.trafficCellId, - occupiedTrafficLabelPoints, - interfaceLabelPoints, - "start", - graphScale, - trafficRatesOnHoverOnly - ); - const secondHalf = createTrafficHalfCell( - doc, - trafficPath, - secondHalfData, - mapping.reverseTrafficCellId, - occupiedTrafficLabelPoints, - interfaceLabelPoints, - "end", - graphScale, - trafficRatesOnHoverOnly - ); - parent.insertBefore(firstHalf, trafficPath); - parent.insertBefore(secondHalf, trafficPath); - trafficPath.remove(); -} - -function applyTrafficCellsToEdgeGroup( - doc: XMLDocument, - mapping: GrafanaEdgeCellMapping, - trafficGroup: Element, - occupiedTrafficLabelPoints: TrafficLabelPlacement[], - interfaceLabelPoints: Point[], - graphScale: number, - trafficRatesOnHoverOnly: boolean -): void { - const trafficCellEl = resolveTrafficCellElement(trafficGroup); - if (trafficCellEl.tagName.toLowerCase() !== "path") { - setCellIdAttributes(trafficCellEl, mapping.trafficCellId); - return; - } - - replaceTrafficPathWithHalfCells( - doc, - trafficCellEl, - mapping, - occupiedTrafficLabelPoints, - interfaceLabelPoints, - graphScale, - trafficRatesOnHoverOnly - ); -} - -function applyOperstateCellsToEdgeGroup( - doc: XMLDocument, - mapping: GrafanaEdgeCellMapping, - trafficGroup: Element -): void { - const operstateCellEls = resolveOperstateCellElements(trafficGroup); - if (operstateCellEls.source) { - setCellIdAttributes(operstateCellEls.source, mapping.operstateCellId); - operstateCellEls.source.classList.add("grafana-operstate-cell"); - } else { - const operstateGroup = createOperstateCellGroup(doc, trafficGroup, mapping.operstateCellId); - trafficGroup.parentNode?.insertBefore(operstateGroup, trafficGroup); - } - - if (!operstateCellEls.target) return; - setCellIdAttributes(operstateCellEls.target, mapping.targetOperstateCellId); - operstateCellEls.target.classList.add("grafana-operstate-cell"); -} - -export function applyGrafanaCellIdsToSvg( - svgContent: string, - mappings: GrafanaEdgeCellMapping[], - options: GrafanaCellIdSvgOptions = {} -): string { - if (mappings.length === 0) return svgContent; - const trafficRatesOnHoverOnly = options.trafficRatesOnHoverOnly === true; - - const parser = new DOMParser(); - const doc = parser.parseFromString(svgContent, SVG_MIME_TYPE); - const graphTransform = parseGraphTransform(doc.documentElement); - const graphScale = Math.max(0.05, Math.abs(graphTransform.scale)); - const edgeGroupByDataId = buildEdgeGroupByDataId(doc); - const occupiedTrafficLabelPoints: TrafficLabelPlacement[] = []; - const interfaceLabelPoints = collectInterfaceLabelPoints(doc); - - for (const mapping of mappings) { - const trafficGroup = edgeGroupByDataId.get(mapping.edgeId); - if (!trafficGroup) continue; - applyTrafficCellsToEdgeGroup( - doc, - mapping, - trafficGroup, - occupiedTrafficLabelPoints, - interfaceLabelPoints, - graphScale, - trafficRatesOnHoverOnly - ); - applyOperstateCellsToEdgeGroup(doc, mapping, trafficGroup); - } - - if (trafficRatesOnHoverOnly) { - applyTrafficLabelHoverOnlyStyle(doc); - } - - return new XMLSerializer().serializeToString(doc.documentElement); -} - -export function sanitizeSvgForGrafana(svgContent: string): string { - if (typeof DOMParser === "undefined" || typeof XMLSerializer === "undefined") { - return svgContent - .replace(/\sfilter=(["'])url\(#text-shadow\)\1/gi, "") - .replace(/]*id=(["'])text-shadow\1[\s\S]*?<\/filter>/gi, ""); - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(svgContent, SVG_MIME_TYPE); - - for (const textEl of Array.from(doc.querySelectorAll("text[filter]"))) { - textEl.removeAttribute("filter"); - } - - for (const filterEl of Array.from(doc.querySelectorAll("defs filter#text-shadow"))) { - filterEl.remove(); - } - - // Keep interface labels readable while minimally affecting operstate color. - for (const edgeLabelBg of Array.from(doc.querySelectorAll("g.edge-label rect"))) { - edgeLabelBg.setAttribute("fill", "rgba(0, 0, 0, 0.36)"); - edgeLabelBg.setAttribute("stroke", "none"); - } - - return new XMLSerializer().serializeToString(doc.documentElement); -} - -export function removeUnlinkedNodesFromSvg(svgContent: string, linkedNodeIds: Set): string { - if (typeof DOMParser === "undefined" || typeof XMLSerializer === "undefined") { - return svgContent.replace( - /]*class=(["'])[^"']*\bexport-node\b[^"']*\1[^>]*data-id=(["'])([^"']+)\2[^>]*>[\s\S]*?<\/g>/gi, - (match, _classQuote: string, _idQuote: string, nodeId: string) => - linkedNodeIds.has(nodeId) ? match : "" - ); - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(svgContent, SVG_MIME_TYPE); - - for (const nodeEl of Array.from(doc.querySelectorAll("g.export-node[data-id]"))) { - const nodeId = nodeEl.getAttribute("data-id"); - if (nodeId === null || nodeId.length === 0 || linkedNodeIds.has(nodeId)) continue; - nodeEl.remove(); - } - - return new XMLSerializer().serializeToString(doc.documentElement); -} - -function quoteYaml(value: string): string { - return JSON.stringify(value); -} - -function asValidYamlNumber(value: number, fallback: number): number { - if (!Number.isFinite(value)) return fallback; - return Math.max(0, value); -} - -const RATE_LABEL_HIDE_TAG = "hide-rates"; - -export function buildGrafanaPanelYaml( - mappings: GrafanaEdgeCellMapping[], - options: GrafanaPanelYamlOptions = {} -): string { - const trafficThresholds = options.trafficThresholds ?? DEFAULT_GRAFANA_TRAFFIC_THRESHOLDS; - const includeHideRatesLegendToggle = options.includeHideRatesLegendToggle !== false; - const greenThreshold = asValidYamlNumber( - trafficThresholds.green, - DEFAULT_GRAFANA_TRAFFIC_THRESHOLDS.green - ); - const yellowThreshold = asValidYamlNumber( - trafficThresholds.yellow, - DEFAULT_GRAFANA_TRAFFIC_THRESHOLDS.yellow - ); - const orangeThreshold = asValidYamlNumber( - trafficThresholds.orange, - DEFAULT_GRAFANA_TRAFFIC_THRESHOLDS.orange - ); - const redThreshold = asValidYamlNumber( - trafficThresholds.red, - DEFAULT_GRAFANA_TRAFFIC_THRESHOLDS.red - ); - const lines: string[] = [ - "---", - "anchors:", - " thresholds-operstate: &thresholds-operstate", - ' - { color: "red", level: 0 }', - ' - { color: "green", level: 1 }', - " thresholds-traffic: &thresholds-traffic", - ' - { color: "gray", level: 0 }', - ` - { color: "green", level: ${greenThreshold} }`, - ` - { color: "yellow", level: ${yellowThreshold} }`, - ` - { color: "orange", level: ${orangeThreshold} }`, - ` - { color: "red", level: ${redThreshold} }`, - " thresholds-rate-label: &thresholds-rate-label", - ' - { color: "white", level: 0 }', - " label-config: &label-config", - ' separator: "replace"', - ' units: "bps"', - " decimalPoints: 1", - " valueMappings:", - ` - { valueMax: ${greenThreshold}, text: "\\u200B" }`, - 'cellIdPreamble: "cell-"', - "cells:" - ]; - if (includeHideRatesLegendToggle) { - lines.splice( - lines.length - 1, - 0, - "tagConfig:", - ` legend: ["${RATE_LABEL_HIDE_TAG}"]`, - " lowlightAlphaFactor: 0", - " highlightRgbFactor: 1" - ); - } - - if (mappings.length === 0) { - lines.push(" {}"); - return `${lines.join("\n")}\n`; - } - - for (const mapping of mappings) { - const operstateDataRef = `oper-state:${mapping.source}:${mapping.sourceEndpoint}`; - const targetOperstateDataRef = `oper-state:${mapping.target}:${mapping.targetEndpoint}`; - const trafficDataRef = `${mapping.source}:${mapping.sourceEndpoint}:out`; - const reverseTrafficDataRef = `${mapping.target}:${mapping.targetEndpoint}:out`; - lines.push(` ${quoteYaml(mapping.operstateCellId)}:`); - lines.push(` dataRef: ${quoteYaml(operstateDataRef)}`); - lines.push(" fillColor:"); - lines.push(" thresholds: *thresholds-operstate"); - if (includeHideRatesLegendToggle) { - lines.push(` tags: ["${RATE_LABEL_HIDE_TAG}"]`); - } - lines.push(` ${quoteYaml(mapping.targetOperstateCellId)}:`); - lines.push(` dataRef: ${quoteYaml(targetOperstateDataRef)}`); - lines.push(" fillColor:"); - lines.push(" thresholds: *thresholds-operstate"); - if (includeHideRatesLegendToggle) { - lines.push(` tags: ["${RATE_LABEL_HIDE_TAG}"]`); - } - lines.push(` ${quoteYaml(mapping.trafficCellId)}:`); - lines.push(` dataRef: ${quoteYaml(trafficDataRef)}`); - lines.push(" strokeColor:"); - lines.push(" thresholds: *thresholds-traffic"); - if (includeHideRatesLegendToggle) { - lines.push(` tags: ["${RATE_LABEL_HIDE_TAG}"]`); - } - lines.push(` ${quoteYaml(getTrafficLabelCellId(mapping.trafficCellId))}:`); - lines.push(` dataRef: ${quoteYaml(trafficDataRef)}`); - lines.push(" label: *label-config"); - lines.push(" labelColor:"); - lines.push(" thresholds: *thresholds-rate-label"); - lines.push(` ${quoteYaml(mapping.reverseTrafficCellId)}:`); - lines.push(` dataRef: ${quoteYaml(reverseTrafficDataRef)}`); - lines.push(" strokeColor:"); - lines.push(" thresholds: *thresholds-traffic"); - if (includeHideRatesLegendToggle) { - lines.push(` tags: ["${RATE_LABEL_HIDE_TAG}"]`); - } - lines.push(` ${quoteYaml(getTrafficLabelCellId(mapping.reverseTrafficCellId))}:`); - lines.push(` dataRef: ${quoteYaml(reverseTrafficDataRef)}`); - lines.push(" label: *label-config"); - lines.push(" labelColor:"); - lines.push(" thresholds: *thresholds-rate-label"); - } - - return `${lines.join("\n")}\n`; -} - -function buildDashboardTargets() { - return DEFAULT_GRAFANA_TARGETS.map((target, index) => ({ - datasource: { type: target.datasource }, - editorMode: "code", - expr: target.expr, - hide: target.hide ?? false, - instant: target.instant, - legendFormat: target.legendFormat, - range: target.range, - refId: String.fromCharCode("A".charCodeAt(0) + index) - })); -} - -export function buildGrafanaDashboardJson( - panelConfigYaml: string, - svgContent: string, - dashboardTitle: string -): string { - const title = dashboardTitle.trim() || "Network Telemetry"; - const dashboard = { - annotations: { - list: [ - { - builtIn: 1, - datasource: { type: "prometheus" }, - enable: true, - hide: true, - iconColor: "rgba(0, 211, 255, 1)", - name: "Annotations & Alerts", - type: "dashboard" - } - ] - }, - editable: true, - fiscalYearStartMonth: 0, - graphTooltip: 0, - id: 3, - links: [], - liveNow: false, - panels: [ - { - datasource: { type: "prometheus" }, - gridPos: { h: 23, w: 13, x: 0, y: 0 }, - id: 1, - options: { - animationControlEnabled: true, - animationsEnabled: true, - debuggingCtr: { - colorsCtr: 1, - dataCtr: 0, - displaySvgCtr: 0, - mappingsCtr: 0, - timingsCtr: 0 - }, - highlighterEnabled: true, - panZoomEnabled: true, - panelConfig: panelConfigYaml, - siteConfig: "", - svg: svgContent, - testDataEnabled: false, - timeSliderEnabled: true - }, - targets: buildDashboardTargets(), - title, - type: "andrewbmchugh-flow-panel" - } - ], - refresh: "5s", - schemaVersion: 38, - tags: [], - time: { from: "now-5m", to: "now" }, - timepicker: {}, - timezone: "", - title, - version: 6, - weekStart: "" - }; - - return JSON.stringify(dashboard, null, 2); -} diff --git a/src/reactTopoViewer/webview/components/panels/svg-export/graphSvg.ts b/src/reactTopoViewer/webview/components/panels/svg-export/graphSvg.ts deleted file mode 100644 index e40970ec1..000000000 --- a/src/reactTopoViewer/webview/components/panels/svg-export/graphSvg.ts +++ /dev/null @@ -1,101 +0,0 @@ -// Core graph SVG export helpers. -import type { Edge, Node, ReactFlowInstance } from "@xyflow/react"; - -import { buildSvgDefs } from "./constants"; -import { renderEdgesToSvg, type EdgeSvgRenderOptions } from "./edgesToSvg"; -import { renderNodesToSvg, type CustomIconMap, type NodeSvgRenderOptions } from "./nodesToSvg"; - -export interface ViewportSize { - width: number; - height: number; -} - -export interface GraphSvgResult { - svg: string; - transform: string; - nodes: Node[]; - edges: Edge[]; -} - -export interface GraphSvgRenderOptions extends EdgeSvgRenderOptions, NodeSvgRenderOptions {} - -export function getViewportSize(): ViewportSize | null { - const container = document.querySelector(".react-flow"); - if (!container) return null; - const rect = container.getBoundingClientRect(); - if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height)) return null; - return { width: rect.width, height: rect.height }; -} - -export function buildViewportTransform( - viewport: { x: number; y: number; zoom: number }, - size: ViewportSize, - zoomPercent: number -): { width: number; height: number; transform: string; scaleFactor: number } { - const scaleFactor = Math.max(0.1, zoomPercent / 100); - const width = Math.max(1, Math.round(size.width * scaleFactor)); - const height = Math.max(1, Math.round(size.height * scaleFactor)); - const transform = `translate(${viewport.x * scaleFactor}, ${viewport.y * scaleFactor}) scale(${viewport.zoom * scaleFactor})`; - return { width, height, transform, scaleFactor }; -} - -export function buildGraphSvg( - rfInstance: ReactFlowInstance, - zoomPercent: number, - customIcons?: CustomIconMap, - includeEdgeLabels = true, - annotationNodeTypes?: Set, - nodeProximateLabels = false, - renderOptions?: GraphSvgRenderOptions -): GraphSvgResult | null { - const viewport = rfInstance.getViewport(); - const size = getViewportSize(); - if (!size) return null; - const { width, height, transform } = buildViewportTransform(viewport, size, zoomPercent); - const nodes = rfInstance.getNodes(); - const edges = rfInstance.getEdges(); - - const edgesSvg = renderEdgesToSvg( - edges, - nodes, - includeEdgeLabels, - annotationNodeTypes, - nodeProximateLabels, - renderOptions - ); - const nodesSvg = renderNodesToSvg(nodes, customIcons, annotationNodeTypes, { - nodeIconSize: renderOptions?.nodeIconSize - }); - - let svg = ``; - svg += buildSvgDefs(); - svg += ``; - svg += edgesSvg; - svg += nodesSvg; - svg += ``; - - return { svg, transform, nodes, edges }; -} - -export function applyPadding(svgContent: string, padding: number): string { - const parser = new DOMParser(); - const doc = parser.parseFromString(svgContent, "image/svg+xml"); - const svgEl = doc.documentElement; - const width = parseFloat(svgEl.getAttribute("width") ?? "0"); - const height = parseFloat(svgEl.getAttribute("height") ?? "0"); - const newWidth = width + 2 * padding; - const newHeight = height + 2 * padding; - const viewBox = svgEl.getAttribute("viewBox") ?? `0 0 ${width} ${height}`; - const [x, y, vWidth, vHeight] = viewBox.split(" ").map(parseFloat); - const paddingX = padding * (vWidth / width); - const paddingY = padding * (vHeight / height); - - svgEl.setAttribute( - "viewBox", - `${x - paddingX} ${y - paddingY} ${vWidth + 2 * paddingX} ${vHeight + 2 * paddingY}` - ); - svgEl.setAttribute("width", newWidth.toString()); - svgEl.setAttribute("height", newHeight.toString()); - - return new XMLSerializer().serializeToString(svgEl); -} diff --git a/src/reactTopoViewer/webview/components/panels/svg-export/index.ts b/src/reactTopoViewer/webview/components/panels/svg-export/index.ts deleted file mode 100644 index a54c92eca..000000000 --- a/src/reactTopoViewer/webview/components/panels/svg-export/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -// SVG export barrel. - -// Constants -export { - NODE_ICON_SIZE, - NODE_ICON_RADIUS, - NODE_LABEL, - DEFAULT_ICON_COLOR, - EDGE_COLOR, - EDGE_STYLE, - EDGE_LABEL, - CONTROL_POINT_STEP_SIZE, - NETWORK_TYPE_COLOR, - ROLE_SVG_MAP, - TEXT_SHADOW_FILTER, - getNetworkTypeColor, - getRoleSvgType, - buildSvgDefs -} from "./constants"; - -// Node rendering -export { - topologyNodeToSvg, - networkNodeToSvg, - buildNodeLabelSvg, - renderNodesToSvg -} from "./nodesToSvg"; -export type { CustomIconMap, NodeSvgRenderOptions } from "./nodesToSvg"; - -// Edge rendering -export { buildEdgeInfoForExport, edgeToSvg, renderEdgesToSvg } from "./edgesToSvg"; -export type { EdgeSvgRenderOptions } from "./edgesToSvg"; - -// Annotation rendering (existing) -export { compositeAnnotationsIntoSvg, addBackgroundRect } from "./annotationsToSvg"; -export type { AnnotationData } from "./annotationsToSvg"; - -// Graph export helpers -export { getViewportSize, buildViewportTransform, buildGraphSvg, applyPadding } from "./graphSvg"; -export type { GraphSvgResult, ViewportSize, GraphSvgRenderOptions } from "./graphSvg"; - -// Grafana export helpers -export { - collectGrafanaEdgeCellMappings, - collectLinkedNodeIds, - sanitizeSvgForGrafana, - removeUnlinkedNodesFromSvg, - trimGrafanaSvgToTopologyContent, - addGrafanaTrafficLegend, - makeGrafanaSvgResponsive, - applyGrafanaCellIdsToSvg, - buildGrafanaPanelYaml, - buildGrafanaDashboardJson, - DEFAULT_GRAFANA_TRAFFIC_THRESHOLDS -} from "./grafanaExport"; -export type { - GrafanaEdgeCellMapping, - GrafanaCellIdSvgOptions, - GrafanaPanelYamlOptions, - GrafanaTrafficThresholds -} from "./grafanaExport"; diff --git a/src/reactTopoViewer/webview/components/panels/svg-export/nodesToSvg.ts b/src/reactTopoViewer/webview/components/panels/svg-export/nodesToSvg.ts deleted file mode 100644 index 984d98cc9..000000000 --- a/src/reactTopoViewer/webview/components/panels/svg-export/nodesToSvg.ts +++ /dev/null @@ -1,411 +0,0 @@ -// Node-to-SVG conversion for export. -import type { Node } from "@xyflow/react"; - -import type { NodeType } from "../../../icons/SvgGenerator"; -import { generateEncodedSVG } from "../../../icons/SvgGenerator"; - -import { - NODE_ICON_SIZE, - NODE_ICON_RADIUS, - NODE_LABEL, - DEFAULT_ICON_COLOR, - getNetworkTypeColor, - getRoleSvgType, - escapeXml -} from "./constants"; - -// ============================================================================ -// Types -// ============================================================================ - -/** Custom icons map type (icon name -> data URI) */ -export type CustomIconMap = Map; - -export interface NodeSvgRenderOptions { - nodeIconSize?: number; -} - -interface TopologyNodeData { - label?: string; - role?: string; - iconColor?: string; - iconCornerRadius?: number; - labelPosition?: string; - direction?: string; - labelBackgroundColor?: string; - [key: string]: unknown; -} - -interface NetworkNodeData { - label?: string; - nodeType?: string; - labelPosition?: string; - direction?: string; - labelBackgroundColor?: string; - [key: string]: unknown; -} - -const NODE_TYPE_SET: ReadonlySet = new Set([ - "pe", - "dcgw", - "leaf", - "switch", - "spine", - "super-spine", - "server", - "pon", - "controller", - "rgw", - "ue", - "cloud", - "client", - "bridge" -]); - -function isNodeType(value: string): value is NodeType { - return NODE_TYPE_SET.has(value); -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function resolveNodeIconSize(nodeIconSize: number | undefined): number { - if (typeof nodeIconSize !== "number" || !Number.isFinite(nodeIconSize)) return NODE_ICON_SIZE; - return clamp(nodeIconSize, 12, 240); -} - -// ============================================================================ -// Helpers -// ============================================================================ - -/** - * Decode a data URI SVG and extract the inner content - * Returns the SVG content ready for embedding - */ -function decodeSvgDataUri(dataUri: string): string { - if (!dataUri.startsWith("data:image/svg+xml")) { - return ""; - } - const encoded = dataUri.replace(/^data:image\/svg\+xml[^,]*,/, ""); - return decodeURIComponent(encoded); -} - -/** - * Extract SVG inner content (everything inside the tags) - * and transform it for embedding at a target size - */ -function extractSvgContent(svgString: string, targetSize: number): string { - // Parse the SVG to get viewBox or size - const viewBoxMatch = /viewBox="([^"]+)"/.exec(svgString); - const viewBox = viewBoxMatch ? viewBoxMatch[1] : "0 0 120 120"; - const [, , vbWidth, vbHeight] = viewBox.split(/\s+/).map(parseFloat); - - // Calculate scale to fit targetSize - const scaleX = targetSize / (vbWidth || 120); - const scaleY = targetSize / (vbHeight || 120); - const scale = Math.min(scaleX, scaleY); - - // Extract inner content (between and ) - const innerMatch = /]*>([\s\S]*)<\/svg>/i.exec(svgString); - if (!innerMatch) return ""; - - let inner = innerMatch[1]; - - // Remove - setColor(DEFAULT_COLOR)} - disabled={!isBuiltInIcon(icon) || color === DEFAULT_COLOR} - > - - - - - - - - - - {/* Preview section */} - - - Preview - - - - - - - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/ui/ShortcutDisplay.tsx b/src/reactTopoViewer/webview/components/ui/ShortcutDisplay.tsx deleted file mode 100644 index 84c62e5f1..000000000 --- a/src/reactTopoViewer/webview/components/ui/ShortcutDisplay.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/** - * ShortcutDisplay - Visual feedback for keyboard/mouse shortcuts - * Displays detected input events as floating labels - */ -import React from "react"; -import Box from "@mui/material/Box"; - -interface ShortcutDisplayItem { - id: number; - text: string; -} - -interface ShortcutDisplayProps { - shortcuts: ShortcutDisplayItem[]; -} - -export const ShortcutDisplay: React.FC = ({ shortcuts }) => { - if (shortcuts.length === 0) return null; - - return ( - - {shortcuts.map((shortcut) => ( - - {shortcut.text} - - ))} - - ); -}; diff --git a/src/reactTopoViewer/webview/components/ui/Toast.tsx b/src/reactTopoViewer/webview/components/ui/Toast.tsx deleted file mode 100644 index d79b8c2ff..000000000 --- a/src/reactTopoViewer/webview/components/ui/Toast.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// Notification toast. -import React, { useState, useCallback } from "react"; -import Snackbar from "@mui/material/Snackbar"; -import Alert from "@mui/material/Alert"; - -export interface ToastMessage { - id: string; - message: string; - type?: "info" | "success" | "warning" | "error"; - duration?: number; -} - -interface ToastContainerProps { - toasts: ToastMessage[]; - onDismiss: (id: string) => void; -} - -export const ToastContainer: React.FC = ({ toasts, onDismiss }) => ( - <> - {toasts.map((toast, index) => ( - onDismiss(toast.id)} - anchorOrigin={{ vertical: "bottom", horizontal: "center" }} - sx={{ bottom: `${24 + index * 60}px !important` }} - > - onDismiss(toast.id)} - severity={toast.type ?? "info"} - variant="standard" - > - {toast.message} - - - ))} - -); - -// Counter for generating unique toast IDs -let toastIdCounter = 0; - -/** - * Hook for managing toast notifications - */ -export function useToasts() { - const [toasts, setToasts] = useState([]); - - const addToast = useCallback( - (message: string, type: ToastMessage["type"] = "info", duration?: number) => { - const id = `toast-${Date.now()}-${++toastIdCounter}`; - setToasts((prev) => [...prev, { id, message, type, duration }]); - return id; - }, - [] - ); - - const dismissToast = useCallback((id: string) => { - setToasts((prev) => prev.filter((t) => t.id !== id)); - }, []); - - return { - toasts, - addToast, - dismissToast - }; -} diff --git a/src/reactTopoViewer/webview/components/ui/dialog/DialogChrome.tsx b/src/reactTopoViewer/webview/components/ui/dialog/DialogChrome.tsx deleted file mode 100644 index 6a455b037..000000000 --- a/src/reactTopoViewer/webview/components/ui/dialog/DialogChrome.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from "react"; -import CloseIcon from "@mui/icons-material/Close"; -import Button from "@mui/material/Button"; -import DialogActions from "@mui/material/DialogActions"; -import DialogTitle from "@mui/material/DialogTitle"; -import IconButton from "@mui/material/IconButton"; - -interface DialogTitleWithCloseProps { - title: React.ReactNode; - onClose: () => void; -} - -export const DialogTitleWithClose: React.FC = ({ title, onClose }) => ( - - {title} - - - - -); - -interface DialogCancelSaveActionsProps { - onCancel: () => void; - onSave: () => void; - cancelLabel?: string; - saveLabel?: string; - disableSave?: boolean; -} - -export const DialogCancelSaveActions: React.FC = ({ - onCancel, - onSave, - cancelLabel = "Cancel", - saveLabel = "Save", - disableSave = false -}) => ( - - - - -); diff --git a/src/reactTopoViewer/webview/components/ui/editor/EditorPanel.tsx b/src/reactTopoViewer/webview/components/ui/editor/EditorPanel.tsx deleted file mode 100644 index 336b08e0a..000000000 --- a/src/reactTopoViewer/webview/components/ui/editor/EditorPanel.tsx +++ /dev/null @@ -1,140 +0,0 @@ -// Shared editor panel shell (tabbed or children mode). -import React from "react"; -import Box from "@mui/material/Box"; - -import { useFooterControlsRef } from "../../../hooks/ui/useFooterControlsRef"; -import type { FooterControlsRef } from "../../../hooks/ui/useFooterControlsRef"; -import { FIELDSET_RESET_STYLE } from "../../panels/context-panel/ContextPanelScrollArea"; - -import type { TabDefinition } from "./TabNavigation"; -import { TabNavigation } from "./TabNavigation"; - -export interface TabConfig> { - id: string; - label: string; - hidden?: boolean; - /** The component to render for this tab. */ - component: React.ComponentType; -} - -export interface EditorPanelFooterConfig { - onFooterRef?: (ref: FooterControlsRef | null) => void; - hasChanges: boolean; - onApply: () => void; - onSave: () => void; - onDiscard: () => void; -} - -export interface EditorPanelProps> { - // Tabbed mode - tabs?: Array>; - activeTab?: string; - onTabChange?: (tabId: string) => void; - /** Props passed to the active tab component */ - tabProps?: TProps; - // Non-tabbed mode - children?: React.ReactNode; - // Common - readOnly?: boolean; - footer?: EditorPanelFooterConfig; -} - -interface FooterControlConfig { - isEnabled: boolean; - onApply: () => void; - onSave: () => void; - hasChanges: boolean; - onDiscard?: () => void; -} - -function resolveFooterControlConfig(footer?: EditorPanelFooterConfig): FooterControlConfig { - if (!footer) { - return { - isEnabled: false, - onApply: () => {}, - onSave: () => {}, - hasChanges: false, - onDiscard: undefined - }; - } - - return { - isEnabled: true, - onApply: footer.onApply, - onSave: footer.onSave, - hasChanges: footer.hasChanges, - onDiscard: footer.onDiscard - }; -} - -function renderTabbedMode( - tabs: Array>, - activeTab: string, - onTabChange: (tabId: string) => void, - tabProps: TProps | undefined, - readOnly: boolean -): React.ReactElement { - const tabDefs: TabDefinition[] = tabs - .filter((tab) => tab.hidden !== true) - .map(({ id, label }) => ({ id, label })); - const activeConfig = tabs.find((tab) => tab.id === activeTab); - const ActiveComponent = activeConfig?.component; - - return ( - - - -
    - {ActiveComponent && tabProps !== undefined ? : null} -
    -
    -
    - ); -} - -function renderChildrenMode(children: React.ReactNode, readOnly: boolean): React.ReactElement { - return ( - - -
    - {children} -
    -
    -
    - ); -} - -export function EditorPanel>({ - tabs, - activeTab, - onTabChange, - tabProps, - children, - readOnly = false, - footer -}: EditorPanelProps): React.ReactElement { - const footerConfig = resolveFooterControlConfig(footer); - - // Wire up footer ref if provided - useFooterControlsRef( - footer?.onFooterRef, - footerConfig.isEnabled, - footerConfig.onApply, - footerConfig.onSave, - footerConfig.hasChanges, - footerConfig.onDiscard - ); - - // Tabbed mode - if ( - tabs !== undefined && - activeTab !== undefined && - activeTab.length > 0 && - onTabChange !== undefined - ) { - return renderTabbedMode(tabs, activeTab, onTabChange, tabProps, readOnly); - } - - // Children mode (non-tabbed) - return renderChildrenMode(children, readOnly); -} diff --git a/src/reactTopoViewer/webview/components/ui/editor/TabNavigation.tsx b/src/reactTopoViewer/webview/components/ui/editor/TabNavigation.tsx deleted file mode 100644 index 77696ba81..000000000 --- a/src/reactTopoViewer/webview/components/ui/editor/TabNavigation.tsx +++ /dev/null @@ -1,42 +0,0 @@ -// Tab strip with divider. -import React from "react"; -import Tabs from "@mui/material/Tabs"; -import Tab from "@mui/material/Tab"; -import Divider from "@mui/material/Divider"; - -export interface TabDefinition { - id: string; - label: string; - hidden?: boolean; -} - -interface TabNavigationProps { - tabs: TabDefinition[]; - activeTab: string; - onTabChange: (tabId: string) => void; -} - -export const TabNavigation: React.FC = ({ tabs, activeTab, onTabChange }) => { - const visibleTabs = tabs.filter((t) => t.hidden !== true); - - const handleChange = (_event: React.SyntheticEvent, newValue: string) => { - onTabChange(newValue); - }; - - return ( - <> - - {visibleTabs.map((tab) => ( - - ))} - - - - ); -}; diff --git a/src/reactTopoViewer/webview/components/ui/editor/index.ts b/src/reactTopoViewer/webview/components/ui/editor/index.ts deleted file mode 100644 index 426ced85f..000000000 --- a/src/reactTopoViewer/webview/components/ui/editor/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Shared editor components. -export { TabNavigation, type TabDefinition } from "./TabNavigation"; -export { EditorPanel } from "./EditorPanel"; -export type { TabConfig, EditorPanelProps, EditorPanelFooterConfig } from "./EditorPanel"; diff --git a/src/reactTopoViewer/webview/components/ui/form/Badge.tsx b/src/reactTopoViewer/webview/components/ui/form/Badge.tsx deleted file mode 100644 index e9484cd04..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/Badge.tsx +++ /dev/null @@ -1,37 +0,0 @@ -// Badge components for form fields. -import React from "react"; -import Chip from "@mui/material/Chip"; -import Box from "@mui/material/Box"; - -/** - * Inheritance badge - shown when a field value comes from defaults, kinds, or groups - */ -export const InheritanceBadge: React.FC = () => ( - -); - -/** - * Read-only badge for displaying non-editable values - */ -export const ReadOnlyBadge: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - {children} - -); diff --git a/src/reactTopoViewer/webview/components/ui/form/CheckboxField.tsx b/src/reactTopoViewer/webview/components/ui/form/CheckboxField.tsx deleted file mode 100644 index e6c9fe632..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/CheckboxField.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * CheckboxField - Checkbox with label - */ -import React from "react"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import Checkbox from "@mui/material/Checkbox"; - -interface CheckboxFieldProps { - id: string; - label: string; - checked: boolean; - onChange: (checked: boolean) => void; - className?: string; - disabled?: boolean; -} - -export const CheckboxField: React.FC = ({ - id, - label, - checked, - onChange, - disabled -}) => ( - onChange(e.target.checked)} - disabled={disabled} - size="small" - /> - } - label={label} - /> -); diff --git a/src/reactTopoViewer/webview/components/ui/form/ColorField.tsx b/src/reactTopoViewer/webview/components/ui/form/ColorField.tsx deleted file mode 100644 index 9d0de4b46..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/ColorField.tsx +++ /dev/null @@ -1,197 +0,0 @@ -// Color picker input with hex display and optional label. -import React, { useState, useRef, useCallback, useEffect } from "react"; -import Box from "@mui/material/Box"; -import IconButton from "@mui/material/IconButton"; -import InputAdornment from "@mui/material/InputAdornment"; -import TextField from "@mui/material/TextField"; -import ContentCopyIcon from "@mui/icons-material/ContentCopy"; - -import { normalizeHexColor } from "../../../utils/color"; - -interface ColorFieldProps { - id?: string; - label?: string; - value: string; - onChange: (value: string) => void; - disabled?: boolean; - className?: string; -} - -const SWATCH_SIZE = 22; -const COLOR_INPUT_THROTTLE_MS = 40; - -export const ColorField: React.FC = ({ - id, - label, - value, - onChange, - disabled, - className = "" -}) => { - const normalizedValue = normalizeHexColor(value); - const colorInputRef = useRef(null); - const pendingColorRef = useRef(null); - const colorThrottleRef = useRef>(undefined); - // Local state: hex digits only (no "#"). - const [hexText, setHexText] = useState(normalizedValue.slice(1)); - - // Sync the color input imperatively (uncontrolled) to avoid React's - // controlled-input machinery firing spurious change events on color inputs. - useEffect(() => { - if (colorInputRef.current) { - colorInputRef.current.value = normalizedValue; - } - }, [normalizedValue]); - - // Debounced sync: update hex text after the value settles. - // Skip if the current text already represents the same color (case-insensitive). - const debounceRef = useRef>(undefined); - useEffect(() => { - const target = normalizedValue.slice(1); - debounceRef.current = setTimeout(() => { - setHexText((prev) => (prev.toLowerCase() === target ? prev : target)); - }, 100); - return () => clearTimeout(debounceRef.current); - }, [normalizedValue]); - - const flushPendingColor = useCallback(() => { - const pending = pendingColorRef.current; - if (pending === null) return; - pendingColorRef.current = null; - onChange(pending); - }, [onChange]); - - useEffect( - () => () => { - if (colorThrottleRef.current) { - clearTimeout(colorThrottleRef.current); - } - }, - [] - ); - - const handleColorChange = useCallback( - (e: React.ChangeEvent) => { - pendingColorRef.current = e.target.value; - if (colorThrottleRef.current) { - return; - } - colorThrottleRef.current = setTimeout(() => { - colorThrottleRef.current = undefined; - flushPendingColor(); - }, COLOR_INPUT_THROTTLE_MS); - }, - [flushPendingColor] - ); - - const handleColorBlur = useCallback(() => { - if (colorThrottleRef.current) { - clearTimeout(colorThrottleRef.current); - colorThrottleRef.current = undefined; - } - flushPendingColor(); - }, [flushPendingColor]); - - const handleHexBlur = useCallback(() => { - if (hexText.length === 3 || hexText.length === 6) { - onChange("#" + hexText); - } - }, [hexText, onChange]); - - const handleHexChange = useCallback( - (e: React.ChangeEvent) => { - const raw = e.target.value.replace(/^#/, ""); - if (/^[0-9A-Fa-f]{0,6}$/.test(raw)) { - setHexText(raw); - if (raw.length === 6) { - onChange("#" + raw); - } - } - }, - [onChange] - ); - - const handleCopy = useCallback(() => { - const clipboard = globalThis.navigator.clipboard; - if (typeof clipboard.writeText !== "function") { - return; - } - clipboard.writeText(normalizedValue).catch(() => undefined); - }, [normalizedValue]); - - const openPicker = useCallback(() => { - colorInputRef.current?.click(); - }, []); - - return ( - - {/* Hidden native color input — uncontrolled, updated via ref */} - 0 ? { id } : {})} - type="color" - defaultValue={normalizedValue} - onChange={handleColorChange} - onBlur={handleColorBlur} - disabled={disabled} - style={{ - position: "absolute", - width: 0, - height: 0, - overflow: "hidden", - opacity: 0, - pointerEvents: "none" - }} - /> - - - - # - - - ), - endAdornment: ( - - - - - - ) - } - }} - /> - - ); -}; diff --git a/src/reactTopoViewer/webview/components/ui/form/DynamicList.tsx b/src/reactTopoViewer/webview/components/ui/form/DynamicList.tsx deleted file mode 100644 index dab99745a..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/DynamicList.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * DynamicList - Array of string inputs with add/remove - */ -import React from "react"; -import Box from "@mui/material/Box"; -import TextField from "@mui/material/TextField"; - -import { AddItemButton, DeleteItemButton } from "./ListButtons"; - -interface DynamicListProps { - items: string[]; - onChange: (items: string[]) => void; - placeholder?: string; - addLabel?: string; - disabled?: boolean; - hideAddButton?: boolean; -} - -export const DynamicList: React.FC = ({ - items, - onChange, - placeholder, - addLabel = "Add", - disabled, - hideAddButton -}) => { - const handleAdd = () => { - onChange([...items, ""]); - }; - - const handleRemove = (index: number) => { - onChange(items.filter((_, i) => i !== index)); - }; - - const handleChange = (index: number, value: string) => { - const newItems = [...items]; - newItems[index] = value; - onChange(newItems); - }; - - return ( - - {items.map((item, index) => ( - handleChange(index, value)} - onRemove={() => handleRemove(index)} - placeholder={placeholder} - disabled={disabled} - /> - ))} - {hideAddButton !== true && ( - - )} - - ); -}; - -/** - * Single list item with input and delete button - */ -interface DynamicListItemProps { - value: string; - onChange: (value: string) => void; - onRemove: () => void; - placeholder?: string; - disabled?: boolean; -} - -const DynamicListItem: React.FC = ({ - value, - onChange, - onRemove, - placeholder, - disabled -}) => ( - - onChange(e.target.value)} - placeholder={placeholder} - disabled={disabled} - size="small" - fullWidth - /> - - -); diff --git a/src/reactTopoViewer/webview/components/ui/form/FilterableDropdown.tsx b/src/reactTopoViewer/webview/components/ui/form/FilterableDropdown.tsx deleted file mode 100644 index ad5574cd0..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/FilterableDropdown.tsx +++ /dev/null @@ -1,100 +0,0 @@ -// Searchable dropdown with keyboard navigation. -import React from "react"; -import Autocomplete from "@mui/material/Autocomplete"; -import TextField from "@mui/material/TextField"; -import Box from "@mui/material/Box"; - -export interface FilterableDropdownOption { - value: string; - label: string; -} - -interface FilterableDropdownProps { - id: string; - options: FilterableDropdownOption[]; - value: string; - onChange: (value: string) => void; - label?: string; - placeholder?: string; - allowFreeText?: boolean; - className?: string; - disabled?: boolean; - renderOption?: (option: FilterableDropdownOption) => React.ReactNode; - menuClassName?: string; - helperText?: string; - required?: boolean; -} - -export const FilterableDropdown: React.FC = ({ - id, - options, - value, - onChange, - label, - placeholder = "Type to filter...", - allowFreeText = false, - disabled = false, - renderOption, - helperText, - required -}) => { - const selectedOption = options.find((opt) => opt.value === value) ?? null; - - return ( - { - if (newValue !== null) { - onChange(typeof newValue === "string" ? newValue : newValue.value); - } else { - onChange(""); - } - }} - onInputChange={(_event, newInputValue, reason) => { - if (allowFreeText && reason === "input") { - onChange(newInputValue); - } - }} - inputValue={allowFreeText ? value : undefined} - getOptionLabel={(option) => { - if (typeof option === "string") return option; - return option.label; - }} - isOptionEqualToValue={(option, val) => option.value === val.value} - freeSolo={allowFreeText} - disabled={disabled} - size="small" - fullWidth - renderOption={ - renderOption - ? (props, option) => { - const { key, ...otherProps } = props as React.HTMLAttributes & { - key: React.Key; - }; - return ( - - {renderOption(option)} - - ); - } - : undefined - } - renderInput={(params) => ( - - )} - slotProps={{ - listbox: { - sx: { maxHeight: 200 } - } - }} - /> - ); -}; diff --git a/src/reactTopoViewer/webview/components/ui/form/FormComponents.tsx b/src/reactTopoViewer/webview/components/ui/form/FormComponents.tsx deleted file mode 100644 index eabc82435..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/FormComponents.tsx +++ /dev/null @@ -1,217 +0,0 @@ -// Shared form components for annotation editors. -import React from "react"; -import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; -import type { SxProps, Theme } from "@mui/material/styles"; -import TextField from "@mui/material/TextField"; -import InputAdornment from "@mui/material/InputAdornment"; -import Typography from "@mui/material/Typography"; -import Select from "@mui/material/Select"; -import MenuItem from "@mui/material/MenuItem"; -import FormControl from "@mui/material/FormControl"; -import Slider from "@mui/material/Slider"; -import DeleteIcon from "@mui/icons-material/Delete"; - -/** - * Toggle pill button - */ -export const Toggle: React.FC<{ - active: boolean; - onClick: () => void; - children: React.ReactNode; - sx?: SxProps; -}> = ({ active, onClick, children, sx }) => { - const baseSx = { - fontWeight: (theme: Theme) => theme.typography.fontWeightMedium, - minWidth: 0, - px: 1.5, - py: 0.5 - }; - const mergedSx = - sx !== undefined && !Array.isArray(sx) && typeof sx !== "function" - ? Object.assign({}, baseSx, sx) - : baseSx; - return ( - - ); -}; - -/** - * Number input with label and optional unit - */ -export const NumberInput: React.FC<{ - label: string; - value: number; - onChange: (v: number) => void; - min?: number; - max?: number; - step?: number; - unit?: string; -}> = ({ label, value, onChange, min = 0, max = 999, step = 1, unit }) => ( - - - {label} - - onChange(parseFloat(e.target.value) || 0)} - slotProps={{ - htmlInput: { min, max, step, style: { textAlign: "center" } }, - input: - unit !== undefined && unit.length > 0 - ? { - endAdornment: ( - - - {unit} - - - ) - } - : undefined - }} - sx={{ "& .MuiInputBase-input": { py: 0.75, px: 1 } }} - /> - -); - -/** - * Text input with label - */ -export const TextInput: React.FC<{ - label: string; - value: string; - onChange: (v: string) => void; - placeholder?: string; -}> = ({ label, value, onChange, placeholder }) => ( - - - {label} - - onChange(e.target.value)} - placeholder={placeholder} - sx={{ "& .MuiInputBase-input": { py: 0.75, px: 1 } }} - /> - -); - -/** - * Select input with label - */ -export const SelectInput: React.FC<{ - label: string; - value: string; - onChange: (v: string) => void; - options: { value: string; label: string }[]; -}> = ({ label, value, onChange, options }) => ( - - - {label} - - - - - -); - -/** - * Range slider with label and value display - */ -export const RangeSlider: React.FC<{ - label: string; - value: number; - onChange: (v: number) => void; - min?: number; - max?: number; - unit?: string; -}> = ({ label, value, onChange, min = 0, max = 100, unit = "%" }) => ( - - - - {label} - - - {value} - {unit} - - - - onChange(v)} /> - - -); - -/** - * Grid pattern background for previews (sx-compatible style object) - */ -export const PREVIEW_GRID_BG_SX = { - backgroundImage: - "url('data:image/svg+xml,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cdefs%3E%3Cpattern%20id%3D%22grid%22%20width%3D%2220%22%20height%3D%2220%22%20patternUnits%3D%22userSpaceOnUse%22%3E%3Cpath%20d%3D%22M%200%200%20L%2020%200%2020%2020%22%20fill%3D%22none%22%20stroke%3D%22rgba(255%2C255%2C255%2C0.03)%22%20stroke-width%3D%221%22%2F%3E%3C%2Fpattern%3E%3C%2Fdefs%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20fill%3D%22url(%23grid)%22%2F%3E%3C%2Fsvg%3E')" -}; - -/** - * Preview surface used by annotation editors. - * Renders a bordered panel with a subtle grid background. - */ -export const PreviewSurface: React.FC<{ - minHeight?: number; - padding?: number; - gridOpacity?: number; - children: React.ReactNode; -}> = ({ minHeight = 80, padding = 3, gridOpacity = 0.5, children }) => ( - - - {children} - -); - -export const DeleteActionButton: React.FC<{ - onClick: () => void; - label?: string; - alignSelf?: string; -}> = ({ onClick, label = "Delete", alignSelf = "flex-start" }) => ( - -); diff --git a/src/reactTopoViewer/webview/components/ui/form/FormField.tsx b/src/reactTopoViewer/webview/components/ui/form/FormField.tsx deleted file mode 100644 index cefea9f1f..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/FormField.tsx +++ /dev/null @@ -1,71 +0,0 @@ -// Label wrapper with optional tooltip and inheritance badge. -import React from "react"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Tooltip from "@mui/material/Tooltip"; -import IconButton from "@mui/material/IconButton"; -import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; - -import { InheritanceBadge } from "./Badge"; - -interface FormFieldProps { - label: string; - children: React.ReactNode; - className?: string; - unit?: string; - tooltip?: string; - required?: boolean; - /** When true, shows an "inherited" badge indicating the value comes from defaults/kinds/groups */ - inherited?: boolean; -} - -export const FormField: React.FC = ({ - label, - children, - unit, - tooltip, - required, - inherited -}) => { - const hasUnit = unit !== undefined && unit.length > 0; - const isRequired = required === true; - const isInherited = inherited === true; - const hasTooltip = tooltip !== undefined && tooltip.length > 0; - - return ( - - - theme.typography.fontWeightMedium, - textTransform: "uppercase", - letterSpacing: 0.5 - }} - > - {label} - {hasUnit && ( - - ({unit}) - - )} - {isRequired && ( - - * - - )} - - {isInherited && } - {hasTooltip && ( - - - - - - )} - - {children} - - ); -}; diff --git a/src/reactTopoViewer/webview/components/ui/form/IconPreview.tsx b/src/reactTopoViewer/webview/components/ui/form/IconPreview.tsx deleted file mode 100644 index 094c491d9..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/IconPreview.tsx +++ /dev/null @@ -1,24 +0,0 @@ -// Icon preview with configurable corner radius. -import Avatar from "@mui/material/Avatar"; -import type { FC } from "react"; - -interface IconPreviewProps { - src: string; - alt?: string; - size: number; - cornerRadius?: number; -} - -export const IconPreview: FC = ({ src, alt = "", size, cornerRadius }) => ( - 0 ? `${(cornerRadius / 48) * size}px` : 0 - }} - /> -); diff --git a/src/reactTopoViewer/webview/components/ui/form/InputField.tsx b/src/reactTopoViewer/webview/components/ui/form/InputField.tsx deleted file mode 100644 index 9934724ab..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/InputField.tsx +++ /dev/null @@ -1,379 +0,0 @@ -// Text or number input field. -import React from "react"; -import TextField from "@mui/material/TextField"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Tooltip from "@mui/material/Tooltip"; -import InputAdornment from "@mui/material/InputAdornment"; -import IconButton from "@mui/material/IconButton"; -import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; -import ClearIcon from "@mui/icons-material/Clear"; -import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import type { SxProps, Theme } from "@mui/material/styles"; - -interface InputFieldProps { - id: string; - value: string; - onChange: (value: string) => void; - label?: string; - placeholder?: string; - type?: "text" | "number"; - min?: number; - max?: number; - step?: number; - className?: string; - disabled?: boolean; - helperText?: string; - tooltip?: string; - required?: boolean; - error?: boolean; - /** Fixed text suffix shown inside the input (e.g. "seconds") */ - suffix?: string; - /** Show a clear (×) button when the field has a value */ - clearable?: boolean; -} - -const NUMBER_INPUT_SX: SxProps = { - "& input[type=number]": { - MozAppearance: "textfield" - }, - "& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button": - { - WebkitAppearance: "none", - margin: 0 - } -}; - -const STEPPER_BUTTON_SX: SxProps = { - width: 18, - height: 14, - p: 0, - borderRadius: 0.5, - border: "1px solid", - borderColor: "var(--vscode-input-border)", - color: "text.secondary", - "&:hover": { - bgcolor: "action.hover", - color: "text.primary", - borderColor: "var(--vscode-focusBorder, var(--vscode-input-border))" - } -}; - -const STEPPER_ICON_SX: SxProps = { fontSize: 12 }; - -const parseNumericValue = (value: string): number | undefined => { - const numericValue = Number(value); - return value.trim().length > 0 && Number.isFinite(numericValue) ? numericValue : undefined; -}; - -const decimalPlaces = (n?: number): number => { - if (n === undefined || !Number.isFinite(n)) return 0; - const text = n.toString(); - if (text.includes("e-")) { - const exponent = Number(text.split("e-")[1] ?? "0"); - return Number.isFinite(exponent) ? exponent : 0; - } - const dot = text.indexOf("."); - return dot >= 0 ? text.length - dot - 1 : 0; -}; - -const formatNumber = (value: number, precision: number): string => { - const normalized = precision > 0 ? Number(value.toFixed(precision)) : Math.round(value); - return String(normalized); -}; - -const normalizeStep = (step?: number): number => (step !== undefined && step > 0 ? step : 1); - -const getPrecision = (safeStep: number, min?: number, max?: number): number => - Math.max(decimalPlaces(safeStep), decimalPlaces(min), decimalPlaces(max)); - -const clampNumber = (value: number, min?: number, max?: number): number => { - let next = value; - if (min !== undefined) next = Math.max(min, next); - if (max !== undefined) next = Math.min(max, next); - return next; -}; - -const hasContent = (value?: string): boolean => value !== undefined && value.length > 0; - -const getInputSx = (isNumberField: boolean): SxProps | undefined => - isNumberField ? NUMBER_INPUT_SX : undefined; - -interface AdornmentStateOptions { - clearable?: boolean; - value: string; - disabled?: boolean; - tooltip?: string; - suffix?: string; - isNumberField: boolean; - min?: number; - max?: number; - parsedValue?: number; -} - -interface AdornmentState { - showClear: boolean; - hasTooltip: boolean; - hasSuffix: boolean; - showStepper: boolean; - canStepDown: boolean; - canStepUp: boolean; - stepperRightGap: number; - suffixRightGap: number; - tooltipText: string; - hasEndAdornment: boolean; -} - -const getAdornmentState = ({ - clearable, - value, - disabled, - tooltip, - suffix, - isNumberField, - min, - max, - parsedValue -}: AdornmentStateOptions): AdornmentState => { - const canInteract = disabled !== true; - const showClear = clearable === true && value.length > 0 && canInteract; - const hasTooltip = hasContent(tooltip); - const hasSuffix = hasContent(suffix); - const showStepper = isNumberField && canInteract; - const canStepDown = min === undefined || parsedValue === undefined || parsedValue > min; - const canStepUp = max === undefined || parsedValue === undefined || parsedValue < max; - const stepperRightGap = hasSuffix ? 0.75 : 0.5; - const suffixRightGap = showClear || hasTooltip ? 0.5 : 0; - const hasEndAdornment = hasTooltip || hasSuffix || showClear || showStepper; - - return { - showClear, - hasTooltip, - hasSuffix, - showStepper, - canStepDown, - canStepUp, - stepperRightGap, - suffixRightGap, - tooltipText: tooltip ?? "", - hasEndAdornment - }; -}; - -interface CreateStepHandlerOptions { - isNumberField: boolean; - disabled?: boolean; - min?: number; - max?: number; - parsedValue?: number; - safeStep: number; - precision: number; - onChange: (value: string) => void; -} - -const createStepHandler = - ({ - isNumberField, - disabled, - min, - max, - parsedValue, - safeStep, - precision, - onChange - }: CreateStepHandlerOptions) => - (direction: 1 | -1): void => { - if (!isNumberField || disabled === true) return; - const fallbackBase = direction > 0 ? (min ?? 0) : (max ?? min ?? 0); - const base = parsedValue ?? fallbackBase; - const stepped = clampNumber(base + direction * safeStep, min, max); - onChange(formatNumber(stepped, precision)); - }; - -interface StepperButtonProps { - direction: 1 | -1; - onStep: (direction: 1 | -1) => void; - disabled: boolean; - label: string; - icon: React.ReactNode; -} - -const StepperButton: React.FC = ({ - direction, - onStep, - disabled, - label, - icon -}) => ( - onStep(direction)} - onMouseDown={(e) => e.preventDefault()} - edge="end" - disabled={disabled} - aria-label={label} - sx={STEPPER_BUTTON_SX} - > - {icon} - -); - -interface InputEndAdornmentProps { - showStepper: boolean; - canStepUp: boolean; - canStepDown: boolean; - onStep: (direction: 1 | -1) => void; - stepperRightGap: number; - hasSuffix: boolean; - suffix?: string; - suffixRightGap: number; - showClear: boolean; - onClear: () => void; - hasTooltip: boolean; - tooltipText: string; -} - -const InputEndAdornment: React.FC = ({ - showStepper, - canStepUp, - canStepDown, - onStep, - stepperRightGap, - hasSuffix, - suffix, - suffixRightGap, - showClear, - onClear, - hasTooltip, - tooltipText -}) => ( - - {showStepper ? ( - - } - /> - } - /> - - ) : null} - {hasSuffix ? ( - - {suffix} - - ) : null} - {showClear ? ( - - - - ) : null} - {hasTooltip ? ( - - - - - - ) : null} - -); - -export const InputField: React.FC = ({ - id, - value, - onChange, - label, - placeholder, - type = "text", - min, - max, - step, - disabled, - helperText, - tooltip, - required, - error, - suffix, - clearable -}) => { - const isNumberField = type === "number"; - const safeStep = normalizeStep(step); - const parsedValue = parseNumericValue(value); - const precision = getPrecision(safeStep, min, max); - const handleStep = createStepHandler({ - isNumberField, - disabled, - min, - max, - parsedValue, - safeStep, - precision, - onChange - }); - const adornmentState = getAdornmentState({ - clearable, - value, - disabled, - tooltip, - suffix, - isNumberField, - min, - max, - parsedValue - }); - const inputSlotProps = adornmentState.hasEndAdornment - ? { - endAdornment: ( - onChange("")} - hasTooltip={adornmentState.hasTooltip} - tooltipText={adornmentState.tooltipText} - /> - ) - } - : undefined; - - return ( - onChange(e.target.value)} - label={label} - placeholder={placeholder} - disabled={disabled} - size="small" - fullWidth - required={required} - error={error} - helperText={helperText} - sx={getInputSx(isNumberField)} - slotProps={{ - htmlInput: { - min, - max, - step - }, - input: inputSlotProps - }} - /> - ); -}; diff --git a/src/reactTopoViewer/webview/components/ui/form/KeyValueList.tsx b/src/reactTopoViewer/webview/components/ui/form/KeyValueList.tsx deleted file mode 100644 index 67f43935b..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/KeyValueList.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * KeyValueList - Dynamic key-value pairs - */ -import React from "react"; -import Box from "@mui/material/Box"; -import TextField from "@mui/material/TextField"; - -import { AddItemButton, DeleteItemButton } from "./ListButtons"; - -interface KeyValueListProps { - items: Record; - onChange: (items: Record) => void; - keyPlaceholder?: string; - valuePlaceholder?: string; - addLabel?: string; - disabled?: boolean; - hideAddButton?: boolean; -} - -export const KeyValueList: React.FC = ({ - items, - onChange, - keyPlaceholder = "Key", - valuePlaceholder = "Value", - addLabel = "Add", - disabled, - hideAddButton -}) => { - const entries = Object.entries(items); - - const handleAdd = () => { - onChange({ ...items, "": "" }); - }; - - const handleRemove = (key: string) => { - const newItems = { ...items }; - delete newItems[key]; - onChange(newItems); - }; - - const handleKeyChange = (oldKey: string, newKey: string) => { - if (oldKey === newKey) return; - const newItems: Record = {}; - for (const [k, v] of Object.entries(items)) { - newItems[k === oldKey ? newKey : k] = v; - } - onChange(newItems); - }; - - const handleValueChange = (key: string, value: string) => { - onChange({ ...items, [key]: value }); - }; - - return ( - - {entries.map(([key, value], index) => ( - handleKeyChange(key, newKey)} - onValueChange={(val) => handleValueChange(key, val)} - onRemove={() => handleRemove(key)} - keyPlaceholder={keyPlaceholder} - valuePlaceholder={valuePlaceholder} - disabled={disabled} - /> - ))} - {hideAddButton !== true && ( - - )} - - ); -}; - -/** - * Single key-value item - */ -interface KeyValueItemProps { - itemKey: string; - value: string; - onKeyChange: (key: string) => void; - onValueChange: (value: string) => void; - onRemove: () => void; - keyPlaceholder: string; - valuePlaceholder: string; - disabled?: boolean; -} - -const KeyValueItem: React.FC = ({ - itemKey, - value, - onKeyChange, - onValueChange, - onRemove, - keyPlaceholder, - valuePlaceholder, - disabled -}) => ( - - onKeyChange(e.target.value)} - label={keyPlaceholder} - disabled={disabled} - size="small" - sx={{ width: "33%" }} - /> - onValueChange(e.target.value)} - label={valuePlaceholder} - disabled={disabled} - size="small" - sx={{ flex: 1 }} - /> - - -); diff --git a/src/reactTopoViewer/webview/components/ui/form/ListButtons.tsx b/src/reactTopoViewer/webview/components/ui/form/ListButtons.tsx deleted file mode 100644 index 62cf8353b..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/ListButtons.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Shared button components for dynamic list components - */ -import React from "react"; -import Button from "@mui/material/Button"; -import IconButton from "@mui/material/IconButton"; -import DeleteIcon from "@mui/icons-material/Delete"; -import AddIcon from "@mui/icons-material/Add"; - -interface DeleteItemButtonProps { - onRemove: () => void; - disabled?: boolean; -} - -export const DeleteItemButton: React.FC = ({ onRemove, disabled }) => ( - - - -); - -interface AddItemButtonProps { - onAdd: () => void; - label?: string; - disabled?: boolean; -} - -export const AddItemButton: React.FC = ({ onAdd, label = "Add", disabled }) => ( - -); diff --git a/src/reactTopoViewer/webview/components/ui/form/PanelEmptyState.tsx b/src/reactTopoViewer/webview/components/ui/form/PanelEmptyState.tsx deleted file mode 100644 index e6842f670..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/PanelEmptyState.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from "react"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; - -interface PanelEmptyStateProps { - icon: React.ReactNode; - message: string; -} - -export const PanelEmptyState: React.FC = ({ icon, message }) => ( - - {icon} - - {message} - - -); diff --git a/src/reactTopoViewer/webview/components/ui/form/PanelSections.tsx b/src/reactTopoViewer/webview/components/ui/form/PanelSections.tsx deleted file mode 100644 index 337fccb82..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/PanelSections.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from "react"; -import AddIcon from "@mui/icons-material/Add"; -import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; -import Divider from "@mui/material/Divider"; -import Typography from "@mui/material/Typography"; -import type { SxProps, Theme } from "@mui/material/styles"; - -const HEADER_SX = { px: 2, py: 1 } as const; -const HEADER_WITH_ACTION_SX = { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - px: 2, - py: 1 -} as const; -const DEFAULT_FORM_BODY_SX = { display: "flex", flexDirection: "column", gap: 1.5, p: 2 } as const; -const DEFAULT_LIST_BODY_SX = { p: 2 } as const; - -interface PanelSectionHeaderProps { - title: string; - withTopDivider?: boolean; -} - -export const PanelSectionHeader: React.FC = ({ - title, - withTopDivider = true -}) => ( - <> - {withTopDivider && } - - {title} - - - -); - -interface PanelSectionProps { - title: string; - children: React.ReactNode; - withTopDivider?: boolean; - bodySx?: SxProps; -} - -export const PanelSection: React.FC = ({ - title, - children, - withTopDivider = true, - bodySx = DEFAULT_FORM_BODY_SX -}) => ( - <> - - {children} - -); - -interface PanelAddSectionProps { - title: string; - children: React.ReactNode; - onAdd: () => void; - addLabel?: string; - withTopDivider?: boolean; - bodySx?: SxProps; - addDisabled?: boolean; - addTitle?: string; -} - -export const PanelAddSection: React.FC = ({ - title, - children, - onAdd, - addLabel = "ADD", - withTopDivider = true, - bodySx = DEFAULT_LIST_BODY_SX, - addDisabled = false, - addTitle -}) => ( - <> - {withTopDivider && } - - {title} - - - - {children} - -); diff --git a/src/reactTopoViewer/webview/components/ui/form/ReadOnlyCopyField.tsx b/src/reactTopoViewer/webview/components/ui/form/ReadOnlyCopyField.tsx deleted file mode 100644 index a077e106a..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/ReadOnlyCopyField.tsx +++ /dev/null @@ -1,55 +0,0 @@ -// Read-only text field with copy button. -import React, { useCallback } from "react"; -import IconButton from "@mui/material/IconButton"; -import InputAdornment from "@mui/material/InputAdornment"; -import TextField from "@mui/material/TextField"; -import Tooltip from "@mui/material/Tooltip"; -import ContentCopyIcon from "@mui/icons-material/ContentCopy"; - -export interface ReadOnlyCopyFieldProps { - label: string; - value: string; - mono?: boolean; -} - -export const ReadOnlyCopyField: React.FC = ({ - label, - value, - mono = false -}) => { - const handleCopy = useCallback(() => { - if (value) { - window.navigator.clipboard.writeText(value).catch(() => {}); - } - }, [value]); - - return ( - - - - - - - - ) : undefined, - sx: { - userSelect: "none", - WebkitUserSelect: "none", - caretColor: "transparent", - cursor: "default", - ...(mono ? { fontFamily: "monospace" } : undefined) - } - } - }} - /> - ); -}; diff --git a/src/reactTopoViewer/webview/components/ui/form/Section.tsx b/src/reactTopoViewer/webview/components/ui/form/Section.tsx deleted file mode 100644 index a8db4be15..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/Section.tsx +++ /dev/null @@ -1,34 +0,0 @@ -// Bordered section with title and optional inheritance badge. -import React from "react"; -import Box from "@mui/material/Box"; -import Divider from "@mui/material/Divider"; -import Typography from "@mui/material/Typography"; - -import { InheritanceBadge } from "./Badge"; - -interface SectionProps { - title: string; - children: React.ReactNode; - className?: string; - hasBorder?: boolean; - /** When true, shows an "inherited" badge indicating the values come from defaults/kinds/groups */ - inherited?: boolean; -} - -export const Section: React.FC = ({ - title, - children, - hasBorder = true, - inherited -}) => ( - <> - - - {title} - {inherited === true && } - - {children} - - {hasBorder && } - -); diff --git a/src/reactTopoViewer/webview/components/ui/form/SelectField.tsx b/src/reactTopoViewer/webview/components/ui/form/SelectField.tsx deleted file mode 100644 index 5692d01f7..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/SelectField.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/** - * SelectField - Dropdown select - */ -import React from "react"; -import Box from "@mui/material/Box"; -import FormControl from "@mui/material/FormControl"; -import IconButton from "@mui/material/IconButton"; -import InputAdornment from "@mui/material/InputAdornment"; -import InputLabel from "@mui/material/InputLabel"; -import Select from "@mui/material/Select"; -import MenuItem from "@mui/material/MenuItem"; -import FormHelperText from "@mui/material/FormHelperText"; -import ClearIcon from "@mui/icons-material/Clear"; - -export interface SelectOption { - value: string; - label: string; - icon?: React.ReactNode; -} - -interface SelectFieldProps { - id: string; - value: string; - onChange: (value: string) => void; - options: SelectOption[]; - label?: string; - placeholder?: string; - className?: string; - disabled?: boolean; - helperText?: string; - required?: boolean; - clearable?: boolean; -} - -const INLINE_FLEX_DISPLAY = "inline-flex"; -const INLINE_FLEX_ALIGN_SX = { - display: INLINE_FLEX_DISPLAY, - alignItems: "center" -} as const; - -function renderOptionLabel( - label: string, - icon: React.ReactNode | undefined, - gap = 1 -): React.ReactElement { - if (icon === undefined || icon === null) { - return {label}; - } - - return ( - - - {icon} - - {label} - - ); -} - -export const SelectField: React.FC = ({ - id, - value, - onChange, - options, - label, - placeholder, - disabled, - helperText, - required, - clearable -}) => { - const hasLabel = label !== undefined && label.length > 0; - const hasPlaceholder = placeholder !== undefined && placeholder.length > 0; - const hasHelperText = helperText !== undefined && helperText.length > 0; - const showClear = clearable === true && value.length > 0 && disabled !== true; - - return ( - - {hasLabel ? {label} : null} - - {hasHelperText ? {helperText} : null} - - ); -}; diff --git a/src/reactTopoViewer/webview/components/ui/form/index.ts b/src/reactTopoViewer/webview/components/ui/form/index.ts deleted file mode 100644 index 754c66bb9..000000000 --- a/src/reactTopoViewer/webview/components/ui/form/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Shared form components. -export { InputField } from "./InputField"; -export { SelectField, type SelectOption } from "./SelectField"; -export { CheckboxField } from "./CheckboxField"; -export { ColorField } from "./ColorField"; -export { DynamicList } from "./DynamicList"; -export { KeyValueList } from "./KeyValueList"; -export { Section } from "./Section"; -export { FilterableDropdown, type FilterableDropdownOption } from "./FilterableDropdown"; -export { InheritanceBadge, ReadOnlyBadge } from "./Badge"; -export { IconPreview } from "./IconPreview"; -export { ReadOnlyCopyField } from "./ReadOnlyCopyField"; -export { PanelSectionHeader, PanelSection, PanelAddSection } from "./PanelSections"; -export { PanelEmptyState } from "./PanelEmptyState"; -export { - Toggle, - NumberInput, - TextInput, - SelectInput, - RangeSlider, - PREVIEW_GRID_BG_SX, - PreviewSurface, - DeleteActionButton -} from "./FormComponents"; diff --git a/src/reactTopoViewer/webview/components/ui/index.ts b/src/reactTopoViewer/webview/components/ui/index.ts deleted file mode 100644 index 2fcea6408..000000000 --- a/src/reactTopoViewer/webview/components/ui/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ShortcutDisplay } from "./ShortcutDisplay"; -export { ToastContainer } from "./Toast"; diff --git a/src/reactTopoViewer/webview/easter-eggs/EasterEggRenderer.tsx b/src/reactTopoViewer/webview/easter-eggs/EasterEggRenderer.tsx deleted file mode 100644 index 7650c2742..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/EasterEggRenderer.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Easter Egg Renderer - Renders the active easter egg mode component. - * Extracts duplicated conditional rendering logic from App.tsx. - */ - -import React from "react"; - -import { - NightcallMode, - StickerbushMode, - AquaticAmbienceMode, - VaporwaveMode, - DeusExMode -} from "./modes"; -import type { UseEasterEggReturn } from "./useEasterEgg"; - -interface EasterEggRendererProps { - easterEgg: UseEasterEggReturn; -} - -/** - * Renders the appropriate easter egg mode based on current state. - */ -export const EasterEggRenderer: React.FC = ({ easterEgg }) => { - const { state, endPartyMode, nextMode, getModeName } = easterEgg; - const { isPartyMode, easterEggMode } = state; - - if (!isPartyMode) return null; - - const commonProps = { - isActive: isPartyMode, - onClose: endPartyMode, - onSwitchMode: nextMode, - modeName: getModeName() - }; - - switch (easterEggMode) { - case "nightcall": - return ; - case "stickerbrush": - return ; - case "aquatic": - return ; - case "vaporwave": - return ; - case "deusex": - return ; - default: - return null; - } -}; diff --git a/src/reactTopoViewer/webview/easter-eggs/audio/core/frequencyUtils.ts b/src/reactTopoViewer/webview/easter-eggs/audio/core/frequencyUtils.ts deleted file mode 100644 index d3e2d6383..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/audio/core/frequencyUtils.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Frequency utilities for audio easter eggs - * - * Provides unified scale frequency lookup for different keys. - */ - -import type { ScaleDefinition } from "./types"; - -function getFiniteNumberAt(values: unknown[], index: number): number | undefined { - const value = values[index]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -/** - * Get frequency for a scale degree in the given scale - * - * @param scaleDegree - Scale degree 1-7 - * @param octave - Octave offset (0 = base octave) - * @param scale - Scale definition mapping octave to frequencies - * @param fallbackFreq - Fallback frequency if lookup fails (default: 440Hz) - * @returns Frequency in Hz - */ -export function getScaleFrequency( - scaleDegree: number, - octave: number, - scale: ScaleDefinition, - fallbackFreq = 440 -): number { - const scaleIndex = scaleDegree - 1; // Convert 1-7 to 0-6 - const frequenciesRaw: unknown = Reflect.get(scale, octave); - const frequencies = Array.isArray(frequenciesRaw) ? frequenciesRaw : undefined; - if (frequencies === undefined || scaleIndex < 0 || scaleIndex >= 7) { - // Try base octave as fallback - const baseFreqsRaw: unknown = Reflect.get(scale, 0); - const baseFreqs = Array.isArray(baseFreqsRaw) ? baseFreqsRaw : undefined; - if (baseFreqs !== undefined && scaleIndex >= 0 && scaleIndex < 7) { - const freq = getFiniteNumberAt(baseFreqs, scaleIndex); - if (freq !== undefined) { - return freq; - } - } - return fallbackFreq; - } - return getFiniteNumberAt(frequencies, scaleIndex) ?? fallbackFreq; -} - -/** - * A minor scale frequencies - * Scale degrees: 1=A, 2=B, 3=C, 4=D, 5=E, 6=F, 7=G - */ -export const A_MINOR_SCALE: ScaleDefinition = { - [-2]: [55.0, 61.74, 65.41, 73.42, 82.41, 87.31, 98.0], - [-1]: [110.0, 123.47, 130.81, 146.83, 164.81, 174.61, 196.0], - 0: [220.0, 246.94, 261.63, 293.66, 329.63, 349.23, 392.0], - 1: [440.0, 493.88, 523.25, 587.33, 659.25, 698.46, 783.99] -}; - -/** - * B minor scale frequencies - * Scale degrees: 1=B, 2=C#, 3=D, 4=E, 5=F#, 6=G, 7=A - */ -export const B_MINOR_SCALE: ScaleDefinition = { - [-3]: [30.87, 34.65, 36.71, 41.2, 46.25, 49.0, 55.0], - [-2]: [61.74, 69.3, 73.42, 82.41, 92.5, 98.0, 110.0], - [-1]: [123.47, 138.59, 146.83, 164.81, 185.0, 196.0, 220.0], - 0: [246.94, 277.18, 293.66, 329.63, 369.99, 392.0, 440.0], - 1: [493.88, 554.37, 587.33, 659.25, 739.99, 783.99, 880.0], - 2: [987.77, 1108.73, 1174.66, 1318.51, 1479.98, 1567.98, 1760.0] -}; - -/** - * C minor scale frequencies - * Scale degrees: 1=C, 2=D, 3=Eb, 4=F, 5=G, 6=Ab, 7=Bb - */ -export const C_MINOR_SCALE: ScaleDefinition = { - 3: [130.81, 146.83, 155.56, 174.61, 196.0, 207.65, 233.08], - 4: [261.63, 293.66, 311.13, 349.23, 392.0, 415.3, 466.16], - 5: [523.25, 587.33, 622.25, 698.46, 783.99, 830.61, 932.33], - 6: [1046.5, 1174.66, 1244.51, 0, 0, 0, 1864.66] // Sparse - only some notes defined -}; - -/** - * C minor specific note lookup (for Aquatic Ambience with different octave scheme) - * - * The original implementation uses octave offsets from 4 as base, - * so we provide a wrapper that matches that behavior. - */ -export function getCMinorFrequency(scaleDegree: string | number, octaveOffset: number): number { - const sd = typeof scaleDegree === "string" ? parseInt(scaleDegree, 10) : scaleDegree; - const baseOctave = 4; - const actualOctave = baseOctave + octaveOffset; - return getScaleFrequency(sd, actualOctave, C_MINOR_SCALE, 261.63); -} - -/** - * Get A minor frequency helper - */ -export function getAMinorFrequency(scaleDegree: number, octave: number): number { - return getScaleFrequency(scaleDegree, octave, A_MINOR_SCALE, 220); -} - -/** - * Get B minor frequency helper - */ -export function getBMinorFrequency(scaleDegree: number, octave: number): number { - return getScaleFrequency(scaleDegree, octave, B_MINOR_SCALE, 246.94); -} diff --git a/src/reactTopoViewer/webview/easter-eggs/audio/core/index.ts b/src/reactTopoViewer/webview/easter-eggs/audio/core/index.ts deleted file mode 100644 index 36156e7fc..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/audio/core/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Core audio utilities for easter egg hooks - */ - -export { useAudioEngine } from "./useAudioEngine"; -export type { - AudioEngineConfig, - AudioEngineRefs, - AudioEngineReturn, - BufferCache, - MelodyNote, - ScaleDefinition -} from "./types"; -export { - A_MINOR_SCALE, - B_MINOR_SCALE, - C_MINOR_SCALE, - getAMinorFrequency, - getBMinorFrequency, - getCMinorFrequency, - getScaleFrequency -} from "./frequencyUtils"; diff --git a/src/reactTopoViewer/webview/easter-eggs/audio/core/types.ts b/src/reactTopoViewer/webview/easter-eggs/audio/core/types.ts deleted file mode 100644 index a2c85c680..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/audio/core/types.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Shared types for audio easter egg hooks - */ - -/** - * Common return interface for all audio hooks - * Each hook extends this with track-specific methods - */ -export interface AudioEngineReturn { - play: () => Promise; - stop: () => void; - isPlaying: boolean; - isLoading: boolean; - isMuted: boolean; - toggleMute: () => void; - getFrequencyData: () => Uint8Array; - getTimeDomainData: () => Uint8Array; -} - -/** - * Configuration for audio engine - */ -export interface AudioEngineConfig { - /** Whether to loop the audio buffer */ - loop?: boolean; - /** Loop end time in seconds (for pre-rendered reverb tails) */ - loopEnd?: number; - /** FFT size for analyser (default 256) */ - fftSize?: number; - /** Smoothing time constant for analyser (default 0.85) */ - smoothingTimeConstant?: number; - /** Called when playback starts successfully */ - onPlay?: () => void; - /** Called when playback stops (manually or naturally) */ - onStop?: () => void; -} - -/** - * Refs exposed by audio engine for track-specific extensions - */ -export interface AudioEngineRefs { - audioContextRef: { current: AudioContext | null }; - startTimeRef: { current: number }; - analyserRef: { current: AnalyserNode | null }; -} - -/** - * Scale definition for frequency lookup - * Maps octave offset to array of 7 frequencies (scale degrees 1-7) - */ -export type ScaleDefinition = Record; - -/** - * Melody note interface used by all audio hooks - */ -export interface MelodyNote { - frequency: number; - beat: number; - duration: number; - isRest?: boolean; -} - -/** - * Buffer cache for pre-rendered audio - */ -export interface BufferCache { - buffer: AudioBuffer | null; - isRendering: boolean; - renderPromise: Promise | null; -} diff --git a/src/reactTopoViewer/webview/easter-eggs/audio/core/useAudioEngine.ts b/src/reactTopoViewer/webview/easter-eggs/audio/core/useAudioEngine.ts deleted file mode 100644 index 07ca44643..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/audio/core/useAudioEngine.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Base audio engine hook - * - * Provides common audio playback functionality: - * - Play/stop/mute controls - * - Audio context and analyser management - * - Frequency and time domain data for visualizations - * - * Individual audio hooks compose this with their specific audio generation. - */ - -import { useCallback, useEffect, useRef, useState } from "react"; - -import type { AudioEngineConfig, AudioEngineRefs, AudioEngineReturn } from "./types"; - -/** - * Default configuration values - */ -const DEFAULTS: Required> = { - loop: false, - fftSize: 256, - smoothingTimeConstant: 0.85 -}; - -/** - * Global mute state that persists across all audio engine instances. - * When muted, stays muted when switching songs until explicitly unmuted. - */ -let globalMuteState = false; - -/** - * Base audio engine hook - * - * @param renderBuffer - Function that returns a Promise for playback - * @param config - Optional configuration for playback behavior - * @returns Audio engine controls and refs for track-specific extensions - */ -export function useAudioEngine( - renderBuffer: () => Promise, - config: AudioEngineConfig = {} -): AudioEngineReturn & { refs: AudioEngineRefs } { - const { - loop = DEFAULTS.loop, - loopEnd, - fftSize = DEFAULTS.fftSize, - smoothingTimeConstant = DEFAULTS.smoothingTimeConstant, - onPlay, - onStop - } = config; - - // React state - initialize mute from global state - const [isPlaying, setIsPlaying] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [isMuted, setIsMuted] = useState(globalMuteState); - - // Audio node refs - const audioContextRef = useRef(null); - const analyserRef = useRef(null); - const sourceNodeRef = useRef(null); - const gainNodeRef = useRef(null); - const startTimeRef = useRef(0); - - // Load generation counter for cancellation - const loadGenRef = useRef(0); - // Muted state ref (avoids stale closure in play()) - initialized from global state - const isMutedRef = useRef(globalMuteState); - - // Sync with global mute state on mount (in case it changed externally) - useEffect(() => { - isMutedRef.current = globalMuteState; - setIsMuted(globalMuteState); - }, []); - - // Pre-allocated data arrays for getFrequencyData/getTimeDomainData - const emptyFrequencyData = useRef(new Uint8Array(fftSize / 2)); - const emptyTimeDomainData = useRef(new Uint8Array(fftSize / 2)); - - /** - * Start playback - */ - const play = useCallback(async () => { - if (isPlaying || isLoading) return; - - setIsLoading(true); - const currentGen = ++loadGenRef.current; - - try { - const buffer = await renderBuffer(); - - // Check if cancelled during loading - if (loadGenRef.current !== currentGen) { - return; - } - - const ctx = new AudioContext({ latencyHint: "playback" }); - audioContextRef.current = ctx; - - // Resume if suspended (mobile browsers) - if (ctx.state === "suspended") { - await ctx.resume(); - } - - // Create analyser for visualizations - const analyser = ctx.createAnalyser(); - analyser.fftSize = fftSize; - analyser.smoothingTimeConstant = smoothingTimeConstant; - analyserRef.current = analyser; - - // Create buffer source - const source = ctx.createBufferSource(); - source.buffer = buffer; - source.loop = loop; - if (loop && loopEnd !== undefined) { - source.loopEnd = loopEnd; - } - sourceNodeRef.current = source; - - // Create gain node for mute control - const gainNode = ctx.createGain(); - gainNode.gain.value = isMutedRef.current ? 0 : 1; - gainNodeRef.current = gainNode; - - // Connect: source -> analyser -> gainNode -> destination - source.connect(analyser); - analyser.connect(gainNode); - gainNode.connect(ctx.destination); - - // Handle natural end of playback - source.onended = () => { - // Only trigger stop if still the active source - if (sourceNodeRef.current === source) { - stopPlayback(); - } - }; - - startTimeRef.current = ctx.currentTime; - source.start(0); - - setIsPlaying(true); - onPlay?.(); - } finally { - setIsLoading(false); - } - }, [isPlaying, isLoading, renderBuffer, loop, loopEnd, fftSize, smoothingTimeConstant, onPlay]); - - /** - * Internal stop implementation - */ - const stopPlayback = useCallback(() => { - // Cancel any pending load operations - loadGenRef.current++; - - if (sourceNodeRef.current) { - try { - sourceNodeRef.current.stop(); - } catch { - // Node may already be stopped - } - sourceNodeRef.current = null; - } - - if (audioContextRef.current) { - void audioContextRef.current.close(); - audioContextRef.current = null; - } - - analyserRef.current = null; - gainNodeRef.current = null; - setIsPlaying(false); - onStop?.(); - }, [onStop]); - - /** - * Stop playback (public API) - */ - const stop = useCallback(() => { - stopPlayback(); - }, [stopPlayback]); - - /** - * Toggle mute state - persists globally across song switches - */ - const toggleMute = useCallback(() => { - const newMuted = !isMutedRef.current; - isMutedRef.current = newMuted; - globalMuteState = newMuted; // Persist to global state - setIsMuted(newMuted); - if (gainNodeRef.current) { - gainNodeRef.current.gain.value = newMuted ? 0 : 1; - } - }, []); - - /** - * Get frequency data for visualizations - */ - const getFrequencyData = useCallback((): Uint8Array => { - if (!analyserRef.current) return emptyFrequencyData.current; - const data = new Uint8Array(analyserRef.current.frequencyBinCount); - analyserRef.current.getByteFrequencyData(data); - return data; - }, []); - - /** - * Get time domain data for waveform visualizations - */ - const getTimeDomainData = useCallback((): Uint8Array => { - if (!analyserRef.current) return emptyTimeDomainData.current; - const data = new Uint8Array(analyserRef.current.frequencyBinCount); - analyserRef.current.getByteTimeDomainData(data); - return data; - }, []); - - return { - play, - stop, - isPlaying, - isLoading, - isMuted, - toggleMute, - getFrequencyData, - getTimeDomainData, - refs: { - audioContextRef, - startTimeRef, - analyserRef - } - }; -} diff --git a/src/reactTopoViewer/webview/easter-eggs/audio/index.ts b/src/reactTopoViewer/webview/easter-eggs/audio/index.ts deleted file mode 100644 index a09d4b99e..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/audio/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Easter Egg audio hooks - */ - -export { useAquaticAmbienceAudio } from "./useAquaticAmbienceAudio"; -export type { UseAquaticAmbienceAudioReturn } from "./useAquaticAmbienceAudio"; - -export { useVaporwaveAudio } from "./useVaporwaveAudio"; -export type { UseVaporwaveAudioReturn } from "./useVaporwaveAudio"; - -export { useNightcallAudio } from "./useNightcallAudio"; -export type { UseNightcallAudioReturn } from "./useNightcallAudio"; - -export { useStickerbushAudio } from "./useStickerbushAudio"; -export type { UseStickerbushAudioReturn } from "./useStickerbushAudio"; diff --git a/src/reactTopoViewer/webview/easter-eggs/audio/useAquaticAmbienceAudio.ts b/src/reactTopoViewer/webview/easter-eggs/audio/useAquaticAmbienceAudio.ts deleted file mode 100644 index e3f85cd16..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/audio/useAquaticAmbienceAudio.ts +++ /dev/null @@ -1,443 +0,0 @@ -/** - * Aquatic Ambience Audio Hook - * - * Generates the iconic underwater melody from Donkey Kong Country. - * Composed by David Wise - ethereal, dreamy aquatic atmosphere. - * - * Uses pre-rendered audio for smooth playback on slow hardware. - * - * Key: C minor - * Chords: Cm(add9) - Abm(add9) - Cm(add9) - Abm(add9) - Fmaj7 - Bdim(add9) - */ - -import { useCallback } from "react"; - -import { getCMinorFrequency, useAudioEngine, type MelodyNote } from "./core"; - -const NOTES = { - C3: 130.81, - D3: 146.83, - Eb3: 155.56, - F3: 174.61, - G3: 196.0, - Ab3: 207.65, - Bb3: 233.08, - C4: 261.63, - D4: 293.66, - Eb4: 311.13, - F4: 349.23, - G4: 392.0, - Ab4: 415.3, - Bb4: 466.16, - C5: 523.25, - D5: 587.33, - Eb5: 622.25, - F5: 698.46, - G5: 783.99, - Ab5: 830.61, - Bb5: 932.33, - C6: 1046.5, - D6: 1174.66, - Eb6: 1244.51, - Bb6: 1864.66 -} as const; - -const BEAT = 0.923; -const TOTAL_BEATS = 48; -const TOTAL_DURATION = TOTAL_BEATS * BEAT; -const SAMPLE_RATE = 44100; - -function buildMelody(): MelodyNote[] { - const rawNotes = [ - { sd: "1", octave: 0, beat: 1, duration: 1, isRest: true }, - { sd: "2", octave: 1, beat: 2, duration: 0.5, isRest: false }, - { sd: "1", octave: 1, beat: 2.5, duration: 0.25, isRest: false }, - { sd: "5", octave: 0, beat: 2.75, duration: 2.25, isRest: false }, - { sd: "1", octave: 0, beat: 5, duration: 0.5, isRest: true }, - { sd: "2", octave: 1, beat: 5.5, duration: 0.25, isRest: false }, - { sd: "1", octave: 1, beat: 5.75, duration: 0.25, isRest: false }, - { sd: "2", octave: 1, beat: 6, duration: 0.5, isRest: false }, - { sd: "3", octave: 1, beat: 6.5, duration: 0.25, isRest: false }, - { sd: "4", octave: 1, beat: 6.75, duration: 0.75, isRest: false }, - { sd: "3", octave: 1, beat: 7.5, duration: 0.25, isRest: false }, - { sd: "2", octave: 1, beat: 7.75, duration: 0.75, isRest: false }, - { sd: "7", octave: 0, beat: 8.5, duration: 1.5, isRest: false }, - { sd: "7", octave: 0, beat: 10, duration: 0.5, isRest: false }, - { sd: "1", octave: 1, beat: 10.5, duration: 0.25, isRest: false }, - { sd: "3", octave: 0, beat: 10.75, duration: 2.25, isRest: false }, - { sd: "1", octave: 0, beat: 13, duration: 1, isRest: true }, - { sd: "7", octave: 0, beat: 14, duration: 0.5, isRest: false }, - { sd: "1", octave: 1, beat: 14.5, duration: 0.25, isRest: false }, - { sd: "3", octave: 0, beat: 14.75, duration: 2.25, isRest: false }, - { sd: "1", octave: 0, beat: 17, duration: 1, isRest: true }, - { sd: "2", octave: 1, beat: 18, duration: 0.5, isRest: false }, - { sd: "1", octave: 1, beat: 18.5, duration: 0.25, isRest: false }, - { sd: "5", octave: 0, beat: 18.75, duration: 2.25, isRest: false }, - { sd: "1", octave: 0, beat: 21, duration: 0.5, isRest: true }, - { sd: "2", octave: 1, beat: 21.5, duration: 0.25, isRest: false }, - { sd: "1", octave: 1, beat: 21.75, duration: 0.25, isRest: false }, - { sd: "2", octave: 1, beat: 22, duration: 0.5, isRest: false }, - { sd: "3", octave: 1, beat: 22.5, duration: 0.25, isRest: false }, - { sd: "4", octave: 1, beat: 22.75, duration: 0.75, isRest: false }, - { sd: "5", octave: 1, beat: 23.5, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 23.75, duration: 0.75, isRest: false }, - { sd: "1", octave: 2, beat: 24.5, duration: 0.5, isRest: false }, - { sd: "3", octave: 1, beat: 25, duration: 1, isRest: false }, - { sd: "7", octave: 0, beat: 26, duration: 0.5, isRest: false }, - { sd: "1", octave: 1, beat: 26.5, duration: 0.25, isRest: false }, - { sd: "3", octave: 0, beat: 26.75, duration: 1.25, isRest: false }, - { sd: "7", octave: 1, beat: 28, duration: 0.5, isRest: false }, - { sd: "1", octave: 2, beat: 28.5, duration: 0.25, isRest: false }, - { sd: "3", octave: 1, beat: 28.75, duration: 1.25, isRest: false }, - { sd: "7", octave: 0, beat: 30, duration: 0.5, isRest: false }, - { sd: "1", octave: 1, beat: 30.5, duration: 0.25, isRest: false }, - { sd: "3", octave: 0, beat: 30.75, duration: 2.25, isRest: false }, - { sd: "1", octave: 0, beat: 33, duration: 0.5, isRest: true }, - { sd: "6", octave: 1, beat: 33.5, duration: 0.25, isRest: false }, - { sd: "5", octave: 1, beat: 33.75, duration: 0.25, isRest: false }, - { sd: "6", octave: 1, beat: 34, duration: 0.5, isRest: false }, - { sd: "5", octave: 1, beat: 34.5, duration: 0.25, isRest: false }, - { sd: "6", octave: 1, beat: 34.75, duration: 0.75, isRest: false }, - { sd: "5", octave: 1, beat: 35.5, duration: 0.25, isRest: false }, - { sd: "1", octave: 1, beat: 35.75, duration: 1.25, isRest: false }, - { sd: "1", octave: 0, beat: 37, duration: 0.5, isRest: true }, - { sd: "6", octave: 1, beat: 37.5, duration: 0.25, isRest: false }, - { sd: "5", octave: 1, beat: 37.75, duration: 0.25, isRest: false }, - { sd: "6", octave: 1, beat: 38, duration: 0.5, isRest: false }, - { sd: "5", octave: 1, beat: 38.5, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 38.75, duration: 0.75, isRest: false }, - { sd: "6", octave: 1, beat: 39.5, duration: 0.25, isRest: false }, - { sd: "5", octave: 1, beat: 39.75, duration: 0.75, isRest: false }, - { sd: "4", octave: 1, beat: 40.5, duration: 0.5, isRest: false }, - { sd: "7", octave: 2, beat: 41, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 41.25, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 41.5, duration: 0.25, isRest: false }, - { sd: "7", octave: 2, beat: 41.75, duration: 0.25, isRest: false }, - { sd: "7", octave: 2, beat: 42, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 42.25, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 42.5, duration: 0.25, isRest: false }, - { sd: "7", octave: 2, beat: 42.75, duration: 0.25, isRest: false }, - { sd: "7", octave: 2, beat: 43, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 43.25, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 43.5, duration: 0.25, isRest: false }, - { sd: "7", octave: 2, beat: 43.75, duration: 0.25, isRest: false }, - { sd: "1", octave: 0, beat: 44, duration: 1, isRest: true }, - { sd: "7", octave: 2, beat: 45, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 45.25, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 45.5, duration: 0.25, isRest: false }, - { sd: "7", octave: 2, beat: 45.75, duration: 0.25, isRest: false }, - { sd: "7", octave: 2, beat: 46, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 46.25, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 46.5, duration: 0.25, isRest: false }, - { sd: "7", octave: 2, beat: 46.75, duration: 0.25, isRest: false }, - { sd: "7", octave: 2, beat: 47, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 47.25, duration: 0.25, isRest: false }, - { sd: "7", octave: 1, beat: 47.5, duration: 0.25, isRest: false }, - { sd: "7", octave: 2, beat: 47.75, duration: 0.25, isRest: false } - ]; - - return rawNotes.map((note) => ({ - frequency: note.isRest ? 0 : getCMinorFrequency(note.sd, note.octave), - beat: note.beat, - duration: note.duration, - isRest: note.isRest - })); -} - -const FULL_MELODY = buildMelody(); - -const CHORD_PADS = { - Cm_add9: [NOTES.C3, NOTES.Eb3, NOTES.G3, NOTES.D4], - Abm_add9: [NOTES.Ab3, NOTES.C4, NOTES.Eb4, NOTES.Bb4], - Fmaj7: [NOTES.F3, NOTES.Ab3, NOTES.C4, NOTES.Eb4], - Bdim_add9: [NOTES.Bb3, NOTES.D4, NOTES.F4, NOTES.C5] -}; - -// Module-level cache -let cachedBuffer: AudioBuffer | null = null; -let isRendering = false; -let renderPromise: Promise | null = null; - -function createPadChordOffline( - ctx: OfflineAudioContext, - masterGain: GainNode, - frequencies: number[], - startTime: number, - duration: number -): void { - for (const freq of frequencies) { - const osc1 = ctx.createOscillator(); - osc1.type = "sine"; - osc1.frequency.value = freq; - - const osc2 = ctx.createOscillator(); - osc2.type = "sine"; - osc2.frequency.value = freq * 1.003; - - const osc3 = ctx.createOscillator(); - osc3.type = "triangle"; - osc3.frequency.value = freq * 0.5; - - const oscGain = ctx.createGain(); - oscGain.gain.setValueAtTime(0, startTime); - oscGain.gain.linearRampToValueAtTime(0.012, startTime + 2.0); - oscGain.gain.setValueAtTime(0.012, startTime + duration - 2.5); - oscGain.gain.linearRampToValueAtTime(0, startTime + duration); - - osc1.connect(oscGain); - osc2.connect(oscGain); - osc3.connect(oscGain); - oscGain.connect(masterGain); - - osc1.start(startTime); - osc1.stop(startTime + duration + 0.5); - osc2.start(startTime); - osc2.stop(startTime + duration + 0.5); - osc3.start(startTime); - osc3.stop(startTime + duration + 0.5); - } -} - -function scheduleNoteOffline( - ctx: OfflineAudioContext, - masterGain: GainNode, - frequency: number, - startTime: number, - duration: number -): void { - if (frequency === 0) return; - - const noteDuration = duration * BEAT; - const noteMixer = ctx.createGain(); - noteMixer.connect(masterGain); - - const mainOsc = ctx.createOscillator(); - mainOsc.type = "sine"; - mainOsc.frequency.value = frequency; - - const mainGain = ctx.createGain(); - mainGain.gain.setValueAtTime(0, startTime); - mainGain.gain.linearRampToValueAtTime(0.1, startTime + 0.04); - mainGain.gain.exponentialRampToValueAtTime(0.07, startTime + 0.15); - mainGain.gain.setValueAtTime(0.07, startTime + noteDuration * 0.4); - mainGain.gain.exponentialRampToValueAtTime(0.001, startTime + noteDuration + 1.2); - - mainOsc.connect(mainGain); - mainGain.connect(noteMixer); - - const subOsc = ctx.createOscillator(); - subOsc.type = "sine"; - subOsc.frequency.value = frequency / 2; - - const subGain = ctx.createGain(); - subGain.gain.setValueAtTime(0, startTime); - subGain.gain.linearRampToValueAtTime(0.025, startTime + 0.08); - subGain.gain.setValueAtTime(0.025, startTime + noteDuration * 0.5); - subGain.gain.exponentialRampToValueAtTime(0.001, startTime + noteDuration + 0.8); - - subOsc.connect(subGain); - subGain.connect(noteMixer); - - const bodyOsc = ctx.createOscillator(); - bodyOsc.type = "triangle"; - bodyOsc.frequency.value = frequency; - - const bodyGain = ctx.createGain(); - bodyGain.gain.setValueAtTime(0, startTime); - bodyGain.gain.linearRampToValueAtTime(0.03, startTime + 0.06); - bodyGain.gain.exponentialRampToValueAtTime(0.015, startTime + 0.2); - bodyGain.gain.setValueAtTime(0.015, startTime + noteDuration * 0.3); - bodyGain.gain.exponentialRampToValueAtTime(0.001, startTime + noteDuration + 0.6); - - bodyOsc.connect(bodyGain); - bodyGain.connect(noteMixer); - - const endTime = startTime + noteDuration + 1.5; - - mainOsc.start(startTime); - mainOsc.stop(endTime); - subOsc.start(startTime); - subOsc.stop(endTime); - bodyOsc.start(startTime); - bodyOsc.stop(endTime); -} - -async function renderAudio(): Promise { - if (cachedBuffer) return cachedBuffer; - if (isRendering && renderPromise) return renderPromise; - - isRendering = true; - - renderPromise = (async () => { - const totalDuration = TOTAL_DURATION + 4; - const ctx = new OfflineAudioContext(2, totalDuration * SAMPLE_RATE, SAMPLE_RATE); - - const masterGain = ctx.createGain(); - masterGain.gain.value = 0.32; - - const underwaterFilter = ctx.createBiquadFilter(); - underwaterFilter.type = "lowpass"; - underwaterFilter.frequency.value = 1600; - underwaterFilter.Q.value = 0.4; - - const cleanFilter = ctx.createBiquadFilter(); - cleanFilter.type = "highpass"; - cleanFilter.frequency.value = 60; - - const chorusDelay = ctx.createDelay(0.1); - chorusDelay.delayTime.value = 0.02; - - const chorusLFO = ctx.createOscillator(); - chorusLFO.type = "sine"; - chorusLFO.frequency.value = 0.3; - const chorusDepth = ctx.createGain(); - chorusDepth.gain.value = 0.003; - chorusLFO.connect(chorusDepth); - chorusDepth.connect(chorusDelay.delayTime); - chorusLFO.start(0); - chorusLFO.stop(totalDuration); - - const chorusGain = ctx.createGain(); - chorusGain.gain.value = 0.4; - - const reverbDelay1 = ctx.createDelay(2.0); - reverbDelay1.delayTime.value = 0.35; - const reverbGain1 = ctx.createGain(); - reverbGain1.gain.value = 0.3; - - const reverbFilter = ctx.createBiquadFilter(); - reverbFilter.type = "lowpass"; - reverbFilter.frequency.value = 1000; - - const reverbDelay2 = ctx.createDelay(2.0); - reverbDelay2.delayTime.value = 0.7; - const reverbGain2 = ctx.createGain(); - reverbGain2.gain.value = 0.2; - - const reverbDelay3 = ctx.createDelay(2.0); - reverbDelay3.delayTime.value = 1.1; - const reverbGain3 = ctx.createGain(); - reverbGain3.gain.value = 0.12; - - const compressor = ctx.createDynamicsCompressor(); - compressor.threshold.value = -22; - compressor.knee.value = 25; - compressor.ratio.value = 2.5; - compressor.attack.value = 0.04; - compressor.release.value = 0.5; - - const outputMixer = ctx.createGain(); - outputMixer.gain.value = 1.0; - - masterGain.connect(cleanFilter); - cleanFilter.connect(underwaterFilter); - underwaterFilter.connect(compressor); - - underwaterFilter.connect(chorusDelay); - chorusDelay.connect(chorusGain); - chorusGain.connect(compressor); - - underwaterFilter.connect(reverbDelay1); - reverbDelay1.connect(reverbFilter); - reverbFilter.connect(reverbGain1); - reverbGain1.connect(compressor); - - reverbFilter.connect(reverbDelay2); - reverbDelay2.connect(reverbGain2); - reverbGain2.connect(compressor); - - reverbDelay2.connect(reverbDelay3); - reverbDelay3.connect(reverbGain3); - reverbGain3.connect(compressor); - - compressor.connect(outputMixer); - outputMixer.connect(ctx.destination); - - const padDuration = 8 * BEAT; - let padTime = 0; - createPadChordOffline(ctx, masterGain, CHORD_PADS.Cm_add9, padTime, padDuration); - padTime += padDuration; - createPadChordOffline(ctx, masterGain, CHORD_PADS.Abm_add9, padTime, padDuration); - padTime += padDuration; - createPadChordOffline(ctx, masterGain, CHORD_PADS.Cm_add9, padTime, padDuration); - padTime += padDuration; - createPadChordOffline(ctx, masterGain, CHORD_PADS.Abm_add9, padTime, padDuration); - padTime += padDuration; - createPadChordOffline(ctx, masterGain, CHORD_PADS.Fmaj7, padTime, padDuration); - padTime += padDuration; - createPadChordOffline(ctx, masterGain, CHORD_PADS.Bdim_add9, padTime, padDuration); - - for (const note of FULL_MELODY) { - if (note.isRest !== true) { - const startTime = (note.beat - 1) * BEAT; - scheduleNoteOffline(ctx, masterGain, note.frequency, startTime, note.duration); - } - } - - const buffer = await ctx.startRendering(); - cachedBuffer = buffer; - isRendering = false; - return buffer; - })(); - - return renderPromise; -} - -export interface UseAquaticAmbienceAudioReturn { - play: () => Promise; - stop: () => void; - isPlaying: boolean; - isLoading: boolean; - isMuted: boolean; - toggleMute: () => void; - getFrequencyData: () => Uint8Array; - getTimeDomainData: () => Uint8Array; - getBeatIntensity: () => number; - getCurrentSection: () => number; -} - -export function useAquaticAmbienceAudio(): UseAquaticAmbienceAudioReturn { - const engine = useAudioEngine(renderAudio, { - loop: false, - fftSize: 256, - smoothingTimeConstant: 0.93 - }); - - const getCurrentSection = useCallback((): number => { - const { audioContextRef, startTimeRef } = engine.refs; - if (!audioContextRef.current || !engine.isPlaying) return 0; - - const elapsed = audioContextRef.current.currentTime - startTimeRef.current; - const currentBeat = elapsed / BEAT; - - if (currentBeat < 8) return 0; - if (currentBeat < 16) return 1; - if (currentBeat < 24) return 2; - if (currentBeat < 32) return 3; - if (currentBeat < 40) return 4; - return 5; - }, [engine.isPlaying, engine.refs]); - - const getBeatIntensity = useCallback((): number => { - const { audioContextRef, startTimeRef } = engine.refs; - if (!audioContextRef.current || !engine.isPlaying) return 0; - - const elapsed = audioContextRef.current.currentTime - startTimeRef.current; - const beatPosition = (elapsed / BEAT) % 1; - - return Math.max(0, 0.7 - beatPosition * 0.7); - }, [engine.isPlaying, engine.refs]); - - return { - play: engine.play, - stop: engine.stop, - isPlaying: engine.isPlaying, - isLoading: engine.isLoading, - isMuted: engine.isMuted, - toggleMute: engine.toggleMute, - getFrequencyData: engine.getFrequencyData, - getTimeDomainData: engine.getTimeDomainData, - getBeatIntensity, - getCurrentSection - }; -} diff --git a/src/reactTopoViewer/webview/easter-eggs/audio/useNightcallAudio.ts b/src/reactTopoViewer/webview/easter-eggs/audio/useNightcallAudio.ts deleted file mode 100644 index b198baa71..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/audio/useNightcallAudio.ts +++ /dev/null @@ -1,501 +0,0 @@ -/** - * Nightcall Audio Hook - * - * Generates the Nightcall melody with arpeggio accompaniment. - * Classic synthwave with the Am - G/B - F - Dm progression. - * - * Uses pre-rendered audio for smooth playback on slow hardware. - * - * Key: A minor - * Tempo: ~91 BPM - */ - -import { useCallback } from "react"; - -import { getAMinorFrequency, useAudioEngine, type MelodyNote } from "./core"; - -const BEAT = 0.659; -const SIXTEENTH = BEAT / 4; -const LOOP_DURATION = 32 * BEAT; -const SAMPLE_RATE = 44100; - -function buildMelody(): MelodyNote[] { - const rawNotes = [ - { sd: 1, octave: 0, beat: 1, duration: 3.5 }, - { sd: 2, octave: 0, beat: 4.5, duration: 3.5 }, - { sd: 7, octave: -1, beat: 8, duration: 0.5 }, - { sd: 6, octave: -1, beat: 8.5, duration: 3.5 }, - { sd: 5, octave: -1, beat: 12, duration: 0.5 }, - { sd: 4, octave: -1, beat: 12.5, duration: 3.5 }, - { sd: 3, octave: -1, beat: 16, duration: 0.5 }, - { sd: 1, octave: 0, beat: 16.5, duration: 4 }, - { sd: 2, octave: 0, beat: 20.5, duration: 3.5 }, - { sd: 7, octave: -1, beat: 24, duration: 0.5 }, - { sd: 6, octave: -1, beat: 24.5, duration: 3.5 }, - { sd: 5, octave: -1, beat: 28, duration: 0.5 }, - { sd: 4, octave: -1, beat: 28.5, duration: 4 }, - { sd: 3, octave: -1, beat: 32.5, duration: 0.5 } - ]; - - return rawNotes.map((note) => ({ - frequency: getAMinorFrequency(note.sd, note.octave), - beat: note.beat, - duration: note.duration - })); -} - -const FULL_MELODY = buildMelody(); - -const ARPEGGIO_PATTERNS = { - Am: [ - { sd: 1, octave: -1 }, - { sd: 5, octave: -1 }, - { sd: 1, octave: 0 }, - { sd: 5, octave: -1 }, - { sd: 1, octave: -1 }, - { sd: 5, octave: -1 }, - { sd: 1, octave: 0 }, - { sd: 5, octave: -1 }, - { sd: 1, octave: -1 }, - { sd: 5, octave: -1 }, - { sd: 1, octave: 0 }, - { sd: 5, octave: -1 }, - { sd: 1, octave: -1 }, - { sd: 5, octave: -1 }, - { sd: 1, octave: 0 }, - { sd: 5, octave: -1 } - ], - GB: [ - { sd: 2, octave: -1 }, - { sd: 7, octave: -1 }, - { sd: 2, octave: 0 }, - { sd: 7, octave: -1 }, - { sd: 2, octave: -1 }, - { sd: 7, octave: -1 }, - { sd: 2, octave: 0 }, - { sd: 7, octave: -1 }, - { sd: 2, octave: -1 }, - { sd: 7, octave: -1 }, - { sd: 2, octave: 0 }, - { sd: 7, octave: -1 }, - { sd: 2, octave: -1 }, - { sd: 7, octave: -1 }, - { sd: 2, octave: 0 }, - { sd: 7, octave: -1 } - ], - F: [ - { sd: 6, octave: -2 }, - { sd: 3, octave: -1 }, - { sd: 6, octave: -1 }, - { sd: 3, octave: -1 }, - { sd: 6, octave: -2 }, - { sd: 3, octave: -1 }, - { sd: 6, octave: -1 }, - { sd: 3, octave: -1 }, - { sd: 6, octave: -2 }, - { sd: 3, octave: -1 }, - { sd: 6, octave: -1 }, - { sd: 3, octave: -1 }, - { sd: 6, octave: -2 }, - { sd: 3, octave: -1 }, - { sd: 6, octave: -1 }, - { sd: 3, octave: -1 } - ], - Dm: [ - { sd: 4, octave: -1 }, - { sd: 1, octave: 0 }, - { sd: 4, octave: 0 }, - { sd: 1, octave: 0 }, - { sd: 4, octave: -1 }, - { sd: 1, octave: 0 }, - { sd: 4, octave: 0 }, - { sd: 1, octave: 0 }, - { sd: 4, octave: -1 }, - { sd: 1, octave: 0 }, - { sd: 4, octave: 0 }, - { sd: 1, octave: 0 }, - { sd: 4, octave: -1 }, - { sd: 1, octave: 0 }, - { sd: 4, octave: 0 }, - { sd: 1, octave: 0 } - ] -}; - -const BASS_NOTES = { - Am: getAMinorFrequency(1, -2), - GB: getAMinorFrequency(7, -2), - F: getAMinorFrequency(6, -2), - Dm: getAMinorFrequency(4, -2) -}; - -type NightcallChord = "Am" | "GB" | "F" | "Dm"; - -// Module-level cache for pre-rendered audio buffer -let cachedBuffer: AudioBuffer | null = null; -let isRendering = false; -let renderPromise: Promise | null = null; - -function createLeadNoteOffline( - ctx: OfflineAudioContext, - frequency: number, - startTime: number, - duration: number, - destination: AudioNode, - volume: number = 0.15 -): void { - const saw1 = ctx.createOscillator(); - saw1.type = "sawtooth"; - saw1.frequency.value = frequency; - - const saw2 = ctx.createOscillator(); - saw2.type = "sawtooth"; - saw2.frequency.value = frequency * 1.007; - - const saw3 = ctx.createOscillator(); - saw3.type = "sawtooth"; - saw3.frequency.value = frequency * 0.993; - - const sub = ctx.createOscillator(); - sub.type = "sine"; - sub.frequency.value = frequency / 2; - - const saw1Gain = ctx.createGain(); - const saw2Gain = ctx.createGain(); - const saw3Gain = ctx.createGain(); - const subGain = ctx.createGain(); - - const filter = ctx.createBiquadFilter(); - filter.type = "lowpass"; - filter.Q.value = 2; - - filter.frequency.setValueAtTime(400, startTime); - filter.frequency.linearRampToValueAtTime(3500, startTime + 0.08); - filter.frequency.exponentialRampToValueAtTime(1800, startTime + 0.3); - filter.frequency.exponentialRampToValueAtTime(800, startTime + duration); - - saw1.connect(saw1Gain); - saw2.connect(saw2Gain); - saw3.connect(saw3Gain); - sub.connect(subGain); - - saw1Gain.connect(filter); - saw2Gain.connect(filter); - saw3Gain.connect(filter); - subGain.connect(filter); - filter.connect(destination); - - const attack = 0.02; - const decay = 0.15; - const sustain = 0.7; - const release = 0.5; - - saw1Gain.gain.setValueAtTime(0, startTime); - saw1Gain.gain.linearRampToValueAtTime(volume, startTime + attack); - saw1Gain.gain.linearRampToValueAtTime(volume * sustain, startTime + attack + decay); - saw1Gain.gain.setValueAtTime(volume * sustain, startTime + duration); - saw1Gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration + release); - - saw2Gain.gain.setValueAtTime(0, startTime); - saw2Gain.gain.linearRampToValueAtTime(volume * 0.7, startTime + attack); - saw2Gain.gain.linearRampToValueAtTime(volume * sustain * 0.7, startTime + attack + decay); - saw2Gain.gain.setValueAtTime(volume * sustain * 0.7, startTime + duration); - saw2Gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration + release); - - saw3Gain.gain.setValueAtTime(0, startTime); - saw3Gain.gain.linearRampToValueAtTime(volume * 0.7, startTime + attack); - saw3Gain.gain.linearRampToValueAtTime(volume * sustain * 0.7, startTime + attack + decay); - saw3Gain.gain.setValueAtTime(volume * sustain * 0.7, startTime + duration); - saw3Gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration + release); - - subGain.gain.setValueAtTime(0, startTime); - subGain.gain.linearRampToValueAtTime(volume * 0.5, startTime + attack); - subGain.gain.setValueAtTime(volume * 0.4, startTime + duration); - subGain.gain.exponentialRampToValueAtTime(0.001, startTime + duration + release); - - const stopTime = startTime + duration + release + 0.1; - saw1.start(startTime); - saw1.stop(stopTime); - saw2.start(startTime); - saw2.stop(stopTime); - saw3.start(startTime); - saw3.stop(stopTime); - sub.start(startTime); - sub.stop(stopTime); -} - -function createArpNoteOffline( - ctx: OfflineAudioContext, - frequency: number, - startTime: number, - destination: AudioNode, - volume: number = 0.08 -): void { - const duration = SIXTEENTH * 0.9; - - const osc1 = ctx.createOscillator(); - osc1.type = "square"; - osc1.frequency.value = frequency; - - const osc2 = ctx.createOscillator(); - osc2.type = "sawtooth"; - osc2.frequency.value = frequency * 1.003; - - const osc1Gain = ctx.createGain(); - const osc2Gain = ctx.createGain(); - - const filter = ctx.createBiquadFilter(); - filter.type = "lowpass"; - filter.Q.value = 4; - - filter.frequency.setValueAtTime(300, startTime); - filter.frequency.linearRampToValueAtTime(2800, startTime + 0.01); - filter.frequency.exponentialRampToValueAtTime(600, startTime + duration); - - osc1.connect(osc1Gain); - osc2.connect(osc2Gain); - osc1Gain.connect(filter); - osc2Gain.connect(filter); - filter.connect(destination); - - osc1Gain.gain.setValueAtTime(0, startTime); - osc1Gain.gain.linearRampToValueAtTime(volume, startTime + 0.005); - osc1Gain.gain.exponentialRampToValueAtTime(volume * 0.5, startTime + 0.03); - osc1Gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); - - osc2Gain.gain.setValueAtTime(0, startTime); - osc2Gain.gain.linearRampToValueAtTime(volume * 0.6, startTime + 0.005); - osc2Gain.gain.exponentialRampToValueAtTime(volume * 0.3, startTime + 0.03); - osc2Gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); - - const stopTime = startTime + duration + 0.05; - osc1.start(startTime); - osc1.stop(stopTime); - osc2.start(startTime); - osc2.stop(stopTime); -} - -function createBassNoteOffline( - ctx: OfflineAudioContext, - frequency: number, - startTime: number, - duration: number, - destination: AudioNode, - volume: number = 0.2 -): void { - const sub = ctx.createOscillator(); - sub.type = "sine"; - sub.frequency.value = frequency; - - const punch = ctx.createOscillator(); - punch.type = "square"; - punch.frequency.value = frequency; - - const subGain = ctx.createGain(); - const punchGain = ctx.createGain(); - - const filter = ctx.createBiquadFilter(); - filter.type = "lowpass"; - filter.frequency.value = 200; - filter.Q.value = 1; - - sub.connect(subGain); - punch.connect(punchGain); - punchGain.connect(filter); - subGain.connect(destination); - filter.connect(destination); - - subGain.gain.setValueAtTime(0, startTime); - subGain.gain.linearRampToValueAtTime(volume, startTime + 0.02); - subGain.gain.setValueAtTime(volume * 0.8, startTime + duration * 0.8); - subGain.gain.linearRampToValueAtTime(0, startTime + duration); - - punchGain.gain.setValueAtTime(0, startTime); - punchGain.gain.linearRampToValueAtTime(volume * 0.4, startTime + 0.01); - punchGain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.15); - - const stopTime = startTime + duration + 0.1; - sub.start(startTime); - sub.stop(stopTime); - punch.start(startTime); - punch.stop(stopTime); -} - -async function renderLoop(): Promise { - if (cachedBuffer) return cachedBuffer; - if (isRendering && renderPromise) return renderPromise; - - isRendering = true; - - renderPromise = (async () => { - const totalDuration = LOOP_DURATION + 2; - const ctx = new OfflineAudioContext(2, totalDuration * SAMPLE_RATE, SAMPLE_RATE); - - const masterGain = ctx.createGain(); - masterGain.gain.value = 0.6; - - const compressor = ctx.createDynamicsCompressor(); - compressor.threshold.value = -18; - compressor.knee.value = 20; - compressor.ratio.value = 3; - compressor.attack.value = 0.02; - compressor.release.value = 0.25; - - const chorusDelay = ctx.createDelay(0.05); - chorusDelay.delayTime.value = 0.018; - - const chorusLfo = ctx.createOscillator(); - chorusLfo.type = "sine"; - chorusLfo.frequency.value = 0.5; - const chorusDepth = ctx.createGain(); - chorusDepth.gain.value = 0.003; - chorusLfo.connect(chorusDepth); - chorusDepth.connect(chorusDelay.delayTime); - chorusLfo.start(0); - chorusLfo.stop(totalDuration); - - const chorusGain = ctx.createGain(); - chorusGain.gain.value = 0.4; - - const reverb1 = ctx.createDelay(0.5); - reverb1.delayTime.value = 0.18; - const reverb1Gain = ctx.createGain(); - reverb1Gain.gain.value = 0.4; - - const reverb2 = ctx.createDelay(0.5); - reverb2.delayTime.value = 0.35; - const reverb2Gain = ctx.createGain(); - reverb2Gain.gain.value = 0.3; - - const reverb3 = ctx.createDelay(0.6); - reverb3.delayTime.value = 0.5; - const reverb3Gain = ctx.createGain(); - reverb3Gain.gain.value = 0.2; - - const reverbFilter = ctx.createBiquadFilter(); - reverbFilter.type = "lowpass"; - reverbFilter.frequency.value = 1500; - - const outputMixer = ctx.createGain(); - outputMixer.gain.value = 1.0; - - masterGain.connect(compressor); - compressor.connect(outputMixer); - - compressor.connect(chorusDelay); - chorusDelay.connect(chorusGain); - chorusGain.connect(outputMixer); - - compressor.connect(reverb1); - reverb1.connect(reverbFilter); - reverbFilter.connect(reverb1Gain); - reverb1Gain.connect(outputMixer); - reverb1Gain.connect(reverb2); - - reverb2.connect(reverb2Gain); - reverb2Gain.connect(outputMixer); - reverb2Gain.connect(reverb3); - - reverb3.connect(reverb3Gain); - reverb3Gain.connect(outputMixer); - - outputMixer.connect(ctx.destination); - - const startTime = 0; - - // Schedule melody - FULL_MELODY.forEach((note) => { - const noteStart = startTime + (note.beat - 1) * BEAT; - createLeadNoteOffline(ctx, note.frequency, noteStart, note.duration * BEAT, masterGain, 0.12); - }); - - // Schedule arpeggios - const chords: NightcallChord[] = ["Am", "GB", "F", "Dm", "Am", "GB", "F", "Dm"]; - chords.forEach((chord, i) => { - const chordStart = startTime + i * 4 * BEAT; - const pattern = ARPEGGIO_PATTERNS[chord]; - pattern.forEach((note, j) => { - const noteTime = chordStart + j * SIXTEENTH; - const freq = getAMinorFrequency(note.sd, note.octave); - createArpNoteOffline(ctx, freq, noteTime, masterGain); - }); - }); - - // Schedule bass - chords.forEach((chord, i) => { - const bassStart = startTime + i * 4 * BEAT; - const bassFreq = BASS_NOTES[chord]; - createBassNoteOffline(ctx, bassFreq, bassStart, 4 * BEAT, masterGain, 0.15); - }); - - const buffer = await ctx.startRendering(); - cachedBuffer = buffer; - isRendering = false; - return buffer; - })(); - - return renderPromise; -} - -export interface UseNightcallAudioReturn { - play: () => Promise; - stop: () => void; - isPlaying: boolean; - isLoading: boolean; - isMuted: boolean; - toggleMute: () => void; - getFrequencyData: () => Uint8Array; - getTimeDomainData: () => Uint8Array; - getBeatIntensity: () => number; - getCurrentChord: () => string; -} - -export function useNightcallAudio(): UseNightcallAudioReturn { - const engine = useAudioEngine(renderLoop, { - loop: true, - loopEnd: LOOP_DURATION, - fftSize: 256, - smoothingTimeConstant: 0.85 - }); - - const getCurrentChord = useCallback((): string => { - const { audioContextRef, startTimeRef } = engine.refs; - if (!audioContextRef.current || !engine.isPlaying) return "Am"; - - const elapsed = audioContextRef.current.currentTime - startTimeRef.current; - const positionInLoop = elapsed % LOOP_DURATION; - const currentBeat = positionInLoop / BEAT; - - if (currentBeat < 4) return "Am"; - if (currentBeat < 8) return "GB"; - if (currentBeat < 12) return "F"; - if (currentBeat < 16) return "Dm"; - if (currentBeat < 20) return "Am"; - if (currentBeat < 24) return "GB"; - if (currentBeat < 28) return "F"; - return "Dm"; - }, [engine.isPlaying, engine.refs]); - - const getBeatIntensity = useCallback((): number => { - const { audioContextRef, startTimeRef } = engine.refs; - if (!audioContextRef.current || !engine.isPlaying) return 0; - - const elapsed = audioContextRef.current.currentTime - startTimeRef.current; - const positionInLoop = elapsed % LOOP_DURATION; - const sixteenthPosition = (positionInLoop / SIXTEENTH) % 1; - - return Math.max(0, 1 - sixteenthPosition * 4); - }, [engine.isPlaying, engine.refs]); - - return { - play: engine.play, - stop: engine.stop, - isPlaying: engine.isPlaying, - isLoading: engine.isLoading, - isMuted: engine.isMuted, - toggleMute: engine.toggleMute, - getFrequencyData: engine.getFrequencyData, - getTimeDomainData: engine.getTimeDomainData, - getBeatIntensity, - getCurrentChord - }; -} diff --git a/src/reactTopoViewer/webview/easter-eggs/audio/useStickerbushAudio.ts b/src/reactTopoViewer/webview/easter-eggs/audio/useStickerbushAudio.ts deleted file mode 100644 index bbf5b7f30..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/audio/useStickerbushAudio.ts +++ /dev/null @@ -1,493 +0,0 @@ -/** - * Stickerbrush Symphony Audio Hook - * - * Generates the iconic ambient melody from Donkey Kong Country 2. - * Composed by Dave Wise - dreamy, ethereal arpeggios. - * - * Pre-rendered synthesis with: - * - Multiple oscillator layers per note - * - LFO-modulated chorus effect - * - Rich 3-tap reverb chain - * - Warm ethereal filtering - * - * Key: A minor (from Hooktheory transcription) - * Pattern: B-C-B-C-G(bass) repeating with Am7/Cmaj7 chord changes - * G4 transitions only at phrase endings (beats 16.5-16.75, 32.5-32.75) - */ - -import { useCallback, useRef } from "react"; - -import { useAudioEngine, type MelodyNote } from "./core"; - -const NOTES = { - A3: 220.0, - B3: 246.94, - C4: 261.63, - D4: 293.66, - E4: 329.63, - F4: 349.23, - G3: 196.0, - A4: 440.0, - B4: 493.88, - C5: 523.25, - D5: 587.33, - E5: 659.25, - F5: 698.46, - G4: 392.0, - G5: 783.99 -} as const; - -const BEAT = 0.8; -const SAMPLE_RATE = 44100; -const TOTAL_BEATS = 33; -const REVERB_TAIL = 4; -const TOTAL_DURATION = TOTAL_BEATS * BEAT + REVERB_TAIL; - -const OCTAVE_0_FREQS: Record = { - 1: NOTES.A4, - 2: NOTES.B4, - 3: NOTES.C5, - 4: NOTES.D5, - 5: NOTES.E5, - 6: NOTES.F5, - 7: NOTES.G4 -}; - -const OCTAVE_NEG1_FREQS: Record = { - 1: NOTES.A3, - 2: NOTES.B3, - 3: NOTES.C4, - 4: NOTES.D4, - 5: NOTES.E4, - 6: NOTES.F4, - 7: NOTES.G3 -}; - -function getFrequency(sd: number, octave: number): number { - const lookup = octave === 0 ? OCTAVE_0_FREQS : OCTAVE_NEG1_FREQS; - return lookup[sd] ?? NOTES.A4; -} - -function buildMelody(): MelodyNote[] { - const rawNotes = [ - { sd: 2, octave: 0, beat: 1.5, duration: 0.5 }, - { sd: 3, octave: 0, beat: 2, duration: 0.75 }, - { sd: 2, octave: 0, beat: 2.75, duration: 0.75 }, - { sd: 3, octave: 0, beat: 3.5, duration: 0.5 }, - { sd: 7, octave: -1, beat: 4, duration: 1 }, - { sd: 2, octave: 0, beat: 5.5, duration: 0.5 }, - { sd: 3, octave: 0, beat: 6, duration: 0.75 }, - { sd: 2, octave: 0, beat: 6.75, duration: 0.75 }, - { sd: 3, octave: 0, beat: 7.5, duration: 0.5 }, - { sd: 7, octave: -1, beat: 8, duration: 1 }, - { sd: 2, octave: 0, beat: 9.5, duration: 0.5 }, - { sd: 3, octave: 0, beat: 10, duration: 0.75 }, - { sd: 2, octave: 0, beat: 10.75, duration: 0.75 }, - { sd: 3, octave: 0, beat: 11.5, duration: 0.5 }, - { sd: 7, octave: -1, beat: 12, duration: 1 }, - { sd: 2, octave: 0, beat: 13.5, duration: 0.5 }, - { sd: 3, octave: 0, beat: 14, duration: 0.75 }, - { sd: 2, octave: 0, beat: 14.75, duration: 0.75 }, - { sd: 3, octave: 0, beat: 15.5, duration: 0.5 }, - { sd: 7, octave: -1, beat: 16, duration: 0.5 }, - { sd: 7, octave: 0, beat: 16.5, duration: 0.25 }, - { sd: 7, octave: 0, beat: 16.75, duration: 0.25 }, - { sd: 2, octave: 0, beat: 17.5, duration: 0.5 }, - { sd: 3, octave: 0, beat: 18, duration: 0.75 }, - { sd: 2, octave: 0, beat: 18.75, duration: 0.75 }, - { sd: 3, octave: 0, beat: 19.5, duration: 0.5 }, - { sd: 7, octave: -1, beat: 20, duration: 1 }, - { sd: 2, octave: 0, beat: 21.5, duration: 0.5 }, - { sd: 3, octave: 0, beat: 22, duration: 0.75 }, - { sd: 2, octave: 0, beat: 22.75, duration: 0.75 }, - { sd: 3, octave: 0, beat: 23.5, duration: 0.5 }, - { sd: 7, octave: -1, beat: 24, duration: 1 }, - { sd: 2, octave: 0, beat: 25.5, duration: 0.5 }, - { sd: 3, octave: 0, beat: 26, duration: 0.75 }, - { sd: 2, octave: 0, beat: 26.75, duration: 0.75 }, - { sd: 3, octave: 0, beat: 27.5, duration: 0.5 }, - { sd: 7, octave: -1, beat: 28, duration: 1 }, - { sd: 2, octave: 0, beat: 29.5, duration: 0.5 }, - { sd: 3, octave: 0, beat: 30, duration: 0.75 }, - { sd: 2, octave: 0, beat: 30.75, duration: 0.75 }, - { sd: 3, octave: 0, beat: 31.5, duration: 0.5 }, - { sd: 7, octave: -1, beat: 32, duration: 0.5 }, - { sd: 7, octave: 0, beat: 32.5, duration: 0.25 }, - { sd: 7, octave: 0, beat: 32.75, duration: 0.25 } - ]; - - return rawNotes.map((note) => ({ - frequency: getFrequency(note.sd, note.octave), - beat: note.beat, - duration: note.duration - })); -} - -const FULL_MELODY = buildMelody(); - -const CHORD_PADS = { - Am7: [NOTES.A3, NOTES.C4, NOTES.E4, NOTES.G3], - Cmaj7: [NOTES.C4, NOTES.E4, NOTES.G4, NOTES.B4] -}; - -// Module-level audio buffer cache -let cachedBuffer: AudioBuffer | null = null; -let isRendering = false; -let renderPromise: Promise | null = null; - -function schedulePadChord( - ctx: OfflineAudioContext, - masterGain: GainNode, - frequencies: number[], - startTime: number, - duration: number -): void { - const padMixer = ctx.createGain(); - padMixer.gain.setValueAtTime(0.7, startTime); - padMixer.connect(masterGain); - - const padFilter = ctx.createBiquadFilter(); - padFilter.type = "lowpass"; - padFilter.frequency.setValueAtTime(800, startTime); - padFilter.Q.setValueAtTime(0.5, startTime); - padFilter.connect(padMixer); - - const filterLFO = ctx.createOscillator(); - filterLFO.type = "sine"; - filterLFO.frequency.setValueAtTime(0.08, startTime); - const filterLFOGain = ctx.createGain(); - filterLFOGain.gain.setValueAtTime(200, startTime); - filterLFO.connect(filterLFOGain); - filterLFOGain.connect(padFilter.frequency); - filterLFO.start(startTime); - filterLFO.stop(startTime + duration + 1); - - for (const freq of frequencies) { - const voiceMixer = ctx.createGain(); - voiceMixer.connect(padFilter); - - const envelope = ctx.createGain(); - envelope.gain.setValueAtTime(0, startTime); - envelope.gain.linearRampToValueAtTime(0.08, startTime + 1.5); - envelope.gain.setValueAtTime(0.08, startTime + duration - 2.0); - envelope.gain.linearRampToValueAtTime(0, startTime + duration + 0.5); - envelope.connect(voiceMixer); - - const saw1 = ctx.createOscillator(); - saw1.type = "sawtooth"; - saw1.frequency.setValueAtTime(freq, startTime); - const saw1Gain = ctx.createGain(); - saw1Gain.gain.setValueAtTime(0.15, startTime); - saw1.connect(saw1Gain); - saw1Gain.connect(envelope); - - const saw2 = ctx.createOscillator(); - saw2.type = "sawtooth"; - saw2.frequency.setValueAtTime(freq * 1.003, startTime); - const saw2Gain = ctx.createGain(); - saw2Gain.gain.setValueAtTime(0.12, startTime); - saw2.connect(saw2Gain); - saw2Gain.connect(envelope); - - const saw3 = ctx.createOscillator(); - saw3.type = "sawtooth"; - saw3.frequency.setValueAtTime(freq * 0.997, startTime); - const saw3Gain = ctx.createGain(); - saw3Gain.gain.setValueAtTime(0.12, startTime); - saw3.connect(saw3Gain); - saw3Gain.connect(envelope); - - const sub = ctx.createOscillator(); - sub.type = "sine"; - sub.frequency.setValueAtTime(freq * 0.5, startTime); - const subGain = ctx.createGain(); - subGain.gain.setValueAtTime(0.1, startTime); - sub.connect(subGain); - subGain.connect(envelope); - - const shimmer = ctx.createOscillator(); - shimmer.type = "triangle"; - shimmer.frequency.setValueAtTime(freq * 2, startTime); - const shimmerGain = ctx.createGain(); - shimmerGain.gain.setValueAtTime(0.03, startTime); - shimmer.connect(shimmerGain); - shimmerGain.connect(envelope); - - const endTime = startTime + duration + 1; - saw1.start(startTime); - saw1.stop(endTime); - saw2.start(startTime); - saw2.stop(endTime); - saw3.start(startTime); - saw3.stop(endTime); - sub.start(startTime); - sub.stop(endTime); - shimmer.start(startTime); - shimmer.stop(endTime); - } -} - -function scheduleNote( - ctx: OfflineAudioContext, - masterGain: GainNode, - frequency: number, - startTime: number, - duration: number -): void { - if (frequency === 0) return; - - const noteDuration = duration * BEAT; - const noteMixer = ctx.createGain(); - noteMixer.connect(masterGain); - - const mainOsc = ctx.createOscillator(); - mainOsc.type = "sine"; - mainOsc.frequency.setValueAtTime(frequency, startTime); - - const mainGain = ctx.createGain(); - mainGain.gain.setValueAtTime(0, startTime); - mainGain.gain.linearRampToValueAtTime(0.1, startTime + 0.04); - mainGain.gain.exponentialRampToValueAtTime(0.07, startTime + 0.18); - mainGain.gain.setValueAtTime(0.07, startTime + noteDuration * 0.4); - mainGain.gain.exponentialRampToValueAtTime(0.001, startTime + noteDuration + 1.0); - - mainOsc.connect(mainGain); - mainGain.connect(noteMixer); - - const subOsc = ctx.createOscillator(); - subOsc.type = "sine"; - subOsc.frequency.setValueAtTime(frequency / 2, startTime); - - const subGain = ctx.createGain(); - subGain.gain.setValueAtTime(0, startTime); - subGain.gain.linearRampToValueAtTime(0.025, startTime + 0.08); - subGain.gain.setValueAtTime(0.025, startTime + noteDuration * 0.5); - subGain.gain.exponentialRampToValueAtTime(0.001, startTime + noteDuration + 0.7); - - subOsc.connect(subGain); - subGain.connect(noteMixer); - - const bodyOsc = ctx.createOscillator(); - bodyOsc.type = "triangle"; - bodyOsc.frequency.setValueAtTime(frequency, startTime); - - const bodyGain = ctx.createGain(); - bodyGain.gain.setValueAtTime(0, startTime); - bodyGain.gain.linearRampToValueAtTime(0.02, startTime + 0.05); - bodyGain.gain.exponentialRampToValueAtTime(0.012, startTime + 0.2); - bodyGain.gain.setValueAtTime(0.012, startTime + noteDuration * 0.3); - bodyGain.gain.exponentialRampToValueAtTime(0.001, startTime + noteDuration + 0.5); - - bodyOsc.connect(bodyGain); - bodyGain.connect(noteMixer); - - const endTime = startTime + noteDuration + 1.2; - - mainOsc.start(startTime); - mainOsc.stop(endTime); - subOsc.start(startTime); - subOsc.stop(endTime); - bodyOsc.start(startTime); - bodyOsc.stop(endTime); -} - -async function renderAudio(): Promise { - if (cachedBuffer) return cachedBuffer; - if (isRendering && renderPromise) return renderPromise; - - isRendering = true; - renderPromise = (async () => { - const totalSamples = Math.ceil(TOTAL_DURATION * SAMPLE_RATE); - const ctx = new OfflineAudioContext(2, totalSamples, SAMPLE_RATE); - - const masterGain = ctx.createGain(); - masterGain.gain.setValueAtTime(0.3, 0); - - const warmFilter = ctx.createBiquadFilter(); - warmFilter.type = "lowpass"; - warmFilter.frequency.setValueAtTime(1600, 0); - warmFilter.Q.setValueAtTime(0.3, 0); - - const cleanFilter = ctx.createBiquadFilter(); - cleanFilter.type = "highpass"; - cleanFilter.frequency.setValueAtTime(60, 0); - - const chorusDelay = ctx.createDelay(0.1); - chorusDelay.delayTime.setValueAtTime(0.022, 0); - - const chorusLFO = ctx.createOscillator(); - chorusLFO.type = "sine"; - chorusLFO.frequency.setValueAtTime(0.25, 0); - - const chorusDepth = ctx.createGain(); - chorusDepth.gain.setValueAtTime(0.004, 0); - - chorusLFO.connect(chorusDepth); - chorusDepth.connect(chorusDelay.delayTime); - chorusLFO.start(0); - chorusLFO.stop(TOTAL_DURATION); - - const chorusGain = ctx.createGain(); - chorusGain.gain.setValueAtTime(0.4, 0); - - const reverbDelay1 = ctx.createDelay(2.0); - reverbDelay1.delayTime.setValueAtTime(0.35, 0); - - const reverbGain1 = ctx.createGain(); - reverbGain1.gain.setValueAtTime(0.3, 0); - - const reverbFilter = ctx.createBiquadFilter(); - reverbFilter.type = "lowpass"; - reverbFilter.frequency.setValueAtTime(1000, 0); - - const reverbDelay2 = ctx.createDelay(2.0); - reverbDelay2.delayTime.setValueAtTime(0.7, 0); - - const reverbGain2 = ctx.createGain(); - reverbGain2.gain.setValueAtTime(0.2, 0); - - const reverbDelay3 = ctx.createDelay(2.0); - reverbDelay3.delayTime.setValueAtTime(1.1, 0); - - const reverbGain3 = ctx.createGain(); - reverbGain3.gain.setValueAtTime(0.12, 0); - - const compressor = ctx.createDynamicsCompressor(); - compressor.threshold.setValueAtTime(-24, 0); - compressor.knee.setValueAtTime(28, 0); - compressor.ratio.setValueAtTime(2, 0); - compressor.attack.setValueAtTime(0.05, 0); - compressor.release.setValueAtTime(0.5, 0); - - masterGain.connect(cleanFilter); - cleanFilter.connect(warmFilter); - warmFilter.connect(compressor); - - warmFilter.connect(chorusDelay); - chorusDelay.connect(chorusGain); - chorusGain.connect(compressor); - - warmFilter.connect(reverbDelay1); - reverbDelay1.connect(reverbFilter); - reverbFilter.connect(reverbGain1); - reverbGain1.connect(compressor); - - reverbFilter.connect(reverbDelay2); - reverbDelay2.connect(reverbGain2); - reverbGain2.connect(compressor); - - reverbDelay2.connect(reverbDelay3); - reverbDelay3.connect(reverbGain3); - reverbGain3.connect(compressor); - - compressor.connect(ctx.destination); - - const chordDuration = 8 * BEAT; - let padTime = 0.1; - - schedulePadChord(ctx, masterGain, CHORD_PADS.Am7, padTime, chordDuration); - padTime += chordDuration; - schedulePadChord(ctx, masterGain, CHORD_PADS.Cmaj7, padTime, chordDuration); - padTime += chordDuration; - schedulePadChord(ctx, masterGain, CHORD_PADS.Am7, padTime, chordDuration); - padTime += chordDuration; - schedulePadChord(ctx, masterGain, CHORD_PADS.Cmaj7, padTime, chordDuration); - - for (const note of FULL_MELODY) { - const startTime = 0.1 + (note.beat - 1) * BEAT; - scheduleNote(ctx, masterGain, note.frequency, startTime, note.duration); - } - - const buffer = await ctx.startRendering(); - cachedBuffer = buffer; - isRendering = false; - return buffer; - })(); - - return renderPromise; -} - -export interface UseStickerbushAudioReturn { - play: () => Promise; - stop: () => void; - isPlaying: boolean; - isLoading: boolean; - isMuted: boolean; - toggleMute: () => void; - getFrequencyData: () => Uint8Array; - getTimeDomainData: () => Uint8Array; - getBeatIntensity: () => number; - getCurrentSection: () => number; -} - -export function useStickerbushAudio(): UseStickerbushAudioReturn { - const beatIntensityRef = useRef(0); - const currentSectionRef = useRef(0); - const beatDecayIntervalRef = useRef | null>(null); - const sectionIntervalRef = useRef | null>(null); - - const startTracking = useCallback( - (audioContextRef: { current: AudioContext | null }, startTimeRef: { current: number }) => { - beatDecayIntervalRef.current = setInterval(() => { - beatIntensityRef.current = Math.max(0, beatIntensityRef.current - 0.015); - }, 16); - - sectionIntervalRef.current = setInterval(() => { - if (!audioContextRef.current) return; - const elapsed = audioContextRef.current.currentTime - startTimeRef.current; - const currentBeat = elapsed / BEAT; - - const section = Math.min(3, Math.floor(currentBeat / 8)); - currentSectionRef.current = section; - - const beatFraction = currentBeat % 1; - if (beatFraction < 0.1) { - beatIntensityRef.current = 0.75; - } - }, 50); - }, - [] - ); - - const stopTracking = useCallback(() => { - if (beatDecayIntervalRef.current) { - clearInterval(beatDecayIntervalRef.current); - beatDecayIntervalRef.current = null; - } - if (sectionIntervalRef.current) { - clearInterval(sectionIntervalRef.current); - sectionIntervalRef.current = null; - } - beatIntensityRef.current = 0; - currentSectionRef.current = 0; - }, []); - - const engine = useAudioEngine(renderAudio, { - loop: false, - fftSize: 256, - smoothingTimeConstant: 0.93, - onPlay: () => startTracking(engine.refs.audioContextRef, engine.refs.startTimeRef), - onStop: stopTracking - }); - - const getBeatIntensity = useCallback((): number => { - return beatIntensityRef.current; - }, []); - - const getCurrentSection = useCallback((): number => { - return currentSectionRef.current; - }, []); - - return { - play: engine.play, - stop: engine.stop, - isPlaying: engine.isPlaying, - isLoading: engine.isLoading, - isMuted: engine.isMuted, - toggleMute: engine.toggleMute, - getFrequencyData: engine.getFrequencyData, - getTimeDomainData: engine.getTimeDomainData, - getBeatIntensity, - getCurrentSection - }; -} diff --git a/src/reactTopoViewer/webview/easter-eggs/audio/useVaporwaveAudio.ts b/src/reactTopoViewer/webview/easter-eggs/audio/useVaporwaveAudio.ts deleted file mode 100644 index b319d8784..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/audio/useVaporwaveAudio.ts +++ /dev/null @@ -1,351 +0,0 @@ -/** - * Vaporwave Audio Hook - * - * Generates that classic vaporwave aesthetic sound. - * Inspired by the vaporwave genre aesthetic. - * - * Uses pre-rendered audio for smooth playback on slow hardware. - * - * Key: B minor - * Tempo: ~70 BPM (slow and dreamy) - */ - -import { useCallback } from "react"; - -import { getBMinorFrequency, useAudioEngine, type MelodyNote } from "./core"; - -/** ~70 BPM for that slowed down vaporwave feel */ -const BEAT = 0.857; // seconds per beat -const LOOP_DURATION = 33 * BEAT; // 33 beats per loop -const SAMPLE_RATE = 44100; - -function buildMelody(): MelodyNote[] { - const rawNotes = [ - { sd: 1, octave: 0, beat: 1, duration: 1.5 }, - { sd: 5, octave: 0, beat: 2.5, duration: 2 }, - { sd: 5, octave: 0, beat: 4.5, duration: 0.5 }, - { sd: 4, octave: 0, beat: 5, duration: 0.5 }, - { sd: 3, octave: 0, beat: 5.5, duration: 0.5 }, - { sd: 2, octave: 0, beat: 6, duration: 0.5 }, - { sd: 2, octave: 0, beat: 6.5, duration: 1.5 }, - { sd: 3, octave: 0, beat: 8, duration: 0.5 }, - { sd: 1, octave: 0, beat: 8.5, duration: 3.5 }, - { sd: 1, octave: 1, beat: 12, duration: 0.5 }, - { sd: 7, octave: 0, beat: 12.5, duration: 3.5 }, - { sd: 1, octave: 0, beat: 16, duration: 0.5 }, - { sd: 3, octave: 0, beat: 16.5, duration: 0.5 }, - { sd: 1, octave: 0, beat: 17, duration: 1.5 }, - { sd: 5, octave: 0, beat: 18.5, duration: 2 }, - { sd: 5, octave: 0, beat: 20.5, duration: 0.5 }, - { sd: 4, octave: 0, beat: 21, duration: 0.5 }, - { sd: 3, octave: 0, beat: 21.5, duration: 0.5 }, - { sd: 2, octave: 0, beat: 22, duration: 0.5 }, - { sd: 2, octave: 0, beat: 22.5, duration: 1.5 }, - { sd: 1, octave: 0, beat: 24, duration: 0.5 }, - { sd: 5, octave: 0, beat: 24.5, duration: 4.5 }, - { sd: 7, octave: -3, beat: 29, duration: 0.5 }, - { sd: 3, octave: -1, beat: 29.5, duration: 0.5 }, - { sd: 3, octave: -1, beat: 30, duration: 0.5 }, - { sd: 7, octave: -3, beat: 30.5, duration: 0.5 }, - { sd: 4, octave: -1, beat: 31, duration: 0.5 }, - { sd: 4, octave: -1, beat: 31.5, duration: 0.5 }, - { sd: 7, octave: -3, beat: 32, duration: 0.5 }, - { sd: 3, octave: 0, beat: 32.5, duration: 0.5 } - ]; - - return rawNotes.map((note) => ({ - frequency: getBMinorFrequency(note.sd, note.octave), - beat: note.beat, - duration: note.duration - })); -} - -const FULL_MELODY = buildMelody(); - -const CHORD_PADS = { - Em7: [ - getBMinorFrequency(4, -1), - getBMinorFrequency(6, -1), - getBMinorFrequency(1, 0), - getBMinorFrequency(3, 0) - ], - Bm: [ - getBMinorFrequency(1, -1), - getBMinorFrequency(3, -1), - getBMinorFrequency(5, -1), - getBMinorFrequency(1, 0) - ], - Em: [ - getBMinorFrequency(4, -1), - getBMinorFrequency(6, -1), - getBMinorFrequency(1, 0), - getBMinorFrequency(4, 0) - ], - Csm7: [ - getBMinorFrequency(2, -1), - getBMinorFrequency(4, -1), - getBMinorFrequency(6, -1), - getBMinorFrequency(1, 0) - ], - A: [ - getBMinorFrequency(7, -2), - getBMinorFrequency(2, -1), - getBMinorFrequency(4, -1), - getBMinorFrequency(7, -1) - ] -}; - -type VaporwaveSection = "em7" | "bm" | "em" | "csm7" | "a"; - -// Module-level cache for pre-rendered audio buffer -let cachedBuffer: AudioBuffer | null = null; -let isRendering = false; -let renderPromise: Promise | null = null; - -function createNoteOffline( - ctx: OfflineAudioContext, - frequency: number, - startTime: number, - duration: number, - destination: AudioNode, - volume: number = 0.15 -): void { - const mainOsc = ctx.createOscillator(); - mainOsc.type = "sine"; - mainOsc.frequency.value = frequency; - - const subOsc = ctx.createOscillator(); - subOsc.type = "sine"; - subOsc.frequency.value = frequency / 2; - - const bodyOsc = ctx.createOscillator(); - bodyOsc.type = "triangle"; - bodyOsc.frequency.value = frequency; - - const mainGain = ctx.createGain(); - const subGain = ctx.createGain(); - const bodyGain = ctx.createGain(); - - const lfo = ctx.createOscillator(); - lfo.type = "sine"; - lfo.frequency.value = 3; - const lfoGain = ctx.createGain(); - lfoGain.gain.value = 2; - - lfo.connect(lfoGain); - lfoGain.connect(mainOsc.frequency); - lfoGain.connect(bodyOsc.frequency); - - const filter = ctx.createBiquadFilter(); - filter.type = "lowpass"; - filter.frequency.value = 1500; - filter.Q.value = 0.7; - - mainOsc.connect(mainGain); - subOsc.connect(subGain); - bodyOsc.connect(bodyGain); - - mainGain.connect(filter); - subGain.connect(filter); - bodyGain.connect(filter); - filter.connect(destination); - - const attackTime = 0.15; - const releaseTime = 0.8; - - mainGain.gain.setValueAtTime(0, startTime); - mainGain.gain.linearRampToValueAtTime(volume, startTime + attackTime); - mainGain.gain.setValueAtTime(volume, startTime + duration - releaseTime); - mainGain.gain.linearRampToValueAtTime(0, startTime + duration); - - subGain.gain.setValueAtTime(0, startTime); - subGain.gain.linearRampToValueAtTime(volume * 0.4, startTime + attackTime); - subGain.gain.setValueAtTime(volume * 0.4, startTime + duration - releaseTime); - subGain.gain.linearRampToValueAtTime(0, startTime + duration); - - bodyGain.gain.setValueAtTime(0, startTime); - bodyGain.gain.linearRampToValueAtTime(volume * 0.25, startTime + attackTime); - bodyGain.gain.setValueAtTime(volume * 0.25, startTime + duration - releaseTime); - bodyGain.gain.linearRampToValueAtTime(0, startTime + duration); - - const stopTime = startTime + duration + 0.1; - mainOsc.start(startTime); - mainOsc.stop(stopTime); - subOsc.start(startTime); - subOsc.stop(stopTime); - bodyOsc.start(startTime); - bodyOsc.stop(stopTime); - lfo.start(startTime); - lfo.stop(stopTime); -} - -function createPadChordOffline( - ctx: OfflineAudioContext, - frequencies: number[], - startTime: number, - duration: number, - destination: AudioNode -): void { - frequencies.forEach((freq, i) => { - const stagger = i * 0.08; - createNoteOffline(ctx, freq, startTime + stagger, duration, destination, 0.04); - }); -} - -async function renderLoop(): Promise { - if (cachedBuffer) return cachedBuffer; - if (isRendering && renderPromise) return renderPromise; - - isRendering = true; - - renderPromise = (async () => { - const totalDuration = LOOP_DURATION + 2; - const ctx = new OfflineAudioContext(2, totalDuration * SAMPLE_RATE, SAMPLE_RATE); - - const masterGain = ctx.createGain(); - masterGain.gain.value = 0.7; - - const compressor = ctx.createDynamicsCompressor(); - compressor.threshold.value = -20; - compressor.knee.value = 20; - compressor.ratio.value = 4; - compressor.attack.value = 0.01; - compressor.release.value = 0.3; - - const chorusDelay = ctx.createDelay(0.05); - chorusDelay.delayTime.value = 0.015; - - const chorusLfo = ctx.createOscillator(); - chorusLfo.type = "sine"; - chorusLfo.frequency.value = 0.3; - const chorusDepth = ctx.createGain(); - chorusDepth.gain.value = 0.004; - chorusLfo.connect(chorusDepth); - chorusDepth.connect(chorusDelay.delayTime); - chorusLfo.start(0); - chorusLfo.stop(totalDuration); - - const chorusGain = ctx.createGain(); - chorusGain.gain.value = 0.5; - - const reverb1 = ctx.createDelay(0.5); - reverb1.delayTime.value = 0.15; - const reverb1Gain = ctx.createGain(); - reverb1Gain.gain.value = 0.5; - - const reverb2 = ctx.createDelay(0.5); - reverb2.delayTime.value = 0.25; - const reverb2Gain = ctx.createGain(); - reverb2Gain.gain.value = 0.4; - - const reverb3 = ctx.createDelay(0.5); - reverb3.delayTime.value = 0.4; - const reverb3Gain = ctx.createGain(); - reverb3Gain.gain.value = 0.35; - - const reverb4 = ctx.createDelay(0.6); - reverb4.delayTime.value = 0.55; - const reverb4Gain = ctx.createGain(); - reverb4Gain.gain.value = 0.25; - - const outputMixer = ctx.createGain(); - outputMixer.gain.value = 1.0; - - masterGain.connect(compressor); - compressor.connect(outputMixer); - - compressor.connect(chorusDelay); - chorusDelay.connect(chorusGain); - chorusGain.connect(outputMixer); - - compressor.connect(reverb1); - reverb1.connect(reverb1Gain); - reverb1Gain.connect(outputMixer); - reverb1Gain.connect(reverb2); - - reverb2.connect(reverb2Gain); - reverb2Gain.connect(outputMixer); - reverb2Gain.connect(reverb3); - - reverb3.connect(reverb3Gain); - reverb3Gain.connect(outputMixer); - reverb3Gain.connect(reverb4); - - reverb4.connect(reverb4Gain); - reverb4Gain.connect(outputMixer); - - outputMixer.connect(ctx.destination); - - const startTime = 0; - FULL_MELODY.forEach((note) => { - const noteStart = startTime + (note.beat - 1) * BEAT; - createNoteOffline(ctx, note.frequency, noteStart, note.duration * BEAT, masterGain, 0.18); - }); - - createPadChordOffline(ctx, CHORD_PADS.Em7, startTime, 4 * BEAT, masterGain); - createPadChordOffline(ctx, CHORD_PADS.Bm, startTime + 4 * BEAT, 4 * BEAT, masterGain); - createPadChordOffline(ctx, CHORD_PADS.Em, startTime + 8 * BEAT, 4 * BEAT, masterGain); - createPadChordOffline(ctx, CHORD_PADS.Bm, startTime + 12 * BEAT, 4 * BEAT, masterGain); - createPadChordOffline(ctx, CHORD_PADS.Em, startTime + 16 * BEAT, 4 * BEAT, masterGain); - createPadChordOffline(ctx, CHORD_PADS.Bm, startTime + 20 * BEAT, 4 * BEAT, masterGain); - createPadChordOffline(ctx, CHORD_PADS.Csm7, startTime + 24 * BEAT, 4 * BEAT, masterGain); - createPadChordOffline(ctx, CHORD_PADS.A, startTime + 28 * BEAT, 5 * BEAT, masterGain); - - const buffer = await ctx.startRendering(); - cachedBuffer = buffer; - isRendering = false; - return buffer; - })(); - - return renderPromise; -} - -export interface UseVaporwaveAudioReturn { - play: () => Promise; - stop: () => void; - isPlaying: boolean; - isLoading: boolean; - isMuted: boolean; - toggleMute: () => void; - getFrequencyData: () => Uint8Array; - getTimeDomainData: () => Uint8Array; - getCurrentSection: () => VaporwaveSection; -} - -export function useVaporwaveAudio(): UseVaporwaveAudioReturn { - const engine = useAudioEngine(renderLoop, { - loop: true, - loopEnd: LOOP_DURATION, - fftSize: 256 - }); - - const getCurrentSection = useCallback((): VaporwaveSection => { - const { audioContextRef, startTimeRef } = engine.refs; - if (!audioContextRef.current || !engine.isPlaying) return "em7"; - - const elapsed = audioContextRef.current.currentTime - startTimeRef.current; - const positionInLoop = elapsed % LOOP_DURATION; - const currentBeat = positionInLoop / BEAT; - - if (currentBeat < 4) return "em7"; - if (currentBeat < 8) return "bm"; - if (currentBeat < 12) return "em"; - if (currentBeat < 16) return "bm"; - if (currentBeat < 20) return "em"; - if (currentBeat < 24) return "bm"; - if (currentBeat < 28) return "csm7"; - return "a"; - }, [engine.isPlaying, engine.refs]); - - return { - play: engine.play, - stop: engine.stop, - isPlaying: engine.isPlaying, - isLoading: engine.isLoading, - isMuted: engine.isMuted, - toggleMute: engine.toggleMute, - getFrequencyData: engine.getFrequencyData, - getTimeDomainData: engine.getTimeDomainData, - getCurrentSection - }; -} diff --git a/src/reactTopoViewer/webview/easter-eggs/index.ts b/src/reactTopoViewer/webview/easter-eggs/index.ts deleted file mode 100644 index fec18fc10..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Easter Eggs - Hidden visual modes - * - * Click the Containerlab logo 10 times to trigger one of five easter eggs: - * - Nightcall: 80s synthwave vibe (Kavinsky inspired) - * - Stickerbrush Symphony: Dreamy forest ambient (DKC2 inspired) - * - Aquatic Ambience: Underwater serenity (DKC inspired) - * - Vaporwave: Slowed down smooth jazz aesthetic - * - Deus Ex: 3D rotating logo with metallic theme (silent mode) - */ - -// Main easter egg hook -export { useEasterEgg } from "./useEasterEgg"; -export type { - EasterEggMode, - EasterEggState, - UseEasterEggOptions, - UseEasterEggReturn -} from "./useEasterEgg"; - -// Renderer component -export { EasterEggRenderer } from "./EasterEggRenderer"; - -// Mode components (for direct use if needed) -export { - NightcallMode, - StickerbushMode, - AquaticAmbienceMode, - VaporwaveMode, - DeusExMode -} from "./modes"; diff --git a/src/reactTopoViewer/webview/easter-eggs/modes/AquaticAmbienceMode.tsx b/src/reactTopoViewer/webview/easter-eggs/modes/AquaticAmbienceMode.tsx deleted file mode 100644 index 79ef5f38d..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/modes/AquaticAmbienceMode.tsx +++ /dev/null @@ -1,472 +0,0 @@ -/** - * Aquatic Ambience Mode Component - * - * Underwater visualization inspired by DKC's aquatic levels. - * Deep blues, teals, floating bubbles, and soft light rays. - */ - -import React, { useCallback, useEffect, useRef, useState } from "react"; -import Box from "@mui/material/Box"; - -import { useAquaticAmbienceAudio } from "../audio"; -import { BTN_VISIBLE_SX, BTN_HIDDEN_SX, BTN_BLUR, useNodeGlow, MuteButton } from "../shared"; -import type { RGBColor, BaseModeProps } from "../shared"; - -/** Underwater color palette */ -const COLORS = { - deepBlue: { r: 10, g: 30, b: 80 }, - oceanBlue: { r: 30, g: 80, b: 140 }, - teal: { r: 0, g: 128, b: 128 }, - aqua: { r: 0, g: 180, b: 200 }, - lightBlue: { r: 135, g: 206, b: 235 }, - white: { r: 255, g: 255, b: 255 } -}; - -/** Section to color mapping */ -const SECTION_COLORS: RGBColor[] = [ - COLORS.teal, - COLORS.oceanBlue, - COLORS.teal, - COLORS.oceanBlue, - COLORS.aqua, - COLORS.lightBlue -]; - -function getSectionColor(section: number): RGBColor { - return SECTION_COLORS[section % SECTION_COLORS.length]; -} - -interface Bubble { - x: number; - y: number; - size: number; - speed: number; - wobblePhase: number; - wobbleSpeed: number; - alpha: number; -} - -const bubbles: Bubble[] = []; - -function initializeBubbles(width: number, height: number): void { - if (bubbles.length > 0) return; - - for (let i = 0; i < 35; i++) { - /* eslint-disable sonarjs/pseudo-random */ - bubbles.push({ - x: Math.random() * width, - y: height + Math.random() * height, - size: 3 + Math.random() * 8, - speed: 0.3 + Math.random() * 0.5, - wobblePhase: Math.random() * Math.PI * 2, - wobbleSpeed: 0.01 + Math.random() * 0.02, - alpha: 0.3 + Math.random() * 0.4 - }); - /* eslint-enable sonarjs/pseudo-random */ - } -} - -const AquaticCanvas: React.FC<{ - isActive: boolean; - getFrequencyData: () => Uint8Array; - getBeatIntensity: () => number; - getCurrentSection: () => number; -}> = ({ isActive, getFrequencyData, getBeatIntensity, getCurrentSection }) => { - const canvasRef = useRef(null); - const animationRef = useRef(0); - const timeRef = useRef(0); - - useEffect(() => { - if (!isActive) return undefined; - - const canvas = canvasRef.current; - if (!canvas) return undefined; - - const ctx = canvas.getContext("2d"); - if (!ctx) return undefined; - - const updateSize = (): void => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - }; - updateSize(); - window.addEventListener("resize", updateSize); - - timeRef.current = 0; - initializeBubbles(canvas.width, canvas.height); - - const animate = (): void => { - const width = canvas.width; - const height = canvas.height; - timeRef.current += 1; - const time = timeRef.current; - - const freqData = getFrequencyData(); - const beatIntensity = getBeatIntensity(); - const currentSection = getCurrentSection(); - - ctx.clearRect(0, 0, width, height); - - drawUnderwaterGlow(ctx, width, height, beatIntensity, time, currentSection); - drawCausticRays(ctx, width, height, time, beatIntensity); - drawWaterSurface(ctx, width, time); - drawWaveVisualizer(ctx, width, height, freqData, currentSection); - drawBubbles(ctx, width, height, time, beatIntensity); - - animationRef.current = window.requestAnimationFrame(animate); - }; - - animationRef.current = window.requestAnimationFrame(animate); - - return () => { - window.removeEventListener("resize", updateSize); - window.cancelAnimationFrame(animationRef.current); - }; - }, [isActive, getFrequencyData, getBeatIntensity, getCurrentSection]); - - if (!isActive) return null; - - return ( - - ); -}; - -function drawUnderwaterGlow( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - intensity: number, - time: number, - section: number -): void { - const color = getSectionColor(section); - const gradient = ctx.createLinearGradient(0, 0, 0, height); - - const alpha = 0.06 + intensity * 0.04; - const pulse = Math.sin(time * 0.008) * 0.02; - - gradient.addColorStop( - 0, - `rgba(${COLORS.lightBlue.r}, ${COLORS.lightBlue.g}, ${COLORS.lightBlue.b}, ${alpha + pulse})` - ); - gradient.addColorStop(0.3, `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`); - gradient.addColorStop( - 0.7, - `rgba(${COLORS.oceanBlue.r}, ${COLORS.oceanBlue.g}, ${COLORS.oceanBlue.b}, ${alpha * 0.8})` - ); - gradient.addColorStop( - 1, - `rgba(${COLORS.deepBlue.r}, ${COLORS.deepBlue.g}, ${COLORS.deepBlue.b}, ${alpha * 0.6})` - ); - - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, width, height); -} - -function drawCausticRays( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - time: number, - intensity: number -): void { - ctx.save(); - ctx.globalAlpha = 0.04 + intensity * 0.02; - - const rayCount = 7; - const baseWidth = width / rayCount; - - for (let i = 0; i < rayCount; i++) { - const offset = Math.sin(time * 0.003 + i * 2.0) * 60; - const x = (i + 0.5) * baseWidth + offset; - const pulseWidth = 20 + Math.sin(time * 0.005 + i) * 10; - - const gradient = ctx.createLinearGradient(x, 0, x, height * 0.8); - gradient.addColorStop(0, `rgba(${COLORS.white.r}, ${COLORS.white.g}, ${COLORS.white.b}, 0.6)`); - gradient.addColorStop( - 0.3, - `rgba(${COLORS.lightBlue.r}, ${COLORS.lightBlue.g}, ${COLORS.lightBlue.b}, 0.3)` - ); - gradient.addColorStop(0.7, `rgba(${COLORS.aqua.r}, ${COLORS.aqua.g}, ${COLORS.aqua.b}, 0.1)`); - gradient.addColorStop(1, "transparent"); - - ctx.beginPath(); - ctx.moveTo(x - pulseWidth, 0); - ctx.lineTo(x + pulseWidth, 0); - ctx.lineTo(x + pulseWidth * 2, height * 0.8); - ctx.lineTo(x - pulseWidth * 2, height * 0.8); - ctx.closePath(); - - ctx.fillStyle = gradient; - ctx.fill(); - } - - ctx.restore(); -} - -function drawWaterSurface(ctx: CanvasRenderingContext2D, width: number, time: number): void { - ctx.save(); - ctx.globalAlpha = 0.08; - - const gradient = ctx.createLinearGradient(0, 0, 0, 50); - gradient.addColorStop( - 0, - `rgba(${COLORS.lightBlue.r}, ${COLORS.lightBlue.g}, ${COLORS.lightBlue.b}, 1)` - ); - gradient.addColorStop(1, "transparent"); - - ctx.fillStyle = gradient; - - ctx.beginPath(); - ctx.moveTo(0, 0); - - for (let x = 0; x <= width; x += 20) { - const y = Math.sin(x * 0.02 + time * 0.02) * 5 + Math.sin(x * 0.01 + time * 0.015) * 3; - ctx.lineTo(x, y + 30); - } - - ctx.lineTo(width, 0); - ctx.closePath(); - ctx.fill(); - - ctx.restore(); -} - -function drawWaveVisualizer( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - freqData: Uint8Array, - section: number -): void { - const barCount = Math.min(24, freqData.length); - const totalWidth = width * 0.7; - const segmentWidth = totalWidth / barCount; - const startX = (width - totalWidth) / 2; - const baseY = height - 35; - - const color = getSectionColor(section); - - ctx.save(); - ctx.beginPath(); - ctx.moveTo(startX, baseY); - - for (let i = 0; i <= barCount; i++) { - const amplitude = (freqData[Math.min(i, barCount - 1)] || 0) / 255; - const waveHeight = amplitude * 35; - const x = startX + i * segmentWidth; - const y = baseY - waveHeight; - - if (i === 0) { - ctx.lineTo(x, y); - } else { - const prevX = startX + (i - 1) * segmentWidth; - const cpX = (prevX + x) / 2; - ctx.quadraticCurveTo( - prevX, - baseY - ((freqData[i - 1] || 0) / 255) * 35, - cpX, - (baseY - ((freqData[i - 1] || 0) / 255) * 35 + y) / 2 - ); - } - } - - ctx.lineTo(startX + totalWidth, baseY); - ctx.closePath(); - - const waveGradient = ctx.createLinearGradient(0, baseY - 40, 0, baseY); - waveGradient.addColorStop(0, `rgba(${color.r}, ${color.g}, ${color.b}, 0.5)`); - waveGradient.addColorStop(1, `rgba(${color.r}, ${color.g}, ${color.b}, 0.2)`); - - ctx.fillStyle = waveGradient; - ctx.fill(); - - ctx.restore(); -} - -function drawBubbles( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - time: number, - intensity: number -): void { - for (const b of bubbles) { - b.y -= b.speed + intensity * 0.2; - b.x += Math.sin(time * b.wobbleSpeed + b.wobblePhase) * 0.5; - - if (b.y < -b.size * 2) { - b.y = height + b.size * 2; - // eslint-disable-next-line sonarjs/pseudo-random - b.x = Math.random() * width; - } - - if (b.x < -b.size) b.x = width + b.size; - if (b.x > width + b.size) b.x = -b.size; - - const gradient = ctx.createRadialGradient( - b.x - b.size * 0.3, - b.y - b.size * 0.3, - 0, - b.x, - b.y, - b.size - ); - - const alpha = b.alpha + intensity * 0.15; - gradient.addColorStop(0, `rgba(255, 255, 255, ${alpha * 0.8})`); - gradient.addColorStop( - 0.5, - `rgba(${COLORS.lightBlue.r}, ${COLORS.lightBlue.g}, ${COLORS.lightBlue.b}, ${alpha * 0.4})` - ); - gradient.addColorStop( - 1, - `rgba(${COLORS.aqua.r}, ${COLORS.aqua.g}, ${COLORS.aqua.b}, ${alpha * 0.1})` - ); - - ctx.beginPath(); - ctx.arc(b.x, b.y, b.size, 0, Math.PI * 2); - ctx.fillStyle = gradient; - ctx.fill(); - - ctx.beginPath(); - ctx.arc(b.x - b.size * 0.3, b.y - b.size * 0.3, b.size * 0.2, 0, Math.PI * 2); - ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.6})`; - ctx.fill(); - } -} - -export const AquaticAmbienceMode: React.FC = ({ - isActive, - onClose, - onSwitchMode, - modeName -}) => { - const [visible, setVisible] = useState(false); - const audio = useAquaticAmbienceAudio(); - - const getColor = useCallback((): RGBColor => { - return getSectionColor(audio.getCurrentSection()); - }, [audio]); - - const getIntensity = useCallback((): number => { - return audio.getBeatIntensity() * 0.4 + 0.2; - }, [audio]); - - useNodeGlow(isActive, getColor, getIntensity); - - useEffect(() => { - if (isActive && !audio.isPlaying && !audio.isLoading) { - void audio.play(); - setVisible(true); - } else if (!isActive && audio.isPlaying) { - audio.stop(); - setVisible(false); - } - }, [isActive, audio]); - - const handleClose = (): void => { - audio.stop(); - onClose?.(); - }; - - const handleSwitch = (): void => { - audio.stop(); - onSwitchMode?.(); - }; - - if (!isActive) return null; - - return ( - <> - - - - - Switch - - - - Surface - - - - ); -}; diff --git a/src/reactTopoViewer/webview/easter-eggs/modes/DeusExMode.tsx b/src/reactTopoViewer/webview/easter-eggs/modes/DeusExMode.tsx deleted file mode 100644 index 288912596..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/modes/DeusExMode.tsx +++ /dev/null @@ -1,338 +0,0 @@ -/** - * Deus Ex Mode Component - * - * Silent easter egg with 3D rotating containerlab logo. - * Inspired by the iconic Deus Ex main menu logo animation. - */ - -import React, { useCallback, useEffect, useRef, useState } from "react"; -import Box from "@mui/material/Box"; - -import { lerpColor, useNodeGlow } from "../shared"; -import type { RGBColor, BaseModeProps } from "../shared"; - -/** Deus Ex color palette with neon accents */ -const COLORS: Record = { - silver: { r: 192, g: 192, b: 192 }, - chrome: { r: 220, g: 220, b: 225 }, - steel: { r: 113, g: 121, b: 126 }, - dark: { r: 15, g: 18, b: 22 }, - highlight: { r: 255, g: 255, b: 255 }, - cyan: { r: 0, g: 255, b: 255 }, - magenta: { r: 255, g: 0, b: 255 } -}; - -/** Containerlab SVG content with original blue water/bubbles */ -const CONTAINERLAB_SVG_CONTENT = ` - - - - - - -`; - -const CONTAINERLAB_SVG = "data:image/svg+xml," + encodeURIComponent(CONTAINERLAB_SVG_CONTENT); - -/** - * Deus Ex Canvas - 3D rotating logo - */ -const DeusExCanvas: React.FC<{ - isActive: boolean; - getRotationAngle: () => number; -}> = ({ isActive, getRotationAngle }) => { - const canvasRef = useRef(null); - const animationRef = useRef(0); - const logoRef = useRef(null); - - useEffect(() => { - if (!isActive) return undefined; - - const canvas = canvasRef.current; - if (!canvas) return undefined; - - const ctx = canvas.getContext("2d"); - if (!ctx) return undefined; - - // Load logo image - const logo = new window.Image(); - logo.src = CONTAINERLAB_SVG; - logoRef.current = logo; - - const dpr = window.devicePixelRatio || 1; - - const updateSize = (): void => { - canvas.width = window.innerWidth * dpr; - canvas.height = window.innerHeight * dpr; - canvas.style.width = `${window.innerWidth}px`; - canvas.style.height = `${window.innerHeight}px`; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - }; - updateSize(); - window.addEventListener("resize", updateSize); - - const animate = (): void => { - const width = window.innerWidth; - const height = window.innerHeight; - const rotationAngle = getRotationAngle(); - - ctx.clearRect(0, 0, canvas.width, canvas.height); - - const colorT = (Math.sin(rotationAngle * 0.5) + 1) / 2; - const glowColor = lerpColor(COLORS.cyan, COLORS.magenta, colorT); - - drawRotatingLogo(ctx, width, height, rotationAngle, logoRef.current); - drawLogoGlow(ctx, width, height, rotationAngle, glowColor); - - animationRef.current = window.requestAnimationFrame(animate); - }; - - logo.onload = () => { - animationRef.current = window.requestAnimationFrame(animate); - }; - - logo.onerror = () => { - animationRef.current = window.requestAnimationFrame(animate); - }; - - return () => { - window.removeEventListener("resize", updateSize); - window.cancelAnimationFrame(animationRef.current); - }; - }, [isActive, getRotationAngle]); - - if (!isActive) return null; - - return ( - - ); -}; - -function drawRotatingLogo( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - angle: number, - logo: HTMLImageElement | null -): void { - if (!logo || !logo.complete) return; - - const centerX = width / 2; - const centerY = height * 0.35; - - const baseSize = Math.min(width, height) * 0.22; - const aspectRatio = 81.8 / 87.413; - const logoWidth = baseSize * aspectRatio; - const logoHeight = baseSize; - - const scaleX = Math.cos(angle); - const absScaleX = Math.abs(scaleX); - const sinAngle = Math.sin(angle); - - const extrusionDepth = 15; - const numLayers = 15; - - ctx.save(); - ctx.translate(centerX, centerY); - - const isBackFace = scaleX < 0; - - for (let i = numLayers - 1; i >= 0; i--) { - const t = i / numLayers; - const xOffset = sinAngle * extrusionDepth * t; - - ctx.save(); - ctx.translate(xOffset, 0); - ctx.scale(scaleX, 1); - - const brightness = 0.3 + (1 - t) * 0.7; - const alpha = (0.2 + (1 - t) * 0.2) * absScaleX + 0.05; - - ctx.globalAlpha = alpha; - - if (isBackFace) { - ctx.filter = `brightness(${brightness * 0.6})`; - } else { - ctx.filter = `brightness(${brightness})`; - } - - ctx.drawImage(logo, -logoWidth / 2, -logoHeight / 2, logoWidth, logoHeight); - ctx.restore(); - } - - ctx.restore(); -} - -function drawLogoGlow( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - angle: number, - color: RGBColor -): void { - const centerX = width / 2; - const centerY = height * 0.35; - const glowSize = Math.min(width, height) * 0.25; - - const intensity = 0.15 + Math.abs(Math.sin(angle)) * 0.1; - - const gradient = ctx.createRadialGradient( - centerX, - centerY, - glowSize * 0.2, - centerX, - centerY, - glowSize * 1.2 - ); - - gradient.addColorStop(0, `rgba(${color.r}, ${color.g}, ${color.b}, ${intensity})`); - gradient.addColorStop(0.4, `rgba(${color.r}, ${color.g}, ${color.b}, ${intensity * 0.5})`); - gradient.addColorStop(1, "transparent"); - - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, width, height); -} - -export const DeusExMode: React.FC = ({ - isActive, - onClose, - onSwitchMode, - modeName -}) => { - const [visible, setVisible] = useState(false); - const timeRef = useRef(0); - const animationRef = useRef(0); - - const getRotationAngle = useCallback((): number => { - return timeRef.current * 0.5; - }, []); - - const getColor = useCallback((): RGBColor => { - const angle = getRotationAngle(); - const colorT = (Math.sin(angle * 0.5) + 1) / 2; - return lerpColor(COLORS.cyan, COLORS.magenta, colorT); - }, [getRotationAngle]); - - const getIntensity = useCallback((): number => { - const angle = getRotationAngle(); - return 0.3 + Math.abs(Math.sin(angle)) * 0.5; - }, [getRotationAngle]); - - useNodeGlow(isActive, getColor, getIntensity); - - useEffect(() => { - if (isActive) { - setVisible(true); - timeRef.current = 0; - - const animate = (): void => { - timeRef.current += 0.016; - animationRef.current = window.requestAnimationFrame(animate); - }; - - animationRef.current = window.requestAnimationFrame(animate); - - return () => { - window.cancelAnimationFrame(animationRef.current); - }; - } else { - setVisible(false); - return undefined; - } - }, [isActive]); - - const handleClose = (): void => { - onClose?.(); - }; - - const handleSwitch = (): void => { - onSwitchMode?.(); - }; - - if (!isActive) return null; - - return ( - <> - - - - - Switch - - - Shutdown - - - - ); -}; diff --git a/src/reactTopoViewer/webview/easter-eggs/modes/NightcallMode.tsx b/src/reactTopoViewer/webview/easter-eggs/modes/NightcallMode.tsx deleted file mode 100644 index 46925b3a7..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/modes/NightcallMode.tsx +++ /dev/null @@ -1,508 +0,0 @@ -/** - * Nightcall Mode Component - * - * Retro 80s synthwave visualization with smooth purple gradients. - * Dreamy, non-stressed aesthetic while keeping topology visible. - */ - -import React, { useCallback, useEffect, useRef, useState } from "react"; -import Box from "@mui/material/Box"; - -import { useNightcallAudio } from "../audio"; -import { - BTN_VISIBLE_SX, - BTN_HIDDEN_SX, - BTN_BLUR, - lerpColor, - useNodeGlow, - MuteButton -} from "../shared"; -import type { RGBColor, BaseModeProps } from "../shared"; - -/** Retro synthwave color palette */ -const COLORS = { - purple: { r: 138, g: 43, b: 226 }, // Blue violet - magenta: { r: 255, g: 0, b: 128 }, // Hot pink - cyan: { r: 0, g: 255, b: 255 }, // Cyan - darkPurple: { r: 48, g: 25, b: 88 }, // Dark purple - pink: { r: 255, g: 105, b: 180 } // Hot pink -}; - -/** Chord to color mapping */ -const CHORD_COLORS: Record = { - Am: COLORS.purple, - GB: COLORS.cyan, - F: COLORS.magenta, - Dm: COLORS.pink -}; - -/** - * Get color for current chord - */ -function getChordColor(chord: string): RGBColor { - if (Object.prototype.hasOwnProperty.call(CHORD_COLORS, chord)) { - return CHORD_COLORS[chord]; - } - return COLORS.purple; -} - -/** - * Nightcall Canvas - Retro synthwave visualization - */ -const NightcallCanvas: React.FC<{ - isActive: boolean; - getFrequencyData: () => Uint8Array; - getBeatIntensity: () => number; - getCurrentChord: () => string; -}> = ({ isActive, getFrequencyData, getBeatIntensity, getCurrentChord }) => { - const canvasRef = useRef(null); - const animationRef = useRef(0); - const timeRef = useRef(0); - const prevChordRef = useRef("Am"); - const colorTransitionRef = useRef(1); - - useEffect(() => { - if (!isActive) return undefined; - - const canvas = canvasRef.current; - if (!canvas) return undefined; - - const ctx = canvas.getContext("2d"); - if (!ctx) return undefined; - - const updateSize = (): void => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - }; - updateSize(); - window.addEventListener("resize", updateSize); - - timeRef.current = 0; - - const animate = (): void => { - const width = canvas.width; - const height = canvas.height; - timeRef.current += 1; - const time = timeRef.current; - - const freqData = getFrequencyData(); - const beatIntensity = getBeatIntensity(); - const currentChord = getCurrentChord(); - - // Handle color transitions between chords - if (currentChord !== prevChordRef.current) { - prevChordRef.current = currentChord; - colorTransitionRef.current = 0; - } - colorTransitionRef.current = Math.min(1, colorTransitionRef.current + 0.02); - - // Clear canvas - ctx.clearRect(0, 0, width, height); - - // Draw retro sun glow (bottom center, subtle) - drawRetroSunGlow(ctx, width, height, beatIntensity, time, currentChord); - - // Draw horizontal scan lines (very subtle) - drawScanLines(ctx, width, height, time); - - // Draw smooth edge glow - drawSmoothEdgeGlow(ctx, width, height, beatIntensity, currentChord); - - // Draw frequency visualizer (subtle bars at bottom) - drawFrequencyBars(ctx, width, height, freqData, beatIntensity, currentChord); - - // Draw floating particles - drawFloatingParticles(ctx, width, height, time, beatIntensity); - - animationRef.current = window.requestAnimationFrame(animate); - }; - - animationRef.current = window.requestAnimationFrame(animate); - - return () => { - window.removeEventListener("resize", updateSize); - window.cancelAnimationFrame(animationRef.current); - }; - }, [isActive, getFrequencyData, getBeatIntensity, getCurrentChord]); - - if (!isActive) return null; - - return ( - - ); -}; - -/** - * Draw retro sun glow effect - */ -function drawRetroSunGlow( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - intensity: number, - time: number, - chord: string -): void { - const color = getChordColor(chord); - const centerX = width / 2; - const centerY = height + 100; // Below screen for subtle glow - const baseRadius = height * 0.8; - const pulseRadius = baseRadius + Math.sin(time * 0.02) * 20 + intensity * 30; - - // Outer glow - const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, pulseRadius); - - const alpha = 0.08 + intensity * 0.06; - gradient.addColorStop(0, `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha * 1.5})`); - gradient.addColorStop(0.3, `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`); - gradient.addColorStop( - 0.6, - `rgba(${COLORS.darkPurple.r}, ${COLORS.darkPurple.g}, ${COLORS.darkPurple.b}, ${alpha * 0.5})` - ); - gradient.addColorStop(1, "transparent"); - - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, width, height); -} - -/** - * Draw subtle scan lines for retro CRT effect - */ -function drawScanLines( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - time: number -): void { - ctx.save(); - ctx.globalAlpha = 0.03; - - const lineSpacing = 4; - const offset = (time * 0.5) % lineSpacing; - - ctx.strokeStyle = "rgba(255, 255, 255, 0.5)"; - ctx.lineWidth = 1; - - for (let y = offset; y < height; y += lineSpacing) { - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(width, y); - ctx.stroke(); - } - - ctx.restore(); -} - -/** - * Draw smooth gradient edge glow - */ -function drawSmoothEdgeGlow( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - intensity: number, - chord: string -): void { - const color = getChordColor(chord); - const glowSize = 120 + intensity * 40; - const alpha = 0.15 + intensity * 0.1; - - // Top edge - purple to transparent - const topGrad = ctx.createLinearGradient(0, 0, 0, glowSize); - topGrad.addColorStop( - 0, - `rgba(${COLORS.purple.r}, ${COLORS.purple.g}, ${COLORS.purple.b}, ${alpha})` - ); - topGrad.addColorStop(1, "transparent"); - ctx.fillStyle = topGrad; - ctx.fillRect(0, 0, width, glowSize); - - // Bottom edge - chord color - const botGrad = ctx.createLinearGradient(0, height, 0, height - glowSize); - botGrad.addColorStop(0, `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha * 0.8})`); - botGrad.addColorStop(1, "transparent"); - ctx.fillStyle = botGrad; - ctx.fillRect(0, height - glowSize, width, glowSize); - - // Left edge - cyan accent - const leftGrad = ctx.createLinearGradient(0, 0, glowSize * 0.7, 0); - leftGrad.addColorStop( - 0, - `rgba(${COLORS.cyan.r}, ${COLORS.cyan.g}, ${COLORS.cyan.b}, ${alpha * 0.5})` - ); - leftGrad.addColorStop(1, "transparent"); - ctx.fillStyle = leftGrad; - ctx.fillRect(0, 0, glowSize * 0.7, height); - - // Right edge - magenta accent - const rightGrad = ctx.createLinearGradient(width, 0, width - glowSize * 0.7, 0); - rightGrad.addColorStop( - 0, - `rgba(${COLORS.magenta.r}, ${COLORS.magenta.g}, ${COLORS.magenta.b}, ${alpha * 0.5})` - ); - rightGrad.addColorStop(1, "transparent"); - ctx.fillStyle = rightGrad; - ctx.fillRect(width - glowSize * 0.7, 0, glowSize * 0.7, height); -} - -/** - * Draw smooth frequency bars at bottom - */ -function drawFrequencyBars( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - freqData: Uint8Array, - _intensity: number, - chord: string -): void { - const barCount = Math.min(24, freqData.length); - const totalWidth = width * 0.5; - const barWidth = totalWidth / barCount; - const maxBarHeight = 50; - const startX = (width - totalWidth) / 2; - const baseY = height - 30; - - const color = getChordColor(chord); - - for (let i = 0; i < barCount; i++) { - const amplitude = freqData[i] / 255; - const barHeight = amplitude * maxBarHeight; - - // Gradient from chord color to purple - const t = i / barCount; - const barColor = lerpColor(COLORS.cyan, color, t); - - const alpha = 0.4 + amplitude * 0.4; - ctx.fillStyle = `rgba(${barColor.r}, ${barColor.g}, ${barColor.b}, ${alpha})`; - - const x = startX + i * barWidth; - const y = baseY - barHeight; - - // Rounded rectangle - const radius = 2; - ctx.beginPath(); - ctx.roundRect(x + 1, y, barWidth - 2, barHeight, radius); - ctx.fill(); - - // Glow on high amplitude - if (amplitude > 0.5) { - ctx.shadowColor = `rgba(${barColor.r}, ${barColor.g}, ${barColor.b}, 0.8)`; - ctx.shadowBlur = 10; - ctx.fill(); - ctx.shadowBlur = 0; - } - } -} - -// Particle storage -const particles: Array<{ - x: number; - y: number; - vx: number; - vy: number; - size: number; - alpha: number; - hue: number; -}> = []; - -/** - * Draw floating ambient particles - */ -function drawFloatingParticles( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - time: number, - intensity: number -): void { - // Initialize particles if needed (visual effect, not security-sensitive) - if (particles.length === 0) { - for (let i = 0; i < 30; i++) { - /* eslint-disable sonarjs/pseudo-random */ - particles.push({ - x: Math.random() * width, - y: Math.random() * height, - vx: (Math.random() - 0.5) * 0.3, - vy: -0.2 - Math.random() * 0.3, - size: 1 + Math.random() * 2, - alpha: 0.2 + Math.random() * 0.3, - hue: 260 + Math.random() * 60 // Purple to pink range - }); - /* eslint-enable sonarjs/pseudo-random */ - } - } - - for (const p of particles) { - // Update position - p.x += p.vx + Math.sin(time * 0.01 + p.y * 0.01) * 0.2; - p.y += p.vy; - - // Wrap around - if (p.y < -10) { - p.y = height + 10; - // eslint-disable-next-line sonarjs/pseudo-random - p.x = Math.random() * width; - } - if (p.x < -10) p.x = width + 10; - if (p.x > width + 10) p.x = -10; - - // Draw particle with glow - const pulseAlpha = p.alpha + Math.sin(time * 0.05 + p.x * 0.01) * 0.1; - const finalAlpha = pulseAlpha + intensity * 0.2; - - ctx.beginPath(); - ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); - ctx.fillStyle = `hsla(${p.hue}, 80%, 70%, ${finalAlpha})`; - ctx.fill(); - - // Subtle glow - ctx.beginPath(); - ctx.arc(p.x, p.y, p.size * 3, 0, Math.PI * 2); - ctx.fillStyle = `hsla(${p.hue}, 80%, 70%, ${finalAlpha * 0.2})`; - ctx.fill(); - } -} - -/** - * Nightcall Mode Overlay - */ -export const NightcallMode: React.FC = ({ - isActive, - onClose, - onSwitchMode, - modeName -}) => { - const [visible, setVisible] = useState(false); - const audio = useNightcallAudio(); - - // Get color and intensity for node glow - const getColor = useCallback((): RGBColor => { - return getChordColor(audio.getCurrentChord()); - }, [audio]); - - const getIntensity = useCallback((): number => { - return audio.getBeatIntensity() * 0.5 + 0.3; - }, [audio]); - - // Apply synthwave glow to nodes via canvas store - useNodeGlow(isActive, getColor, getIntensity); - - // Start audio when activated - useEffect(() => { - if (isActive && !audio.isPlaying && !audio.isLoading) { - void audio.play(); - setVisible(true); - } else if (!isActive && audio.isPlaying) { - audio.stop(); - setVisible(false); - } - }, [isActive, audio]); - - const handleClose = (): void => { - audio.stop(); - onClose?.(); - }; - - const handleSwitch = (): void => { - audio.stop(); - onSwitchMode?.(); - }; - - if (!isActive) return null; - - return ( - <> - - - {/* Control buttons - retro style */} - - - Switch - - - - End Nightcall - - - - ); -}; diff --git a/src/reactTopoViewer/webview/easter-eggs/modes/StickerbushMode.tsx b/src/reactTopoViewer/webview/easter-eggs/modes/StickerbushMode.tsx deleted file mode 100644 index 641c9d5f2..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/modes/StickerbushMode.tsx +++ /dev/null @@ -1,451 +0,0 @@ -/** - * Stickerbrush Mode Component - * - * Dreamy forest/bramble visualization inspired by DKC2. - * Ethereal greens and purples with floating firefly particles. - */ - -import React, { useCallback, useEffect, useRef, useState } from "react"; -import Box from "@mui/material/Box"; - -import { useStickerbushAudio } from "../audio"; -import { - BTN_VISIBLE_SX, - BTN_HIDDEN_SX, - BTN_BLUR, - lerpColor, - useNodeGlow, - MuteButton -} from "../shared"; -import type { RGBColor, BaseModeProps } from "../shared"; - -/** Forest/bramble color palette */ -const COLORS = { - deepGreen: { r: 0, g: 100, b: 60 }, - forestGreen: { r: 34, g: 139, b: 34 }, - emerald: { r: 80, g: 200, b: 120 }, - purple: { r: 128, g: 0, b: 128 }, - lavender: { r: 150, g: 120, b: 182 }, - gold: { r: 255, g: 215, b: 0 }, - warmWhite: { r: 255, g: 250, b: 240 } -}; - -/** Section to color mapping - cycling through forest colors */ -const SECTION_COLORS: RGBColor[] = [ - COLORS.emerald, - COLORS.forestGreen, - COLORS.lavender, - COLORS.purple -]; - -/** - * Get color for current section - */ -function getSectionColor(section: number): RGBColor { - return SECTION_COLORS[section % SECTION_COLORS.length]; -} - -/** Firefly particle type */ -interface Firefly { - x: number; - y: number; - vx: number; - vy: number; - size: number; - brightness: number; - pulsePhase: number; - pulseSpeed: number; - hue: number; -} - -// Persistent firefly storage -const fireflies: Firefly[] = []; - -/** - * Initialize fireflies for the canvas - */ -function initializeFireflies(width: number, height: number): void { - if (fireflies.length > 0) return; - - for (let i = 0; i < 40; i++) { - /* eslint-disable sonarjs/pseudo-random */ - fireflies.push({ - x: Math.random() * width, - y: Math.random() * height, - vx: (Math.random() - 0.5) * 0.4, - vy: (Math.random() - 0.5) * 0.4, - size: 2 + Math.random() * 3, - brightness: 0.3 + Math.random() * 0.7, - pulsePhase: Math.random() * Math.PI * 2, - pulseSpeed: 0.02 + Math.random() * 0.03, - hue: 60 + Math.random() * 80 // Yellow to green range - }); - /* eslint-enable sonarjs/pseudo-random */ - } -} - -/** - * Stickerbrush Canvas - Dreamy forest visualization - */ -const StickerbushCanvas: React.FC<{ - isActive: boolean; - getFrequencyData: () => Uint8Array; - getBeatIntensity: () => number; - getCurrentSection: () => number; -}> = ({ isActive, getFrequencyData, getBeatIntensity, getCurrentSection }) => { - const canvasRef = useRef(null); - const animationRef = useRef(0); - const timeRef = useRef(0); - - useEffect(() => { - if (!isActive) return undefined; - - const canvas = canvasRef.current; - if (!canvas) return undefined; - - const ctx = canvas.getContext("2d"); - if (!ctx) return undefined; - - const updateSize = (): void => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - }; - updateSize(); - window.addEventListener("resize", updateSize); - - timeRef.current = 0; - initializeFireflies(canvas.width, canvas.height); - - const animate = (): void => { - const width = canvas.width; - const height = canvas.height; - timeRef.current += 1; - const time = timeRef.current; - - const freqData = getFrequencyData(); - const beatIntensity = getBeatIntensity(); - const currentSection = getCurrentSection(); - - ctx.clearRect(0, 0, width, height); - - drawForestGlow(ctx, width, height, beatIntensity, time, currentSection); - drawVignette(ctx, width, height); - drawLightRays(ctx, width, height, time, beatIntensity); - drawGrassBars(ctx, width, height, freqData, beatIntensity, currentSection); - drawFireflies(ctx, width, height, time, beatIntensity); - - animationRef.current = window.requestAnimationFrame(animate); - }; - - animationRef.current = window.requestAnimationFrame(animate); - - return () => { - window.removeEventListener("resize", updateSize); - window.cancelAnimationFrame(animationRef.current); - }; - }, [isActive, getFrequencyData, getBeatIntensity, getCurrentSection]); - - if (!isActive) return null; - - return ( - - ); -}; - -function drawForestGlow( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - intensity: number, - time: number, - section: number -): void { - const color = getSectionColor(section); - const centerX = width / 2; - const centerY = height / 2; - const baseRadius = Math.max(width, height) * 0.7; - const pulseRadius = baseRadius + Math.sin(time * 0.01) * 30 + intensity * 40; - - const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, pulseRadius); - - const alpha = 0.06 + intensity * 0.04; - gradient.addColorStop(0, `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha * 1.5})`); - gradient.addColorStop(0.4, `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`); - gradient.addColorStop( - 0.7, - `rgba(${COLORS.deepGreen.r}, ${COLORS.deepGreen.g}, ${COLORS.deepGreen.b}, ${alpha * 0.5})` - ); - gradient.addColorStop(1, "transparent"); - - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, width, height); -} - -function drawVignette(ctx: CanvasRenderingContext2D, width: number, height: number): void { - const gradient = ctx.createRadialGradient( - width / 2, - height / 2, - Math.min(width, height) * 0.3, - width / 2, - height / 2, - Math.max(width, height) * 0.8 - ); - - gradient.addColorStop(0, "transparent"); - gradient.addColorStop(0.7, "transparent"); - gradient.addColorStop(1, "rgba(0, 30, 20, 0.4)"); - - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, width, height); -} - -function drawLightRays( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - time: number, - intensity: number -): void { - ctx.save(); - ctx.globalAlpha = 0.03 + intensity * 0.02; - - const rayCount = 5; - const baseWidth = width / rayCount; - - for (let i = 0; i < rayCount; i++) { - const offset = Math.sin(time * 0.005 + i * 1.5) * 50; - const x = (i + 0.5) * baseWidth + offset; - - const gradient = ctx.createLinearGradient(x, 0, x, height * 0.7); - gradient.addColorStop( - 0, - `rgba(${COLORS.warmWhite.r}, ${COLORS.warmWhite.g}, ${COLORS.warmWhite.b}, 0.8)` - ); - gradient.addColorStop(0.5, `rgba(${COLORS.gold.r}, ${COLORS.gold.g}, ${COLORS.gold.b}, 0.3)`); - gradient.addColorStop(1, "transparent"); - - ctx.beginPath(); - ctx.moveTo(x - 30, 0); - ctx.lineTo(x + 30, 0); - ctx.lineTo(x + 60, height * 0.7); - ctx.lineTo(x - 60, height * 0.7); - ctx.closePath(); - - ctx.fillStyle = gradient; - ctx.fill(); - } - - ctx.restore(); -} - -function drawGrassBars( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - freqData: Uint8Array, - _intensity: number, - section: number -): void { - const barCount = Math.min(32, freqData.length); - const totalWidth = width * 0.8; - const barWidth = totalWidth / barCount; - const maxBarHeight = 40; - const startX = (width - totalWidth) / 2; - const baseY = height - 20; - - const color = getSectionColor(section); - - for (let i = 0; i < barCount; i++) { - const amplitude = freqData[i] / 255; - const barHeight = amplitude * maxBarHeight; - - const t = i / barCount; - const barColor = lerpColor(COLORS.forestGreen, color, t); - - const alpha = 0.3 + amplitude * 0.4; - ctx.fillStyle = `rgba(${barColor.r}, ${barColor.g}, ${barColor.b}, ${alpha})`; - - const x = startX + i * barWidth; - const y = baseY - barHeight; - - ctx.beginPath(); - ctx.moveTo(x + barWidth / 2, y); - ctx.lineTo(x + barWidth - 1, baseY); - ctx.lineTo(x + 1, baseY); - ctx.closePath(); - ctx.fill(); - } -} - -function drawFireflies( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - time: number, - intensity: number -): void { - for (const f of fireflies) { - f.x += f.vx + Math.sin(time * 0.008 + f.pulsePhase) * 0.3; - f.y += f.vy + Math.cos(time * 0.006 + f.pulsePhase) * 0.2; - - if (f.x < -20) f.x = width + 20; - if (f.x > width + 20) f.x = -20; - if (f.y < -20) f.y = height + 20; - if (f.y > height + 20) f.y = -20; - - const pulse = Math.sin(time * f.pulseSpeed + f.pulsePhase); - const currentBrightness = f.brightness * (0.5 + pulse * 0.5) + intensity * 0.3; - - const glowSize = f.size * 4; - const gradient = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, glowSize); - gradient.addColorStop(0, `hsla(${f.hue}, 80%, 70%, ${currentBrightness * 0.6})`); - gradient.addColorStop(0.3, `hsla(${f.hue}, 70%, 60%, ${currentBrightness * 0.3})`); - gradient.addColorStop(1, "transparent"); - - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.arc(f.x, f.y, glowSize, 0, Math.PI * 2); - ctx.fill(); - - ctx.beginPath(); - ctx.arc(f.x, f.y, f.size, 0, Math.PI * 2); - ctx.fillStyle = `hsla(${f.hue}, 90%, 85%, ${currentBrightness})`; - ctx.fill(); - } -} - -export const StickerbushMode: React.FC = ({ - isActive, - onClose, - onSwitchMode, - modeName -}) => { - const [visible, setVisible] = useState(false); - const audio = useStickerbushAudio(); - - const getColor = useCallback((): RGBColor => { - return getSectionColor(audio.getCurrentSection()); - }, [audio]); - - const getIntensity = useCallback((): number => { - return audio.getBeatIntensity() * 0.4 + 0.2; - }, [audio]); - - useNodeGlow(isActive, getColor, getIntensity); - - useEffect(() => { - if (isActive && !audio.isPlaying && !audio.isLoading) { - void audio.play(); - setVisible(true); - } else if (!isActive && audio.isPlaying) { - audio.stop(); - setVisible(false); - } - }, [isActive, audio]); - - const handleClose = (): void => { - audio.stop(); - onClose?.(); - }; - - const handleSwitch = (): void => { - audio.stop(); - onSwitchMode?.(); - }; - - if (!isActive) return null; - - return ( - <> - - - - - Switch - - - - End Stickerbrush - - - - ); -}; diff --git a/src/reactTopoViewer/webview/easter-eggs/modes/VaporwaveMode.tsx b/src/reactTopoViewer/webview/easter-eggs/modes/VaporwaveMode.tsx deleted file mode 100644 index 1b80c4d0b..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/modes/VaporwaveMode.tsx +++ /dev/null @@ -1,527 +0,0 @@ -/** - * Vaporwave Mode Component - * - * Classic vaporwave aesthetic with pink/cyan gradients, - * perspective grid, and dreamy smooth jazz vibes. - */ - -import React, { useCallback, useEffect, useRef, useState } from "react"; -import Box from "@mui/material/Box"; - -import { useVaporwaveAudio } from "../audio"; -import { - BTN_VISIBLE_SX, - BTN_HIDDEN_SX, - BTN_BLUR, - lerpColor, - useNodeGlow, - MuteButton -} from "../shared"; -import type { RGBColor, BaseModeProps } from "../shared"; - -const BTN_BORDER = "2px solid rgba(255, 255, 255, 0.4)"; - -const COLORS = { - pink: { r: 255, g: 113, b: 206 }, - cyan: { r: 1, g: 205, b: 254 }, - purple: { r: 185, g: 103, b: 255 }, - yellow: { r: 254, g: 255, b: 156 }, - blue: { r: 120, g: 129, b: 255 }, - darkPurple: { r: 25, g: 4, b: 50 } -}; - -const SECTION_COLORS: Record = { - em7: COLORS.pink, - bm: COLORS.cyan, - em: COLORS.purple, - csm7: COLORS.yellow, - a: COLORS.blue -}; - -function getSectionColor(section: string): RGBColor { - if (Object.prototype.hasOwnProperty.call(SECTION_COLORS, section)) { - return SECTION_COLORS[section]; - } - return COLORS.cyan; -} - -const VaporwaveCanvas: React.FC<{ - isActive: boolean; - getFrequencyData: () => Uint8Array; - getCurrentSection: () => string; -}> = ({ isActive, getFrequencyData, getCurrentSection }) => { - const canvasRef = useRef(null); - const animationRef = useRef(0); - const timeRef = useRef(0); - - useEffect(() => { - if (!isActive) return undefined; - - const canvas = canvasRef.current; - if (!canvas) return undefined; - - const ctx = canvas.getContext("2d"); - if (!ctx) return undefined; - - const updateSize = (): void => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - }; - updateSize(); - window.addEventListener("resize", updateSize); - - timeRef.current = 0; - - const animate = (): void => { - const width = canvas.width; - const height = canvas.height; - timeRef.current += 1; - const time = timeRef.current; - - const freqData = getFrequencyData(); - const currentSection = getCurrentSection(); - const avgIntensity = getAverageIntensity(freqData); - - ctx.clearRect(0, 0, width, height); - - drawBackgroundGlow(ctx, width, height, time, currentSection, avgIntensity); - drawPerspectiveGrid(ctx, width, height, time, currentSection); - drawVaporwaveSun(ctx, width, height, time, avgIntensity, currentSection); - drawHorizontalBands(ctx, width, height, time); - drawMinimalistBars(ctx, width, height, freqData, currentSection); - drawFloatingShapes(ctx, width, height, time, avgIntensity); - - animationRef.current = window.requestAnimationFrame(animate); - }; - - animationRef.current = window.requestAnimationFrame(animate); - - return () => { - window.removeEventListener("resize", updateSize); - window.cancelAnimationFrame(animationRef.current); - }; - }, [isActive, getFrequencyData, getCurrentSection]); - - if (!isActive) return null; - - return ( - - ); -}; - -function getAverageIntensity(freqData: Uint8Array): number { - if (freqData.length === 0) return 0; - let sum = 0; - for (let i = 0; i < freqData.length; i++) { - sum += freqData[i]; - } - return sum / freqData.length / 255; -} - -function drawBackgroundGlow( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - time: number, - section: string, - intensity: number -): void { - const color = getSectionColor(section); - const pulseAlpha = 0.08 + Math.sin(time * 0.01) * 0.02 + intensity * 0.06; - - const topGrad = ctx.createLinearGradient(0, 0, 0, height * 0.4); - topGrad.addColorStop( - 0, - `rgba(${COLORS.pink.r}, ${COLORS.pink.g}, ${COLORS.pink.b}, ${pulseAlpha * 1.2})` - ); - topGrad.addColorStop(0.5, `rgba(${color.r}, ${color.g}, ${color.b}, ${pulseAlpha * 0.6})`); - topGrad.addColorStop(1, "transparent"); - ctx.fillStyle = topGrad; - ctx.fillRect(0, 0, width, height * 0.4); - - const botGrad = ctx.createLinearGradient(0, height, 0, height * 0.6); - botGrad.addColorStop( - 0, - `rgba(${COLORS.cyan.r}, ${COLORS.cyan.g}, ${COLORS.cyan.b}, ${pulseAlpha})` - ); - botGrad.addColorStop(1, "transparent"); - ctx.fillStyle = botGrad; - ctx.fillRect(0, height * 0.6, width, height * 0.4); -} - -function drawPerspectiveGrid( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - time: number, - section: string -): void { - const color = getSectionColor(section); - const horizonY = height * 0.55; - const vanishX = width / 2; - - ctx.save(); - ctx.globalAlpha = 0.15; - ctx.strokeStyle = `rgb(${color.r}, ${color.g}, ${color.b})`; - ctx.lineWidth = 1; - - const numLines = 16; - for (let i = 0; i <= numLines; i++) { - const t = i / numLines; - const bottomX = t * width; - - ctx.beginPath(); - ctx.moveTo(vanishX, horizonY); - ctx.lineTo(bottomX, height); - ctx.stroke(); - } - - const offset = (time * 0.5) % 40; - for (let i = 0; i < 12; i++) { - const baseY = horizonY + i * 40 + offset; - if (baseY > height) continue; - - const progress = (baseY - horizonY) / (height - horizonY); - const lineWidth = progress * width; - const lineX = (width - lineWidth) / 2; - - ctx.globalAlpha = 0.1 + progress * 0.1; - ctx.beginPath(); - ctx.moveTo(lineX, baseY); - ctx.lineTo(lineX + lineWidth, baseY); - ctx.stroke(); - } - - ctx.restore(); -} - -function drawVaporwaveSun( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - time: number, - intensity: number, - section: string -): void { - const color = getSectionColor(section); - const centerX = width / 2; - const centerY = height * 0.35; - const baseRadius = Math.min(width, height) * 0.12; - const radius = baseRadius + Math.sin(time * 0.015) * 5 + intensity * 15; - - const gradient = ctx.createRadialGradient( - centerX, - centerY, - radius * 0.3, - centerX, - centerY, - radius * 2 - ); - gradient.addColorStop(0, `rgba(${color.r}, ${color.g}, ${color.b}, 0.3)`); - gradient.addColorStop(0.5, `rgba(${COLORS.pink.r}, ${COLORS.pink.g}, ${COLORS.pink.b}, 0.1)`); - gradient.addColorStop(1, "transparent"); - - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, width, height * 0.6); - - ctx.save(); - ctx.beginPath(); - ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); - ctx.clip(); - - const sunGrad = ctx.createLinearGradient(centerX, centerY - radius, centerX, centerY + radius); - sunGrad.addColorStop(0, `rgba(${COLORS.pink.r}, ${COLORS.pink.g}, ${COLORS.pink.b}, 0.4)`); - sunGrad.addColorStop( - 0.5, - `rgba(${COLORS.yellow.r}, ${COLORS.yellow.g}, ${COLORS.yellow.b}, 0.35)` - ); - sunGrad.addColorStop(1, `rgba(${COLORS.cyan.r}, ${COLORS.cyan.g}, ${COLORS.cyan.b}, 0.4)`); - - ctx.fillStyle = sunGrad; - ctx.fillRect(centerX - radius, centerY - radius, radius * 2, radius * 2); - - ctx.globalAlpha = 0.3; - ctx.fillStyle = `rgba(${COLORS.darkPurple.r}, ${COLORS.darkPurple.g}, ${COLORS.darkPurple.b}, 0.5)`; - for (let i = 0; i < 8; i++) { - const y = centerY + (i - 4) * (radius / 5) + Math.sin(time * 0.02 + i) * 2; - const lineHeight = 2 + i * 0.5; - ctx.fillRect(centerX - radius, y, radius * 2, lineHeight); - } - - ctx.restore(); -} - -function drawHorizontalBands( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - time: number -): void { - ctx.save(); - ctx.globalAlpha = 0.04; - - const lineSpacing = 3; - const offset = (time * 0.3) % lineSpacing; - - for (let y = offset; y < height; y += lineSpacing) { - const alpha = 0.02 + Math.sin(y * 0.01 + time * 0.01) * 0.01; - ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; - ctx.fillRect(0, y, width, 1); - } - - ctx.restore(); -} - -function drawMinimalistBars( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - freqData: Uint8Array, - section: string -): void { - const color = getSectionColor(section); - const barCount = Math.min(16, freqData.length); - const totalWidth = width * 0.4; - const barWidth = totalWidth / barCount; - const maxBarHeight = 40; - const startX = (width - totalWidth) / 2; - const baseY = height - 25; - - for (let i = 0; i < barCount; i++) { - const amplitude = freqData[i] / 255; - const barHeight = amplitude * maxBarHeight; - - const t = i / barCount; - const barColor = lerpColor(COLORS.pink, COLORS.cyan, t); - - const alpha = 0.3 + amplitude * 0.5; - ctx.fillStyle = `rgba(${barColor.r}, ${barColor.g}, ${barColor.b}, ${alpha})`; - - const x = startX + i * barWidth; - const y = baseY - barHeight; - - ctx.beginPath(); - ctx.roundRect(x + 2, y, barWidth - 4, barHeight, 2); - ctx.fill(); - - if (amplitude > 0.4) { - ctx.shadowColor = `rgba(${color.r}, ${color.g}, ${color.b}, 0.6)`; - ctx.shadowBlur = 8; - ctx.fill(); - ctx.shadowBlur = 0; - } - } -} - -const shapes: Array<{ - x: number; - y: number; - size: number; - rotation: number; - rotSpeed: number; - type: "triangle" | "circle" | "diamond"; - alpha: number; - hue: number; -}> = []; - -function drawFloatingShapes( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - time: number, - intensity: number -): void { - if (shapes.length === 0) { - const shapeTypes: Array<"triangle" | "circle" | "diamond"> = ["triangle", "circle", "diamond"]; - for (let i = 0; i < 15; i++) { - /* eslint-disable sonarjs/pseudo-random */ - shapes.push({ - x: Math.random() * width, - y: Math.random() * height, - size: 8 + Math.random() * 15, - rotation: Math.random() * Math.PI * 2, - rotSpeed: (Math.random() - 0.5) * 0.01, - type: shapeTypes[Math.floor(Math.random() * 3)], - alpha: 0.1 + Math.random() * 0.15, - hue: 280 + Math.random() * 100 - }); - /* eslint-enable sonarjs/pseudo-random */ - } - } - - for (const s of shapes) { - s.x += Math.sin(time * 0.005 + s.y * 0.01) * 0.3; - s.y -= 0.1 + intensity * 0.2; - s.rotation += s.rotSpeed; - - if (s.y < -20) { - s.y = height + 20; - // eslint-disable-next-line sonarjs/pseudo-random - s.x = Math.random() * width; - } - - ctx.save(); - ctx.translate(s.x, s.y); - ctx.rotate(s.rotation); - ctx.globalAlpha = s.alpha + Math.sin(time * 0.02) * 0.05; - ctx.strokeStyle = `hsla(${s.hue}, 80%, 70%, 0.6)`; - ctx.lineWidth = 1.5; - - ctx.beginPath(); - if (s.type === "triangle") { - const h = s.size * 0.866; - ctx.moveTo(0, -h / 2); - ctx.lineTo(-s.size / 2, h / 2); - ctx.lineTo(s.size / 2, h / 2); - ctx.closePath(); - } else if (s.type === "circle") { - ctx.arc(0, 0, s.size / 2, 0, Math.PI * 2); - } else { - ctx.moveTo(0, -s.size / 2); - ctx.lineTo(s.size / 2, 0); - ctx.lineTo(0, s.size / 2); - ctx.lineTo(-s.size / 2, 0); - ctx.closePath(); - } - ctx.stroke(); - - ctx.restore(); - } -} - -export const VaporwaveMode: React.FC = ({ - isActive, - onClose, - onSwitchMode, - modeName -}) => { - const [visible, setVisible] = useState(false); - const audio = useVaporwaveAudio(); - const timeRef = useRef(0); - - const getColor = useCallback((): RGBColor => { - return getSectionColor(audio.getCurrentSection()); - }, [audio]); - - const getIntensity = useCallback((): number => { - timeRef.current += 1; - return 0.3 + Math.sin(timeRef.current * 0.03) * 0.2; - }, []); - - useNodeGlow(isActive, getColor, getIntensity); - - useEffect(() => { - if (isActive && !audio.isPlaying && !audio.isLoading) { - void audio.play(); - setVisible(true); - } else if (!isActive && audio.isPlaying) { - audio.stop(); - setVisible(false); - } - }, [isActive, audio]); - - const handleClose = (): void => { - audio.stop(); - onClose?.(); - }; - - const handleSwitch = (): void => { - audio.stop(); - onSwitchMode?.(); - }; - - if (!isActive) return null; - - return ( - <> - - - - - S W I T C H - - - - E X I T V A P O R - - - - ); -}; diff --git a/src/reactTopoViewer/webview/easter-eggs/modes/index.ts b/src/reactTopoViewer/webview/easter-eggs/modes/index.ts deleted file mode 100644 index 6e0d0b0af..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/modes/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Easter Egg mode components - */ - -export { AquaticAmbienceMode } from "./AquaticAmbienceMode"; -export { VaporwaveMode } from "./VaporwaveMode"; -export { NightcallMode } from "./NightcallMode"; -export { StickerbushMode } from "./StickerbushMode"; -export { DeusExMode } from "./DeusExMode"; diff --git a/src/reactTopoViewer/webview/easter-eggs/shared/MuteButton.tsx b/src/reactTopoViewer/webview/easter-eggs/shared/MuteButton.tsx deleted file mode 100644 index 11a52623c..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/shared/MuteButton.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Shared Mute Button component for Easter Egg modes - */ - -import React from "react"; -import Box from "@mui/material/Box"; - -import { BTN_VISIBLE_SX, BTN_HIDDEN_SX, BTN_BLUR } from "./buttonConstants"; - -export interface MuteButtonProps { - isMuted: boolean; - onToggle: () => void; - visible: boolean; - /** Background gradient for unmuted state */ - unmutedBackground: string; - /** Box shadow for unmuted state */ - unmutedShadow: string; - /** Border color */ - borderColor?: string; -} - -/** Muted speaker icon */ -const MutedIcon: React.FC = () => ( - - - - - -); - -/** Unmuted speaker icon */ -const UnmutedIcon: React.FC = () => ( - - - - -); - -/** Muted state background */ -const MUTED_BACKGROUND = - "linear-gradient(135deg, rgba(100, 100, 100, 0.8) 0%, rgba(60, 60, 60, 0.8) 100%)"; - -/** Muted state shadow */ -const MUTED_SHADOW = "0 0 20px rgba(100, 100, 100, 0.5), inset 0 0 20px rgba(60, 60, 60, 0.1)"; - -/** - * Shared mute button component with consistent styling across all modes - */ -export const MuteButton: React.FC = ({ - isMuted, - onToggle, - visible, - unmutedBackground, - unmutedShadow, - borderColor = "rgba(255, 255, 255, 0.5)" -}) => { - return ( - - {isMuted ? : } - - ); -}; diff --git a/src/reactTopoViewer/webview/easter-eggs/shared/buttonConstants.ts b/src/reactTopoViewer/webview/easter-eggs/shared/buttonConstants.ts deleted file mode 100644 index 0bb464028..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/shared/buttonConstants.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Shared button constants for Easter Egg modes - */ -import type { Theme } from "@mui/material/styles"; -import type { SystemStyleObject } from "@mui/system"; - -/** Button visible state sx */ -export const BTN_VISIBLE_SX: SystemStyleObject = { - opacity: 1, - transform: "translateY(0)" -}; - -/** Button hidden state sx */ -export const BTN_HIDDEN_SX: SystemStyleObject = { - opacity: 0, - transform: "translateY(16px)" -}; - -/** Button backdrop blur value */ -export const BTN_BLUR = "blur(10px)"; diff --git a/src/reactTopoViewer/webview/easter-eggs/shared/colorUtils.ts b/src/reactTopoViewer/webview/easter-eggs/shared/colorUtils.ts deleted file mode 100644 index 53a8da18b..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/shared/colorUtils.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Shared color utilities for Easter Egg modes - */ - -import type { RGBColor } from "./types"; - -/** - * Interpolate between two colors - */ -export function lerpColor(c1: RGBColor, c2: RGBColor, t: number): RGBColor { - return { - r: Math.round(c1.r + (c2.r - c1.r) * t), - g: Math.round(c1.g + (c2.g - c1.g) * t), - b: Math.round(c1.b + (c2.b - c1.b) * t) - }; -} diff --git a/src/reactTopoViewer/webview/easter-eggs/shared/index.ts b/src/reactTopoViewer/webview/easter-eggs/shared/index.ts deleted file mode 100644 index 8b0e28603..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/shared/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Shared utilities for Easter Egg modes - */ - -export type { RGBColor, BaseModeProps, BaseAudioReturn } from "./types"; -export { lerpColor } from "./colorUtils"; -export { BTN_VISIBLE_SX, BTN_HIDDEN_SX, BTN_BLUR } from "./buttonConstants"; -export { useNodeGlow } from "./nodeGlow"; -export { MuteButton } from "./MuteButton"; -export type { MuteButtonProps } from "./MuteButton"; diff --git a/src/reactTopoViewer/webview/easter-eggs/shared/nodeGlow.ts b/src/reactTopoViewer/webview/easter-eggs/shared/nodeGlow.ts deleted file mode 100644 index 1d7ee0ef2..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/shared/nodeGlow.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Node glow hook for React Flow easter egg modes - * - * Updates the canvas store's easterEggGlow state at a throttled rate - * so node components can read and apply CSS box-shadow effects. - */ - -import { useCallback, useEffect, useRef } from "react"; - -import { useCanvasStore } from "../../stores/canvasStore"; - -import type { RGBColor } from "./types"; - -/** Throttle interval for glow updates (~30fps) */ -const GLOW_UPDATE_INTERVAL = 33; // ms - -/** - * Hook to update the canvas store's easter egg glow state. - * - * This hook manages glow state updates at ~30fps, throttled to avoid - * excessive React re-renders while still providing smooth visual effects. - * - * @param isActive - Whether the easter egg mode is active - * @param getColor - Function that returns the current glow color - * @param getIntensity - Function that returns the current intensity (0-1) - */ -export function useNodeGlow( - isActive: boolean, - getColor: () => RGBColor, - getIntensity: () => number -): void { - const setEasterEggGlow = useCanvasStore((state) => state.setEasterEggGlow); - const intervalRef = useRef | null>(null); - const lastColorRef = useRef(null); - const lastIntensityRef = useRef(-1); - - const updateGlow = useCallback(() => { - const color = getColor(); - const intensity = getIntensity(); - - // Only update if values changed (to minimize store updates) - const colorChanged = - !lastColorRef.current || - lastColorRef.current.r !== color.r || - lastColorRef.current.g !== color.g || - lastColorRef.current.b !== color.b; - const intensityChanged = Math.abs(lastIntensityRef.current - intensity) > 0.01; - - if (colorChanged || intensityChanged) { - lastColorRef.current = color; - lastIntensityRef.current = intensity; - setEasterEggGlow({ color, intensity }); - } - }, [getColor, getIntensity, setEasterEggGlow]); - - useEffect(() => { - if (!isActive) { - // Clear glow when deactivated - setEasterEggGlow(null); - lastColorRef.current = null; - lastIntensityRef.current = -1; - return undefined; - } - - // Start interval for glow updates - intervalRef.current = setInterval(updateGlow, GLOW_UPDATE_INTERVAL); - - // Initial update - updateGlow(); - - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - // Clear glow on cleanup - setEasterEggGlow(null); - lastColorRef.current = null; - lastIntensityRef.current = -1; - }; - }, [isActive, updateGlow, setEasterEggGlow]); -} diff --git a/src/reactTopoViewer/webview/easter-eggs/shared/types.ts b/src/reactTopoViewer/webview/easter-eggs/shared/types.ts deleted file mode 100644 index fc020b992..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/shared/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Shared types for Easter Egg modes - */ - -/** RGB color type */ -export interface RGBColor { - r: number; - g: number; - b: number; -} - -/** Base props for all easter egg mode components */ -export interface BaseModeProps { - isActive: boolean; - onClose?: () => void; - onSwitchMode?: () => void; - modeName?: string; -} - -/** Base return type for audio hooks */ -export interface BaseAudioReturn { - play: () => void; - stop: () => void; - isPlaying: boolean; - isLoading: boolean; - isMuted: boolean; - toggleMute: () => void; - getFrequencyData: () => Uint8Array; - getTimeDomainData: () => Uint8Array; -} diff --git a/src/reactTopoViewer/webview/easter-eggs/useEasterEgg.ts b/src/reactTopoViewer/webview/easter-eggs/useEasterEgg.ts deleted file mode 100644 index 253d2b2e8..000000000 --- a/src/reactTopoViewer/webview/easter-eggs/useEasterEgg.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Easter Egg Hook - Logo Click Easter Egg Modes - * - * Click the Containerlab logo 10 times to trigger one of five easter eggs: - * - Nightcall: 80s synthwave vibe (Kavinsky inspired) - * - Stickerbrush Symphony: Dreamy forest ambient (DKC2 inspired) - * - Aquatic Ambience: Underwater serenity (DKC inspired) - * - Vaporwave: Slowed down smooth jazz aesthetic - * - Deus Ex: 3D rotating logo with metallic theme (silent mode) - * - * 20/20/20/20/20 random chance between the five modes. - */ - -import { useCallback, useEffect, useRef, useState } from "react"; - -/** Number of clicks required to trigger easter egg */ -const CLICKS_REQUIRED = 10; - -/** Timeout for resetting click count if user stops clicking (ms) */ -const CLICK_TIMEOUT = 2000; - -/** Available easter egg modes */ -export type EasterEggMode = "nightcall" | "stickerbrush" | "aquatic" | "vaporwave" | "deusex"; - -export interface EasterEggState { - /** Whether easter egg mode is currently active */ - isPartyMode: boolean; - /** Which easter egg mode is active */ - easterEggMode: EasterEggMode; - /** Progress through clicks (0-10) */ - progress: number; -} - -export interface UseEasterEggOptions { - /** Callback when easter egg activates */ - onActivate?: () => void; - /** Callback when easter egg ends */ - onDeactivate?: () => void; -} - -/** All available modes in order */ -const ALL_MODES: EasterEggMode[] = ["nightcall", "stickerbrush", "aquatic", "vaporwave", "deusex"]; - -export interface UseEasterEggReturn { - /** Current easter egg state */ - state: EasterEggState; - /** Handle logo click - call this when logo is clicked */ - handleLogoClick: () => void; - /** Manually trigger easter egg (for testing) */ - triggerPartyMode: () => void; - /** End easter egg early */ - endPartyMode: () => void; - /** Switch to the next easter egg mode */ - nextMode: () => void; - /** Get display name for current mode */ - getModeName: () => string; -} - -/** - * Hook for detecting logo clicks and managing easter egg state - */ -export function useEasterEgg(options: UseEasterEggOptions): UseEasterEggReturn { - const { onActivate, onDeactivate } = options; - - const [isPartyMode, setIsPartyMode] = useState(false); - const [easterEggMode, setEasterEggMode] = useState("nightcall"); - const [progress, setProgress] = useState(0); - - const clickCountRef = useRef(0); - const clickTimeoutRef = useRef | null>(null); - - /** - * End easter egg mode - */ - const endPartyMode = useCallback(() => { - setIsPartyMode(false); - onDeactivate?.(); - }, [onDeactivate]); - - /** - * Switch to the next easter egg mode - */ - const nextMode = useCallback(() => { - if (!isPartyMode) return; - - const currentIndex = ALL_MODES.indexOf(easterEggMode); - const nextIndex = (currentIndex + 1) % ALL_MODES.length; - setEasterEggMode(ALL_MODES[nextIndex]); - }, [isPartyMode, easterEggMode]); - - /** - * Get display name for the current mode - */ - const getModeName = useCallback((): string => { - const names: Record = { - nightcall: "Nightcall", - stickerbrush: "Stickerbrush", - aquatic: "Aquatic", - vaporwave: "Vaporwave", - deusex: "Deus Ex" - }; - return names[easterEggMode]; - }, [easterEggMode]); - - /** - * Trigger easter egg mode with random mode selection - */ - const triggerPartyMode = useCallback(() => { - if (isPartyMode) return; - - // 20/20/20/20/20 random selection between modes (visual effect only, not security-sensitive) - // eslint-disable-next-line sonarjs/pseudo-random - const rand = Math.random(); - let mode: EasterEggMode; - if (rand < 0.2) { - mode = "nightcall"; - } else if (rand < 0.4) { - mode = "stickerbrush"; - } else if (rand < 0.6) { - mode = "aquatic"; - } else if (rand < 0.8) { - mode = "vaporwave"; - } else { - mode = "deusex"; - } - setEasterEggMode(mode); - - setIsPartyMode(true); - setProgress(0); - clickCountRef.current = 0; - - onActivate?.(); - }, [isPartyMode, onActivate]); - - /** - * Handle logo click - */ - const handleLogoClick = useCallback(() => { - if (isPartyMode) return; - - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - } - - clickCountRef.current += 1; - setProgress(clickCountRef.current); - - if (clickCountRef.current >= CLICKS_REQUIRED) { - triggerPartyMode(); - return; - } - - clickTimeoutRef.current = setTimeout(() => { - clickCountRef.current = 0; - setProgress(0); - }, CLICK_TIMEOUT); - }, [isPartyMode, triggerPartyMode]); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - } - }; - }, []); - - return { - state: { - isPartyMode, - easterEggMode, - progress - }, - handleLogoClick, - triggerPartyMode, - endPartyMode, - nextMode, - getModeName - }; -} diff --git a/src/reactTopoViewer/webview/hooks/app/index.ts b/src/reactTopoViewer/webview/hooks/app/index.ts deleted file mode 100644 index db335df10..000000000 --- a/src/reactTopoViewer/webview/hooks/app/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * App hooks barrel export - * - * All hooks extracted from App.tsx to reduce complexity. - */ - -// Clipboard handlers -export { - useClipboardHandlers, - type ClipboardHandlersConfig, - type ClipboardHandlersReturn -} from "./useClipboardHandlers"; - -// Keyboard shortcuts -export { - useAppKeyboardShortcuts, - type AppKeyboardShortcutsConfig -} from "./useAppKeyboardShortcuts"; - -// Graph creation -export { - useGraphCreation, - type GraphCreationConfig, - type GraphCreationReturn -} from "./useGraphCreation"; - -// App helpers (original hooks) -export { - useCustomNodeCommands, - useNavbarCommands, - useE2ETestingExposure, - type CustomNodeCommands, - type NavbarCommands, - type E2ETestingConfig -} from "./useAppHelpers"; - -// App content composition helpers -export { useAppAnnotations } from "./useAppAnnotations"; -export { useAppDerivedData } from "./useAppDerivedData"; -export { useAppEditorBindings } from "./useAppEditorBindings"; -export { useAppE2EExposure } from "./useAppE2EExposure"; -export { useAppGraphHandlers } from "./useAppGraphHandlers"; -export { useAppToasts } from "./useAppToasts"; -export { useDevMockTrafficStats } from "./useDevMockTrafficStats"; -export { useIconReconciliation } from "./useIconReconciliation"; -export { useUndoRedoControls } from "./useUndoRedoControls"; -export type { InitialGraphData } from "./useInitialGraphData"; - -// App initialization & subscriptions -export { - useStoreInitialization, - useGraphMessageSubscription, - useTopoViewerMessageSubscription, - useTopologyHostInitialization, - type StoreInitializationData -} from "./lifecycle"; diff --git a/src/reactTopoViewer/webview/hooks/app/lifecycle/index.ts b/src/reactTopoViewer/webview/hooks/app/lifecycle/index.ts deleted file mode 100644 index c59e3db53..000000000 --- a/src/reactTopoViewer/webview/hooks/app/lifecycle/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * App lifecycle hooks barrel. - */ -export { useStoreInitialization } from "../useStoreInitialization"; -export type { StoreInitializationData } from "../useStoreInitialization"; -export { useGraphMessageSubscription } from "../useGraphMessageSubscription"; -export { useTopoViewerMessageSubscription } from "../useTopoViewerMessageSubscription"; -export { useTopologyHostInitialization } from "../useTopologyHostInitialization"; diff --git a/src/reactTopoViewer/webview/hooks/app/useAppAnnotations.ts b/src/reactTopoViewer/webview/hooks/app/useAppAnnotations.ts deleted file mode 100644 index 86e1478a6..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useAppAnnotations.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * useAppAnnotations - app-level annotation wiring. - */ -import type { ReactFlowInstance } from "@xyflow/react"; - -import { useAnnotations } from "../canvas"; - -import { useAnnotationCanvasHandlers } from "./useAppContentHelpers"; - -interface UseAppAnnotationsParams { - rfInstance: ReactFlowInstance | null; - onLockedAction?: () => void; -} - -export function useAppAnnotations({ rfInstance, onLockedAction }: UseAppAnnotationsParams) { - const annotations = useAnnotations({ rfInstance, onLockedAction }); - const { annotationMode, canvasAnnotationHandlers } = useAnnotationCanvasHandlers(annotations); - - return { annotations, annotationMode, canvasAnnotationHandlers }; -} diff --git a/src/reactTopoViewer/webview/hooks/app/useAppContentHelpers.ts b/src/reactTopoViewer/webview/hooks/app/useAppContentHelpers.ts deleted file mode 100644 index fb15c032d..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useAppContentHelpers.ts +++ /dev/null @@ -1,402 +0,0 @@ -/** - * AppContent helpers. - */ -import React from "react"; - -import type { TopoEdge, TopoNode } from "../../../shared/types/graph"; -import { getRecordUnknown } from "../../../shared/utilities/typeHelpers"; -import { convertToEditorData, convertToNetworkEditorData } from "../../../shared/utilities"; -import type { AnnotationHandlers } from "../../components/canvas/types"; -import { - findEdgeAnnotationInLookup, - type EdgeAnnotationLookup -} from "../../annotations/edgeAnnotations"; -import { convertToLinkEditorData } from "../../utils/linkEditorConversions"; -import { parseEndpointLabelOffset } from "../../annotations/endpointLabelOffset"; -import type { AnnotationContextValue } from "../canvas"; - -interface SelectionStateSlice { - selectedNode: string | null; - selectedEdge: string | null; - editingImpairment: string | null; - editingNode: string | null; - editingEdge: string | null; - editingNetwork: string | null; - endpointLabelOffset: number; -} - -type EdgeRawData = { id: string; source: string; target: string } & Record; -type NodeRawData = { id: string } & Record; - -/** Extract edge raw data by ID */ -function getEdgeRawData(edgeId: string | null, edges: TopoEdge[]): EdgeRawData | null { - if (edgeId === null || edgeId.length === 0) return null; - const edge = edges.find((e) => e.id === edgeId); - if (!edge) return null; - return { - id: edge.id, - source: edge.source, - target: edge.target, - ...getRecordUnknown(edge.data) - }; -} - -function getNodeRawData(nodeId: string | null, nodes: TopoNode[]): NodeRawData | null { - if (nodeId === null || nodeId.length === 0) return null; - const node = nodes.find((n) => n.id === nodeId); - if (!node) return null; - const data = getRecordUnknown(node.data); - if (data === undefined) { - return { id: node.id }; - } - return { id: node.id, ...data }; -} - -function getAnnotationHandlerSnapshot(annotations: AnnotationContextValue) { - return { - handleTextCanvasClick: annotations.handleTextCanvasClick, - handleShapeCanvasClick: annotations.handleShapeCanvasClick, - disableAddTextMode: annotations.disableAddTextMode, - disableAddShapeMode: annotations.disableAddShapeMode, - editTextAnnotation: annotations.editTextAnnotation, - editShapeAnnotation: annotations.editShapeAnnotation, - editTrafficRateAnnotation: annotations.editTrafficRateAnnotation, - deleteTextAnnotation: annotations.deleteTextAnnotation, - deleteShapeAnnotation: annotations.deleteShapeAnnotation, - deleteTrafficRateAnnotation: annotations.deleteTrafficRateAnnotation, - updateTextSize: annotations.updateTextSize, - updateShapeSize: annotations.updateShapeSize, - updateTrafficRateSize: annotations.updateTrafficRateSize, - updateTextRotation: annotations.updateTextRotation, - updateShapeRotation: annotations.updateShapeRotation, - onTextRotationStart: annotations.onTextRotationStart, - onTextRotationEnd: annotations.onTextRotationEnd, - onShapeRotationStart: annotations.onShapeRotationStart, - onShapeRotationEnd: annotations.onShapeRotationEnd, - updateShapeStartPosition: annotations.updateShapeStartPosition, - updateShapeEndPosition: annotations.updateShapeEndPosition, - persistAnnotations: annotations.persistAnnotations, - onNodeDropped: annotations.onNodeDropped, - updateGroupSize: annotations.updateGroupSize, - editGroup: annotations.editGroup, - deleteGroup: annotations.deleteGroup, - getGroupMembers: annotations.getGroupMembers - }; -} - -export function useCustomNodeErrorToast( - customNodeError: unknown, - addToast: (message: string, type?: "success" | "error" | "info", duration?: number) => void, - clearCustomNodeError: () => void -): void { - React.useEffect(() => { - if (customNodeError === null || customNodeError === undefined) return; - const errorMsg = typeof customNodeError === "string" ? customNodeError : "Unknown error"; - addToast(`Failed to save custom node: ${errorMsg}`, "error", 5000); - clearCustomNodeError(); - }, [customNodeError, addToast, clearCustomNodeError]); -} - -export function useFilteredGraphElements( - nodes: TopoNode[], - edges: TopoEdge[], - showDummyLinks: boolean -): { filteredNodes: TopoNode[]; filteredEdges: TopoEdge[] } { - const filteredNodes = React.useMemo(() => { - if (showDummyLinks) return nodes; - return nodes.filter((node) => !node.id.startsWith("dummy")); - }, [nodes, showDummyLinks]); - - const filteredEdges = React.useMemo(() => { - if (showDummyLinks) return edges; - const dummyNodeIds = new Set( - nodes.filter((node) => node.id.startsWith("dummy")).map((node) => node.id) - ); - return edges.filter((edge) => !dummyNodeIds.has(edge.source) && !dummyNodeIds.has(edge.target)); - }, [nodes, edges, showDummyLinks]); - - return { filteredNodes, filteredEdges }; -} - -export function useSelectionData( - state: SelectionStateSlice, - nodes: TopoNode[], - edges: TopoEdge[], - edgeAnnotationLookup: EdgeAnnotationLookup -) { - const selectedNodeData = React.useMemo( - () => getNodeRawData(state.selectedNode, nodes), - [state.selectedNode, nodes] - ); - - const selectedLinkData = React.useMemo( - () => getEdgeRawData(state.selectedEdge, edges), - [state.selectedEdge, edges] - ); - - const selectedLinkImpairmentData = React.useMemo( - () => getEdgeRawData(state.editingImpairment, edges), - [state.editingImpairment, edges] - ); - - const editingNodeRawData = React.useMemo( - () => getNodeRawData(state.editingNode, nodes), - [state.editingNode, nodes] - ); - - const editingNetworkRawData = React.useMemo( - () => getNodeRawData(state.editingNetwork, nodes), - [state.editingNetwork, nodes] - ); - - const editingLinkRawData = React.useMemo( - () => getEdgeRawData(state.editingEdge, edges), - [state.editingEdge, edges] - ); - - const editingNodeData = React.useMemo( - () => convertToEditorData(editingNodeRawData), - [editingNodeRawData] - ); - const selectedNodeEditorData = React.useMemo( - () => convertToEditorData(selectedNodeData), - [selectedNodeData] - ); - const editingNodeInheritedProps = React.useMemo(() => { - const extra = getRecordUnknown(editingNodeRawData?.["extraData"]); - const inherited = extra?.inherited; - return Array.isArray(inherited) - ? inherited.filter((p): p is string => typeof p === "string") - : []; - }, [editingNodeRawData]); - const selectedNodeInheritedProps = React.useMemo(() => { - const extra = getRecordUnknown(selectedNodeData?.["extraData"]); - const inherited = extra?.inherited; - return Array.isArray(inherited) - ? inherited.filter((p): p is string => typeof p === "string") - : []; - }, [selectedNodeData]); - const editingNetworkData = React.useMemo( - () => convertToNetworkEditorData(editingNetworkRawData), - [editingNetworkRawData] - ); - const editingLinkData = React.useMemo(() => { - const base = convertToLinkEditorData(editingLinkRawData); - if (!base) return null; - const annotation = findEdgeAnnotationInLookup(edgeAnnotationLookup, { - id: base.id, - source: base.source, - target: base.target, - sourceEndpoint: base.sourceEndpoint, - targetEndpoint: base.targetEndpoint - }); - const offset = - parseEndpointLabelOffset(annotation?.endpointLabelOffset) ?? state.endpointLabelOffset; - const enabled = - annotation?.endpointLabelOffsetEnabled ?? - (annotation?.endpointLabelOffset !== undefined ? true : false); - return { - ...base, - endpointLabelOffsetEnabled: enabled, - endpointLabelOffset: offset - }; - }, [editingLinkRawData, edgeAnnotationLookup, state.endpointLabelOffset]); - - return { - selectedNodeData, - selectedNodeEditorData, - selectedNodeInheritedProps, - selectedLinkData, - selectedLinkImpairmentData, - editingNodeData, - editingNetworkData, - editingLinkData, - editingNodeInheritedProps - }; -} - -export function useAnnotationCanvasHandlers(annotations: AnnotationContextValue): { - annotationMode: { - isAddTextMode: boolean; - isAddShapeMode: boolean; - pendingShapeType?: "rectangle" | "circle" | "line"; - }; - canvasAnnotationHandlers: AnnotationHandlers; -} { - const annotationMode = React.useMemo( - () => ({ - isAddTextMode: annotations.isAddTextMode, - isAddShapeMode: annotations.isAddShapeMode, - pendingShapeType: annotations.isAddShapeMode ? annotations.pendingShapeType : undefined - }), - [annotations.isAddTextMode, annotations.isAddShapeMode, annotations.pendingShapeType] - ); - - // Keep a stable handlers object for ReactFlow/canvas store subscribers. - const latestAnnotationsRef = React.useRef(getAnnotationHandlerSnapshot(annotations)); - latestAnnotationsRef.current = getAnnotationHandlerSnapshot(annotations); - - const onAddTextClick = React.useCallback((position: { x: number; y: number }) => { - latestAnnotationsRef.current.handleTextCanvasClick(position); - }, []); - const onAddShapeClick = React.useCallback((position: { x: number; y: number }) => { - latestAnnotationsRef.current.handleShapeCanvasClick(position); - }, []); - const disableAddTextMode = React.useCallback(() => { - latestAnnotationsRef.current.disableAddTextMode(); - }, []); - const disableAddShapeMode = React.useCallback(() => { - latestAnnotationsRef.current.disableAddShapeMode(); - }, []); - const onEditFreeText = React.useCallback((id: string) => { - latestAnnotationsRef.current.editTextAnnotation(id); - }, []); - const onEditFreeShape = React.useCallback((id: string) => { - latestAnnotationsRef.current.editShapeAnnotation(id); - }, []); - const onEditTrafficRate = React.useCallback((id: string) => { - latestAnnotationsRef.current.editTrafficRateAnnotation(id); - }, []); - const onDeleteFreeText = React.useCallback((id: string) => { - latestAnnotationsRef.current.deleteTextAnnotation(id); - }, []); - const onDeleteFreeShape = React.useCallback((id: string) => { - latestAnnotationsRef.current.deleteShapeAnnotation(id); - }, []); - const onDeleteTrafficRate = React.useCallback((id: string) => { - latestAnnotationsRef.current.deleteTrafficRateAnnotation(id); - }, []); - const onUpdateFreeTextSize = React.useCallback((id: string, width: number, height: number) => { - latestAnnotationsRef.current.updateTextSize(id, width, height); - }, []); - const onUpdateFreeShapeSize = React.useCallback((id: string, width: number, height: number) => { - latestAnnotationsRef.current.updateShapeSize(id, width, height); - }, []); - const onUpdateTrafficRateSize = React.useCallback((id: string, width: number, height: number) => { - latestAnnotationsRef.current.updateTrafficRateSize(id, width, height); - }, []); - const onUpdateFreeTextRotation = React.useCallback((id: string, rotation: number) => { - latestAnnotationsRef.current.updateTextRotation(id, rotation); - }, []); - const onUpdateFreeShapeRotation = React.useCallback((id: string, rotation: number) => { - latestAnnotationsRef.current.updateShapeRotation(id, rotation); - }, []); - const onFreeTextRotationStart = React.useCallback((id: string) => { - latestAnnotationsRef.current.onTextRotationStart(id); - }, []); - const onFreeTextRotationEnd = React.useCallback((id: string) => { - latestAnnotationsRef.current.onTextRotationEnd(id); - }, []); - const onFreeShapeRotationStart = React.useCallback((id: string) => { - latestAnnotationsRef.current.onShapeRotationStart(id); - }, []); - const onFreeShapeRotationEnd = React.useCallback((id: string) => { - latestAnnotationsRef.current.onShapeRotationEnd(id); - }, []); - const onUpdateFreeShapeStartPosition = React.useCallback( - (id: string, startPosition: { x: number; y: number }) => { - latestAnnotationsRef.current.updateShapeStartPosition(id, startPosition); - }, - [] - ); - const onUpdateFreeShapeEndPosition = React.useCallback( - (id: string, endPosition: { x: number; y: number }) => { - latestAnnotationsRef.current.updateShapeEndPosition(id, endPosition); - }, - [] - ); - const onPersistAnnotations = React.useCallback(() => { - latestAnnotationsRef.current.persistAnnotations(); - }, []); - const onNodeDropped = React.useCallback((nodeId: string, position: { x: number; y: number }) => { - latestAnnotationsRef.current.onNodeDropped(nodeId, position); - }, []); - const onUpdateGroupSize = React.useCallback((id: string, width: number, height: number) => { - latestAnnotationsRef.current.updateGroupSize(id, width, height); - }, []); - const onEditGroup = React.useCallback((id: string) => { - latestAnnotationsRef.current.editGroup(id); - }, []); - const onDeleteGroup = React.useCallback((id: string) => { - latestAnnotationsRef.current.deleteGroup(id); - }, []); - const getGroupMembers = React.useCallback( - (groupId: string, options?: { includeNested?: boolean }) => - latestAnnotationsRef.current.getGroupMembers(groupId, options), - [] - ); - - const canvasAnnotationHandlers: AnnotationHandlers = React.useMemo( - () => ({ - // Add mode handlers - onAddTextClick, - onAddShapeClick, - disableAddTextMode, - disableAddShapeMode, - // Edit handlers - onEditFreeText, - onEditFreeShape, - onEditTrafficRate, - // Delete handlers - onDeleteFreeText, - onDeleteFreeShape, - onDeleteTrafficRate, - // Size update handlers (for resize) - onUpdateFreeTextSize, - onUpdateFreeShapeSize, - onUpdateTrafficRateSize, - // Rotation handlers (live updates during drag) - onUpdateFreeTextRotation, - onUpdateFreeShapeRotation, - // Rotation start/end handlers (for undo/redo) - onFreeTextRotationStart, - onFreeTextRotationEnd, - onFreeShapeRotationStart, - onFreeShapeRotationEnd, - // Line-specific handlers - onUpdateFreeShapeStartPosition, - onUpdateFreeShapeEndPosition, - // Persist annotations (call on drag end) - onPersistAnnotations, - // Node dropped handler (for group membership) - onNodeDropped, - // Group handlers - onUpdateGroupSize, - onEditGroup, - onDeleteGroup, - // Get group members (for group dragging) - getGroupMembers - }), - [ - onAddTextClick, - onAddShapeClick, - disableAddTextMode, - disableAddShapeMode, - onEditFreeText, - onEditFreeShape, - onEditTrafficRate, - onDeleteFreeText, - onDeleteFreeShape, - onDeleteTrafficRate, - onUpdateFreeTextSize, - onUpdateFreeShapeSize, - onUpdateTrafficRateSize, - onUpdateFreeTextRotation, - onUpdateFreeShapeRotation, - onFreeTextRotationStart, - onFreeTextRotationEnd, - onFreeShapeRotationStart, - onFreeShapeRotationEnd, - onUpdateFreeShapeStartPosition, - onUpdateFreeShapeEndPosition, - onPersistAnnotations, - onNodeDropped, - onUpdateGroupSize, - onEditGroup, - onDeleteGroup, - getGroupMembers - ] - ); - - return { annotationMode, canvasAnnotationHandlers }; -} diff --git a/src/reactTopoViewer/webview/hooks/app/useAppDerivedData.ts b/src/reactTopoViewer/webview/hooks/app/useAppDerivedData.ts deleted file mode 100644 index d5e470318..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useAppDerivedData.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * useAppDerivedData - derived graph and selection data for AppContent. - */ -import React from "react"; - -import type { TopoEdge, TopoNode } from "../../../shared/types/graph"; -import type { TopoViewerState } from "../../stores/topoViewerStore"; -import { buildEdgeAnnotationLookup } from "../../annotations/edgeAnnotations"; - -import { useFilteredGraphElements, useSelectionData } from "./useAppContentHelpers"; - -interface AppDerivedDataParams { - state: Pick< - TopoViewerState, - | "edgeAnnotations" - | "showDummyLinks" - | "selectedNode" - | "selectedEdge" - | "editingImpairment" - | "editingNode" - | "editingEdge" - | "editingNetwork" - | "endpointLabelOffset" - | "mode" - >; - nodes: TopoNode[]; - edges: TopoEdge[]; -} - -export function useAppDerivedData({ state, nodes, edges }: AppDerivedDataParams) { - const edgeAnnotationLookup = React.useMemo( - () => buildEdgeAnnotationLookup(state.edgeAnnotations), - [state.edgeAnnotations] - ); - - const { filteredNodes, filteredEdges } = useFilteredGraphElements( - nodes, - edges, - state.showDummyLinks - ); - - const selectionData = useSelectionData(state, nodes, edges, edgeAnnotationLookup); - - return { filteredNodes, filteredEdges, selectionData, edgeAnnotationLookup }; -} diff --git a/src/reactTopoViewer/webview/hooks/app/useAppE2EExposure.ts b/src/reactTopoViewer/webview/hooks/app/useAppE2EExposure.ts deleted file mode 100644 index da846b642..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useAppE2EExposure.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * useAppE2EExposure - wires E2E test helpers for AppContent. - */ -import type { ReactFlowInstance } from "@xyflow/react"; - -import type { TopoViewerActions, TopoViewerState } from "../../stores/topoViewerStore"; -import type { useLayoutControls } from "../ui"; -import type { GroupStyleAnnotation } from "../../../shared/types/topology"; - -import type { GraphCreationReturn } from "./useGraphCreation"; -import type { UndoRedoControls } from "./useUndoRedoControls"; -import { useE2ETestingExposure } from "./useAppHelpers"; -import type { AppGraphHandlers } from "./useAppGraphHandlers"; - -type LayoutControls = ReturnType; - -interface AppE2EExposureParams { - state: Pick; - actions: Pick< - TopoViewerActions, - "toggleLock" | "editNode" | "editNetwork" | "selectNode" | "selectEdge" | "setMode" - >; - undoRedo: Pick; - graphHandlers: Pick; - annotations: { - handleAddGroup: () => void; - getGroups: () => GroupStyleAnnotation[]; - }; - graphCreation: Pick; - layoutControls: LayoutControls; - rfInstance: ReactFlowInstance | null; -} - -export function useAppE2EExposure({ - state, - actions, - undoRedo, - graphHandlers, - annotations, - graphCreation, - layoutControls, - rfInstance -}: AppE2EExposureParams): void { - useE2ETestingExposure({ - isLocked: state.isLocked, - mode: state.mode, - toggleLock: actions.toggleLock, - setMode: actions.setMode, - undoRedo, - handleEdgeCreated: graphHandlers.handleEdgeCreated, - handleNodeCreatedCallback: graphHandlers.handleNodeCreatedCallback, - handleAddGroup: annotations.handleAddGroup, - createNetworkAtPosition: graphCreation.createNetworkAtPosition, - editNode: actions.editNode, - editNetwork: actions.editNetwork, - getGroups: annotations.getGroups, - elements: [], - setLayout: layoutControls.setLayout, - isGeoLayout: layoutControls.isGeoLayout, - rfInstance, - selectedNode: state.selectedNode, - selectedEdge: state.selectedEdge, - selectNode: actions.selectNode, - selectEdge: actions.selectEdge - }); -} diff --git a/src/reactTopoViewer/webview/hooks/app/useAppEditorBindings.ts b/src/reactTopoViewer/webview/hooks/app/useAppEditorBindings.ts deleted file mode 100644 index 0db1745ba..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useAppEditorBindings.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * useAppEditorBindings - editor handler wiring for AppContent. - */ -import type { LinkEditorData, NodeEditorData } from "../../../shared/types/editors"; -import type { TopoViewerActions, TopoViewerState } from "../../stores/topoViewerStore"; -import { useNodeEditorHandlers, useLinkEditorHandlers, useNetworkEditorHandlers } from "../editor"; - -import type { useSelectionData } from "./useAppContentHelpers"; - -interface AppEditorBindingsParams { - state: Pick; - actions: Pick< - TopoViewerActions, - "editNode" | "editEdge" | "editNetwork" | "setEdgeAnnotations" | "refreshEditorData" - >; - selectionData: ReturnType; - effectiveNodeEditorData: NodeEditorData | null; - renameNodeInGraph: (oldId: string, newId: string, name?: string) => void; - handleUpdateNodeData: (nodeId: string, extraData: Record) => void; - handleUpdateEdgeData: (edgeId: string, data: LinkEditorData) => void; -} - -export function useAppEditorBindings({ - state, - actions, - selectionData, - effectiveNodeEditorData, - renameNodeInGraph, - handleUpdateNodeData, - handleUpdateEdgeData -}: AppEditorBindingsParams) { - const { editNode, editEdge, editNetwork, setEdgeAnnotations, refreshEditorData } = actions; - - const nodeEditorHandlers = useNodeEditorHandlers( - editNode, - effectiveNodeEditorData, - renameNodeInGraph, - handleUpdateNodeData, - refreshEditorData - ); - - const linkEditorHandlers = useLinkEditorHandlers( - editEdge, - selectionData.editingLinkData, - { - edgeAnnotations: state.edgeAnnotations, - setEdgeAnnotations - }, - handleUpdateEdgeData - ); - - const networkEditorHandlers = useNetworkEditorHandlers( - editNetwork, - selectionData.editingNetworkData, - renameNodeInGraph - ); - - return { - nodeEditorHandlers, - linkEditorHandlers, - networkEditorHandlers - }; -} diff --git a/src/reactTopoViewer/webview/hooks/app/useAppGraphHandlers.ts b/src/reactTopoViewer/webview/hooks/app/useAppGraphHandlers.ts deleted file mode 100644 index 5a2b942aa..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useAppGraphHandlers.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * useAppGraphHandlers - app-level graph mutation wiring. - */ -import React from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; - -import type { LinkEditorData } from "../../../shared/types/editors"; -import type { - TopologyEdgeData, - TopoEdge, - TopoNode, - EdgeCreatedHandler, - NodeCreatedHandler -} from "../../../shared/types/graph"; -import { getRecordUnknown } from "../../../shared/utilities/typeHelpers"; -import type { GraphActions } from "../../stores/graphStore"; -import { convertEditorDataToLinkSaveData } from "../../utils/linkEditorConversions"; -import { useGraphHandlersWithContext } from "../state"; - -interface MenuHandlers { - handleDeleteNode: (nodeId: string) => void; - handleDeleteLink: (edgeId: string) => void; -} - -type GraphActionSubset = Pick< - GraphActions, - | "addNode" - | "addEdge" - | "removeNodeAndEdges" - | "removeEdge" - | "updateNodeData" - | "updateEdge" - | "renameNode" ->; - -interface AppGraphHandlersConfig { - rfInstance: ReactFlowInstance | null; - menuHandlers: MenuHandlers; - actions: GraphActionSubset; -} - -function isTopologyEdgeData(value: unknown): value is TopologyEdgeData { - const record = getRecordUnknown(value) ?? {}; - const sourceEndpoint = record.sourceEndpoint; - const targetEndpoint = record.targetEndpoint; - return typeof sourceEndpoint === "string" && typeof targetEndpoint === "string"; -} - -export interface AppGraphHandlers { - handleEdgeCreated: EdgeCreatedHandler; - handleNodeCreatedCallback: NodeCreatedHandler; - handleBatchPaste: (payload: { nodes: TopoNode[]; edges: TopoEdge[] }) => void; - handleDeleteNode: (nodeId: string) => void; - handleDeleteLink: (edgeId: string) => void; - handleUpdateNodeData: (nodeId: string, extraData: Record) => void; - handleUpdateEdgeData: (edgeId: string, data: LinkEditorData) => void; - renameNodeInGraph: (oldId: string, newId: string, name?: string) => void; - addNodeDirect: (node: TopoNode) => void; - addEdgeDirect: (edge: TopoEdge) => void; -} - -export function useAppGraphHandlers({ - rfInstance, - menuHandlers, - actions -}: AppGraphHandlersConfig): AppGraphHandlers { - const { - addNode, - addEdge, - removeNodeAndEdges, - removeEdge, - updateNodeData, - updateEdge, - renameNode - } = actions; - - const addNodeDirect = React.useCallback( - (node: TopoNode) => { - addNode(node); - }, - [addNode] - ); - - const addEdgeDirect = React.useCallback( - (edge: TopoEdge) => { - addEdge(edge); - }, - [addEdge] - ); - - const getNodes = React.useCallback(() => rfInstance?.getNodes() ?? [], [rfInstance]); - const getEdges = React.useCallback((): TopoEdge[] => { - const edges = rfInstance?.getEdges() ?? []; - return edges.filter((edge): edge is TopoEdge => isTopologyEdgeData(edge.data)); - }, [rfInstance]); - - const { - handleEdgeCreated, - handleNodeCreatedCallback, - handleBatchPaste, - handleDeleteNode, - handleDeleteLink - } = useGraphHandlersWithContext({ - getNodes, - getEdges, - addNode: addNodeDirect, - addEdge: addEdgeDirect, - removeNodeAndEdges, - removeEdge, - menuHandlers - }); - - const handleUpdateNodeData = React.useCallback( - (nodeId: string, extraData: Record) => { - updateNodeData(nodeId, extraData); - }, - [updateNodeData] - ); - - const handleUpdateEdgeData = React.useCallback( - (edgeId: string, data: LinkEditorData) => { - const saveData = convertEditorDataToLinkSaveData(data); - updateEdge(edgeId, { - source: saveData.source, - target: saveData.target, - data: { - sourceEndpoint: saveData.sourceEndpoint ?? data.sourceEndpoint, - targetEndpoint: saveData.targetEndpoint ?? data.targetEndpoint, - ...(saveData.extraData ? { extraData: saveData.extraData } : {}) - } - }); - }, - [updateEdge] - ); - - const renameNodeInGraph = React.useCallback( - (oldId: string, newId: string, name?: string) => { - renameNode(oldId, newId, name); - }, - [renameNode] - ); - - return { - handleEdgeCreated, - handleNodeCreatedCallback, - handleBatchPaste, - handleDeleteNode, - handleDeleteLink, - handleUpdateNodeData, - handleUpdateEdgeData, - renameNodeInGraph, - addNodeDirect, - addEdgeDirect - }; -} diff --git a/src/reactTopoViewer/webview/hooks/app/useAppHelpers.ts b/src/reactTopoViewer/webview/hooks/app/useAppHelpers.ts deleted file mode 100644 index 5ced27d21..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useAppHelpers.ts +++ /dev/null @@ -1,315 +0,0 @@ -/** - * App helper hooks - extracted from App.tsx to reduce file size - */ -import React from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; - -import type { - CustomNodeTemplate, - CustomTemplateEditorData, - NetworkType -} from "../../../shared/types/editors"; -import type { EdgeCreatedHandler, NodeCreatedHandler } from "../../../shared/types/graph"; -import { - createNewTemplateEditorData, - convertTemplateToEditorData -} from "../../../shared/utilities/customNodeConversions"; -import { - sendDeleteCustomNode, - sendSetDefaultCustomNode, - sendCommandToExtension -} from "../../messaging/extensionMessaging"; -import type { GroupStyleAnnotation } from "../../../shared/types/topology"; -import { useTopoViewerStore } from "../../stores/topoViewerStore"; - -/** - * Custom node template UI commands interface - */ -export interface CustomNodeCommands { - onNewCustomNode: () => void; - onEditCustomNode: (nodeName: string) => void; - onDeleteCustomNode: (nodeName: string) => void; - onSetDefaultCustomNode: (nodeName: string) => void; -} - -/** - * Hook for custom node template UI commands - */ -export function useCustomNodeCommands( - customNodes: CustomNodeTemplate[], - editCustomTemplate: (data: CustomTemplateEditorData | null) => void -): CustomNodeCommands { - const onNewCustomNode = React.useCallback(() => { - const templateData = createNewTemplateEditorData(); - editCustomTemplate(templateData); - }, [editCustomTemplate]); - - const onEditCustomNode = React.useCallback( - (nodeName: string) => { - // Handle special "__new__" case for creating new custom nodes - if (nodeName === "__new__") { - const templateData = createNewTemplateEditorData(); - editCustomTemplate(templateData); - return; - } - const template = customNodes.find((n) => n.name === nodeName); - if (!template) return; - const templateData = convertTemplateToEditorData(template); - editCustomTemplate(templateData); - }, - [customNodes, editCustomTemplate] - ); - - const onDeleteCustomNode = React.useCallback((nodeName: string) => { - sendDeleteCustomNode(nodeName); - }, []); - - const onSetDefaultCustomNode = React.useCallback((nodeName: string) => { - sendSetDefaultCustomNode(nodeName); - }, []); - - return { - onNewCustomNode, - onEditCustomNode, - onDeleteCustomNode, - onSetDefaultCustomNode - }; -} - -/** - * Navbar commands interface - */ -export interface NavbarCommands { - onLayoutToggle: () => void; - onToggleSplit: () => void; -} - -/** - * Hook for navbar UI commands - */ -export function useNavbarCommands(): NavbarCommands { - const onLayoutToggle = React.useCallback(() => { - // Layout selection is handled entirely in the webview. - }, []); - - const onToggleSplit = React.useCallback(() => { - sendCommandToExtension("topo-toggle-split-view"); - }, []); - - return { - onLayoutToggle, - onToggleSplit - }; -} - -/** Layout option type for E2E testing */ -export type LayoutOption = "preset" | "force" | "geo"; - -/** - * E2E testing exposure configuration - */ -export interface E2ETestingConfig { - isLocked: boolean; - mode: "edit" | "view"; - toggleLock: () => void; - setMode?: (mode: "edit" | "view") => void; - undoRedo: { - canUndo: boolean; - canRedo: boolean; - }; - handleEdgeCreated: EdgeCreatedHandler; - handleNodeCreatedCallback: NodeCreatedHandler; - handleAddGroup: () => void; - createNetworkAtPosition: ( - position: { x: number; y: number }, - networkType: NetworkType - ) => string | null; - editNode?: (nodeId: string | null) => void; - editNetwork?: (nodeId: string | null) => void; - groups?: GroupStyleAnnotation[]; - getGroups?: () => GroupStyleAnnotation[]; - elements: unknown[]; - /** Layout controls for E2E testing */ - setLayout?: (layout: LayoutOption) => void; - isGeoLayout?: boolean; - /** React Flow instance for E2E testing */ - rfInstance?: ReactFlowInstance | null; - /** Selection state for E2E testing */ - selectedNode?: string | null; - selectedEdge?: string | null; - /** Selection actions for E2E testing */ - selectNode?: (nodeId: string | null) => void; - selectEdge?: (edgeId: string | null) => void; -} - -/** - * Hook to expose testing utilities for E2E tests. - * Consolidates all window.__DEV__ assignments into one place. - */ -export function useE2ETestingExposure(config: E2ETestingConfig): void { - const { - isLocked, - mode, - toggleLock, - setMode, - undoRedo, - handleEdgeCreated, - handleNodeCreatedCallback, - handleAddGroup, - createNetworkAtPosition, - editNode, - editNetwork, - groups, - getGroups, - elements, - setLayout, - isGeoLayout, - rfInstance, - selectedNode, - selectedEdge, - selectNode, - selectEdge - } = config; - - // Core E2E exposure (isLocked, mode, setLocked) - React.useEffect(() => { - if (typeof window !== "undefined" && window.__DEV__) { - window.__DEV__.isLocked = () => isLocked; - window.__DEV__.mode = () => mode; - window.__DEV__.setLocked = (locked: boolean) => { - if (isLocked !== locked) toggleLock(); - }; - if (setMode) { - window.__DEV__.setModeState = (nextMode: "edit" | "view") => { - setMode(nextMode); - }; - } - } - }, [isLocked, mode, toggleLock, setMode]); - - // Undo/redo E2E exposure - React.useEffect(() => { - if (typeof window !== "undefined" && window.__DEV__) { - window.__DEV__.undoRedo = { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }; - window.__DEV__.handleEdgeCreated = handleEdgeCreated; - window.__DEV__.handleNodeCreatedCallback = handleNodeCreatedCallback; - window.__DEV__.createGroupFromSelected = handleAddGroup; - window.__DEV__.createNetworkAtPosition = createNetworkAtPosition; - } - }, [ - undoRedo.canUndo, - undoRedo.canRedo, - handleEdgeCreated, - handleNodeCreatedCallback, - handleAddGroup, - createNetworkAtPosition - ]); - - // Node editor E2E exposure - React.useEffect(() => { - if (typeof window !== "undefined" && window.__DEV__ && editNode) { - window.__DEV__.openNodeEditor = (nodeId: string | null) => { - editNode(nodeId); - }; - } - }, [editNode]); - - // Network editor E2E exposure - React.useEffect(() => { - if (typeof window !== "undefined" && window.__DEV__ && editNetwork) { - window.__DEV__.openNetworkEditor = (nodeId: string | null) => { - editNetwork(nodeId); - }; - } - }, [editNetwork]); - - const resolveGroups = React.useCallback(() => { - if (getGroups) { - return getGroups(); - } - return groups ?? []; - }, [getGroups, groups]); - - // Groups E2E exposure - React.useEffect(() => { - if (typeof window !== "undefined" && window.__DEV__) { - window.__DEV__.getReactGroups = () => resolveGroups(); - Object.defineProperty(window.__DEV__, "groupsCount", { - configurable: true, - enumerable: true, - get: () => resolveGroups().length - }); - } - }, [resolveGroups]); - - // Elements E2E exposure - React.useEffect(() => { - if (typeof window !== "undefined" && window.__DEV__) { - window.__DEV__.getElements = () => elements; - } - }, [elements]); - - // Layout controls E2E exposure - React.useEffect(() => { - if (typeof window !== "undefined" && window.__DEV__) { - if (setLayout) window.__DEV__.setLayout = setLayout; - window.__DEV__.isGeoLayout = () => isGeoLayout ?? false; - } - }, [setLayout, isGeoLayout]); - - // React Flow instance E2E exposure (replaces Cytoscape cy) - React.useEffect(() => { - if (typeof window !== "undefined" && window.__DEV__) { - window.__DEV__.rfInstance = rfInstance ?? undefined; - } - }, [rfInstance]); - - // Selection state and actions E2E exposure - // Use refs to ensure the exposed functions always return the latest value - const selectedNodeRef = React.useRef(selectedNode); - const selectedEdgeRef = React.useRef(selectedEdge); - selectedNodeRef.current = selectedNode; - selectedEdgeRef.current = selectedEdge; - - React.useEffect(() => { - if (typeof window !== "undefined" && window.__DEV__) { - // Use ref.current to always get the latest value - window.__DEV__.selectedNode = () => selectedNodeRef.current ?? null; - window.__DEV__.selectedEdge = () => selectedEdgeRef.current ?? null; - if (selectNode) window.__DEV__.selectNode = selectNode; - if (selectEdge) window.__DEV__.selectEdge = selectEdge; - - // React Flow node selection for clipboard operations - // This updates the React Flow nodes' `selected` property directly - window.__DEV__.selectNodesForClipboard = (nodeIds: string[]) => { - if (!rfInstance) return; - const nodeIdSet = new Set(nodeIds); - const nodes = rfInstance.getNodes(); - const updatedNodes = nodes.map((node) => ({ - ...node, - selected: nodeIdSet.has(node.id) - })); - rfInstance.setNodes(updatedNodes); - }; - - // Clear all React Flow node selections - window.__DEV__.clearNodeSelection = () => { - if (!rfInstance) return; - const nodes = rfInstance.getNodes(); - const updatedNodes = nodes.map((node) => ({ - ...node, - selected: false - })); - rfInstance.setNodes(updatedNodes); - }; - } - }, [selectNode, selectEdge, rfInstance]); // Include rfInstance in dependencies - - // Dummy links toggle E2E exposure - const toggleDummyLinks = useTopoViewerStore((state) => state.toggleDummyLinks); - React.useEffect(() => { - if (typeof window !== "undefined" && window.__DEV__) { - window.__DEV__.toggleDummyLinks = toggleDummyLinks; - } - }, [toggleDummyLinks]); -} diff --git a/src/reactTopoViewer/webview/hooks/app/useAppKeyboardShortcuts.ts b/src/reactTopoViewer/webview/hooks/app/useAppKeyboardShortcuts.ts deleted file mode 100644 index 3127ca8a9..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useAppKeyboardShortcuts.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * useAppKeyboardShortcuts - App-level keyboard shortcuts integration - * - * Extracts keyboard shortcut configuration from App.tsx: - * - Combined selection IDs calculation - * - useKeyboardShortcuts call with all 20+ arguments - */ -import React from "react"; - -import { useKeyboardShortcuts } from "../ui/useKeyboardShortcuts"; - -import type { ClipboardHandlersReturn } from "./useClipboardHandlers"; - -/** - * Configuration for useAppKeyboardShortcuts hook - */ -export interface AppKeyboardShortcutsConfig { - state: { - mode: "edit" | "view"; - isLocked: boolean; - selectedNode: string | null; - selectedEdge: string | null; - }; - undoRedo: { - undo: () => void; - redo: () => void; - canUndo: boolean; - canRedo: boolean; - }; - annotations: { - selectedTextIds: Set; - selectedShapeIds: Set; - selectedTrafficRateIds: Set; - selectedGroupIds: Set; - clearAllSelections: () => void; - handleAddGroup: () => void; - }; - clipboardHandlers: ClipboardHandlersReturn; - deleteHandlers: { - handleDeleteNode: (nodeId: string) => void; - handleDeleteLink: (edgeId: string) => void; - handleDeleteSelection?: () => void; - }; - handleDeselectAll: () => void; -} - -/** - * Hook that configures app-level keyboard shortcuts. - * - * Simplifies the 20+ argument useKeyboardShortcuts call into a structured config. - */ -export function useAppKeyboardShortcuts(config: AppKeyboardShortcutsConfig): void { - const { state, undoRedo, annotations, clipboardHandlers, deleteHandlers, handleDeselectAll } = - config; - - // Combined selection IDs (text + shape + group annotations) - const combinedSelectedAnnotationIds = React.useMemo(() => { - const combined = new Set([ - ...annotations.selectedTextIds, - ...annotations.selectedShapeIds, - ...annotations.selectedTrafficRateIds - ]); - annotations.selectedGroupIds.forEach((id) => combined.add(id)); - return combined; - }, [ - annotations.selectedTextIds, - annotations.selectedShapeIds, - annotations.selectedTrafficRateIds, - annotations.selectedGroupIds - ]); - - // Keyboard shortcuts - useKeyboardShortcuts({ - mode: state.mode, - isLocked: state.isLocked, - selectedNode: state.selectedNode, - selectedEdge: state.selectedEdge, - onDeleteNode: deleteHandlers.handleDeleteNode, - onDeleteEdge: deleteHandlers.handleDeleteLink, - onDeleteSelection: deleteHandlers.handleDeleteSelection, - onDeselectAll: handleDeselectAll, - onUndo: undoRedo.undo, - onRedo: undoRedo.redo, - canUndo: undoRedo.canUndo, - canRedo: undoRedo.canRedo, - onCopy: clipboardHandlers.handleUnifiedCopy, - onPaste: clipboardHandlers.handleUnifiedPaste, - onDuplicate: clipboardHandlers.handleUnifiedDuplicate, - selectedAnnotationIds: combinedSelectedAnnotationIds, - onCopyAnnotations: clipboardHandlers.handleUnifiedCopy, - onPasteAnnotations: clipboardHandlers.handleUnifiedPaste, - onDuplicateAnnotations: clipboardHandlers.handleUnifiedDuplicate, - onDeleteAnnotations: clipboardHandlers.handleUnifiedDelete, - onClearAnnotationSelection: annotations.clearAllSelections, - hasAnnotationClipboard: clipboardHandlers.hasClipboardData, - hasGraphClipboard: clipboardHandlers.hasClipboardData, - onCreateGroup: annotations.handleAddGroup - }); -} diff --git a/src/reactTopoViewer/webview/hooks/app/useAppToasts.ts b/src/reactTopoViewer/webview/hooks/app/useAppToasts.ts deleted file mode 100644 index cd37c276c..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useAppToasts.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * useAppToasts - app-level toast wiring. - */ -import { useToasts } from "../../components/ui/Toast"; - -import { useCustomNodeErrorToast } from "./useAppContentHelpers"; - -interface AppToastsParams { - customNodeError: string | null; - clearCustomNodeError: () => void; -} - -export function useAppToasts({ customNodeError, clearCustomNodeError }: AppToastsParams) { - const { toasts, addToast, dismissToast } = useToasts(); - - useCustomNodeErrorToast(customNodeError, addToast, clearCustomNodeError); - - return { toasts, dismissToast, addToast }; -} diff --git a/src/reactTopoViewer/webview/hooks/app/useClipboard.ts b/src/reactTopoViewer/webview/hooks/app/useClipboard.ts deleted file mode 100644 index c16c47ace..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useClipboard.ts +++ /dev/null @@ -1,768 +0,0 @@ -/** - * React Flow Clipboard Hook - * - * Provides copy/paste functionality using the browser's clipboard API - * and React Flow's node/edge state via the graph store. - */ -import { useCallback, useRef } from "react"; -import type { ReactFlowInstance, Node, Edge } from "@xyflow/react"; - -import { useGraphActions, useGraphStore } from "../../stores/graphStore"; -import { log } from "../../utils/logger"; -import { getUniqueId } from "../../../shared/utilities/idUtils"; -import { isSpecialEndpointId } from "../../../shared/utilities/LinkTypes"; -import type { - TopoNode, - TopoEdge, - TopologyNodeData, - TopologyEdgeData -} from "../../../shared/types/graph"; -import { - FREE_TEXT_NODE_TYPE, - FREE_SHAPE_NODE_TYPE, - TRAFFIC_RATE_NODE_TYPE, - GROUP_NODE_TYPE -} from "../../annotations/annotationNodeConverters"; - -/** Version string for clipboard format compatibility */ -const CLIPBOARD_VERSION = "1.0"; - -/** Serialized node data for clipboard */ -interface SerializedNode { - id: string; - data: Record; - position: { x: number; y: number }; - relativePosition: { x: number; y: number }; - type?: string; - /** Top-level width (for annotation nodes like groups) */ - width?: number; - /** Top-level height (for annotation nodes like groups) */ - height?: number; - /** zIndex for proper layering */ - zIndex?: number; -} - -/** Serialized edge data for clipboard */ -interface SerializedEdge { - id: string; - source: string; - target: string; - data: Record; -} - -type PasteEdgeData = { - id: string; - source: string; - target: string; - sourceEndpoint: string; - targetEndpoint: string; -}; - -type PasteNodeCreatedHandler = ( - nodeId: string, - nodeElement: TopoNode, - position: { x: number; y: number } -) => void; - -type PasteEdgeCreatedHandler = ( - sourceId: string, - targetId: string, - edgeData: PasteEdgeData -) => void; - -type PasteCallbacks = { - onNodeCreated?: PasteNodeCreatedHandler; - onEdgeCreated?: PasteEdgeCreatedHandler; - addNodeToGroup?: (nodeId: string, groupId: string) => void; -}; - -type PasteTimeRef = { current: number }; - -/** Clipboard data structure */ -interface ClipboardData { - version: string; - origin: { x: number; y: number }; - nodes: SerializedNode[]; - edges: SerializedEdge[]; - timestamp: number; -} - -/** Options for useClipboard hook */ -export interface UseClipboardOptions extends PasteCallbacks { - /** React Flow instance for viewport calculations */ - rfInstance?: ReactFlowInstance | null; - /** Get node's group membership (for topology nodes whose groupId is not in node.data) */ - getNodeMembership?: (nodeId: string) => string | null; - /** Optional callback for batch persistence after paste */ - onPasteComplete?: (result: { nodes: TopoNode[]; edges: TopoEdge[] }) => void; -} - -/** Return type for useClipboard hook */ -export interface UseClipboardReturn { - /** Copy selected nodes and edges to clipboard */ - copy: () => Promise; - /** Paste clipboard contents at position */ - paste: (position?: { x: number; y: number }) => Promise; - /** Check if browser clipboard has compatible data */ - hasClipboardData: () => Promise; -} - -/** Calculate center of positions */ -function calculateCenter(positions: Array<{ x: number; y: number }>): { x: number; y: number } { - if (positions.length === 0) return { x: 0, y: 0 }; - const sum = positions.reduce((acc, pos) => ({ x: acc.x + pos.x, y: acc.y + pos.y }), { - x: 0, - y: 0 - }); - return { - x: sum.x / positions.length, - y: sum.y / positions.length - }; -} - -/** Counter for generating unique IDs */ -let pasteCounter = 0; - -/** Annotation node types that should use original ID as base (not name) */ -const ANNOTATION_TYPES = new Set([ - GROUP_NODE_TYPE, - FREE_TEXT_NODE_TYPE, - FREE_SHAPE_NODE_TYPE, - TRAFFIC_RATE_NODE_TYPE -]); - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function readNonEmptyString(value: unknown): string | undefined { - const next = readString(value); - if (next == null || next.length === 0) return undefined; - return next; -} - -function readPosition(value: unknown): { x: number; y: number } | null { - if (!isRecord(value)) return null; - const { x, y } = value; - if (typeof x !== "number" || typeof y !== "number") return null; - return { x, y }; -} - -function isSerializedNode(value: unknown): value is SerializedNode { - if (!isRecord(value)) return false; - if (typeof value.id !== "string") return false; - if (!isRecord(value.data)) return false; - if (readPosition(value.position) == null) return false; - if (readPosition(value.relativePosition) == null) return false; - return true; -} - -function isSerializedEdge(value: unknown): value is SerializedEdge { - if (!isRecord(value)) return false; - if (typeof value.id !== "string") return false; - if (typeof value.source !== "string") return false; - if (typeof value.target !== "string") return false; - return isRecord(value.data); -} - -function isClipboardData(value: unknown): value is ClipboardData { - if (!isRecord(value)) return false; - if (typeof value.version !== "string") return false; - if (readPosition(value.origin) == null) return false; - if (!Array.isArray(value.nodes) || !value.nodes.every(isSerializedNode)) return false; - if (!Array.isArray(value.edges) || !value.edges.every(isSerializedEdge)) return false; - if (typeof value.timestamp !== "number") return false; - return true; -} - -function resolveNodeType(type: string | undefined): TopoNode["type"] { - switch (type) { - case "network-node": - case GROUP_NODE_TYPE: - case FREE_TEXT_NODE_TYPE: - case FREE_SHAPE_NODE_TYPE: - case TRAFFIC_RATE_NODE_TYPE: - return type; - default: - return "topology-node"; - } -} - -// ============================================================================ -// Paste helper functions (extracted for complexity reduction) -// ============================================================================ - -/** Read and validate clipboard data */ -async function readClipboardData(): Promise { - try { - const text = await window.navigator.clipboard.readText(); - const clipboardData: unknown = JSON.parse(text); - - if (!isClipboardData(clipboardData)) { - log.warn("[Clipboard] Invalid clipboard data format"); - return null; - } - return clipboardData; - } catch (err) { - log.warn(`[Clipboard] Failed to read clipboard: ${String(err)}`); - return null; - } -} - -/** Calculate paste position from viewport center or provided position */ -function calculatePastePosition( - position: { x: number; y: number } | undefined, - rfInstance: { getViewport: () => { x: number; y: number; zoom: number } } | null -): { x: number; y: number } { - if (position) return position; - if (!rfInstance) return { x: 0, y: 0 }; - - const viewport = rfInstance.getViewport(); - const container = document.querySelector(".react-flow"); - if (!container) return { x: 0, y: 0 }; - - const rect = container.getBoundingClientRect(); - return { - x: (rect.width / 2 - viewport.x) / viewport.zoom, - y: (rect.height / 2 - viewport.y) / viewport.zoom - }; -} - -/** Build mapping from old IDs to new unique IDs */ -function buildIdMapping( - clipboardNodes: SerializedNode[], - existingNodeIds: Set -): Map { - const idMapping = new Map(); - const usedNames = new Set(existingNodeIds); - - log.info( - `[Clipboard] Building unique IDs from ${usedNames.size} existing nodes: ${Array.from(usedNames).join(", ")}` - ); - - const splitNumericSuffix = (value: string): { prefix: string; number: number } | null => { - let idx = value.length - 1; - while (idx >= 0) { - const code = value.charCodeAt(idx); - if (code < 48 || code > 57) break; - idx -= 1; - } - if (idx === value.length - 1) return null; - const prefix = value.slice(0, idx + 1); - if (!prefix) return null; - const num = Number.parseInt(value.slice(idx + 1), 10); - if (!Number.isFinite(num)) return null; - return { prefix, number: num }; - }; - - const isDigits = (value: string): boolean => { - if (!value) return false; - for (let i = 0; i < value.length; i++) { - const code = value.charCodeAt(i); - if (code < 48 || code > 57) return false; - } - return true; - }; - - const generateSequentialId = (baseName: string): string | null => { - const suffixInfo = splitNumericSuffix(baseName); - if (!suffixInfo) return null; - const { prefix, number: baseNum } = suffixInfo; - - let maxNum = baseNum; - for (const id of usedNames) { - if (!id.startsWith(prefix)) continue; - const suffix = id.slice(prefix.length); - if (!isDigits(suffix)) continue; - const num = parseInt(suffix, 10); - if (num > maxNum) maxNum = num; - } - - const candidate = `${prefix}${maxNum + 1}`; - return usedNames.has(candidate) ? null : candidate; - }; - - for (const node of clipboardNodes) { - const isAnnotation = ANNOTATION_TYPES.has(node.type ?? ""); - const nodeName = readNonEmptyString(node.data.name); - const idBase = isAnnotation ? node.id : (nodeName ?? node.id); - log.info( - `[Clipboard] Generating ID for idBase="${idBase}", node.id="${node.id}", isAnnotation=${String(isAnnotation)}` - ); - const shouldSequence = !isAnnotation && !isSpecialEndpointId(idBase); - const sequentialId = shouldSequence ? generateSequentialId(idBase) : null; - const newId = sequentialId ?? getUniqueId(idBase, usedNames); - log.info(`[Clipboard] Generated unique ID: ${idBase} -> ${newId}`); - usedNames.add(newId); - idMapping.set(node.id, newId); - } - - return idMapping; -} - -/** Create a single pasted node */ -function createPastedNode( - node: SerializedNode, - newId: string, - newPosition: { x: number; y: number }, - newGroupId: string | undefined -): TopoNode { - const role = readNonEmptyString(node.data.role) ?? "node"; - const pastedNode: TopoNode = { - id: newId, - type: "topology-node", - position: newPosition, - ...(node.width !== undefined && { width: node.width }), - ...(node.height !== undefined && { height: node.height }), - ...(node.zIndex !== undefined && { zIndex: node.zIndex }), - data: { - ...node.data, - id: newId, - name: newId, - label: newId, - role, - groupId: newGroupId - } as TopologyNodeData - }; - Reflect.set(pastedNode, "type", resolveNodeType(node.type)); - return pastedNode; -} - -function mapYamlNodeId(value: unknown, idMapping: Map): string | undefined { - if (typeof value !== "string" || !value) return undefined; - return idMapping.get(value); -} - -function remapEdgeExtraData( - data: Record, - idMapping: Map -): Record { - const extra = isRecord(data.extraData) ? data.extraData : undefined; - if (!extra) return data; - - const cleaned = { ...extra }; - const yamlSource = mapYamlNodeId(cleaned.yamlSourceNodeId, idMapping); - const yamlTarget = mapYamlNodeId(cleaned.yamlTargetNodeId, idMapping); - - if (yamlSource != null && yamlSource.length > 0) { - cleaned.yamlSourceNodeId = yamlSource; - } else { - delete cleaned.yamlSourceNodeId; - } - - if (yamlTarget != null && yamlTarget.length > 0) { - cleaned.yamlTargetNodeId = yamlTarget; - } else { - delete cleaned.yamlTargetNodeId; - } - - const next = { ...data }; - if (Object.keys(cleaned).length === 0) { - delete next.extraData; - } else { - next.extraData = cleaned; - } - - return next; -} - -/** Create a single pasted edge */ -function createPastedEdge( - edge: SerializedEdge, - newSource: string, - newTarget: string, - idMapping: Map -): TopoEdge { - const sourceEndpoint = readNonEmptyString(edge.data.sourceEndpoint) ?? "eth1"; - const targetEndpoint = readNonEmptyString(edge.data.targetEndpoint) ?? "eth1"; - const newId = `${newSource}:${sourceEndpoint}--${newTarget}:${targetEndpoint}`; - const data = remapEdgeExtraData({ ...edge.data }, idMapping); - - return { - id: newId, - source: newSource, - target: newTarget, - type: "topology-edge", - data: { - ...data, - sourceEndpoint, - targetEndpoint - } as TopologyEdgeData - }; -} - -/** Select pasted elements and deselect others */ -function selectPastedElements( - rfInstance: ReactFlowInstance, - pastedNodeIds: string[], - pastedEdgeIds: string[] -): void { - const pastedNodeSet = new Set(pastedNodeIds); - const pastedEdgeSet = new Set(pastedEdgeIds); - - rfInstance.setNodes((nodes) => - nodes.map((n) => ({ - ...n, - selected: pastedNodeSet.has(n.id) - })) - ); - - rfInstance.setEdges((edges) => - edges.map((e) => ({ - ...e, - selected: pastedEdgeSet.has(e.id) - })) - ); - - log.info( - `[Clipboard] Selected ${pastedNodeIds.length} nodes, ${pastedEdgeIds.length} edges after paste` - ); -} - -/** Paste context passed to paste helper functions */ -interface PasteContext extends PasteCallbacks { - idMapping: Map; - pastePosition: { x: number; y: number }; - offset: number; - pastedNodes: TopoNode[]; - pastedEdges: TopoEdge[]; - pastedNodeIds: string[]; - pastedEdgeIds: string[]; - addNode: (node: TopoNode) => void; - addEdge: (edge: TopoEdge) => void; -} - -/** Paste all nodes from clipboard */ -function pasteNodes(clipboardNodes: SerializedNode[], ctx: PasteContext): void { - for (const node of clipboardNodes) { - const newId = ctx.idMapping.get(node.id)!; - ctx.pastedNodeIds.push(newId); - const newPosition = { - x: ctx.pastePosition.x + node.relativePosition.x + ctx.offset, - y: ctx.pastePosition.y + node.relativePosition.y + ctx.offset - }; - - const originalGroupId = readNonEmptyString(node.data.groupId); - const newGroupId = - originalGroupId != null && originalGroupId.length > 0 - ? ctx.idMapping.get(originalGroupId) - : undefined; - const newNode = createPastedNode(node, newId, newPosition, newGroupId); - ctx.pastedNodes.push(newNode); - - if (ctx.onNodeCreated) { - ctx.onNodeCreated(newId, newNode, newPosition); - } else { - ctx.addNode(newNode); - } - - // Update membership for topology nodes (not annotations) - const isAnnotation = ANNOTATION_TYPES.has(node.type ?? ""); - if (newGroupId != null && newGroupId.length > 0 && !isAnnotation && ctx.addNodeToGroup) { - ctx.addNodeToGroup(newId, newGroupId); - } - } -} - -/** Paste all edges from clipboard */ -function pasteEdges(clipboardEdges: SerializedEdge[], ctx: PasteContext): number { - let edgeCount = 0; - for (const edge of clipboardEdges) { - const newSource = ctx.idMapping.get(edge.source); - const newTarget = ctx.idMapping.get(edge.target); - if ( - newSource == null || - newSource.length === 0 || - newTarget == null || - newTarget.length === 0 - ) { - continue; - } - - const newEdge = createPastedEdge(edge, newSource, newTarget, ctx.idMapping); - ctx.pastedEdgeIds.push(newEdge.id); - ctx.pastedEdges.push(newEdge); - - if (ctx.onEdgeCreated) { - const sourceEndpoint = readNonEmptyString(newEdge.data?.sourceEndpoint) ?? "eth1"; - const targetEndpoint = readNonEmptyString(newEdge.data?.targetEndpoint) ?? "eth1"; - ctx.onEdgeCreated(newSource, newTarget, { - id: newEdge.id, - source: newSource, - target: newTarget, - sourceEndpoint, - targetEndpoint - }); - } else { - ctx.addEdge(newEdge); - } - edgeCount++; - } - return edgeCount; -} - -function shouldThrottlePaste(now: number, lastPasteTimeRef: PasteTimeRef): boolean { - if (now - lastPasteTimeRef.current < 100) return true; - lastPasteTimeRef.current = now; - return false; -} - -interface PasteContextParams extends PasteCallbacks { - pastePosition: { x: number; y: number }; - offset: number; - addNode: (node: TopoNode) => void; - addEdge: (edge: TopoEdge) => void; - existingNodeIds: Set; -} - -function createPasteContext( - clipboardData: ClipboardData, - params: PasteContextParams -): PasteContext { - return { - idMapping: buildIdMapping(clipboardData.nodes, params.existingNodeIds), - pastePosition: params.pastePosition, - offset: params.offset, - pastedNodes: [], - pastedEdges: [], - pastedNodeIds: [], - pastedEdgeIds: [], - addNode: params.addNode, - addEdge: params.addEdge, - onNodeCreated: params.onNodeCreated, - onEdgeCreated: params.onEdgeCreated, - addNodeToGroup: params.addNodeToGroup - }; -} - -function finalizePaste( - ctx: PasteContext, - options: { - rfInstance?: ReactFlowInstance | null; - onPasteComplete?: (result: { nodes: TopoNode[]; edges: TopoEdge[] }) => void; - } -): void { - if (options.onPasteComplete) { - options.onPasteComplete({ nodes: ctx.pastedNodes, edges: ctx.pastedEdges }); - } - - setTimeout(() => { - if (options.rfInstance) { - selectPastedElements(options.rfInstance, ctx.pastedNodeIds, ctx.pastedEdgeIds); - } - }, 50); -} - -interface PerformPasteParams extends PasteCallbacks { - position?: { x: number; y: number }; - rfInstance?: ReactFlowInstance | null; - getNodes: () => Array<{ id: string }>; - addNode: (node: TopoNode) => void; - addEdge: (edge: TopoEdge) => void; - onPasteComplete?: (result: { nodes: TopoNode[]; edges: TopoEdge[] }) => void; -} - -async function performPaste(params: PerformPasteParams): Promise { - const clipboardData = await readClipboardData(); - if (!clipboardData || clipboardData.nodes.length === 0) { - if (clipboardData?.nodes.length === 0) log.info("[Clipboard] No nodes to paste"); - return false; - } - - const pastePosition = calculatePastePosition(params.position, params.rfInstance ?? null); - pasteCounter++; - - const currentNodes = params.rfInstance?.getNodes() ?? params.getNodes(); - const existingNodeIds = new Set(currentNodes.map((n: { id: string }) => n.id)); - - const ctx = createPasteContext(clipboardData, { - pastePosition, - offset: pasteCounter * 20, - addNode: params.addNode, - addEdge: params.addEdge, - onNodeCreated: params.onPasteComplete ? undefined : params.onNodeCreated, - onEdgeCreated: params.onPasteComplete ? undefined : params.onEdgeCreated, - addNodeToGroup: params.addNodeToGroup, - existingNodeIds - }); - - pasteNodes(clipboardData.nodes, ctx); - const edgeCount = pasteEdges(clipboardData.edges, ctx); - log.info(`[Clipboard] Pasted ${clipboardData.nodes.length} nodes, ${edgeCount} edges`); - - finalizePaste(ctx, { rfInstance: params.rfInstance, onPasteComplete: params.onPasteComplete }); - - return true; -} - -// ============================================================================ -// Main hook -// ============================================================================ - -/** - * Hook that provides clipboard operations for nodes and edges. - * Uses the browser's clipboard API for persistence. - * - * @param options - Optional callbacks for node/edge creation that include persistence - */ -export function useClipboard(options: UseClipboardOptions = {}): UseClipboardReturn { - const { - onNodeCreated, - onEdgeCreated, - getNodeMembership, - addNodeToGroup, - rfInstance, - onPasteComplete - } = options; - const { addNode, addEdge } = useGraphActions(); - const lastPasteTimeRef = useRef(0); - const getCurrentNodes = useCallback(() => useGraphStore.getState().nodes, []); - - /** - * Copy selected nodes and connected edges to the browser clipboard. - */ - const copy = useCallback(async (): Promise => { - if (!rfInstance) { - log.warn("[Clipboard] ReactFlow instance not available"); - return false; - } - - const instance = rfInstance; - const allNodes = instance.getNodes(); - // Filter selected nodes, excluding group nodes (they are annotation overlays, not topology elements) - const selectedNodes = allNodes.filter( - (n: { selected?: boolean; type?: string }) => n.selected === true && n.type !== "group" - ); - - if (selectedNodes.length === 0) { - log.info("[Clipboard] No nodes selected to copy"); - return false; - } - - log.info( - `[Clipboard] Selected ${selectedNodes.length} nodes for copy: ${selectedNodes.map((n: { id: string }) => n.id).join(", ")}` - ); - - const selectedNodeIds = new Set(selectedNodes.map((n: { id: string }) => n.id)); - - const allEdges = instance.getEdges(); - const selectedEdges = allEdges.filter( - (e: { source: string; target: string }) => - selectedNodeIds.has(e.source) && selectedNodeIds.has(e.target) - ); - - const positions = selectedNodes.map((n: { position: { x: number; y: number } }) => n.position); - const origin = calculateCenter(positions); - - const serializedNodes: SerializedNode[] = selectedNodes.map( - (node: { - id: string; - data: unknown; - position: { x: number; y: number }; - type?: string; - width?: number; - height?: number; - zIndex?: number; - }) => { - const nodeData = isRecord(node.data) ? node.data : {}; - // For topology nodes, groupId is stored in membershipMap, not in node.data - // Look it up if available and not already present in data - let groupId = readNonEmptyString(nodeData.groupId); - if (groupId == null && getNodeMembership) { - groupId = getNodeMembership(node.id) ?? undefined; - } - return { - id: node.id, - data: { ...nodeData, groupId }, - position: { ...node.position }, - relativePosition: { - x: node.position.x - origin.x, - y: node.position.y - origin.y - }, - type: node.type, - // Preserve top-level dimensions for annotation nodes (groups, shapes, text) - width: node.width, - height: node.height, - zIndex: node.zIndex - }; - } - ); - - const serializedEdges: SerializedEdge[] = selectedEdges.map( - (edge: { id: string; source: string; target: string; data?: unknown }) => ({ - id: edge.id, - source: edge.source, - target: edge.target, - data: { ...(isRecord(edge.data) ? edge.data : {}) } - }) - ); - - const clipboardData: ClipboardData = { - version: CLIPBOARD_VERSION, - origin, - nodes: serializedNodes, - edges: serializedEdges, - timestamp: Date.now() - }; - - try { - await window.navigator.clipboard.writeText(JSON.stringify(clipboardData)); - log.info( - `[Clipboard] Copied ${serializedNodes.length} nodes, ${serializedEdges.length} edges` - ); - return true; - } catch (err) { - log.error(`[Clipboard] Failed to write to clipboard: ${String(err)}`); - return false; - } - }, [rfInstance, getNodeMembership]); - - /** - * Paste clipboard contents at the given position (or viewport center). - */ - const paste = useCallback( - async (position?: { x: number; y: number }): Promise => { - if (shouldThrottlePaste(Date.now(), lastPasteTimeRef)) return false; - return performPaste({ - position, - rfInstance, - getNodes: getCurrentNodes, - addNode, - addEdge, - onNodeCreated, - onEdgeCreated, - addNodeToGroup, - onPasteComplete - }); - }, - [ - rfInstance, - getCurrentNodes, - addNode, - addEdge, - onNodeCreated, - onEdgeCreated, - addNodeToGroup, - onPasteComplete - ] - ); - - /** - * Check if the browser clipboard has compatible data. - */ - const hasClipboardData = useCallback(async (): Promise => { - try { - const text = await window.navigator.clipboard.readText(); - const data: unknown = JSON.parse(text); - return isClipboardData(data); - } catch { - return false; - } - }, []); - - return { copy, paste, hasClipboardData }; -} diff --git a/src/reactTopoViewer/webview/hooks/app/useClipboardHandlers.ts b/src/reactTopoViewer/webview/hooks/app/useClipboardHandlers.ts deleted file mode 100644 index 5ee457f0a..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useClipboardHandlers.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * useClipboardHandlers - Unified clipboard operations with debouncing - * - * Provides debounced copy/paste/duplicate/delete handlers - * using the React Flow clipboard hook. - */ -import React from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; - -import type { TopoNode, TopoEdge } from "../../../shared/types/graph"; - -import { useClipboard, type UseClipboardOptions } from "./useClipboard"; -/** - * Annotations interface subset for clipboard operations - * Avoids circular dependency with AnnotationContext.tsx - */ -interface AnnotationsClipboardSubset { - getNodeMembership: (nodeId: string) => string | null; - addNodeToGroup: (nodeId: string, groupId: string) => void; - deleteAllSelected: () => void; -} - -/** Debounce interval in milliseconds */ -const DEBOUNCE_MS = 50; - -/** - * Configuration for useClipboardHandlers hook - */ -export interface ClipboardHandlersConfig { - annotations: AnnotationsClipboardSubset; - rfInstance?: ReactFlowInstance | null; - /** Callback for node creation (includes YAML persistence and undo) */ - handleNodeCreatedCallback?: ( - nodeId: string, - nodeElement: TopoNode, - position: { x: number; y: number } - ) => void; - /** Callback for edge creation (includes YAML persistence and undo) */ - handleEdgeCreated?: ( - sourceId: string, - targetId: string, - edgeData: { - id: string; - source: string; - target: string; - sourceEndpoint: string; - targetEndpoint: string; - } - ) => void; - /** Batch paste handler for unified undo/redo */ - handleBatchPaste?: (result: { nodes: TopoNode[]; edges: TopoEdge[] }) => void; -} - -/** - * Return type for useClipboardHandlers hook - */ -export interface ClipboardHandlersReturn { - /** Debounced copy handler */ - handleUnifiedCopy: () => void; - /** Debounced paste handler */ - handleUnifiedPaste: () => void; - /** Debounced duplicate handler (copy + paste) */ - handleUnifiedDuplicate: () => void; - /** Delete selected elements (graph + annotations) */ - handleUnifiedDelete: () => void; - /** Check if clipboard has data (async) */ - hasClipboardData: () => boolean; -} - -/** - * Hook that provides debounced clipboard operations. - */ -export function useClipboardHandlers(config: ClipboardHandlersConfig): ClipboardHandlersReturn { - const { - annotations, - handleNodeCreatedCallback, - handleEdgeCreated, - handleBatchPaste, - rfInstance - } = config; - - // Build clipboard options with persistence callbacks - const clipboardOptions: UseClipboardOptions = React.useMemo( - () => ({ - rfInstance, - onNodeCreated: handleNodeCreatedCallback, - onEdgeCreated: handleEdgeCreated, - getNodeMembership: annotations.getNodeMembership, - addNodeToGroup: annotations.addNodeToGroup, - onPasteComplete: handleBatchPaste - }), - [ - rfInstance, - handleNodeCreatedCallback, - handleEdgeCreated, - handleBatchPaste, - annotations.getNodeMembership, - annotations.addNodeToGroup - ] - ); - - // Use the React Flow clipboard hook with persistence callbacks - const clipboard = useClipboard(clipboardOptions); - - // Track if clipboard has data (synced periodically) - const [hasData, setHasData] = React.useState(false); - - // Check clipboard on mount and after operations - const checkClipboard = React.useCallback(async () => { - const has = await clipboard.hasClipboardData(); - setHasData(has); - }, [clipboard]); - - React.useEffect(() => { - void checkClipboard(); - }, [checkClipboard]); - - // Debounce refs - const lastCopyTimeRef = React.useRef(0); - const lastPasteTimeRef = React.useRef(0); - const lastDuplicateTimeRef = React.useRef(0); - - // Debounced copy - const handleUnifiedCopy = React.useCallback(() => { - const now = Date.now(); - if (now - lastCopyTimeRef.current < DEBOUNCE_MS) return; - lastCopyTimeRef.current = now; - void clipboard.copy().then(() => checkClipboard()); - }, [clipboard, checkClipboard]); - - // Debounced paste - const handleUnifiedPaste = React.useCallback(() => { - const now = Date.now(); - if (now - lastPasteTimeRef.current < DEBOUNCE_MS) return; - lastPasteTimeRef.current = now; - void clipboard.paste(); - }, [clipboard]); - - // Debounced duplicate (copy + paste) - const handleUnifiedDuplicate = React.useCallback(() => { - const now = Date.now(); - if (now - lastDuplicateTimeRef.current < DEBOUNCE_MS) return; - lastDuplicateTimeRef.current = now; - void clipboard.copy().then(async (success) => { - if (success) { - await clipboard.paste(); - } - }); - }, [clipboard]); - - // Delete handler (graph elements + annotations) - const handleUnifiedDelete = React.useCallback(() => { - annotations.deleteAllSelected(); - }, [annotations]); - - // Synchronous check (uses cached state) - const hasClipboardData = React.useCallback(() => hasData, [hasData]); - - return { - handleUnifiedCopy, - handleUnifiedPaste, - handleUnifiedDuplicate, - handleUnifiedDelete, - hasClipboardData - }; -} diff --git a/src/reactTopoViewer/webview/hooks/app/useDevMockTrafficStats.ts b/src/reactTopoViewer/webview/hooks/app/useDevMockTrafficStats.ts deleted file mode 100644 index b271358a3..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useDevMockTrafficStats.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { Edge } from "@xyflow/react"; - -import type { InterfaceStatsPayload } from "../../../shared/types/topology"; -import { getRecordUnknown } from "../../../shared/utilities/typeHelpers"; -import { useGraphStore } from "../../stores/graphStore"; - -const UPDATE_INTERVAL_MS = 1000; -const MIN_BPS = 20_000; -const MAX_BPS = 60_000_000; -const SMOOTHING_FACTOR = 0.4; - -interface MockEndpointState { - baseRxBps: number; - baseTxBps: number; - rxBps: number; - txBps: number; - rxBytes: number; - txBytes: number; - rxPackets: number; - txPackets: number; - avgPacketBits: number; - phase: number; -} - -function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); -} - -function lerp(current: number, target: number, factor: number): number { - return current + (target - current) * factor; -} - -function hashKey(value: string): number { - let hash = 5381; - for (let i = 0; i < value.length; i += 1) { - hash = (hash * 33) ^ value.charCodeAt(i); - } - return hash >>> 0; -} - -function seededFraction(seed: number): number { - const raw = Math.sin(seed * 12.9898) * 43758.5453; - return raw - Math.floor(raw); -} - -function resolveRateProfile(seed: number): { - baseRxBps: number; - baseTxBps: number; - avgPacketBytes: number; -} { - const profile = seed % 3; - - if (profile === 0) { - return { - baseRxBps: 80_000 + Math.round(seededFraction(seed + 1) * 900_000), - baseTxBps: 60_000 + Math.round(seededFraction(seed + 2) * 700_000), - avgPacketBytes: 350 + Math.round(seededFraction(seed + 3) * 500) - }; - } - - if (profile === 1) { - return { - baseRxBps: 900_000 + Math.round(seededFraction(seed + 1) * 8_000_000), - baseTxBps: 700_000 + Math.round(seededFraction(seed + 2) * 6_000_000), - avgPacketBytes: 500 + Math.round(seededFraction(seed + 3) * 700) - }; - } - - return { - baseRxBps: 4_000_000 + Math.round(seededFraction(seed + 1) * 24_000_000), - baseTxBps: 3_000_000 + Math.round(seededFraction(seed + 2) * 20_000_000), - avgPacketBytes: 700 + Math.round(seededFraction(seed + 3) * 800) - }; -} - -function createEndpointState(key: string): MockEndpointState { - const seed = hashKey(key); - const profile = resolveRateProfile(seed); - const baseRxBps = profile.baseRxBps; - const baseTxBps = profile.baseTxBps; - const avgPacketBytes = profile.avgPacketBytes; - - return { - baseRxBps, - baseTxBps, - rxBps: baseRxBps, - txBps: baseTxBps, - rxBytes: 0, - txBytes: 0, - rxPackets: 0, - txPackets: 0, - avgPacketBits: avgPacketBytes * 8, - phase: seededFraction(seed + 4) * Math.PI * 2 - }; -} - -function getPhaseDelta(phase: number): number { - return 0.19 + 0.06 * (1 + Math.sin(phase * 0.27)); -} - -function getJitter(phase: number): number { - return 1 + 0.11 * Math.sin(phase * 1.7 + 0.4) + 0.04 * Math.cos(phase * 0.55 - 1.1); -} - -function getBurst(phase: number): number { - return 1 + 0.32 * Math.max(0, Math.sin(phase * 0.4 - 0.9)); -} - -function buildMockStats(state: MockEndpointState, stepSeconds: number): InterfaceStatsPayload { - state.phase += getPhaseDelta(state.phase); - - const rxWave = 1 + 0.35 * Math.sin(state.phase); - const txWave = 1 + 0.3 * Math.cos(state.phase + 0.7); - const jitter = getJitter(state.phase); - const burst = getBurst(state.phase); - - const rxTarget = clamp(state.baseRxBps * rxWave * jitter * burst, MIN_BPS, MAX_BPS); - const txTarget = clamp(state.baseTxBps * txWave * jitter, MIN_BPS, MAX_BPS); - - state.rxBps = Math.round(lerp(state.rxBps, rxTarget, SMOOTHING_FACTOR)); - state.txBps = Math.round(lerp(state.txBps, txTarget, SMOOTHING_FACTOR)); - - const rxPps = Math.max(1, Math.round(state.rxBps / state.avgPacketBits)); - const txPps = Math.max(1, Math.round(state.txBps / state.avgPacketBits)); - - state.rxPackets += Math.round(rxPps * stepSeconds); - state.txPackets += Math.round(txPps * stepSeconds); - state.rxBytes += Math.round((state.rxBps * stepSeconds) / 8); - state.txBytes += Math.round((state.txBps * stepSeconds) / 8); - - return { - rxBps: state.rxBps, - txBps: state.txBps, - rxPps, - txPps, - rxBytes: state.rxBytes, - txBytes: state.txBytes, - rxPackets: state.rxPackets, - txPackets: state.txPackets, - statsIntervalSeconds: stepSeconds - }; -} - -function applyMockStatsToEdge( - edge: Edge, - endpointStateByKey: Map, - stepSeconds: number -): Edge { - const sourceKey = `source:${edge.id}`; - const targetKey = `target:${edge.id}`; - - const sourceState = endpointStateByKey.get(sourceKey) ?? createEndpointState(sourceKey); - const targetState = endpointStateByKey.get(targetKey) ?? createEndpointState(targetKey); - endpointStateByKey.set(sourceKey, sourceState); - endpointStateByKey.set(targetKey, targetState); - - const sourceStats = buildMockStats(sourceState, stepSeconds); - const targetStats = buildMockStats(targetState, stepSeconds); - - const data = getRecordUnknown(edge.data) ?? {}; - const extraData = getRecordUnknown(data.extraData) ?? {}; - - return { - ...edge, - data: { - ...data, - extraData: { - ...extraData, - clabSourceStats: sourceStats, - clabTargetStats: targetStats - } - } - }; -} - -function pruneInactiveEndpointKeys( - endpointStateByKey: Map, - activeKeys: Set -): void { - for (const key of Array.from(endpointStateByKey.keys())) { - if (!activeKeys.has(key)) { - endpointStateByKey.delete(key); - } - } -} - -function updateEdgesWithMockStats( - currentEdges: Edge[], - endpointStateByKey: Map, - stepSeconds: number -): Edge[] { - if (currentEdges.length === 0) { - endpointStateByKey.clear(); - return currentEdges; - } - - const activeKeys = new Set(); - const updatedEdges = currentEdges.map((edge) => { - activeKeys.add(`source:${edge.id}`); - activeKeys.add(`target:${edge.id}`); - return applyMockStatsToEdge(edge, endpointStateByKey, stepSeconds); - }); - - pruneInactiveEndpointKeys(endpointStateByKey, activeKeys); - return updatedEdges; -} - -function computeStepSeconds(lastTickRef: { current: number }): number { - const now = Date.now(); - const elapsedMs = now - lastTickRef.current; - lastTickRef.current = now; - return Math.max(0.5, elapsedMs / 1000); -} - -function runMockTrafficTick( - endpointStateByKey: Map, - lastTickRef: { current: number } -): void { - const stepSeconds = computeStepSeconds(lastTickRef); - const { setEdges } = useGraphStore.getState(); - setEdges((currentEdges) => - updateEdgesWithMockStats(currentEdges, endpointStateByKey, stepSeconds) - ); -} - -export function useDevMockTrafficStats(enabled: boolean): void { - const endpointStateRef = useRef>(new Map()); - const lastTickRef = useRef(Date.now()); - - useEffect(() => { - if (!enabled) { - endpointStateRef.current.clear(); - return; - } - - lastTickRef.current = Date.now(); - runMockTrafficTick(endpointStateRef.current, lastTickRef); - const timer = window.setInterval( - () => runMockTrafficTick(endpointStateRef.current, lastTickRef), - UPDATE_INTERVAL_MS - ); - - return () => { - window.clearInterval(timer); - endpointStateRef.current.clear(); - }; - }, [enabled]); -} diff --git a/src/reactTopoViewer/webview/hooks/app/useGraphCreation.ts b/src/reactTopoViewer/webview/hooks/app/useGraphCreation.ts deleted file mode 100644 index ed8e68d8a..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useGraphCreation.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * useGraphCreation - Composed hook for node, edge, and network creation - * - * Extracts graph creation logic from App.tsx: - * - Edge creation (useEdgeCreation + handleCreateLinkFromNode) - * - Node creation (useNodeCreation + useNodeCreationHandlers) - * - Network creation (useNetworkCreation + handleNetworkCreatedCallback + handleAddNetworkFromPanel) - */ -import React from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; - -import { useNodeCreation, useNetworkCreation, type NetworkType } from "../canvas"; -import { useNodeCreationHandlers, type NodeCreationState } from "../editor"; -import type { CustomNodeTemplate } from "../../../shared/types/editors"; -import type { TopoNode } from "../../../shared/types/graph"; -import { getRecordUnknown } from "../../../shared/utilities/typeHelpers"; -import { getViewportCenter } from "../../utils/viewportUtils"; - -/** Edge data structure for edge creation callback */ -interface EdgeData { - id: string; - source: string; - target: string; - sourceEndpoint: string; - targetEndpoint: string; -} - -/** Callback type for edge creation */ -type EdgeCreatedCallback = (sourceId: string, targetId: string, edgeData: EdgeData) => void; - -/** Callback type for node creation */ -export type NodeCreatedCallback = ( - nodeId: string, - nodeElement: TopoNode, - position: Position -) => void; - -/** Position type */ -type Position = { x: number; y: number }; - -/** - * Configuration for useGraphCreation hook - */ -export interface GraphCreationConfig { - /** React Flow instance for viewport operations */ - rfInstance: ReactFlowInstance | null; - /** Callback when a locked action is attempted */ - onLockedAction?: () => void; - state: { - mode: "edit" | "view"; - isLocked: boolean; - customNodes: CustomNodeTemplate[]; - defaultNode: string; - getNodes: () => TopoNode[]; - }; - /** Callback when an edge is created */ - onEdgeCreated: EdgeCreatedCallback; - /** Callback when a node is created */ - onNodeCreated: NodeCreatedCallback; - /** Callback to add a node element to state */ - addNode: (element: TopoNode) => void; - /** Callback to open new custom node template editor */ - onNewCustomNode: () => void; -} - -/** - * Return type for useGraphCreation hook - */ -export interface GraphCreationReturn { - /** Start edge creation mode from a node */ - startEdgeCreation: (nodeId: string) => void; - /** Handle link creation from node (wrapper for startEdgeCreation) */ - handleCreateLinkFromNode: (nodeId: string) => void; - /** Create a node at specific position */ - createNodeAtPosition: (position: Position, template?: CustomNodeTemplate) => void; - /** Handle adding a node from toolbar controls */ - handleAddNodeFromPanel: (templateName?: string) => void; - /** Create a network at specific position */ - createNetworkAtPosition: (position: Position, networkType: NetworkType) => string | null; - /** Handle adding a network from toolbar controls */ - handleAddNetworkFromPanel: (networkType?: string) => void; -} - -function isNetworkType(value: unknown): value is NetworkType { - switch (value) { - case "host": - case "mgmt-net": - case "macvlan": - case "vxlan": - case "vxlan-stitch": - case "dummy": - case "bridge": - case "ovs-bridge": - return true; - default: - return false; - } -} - -/** - * Hook that composes node, edge, and network creation logic. - * - * Consolidates ~65 lines of graph creation code from App.tsx. - */ -export function useGraphCreation(config: GraphCreationConfig): GraphCreationReturn { - const { - rfInstance, - onLockedAction, - state, - onEdgeCreated, - onNodeCreated, - // addNode is kept in interface for backwards compatibility but not used here - // Network nodes now use onNodeCreated for undo/redo support - onNewCustomNode - } = config; - - const getUsedNodeIds = React.useCallback(() => { - const currentNodes = state.getNodes(); - const ids = new Set(); - for (const node of currentNodes) { - if (node.id) ids.add(node.id); - } - return ids; - }, [state.getNodes]); - - const getExistingNetworkNodes = React.useCallback(() => { - const currentNodes = state.getNodes(); - const nodes: Array<{ id: string; kind: NetworkType }> = []; - for (const node of currentNodes) { - if (node.type !== "network-node") continue; - const data = getRecordUnknown(node.data); - const kind = data?.kind ?? data?.nodeType; - if (isNetworkType(kind)) { - nodes.push({ id: node.id, kind }); - } - } - return nodes; - }, [state.getNodes]); - - // Edge creation - uses ReactFlow's connection API - // Note: Edge creation is primarily handled through ReactFlow's onConnect callback - // This function is kept for programmatic edge creation if needed - const startEdgeCreation = React.useCallback( - (_nodeId: string) => { - // Edge creation in ReactFlow is handled through the onConnect callback - // and the connection line feature. This function could be used to - // start an interactive edge creation mode if needed. - // onEdgeCreated callback is available for future programmatic use - }, - [onEdgeCreated] - ); - - const handleCreateLinkFromNode = React.useCallback((_nodeId: string) => { - // Same as startEdgeCreation - edge creation handled through ReactFlow - }, []); - - // Node creation state - const nodeCreationState: NodeCreationState = { - isLocked: state.isLocked, - customNodes: state.customNodes, - defaultNode: state.defaultNode - }; - - // Node creation - const { createNodeAtPosition } = useNodeCreation(rfInstance, { - customNodes: state.customNodes, - defaultNode: state.defaultNode, - getUsedNodeIds, - onNodeCreated, - onLockedClick: onLockedAction - }); - - // Node creation handlers (for toolbar) - const { handleAddNodeFromPanel } = useNodeCreationHandlers( - onLockedAction, - nodeCreationState, - rfInstance, - createNodeAtPosition, - onNewCustomNode - ); - - // Network creation callback - uses the same handler as regular nodes (which has undo/redo support) - // Persistence is handled by snapshot-based undo/redo after graph mutations - const handleNetworkCreatedCallback = React.useCallback( - (networkId: string, networkElement: TopoNode, position: Position) => { - // Delegate to the node created handler which handles persistence and undo/redo - // The handler detects network nodes by type='network-node' and persists appropriately: - // - Bridge types (bridge, ovs-bridge): saved to YAML nodes + nodeAnnotations - // - Other network types (host, vxlan, etc.): saved to networkNodeAnnotations only - onNodeCreated(networkId, networkElement, position); - }, - [onNodeCreated] - ); - - // Network creation - const { createNetworkAtPosition } = useNetworkCreation({ - mode: state.mode, - isLocked: state.isLocked, - getExistingNodeIds: getUsedNodeIds, - getExistingNetworkNodes, - onNetworkCreated: handleNetworkCreatedCallback, - onLockedClick: onLockedAction - }); - - // Handle adding network from toolbar - const handleAddNetworkFromPanel = React.useCallback( - (networkType?: string) => { - if (state.isLocked) { - onLockedAction?.(); - return; - } - // Get viewport center for network node placement - const position = getViewportCenter(rfInstance); - const resolvedNetworkType = isNetworkType(networkType) ? networkType : "host"; - createNetworkAtPosition(position, resolvedNetworkType); - }, - [rfInstance, state.isLocked, createNetworkAtPosition, onLockedAction] - ); - - return { - startEdgeCreation, - handleCreateLinkFromNode, - createNodeAtPosition, - handleAddNodeFromPanel, - createNetworkAtPosition, - handleAddNetworkFromPanel - }; -} diff --git a/src/reactTopoViewer/webview/hooks/app/useGraphMessageSubscription.ts b/src/reactTopoViewer/webview/hooks/app/useGraphMessageSubscription.ts deleted file mode 100644 index 6d8976e9e..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useGraphMessageSubscription.ts +++ /dev/null @@ -1,396 +0,0 @@ -/** - * useGraphMessageSubscription - Message subscription hook for graph updates - * - * Handles extension messages related to graph state: - * - topology-host:snapshot: Replace nodes/edges from host snapshot - * - edge-stats-update: Update edge extraData (packet stats) - */ -import { useEffect } from "react"; -import type { Edge, Node } from "@xyflow/react"; - -import type { NetemState } from "../../../shared/parsing"; -import type { TopologySnapshot } from "../../../shared/types/messages"; -import { - subscribeToWebviewMessages, - type TypedMessageEvent -} from "../../messaging/webviewMessageBus"; -import { useGraphStore } from "../../stores/graphStore"; -import { applySnapshotToStores } from "../../services/topologyHostSync"; -import { - PENDING_NETEM_KEY, - type PendingNetemOverride, - areNetemEquivalent, - isPendingNetemFresh -} from "../../utils/netemOverrides"; - -// ============================================================================ -// Message Types -// ============================================================================ - -interface EdgeStatsUpdateMessage { - type: "edge-stats-update"; - data?: { - edgeUpdates?: Array<{ - id: string; - extraData: Record; - classes?: string; - }>; - }; -} - -interface NodeDataUpdateMessage { - type: "node-data-updated"; - data?: { - nodeUpdates?: Array<{ - containerLongName: string; - containerShortName: string; - state: string; - status?: string; - mgmtIpv4Address?: string; - mgmtIpv6Address?: string; - }>; - }; -} - -type NodeRuntimeUpdateEntry = NonNullable< - NonNullable["nodeUpdates"] ->[number]; - -type ExtensionMessage = - | { type: "topology-host:snapshot"; snapshot?: TopologySnapshot } - | EdgeStatsUpdateMessage - | NodeDataUpdateMessage - | { type: string }; - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function toRecord(value: unknown): Record { - return isRecord(value) ? value : {}; -} - -function toStringValue(value: unknown): string { - return typeof value === "string" ? value : ""; -} - -function toNetemState(value: unknown): NetemState | undefined { - if (!isRecord(value)) return undefined; - const state: NetemState = {}; - if (typeof value.delay === "string") state.delay = value.delay; - if (typeof value.jitter === "string") state.jitter = value.jitter; - if (typeof value.loss === "string") state.loss = value.loss; - if (typeof value.rate === "string") state.rate = value.rate; - if (typeof value.corruption === "string") state.corruption = value.corruption; - return Object.keys(state).length > 0 ? state : undefined; -} - -function toPendingNetemOverride(value: unknown): PendingNetemOverride | undefined { - if (!isRecord(value)) return undefined; - const appliedAt = value.appliedAt; - if (typeof appliedAt !== "number" || !Number.isFinite(appliedAt)) return undefined; - return { - source: toNetemState(value.source), - target: toNetemState(value.target), - appliedAt - }; -} - -function isTopologySnapshotMessage( - message: ExtensionMessage -): message is { type: "topology-host:snapshot"; snapshot: TopologySnapshot } { - return ( - message.type === "topology-host:snapshot" && - "snapshot" in message && - message.snapshot !== undefined - ); -} - -function isEdgeStatsMessage(message: ExtensionMessage): message is EdgeStatsUpdateMessage { - return message.type === "edge-stats-update"; -} - -function isNodeDataMessage(message: ExtensionMessage): message is NodeDataUpdateMessage { - return message.type === "node-data-updated"; -} - -function buildEdgeWithExtraData( - edge: Edge, - extraData: Record, - classes?: string -): Edge { - const currentData = edge.data ?? {}; - const currentStatus = currentData.linkStatus; - const nextStatus = resolveLinkStatusFromClasses(classes, currentStatus); - - return { - ...edge, - data: { ...currentData, extraData, linkStatus: nextStatus }, - className: classes ?? edge.className - }; -} - -function resolveLinkStatusFromClasses( - classes: string | undefined, - fallback: unknown -): "up" | "down" | "unknown" | undefined { - if (typeof classes === "string") { - if (classes.includes("link-up")) return "up"; - if (classes.includes("link-down")) return "down"; - if (classes.trim().length === 0) return "unknown"; - } - - if (fallback === "up" || fallback === "down" || fallback === "unknown") { - return fallback; - } - - return undefined; -} - -function mergeExtraData( - oldExtraData: Record, - updateExtraData: Record -): Record { - return { ...oldExtraData, ...updateExtraData }; -} - -function stripPendingNetemKey(extraData: Record): void { - delete extraData[PENDING_NETEM_KEY]; -} - -function hasNetemUpdate(updateExtraData: Record): boolean { - return ( - Object.prototype.hasOwnProperty.call(updateExtraData, "clabSourceNetem") || - Object.prototype.hasOwnProperty.call(updateExtraData, "clabTargetNetem") - ); -} - -function matchesPendingNetem( - updateExtraData: Record, - pending: PendingNetemOverride -): boolean { - const incomingSource = toNetemState(updateExtraData.clabSourceNetem); - const incomingTarget = toNetemState(updateExtraData.clabTargetNetem); - return ( - areNetemEquivalent(incomingSource, pending.source) && - areNetemEquivalent(incomingTarget, pending.target) - ); -} - -function mergeExtraDataWithPending( - oldExtraData: Record, - updateExtraData: Record, - pending: PendingNetemOverride -): Record { - if (!isPendingNetemFresh(pending)) { - const merged = mergeExtraData(oldExtraData, updateExtraData); - stripPendingNetemKey(merged); - return merged; - } - - if (!hasNetemUpdate(updateExtraData)) { - return mergeExtraData(oldExtraData, updateExtraData); - } - - if (!matchesPendingNetem(updateExtraData, pending)) { - const { - clabSourceNetem: _clabSourceNetem, - clabTargetNetem: _clabTargetNetem, - ...rest - } = updateExtraData; - return mergeExtraData(oldExtraData, rest); - } - - const merged = mergeExtraData(oldExtraData, updateExtraData); - stripPendingNetemKey(merged); - return merged; -} - -/** Apply edge stats update to a single edge */ -function applyEdgeStatsToEdge( - edge: Edge, - updateMap: Map; classes?: string }> -): Edge { - const update = updateMap.get(edge.id); - if (!update) return edge; - const edgeData = toRecord(edge.data); - const oldExtraData = toRecord(edgeData.extraData); - const updateExtraData = update.extraData; - const pending = toPendingNetemOverride(oldExtraData[PENDING_NETEM_KEY]); - const mergedExtraData = pending - ? mergeExtraDataWithPending(oldExtraData, updateExtraData, pending) - : mergeExtraData(oldExtraData, updateExtraData); - - return buildEdgeWithExtraData(edge, mergedExtraData, update.classes); -} - -function handleSnapshotMessage(msg: { snapshot?: TopologySnapshot }): void { - if (msg.snapshot) { - applySnapshotToStores(msg.snapshot); - } -} - -function handleEdgeStatsUpdateMessage(msg: EdgeStatsUpdateMessage): void { - const updates = msg.data?.edgeUpdates; - if (!updates || updates.length === 0) return; - - const { setEdges } = useGraphStore.getState(); - const updateMap = new Map(updates.map((u) => [u.id, u])); - setEdges((current) => current.map((edge) => applyEdgeStatsToEdge(edge, updateMap))); -} - -function handleNodeDataUpdateMessage(msg: NodeDataUpdateMessage): void { - const updates = msg.data?.nodeUpdates; - if (!updates || updates.length === 0) return; - - const { byLongName, byShortName } = buildNodeRuntimeLookup(updates); - - if (byLongName.size === 0 && byShortName.size === 0) { - return; - } - - const { setNodes } = useGraphStore.getState(); - setNodes((currentNodes) => - currentNodes.map((node) => applyNodeRuntimeUpdate(node, byLongName, byShortName)) - ); -} - -function buildNodeRuntimeLookup(updates: NodeRuntimeUpdateEntry[]): { - byLongName: Map; - byShortName: Map; -} { - const byLongName = new Map(); - const byShortName = new Map(); - - for (const update of updates) { - const longName = update.containerLongName.trim(); - const shortName = update.containerShortName.trim(); - if (longName.length > 0) byLongName.set(longName, update); - if (shortName.length > 0) byShortName.set(shortName, update); - } - - return { byLongName, byShortName }; -} - -function resolveNodeRuntimeUpdate( - nodeId: string, - nodeData: Record, - extraData: Record, - byLongName: Map, - byShortName: Map -): NodeRuntimeUpdateEntry | undefined { - const longNameCandidate = [nodeData.longname, extraData.longname].find( - (value): value is string => typeof value === "string" && value.trim().length > 0 - ); - - if (longNameCandidate != null && longNameCandidate.length > 0) { - const byLong = byLongName.get(longNameCandidate); - if (byLong) return byLong; - } - - return byShortName.get(nodeId) ?? byShortName.get(toStringValue(nodeData.label)); -} - -function hasNodeRuntimeDataChanged( - nodeData: Record, - extraData: Record, - update: NodeRuntimeUpdateEntry -): boolean { - const nextState = update.state; - const nextStatus = update.status ?? ""; - const nextIpv4 = update.mgmtIpv4Address ?? ""; - const nextIpv6 = update.mgmtIpv6Address ?? ""; - - return ( - toStringValue(nodeData.state) !== nextState || - toStringValue(extraData.state) !== nextState || - toStringValue(extraData.status) !== nextStatus || - toStringValue(nodeData.mgmtIpv4Address) !== nextIpv4 || - toStringValue(nodeData.mgmtIpv6Address) !== nextIpv6 - ); -} - -function applyNodeRuntimeUpdate( - node: Node, - byLongName: Map, - byShortName: Map -): Node { - if (node.type !== "topology-node") { - return node; - } - - const nodeData = node.data; - const extraData = toRecord(nodeData.extraData); - const matchedUpdate = resolveNodeRuntimeUpdate( - node.id, - nodeData, - extraData, - byLongName, - byShortName - ); - if (!matchedUpdate) { - return node; - } - - if (!hasNodeRuntimeDataChanged(nodeData, extraData, matchedUpdate)) { - return node; - } - - const nextState = matchedUpdate.state; - const nextStatus = matchedUpdate.status ?? ""; - const nextIpv4 = matchedUpdate.mgmtIpv4Address ?? ""; - const nextIpv6 = matchedUpdate.mgmtIpv6Address ?? ""; - - return { - ...node, - data: { - ...nodeData, - state: nextState, - mgmtIpv4Address: nextIpv4, - mgmtIpv6Address: nextIpv6, - extraData: { - ...extraData, - state: nextState, - status: nextStatus, - mgmtIpv4Address: nextIpv4, - mgmtIpv6Address: nextIpv6 - } - } - }; -} - -// ============================================================================ -// Hook -// ============================================================================ - -/** - * Hook to subscribe to graph-related extension messages. - * Should be called once at the app root. - */ -export function useGraphMessageSubscription(): void { - useEffect(() => { - const handleMessage = (event: TypedMessageEvent) => { - const data = event.data; - if (data == null) return; - const message: ExtensionMessage = data; - - if (isTopologySnapshotMessage(message)) { - handleSnapshotMessage(message); - return; - } - if (isEdgeStatsMessage(message)) { - handleEdgeStatsUpdateMessage(message); - return; - } - if (isNodeDataMessage(message)) { - handleNodeDataUpdateMessage(message); - } - }; - - return subscribeToWebviewMessages(handleMessage); - }, []); -} diff --git a/src/reactTopoViewer/webview/hooks/app/useIconReconciliation.ts b/src/reactTopoViewer/webview/hooks/app/useIconReconciliation.ts deleted file mode 100644 index 15975520d..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useIconReconciliation.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Hook to track used custom icons and trigger reconciliation when usage changes. - * This ensures custom icons used by nodes are copied to the workspace .clab-icons/ folder. - */ -import { useEffect, useRef } from "react"; - -import { extractUsedCustomIcons } from "../../../shared/types/icons"; -import { getRecordUnknown } from "../../../shared/utilities/typeHelpers"; -import { sendIconReconcile } from "../../messaging/extensionMessaging"; -import { useGraphStore } from "../../stores/graphStore"; - -interface IconUsageEntry { - id: string; - topoViewerRole: string | null; -} - -function selectIconUsageEntries(state: { - nodes: Array<{ id: string; data?: unknown }>; -}): IconUsageEntry[] { - const entries: IconUsageEntry[] = []; - for (const node of state.nodes) { - const data = getRecordUnknown(node.data); - const extraData = getRecordUnknown(data?.extraData) ?? {}; - const role = data?.role; - const fallbackRole = extraData.topoViewerRole; - let topoViewerRole: string | null = null; - if (typeof role === "string" && role.length > 0) { - topoViewerRole = role; - } else if (typeof fallbackRole === "string" && fallbackRole.length > 0) { - topoViewerRole = fallbackRole; - } - entries.push({ id: node.id, topoViewerRole }); - } - return entries; -} - -function areIconUsageEntriesEqual(left: IconUsageEntry[], right: IconUsageEntry[]): boolean { - if (left === right) return true; - if (left.length !== right.length) return false; - for (let i = 0; i < left.length; i++) { - if (left[i].id !== right[i].id || left[i].topoViewerRole !== right[i].topoViewerRole) { - return false; - } - } - return true; -} - -/** - * Tracks custom icons used by nodes and triggers reconciliation when usage changes. - * Reconciliation copies used icons from global (~/.clab/icons/) to workspace (.clab-icons/). - * - * Subscribes only to icon-relevant node fields so drag position updates do not trigger work. - */ -export function useIconReconciliation(): void { - const iconUsageEntries = useGraphStore(selectIconUsageEntries, areIconUsageEntriesEqual); - const prevUsedIconsRef = useRef([]); - - useEffect(() => { - const usedIcons = extractUsedCustomIcons( - iconUsageEntries.map((entry) => ({ - data: { topoViewerRole: entry.topoViewerRole ?? undefined } - })) - ); - const prevUsedIcons = prevUsedIconsRef.current; - - // Check if the set of used icons has changed - const usedSet = new Set(usedIcons); - const prevSet = new Set(prevUsedIcons); - const hasChanged = - usedIcons.length !== prevUsedIcons.length || - usedIcons.some((icon) => !prevSet.has(icon)) || - prevUsedIcons.some((icon) => !usedSet.has(icon)); - - if (hasChanged && iconUsageEntries.length > 0) { - prevUsedIconsRef.current = usedIcons; - // Trigger icon reconciliation on extension side - sendIconReconcile(usedIcons); - } - }, [iconUsageEntries]); -} diff --git a/src/reactTopoViewer/webview/hooks/app/useInitialGraphData.ts b/src/reactTopoViewer/webview/hooks/app/useInitialGraphData.ts deleted file mode 100644 index 02ada563b..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useInitialGraphData.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Initial bootstrap data type for the App entry. - * - * Topology state now comes from TopologyHost snapshots; this shape - * represents only non-topology bootstrap data injected by the host. - */ -import type { CustomNodeTemplate, SchemaData } from "../../../shared/schema"; -import type { CustomIconInfo } from "../../../shared/types/icons"; - -export interface InitialGraphData { - schemaData?: SchemaData; - dockerImages?: string[]; - customNodes?: CustomNodeTemplate[]; - defaultNode?: string; - customIcons?: CustomIconInfo[]; -} diff --git a/src/reactTopoViewer/webview/hooks/app/useStoreInitialization.ts b/src/reactTopoViewer/webview/hooks/app/useStoreInitialization.ts deleted file mode 100644 index f7004cd35..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useStoreInitialization.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * useStoreInitialization - Hook to initialize Zustand stores with bootstrap data - * - * This hook should be called once at the app root to set up initial state - * from the extension's bootstrap data payload. - */ -import { useEffect, useRef } from "react"; - -import { useTopoViewerStore, parseInitialData } from "../../stores/topoViewerStore"; - -export interface StoreInitializationData { - initialData?: unknown; -} - -/** - * Hook to initialize stores with initial data. - * Should be called once at the app root. - */ -export function useStoreInitialization({ initialData }: StoreInitializationData): void { - const initializedRef = useRef(false); - - useEffect(() => { - if (initializedRef.current) return; - initializedRef.current = true; - - // Initialize topoViewer store with parsed initial data - if (initialData !== undefined && initialData !== null) { - const parsedData = parseInitialData(initialData); - useTopoViewerStore.getState().setInitialData(parsedData); - } - }, [initialData]); -} diff --git a/src/reactTopoViewer/webview/hooks/app/useTopoViewerMessageSubscription.ts b/src/reactTopoViewer/webview/hooks/app/useTopoViewerMessageSubscription.ts deleted file mode 100644 index b4abb5bb1..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useTopoViewerMessageSubscription.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * useTopoViewerMessageSubscription - Message subscription hook for UI state updates - * - * Handles extension messages related to TopoViewer UI state: - * - topo-mode-changed: Update mode, deploymentState - * - panel-action: Trigger edit/select actions - * - custom-nodes-updated: Update customNodes - * - custom-node-error: Show error - * - icon-list-response: Update customIcons - * - lab-lifecycle-log: Append streaming deploy/destroy logs - * - lab-lifecycle-status: Clear processing state - * - fit-viewport: Fit graph to current viewport - */ -import { useEffect } from "react"; - -import type { CustomNodeTemplate } from "../../../shared/types/editors"; -import type { CustomIconInfo } from "../../../shared/types/icons"; -import { - subscribeToWebviewMessages, - type TypedMessageEvent, - type WebviewMessageBase -} from "../../messaging/webviewMessageBus"; -import { useCanvasStore } from "../../stores/canvasStore"; -import { useTopoViewerStore, type DeploymentState } from "../../stores/topoViewerStore"; - -// ============================================================================ -// Message Helpers -// ============================================================================ - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} - -function getMessageData(message: WebviewMessageBase): Record | undefined { - return isRecord(message.data) ? message.data : undefined; -} - -function isDeploymentState(value: unknown): value is DeploymentState { - return value === "deployed" || value === "undeployed" || value === "unknown"; -} - -function isCustomNodeTemplate(value: unknown): value is CustomNodeTemplate { - return isRecord(value) && isNonEmptyString(value.name) && isNonEmptyString(value.kind); -} - -function isCustomIconInfo(value: unknown): value is CustomIconInfo { - return ( - isRecord(value) && - isNonEmptyString(value.name) && - (value.source === "workspace" || value.source === "global") && - isNonEmptyString(value.dataUri) && - (value.format === "svg" || value.format === "png") - ); -} - -// ============================================================================ -// Message Handlers -// ============================================================================ - -function handleTopoModeChanged(msg: WebviewMessageBase): void { - const { setMode, setDeploymentState } = useTopoViewerStore.getState(); - const data = getMessageData(msg); - - if (isNonEmptyString(data?.mode)) { - const modeValue = data.mode; - const normalizedMode = modeValue === "viewer" || modeValue === "view" ? "view" : "edit"; - setMode(normalizedMode); - } - - if (isDeploymentState(data?.deploymentState)) { - setDeploymentState(data.deploymentState); - } -} - -function handlePanelAction(msg: WebviewMessageBase): void { - const { selectNode, selectEdge, editNode, editEdge, isProcessing } = - useTopoViewerStore.getState(); - if (isProcessing) return; - const action = isNonEmptyString(msg.action) ? msg.action : undefined; - const nodeId = isNonEmptyString(msg.nodeId) ? msg.nodeId : undefined; - const edgeId = isNonEmptyString(msg.edgeId) ? msg.edgeId : undefined; - - if (action === undefined) return; - - switch (action) { - case "edit-node": - if (nodeId !== undefined) editNode(nodeId); - return; - case "edit-link": - if (edgeId !== undefined) editEdge(edgeId); - return; - case "node-info": - if (nodeId !== undefined) selectNode(nodeId); - return; - case "link-info": - if (edgeId !== undefined) selectEdge(edgeId); - break; - } -} - -function handleCustomNodesUpdated(msg: WebviewMessageBase): void { - const { setCustomNodes } = useTopoViewerStore.getState(); - if (!Array.isArray(msg.customNodes)) return; - const customNodes = msg.customNodes.filter(isCustomNodeTemplate); - const defaultNode = isNonEmptyString(msg.defaultNode) ? msg.defaultNode : ""; - setCustomNodes(customNodes, defaultNode); -} - -function handleCustomNodeError(msg: WebviewMessageBase): void { - const { setCustomNodeError } = useTopoViewerStore.getState(); - if (isNonEmptyString(msg.error)) { - setCustomNodeError(msg.error); - } -} - -function handleIconListResponse(msg: WebviewMessageBase): void { - const { setCustomIcons } = useTopoViewerStore.getState(); - if (!Array.isArray(msg.icons)) return; - setCustomIcons(msg.icons.filter(isCustomIconInfo)); -} - -function handleLabLifecycleLog(msg: WebviewMessageBase): void { - const { appendLifecycleLog, isProcessing } = useTopoViewerStore.getState(); - if (!isProcessing) { - return; - } - const data = getMessageData(msg); - const line = data?.line; - if (!isNonEmptyString(line)) { - return; - } - const stream = data?.stream === "stderr" ? "stderr" : "stdout"; - appendLifecycleLog(line, stream); -} - -function handleLabLifecycleStatus(msg: WebviewMessageBase): void { - const { appendLifecycleLog, setLifecycleStatus, setProcessing } = useTopoViewerStore.getState(); - const data = getMessageData(msg); - const status = data?.status; - const errorMessage = data?.errorMessage; - - if (status === "error" && isNonEmptyString(errorMessage)) { - appendLifecycleLog(`[error] ${errorMessage}`, "stderr"); - setLifecycleStatus("error", errorMessage); - } else if (status === "error") { - setLifecycleStatus("error", "Lifecycle command failed."); - } - if (status === "success") { - appendLifecycleLog("Command completed successfully.", "stdout"); - setLifecycleStatus("success"); - } - setProcessing(false); -} - -function handleFitViewport(): void { - const { requestFitView } = useCanvasStore.getState(); - requestFitView(); -} - -const MESSAGE_HANDLERS: Partial void>> = { - "topo-mode-changed": handleTopoModeChanged, - "panel-action": handlePanelAction, - "custom-nodes-updated": handleCustomNodesUpdated, - "custom-node-error": handleCustomNodeError, - "icon-list-response": handleIconListResponse, - "lab-lifecycle-log": handleLabLifecycleLog, - "lab-lifecycle-status": handleLabLifecycleStatus, - "fit-viewport": () => { - handleFitViewport(); - } -}; - -// ============================================================================ -// Hook -// ============================================================================ - -/** - * Hook to subscribe to TopoViewer UI-related extension messages. - * Should be called once at the app root. - */ -export function useTopoViewerMessageSubscription(): void { - useEffect(() => { - const handleMessage = (event: TypedMessageEvent) => { - const message = event.data; - if (message === undefined || !isNonEmptyString(message.type)) return; - const handler = MESSAGE_HANDLERS[message.type]; - if (handler !== undefined) { - handler(message); - } - }; - - return subscribeToWebviewMessages(handleMessage); - }, []); -} diff --git a/src/reactTopoViewer/webview/hooks/app/useTopologyHostInitialization.ts b/src/reactTopoViewer/webview/hooks/app/useTopologyHostInitialization.ts deleted file mode 100644 index a1b9c577c..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useTopologyHostInitialization.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Initialize the webview state from the host snapshot. - */ - -import { useEffect } from "react"; - -import { requestSnapshot } from "../../services/topologyHostClient"; -import { applySnapshotToStores } from "../../services/topologyHostSync"; -import { log } from "../../utils/logger"; - -export function useTopologyHostInitialization(): void { - useEffect(() => { - let disposed = false; - const isDisposed = () => disposed; - void (async () => { - try { - const snapshot = await requestSnapshot(); - if (isDisposed()) { - return; - } - // Pass isInitialLoad: true to apply auto-layout if nodes have no preset positions - applySnapshotToStores(snapshot, { isInitialLoad: true }); - } catch (err) { - log.error( - `[TopologyHost] Failed to load snapshot: ${err instanceof Error ? err.message : String(err)}` - ); - } - })(); - return () => { - disposed = true; - }; - }, []); -} diff --git a/src/reactTopoViewer/webview/hooks/app/useUndoRedoControls.ts b/src/reactTopoViewer/webview/hooks/app/useUndoRedoControls.ts deleted file mode 100644 index 1c01c0465..000000000 --- a/src/reactTopoViewer/webview/hooks/app/useUndoRedoControls.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * useUndoRedoControls - app-level undo/redo bindings. - */ -import React from "react"; - -import { executeTopologyCommand } from "../../services"; - -export interface UndoRedoControls { - undo: () => void; - redo: () => void; - canUndo: boolean; - canRedo: boolean; -} - -export function useUndoRedoControls(canUndo: boolean, canRedo: boolean): UndoRedoControls { - const undo = React.useCallback(() => { - void executeTopologyCommand({ command: "undo" }); - }, []); - - const redo = React.useCallback(() => { - void executeTopologyCommand({ command: "redo" }); - }, []); - - return React.useMemo( - () => ({ - undo, - redo, - canUndo, - canRedo - }), - [undo, redo, canUndo, canRedo] - ); -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/annotationHelpers.ts b/src/reactTopoViewer/webview/hooks/canvas/annotationHelpers.ts deleted file mode 100644 index 61446e344..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/annotationHelpers.ts +++ /dev/null @@ -1,74 +0,0 @@ -export function calculateGroupBoundsFromNodes( - selectedNodes: Array<{ - id: string; - position: { x: number; y: number }; - measured?: { width?: number; height?: number }; - }>, - padding: number -): { position: { x: number; y: number }; width: number; height: number; members: string[] } { - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - const members: string[] = []; - - for (const node of selectedNodes) { - const nodeWidth = node.measured?.width ?? 100; - const nodeHeight = node.measured?.height ?? 100; - minX = Math.min(minX, node.position.x); - minY = Math.min(minY, node.position.y); - maxX = Math.max(maxX, node.position.x + nodeWidth); - maxY = Math.max(maxY, node.position.y + nodeHeight); - members.push(node.id); - } - - return { - position: { x: minX - padding, y: minY - padding }, - width: maxX - minX + padding * 2, - height: maxY - minY + padding * 2, - members - }; -} - -export function calculateDefaultGroupPosition(viewport: { x: number; y: number; zoom: number }): { - position: { x: number; y: number }; - width: number; - height: number; - members: string[]; -} { - return { - position: { x: -viewport.x / viewport.zoom + 200, y: -viewport.y / viewport.zoom + 200 }, - width: 300, - height: 200, - members: [] - }; -} - -export function handleAnnotationNodeDrop( - nodeId: string, - targetGroupId: string | null, - annotationList: Array<{ id: string; groupId?: string }>, - updateFn: (id: string, updates: { groupId?: string }) => void -): void { - const annotation = annotationList.find((a) => a.id === nodeId); - const currentGroupId = annotation?.groupId ?? null; - if (currentGroupId !== targetGroupId) { - updateFn(nodeId, { groupId: targetGroupId ?? undefined }); - } -} - -export function handleTopologyNodeDrop( - nodeId: string, - targetGroupId: string | null, - currentGroupId: string | null, - addToGroup: (nodeId: string, groupId: string) => void, - removeFromGroup: (nodeId: string) => void -): void { - if (currentGroupId === targetGroupId) return; - - if (targetGroupId !== null && targetGroupId.length > 0) { - addToGroup(nodeId, targetGroupId); - } else { - removeFromGroup(nodeId); - } -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/annotationTypes.ts b/src/reactTopoViewer/webview/hooks/canvas/annotationTypes.ts deleted file mode 100644 index 45a8ac2b3..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/annotationTypes.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { - FreeTextAnnotation, - FreeShapeAnnotation, - TrafficRateAnnotation, - GroupStyleAnnotation -} from "../../../shared/types/topology"; - -import type { GroupEditorData } from "./groupTypes"; - -export interface AnnotationState { - groups: GroupStyleAnnotation[]; - selectedGroupIds: Set; - editingGroup: GroupEditorData | null; - textAnnotations: FreeTextAnnotation[]; - selectedTextIds: Set; - editingTextAnnotation: FreeTextAnnotation | null; - isAddTextMode: boolean; - shapeAnnotations: FreeShapeAnnotation[]; - selectedShapeIds: Set; - editingShapeAnnotation: FreeShapeAnnotation | null; - isAddShapeMode: boolean; - pendingShapeType: "rectangle" | "circle" | "line"; - trafficRateAnnotations: TrafficRateAnnotation[]; - selectedTrafficRateIds: Set; - editingTrafficRateAnnotation: TrafficRateAnnotation | null; -} - -export interface AnnotationActions { - // Groups - selectGroup: (id: string) => void; - toggleGroupSelection: (id: string) => void; - boxSelectGroups: (ids: string[]) => void; - clearGroupSelection: () => void; - editGroup: (id: string) => void; - closeGroupEditor: () => void; - saveGroup: (data: GroupEditorData) => void; - deleteGroup: (id: string) => void; - updateGroup: (id: string, updates: Partial) => void; - updateGroupParent: (id: string, parentId: string | null) => void; - updateGroupGeoPosition: (id: string, coords: { lat: number; lng: number }) => void; - addNodeToGroup: (nodeId: string, groupId: string) => void; - getNodeMembership: (nodeId: string) => string | null; - getGroupMembers: (groupId: string, options?: { includeNested?: boolean }) => string[]; - handleAddGroup: () => void; - createGroupAtPosition: (position: { x: number; y: number }) => void; - generateGroupId: () => string; - addGroup: (group: GroupStyleAnnotation) => void; - updateGroupSize: (id: string, width: number, height: number) => void; - - // Text annotations - handleAddText: () => void; - createTextAtPosition: (position: { x: number; y: number }) => void; - disableAddTextMode: () => void; - selectTextAnnotation: (id: string) => void; - toggleTextAnnotationSelection: (id: string) => void; - boxSelectTextAnnotations: (ids: string[]) => void; - clearTextAnnotationSelection: () => void; - editTextAnnotation: (id: string) => void; - closeTextEditor: () => void; - saveTextAnnotation: (annotation: FreeTextAnnotation) => void; - previewTextAnnotation: (annotation: FreeTextAnnotation) => void; - removePreviewTextAnnotation: (id: string) => void; - deleteTextAnnotation: (id: string) => void; - deleteSelectedTextAnnotations: () => void; - updateTextRotation: (id: string, rotation: number) => void; - onTextRotationStart: (id: string) => void; - onTextRotationEnd: (id: string) => void; - updateTextSize: (id: string, width: number, height: number) => void; - updateTextGeoPosition: (id: string, coords: { lat: number; lng: number }) => void; - updateTextAnnotation: (id: string, updates: Partial) => void; - handleTextCanvasClick: (position: { x: number; y: number }) => void; - - // Shape annotations - handleAddShapes: (shapeType?: string) => void; - createShapeAtPosition: (position: { x: number; y: number }, shapeType?: string) => void; - disableAddShapeMode: () => void; - selectShapeAnnotation: (id: string) => void; - toggleShapeAnnotationSelection: (id: string) => void; - boxSelectShapeAnnotations: (ids: string[]) => void; - clearShapeAnnotationSelection: () => void; - editShapeAnnotation: (id: string) => void; - closeShapeEditor: () => void; - saveShapeAnnotation: (annotation: FreeShapeAnnotation) => void; - previewShapeAnnotation: (annotation: FreeShapeAnnotation) => void; - removePreviewShapeAnnotation: (id: string) => void; - deleteShapeAnnotation: (id: string) => void; - deleteSelectedShapeAnnotations: () => void; - updateShapeRotation: (id: string, rotation: number) => void; - onShapeRotationStart: (id: string) => void; - onShapeRotationEnd: (id: string) => void; - updateShapeSize: (id: string, width: number, height: number) => void; - updateShapeStartPosition: (id: string, startPosition: { x: number; y: number }) => void; - updateShapeEndPosition: (id: string, endPosition: { x: number; y: number }) => void; - updateShapeGeoPosition: (id: string, coords: { lat: number; lng: number }) => void; - updateShapeEndGeoPosition: (id: string, coords: { lat: number; lng: number }) => void; - updateShapeAnnotation: (id: string, updates: Partial) => void; - handleShapeCanvasClick: (position: { x: number; y: number }) => void; - /** Persist annotations to file (call on drag end) */ - persistAnnotations: () => void; - - // Traffic-rate annotations - createTrafficRateAtPosition: (position: { x: number; y: number }) => void; - selectTrafficRateAnnotation: (id: string) => void; - toggleTrafficRateAnnotationSelection: (id: string) => void; - boxSelectTrafficRateAnnotations: (ids: string[]) => void; - clearTrafficRateAnnotationSelection: () => void; - editTrafficRateAnnotation: (id: string) => void; - closeTrafficRateEditor: () => void; - saveTrafficRateAnnotation: (annotation: TrafficRateAnnotation) => void; - deleteTrafficRateAnnotation: (id: string) => void; - deleteSelectedTrafficRateAnnotations: () => void; - updateTrafficRateSize: (id: string, width: number, height: number) => void; - updateTrafficRateAnnotation: (id: string, updates: Partial) => void; - updateTrafficRateGeoPosition: (id: string, coords: { lat: number; lng: number }) => void; - - // Membership - onNodeDropped: (nodeId: string, position: { x: number; y: number }) => void; - - // Utilities - clearAllSelections: () => void; - deleteAllSelected: () => void; - deleteSelectedForBatch: (options?: { - groupIds?: Iterable; - textIds?: Iterable; - shapeIds?: Iterable; - trafficRateIds?: Iterable; - }) => { didDelete: boolean; membersCleared: boolean }; -} - -export type AnnotationContextValue = AnnotationState & AnnotationActions; diff --git a/src/reactTopoViewer/webview/hooks/canvas/groupTypes.ts b/src/reactTopoViewer/webview/hooks/canvas/groupTypes.ts deleted file mode 100644 index b6c347f00..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/groupTypes.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Group types for group editor panel and hooks - */ -import type { GroupStyleAnnotation } from "../../../shared/types/topology"; - -/** Style properties for groups */ -export interface GroupStyle { - backgroundColor?: string; - backgroundOpacity?: number; - borderColor?: string; - borderWidth?: number; - borderStyle?: "solid" | "dotted" | "dashed" | "double"; - borderRadius?: number; - color?: string; - labelColor?: string; - labelPosition?: string; -} - -/** Editor data for group editing panel */ -export interface GroupEditorData { - id: string; - name: string; - level: string; - style: GroupStyle; - position: { x: number; y: number }; - width: number; - height: number; - members?: string[]; - parentId?: string; - zIndex?: number; -} - -/** Return type for group clipboard operations */ -export interface UseGroupClipboardReturn { - copyGroup: (groupId: string) => boolean; - pasteGroup: (position: { x: number; y: number }) => void; - hasClipboardData: () => boolean; -} - -/** Label position options */ -export const GROUP_LABEL_POSITIONS = [ - "top-left", - "top-center", - "top-right", - "bottom-left", - "bottom-center", - "bottom-right" -] as const; - -/** Convert GroupStyleAnnotation to GroupEditorData */ -export function groupToEditorData(group: GroupStyleAnnotation): GroupEditorData { - const members = Array.isArray(group.members) - ? group.members.filter((member): member is string => typeof member === "string") - : undefined; - - return { - id: group.id, - name: group.name, - level: group.level, - position: group.position, - width: group.width, - height: group.height, - members, - parentId: group.parentId, - zIndex: group.zIndex, - style: { - backgroundColor: group.backgroundColor, - backgroundOpacity: group.backgroundOpacity, - borderColor: group.borderColor, - borderWidth: group.borderWidth, - borderStyle: group.borderStyle, - borderRadius: group.borderRadius, - color: group.color, - labelColor: group.labelColor, - labelPosition: group.labelPosition - } - }; -} - -/** Convert GroupEditorData to GroupStyleAnnotation */ -export function editorDataToGroup(data: GroupEditorData): GroupStyleAnnotation { - return { - id: data.id, - name: data.name, - level: data.level, - position: data.position, - width: data.width, - height: data.height, - members: data.members, - parentId: data.parentId, - zIndex: data.zIndex, - backgroundColor: data.style.backgroundColor, - backgroundOpacity: data.style.backgroundOpacity, - borderColor: data.style.borderColor, - borderWidth: data.style.borderWidth, - borderStyle: data.style.borderStyle, - borderRadius: data.style.borderRadius, - color: data.style.color, - labelColor: data.style.labelColor, - labelPosition: data.style.labelPosition - }; -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/groupUtils.ts b/src/reactTopoViewer/webview/hooks/canvas/groupUtils.ts deleted file mode 100644 index c7508d4e5..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/groupUtils.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Group utility functions - minimal implementations - */ -import type { GroupStyleAnnotation } from "../../../shared/types/topology"; - -/** Check if a position is inside a group's bounding box */ -export function isPositionInsideGroup( - position: { x: number; y: number }, - group: GroupStyleAnnotation -): boolean { - const gx = group.position.x; - const gy = group.position.y; - return ( - position.x >= gx && - position.x <= gx + group.width && - position.y >= gy && - position.y <= gy + group.height - ); -} - -/** Find the deepest (smallest) group that contains the given position */ -export function findDeepestGroupAtPosition( - position: { x: number; y: number }, - groups: GroupStyleAnnotation[] -): GroupStyleAnnotation | null { - let deepest: GroupStyleAnnotation | null = null; - let smallestArea = Infinity; - - for (const group of groups) { - if (isPositionInsideGroup(position, group)) { - const area = group.width * group.height; - if (area < smallestArea) { - smallestArea = area; - deepest = group; - } - } - } - - return deepest; -} - -/** Alias for findDeepestGroupAtPosition */ -export function findGroupForNodeAtPosition( - position: { x: number; y: number }, - groups: GroupStyleAnnotation[] -): GroupStyleAnnotation | null { - return findDeepestGroupAtPosition(position, groups); -} - -/** Generate a unique group ID */ -export function generateGroupId(existingGroups: GroupStyleAnnotation[]): string { - const existingIds = new Set(existingGroups.map((g) => g.id)); - let counter = 1; - while (existingIds.has(`group-${counter}`)) { - counter++; - } - return `group-${counter}`; -} - -/** Check if a group's bounds are fully contained within another group */ -export function isGroupInsideGroup( - inner: GroupStyleAnnotation, - outer: GroupStyleAnnotation -): boolean { - const innerLeft = inner.position.x; - const innerRight = inner.position.x + inner.width; - const innerTop = inner.position.y; - const innerBottom = inner.position.y + inner.height; - - const outerLeft = outer.position.x; - const outerRight = outer.position.x + outer.width; - const outerTop = outer.position.y; - const outerBottom = outer.position.y + outer.height; - - return ( - innerLeft >= outerLeft && - innerRight <= outerRight && - innerTop >= outerTop && - innerBottom <= outerBottom - ); -} - -/** - * Find the smallest group that fully contains the given bounds. - * Used to determine the parentId for nested groups. - * @param bounds - The bounding box of the new/inner group - * @param groups - All existing groups to check against - * @param excludeId - Optional group ID to exclude (e.g., the new group itself) - */ -export function findParentGroupForBounds( - bounds: { x: number; y: number; width: number; height: number }, - groups: GroupStyleAnnotation[], - excludeId?: string -): GroupStyleAnnotation | null { - let parent: GroupStyleAnnotation | null = null; - let smallestArea = Infinity; - - // Create a temporary group-like object for comparison - const innerBounds: GroupStyleAnnotation = { - id: "__temp__", - name: "", - level: "1", - position: { x: bounds.x, y: bounds.y }, - width: bounds.width, - height: bounds.height - }; - - for (const group of groups) { - // Skip the group itself - if (excludeId !== undefined && group.id === excludeId) continue; - - // Check if this group fully contains the inner bounds - if (isGroupInsideGroup(innerBounds, group)) { - const area = group.width * group.height; - if (area < smallestArea) { - smallestArea = area; - parent = group; - } - } - } - - return parent; -} - -/** Handle node membership change between groups */ -export function handleNodeMembershipChange( - nodeId: string, - oldGroupId: string | null, - newGroupId: string | null, - targetGroup: GroupStyleAnnotation | null, - actions: { - addNodeToGroup: (nodeId: string, groupId: string) => void; - removeNodeFromGroup: (nodeId: string) => void; - }, - onMembershipWillChange?: ( - nodeId: string, - oldGroupId: string | null, - newGroupId: string | null - ) => void -): void { - // Notify about the change - onMembershipWillChange?.(nodeId, oldGroupId, newGroupId); - - // Apply the change - if (newGroupId !== null && targetGroup !== null) { - actions.addNodeToGroup(nodeId, newGroupId); - } else if (oldGroupId !== null) { - actions.removeNodeFromGroup(nodeId); - } -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/index.ts b/src/reactTopoViewer/webview/hooks/canvas/index.ts deleted file mode 100644 index 966586123..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Canvas & graph hooks (React Flow + annotations + groups) - */ -export { - useDeleteHandlers, - useLinkCreation, - useSourceNodePosition, - useKeyboardDeleteHandlers, - useCanvasRefMethods -} from "./useReactFlowCanvasHooks"; - -// Canvas event handlers (React Flow integration) -export { useCanvasHandlers } from "./useCanvasHandlers"; -export { useAnnotationCanvasHandlers } from "./useAnnotationCanvasHandlers"; -export { useGeoMapLayout } from "./useGeoMapLayout"; -export { useHelperLines, calculateAlignments } from "./useHelperLines"; -export type { HelperLinePositions, AlignmentResult } from "./useHelperLines"; - -// Annotation hooks -export { useAnnotations } from "./useAnnotations"; -export type { AnnotationContextValue, AnnotationState, AnnotationActions } from "./annotationTypes"; -export { useDerivedAnnotations } from "./useDerivedAnnotations"; - -// Graph creation hooks -export { useNodeCreation } from "./useNodeCreation"; -export { useNetworkCreation } from "./useNetworkCreation"; -export type { NetworkType } from "./useNetworkCreation"; - -// Group helpers -export type { GroupEditorData, UseGroupClipboardReturn, GroupStyle } from "./groupTypes"; -export { groupToEditorData, editorDataToGroup, GROUP_LABEL_POSITIONS } from "./groupTypes"; -export { - findDeepestGroupAtPosition, - findGroupForNodeAtPosition, - findParentGroupForBounds, - generateGroupId, - handleNodeMembershipChange, - isGroupInsideGroup, - isPositionInsideGroup -} from "./groupUtils"; diff --git a/src/reactTopoViewer/webview/hooks/canvas/themeColor.ts b/src/reactTopoViewer/webview/hooks/canvas/themeColor.ts deleted file mode 100644 index 43ec52c87..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/themeColor.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function readThemeColor(cssVar: string, fallback: string): string { - if (typeof window === "undefined") return fallback; - const bodyColor = window.getComputedStyle(document.body).getPropertyValue(cssVar).trim(); - if (bodyColor) return bodyColor; - const rootColor = window - .getComputedStyle(document.documentElement) - .getPropertyValue(cssVar) - .trim(); - return rootColor || fallback; -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/useAnnotationCanvasHandlers.ts b/src/reactTopoViewer/webview/hooks/canvas/useAnnotationCanvasHandlers.ts deleted file mode 100644 index d7d6d7504..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/useAnnotationCanvasHandlers.ts +++ /dev/null @@ -1,278 +0,0 @@ -/** - * Canvas handlers for annotation interactions in React Flow - * Handles pane clicks, node double-clicks, and node drags for annotations - */ -import type { RefObject } from "react"; -import type React from "react"; -import { useCallback, useEffect, useMemo } from "react"; -import type { Node, ReactFlowInstance } from "@xyflow/react"; - -import type { AnnotationModeState, AnnotationHandlers } from "../../components/canvas/types"; -import { - FREE_TEXT_NODE_TYPE, - FREE_SHAPE_NODE_TYPE, - TRAFFIC_RATE_NODE_TYPE, - GROUP_NODE_TYPE -} from "../../annotations/annotationNodeConverters"; -import { log } from "../../utils/logger"; -import { snapToGrid } from "../../utils/grid"; - -interface UseAnnotationCanvasHandlersOptions { - mode: "view" | "edit"; - isLocked: boolean; - annotationMode?: AnnotationModeState; - annotationHandlers?: AnnotationHandlers; - reactFlowInstanceRef: RefObject; - baseOnPaneClick: (event: React.MouseEvent) => void; - baseOnNodeDoubleClick: (event: React.MouseEvent, node: Node) => void; - baseOnNodeDragStart: (event: React.MouseEvent, node: Node) => void; - baseOnNodeDragStop: (event: React.MouseEvent, node: Node) => void; - /** Callback for shift+click node creation */ - onShiftClickCreate?: (position: { x: number; y: number }) => void; -} - -interface UseAnnotationCanvasHandlersReturn { - wrappedOnPaneClick: (event: React.MouseEvent) => void; - wrappedOnNodeDoubleClick: (event: React.MouseEvent, node: Node) => void; - wrappedOnNodeDragStart: (event: React.MouseEvent, node: Node) => void; - wrappedOnNodeDragStop: (event: React.MouseEvent, node: Node) => void; - isInAddMode: boolean; - addModeMessage: string | null; -} - -/** - * Hook for Escape key to cancel add modes - */ -function useEscapeToCancelAddMode( - annotationMode?: AnnotationModeState, - annotationHandlers?: AnnotationHandlers -) { - useEffect(() => { - const activeAnnotationMode = annotationMode; - const isAddTextMode = activeAnnotationMode?.isAddTextMode === true; - const isAddShapeMode = activeAnnotationMode?.isAddShapeMode === true; - if (activeAnnotationMode === undefined || (!isAddTextMode && !isAddShapeMode)) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== "Escape") return; - if (activeAnnotationMode.isAddTextMode === true) annotationHandlers?.disableAddTextMode(); - if (activeAnnotationMode.isAddShapeMode === true) annotationHandlers?.disableAddShapeMode(); - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [annotationMode?.isAddTextMode, annotationMode?.isAddShapeMode, annotationHandlers]); -} - -/** - * Hook for wrapping pane click handler for annotations - */ -function useWrappedPaneClick( - mode: "view" | "edit", - isLocked: boolean, - annotationMode: AnnotationModeState | undefined, - annotationHandlers: AnnotationHandlers | undefined, - reactFlowInstanceRef: RefObject, - baseOnPaneClick: (event: React.MouseEvent) => void, - onShiftClickCreate?: (position: { x: number; y: number }) => void -) { - return useCallback( - (event: React.MouseEvent) => { - const rfInstance = reactFlowInstanceRef.current; - if (!rfInstance) { - baseOnPaneClick(event); - return; - } - - // Handle Shift+Click for node creation in edit mode - if (event.shiftKey && mode === "edit" && !isLocked && onShiftClickCreate !== undefined) { - const bounds = event.currentTarget.getBoundingClientRect(); - const position = rfInstance.screenToFlowPosition({ - x: event.clientX - bounds.left, - y: event.clientY - bounds.top - }); - log.info(`[ReactFlowCanvas] Shift+Click: Creating node at (${position.x}, ${position.y})`); - onShiftClickCreate(snapToGrid(position)); - return; - } - - if (annotationMode?.isAddTextMode === true && annotationHandlers !== undefined) { - const bounds = event.currentTarget.getBoundingClientRect(); - const position = rfInstance.screenToFlowPosition({ - x: event.clientX - bounds.left, - y: event.clientY - bounds.top - }); - log.info(`[ReactFlowCanvas] Adding text at (${position.x}, ${position.y})`); - annotationHandlers.onAddTextClick(position); - return; - } - - if (annotationMode?.isAddShapeMode === true && annotationHandlers !== undefined) { - const bounds = event.currentTarget.getBoundingClientRect(); - const position = rfInstance.screenToFlowPosition({ - x: event.clientX - bounds.left, - y: event.clientY - bounds.top - }); - log.info(`[ReactFlowCanvas] Adding shape at (${position.x}, ${position.y})`); - annotationHandlers.onAddShapeClick(position); - return; - } - - // Explicitly deselect all nodes/edges when clicking on the pane - // so selection clears even when React Flow doesn't do it implicitly. - const nodes = rfInstance.getNodes(); - const hasSelectedNodes = nodes.some((n) => n.selected === true); - if (hasSelectedNodes) { - rfInstance.setNodes(nodes.map((n) => ({ ...n, selected: false }))); - } - const edges = rfInstance.getEdges(); - const hasSelectedEdges = edges.some((e) => e.selected === true); - if (hasSelectedEdges) { - rfInstance.setEdges(edges.map((e) => ({ ...e, selected: false }))); - } - - baseOnPaneClick(event); - }, - [ - mode, - isLocked, - annotationMode, - annotationHandlers, - reactFlowInstanceRef, - baseOnPaneClick, - onShiftClickCreate - ] - ); -} - -/** - * Hook for wrapping node double-click handler for annotations - */ -function useWrappedNodeDoubleClick( - isLocked: boolean, - annotationHandlers: AnnotationHandlers | undefined, - baseOnNodeDoubleClick: (event: React.MouseEvent, node: Node) => void -) { - return useCallback( - (event: React.MouseEvent, node: Node) => { - if (isLocked || !annotationHandlers) { - baseOnNodeDoubleClick(event, node); - return; - } - - if (node.type === FREE_TEXT_NODE_TYPE) { - log.info(`[ReactFlowCanvas] Editing free text: ${node.id}`); - annotationHandlers.onEditFreeText(node.id); - return; - } - - if (node.type === FREE_SHAPE_NODE_TYPE) { - log.info(`[ReactFlowCanvas] Editing free shape: ${node.id}`); - annotationHandlers.onEditFreeShape(node.id); - return; - } - - if (node.type === GROUP_NODE_TYPE) { - log.info(`[ReactFlowCanvas] Editing group: ${node.id}`); - annotationHandlers.onEditGroup?.(node.id); - return; - } - if (node.type === TRAFFIC_RATE_NODE_TYPE) { - log.info(`[ReactFlowCanvas] Editing traffic-rate annotation: ${node.id}`); - annotationHandlers.onEditTrafficRate?.(node.id); - return; - } - - baseOnNodeDoubleClick(event, node); - }, - [isLocked, annotationHandlers, baseOnNodeDoubleClick] - ); -} - -/** - * Hook for computing add mode state and message - */ -function useAddModeState(annotationMode?: AnnotationModeState) { - const isInAddMode = - annotationMode?.isAddTextMode === true || annotationMode?.isAddShapeMode === true; - - const addModeMessage = useMemo(() => { - if (annotationMode?.isAddTextMode === true) { - return "Adding text — Press Escape to cancel"; - } - if (annotationMode?.isAddShapeMode === true) { - const shapeType = annotationMode.pendingShapeType ?? "shape"; - return `Click on the canvas to add ${shapeType} — Press Escape to cancel`; - } - return null; - }, [ - annotationMode?.isAddTextMode, - annotationMode?.isAddShapeMode, - annotationMode?.pendingShapeType - ]); - - return { isInAddMode, addModeMessage }; -} - -/** - * Hook for annotation-related canvas handlers - */ -export function useAnnotationCanvasHandlers( - options: UseAnnotationCanvasHandlersOptions -): UseAnnotationCanvasHandlersReturn { - const { - mode, - isLocked, - annotationMode, - annotationHandlers, - reactFlowInstanceRef, - baseOnPaneClick, - baseOnNodeDoubleClick, - baseOnNodeDragStart, - baseOnNodeDragStop, - onShiftClickCreate - } = options; - - // Escape key to cancel add modes - useEscapeToCancelAddMode(annotationMode, annotationHandlers); - - // Wrapped handlers - const wrappedOnPaneClick = useWrappedPaneClick( - mode, - isLocked, - annotationMode, - annotationHandlers, - reactFlowInstanceRef, - baseOnPaneClick, - onShiftClickCreate - ); - const wrappedOnNodeDoubleClick = useWrappedNodeDoubleClick( - isLocked, - annotationHandlers, - baseOnNodeDoubleClick - ); - const wrappedOnNodeDragStart = useCallback( - (event: React.MouseEvent, node: Node) => { - baseOnNodeDragStart(event, node); - }, - [baseOnNodeDragStart] - ); - const wrappedOnNodeDragStop = useCallback( - (event: React.MouseEvent, node: Node) => { - baseOnNodeDragStop(event, node); - }, - [baseOnNodeDragStop] - ); - - // Add mode state - const { isInAddMode, addModeMessage } = useAddModeState(annotationMode); - - return { - wrappedOnPaneClick, - wrappedOnNodeDoubleClick, - wrappedOnNodeDragStart, - wrappedOnNodeDragStop, - isInAddMode, - addModeMessage - }; -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/useAnnotations.ts b/src/reactTopoViewer/webview/hooks/canvas/useAnnotations.ts deleted file mode 100644 index 346823ddb..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/useAnnotations.ts +++ /dev/null @@ -1,523 +0,0 @@ -import { useCallback, useMemo } from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; - -import { saveAllNodeGroupMemberships, saveAnnotationNodesFromGraph } from "../../services"; -import { - useAnnotationUIActions, - useAnnotationUIState, - useGraphStore, - useIsLocked -} from "../../stores"; -import { collectNodeGroupMemberships } from "../../annotations/groupMembership"; -import { TRAFFIC_RATE_NODE_TYPE } from "../../annotations/annotationNodeConverters"; -import type { GroupStyleAnnotation } from "../../../shared/types/topology"; - -import type { AnnotationContextValue } from "./annotationTypes"; -import { handleAnnotationNodeDrop, handleTopologyNodeDrop } from "./annotationHelpers"; -import { - findDeepestGroupAtPosition, - findParentGroupForBounds, - generateGroupId -} from "./groupUtils"; -import { useDerivedAnnotations } from "./useDerivedAnnotations"; -import { useGroupAnnotations } from "./useGroupAnnotations"; -import { useShapeAnnotations } from "./useShapeAnnotations"; -import { useTextAnnotations } from "./useTextAnnotations"; -import { useTrafficRateAnnotations } from "./useTrafficRateAnnotations"; - -interface UseAnnotationsParams { - rfInstance: ReactFlowInstance | null; - onLockedAction?: () => void; -} - -export function useAnnotations(params?: UseAnnotationsParams): AnnotationContextValue { - const rfInstance = params?.rfInstance ?? null; - const onLockedAction = params?.onLockedAction ?? (() => {}); - - const isLocked = useIsLocked(); - const uiState = useAnnotationUIState(); - const uiActions = useAnnotationUIActions(); - const derived = useDerivedAnnotations(); - - const groupActions = useGroupAnnotations({ - isLocked, - onLockedAction, - rfInstance, - derived, - uiActions - }); - - const textActions = useTextAnnotations({ - isLocked, - onLockedAction, - derived, - uiState: { - isAddTextMode: uiState.isAddTextMode, - selectedTextIds: uiState.selectedTextIds - }, - uiActions - }); - - const shapeActions = useShapeAnnotations({ - isLocked, - onLockedAction, - derived, - uiState: { - isAddShapeMode: uiState.isAddShapeMode, - pendingShapeType: uiState.pendingShapeType, - selectedShapeIds: uiState.selectedShapeIds - }, - uiActions - }); - - const trafficActions = useTrafficRateAnnotations({ - isLocked, - onLockedAction, - derived, - uiState: { - selectedTrafficRateIds: uiState.selectedTrafficRateIds - }, - uiActions - }); - - const getGroupParentId = useCallback((group: GroupStyleAnnotation): string | null => { - if (typeof group.parentId === "string") return group.parentId; - if (typeof group.groupId === "string") return group.groupId; - return null; - }, []); - - const getGroupDescendants = useCallback( - (rootId: string): Set => { - const descendants = new Set(); - const stack = [rootId]; - - while (stack.length > 0) { - const current = stack.pop(); - if (current === undefined || current.length === 0) continue; - for (const group of derived.groups) { - const parentId = getGroupParentId(group); - if (parentId === null || parentId !== current) continue; - if (!descendants.has(group.id)) { - descendants.add(group.id); - stack.push(group.id); - } - } - } - - return descendants; - }, - [derived.groups, getGroupParentId] - ); - - const handleDroppedGroupReparenting = useCallback( - (nodeId: string, position: { x: number; y: number }, droppedGroup: GroupStyleAnnotation) => { - const bounds = { - x: position.x, - y: position.y, - width: droppedGroup.width, - height: droppedGroup.height - }; - const excluded = getGroupDescendants(nodeId); - excluded.add(nodeId); - const candidateGroups = derived.groups.filter((group) => !excluded.has(group.id)); - const parentGroup = findParentGroupForBounds(bounds, candidateGroups, nodeId); - const nextParentId = parentGroup?.id ?? null; - const currentParentId = getGroupParentId(droppedGroup); - - if (currentParentId === nextParentId) return; - derived.updateGroup(nodeId, { - parentId: nextParentId ?? undefined, - groupId: nextParentId ?? undefined - }); - }, - [derived, getGroupDescendants, getGroupParentId] - ); - - const handleDroppedAnnotationNode = useCallback( - (nodeId: string, targetGroupId: string | null): boolean => { - const movedNode = useGraphStore.getState().nodes.find((node) => node.id === nodeId); - switch (movedNode?.type) { - case "free-text-node": - handleAnnotationNodeDrop( - nodeId, - targetGroupId, - derived.textAnnotations, - derived.updateTextAnnotation - ); - return true; - case "free-shape-node": - handleAnnotationNodeDrop( - nodeId, - targetGroupId, - derived.shapeAnnotations, - derived.updateShapeAnnotation - ); - return true; - case TRAFFIC_RATE_NODE_TYPE: - handleAnnotationNodeDrop( - nodeId, - targetGroupId, - derived.trafficRateAnnotations, - derived.updateTrafficRateAnnotation - ); - return true; - default: - return false; - } - }, - [derived] - ); - - const onNodeDropped = useCallback( - (nodeId: string, position: { x: number; y: number }) => { - const droppedGroup = derived.groups.find((group) => group.id === nodeId); - if (droppedGroup) { - handleDroppedGroupReparenting(nodeId, position, droppedGroup); - return; - } - - const targetGroup = findDeepestGroupAtPosition(position, derived.groups); - const targetGroupId = targetGroup?.id ?? null; - if (handleDroppedAnnotationNode(nodeId, targetGroupId)) { - return; - } - - const currentGroupId = derived.getNodeMembership(nodeId); - handleTopologyNodeDrop( - nodeId, - targetGroupId, - currentGroupId, - derived.addNodeToGroup, - derived.removeNodeFromGroup - ); - // Membership persistence is handled by persistPositionChanges in onNodeDragStop - // to keep position + membership as a single undo entry. - }, - [derived, handleDroppedAnnotationNode, handleDroppedGroupReparenting] - ); - - const deleteSelections = useCallback( - ( - groupIds: Set, - textIds: Set, - shapeIds: Set, - trafficRateIds: Set, - options: { persist: boolean } - ): { didDelete: boolean; membersCleared: boolean } => { - if ( - groupIds.size === 0 && - textIds.size === 0 && - shapeIds.size === 0 && - trafficRateIds.size === 0 - ) { - return { didDelete: false, membersCleared: false }; - } - - const membersToClear = new Set(); - - for (const groupId of groupIds) { - derived.getGroupMembers(groupId).forEach((memberId) => membersToClear.add(memberId)); - derived.deleteGroup(groupId); - uiActions.removeFromGroupSelection(groupId); - } - - for (const id of textIds) { - derived.deleteTextAnnotation(id); - uiActions.removeFromTextSelection(id); - } - - for (const id of shapeIds) { - derived.deleteShapeAnnotation(id); - uiActions.removeFromShapeSelection(id); - } - - for (const id of trafficRateIds) { - derived.deleteTrafficRateAnnotation(id); - uiActions.removeFromTrafficRateSelection(id); - } - - if (membersToClear.size > 0) { - for (const memberId of membersToClear) { - derived.removeNodeFromGroup(memberId); - } - - if (options.persist) { - const memberships = collectNodeGroupMemberships(useGraphStore.getState().nodes); - saveAllNodeGroupMemberships(memberships).catch((err) => { - console.error("[Annotations] Failed to save group memberships", err); - }); - } - } - - uiActions.clearAllSelections(); - - if (options.persist) { - saveAnnotationNodesFromGraph().catch((err) => { - console.error("[Annotations] Failed to save annotations", err); - }); - } - - return { didDelete: true, membersCleared: membersToClear.size > 0 }; - }, - [derived, uiActions, saveAnnotationNodesFromGraph, saveAllNodeGroupMemberships] - ); - - const deleteAllSelected = useCallback(() => { - deleteSelections( - new Set(uiState.selectedGroupIds), - new Set(uiState.selectedTextIds), - new Set(uiState.selectedShapeIds), - new Set(uiState.selectedTrafficRateIds), - { persist: true } - ); - }, [ - uiState.selectedGroupIds, - uiState.selectedTextIds, - uiState.selectedShapeIds, - uiState.selectedTrafficRateIds, - deleteSelections - ]); - - const deleteSelectedForBatch = useCallback( - (options?: { - groupIds?: Iterable; - textIds?: Iterable; - shapeIds?: Iterable; - trafficRateIds?: Iterable; - }): { didDelete: boolean; membersCleared: boolean } => { - const groupIds = new Set(uiState.selectedGroupIds); - const textIds = new Set(uiState.selectedTextIds); - const shapeIds = new Set(uiState.selectedShapeIds); - const trafficRateIds = new Set(uiState.selectedTrafficRateIds); - - for (const id of options?.groupIds ?? []) { - groupIds.add(id); - } - for (const id of options?.textIds ?? []) { - textIds.add(id); - } - for (const id of options?.shapeIds ?? []) { - shapeIds.add(id); - } - for (const id of options?.trafficRateIds ?? []) { - trafficRateIds.add(id); - } - - return deleteSelections(groupIds, textIds, shapeIds, trafficRateIds, { persist: false }); - }, - [ - uiState.selectedGroupIds, - uiState.selectedTextIds, - uiState.selectedShapeIds, - uiState.selectedTrafficRateIds, - deleteSelections - ] - ); - - const persistAnnotationNodes = useCallback(() => { - saveAnnotationNodesFromGraph().catch((err) => { - console.error("[Annotations] Failed to save annotations", err); - }); - }, [saveAnnotationNodesFromGraph]); - - // Persist without re-applying snapshot - use for continuous updates like handle dragging - const persistAnnotationNodesQuiet = useCallback(() => { - saveAnnotationNodesFromGraph(undefined, { applySnapshot: false }).catch((err) => { - console.error("[Annotations] Failed to save annotations (quiet)", err); - }); - }, [saveAnnotationNodesFromGraph]); - - return useMemo( - () => ({ - // State - groups: derived.groups, - selectedGroupIds: uiState.selectedGroupIds, - editingGroup: uiState.editingGroup, - textAnnotations: derived.textAnnotations, - selectedTextIds: uiState.selectedTextIds, - editingTextAnnotation: uiState.editingTextAnnotation, - isAddTextMode: uiState.isAddTextMode, - shapeAnnotations: derived.shapeAnnotations, - selectedShapeIds: uiState.selectedShapeIds, - editingShapeAnnotation: uiState.editingShapeAnnotation, - isAddShapeMode: uiState.isAddShapeMode, - pendingShapeType: uiState.pendingShapeType, - trafficRateAnnotations: derived.trafficRateAnnotations, - selectedTrafficRateIds: uiState.selectedTrafficRateIds, - editingTrafficRateAnnotation: uiState.editingTrafficRateAnnotation, - - // Group actions - selectGroup: uiActions.selectGroup, - toggleGroupSelection: uiActions.toggleGroupSelection, - boxSelectGroups: uiActions.boxSelectGroups, - clearGroupSelection: uiActions.clearGroupSelection, - editGroup: groupActions.editGroup, - closeGroupEditor: uiActions.closeGroupEditor, - saveGroup: groupActions.saveGroup, - deleteGroup: groupActions.deleteGroup, - updateGroup: derived.updateGroup, - updateGroupParent: (id, parentId) => { - derived.updateGroup(id, { - parentId: parentId ?? undefined, - groupId: parentId ?? undefined - }); - persistAnnotationNodes(); - }, - updateGroupGeoPosition: (id, coords) => { - derived.updateGroup(id, { geoCoordinates: coords }); - persistAnnotationNodes(); - }, - addNodeToGroup: derived.addNodeToGroup, - getNodeMembership: derived.getNodeMembership, - getGroupMembers: derived.getGroupMembers, - handleAddGroup: groupActions.handleAddGroup, - createGroupAtPosition: groupActions.createGroupAtPosition, - generateGroupId: () => generateGroupId(derived.groups), - addGroup: groupActions.addGroup, - updateGroupSize: groupActions.updateGroupSize, - - // Text actions - handleAddText: textActions.handleAddText, - createTextAtPosition: textActions.createTextAtPosition, - disableAddTextMode: uiActions.disableAddTextMode, - selectTextAnnotation: uiActions.selectTextAnnotation, - toggleTextAnnotationSelection: uiActions.toggleTextAnnotationSelection, - boxSelectTextAnnotations: uiActions.boxSelectTextAnnotations, - clearTextAnnotationSelection: uiActions.clearTextAnnotationSelection, - editTextAnnotation: textActions.editTextAnnotation, - closeTextEditor: uiActions.closeTextEditor, - saveTextAnnotation: textActions.saveTextAnnotation, - previewTextAnnotation: (annotation) => { - const exists = derived.textAnnotations.some((entry) => entry.id === annotation.id); - if (exists) { - derived.updateTextAnnotation(annotation.id, annotation); - return; - } - derived.addTextAnnotation(annotation); - }, - removePreviewTextAnnotation: (id) => { - derived.deleteTextAnnotation(id); - }, - deleteTextAnnotation: textActions.deleteTextAnnotation, - deleteSelectedTextAnnotations: textActions.deleteSelectedTextAnnotations, - updateTextRotation: (id: string, rotation: number) => { - const currentRotation = derived.textAnnotations.find( - (annotation) => annotation.id === id - )?.rotation; - if ((currentRotation ?? 0) === rotation) return; - derived.updateTextAnnotation(id, { rotation }); - }, - onTextRotationStart: textActions.onTextRotationStart, - onTextRotationEnd: textActions.onTextRotationEnd, - updateTextSize: (id, width, height) => { - derived.updateTextAnnotation(id, { width, height }); - }, - updateTextGeoPosition: (id, coords) => { - derived.updateTextAnnotation(id, { geoCoordinates: coords }); - persistAnnotationNodes(); - }, - updateTextAnnotation: derived.updateTextAnnotation, - handleTextCanvasClick: textActions.handleTextCanvasClick, - - // Shape actions - handleAddShapes: shapeActions.handleAddShapes, - createShapeAtPosition: shapeActions.createShapeAtPosition, - disableAddShapeMode: uiActions.disableAddShapeMode, - selectShapeAnnotation: uiActions.selectShapeAnnotation, - toggleShapeAnnotationSelection: uiActions.toggleShapeAnnotationSelection, - boxSelectShapeAnnotations: uiActions.boxSelectShapeAnnotations, - clearShapeAnnotationSelection: uiActions.clearShapeAnnotationSelection, - editShapeAnnotation: shapeActions.editShapeAnnotation, - closeShapeEditor: uiActions.closeShapeEditor, - saveShapeAnnotation: shapeActions.saveShapeAnnotation, - previewShapeAnnotation: (annotation) => { - const exists = derived.shapeAnnotations.some((entry) => entry.id === annotation.id); - if (exists) { - derived.updateShapeAnnotation(annotation.id, annotation); - return; - } - derived.addShapeAnnotation(annotation); - }, - removePreviewShapeAnnotation: (id) => { - derived.deleteShapeAnnotation(id); - }, - deleteShapeAnnotation: shapeActions.deleteShapeAnnotation, - deleteSelectedShapeAnnotations: shapeActions.deleteSelectedShapeAnnotations, - updateShapeRotation: (id, rotation) => { - const currentRotation = derived.shapeAnnotations.find( - (annotation) => annotation.id === id - )?.rotation; - if ((currentRotation ?? 0) === rotation) return; - derived.updateShapeAnnotation(id, { rotation }); - }, - onShapeRotationStart: shapeActions.onShapeRotationStart, - onShapeRotationEnd: shapeActions.onShapeRotationEnd, - updateShapeSize: (id, width, height) => { - derived.updateShapeAnnotation(id, { width, height }); - persistAnnotationNodes(); - }, - updateShapeStartPosition: (id, startPosition) => { - // Only update local state during drag - persist should be called on drag end - derived.updateShapeAnnotation(id, { position: startPosition }); - }, - updateShapeEndPosition: (id, endPosition) => { - // Only update local state during drag - persist should be called on drag end - derived.updateShapeAnnotation(id, { endPosition }); - }, - // Persist annotations (call on drag end) - persistAnnotations: persistAnnotationNodesQuiet, - updateShapeGeoPosition: (id, coords) => { - derived.updateShapeAnnotation(id, { geoCoordinates: coords }); - persistAnnotationNodes(); - }, - updateShapeEndGeoPosition: (id, coords) => { - derived.updateShapeAnnotation(id, { endGeoCoordinates: coords }); - persistAnnotationNodes(); - }, - updateShapeAnnotation: derived.updateShapeAnnotation, - handleShapeCanvasClick: shapeActions.handleShapeCanvasClick, - - // Traffic-rate actions - createTrafficRateAtPosition: trafficActions.createTrafficRateAtPosition, - selectTrafficRateAnnotation: uiActions.selectTrafficRateAnnotation, - toggleTrafficRateAnnotationSelection: uiActions.toggleTrafficRateAnnotationSelection, - boxSelectTrafficRateAnnotations: uiActions.boxSelectTrafficRateAnnotations, - clearTrafficRateAnnotationSelection: uiActions.clearTrafficRateAnnotationSelection, - editTrafficRateAnnotation: trafficActions.editTrafficRateAnnotation, - closeTrafficRateEditor: uiActions.closeTrafficRateEditor, - saveTrafficRateAnnotation: trafficActions.saveTrafficRateAnnotation, - deleteTrafficRateAnnotation: trafficActions.deleteTrafficRateAnnotation, - deleteSelectedTrafficRateAnnotations: trafficActions.deleteSelectedTrafficRateAnnotations, - updateTrafficRateSize: (id, width, height) => { - derived.updateTrafficRateAnnotation(id, { width, height }); - }, - updateTrafficRateAnnotation: derived.updateTrafficRateAnnotation, - updateTrafficRateGeoPosition: (id, coords) => { - derived.updateTrafficRateAnnotation(id, { geoCoordinates: coords }); - persistAnnotationNodes(); - }, - - // Membership - onNodeDropped, - - // Utilities - clearAllSelections: uiActions.clearAllSelections, - deleteAllSelected, - deleteSelectedForBatch - }), - [ - derived, - uiState, - uiActions, - groupActions, - textActions, - shapeActions, - trafficActions, - onNodeDropped, - deleteAllSelected, - deleteSelectedForBatch, - persistAnnotationNodes, - persistAnnotationNodesQuiet - ] - ); -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/useCanvasHandlers.ts b/src/reactTopoViewer/webview/hooks/canvas/useCanvasHandlers.ts deleted file mode 100644 index 03dc08fd0..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/useCanvasHandlers.ts +++ /dev/null @@ -1,1055 +0,0 @@ -/** - * Canvas event handlers for ReactFlowCanvas - * Comprehensive handlers for all canvas interactions - */ -import type React from "react"; -import { useCallback, useRef, useState } from "react"; -import { - type ReactFlowInstance, - type OnNodesChange, - type NodeMouseHandler, - type EdgeMouseHandler, - type OnConnect, - type OnSelectionChangeFunc, - type Connection, - type Node, - type Edge, - type NodeChange, - type NodePositionChange, - type XYPosition -} from "@xyflow/react"; - -import type { TopoNode, TopoEdge, FreeShapeNodeData } from "../../../shared/types/graph"; -import { log } from "../../utils/logger"; -import { isLineHandleActive } from "../../components/canvas/nodes/AnnotationHandles"; -import { - FREE_SHAPE_NODE_TYPE, - GROUP_NODE_TYPE, - isAnnotationNodeType -} from "../../annotations/annotationNodeConverters"; -import { DEFAULT_LINE_LENGTH } from "../../annotations/constants"; -import { - saveAnnotationNodesFromGraph, - saveNodePositions, - saveNodePositionsWithAnnotations, - saveNodePositionsWithMemberships -} from "../../services"; -import { useGraphStore } from "../../stores/graphStore"; -import { allocateEndpointsForLink } from "../../utils/endpointAllocator"; -import { buildEdgeId } from "../../utils/edgeId"; -import { snapToGrid } from "../../utils/grid"; - -/** Handlers for group member movement during drag */ -export interface GroupMemberHandlers { - /** Get member node IDs for a group */ - getGroupMembers?: (groupId: string, options?: { includeNested?: boolean }) => string[]; - /** Handle node dropped (for group membership updates) */ - onNodeDropped?: (nodeId: string, position: { x: number; y: number }) => void; -} - -interface CanvasHandlersConfig { - selectNode: (id: string | null) => void; - selectEdge: (id: string | null) => void; - editNode: (id: string | null) => void; - editNetwork: (id: string | null) => void; - editEdge: (id: string | null) => void; - mode: "view" | "edit"; - isLocked: boolean; - onNodesChangeBase: OnNodesChange; - onLockedAction?: () => void; - /** Called only when the click actually falls through to the pane (no add-mode/shift-click handling). */ - onPaneClickExtra?: () => void; - /** - * Optional guard to suppress syncing React Flow selection into the app store. - * Used to prevent side effects (like auto-opening the ContextPanel) during transient - * interactions such as link creation. - */ - shouldSuppressSelectionSync?: () => boolean; - /** Current nodes (needed for position tracking) */ - nodes?: Node[]; - /** Direct setNodes for member node updates (bypasses React Flow drag tracking) */ - setNodes?: React.Dispatch>; - /** Callback when an edge is created via drag-to-connect */ - onEdgeCreated?: ( - sourceId: string, - targetId: string, - edgeData: { - id: string; - source: string; - target: string; - sourceEndpoint: string; - targetEndpoint: string; - } - ) => void; - /** Handlers for group member movement */ - groupMemberHandlers?: GroupMemberHandlers; - /** Optional shared React Flow instance ref */ - reactFlowInstanceRef?: React.RefObject; - /** Geo layout support */ - geoLayout?: { - isGeoLayout: boolean; - isEditable: boolean; - getGeoUpdateForNode?: (node: Node) => { - geoCoordinates?: { lat: number; lng: number }; - endGeoCoordinates?: { lat: number; lng: number }; - } | null; - }; -} - -interface ContextMenuState { - type: "node" | "edge" | "pane" | null; - position: { x: number; y: number }; - targetId: string | null; -} - -interface CanvasHandlers { - reactFlowInstance: React.RefObject; - onInit: (instance: ReactFlowInstance) => void; - onNodeClick: NodeMouseHandler; - onNodeDoubleClick: NodeMouseHandler; - onEdgeClick: EdgeMouseHandler; - onEdgeDoubleClick: EdgeMouseHandler; - onPaneClick: (event: React.MouseEvent) => void; - onConnect: OnConnect; - handleNodesChange: OnNodesChange; - onSelectionChange: OnSelectionChangeFunc; - onNodeContextMenu: (event: React.MouseEvent, node: Node) => void; - onEdgeContextMenu: (event: React.MouseEvent, edge: Edge) => void; - onPaneContextMenu: (event: MouseEvent | React.MouseEvent) => void; - onNodeDragStart: NodeMouseHandler; - onNodeDrag: NodeMouseHandler; - onNodeDragStop: NodeMouseHandler; - contextMenu: ContextMenuState; - closeContextMenu: () => void; -} - -const NODE_TYPE_TOPOLOGY = "topology-node"; -const NODE_TYPE_NETWORK = "network-node"; -const EDITABLE_NODE_TYPES = [NODE_TYPE_TOPOLOGY, NODE_TYPE_NETWORK]; - -// ============================================================================ -// Line drag helpers -// ============================================================================ - -interface LineDragSnapshot { - nodePosition: XYPosition; - startPosition: XYPosition; - endPosition: XYPosition; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isLineShapeNode(node: Node): node is Node { - if (node.type !== FREE_SHAPE_NODE_TYPE) return false; - if (!isRecord(node.data)) return false; - return node.data.shapeType === "line"; -} - -function isTopoNode(node: Node): node is TopoNode { - return ( - node.type === "topology-node" || - node.type === "network-node" || - node.type === "group-node" || - node.type === "free-text-node" || - node.type === "free-shape-node" || - node.type === "traffic-rate-node" - ); -} - -function isTopoEdge(edge: Edge): edge is TopoEdge { - return ( - typeof edge.id === "string" && - typeof edge.source === "string" && - typeof edge.target === "string" - ); -} - -function getLineEndpoints(node: Node): { start: XYPosition; end: XYPosition } | null { - if (!isLineShapeNode(node)) return null; - const data = node.data; - const start = data.startPosition ?? node.position; - const end = data.endPosition ?? { - x: start.x + DEFAULT_LINE_LENGTH, - y: start.y - }; - return { - start: { x: start.x, y: start.y }, - end: { x: end.x, y: end.y } - }; -} - -function recordLineDragSnapshot(snapshots: Map, node: Node): void { - const endpoints = getLineEndpoints(node); - if (!endpoints) return; - snapshots.set(node.id, { - nodePosition: { x: node.position.x, y: node.position.y }, - startPosition: endpoints.start, - endPosition: endpoints.end - }); -} - -function collectLineDragNodes( - draggedNode: Node, - nodes: Node[] | undefined, - groupMemberHandlers?: GroupMemberHandlers -): Node[] { - if (!nodes) { - return isLineShapeNode(draggedNode) ? [draggedNode] : []; - } - - if (draggedNode.type === GROUP_NODE_TYPE && groupMemberHandlers?.getGroupMembers) { - const memberIds = groupMemberHandlers.getGroupMembers(draggedNode.id, { includeNested: true }); - return memberIds - .map((id) => nodes.find((node) => node.id === id)) - .filter((node): node is Node => Boolean(node)) - .filter(isLineShapeNode); - } - - const selectedLines = nodes.filter((node) => node.selected === true && isLineShapeNode(node)); - if (selectedLines.length > 0) return selectedLines; - return isLineShapeNode(draggedNode) ? [draggedNode] : []; -} - -function applyLineDragSnapshots(snapshots: Map): void { - if (snapshots.size === 0) return; - const currentNodes = useGraphStore.getState().nodes; - const updateNode = useGraphStore.getState().updateNode; - - for (const [id, snapshot] of snapshots) { - const currentNode = currentNodes.find((node) => node.id === id); - if (!currentNode) continue; - const dx = currentNode.position.x - snapshot.nodePosition.x; - const dy = currentNode.position.y - snapshot.nodePosition.y; - if (dx === 0 && dy === 0) continue; - updateNode(id, { - data: { - startPosition: { - x: snapshot.startPosition.x + dx, - y: snapshot.startPosition.y + dy - }, - endPosition: { - x: snapshot.endPosition.x + dx, - y: snapshot.endPosition.y + dy - } - } - }); - } - - snapshots.clear(); -} - -// ============================================================================ -// Node drag stop helpers (extracted for complexity reduction) -// ============================================================================ - -/** Build position changes for group members */ -function buildGroupMemberChanges( - _node: Node, - members: string[], - nodes: Node[] | undefined -): NodeChange[] { - const changes: NodeChange[] = []; - for (const memberId of members) { - const memberNode = nodes?.find((n) => n.id === memberId); - if (memberNode) { - changes.push({ - type: "position", - id: memberId, - position: memberNode.position, - dragging: false - }); - } - } - return changes; -} - -function buildSelectedNodeChanges( - draggedNodeId: string, - nodes: Node[] | undefined, - excludeIds: Set, - delta?: XYPosition -): NodeChange[] { - if (!nodes) return []; - const changes: NodeChange[] = []; - for (const node of nodes) { - if (node.id === draggedNodeId) continue; - if (node.selected !== true) continue; - if (excludeIds.has(node.id)) continue; - const position = delta - ? { x: node.position.x + delta.x, y: node.position.y + delta.y } - : node.position; - changes.push({ type: "position", id: node.id, position, dragging: false }); - } - return changes; -} - -function isNodePositionChange(change: NodeChange): change is NodePositionChange { - return change.type === "position" && change.position !== undefined; -} - -/** Clean up group tracking refs */ -function cleanupGroupRefs( - nodeId: string, - groupMembersRef: React.RefObject>, - groupMemberIdSetsRef: React.RefObject>>, - groupLastPositionRef: React.RefObject> -): void { - groupMembersRef.current.delete(nodeId); - groupMemberIdSetsRef.current.delete(nodeId); - groupLastPositionRef.current.delete(nodeId); -} - -function updateNodeWithGeoData( - setNodes: React.Dispatch> | undefined, - nodeId: string, - update: { - geoCoordinates?: { lat: number; lng: number }; - endGeoCoordinates?: { lat: number; lng: number }; - } -) { - if (!setNodes) return; - setNodes((latestNodes) => applyGeoUpdateToNodeList(latestNodes, nodeId, update)); -} - -function applyGeoUpdateToNodeList( - nodes: Node[], - nodeId: string, - update: { - geoCoordinates?: { lat: number; lng: number }; - endGeoCoordinates?: { lat: number; lng: number }; - } -): Node[] { - return nodes.map((n) => { - if (n.id !== nodeId) return n; - const data = isRecord(n.data) ? n.data : {}; - return { - ...n, - data: { - ...data, - ...(update.geoCoordinates ? { geoCoordinates: update.geoCoordinates } : {}), - ...(update.endGeoCoordinates ? { endGeoCoordinates: update.endGeoCoordinates } : {}) - } - }; - }); -} - -function saveGeoUpdate( - currentNodes: Node[], - nodeId: string, - update: { - geoCoordinates?: { lat: number; lng: number }; - endGeoCoordinates?: { lat: number; lng: number }; - } -) { - const nodeTypeMap = new Map(currentNodes.map((n) => [n.id, n.type])); - const isAnnotation = isAnnotationNodeType(nodeTypeMap.get(nodeId)); - - if (isAnnotation) { - const nodesForSave = applyGeoUpdateToNodeList(currentNodes, nodeId, update); - void saveAnnotationNodesFromGraph(nodesForSave); - return; - } - - if (update.geoCoordinates) { - void saveNodePositions([{ id: nodeId, geoCoordinates: update.geoCoordinates }]); - } -} - -function handleGeoDragStop( - node: Node, - onNodesChangeBase: OnNodesChange, - setNodes: React.Dispatch> | undefined, - geoLayout: CanvasHandlersConfig["geoLayout"] -): boolean { - const isGeoEdit = geoLayout?.isGeoLayout === true && geoLayout.isEditable === true; - if (!isGeoEdit || geoLayout.getGeoUpdateForNode === undefined) return false; - - const draggedPosition = node.position; - log.info( - `[ReactFlowCanvas] Node ${node.id} dragged to geo position ${draggedPosition.x}, ${draggedPosition.y}` - ); - - const changes: NodeChange[] = [ - { type: "position", id: node.id, position: draggedPosition, dragging: false } - ]; - onNodesChangeBase(changes); - - const currentNodes = useGraphStore.getState().nodes; - const storeNode = currentNodes.find((n) => n.id === node.id); - if (!storeNode) return true; - - const movedNode = { ...storeNode, position: draggedPosition }; - const update = geoLayout.getGeoUpdateForNode(movedNode); - if (!update?.geoCoordinates && !update?.endGeoCoordinates) return true; - - updateNodeWithGeoData(setNodes, node.id, update); - saveGeoUpdate(currentNodes, node.id, update); - return true; -} - -function finalizeGroupChanges( - node: Node, - nodes: Node[] | undefined, - groupMembersRef: React.RefObject>, - groupMemberIdSetsRef: React.RefObject>>, - groupLastPositionRef: React.RefObject> -): NodeChange[] { - const memberIds = groupMembersRef.current.get(node.id) ?? []; - const memberChanges = buildGroupMemberChanges(node, memberIds, nodes); - cleanupGroupRefs(node.id, groupMembersRef, groupMemberIdSetsRef, groupLastPositionRef); - return memberChanges; -} - -function flushScheduledGroupMove( - groupMoveRafIdRef: React.RefObject, - flushPendingGroupMove: () => void -): void { - if (groupMoveRafIdRef.current !== null) { - window.cancelAnimationFrame(groupMoveRafIdRef.current); - groupMoveRafIdRef.current = null; - } - flushPendingGroupMove(); -} - -function persistPositionChanges(changes: NodeChange[]) { - const currentNodes = useGraphStore.getState().nodes; - const nodeTypeMap = new Map(currentNodes.map((n) => [n.id, n.type])); - const movedPositions = changes - .filter(isNodePositionChange) - .map((change) => ({ id: change.id, position: change.position })); - - const topoPositions = movedPositions.filter( - (pos) => !isAnnotationNodeType(nodeTypeMap.get(pos.id)) - ); - const movedAnnotations = movedPositions.some((pos) => - isAnnotationNodeType(nodeTypeMap.get(pos.id)) - ); - - // When both topology positions and annotations are moved together (e.g., group with members), - // save them in a single command to create one undo entry - if (topoPositions.length > 0 && movedAnnotations) { - void saveNodePositionsWithAnnotations(topoPositions, currentNodes); - return; - } - - if (topoPositions.length > 0) { - // Include memberships so position + membership changes are a single undo entry - // (e.g., dragging a node into/out of a group). - void saveNodePositionsWithMemberships(topoPositions); - return; - } - - if (movedAnnotations) { - // Use applySnapshot: false to prevent snapshot re-apply from reverting local changes - void saveAnnotationNodesFromGraph(currentNodes, { applySnapshot: false }); - } -} - -/** Hook for node drag handlers with group member movement */ -function useNodeDragHandlers( - isLockedRef: React.RefObject, - nodes: Node[] | undefined, - onNodesChangeBase: OnNodesChange, - setNodes: React.Dispatch> | undefined, - groupMemberHandlers?: GroupMemberHandlers, - geoLayout?: CanvasHandlersConfig["geoLayout"] -) { - // Track the last position of a dragging group to compute delta - const groupLastPositionRef = useRef>(new Map()); - // Track member IDs that are being moved with a group - const groupMembersRef = useRef>(new Map()); - const groupMemberIdSetsRef = useRef>>(new Map()); - const pendingGroupMoveRef = useRef<{ - nodeId: string; - dx: number; - dy: number; - memberIdSet: Set; - } | null>(null); - const groupMoveRafIdRef = useRef(null); - const lineDragStartRef = useRef>(new Map()); - - const flushPendingGroupMove = useCallback(() => { - const pending = pendingGroupMoveRef.current; - pendingGroupMoveRef.current = null; - if (!pending || !setNodes) return; - if ((pending.dx === 0 && pending.dy === 0) || pending.memberIdSet.size === 0) return; - - setNodes((currentNodes) => - currentNodes.map((n) => { - if (!pending.memberIdSet.has(n.id)) { - return n; - } - return { - ...n, - position: { - x: n.position.x + pending.dx, - y: n.position.y + pending.dy - } - }; - }) - ); - }, [setNodes]); - - const scheduleGroupMoveFlush = useCallback(() => { - if (groupMoveRafIdRef.current !== null) return; - groupMoveRafIdRef.current = window.requestAnimationFrame(() => { - groupMoveRafIdRef.current = null; - flushPendingGroupMove(); - }); - }, [flushPendingGroupMove]); - - const onNodeDragStart: NodeMouseHandler = useCallback( - (_event, node) => { - if (isLockedRef.current || !nodes) return; - - // If dragging a group node, capture members and their initial positions - if (node.type === GROUP_NODE_TYPE && groupMemberHandlers?.getGroupMembers) { - const memberIds = groupMemberHandlers.getGroupMembers(node.id, { includeNested: true }); - groupMembersRef.current.set(node.id, memberIds); - groupMemberIdSetsRef.current.set(node.id, new Set(memberIds)); - groupLastPositionRef.current.set(node.id, { ...node.position }); - } - - lineDragStartRef.current.clear(); - const lineNodes = collectLineDragNodes(node, nodes, groupMemberHandlers); - for (const lineNode of lineNodes) { - recordLineDragSnapshot(lineDragStartRef.current, lineNode); - } - }, - [isLockedRef, nodes, groupMemberHandlers] - ); - - // Called during drag - moves members with group using direct state update - const onNodeDrag: NodeMouseHandler = useCallback( - (_event, node) => { - if (isLockedRef.current || !setNodes) return; - - // Handle group member movement during drag - if (node.type === GROUP_NODE_TYPE) { - const lastPos = groupLastPositionRef.current.get(node.id); - const memberIdSet = groupMemberIdSetsRef.current.get(node.id); - - if (lastPos && memberIdSet && memberIdSet.size > 0) { - // Calculate delta - const dx = node.position.x - lastPos.x; - const dy = node.position.y - lastPos.y; - - if (dx !== 0 || dy !== 0) { - const pending = pendingGroupMoveRef.current; - if (pending && pending.nodeId === node.id) { - pending.dx += dx; - pending.dy += dy; - } else { - flushPendingGroupMove(); - pendingGroupMoveRef.current = { nodeId: node.id, dx, dy, memberIdSet }; - } - scheduleGroupMoveFlush(); - } - } - - // Update last position for next delta calculation - groupLastPositionRef.current.set(node.id, { ...node.position }); - } - }, - [isLockedRef, flushPendingGroupMove, scheduleGroupMoveFlush] - ); - - const onNodeDragStop: NodeMouseHandler = useCallback( - (_event, node) => { - if (isLockedRef.current) return; - - flushScheduledGroupMove(groupMoveRafIdRef, flushPendingGroupMove); - - // Skip for shape nodes with active line handle - if (node.type === FREE_SHAPE_NODE_TYPE && isLineHandleActive()) { - lineDragStartRef.current.clear(); - return; - } - - if (handleGeoDragStop(node, onNodesChangeBase, setNodes, geoLayout)) { - lineDragStartRef.current.clear(); - return; - } - - // Normal (non-geo) mode: update preset position - const isGroupNode = node.type === GROUP_NODE_TYPE; - const shouldSnap = !isAnnotationNodeType(node.type); - const finalPosition = isGroupNode || !shouldSnap ? node.position : snapToGrid(node.position); - const changes: NodeChange[] = [ - { type: "position", id: node.id, position: finalPosition, dragging: false } - ]; - const delta = isGroupNode - ? null - : { - x: finalPosition.x - node.position.x, - y: finalPosition.y - node.position.y - }; - - // Handle group node members - if (isGroupNode) { - changes.push( - ...finalizeGroupChanges( - node, - nodes, - groupMembersRef, - groupMemberIdSetsRef, - groupLastPositionRef - ) - ); - } - - // Include other selected nodes for multi-drag persistence (and snap with same delta) - const excludeIds = new Set( - changes.filter((c): c is NodeChange & { id: string } => "id" in c).map((c) => c.id) - ); - changes.push( - ...buildSelectedNodeChanges( - node.id, - nodes, - excludeIds, - delta && (delta.x !== 0 || delta.y !== 0) ? delta : undefined - ) - ); - - onNodesChangeBase(changes); - log.info(`[ReactFlowCanvas] Node ${node.id} moved to ${finalPosition.x}, ${finalPosition.y}`); - - // Notify group member handler for membership updates - if (groupMemberHandlers?.onNodeDropped) { - groupMemberHandlers.onNodeDropped(node.id, finalPosition); - } - - applyLineDragSnapshots(lineDragStartRef.current); - persistPositionChanges(changes); - }, - [ - isLockedRef, - nodes, - onNodesChangeBase, - groupMemberHandlers, - geoLayout, - setNodes, - flushPendingGroupMove - ] - ); - - return { onNodeDragStart, onNodeDrag, onNodeDragStop }; -} - -/** Hook for context menu handlers */ -function useContextMenuHandlers( - selectNode: (id: string | null) => void, - selectEdge: (id: string | null) => void, - openNodeMenu: (x: number, y: number, id: string) => void, - openEdgeMenu: (x: number, y: number, id: string) => void, - openPaneMenu: (x: number, y: number) => void -) { - const onNodeContextMenu = useCallback( - (event: React.MouseEvent, node: Node) => { - event.preventDefault(); - event.stopPropagation(); - selectNode(node.id); - selectEdge(null); - openNodeMenu(event.clientX, event.clientY, node.id); - }, - [selectNode, selectEdge, openNodeMenu] - ); - - const onEdgeContextMenu = useCallback( - (event: React.MouseEvent, edge: Edge) => { - event.preventDefault(); - event.stopPropagation(); - selectEdge(edge.id); - selectNode(null); - openEdgeMenu(event.clientX, event.clientY, edge.id); - }, - [selectNode, selectEdge, openEdgeMenu] - ); - - const onPaneContextMenu = useCallback( - (event: MouseEvent | React.MouseEvent) => { - event.preventDefault(); - selectNode(null); - selectEdge(null); - openPaneMenu(event.clientX, event.clientY); - }, - [selectNode, selectEdge, openPaneMenu] - ); - - return { onNodeContextMenu, onEdgeContextMenu, onPaneContextMenu }; -} - -/** Hook for context menu state management */ -function useContextMenuState() { - const [contextMenu, setContextMenu] = useState({ - type: null, - position: { x: 0, y: 0 }, - targetId: null - }); - - const closeContextMenu = useCallback(() => { - setContextMenu({ type: null, position: { x: 0, y: 0 }, targetId: null }); - }, []); - - const openNodeMenu = useCallback((x: number, y: number, nodeId: string) => { - setContextMenu({ type: "node", position: { x, y }, targetId: nodeId }); - }, []); - - const openEdgeMenu = useCallback((x: number, y: number, edgeId: string) => { - setContextMenu({ type: "edge", position: { x, y }, targetId: edgeId }); - }, []); - - const openPaneMenu = useCallback((x: number, y: number) => { - setContextMenu({ type: "pane", position: { x, y }, targetId: null }); - }, []); - - return { contextMenu, closeContextMenu, openNodeMenu, openEdgeMenu, openPaneMenu }; -} - -/** Hook for node click handlers */ -function useNodeClickHandlers( - selectNode: (id: string | null) => void, - selectEdge: (id: string | null) => void, - editNode: (id: string | null) => void, - editNetwork: (id: string | null) => void, - closeContextMenu: () => void, - modeRef: React.RefObject<"view" | "edit"> -) { - const onNodeClick: NodeMouseHandler = useCallback( - (_event, node) => { - log.info(`[ReactFlowCanvas] Node clicked: ${node.id}`); - closeContextMenu(); - if (isAnnotationNodeType(node.type)) return; - // In edit mode, open editor directly (read-only when locked) - if (modeRef.current === "edit" && EDITABLE_NODE_TYPES.includes(node.type ?? "")) { - if (node.type === NODE_TYPE_NETWORK) { - editNetwork(node.id); - } else { - editNode(node.id); - } - } else { - selectNode(node.id); - selectEdge(null); - } - }, - [selectNode, selectEdge, editNode, editNetwork, closeContextMenu, modeRef] - ); - - const onNodeDoubleClick: NodeMouseHandler = useCallback((_event, _node) => { - // Node editing in edit mode is handled by single click. - // Annotation double-click (text/shape/group) is handled by the annotation wrapper. - }, []); - - return { onNodeClick, onNodeDoubleClick }; -} - -/** Hook for edge click handlers */ -function useEdgeClickHandlers( - selectNode: (id: string | null) => void, - selectEdge: (id: string | null) => void, - editEdge: (id: string | null) => void, - closeContextMenu: () => void, - modeRef: React.RefObject<"view" | "edit"> -) { - const onEdgeClick: EdgeMouseHandler = useCallback( - (_event, edge) => { - log.info(`[ReactFlowCanvas] Edge clicked: ${edge.id}`); - closeContextMenu(); - // In edit mode, open editor directly (read-only when locked) - if (modeRef.current === "edit") { - editEdge(edge.id); - } else { - selectEdge(edge.id); - selectNode(null); - } - }, - [selectNode, selectEdge, editEdge, closeContextMenu, modeRef] - ); - - const onEdgeDoubleClick: EdgeMouseHandler = useCallback((_event, _edge) => { - // Edge editing in edit mode is handled by single click. - }, []); - - return { onEdgeClick, onEdgeDoubleClick }; -} - -/** Hook for pane click handler */ -function usePaneClickHandler( - selectNode: (id: string | null) => void, - selectEdge: (id: string | null) => void, - editNode: (id: string | null) => void, - closeContextMenu: () => void, - reactFlowInstance: React.RefObject, - modeRef: React.RefObject<"view" | "edit">, - isLockedRef: React.RefObject, - onLockedAction?: () => void, - onPaneClickExtra?: () => void -) { - return useCallback( - (_event: React.MouseEvent) => { - closeContextMenu(); - document.dispatchEvent(new Event("topoviewer:pane-click")); - - selectNode(null); - selectEdge(null); - // Clear editing state so panel returns to palette - editNode(null); - onPaneClickExtra?.(); - }, - [ - selectNode, - selectEdge, - editNode, - closeContextMenu, - onLockedAction, - onPaneClickExtra, - reactFlowInstance, - modeRef, - isLockedRef - ] - ); -} - -/** Hook for connection handler */ -function useConnectionHandler( - modeRef: React.RefObject<"view" | "edit">, - isLockedRef: React.RefObject, - onLockedAction?: () => void, - onEdgeCreated?: ( - sourceId: string, - targetId: string, - edgeData: { - id: string; - source: string; - target: string; - sourceEndpoint: string; - targetEndpoint: string; - } - ) => void -) { - return useCallback( - (connection: Connection) => { - if (modeRef.current !== "edit") return; - if (isLockedRef.current) { - onLockedAction?.(); - return; - } - if (!connection.source || !connection.target) return; - - log.info( - `[ReactFlowCanvas] Creating edge via drag-connect: ${connection.source} -> ${connection.target}` - ); - const { nodes, edges } = useGraphStore.getState(); - const topoNodes = nodes.filter(isTopoNode); - const topoEdges = edges.filter(isTopoEdge); - const { sourceEndpoint, targetEndpoint } = allocateEndpointsForLink( - topoNodes, - topoEdges, - connection.source, - connection.target - ); - const edgeId = buildEdgeId( - connection.source, - connection.target, - sourceEndpoint, - targetEndpoint - ); - - const edgeData = { - id: edgeId, - source: connection.source, - target: connection.target, - sourceEndpoint, - targetEndpoint - }; - - // Use unified callback which handles: - // 1. Adding edge to React state - // 2. Persisting via TopologyHost commands - // 3. Undo/redo support - if (onEdgeCreated) { - onEdgeCreated(connection.source, connection.target, edgeData); - } - }, - [onLockedAction, modeRef, isLockedRef, onEdgeCreated] - ); -} - -/** Node types that can be selected via box selection and synced to context */ -const SELECTABLE_NODE_TYPES = [NODE_TYPE_TOPOLOGY, NODE_TYPE_NETWORK]; - -/** Hook for selection change handler (box selection support) */ -function useSelectionChangeHandler( - selectNode: (id: string | null) => void, - selectEdge: (id: string | null) => void -): OnSelectionChangeFunc { - return useCallback( - ({ nodes, edges }) => { - // Filter to only topology/network nodes (ignore annotation nodes for context selection) - const selectableNodes = nodes.filter((n) => SELECTABLE_NODE_TYPES.includes(n.type ?? "")); - - // If exactly one selectable node is selected, sync to context - if (selectableNodes.length === 1 && edges.length === 0) { - selectNode(selectableNodes[0].id); - return; - } - - // If exactly one edge is selected and no nodes, sync to context - if (edges.length === 1 && selectableNodes.length === 0) { - selectEdge(edges[0].id); - return; - } - - // Multiple items selected or no selectable items - clear context selection - // (React Flow manages the visual selection via node.selected property) - if ( - selectableNodes.length > 1 || - edges.length > 1 || - (selectableNodes.length > 0 && edges.length > 0) - ) { - selectNode(null); - selectEdge(null); - log.info(`[ReactFlowCanvas] Box selection: ${nodes.length} nodes, ${edges.length} edges`); - } - }, - [selectNode, selectEdge] - ); -} - -/** - * Hook for canvas event handlers - */ -export function useCanvasHandlers(config: CanvasHandlersConfig): CanvasHandlers { - const { - selectNode, - selectEdge, - editNode, - editNetwork, - editEdge, - mode, - isLocked, - onNodesChangeBase, - onLockedAction, - onPaneClickExtra, - shouldSuppressSelectionSync, - nodes, - setNodes, - onEdgeCreated, - groupMemberHandlers, - reactFlowInstanceRef, - geoLayout - } = config; - - const reactFlowInstance = reactFlowInstanceRef ?? useRef(null); - const modeRef = useRef(mode); - const isLockedRef = useRef(isLocked); - modeRef.current = mode; - isLockedRef.current = isLocked; - - // Context menu state - const { contextMenu, closeContextMenu, openNodeMenu, openEdgeMenu, openPaneMenu } = - useContextMenuState(); - - // Initialize - const onInit = useCallback( - (instance: ReactFlowInstance) => { - reactFlowInstance.current = instance; - log.info("[ReactFlowCanvas] React Flow initialized"); - // Don't auto-fitView in geo layout mode - map controls the viewport - if (geoLayout?.isGeoLayout !== true) { - void instance.fitView({ padding: 0.2, duration: 0 }); - } - }, - [geoLayout?.isGeoLayout] - ); - - // Click handlers (extracted hooks) - const { onNodeClick, onNodeDoubleClick } = useNodeClickHandlers( - selectNode, - selectEdge, - editNode, - editNetwork, - closeContextMenu, - modeRef - ); - const { onEdgeClick, onEdgeDoubleClick } = useEdgeClickHandlers( - selectNode, - selectEdge, - editEdge, - closeContextMenu, - modeRef - ); - const onPaneClick = usePaneClickHandler( - selectNode, - selectEdge, - editNode, - closeContextMenu, - reactFlowInstance, - modeRef, - isLockedRef, - onLockedAction, - onPaneClickExtra - ); - const onConnect = useConnectionHandler(modeRef, isLockedRef, onLockedAction, onEdgeCreated); - - // Node changes handler - all nodes (topology + annotation) live in the graph store - // The graph store is the single source of truth, so we pass changes through directly - const handleNodesChange: OnNodesChange = useCallback( - (changes: NodeChange[]) => { - onNodesChangeBase(changes); - }, - [onNodesChangeBase] - ); - - // Drag handlers (extracted hook) - const { onNodeDragStart, onNodeDrag, onNodeDragStop } = useNodeDragHandlers( - isLockedRef, - nodes, - onNodesChangeBase, - setNodes, - groupMemberHandlers, - geoLayout - ); - - // Context menu handlers (extracted hook) - const { onNodeContextMenu, onEdgeContextMenu, onPaneContextMenu } = useContextMenuHandlers( - selectNode, - selectEdge, - openNodeMenu, - openEdgeMenu, - openPaneMenu - ); - - // Selection change handler (for box selection) - const baseOnSelectionChange = useSelectionChangeHandler(selectNode, selectEdge); - const onSelectionChange: OnSelectionChangeFunc = useCallback( - (params) => { - if (shouldSuppressSelectionSync?.() === true) return; - baseOnSelectionChange(params); - }, - [baseOnSelectionChange, shouldSuppressSelectionSync] - ); - - return { - reactFlowInstance, - onInit, - onNodeClick, - onNodeDoubleClick, - onEdgeClick, - onEdgeDoubleClick, - onPaneClick, - onConnect, - handleNodesChange, - onSelectionChange, - onNodeContextMenu, - onEdgeContextMenu, - onPaneContextMenu, - onNodeDragStart, - onNodeDrag, - onNodeDragStop, - contextMenu, - closeContextMenu - }; -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/useDerivedAnnotations.ts b/src/reactTopoViewer/webview/hooks/canvas/useDerivedAnnotations.ts deleted file mode 100644 index cfb734e4f..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/useDerivedAnnotations.ts +++ /dev/null @@ -1,437 +0,0 @@ -/** - * useDerivedAnnotations - Derive annotation data from graph store nodes - * - * This hook bridges annotation UI and graph state by: - * 1. Deriving annotation arrays (groups, text, shapes) from graph nodes - * 2. Providing mutation functions that update graph nodes - * 3. Managing membership via node.data.groupId (derived from graph nodes) - * - * This is the key to keeping graph state as the single source of truth for all nodes. - */ -import { useMemo, useCallback } from "react"; -import type { Node } from "@xyflow/react"; -import { shallow } from "zustand/shallow"; - -import type { - FreeTextAnnotation, - FreeShapeAnnotation, - TrafficRateAnnotation, - GroupStyleAnnotation -} from "../../../shared/types/topology"; -import type { - FreeTextNodeData, - FreeShapeNodeData, - TrafficRateNodeData, - GroupNodeData -} from "../../components/canvas/types"; -import { useGraphStore } from "../../stores/graphStore"; -import { - nodeToFreeText, - nodeToFreeShape, - nodeToTrafficRate, - nodeToGroup, - freeTextToNode, - freeShapeToNode, - trafficRateToNode, - groupToNode, - resolveGroupParentId, - FREE_TEXT_NODE_TYPE, - FREE_SHAPE_NODE_TYPE, - TRAFFIC_RATE_NODE_TYPE, - GROUP_NODE_TYPE -} from "../../annotations/annotationNodeConverters"; - -/** - * Return type for useDerivedAnnotations - */ -export interface UseDerivedAnnotationsReturn { - // Derived annotation data (read-only views of graph nodes) - groups: GroupStyleAnnotation[]; - textAnnotations: FreeTextAnnotation[]; - shapeAnnotations: FreeShapeAnnotation[]; - trafficRateAnnotations: TrafficRateAnnotation[]; - - // Group mutations - addGroup: (group: GroupStyleAnnotation) => void; - updateGroup: (id: string, updates: Partial) => void; - deleteGroup: (id: string) => void; - - // Text annotation mutations - addTextAnnotation: (annotation: FreeTextAnnotation) => void; - updateTextAnnotation: (id: string, updates: Partial) => void; - deleteTextAnnotation: (id: string) => void; - - // Shape annotation mutations - addShapeAnnotation: (annotation: FreeShapeAnnotation) => void; - updateShapeAnnotation: (id: string, updates: Partial) => void; - deleteShapeAnnotation: (id: string) => void; - - // Traffic-rate annotation mutations - addTrafficRateAnnotation: (annotation: TrafficRateAnnotation) => void; - updateTrafficRateAnnotation: (id: string, updates: Partial) => void; - deleteTrafficRateAnnotation: (id: string) => void; - - // Membership management - membershipMap: Map; // nodeId -> groupId - addNodeToGroup: (nodeId: string, groupId: string) => void; - removeNodeFromGroup: (nodeId: string) => void; - getNodeMembership: (nodeId: string) => string | null; - getGroupMembers: (groupId: string, options?: { includeNested?: boolean }) => string[]; -} - -interface MembershipEntry { - id: string; - groupId: string; -} - -const isGroupNode = (node: Node): node is Node => node.type === GROUP_NODE_TYPE; -const isFreeTextNode = (node: Node): node is Node => - node.type === FREE_TEXT_NODE_TYPE; -const isFreeShapeNode = (node: Node): node is Node => - node.type === FREE_SHAPE_NODE_TYPE; -const isTrafficRateNode = (node: Node): node is Node => - node.type === TRAFFIC_RATE_NODE_TYPE; - -function selectMembershipEntries(state: { nodes: Node[] }): MembershipEntry[] { - const entries: MembershipEntry[] = []; - for (const node of state.nodes) { - const data = node.data as Record | undefined; - const groupId = data?.groupId; - if (typeof groupId === "string" && groupId.length > 0) { - entries.push({ id: node.id, groupId }); - } - } - return entries; -} - -function areMembershipEntriesEqual(left: MembershipEntry[], right: MembershipEntry[]): boolean { - if (left === right) return true; - if (left.length !== right.length) return false; - for (let i = 0; i < left.length; i++) { - if (left[i].id !== right[i].id || left[i].groupId !== right[i].groupId) { - return false; - } - } - return true; -} - -/** - * Hook to derive annotation data from graph state and provide mutation functions - */ -export function useDerivedAnnotations(): UseDerivedAnnotationsReturn { - const groupNodes = useGraphStore((state) => state.nodes.filter(isGroupNode), shallow); - const textNodes = useGraphStore((state) => state.nodes.filter(isFreeTextNode), shallow); - const shapeNodes = useGraphStore((state) => state.nodes.filter(isFreeShapeNode), shallow); - const trafficRateNodes = useGraphStore((state) => state.nodes.filter(isTrafficRateNode), shallow); - const membershipEntries = useGraphStore(selectMembershipEntries, areMembershipEntriesEqual); - - const addNode = useGraphStore((state) => state.addNode); - const removeNode = useGraphStore((state) => state.removeNode); - const updateNode = useGraphStore((state) => state.updateNode); - const replaceNode = useGraphStore((state) => state.replaceNode); - - // Derive groups from group-node type nodes - const groups = useMemo(() => { - return groupNodes.map(nodeToGroup); - }, [groupNodes]); - - // Derive text annotations from free-text-node type nodes - const textAnnotations = useMemo(() => { - return textNodes.map(nodeToFreeText); - }, [textNodes]); - - // Derive shape annotations from free-shape-node type nodes - const shapeAnnotations = useMemo(() => { - return shapeNodes.map(nodeToFreeShape); - }, [shapeNodes]); - - const trafficRateAnnotations = useMemo(() => { - return trafficRateNodes.map(nodeToTrafficRate); - }, [trafficRateNodes]); - - // Membership map: nodeId -> groupId (derived from node data) - const membershipMap = useMemo(() => { - const map = new Map(); - for (const entry of membershipEntries) { - map.set(entry.id, entry.groupId); - } - return map; - }, [membershipEntries]); - - // ============================================================================ - // Group mutations - // ============================================================================ - - const addGroup = useCallback( - (group: GroupStyleAnnotation) => { - const node = groupToNode(group); - addNode(node); - }, - [addNode] - ); - - const updateGroup = useCallback( - (id: string, updates: Partial) => { - // Find current group node - const currentNode = useGraphStore - .getState() - .nodes.find((n): n is Node => n.id === id && isGroupNode(n)); - if (!currentNode) return; - - // Convert to annotation, apply updates, convert back to node - const currentGroup = nodeToGroup(currentNode); - const updatedGroup = { ...currentGroup, ...updates }; - const newNode = groupToNode(updatedGroup); - replaceNode(id, newNode); - }, - [replaceNode] - ); - - const deleteGroup = useCallback( - (id: string) => { - removeNode(id); - }, - [removeNode] - ); - - // ============================================================================ - // Text annotation mutations - // ============================================================================ - - const addTextAnnotation = useCallback( - (annotation: FreeTextAnnotation) => { - const node = freeTextToNode(annotation); - addNode(node); - }, - [addNode] - ); - - const updateTextAnnotation = useCallback( - (id: string, updates: Partial) => { - const currentNode = useGraphStore - .getState() - .nodes.find((n): n is Node => n.id === id && isFreeTextNode(n)); - if (!currentNode) return; - - const currentAnnotation = nodeToFreeText(currentNode); - const updatedAnnotation = { ...currentAnnotation, ...updates }; - const newNode = freeTextToNode(updatedAnnotation); - replaceNode(id, newNode); - }, - [replaceNode] - ); - - const deleteTextAnnotation = useCallback( - (id: string) => { - removeNode(id); - }, - [removeNode] - ); - - // ============================================================================ - // Shape annotation mutations - // ============================================================================ - - const addShapeAnnotation = useCallback( - (annotation: FreeShapeAnnotation) => { - const node = freeShapeToNode(annotation); - addNode(node); - }, - [addNode] - ); - - const updateShapeAnnotation = useCallback( - (id: string, updates: Partial) => { - const currentNode = useGraphStore - .getState() - .nodes.find((n): n is Node => n.id === id && isFreeShapeNode(n)); - if (!currentNode) return; - - const currentAnnotation = nodeToFreeShape(currentNode); - const updatedAnnotation = { ...currentAnnotation, ...updates }; - const newNode = freeShapeToNode(updatedAnnotation); - replaceNode(id, newNode); - }, - [replaceNode] - ); - - const deleteShapeAnnotation = useCallback( - (id: string) => { - removeNode(id); - }, - [removeNode] - ); - - // ============================================================================ - // Traffic-rate annotation mutations - // ============================================================================ - - const addTrafficRateAnnotation = useCallback( - (annotation: TrafficRateAnnotation) => { - const node = trafficRateToNode(annotation); - addNode(node); - }, - [addNode] - ); - - const updateTrafficRateAnnotation = useCallback( - (id: string, updates: Partial) => { - const currentNode = useGraphStore - .getState() - .nodes.find((n): n is Node => n.id === id && isTrafficRateNode(n)); - if (!currentNode) return; - - const current = nodeToTrafficRate(currentNode); - const updated: TrafficRateAnnotation = { ...current, ...updates }; - const newNode = trafficRateToNode(updated); - replaceNode(id, newNode); - }, - [replaceNode] - ); - - const deleteTrafficRateAnnotation = useCallback( - (id: string) => { - removeNode(id); - }, - [removeNode] - ); - - // ============================================================================ - // Membership management - // ============================================================================ - - const addNodeToGroup = useCallback( - (nodeId: string, groupId: string) => { - const node = useGraphStore.getState().nodes.find((n) => n.id === nodeId); - if (!node) return; - updateNode(nodeId, { - data: { ...node.data, groupId } - }); - }, - [updateNode] - ); - - const removeNodeFromGroup = useCallback( - (nodeId: string) => { - const node = useGraphStore.getState().nodes.find((n) => n.id === nodeId); - if (!node) return; - const nodeData = node.data; - // Force groupId to undefined so updateNode's merge clears membership. - updateNode(nodeId, { data: { ...nodeData, groupId: undefined } }); - }, - [updateNode] - ); - - const getNodeMembership = useCallback( - (nodeId: string): string | null => { - return membershipMap.get(nodeId) ?? null; - }, - [membershipMap] - ); - - const getGroupMembers = useCallback( - (groupId: string, options?: { includeNested?: boolean }): string[] => { - const members = new Set(); - const includeNested = options?.includeNested ?? false; - - const addDirectMembers = (id: string) => { - for (const [nodeId, gId] of membershipMap) { - if (gId === id) { - members.add(nodeId); - } - } - // Also include text/shape annotations with this groupId (from node data) - for (const text of textAnnotations) { - if (text.groupId === id) members.add(text.id); - } - for (const shape of shapeAnnotations) { - if (shape.groupId === id) members.add(shape.id); - } - for (const traffic of trafficRateAnnotations) { - if (traffic.groupId === id) members.add(traffic.id); - } - }; - - if (!includeNested) { - addDirectMembers(groupId); - return Array.from(members); - } - - const childMap = new Map(); - for (const group of groups) { - const parentId = resolveGroupParentId(group.parentId, group.groupId); - if (parentId === undefined || parentId.length === 0) continue; - const list = childMap.get(parentId) ?? []; - list.push(group.id); - childMap.set(parentId, list); - } - - const visited = new Set(); - const stack = [groupId]; - while (stack.length > 0) { - const current = stack.pop(); - if (current === undefined || current.length === 0 || visited.has(current)) continue; - visited.add(current); - addDirectMembers(current); - const children = childMap.get(current) ?? []; - for (const child of children) { - members.add(child); - stack.push(child); - } - } - - members.delete(groupId); - return Array.from(members); - }, - [membershipMap, textAnnotations, shapeAnnotations, trafficRateAnnotations, groups] - ); - - return useMemo( - () => ({ - groups, - textAnnotations, - shapeAnnotations, - trafficRateAnnotations, - addGroup, - updateGroup, - deleteGroup, - addTextAnnotation, - updateTextAnnotation, - deleteTextAnnotation, - addShapeAnnotation, - updateShapeAnnotation, - deleteShapeAnnotation, - addTrafficRateAnnotation, - updateTrafficRateAnnotation, - deleteTrafficRateAnnotation, - membershipMap, - addNodeToGroup, - removeNodeFromGroup, - getNodeMembership, - getGroupMembers - }), - [ - groups, - textAnnotations, - shapeAnnotations, - trafficRateAnnotations, - addGroup, - updateGroup, - deleteGroup, - addTextAnnotation, - updateTextAnnotation, - deleteTextAnnotation, - addShapeAnnotation, - updateShapeAnnotation, - deleteShapeAnnotation, - addTrafficRateAnnotation, - updateTrafficRateAnnotation, - deleteTrafficRateAnnotation, - membershipMap, - addNodeToGroup, - removeNodeFromGroup, - getNodeMembership, - getGroupMembers - ] - ); -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/useGeoMapLayout.ts b/src/reactTopoViewer/webview/hooks/canvas/useGeoMapLayout.ts deleted file mode 100644 index 18352e2b4..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/useGeoMapLayout.ts +++ /dev/null @@ -1,981 +0,0 @@ -/** - * useGeoMapLayout - MapLibre integration for GeoMap layout - */ -import type { Dispatch, RefObject, SetStateAction } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { Map as MapLibreMap, LngLatBounds, StyleSpecification } from "maplibre-gl"; -import maplibregl from "maplibre-gl"; -import type { Node, ReactFlowInstance, XYPosition } from "@xyflow/react"; - -import { log } from "../../utils/logger"; -import { FREE_SHAPE_NODE_TYPE } from "../../annotations/annotationNodeConverters"; -import { saveNodePositions } from "../../services"; - -interface GeoCoordinates { - lat: number; - lng: number; -} - -interface GeoMapLayoutParams { - isGeoLayout: boolean; - isEditable: boolean; - nodes: Node[]; - setNodes: Dispatch>; - reactFlowInstanceRef: RefObject; - canvasContainerRef: RefObject; - restoreOnExit: boolean; -} - -export interface GeoMapLayoutApi { - containerRef: RefObject; - mapRef: RefObject; - isReady: boolean; - isInteracting: boolean; - fitToViewport: (options?: { duration?: number }) => void; - getGeoCoordinatesForNode: (node: Node) => GeoCoordinates | null; - getGeoUpdateForNode: ( - node: Node - ) => { geoCoordinates?: GeoCoordinates; endGeoCoordinates?: GeoCoordinates } | null; -} - -const MAP_STYLE: StyleSpecification = { - version: 8, - sources: { - osm: { - type: "raster", - tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], - tileSize: 256, - attribution: "© OpenStreetMap contributors" - } - }, - layers: [ - { - id: "osm", - type: "raster", - source: "osm" - } - ] -}; -const INITIAL_GEO_SEQUENCE: GeoCoordinates[] = [ - // Stuttgart - { lat: 48.775846, lng: 9.182932 }, - // Frankfurt - { lat: 50.110924, lng: 8.682127 }, - // Paris - { lat: 48.856613, lng: 2.352222 }, - // London - { lat: 51.507351, lng: -0.127758 } -]; -// Default center in Europe (Stuttgart). -const DEFAULT_LAT = INITIAL_GEO_SEQUENCE[0].lat; -const DEFAULT_LNG = INITIAL_GEO_SEQUENCE[0].lng; -const DEFAULT_CENTER: [number, number] = [DEFAULT_LNG, DEFAULT_LAT]; -const DEFAULT_ZOOM = 4; - -const DEFAULT_NODE_SIZE = { width: 60, height: 60 }; -const DEFAULT_GROUP_SIZE = { width: 200, height: 150 }; -const DEFAULT_TEXT_SIZE = { width: 140, height: 40 }; -const DEFAULT_SHAPE_SIZE = { width: 120, height: 120 }; - -const POSITION_EPSILON = 0.05; - -const AUTO_GEO_TYPES = new Set(["topology-node", "network-node"]); - -const LINE_PADDING = 20; -const GEO_TRANSFORM_ANCHOR: [number, number] = [0, 0]; -const GEO_VIEWPORT_RESET = { x: 0, y: 0, zoom: 1 }; -const INTERACTION_END_DEBOUNCE_MS = 20; -const ZOOM_END_DEBOUNCE_MS = 20; - -// Keep wrapped city assignments visually separate when sequence repeats. -const GEO_REPEAT_RING_POINTS = 8; -const GEO_REPEAT_RADIUS_STEP = 2.8; -const MAX_GEO_SLOT_SCAN = 4096; - -let maplibreWorkerBlobUrl: string | null = null; -let maplibreWorkerBlobSourceKey: string | null = null; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function toGeoCoordinates(value: unknown): GeoCoordinates | null { - if (!isRecord(value)) return null; - const lat = value.lat; - const lng = value.lng; - if (typeof lat !== "number" || !Number.isFinite(lat)) return null; - if (typeof lng !== "number" || !Number.isFinite(lng)) return null; - return { lat, lng }; -} - -function toXYPosition(value: unknown): XYPosition | null { - if (!isRecord(value)) return null; - const x = value.x; - const y = value.y; - if (typeof x !== "number" || !Number.isFinite(x)) return null; - if (typeof y !== "number" || !Number.isFinite(y)) return null; - return { x, y }; -} - -function decodeBase64ToString(base64: string): string { - if (typeof window === "undefined" || typeof window.atob !== "function") { - throw new Error("Base64 decoding is unavailable in this environment"); - } - const binary = window.atob(base64); - const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); - return new TextDecoder().decode(bytes); -} - -function getOrCreateWorkerBlobUrl(sourceKey: string, workerSource: string): string | null { - if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") { - return null; - } - - if ( - maplibreWorkerBlobUrl != null && - maplibreWorkerBlobUrl.length > 0 && - maplibreWorkerBlobSourceKey === sourceKey - ) { - return maplibreWorkerBlobUrl; - } - - if (maplibreWorkerBlobUrl != null && maplibreWorkerBlobUrl.length > 0) { - URL.revokeObjectURL(maplibreWorkerBlobUrl); - maplibreWorkerBlobUrl = null; - maplibreWorkerBlobSourceKey = null; - } - - maplibreWorkerBlobUrl = URL.createObjectURL( - new Blob([workerSource], { type: "text/javascript" }) - ); - maplibreWorkerBlobSourceKey = sourceKey; - return maplibreWorkerBlobUrl; -} - -function resolveMapLibreWorkerUrl(): string | null { - if (typeof window === "undefined") { - return null; - } - - const workerSourceBase64 = window.maplibreWorkerSourceBase64; - if (workerSourceBase64 != null && workerSourceBase64.length > 0) { - try { - const sourceKey = `inline:${workerSourceBase64.length}:${workerSourceBase64.slice(0, 32)}`; - const workerSource = decodeBase64ToString(workerSourceBase64); - return getOrCreateWorkerBlobUrl(sourceKey, workerSource); - } catch (error) { - log.warn( - `[GeoMap] Failed to decode embedded worker source, falling back to worker URL: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } - } - - const configuredWorkerUrl = window.maplibreWorkerUrl; - if (configuredWorkerUrl == null || configuredWorkerUrl.length === 0) { - return null; - } - - if (configuredWorkerUrl.startsWith("blob:") || configuredWorkerUrl.startsWith("data:")) { - return configuredWorkerUrl; - } - - const sourceKey = `bootstrap:${configuredWorkerUrl}`; - const bootstrapSource = `importScripts(${JSON.stringify(configuredWorkerUrl)});`; - return getOrCreateWorkerBlobUrl(sourceKey, bootstrapSource) ?? configuredWorkerUrl; -} - -function roundCoord(value: number): number { - return Number(value.toFixed(6)); -} - -function roundGeo(coords: GeoCoordinates): GeoCoordinates { - return { lat: roundCoord(coords.lat), lng: roundCoord(coords.lng) }; -} - -const DEFAULT_SIZE_BY_TYPE: Record = { - "group-node": DEFAULT_GROUP_SIZE, - "free-text-node": DEFAULT_TEXT_SIZE, - "free-shape-node": DEFAULT_SHAPE_SIZE -}; - -function getNodeSize(node: Node): { width: number; height: number } { - const data = node.data; - const width = node.width ?? (typeof data.width === "number" ? data.width : undefined); - const height = node.height ?? (typeof data.height === "number" ? data.height : undefined); - - if (width != null && width > 0 && height != null && height > 0) { - return { width, height }; - } - - const fallback = DEFAULT_SIZE_BY_TYPE[node.type ?? ""] ?? DEFAULT_NODE_SIZE; - return { - width: width ?? fallback.width, - height: height ?? fallback.height - }; -} - -function extractGeoCoordinates(node: Node): GeoCoordinates | null { - const data = node.data; - const topLevelGeo = toGeoCoordinates(data.geoCoordinates); - if (topLevelGeo) return topLevelGeo; - const extraData = isRecord(data.extraData) ? data.extraData : null; - return toGeoCoordinates(extraData?.geoCoordinates); -} - -function extractEndGeoCoordinates(node: Node): GeoCoordinates | null { - const data = node.data; - return toGeoCoordinates(data.endGeoCoordinates); -} - -function positionEquals(a: XYPosition, b: XYPosition): boolean { - return Math.abs(a.x - b.x) <= POSITION_EPSILON && Math.abs(a.y - b.y) <= POSITION_EPSILON; -} - -function projectGeoToPosition(map: MapLibreMap, node: Node, geo: GeoCoordinates): XYPosition { - const { width, height } = getNodeSize(node); - const point = map.project([geo.lng, geo.lat]); - return { - x: point.x - width / 2, - y: point.y - height / 2 - }; -} - -function unprojectPositionToGeo(map: MapLibreMap, node: Node): GeoCoordinates { - const { width, height } = getNodeSize(node); - const centerX = node.position.x + width / 2; - const centerY = node.position.y + height / 2; - const lngLat = map.unproject([centerX, centerY]); - return roundGeo({ lat: lngLat.lat, lng: lngLat.lng }); -} - -function computeLineBounds( - start: XYPosition, - end: XYPosition -): { - nodePosition: XYPosition; - width: number; - height: number; - relativeEndPosition: XYPosition; - lineStartInNode: XYPosition; -} { - const minX = Math.min(start.x, end.x) - LINE_PADDING; - const minY = Math.min(start.y, end.y) - LINE_PADDING; - const maxX = Math.max(start.x, end.x) + LINE_PADDING; - const maxY = Math.max(start.y, end.y) + LINE_PADDING; - - const nodePosition = { x: minX, y: minY }; - return { - nodePosition, - width: maxX - minX, - height: Math.max(maxY - minY, LINE_PADDING * 2), - relativeEndPosition: { x: end.x - start.x, y: end.y - start.y }, - lineStartInNode: { x: start.x - minX, y: start.y - minY } - }; -} - -function buildGeoBounds(nodes: Node[]): LngLatBounds | null { - let bounds: LngLatBounds | null = null; - for (const node of nodes) { - const start = extractGeoCoordinates(node); - const end = extractEndGeoCoordinates(node); - if (!start && !end) continue; - const coords: GeoCoordinates[] = []; - if (start) coords.push(start); - if (end) coords.push(end); - for (const geo of coords) { - if (!bounds) { - bounds = new maplibregl.LngLatBounds([geo.lng, geo.lat], [geo.lng, geo.lat]); - } else { - bounds.extend([geo.lng, geo.lat]); - } - } - } - return bounds; -} - -function geoKey(coords: GeoCoordinates): string { - const rounded = roundGeo(coords); - return `${rounded.lat},${rounded.lng}`; -} - -function geoCoordinatesForSlot(slot: number): GeoCoordinates { - const sequenceLength = INITIAL_GEO_SEQUENCE.length; - const base = INITIAL_GEO_SEQUENCE[slot % sequenceLength]; - const repeatIndex = Math.floor(slot / sequenceLength); - if (repeatIndex === 0) { - return roundGeo(base); - } - - const ringIndex = repeatIndex - 1; - const ring = Math.floor(ringIndex / GEO_REPEAT_RING_POINTS) + 1; - const ringPointIndex = ringIndex % GEO_REPEAT_RING_POINTS; - const angle = (2 * Math.PI * ringPointIndex) / GEO_REPEAT_RING_POINTS; - const radius = ring * GEO_REPEAT_RADIUS_STEP; - - return roundGeo({ - lat: base.lat + Math.sin(angle) * radius, - lng: base.lng + Math.cos(angle) * radius - }); -} - -function assignAutoGeoCoordinates(nodes: Node[]): { - nodes: Node[]; - assignments: Array<{ id: string; geoCoordinates: GeoCoordinates }>; -} { - const occupied = new Set(); - for (const node of nodes) { - const geo = extractGeoCoordinates(node); - if (geo) { - occupied.add(geoKey(geo)); - } - } - - let slot = 0; - const assignments: Array<{ id: string; geoCoordinates: GeoCoordinates }> = []; - - const nextNodes = nodes.map((node) => { - if (!AUTO_GEO_TYPES.has(node.type ?? "")) return node; - if (extractGeoCoordinates(node)) return node; - - let geo: GeoCoordinates | null = null; - for (let attempts = 0; attempts < MAX_GEO_SLOT_SCAN; attempts += 1) { - const candidate = geoCoordinatesForSlot(slot); - slot += 1; - const key = geoKey(candidate); - if (occupied.has(key)) continue; - occupied.add(key); - geo = candidate; - break; - } - if (!geo) { - geo = roundGeo({ lat: DEFAULT_LAT, lng: DEFAULT_LNG }); - occupied.add(geoKey(geo)); - log.warn("[GeoMap] Failed to find non-overlapping initial geo slot; using default center"); - } - - assignments.push({ id: node.id, geoCoordinates: geo }); - const data = node.data; - return { ...node, data: { ...data, geoCoordinates: geo } }; - }); - - return { nodes: nextNodes, assignments }; -} - -function syncNodesToMap(map: MapLibreMap, nodes: Node[]): { nodes: Node[]; changed: boolean } { - let changed = false; - const nextNodes: Node[] = []; - for (const node of nodes) { - const data = node.data; - if (node.type === FREE_SHAPE_NODE_TYPE && data.shapeType === "line") { - const startGeo = extractGeoCoordinates(node); - const endGeo = extractEndGeoCoordinates(node); - if (!startGeo || !endGeo) { - nextNodes.push(node); - continue; - } - const start = map.project([startGeo.lng, startGeo.lat]); - const end = map.project([endGeo.lng, endGeo.lat]); - const boundsInfo = computeLineBounds({ x: start.x, y: start.y }, { x: end.x, y: end.y }); - if ( - positionEquals(node.position, boundsInfo.nodePosition) && - node.width === boundsInfo.width && - node.height === boundsInfo.height - ) { - nextNodes.push(node); - continue; - } - changed = true; - nextNodes.push({ - ...node, - position: boundsInfo.nodePosition, - width: boundsInfo.width, - height: boundsInfo.height, - data: { - ...data, - startPosition: { x: start.x, y: start.y }, - endPosition: { x: end.x, y: end.y }, - relativeEndPosition: boundsInfo.relativeEndPosition, - lineStartInNode: boundsInfo.lineStartInNode - } - }); - continue; - } - - const geo = extractGeoCoordinates(node); - if (!geo) { - nextNodes.push(node); - continue; - } - const position = projectGeoToPosition(map, node, geo); - if (positionEquals(node.position, position)) { - nextNodes.push(node); - continue; - } - changed = true; - nextNodes.push({ ...node, position }); - } - - return { nodes: changed ? nextNodes : nodes, changed }; -} - -function buildGeoSyncSignature(nodes: Node[]): string { - return nodes - .map((node) => { - const geo = extractGeoCoordinates(node); - const endGeo = extractEndGeoCoordinates(node); - const geoPart = geo ? `${roundCoord(geo.lat)},${roundCoord(geo.lng)}` : "-"; - const endPart = endGeo ? `${roundCoord(endGeo.lat)},${roundCoord(endGeo.lng)}` : "-"; - return `${node.id}:${node.type ?? ""}:${geoPart}:${endPart}`; - }) - .join("|"); -} - -interface GeoInteractionBase { - zoom: number; - anchorPoint: { x: number; y: number }; -} - -interface GeoViewportTransform { - x: number; - y: number; - zoom: number; -} - -function computeGeoViewportTransform( - map: MapLibreMap, - base: GeoInteractionBase -): GeoViewportTransform { - const currentAnchorPoint = map.project(GEO_TRANSFORM_ANCHOR); - const scale = Math.pow(2, map.getZoom() - base.zoom); - return { - x: currentAnchorPoint.x - scale * base.anchorPoint.x, - y: currentAnchorPoint.y - scale * base.anchorPoint.y, - zoom: scale - }; -} - -function applyViewportTransformToElement( - viewportElement: HTMLElement, - transform: GeoViewportTransform -): void { - viewportElement.style.transformOrigin = "0 0"; - viewportElement.style.transform = `translate(${transform.x}px, ${transform.y}px) scale(${transform.zoom})`; -} - -function clearViewportTransformOverride(viewportElement: HTMLElement): void { - viewportElement.style.transform = ""; - viewportElement.style.transformOrigin = ""; -} - -function resolveViewportElement(container: HTMLDivElement | null): HTMLElement | null { - if (!container) return null; - const renderer = container.querySelector(".react-flow__renderer"); - if (renderer instanceof HTMLElement) return renderer; - const viewport = container.querySelector(".react-flow__viewport"); - return viewport instanceof HTMLElement ? viewport : null; -} - -function clearViewportOverrideOnNextFrame( - viewportElementRef: { current: HTMLElement | null }, - viewportTransformOverrideActiveRef: { current: boolean } -): void { - if (!viewportElementRef.current || !viewportTransformOverrideActiveRef.current) return; - window.requestAnimationFrame(() => { - if (viewportElementRef.current) { - clearViewportTransformOverride(viewportElementRef.current); - } - viewportTransformOverrideActiveRef.current = false; - }); -} - -function applyGeoViewportTransform( - map: MapLibreMap, - rf: ReactFlowInstance | null, - base: GeoInteractionBase, - viewportElement: HTMLElement | null -): void { - const transform = computeGeoViewportTransform(map, base); - if (viewportElement) { - applyViewportTransformToElement(viewportElement, transform); - return; - } - if (rf) { - void rf.setViewport(transform, { duration: 0 }); - } -} - -function resetGeoViewport(rf: ReactFlowInstance | null): void { - if (!rf) return; - void rf.setViewport(GEO_VIEWPORT_RESET, { duration: 0 }); -} - -function syncNodesAndResetViewport( - map: MapLibreMap, - rf: ReactFlowInstance | null, - nodesRef: { current: Node[] }, - setNodesRef: { current: Dispatch> } -): void { - resetGeoViewport(rf); - const { nodes: syncedNodes, changed } = syncNodesToMap(map, nodesRef.current); - if (changed) { - setNodesRef.current(syncedNodes); - } -} - -function clearPendingInteractionTimeout(interactionEndTimeoutRef: { - current: number | null; -}): void { - if (interactionEndTimeoutRef.current === null) return; - window.clearTimeout(interactionEndTimeoutRef.current); - interactionEndTimeoutRef.current = null; -} - -interface ResetGeoInteractionParams { - interactionEndTimeoutRef: { current: number | null }; - isInteractingRef: { current: boolean }; - interactionBaseRef: { current: GeoInteractionBase | null }; - viewportElementRef: { current: HTMLElement | null }; - viewportTransformOverrideActiveRef: { current: boolean }; - setIsInteracting: (isInteracting: boolean) => void; - reactFlowInstance: ReactFlowInstance | null; - resetViewport?: boolean; -} - -function resetGeoInteractionState({ - interactionEndTimeoutRef, - isInteractingRef, - interactionBaseRef, - viewportElementRef, - viewportTransformOverrideActiveRef, - setIsInteracting, - reactFlowInstance, - resetViewport = false -}: ResetGeoInteractionParams): void { - clearPendingInteractionTimeout(interactionEndTimeoutRef); - isInteractingRef.current = false; - interactionBaseRef.current = null; - if (viewportElementRef.current) { - clearViewportTransformOverride(viewportElementRef.current); - } - viewportTransformOverrideActiveRef.current = false; - if (resetViewport) { - resetGeoViewport(reactFlowInstance); - } - setIsInteracting(false); -} - -export function useGeoMapLayout({ - isGeoLayout, - nodes, - setNodes, - reactFlowInstanceRef, - canvasContainerRef, - restoreOnExit -}: GeoMapLayoutParams): GeoMapLayoutApi { - const containerRef = useRef(null); - const mapRef = useRef(null); - const [isReady, setIsReady] = useState(false); - const [isInteracting, setIsInteracting] = useState(false); - const nodesRef = useRef(nodes); - const setNodesRef = useRef(setNodes); - const wasGeoRef = useRef(false); - const initialAssignmentRef = useRef(false); - const originalPositionsRef = useRef>(new Map()); - const previousViewportRef = useRef<{ x: number; y: number; zoom: number } | null>(null); - const interactionEndTimeoutRef = useRef(null); - const isInteractingRef = useRef(false); - const interactionBaseRef = useRef(null); - const viewportElementRef = useRef(null); - const viewportTransformOverrideActiveRef = useRef(false); - const geoSyncSignatureRef = useRef(""); - const workerConfiguredRef = useRef(false); - - nodesRef.current = nodes; - setNodesRef.current = setNodes; - - useEffect(() => { - if (!isGeoLayout || mapRef.current || !containerRef.current) return; - try { - if (!workerConfiguredRef.current) { - const workerUrl = resolveMapLibreWorkerUrl(); - if (workerUrl != null && workerUrl.length > 0) { - maplibregl.setWorkerUrl(workerUrl); - } - workerConfiguredRef.current = true; - } - const map = new maplibregl.Map({ - container: containerRef.current, - style: MAP_STYLE, - center: DEFAULT_CENTER, - zoom: DEFAULT_ZOOM, - attributionControl: {} - }); - - map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right"); - - map.on("load", () => { - setIsReady(true); - }); - - map.on("error", (event: { error?: Error }) => { - const message = event.error?.message ?? "Unknown map error"; - log.error(`[GeoMap] MapLibre error: ${message}`); - }); - - mapRef.current = map; - } catch (err) { - log.error( - `[GeoMap] Failed to initialize map: ${err instanceof Error ? err.message : String(err)}` - ); - } - }, [isGeoLayout]); - - useEffect(() => { - if (isGeoLayout) return; - resetGeoInteractionState({ - interactionEndTimeoutRef, - isInteractingRef, - interactionBaseRef, - viewportElementRef, - viewportTransformOverrideActiveRef, - setIsInteracting, - reactFlowInstance: reactFlowInstanceRef.current - }); - viewportElementRef.current = null; - geoSyncSignatureRef.current = ""; - if (mapRef.current) { - mapRef.current.remove(); - mapRef.current = null; - setIsReady(false); - } - }, [isGeoLayout]); - - useEffect(() => { - return () => { - resetGeoInteractionState({ - interactionEndTimeoutRef, - isInteractingRef, - interactionBaseRef, - viewportElementRef, - viewportTransformOverrideActiveRef, - setIsInteracting, - reactFlowInstance: reactFlowInstanceRef.current - }); - viewportElementRef.current = null; - if (mapRef.current) { - mapRef.current.remove(); - mapRef.current = null; - } - }; - }, []); - - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (isGeoLayout) { - map.dragPan.enable({ - linearity: 0.2, - maxSpeed: 2200, - deceleration: 3200 - }); - map.dragRotate.disable(); - map.touchZoomRotate.disableRotation(); - map.scrollZoom.enable(); - map.scrollZoom.setWheelZoomRate(1 / 700); - map.scrollZoom.setZoomRate(1 / 120); - map.doubleClickZoom.enable(); - map.keyboard.enable(); - } else { - map.dragPan.disable(); - map.scrollZoom.disable(); - map.doubleClickZoom.disable(); - map.keyboard.disable(); - } - }, [isGeoLayout, isReady]); - - useEffect(() => { - if (!isGeoLayout || wasGeoRef.current) return; - wasGeoRef.current = true; - originalPositionsRef.current = new Map( - nodesRef.current.map((node) => [node.id, { ...node.position }]) - ); - const rf = reactFlowInstanceRef.current; - if (rf) { - previousViewportRef.current = rf.getViewport(); - log.info("[GeoMap] Setting viewport to {x:0, y:0, zoom:1}"); - resetGeoViewport(rf); - // Re-set multiple times with delays to override any pending fitView operations - const setGeoViewport = () => { - resetGeoViewport(rf); - }; - window.requestAnimationFrame(setGeoViewport); - setTimeout(setGeoViewport, 50); - setTimeout(setGeoViewport, 150); - setTimeout(setGeoViewport, 300); - } else { - log.warn("[GeoMap] React Flow instance not available for viewport reset"); - } - }, [isGeoLayout, reactFlowInstanceRef]); - - useEffect(() => { - if (isGeoLayout || !wasGeoRef.current) return; - wasGeoRef.current = false; - const previousViewport = previousViewportRef.current; - if (previousViewport && reactFlowInstanceRef.current) { - void reactFlowInstanceRef.current.setViewport(previousViewport, { duration: 0 }); - previousViewportRef.current = null; - } - - const originalPositions = originalPositionsRef.current; - if (restoreOnExit && originalPositions.size > 0) { - setNodesRef.current((current) => - current.map((node) => { - const original = originalPositions.get(node.id); - return original ? { ...node, position: { ...original } } : node; - }) - ); - } - - originalPositionsRef.current = new Map(); - }, [isGeoLayout, reactFlowInstanceRef, restoreOnExit]); - - useEffect(() => { - if (!isGeoLayout) return; - const map = mapRef.current; - if (!map || !isReady) return; - initialAssignmentRef.current = true; - - const currentNodes = nodesRef.current; - const assigned = assignAutoGeoCoordinates(currentNodes); - const nodesWithGeo = assigned.nodes; - const assignments = assigned.assignments; - const boundsToFit = buildGeoBounds(nodesWithGeo); - - if (boundsToFit) { - map.fitBounds(boundsToFit, { padding: 120, duration: 0, maxZoom: 12 }); - } else { - map.setCenter(DEFAULT_CENTER); - map.setZoom(DEFAULT_ZOOM); - } - - if (assignments.length > 0) { - void saveNodePositions(assignments); - } - - window.requestAnimationFrame(() => { - const { nodes: syncedNodes, changed } = syncNodesToMap(map, nodesWithGeo); - if (changed || assignments.length > 0) { - setNodesRef.current(syncedNodes); - } - initialAssignmentRef.current = false; - }); - - if (assignments.length === 0) { - setTimeout(() => { - initialAssignmentRef.current = false; - }, 0); - } - }, [isGeoLayout, isReady]); - - useEffect(() => { - if (!isGeoLayout || !isReady) return; - const map = mapRef.current; - if (!map) return; - if (initialAssignmentRef.current) return; - if (nodes.some((node) => node.dragging === true)) return; - - const geoSyncSignature = buildGeoSyncSignature(nodes); - if (geoSyncSignature === geoSyncSignatureRef.current) return; - geoSyncSignatureRef.current = geoSyncSignature; - - const assigned = assignAutoGeoCoordinates(nodes); - const { nodes: syncedNodes, changed } = syncNodesToMap(map, assigned.nodes); - - if (changed || assigned.assignments.length > 0) { - setNodesRef.current(syncedNodes); - } - - if (assigned.assignments.length > 0) { - void saveNodePositions(assigned.assignments); - } - }, [isGeoLayout, isReady, nodes]); - - useEffect(() => { - if (!isGeoLayout) return; - const map = mapRef.current; - if (!map) return; - - const syncDuringRender = () => { - const currentMap = mapRef.current; - if (!currentMap || !isInteractingRef.current) return; - const base = interactionBaseRef.current; - if (!base) return; - const rf = reactFlowInstanceRef.current; - if (!rf) return; - viewportElementRef.current ??= resolveViewportElement(canvasContainerRef.current); - applyGeoViewportTransform(currentMap, rf, base, viewportElementRef.current); - viewportTransformOverrideActiveRef.current = Boolean(viewportElementRef.current); - }; - - const handleInteractionStart = () => { - clearPendingInteractionTimeout(interactionEndTimeoutRef); - if (!isInteractingRef.current) { - const currentMap = mapRef.current; - if (currentMap) { - interactionBaseRef.current = { - zoom: currentMap.getZoom(), - anchorPoint: currentMap.project(GEO_TRANSFORM_ANCHOR) - }; - } else { - interactionBaseRef.current = null; - } - } - viewportElementRef.current ??= resolveViewportElement(canvasContainerRef.current); - isInteractingRef.current = true; - setIsInteracting(true); - }; - - const scheduleInteractionEnd = (delayMs: number) => { - clearPendingInteractionTimeout(interactionEndTimeoutRef); - interactionEndTimeoutRef.current = window.setTimeout(() => { - interactionEndTimeoutRef.current = null; - isInteractingRef.current = false; - const currentMap = mapRef.current; - if (currentMap) { - syncNodesAndResetViewport( - currentMap, - reactFlowInstanceRef.current, - nodesRef, - setNodesRef - ); - } else { - resetGeoViewport(reactFlowInstanceRef.current); - } - clearViewportOverrideOnNextFrame(viewportElementRef, viewportTransformOverrideActiveRef); - interactionBaseRef.current = null; - setIsInteracting(false); - }, delayMs); - }; - const handleInteractionEnd = () => scheduleInteractionEnd(INTERACTION_END_DEBOUNCE_MS); - const handleZoomEnd = () => scheduleInteractionEnd(ZOOM_END_DEBOUNCE_MS); - - map.on("render", syncDuringRender); - map.on("movestart", handleInteractionStart); - map.on("zoomstart", handleInteractionStart); - map.on("rotatestart", handleInteractionStart); - map.on("moveend", handleInteractionEnd); - map.on("zoomend", handleZoomEnd); - map.on("rotateend", handleInteractionEnd); - - return () => { - map.off("render", syncDuringRender); - map.off("movestart", handleInteractionStart); - map.off("zoomstart", handleInteractionStart); - map.off("rotatestart", handleInteractionStart); - map.off("moveend", handleInteractionEnd); - map.off("zoomend", handleZoomEnd); - map.off("rotateend", handleInteractionEnd); - resetGeoInteractionState({ - interactionEndTimeoutRef, - isInteractingRef, - interactionBaseRef, - viewportElementRef, - viewportTransformOverrideActiveRef, - setIsInteracting, - reactFlowInstance: reactFlowInstanceRef.current, - resetViewport: true - }); - }; - }, [isGeoLayout, reactFlowInstanceRef, canvasContainerRef]); - - useEffect(() => { - if (!isGeoLayout) return; - const container = containerRef.current; - const map = mapRef.current; - if (!container || !map || typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(() => { - map.resize(); - }); - observer.observe(container); - return () => observer.disconnect(); - }, [isGeoLayout]); - - const getGeoCoordinatesForNode = useCallback((node: Node): GeoCoordinates | null => { - const map = mapRef.current; - if (!map) return null; - return unprojectPositionToGeo(map, node); - }, []); - - const getGeoUpdateForNode = useCallback( - ( - node: Node - ): { geoCoordinates?: GeoCoordinates; endGeoCoordinates?: GeoCoordinates } | null => { - const map = mapRef.current; - if (!map) return null; - const data = node.data; - if (node.type === FREE_SHAPE_NODE_TYPE && data.shapeType === "line") { - const lineStart = toXYPosition(data.lineStartInNode) ?? { - x: LINE_PADDING, - y: LINE_PADDING - }; - const relativeEnd = toXYPosition(data.relativeEndPosition) ?? { x: 0, y: 0 }; - const startX = node.position.x + lineStart.x; - const startY = node.position.y + lineStart.y; - const endX = startX + relativeEnd.x; - const endY = startY + relativeEnd.y; - const startGeo = map.unproject([startX, startY]); - const endGeo = map.unproject([endX, endY]); - return { - geoCoordinates: roundGeo({ lat: startGeo.lat, lng: startGeo.lng }), - endGeoCoordinates: roundGeo({ lat: endGeo.lat, lng: endGeo.lng }) - }; - } - const geoCoordinates = unprojectPositionToGeo(map, node); - return { geoCoordinates }; - }, - [] - ); - - const fitToViewport = useCallback( - (options?: { duration?: number }) => { - if (!isGeoLayout) return; - const map = mapRef.current; - if (!map) return; - const duration = typeof options?.duration === "number" ? options.duration : 200; - - resetGeoInteractionState({ - interactionEndTimeoutRef, - isInteractingRef, - interactionBaseRef, - viewportElementRef, - viewportTransformOverrideActiveRef, - setIsInteracting, - reactFlowInstance: reactFlowInstanceRef.current, - resetViewport: true - }); - - const bounds = buildGeoBounds(nodesRef.current); - if (bounds) { - map.fitBounds(bounds, { padding: 120, duration, maxZoom: 12 }); - return; - } - map.easeTo({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, duration }); - }, - [isGeoLayout, reactFlowInstanceRef] - ); - - return useMemo( - () => ({ - containerRef, - mapRef, - isReady, - isInteracting, - fitToViewport, - getGeoCoordinatesForNode, - getGeoUpdateForNode - }), - [isReady, isInteracting, fitToViewport, getGeoCoordinatesForNode, getGeoUpdateForNode] - ); -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/useGroupAnnotations.ts b/src/reactTopoViewer/webview/hooks/canvas/useGroupAnnotations.ts deleted file mode 100644 index 0f4a4f259..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/useGroupAnnotations.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { useCallback, useMemo } from "react"; -import type { Node, ReactFlowInstance } from "@xyflow/react"; - -import type { GroupStyleAnnotation } from "../../../shared/types/topology"; -import type { GroupNodeData } from "../../components/canvas/types"; -import type { AnnotationUIActions } from "../../stores/annotationUIStore"; -import { saveAnnotationNodesFromGraph, saveAnnotationNodesWithMemberships } from "../../services"; -import { useGraphStore } from "../../stores/graphStore"; -import { collectNodeGroupMemberships } from "../../annotations/groupMembership"; -import { - GROUP_NODE_TYPE, - nodeToGroup, - resolveGroupParentId -} from "../../annotations/annotationNodeConverters"; - -import type { GroupEditorData } from "./groupTypes"; -import { calculateDefaultGroupPosition, calculateGroupBoundsFromNodes } from "./annotationHelpers"; -import { findParentGroupForBounds, generateGroupId } from "./groupUtils"; -import type { UseDerivedAnnotationsReturn } from "./useDerivedAnnotations"; -import { readThemeColor } from "./themeColor"; -interface UseGroupAnnotationsParams { - isLocked: boolean; - onLockedAction: () => void; - rfInstance: ReactFlowInstance | null; - derived: UseDerivedAnnotationsReturn; - uiActions: Pick; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isGroupNodeData(value: unknown): value is GroupNodeData { - if (!isRecord(value)) return false; - return ( - typeof value.name === "string" && - typeof value.level === "string" && - typeof value.width === "number" && - typeof value.height === "number" - ); -} - -function isGroupNode(node: Node): node is Node { - return node.type === GROUP_NODE_TYPE && isGroupNodeData(node.data); -} - -function toStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is string => typeof entry === "string"); -} - -export interface GroupAnnotationActions { - editGroup: (id: string) => void; - saveGroup: (data: GroupEditorData) => void; - deleteGroup: (id: string) => void; - handleAddGroup: () => void; - createGroupAtPosition: (position: { x: number; y: number }) => void; - addGroup: (group: GroupStyleAnnotation) => void; - updateGroupSize: (id: string, width: number, height: number) => void; -} - -function buildGroupsSnapshot( - derived: UseDerivedAnnotationsReturn, - graphNodes: Node[] -): GroupStyleAnnotation[] { - if (derived.groups.length > 0) return derived.groups; - - return graphNodes.filter(isGroupNode).map((node) => nodeToGroup(node)); -} - -function getGroupDeletionContext( - derived: UseDerivedAnnotationsReturn, - groupsSnapshot: GroupStyleAnnotation[], - id: string -): { - parentId: string | null; - memberIds: string[]; - childGroups: GroupStyleAnnotation[]; - textIds: Set; - shapeIds: Set; - trafficRateIds: Set; -} { - const group = groupsSnapshot.find((g) => g.id === id); - const parentId = group ? (resolveGroupParentId(group.parentId, group.groupId) ?? null) : null; - const memberIds = derived.getGroupMembers(id); - const childGroups = groupsSnapshot.filter( - (g) => resolveGroupParentId(g.parentId, g.groupId) === id - ); - const textIds = new Set(derived.textAnnotations.map((t) => t.id)); - const shapeIds = new Set(derived.shapeAnnotations.map((s) => s.id)); - const trafficRateIds = new Set(derived.trafficRateAnnotations.map((entry) => entry.id)); - - return { - parentId, - memberIds, - childGroups, - textIds, - shapeIds, - trafficRateIds - }; -} - -function updateChildGroupParents( - derived: UseDerivedAnnotationsReturn, - childGroups: GroupStyleAnnotation[], - parentId: string | null -): void { - for (const child of childGroups) { - derived.updateGroup(child.id, { - parentId: parentId ?? undefined, - groupId: parentId ?? undefined - }); - } -} - -function reassignGroupMembers( - derived: UseDerivedAnnotationsReturn, - memberIds: string[], - parentId: string | null, - textIds: Set, - shapeIds: Set, - trafficRateIds: Set -): void { - for (const memberId of memberIds) { - if (textIds.has(memberId)) { - derived.updateTextAnnotation(memberId, { groupId: parentId ?? undefined }); - continue; - } - if (shapeIds.has(memberId)) { - derived.updateShapeAnnotation(memberId, { groupId: parentId ?? undefined }); - continue; - } - if (trafficRateIds.has(memberId)) { - derived.updateTrafficRateAnnotation(memberId, { groupId: parentId ?? undefined }); - continue; - } - - if (parentId !== null && parentId.length > 0) { - derived.addNodeToGroup(memberId, parentId); - } else { - derived.removeNodeFromGroup(memberId); - } - } -} - -export function useGroupAnnotations(params: UseGroupAnnotationsParams): GroupAnnotationActions { - const { isLocked, onLockedAction, rfInstance, derived, uiActions } = params; - const canEditAnnotations = !isLocked; - - const persist = useCallback(() => { - void saveAnnotationNodesFromGraph(); - }, []); - - /** Persist annotations + memberships as a single host command (one undo step). */ - const persistWithMemberships = useCallback(() => { - const memberships = collectNodeGroupMemberships(useGraphStore.getState().nodes); - void saveAnnotationNodesWithMemberships(memberships); - }, []); - - const editGroup = useCallback( - (id: string) => { - const group = derived.groups.find((g) => g.id === id); - if (!group) return; - - uiActions.setEditingGroup({ - id: group.id, - name: group.name, - level: group.level, - style: { - backgroundColor: group.backgroundColor, - backgroundOpacity: group.backgroundOpacity, - borderColor: group.borderColor, - borderWidth: group.borderWidth, - borderStyle: group.borderStyle, - borderRadius: group.borderRadius, - labelColor: group.labelColor, - labelPosition: group.labelPosition - }, - position: group.position, - width: group.width, - height: group.height - }); - }, - [derived.groups, uiActions] - ); - - const saveGroup = useCallback( - (data: GroupEditorData) => { - const group = derived.groups.find((g) => g.id === data.id); - if (!group) return; - - derived.updateGroup(data.id, { - name: data.name, - level: data.level, - position: data.position, - width: data.width, - height: data.height, - backgroundColor: data.style.backgroundColor, - backgroundOpacity: data.style.backgroundOpacity, - borderColor: data.style.borderColor, - borderWidth: data.style.borderWidth, - borderStyle: data.style.borderStyle, - borderRadius: data.style.borderRadius, - labelColor: data.style.labelColor, - labelPosition: data.style.labelPosition - }); - persist(); - }, - [derived, persist] - ); - - const deleteGroup = useCallback( - (id: string) => { - const graphNodes = useGraphStore.getState().nodes; - const groupsSnapshot = buildGroupsSnapshot(derived, graphNodes); - const { parentId, memberIds, childGroups, textIds, shapeIds, trafficRateIds } = - getGroupDeletionContext(derived, groupsSnapshot, id); - - derived.deleteGroup(id); - uiActions.removeFromGroupSelection(id); - - // Promote child groups to parent (or clear parent if none) - updateChildGroupParents(derived, childGroups, parentId); - reassignGroupMembers(derived, memberIds, parentId, textIds, shapeIds, trafficRateIds); - - const memberships = collectNodeGroupMemberships(useGraphStore.getState().nodes); - void saveAnnotationNodesWithMemberships(memberships); - }, - [derived, uiActions] - ); - - const handleAddGroup = useCallback(() => { - if (!canEditAnnotations) { - onLockedAction(); - return; - } - - const viewport = rfInstance?.getViewport() ?? { x: 0, y: 0, zoom: 1 }; - const newGroupId = generateGroupId(derived.groups); - const padding = 40; - - const rfNodes = rfInstance?.getNodes() ?? []; - const selectedNodes = rfNodes.filter((n) => n.selected === true && n.type !== "group"); - - const { position, width, height, members } = - selectedNodes.length > 0 - ? calculateGroupBoundsFromNodes(selectedNodes, padding) - : calculateDefaultGroupPosition(viewport); - - const parentGroup = findParentGroupForBounds( - { x: position.x, y: position.y, width, height }, - derived.groups, - newGroupId - ); - - const newGroup: GroupStyleAnnotation = { - id: newGroupId, - name: "New Group", - level: "1", - position, - width, - height, - backgroundColor: "rgba(100, 100, 255, 0.1)", - borderColor: readThemeColor("--vscode-editor-foreground", "#666666"), - borderWidth: 2, - borderStyle: "dashed", - borderRadius: 8, - members, - ...(parentGroup ? { parentId: parentGroup.id, groupId: parentGroup.id } : {}) - }; - - derived.addGroup(newGroup); - if (members.length > 0) { - for (const memberId of members) { - derived.addNodeToGroup(memberId, newGroupId); - } - } - - persistWithMemberships(); - }, [canEditAnnotations, onLockedAction, rfInstance, derived, persistWithMemberships]); - - const createGroupAtPosition = useCallback( - (position: { x: number; y: number }) => { - if (!canEditAnnotations) { - onLockedAction(); - return; - } - - const newGroupId = generateGroupId(derived.groups); - const padding = 40; - - const rfNodes = rfInstance?.getNodes() ?? []; - const selectedNodes = rfNodes.filter((n) => n.selected === true && n.type !== "group"); - - const bounds = - selectedNodes.length > 0 - ? calculateGroupBoundsFromNodes(selectedNodes, padding) - : { - position, - width: 300, - height: 200, - members: [] - }; - - const parentGroup = findParentGroupForBounds( - { x: bounds.position.x, y: bounds.position.y, width: bounds.width, height: bounds.height }, - derived.groups, - newGroupId - ); - - const newGroup: GroupStyleAnnotation = { - id: newGroupId, - name: "New Group", - level: "1", - position: bounds.position, - width: bounds.width, - height: bounds.height, - backgroundColor: "rgba(100, 100, 255, 0.1)", - borderColor: readThemeColor("--vscode-editor-foreground", "#666666"), - borderWidth: 2, - borderStyle: "dashed", - borderRadius: 8, - members: bounds.members, - ...(parentGroup ? { parentId: parentGroup.id, groupId: parentGroup.id } : {}) - }; - - derived.addGroup(newGroup); - if (bounds.members.length > 0) { - for (const memberId of bounds.members) { - derived.addNodeToGroup(memberId, newGroupId); - } - } - - persistWithMemberships(); - }, - [canEditAnnotations, onLockedAction, rfInstance, derived, persistWithMemberships] - ); - - const addGroup = useCallback( - (group: GroupStyleAnnotation) => { - const memberIds = toStringArray(group.members); - derived.addGroup(group); - if (memberIds.length > 0) { - for (const memberId of memberIds) { - derived.addNodeToGroup(memberId, group.id); - } - } - persistWithMemberships(); - }, - [derived, persistWithMemberships] - ); - - const updateGroupSize = useCallback( - (id: string, width: number, height: number) => { - const group = derived.groups.find((g) => g.id === id); - if (!group) return; - derived.updateGroup(id, { width, height }); - persist(); - }, - [derived, persist] - ); - - return useMemo( - () => ({ - editGroup, - saveGroup, - deleteGroup, - handleAddGroup, - createGroupAtPosition, - addGroup, - updateGroupSize - }), - [ - editGroup, - saveGroup, - deleteGroup, - handleAddGroup, - createGroupAtPosition, - addGroup, - updateGroupSize - ] - ); -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/useHelperLines.ts b/src/reactTopoViewer/webview/hooks/canvas/useHelperLines.ts deleted file mode 100644 index 4854a176a..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/useHelperLines.ts +++ /dev/null @@ -1,333 +0,0 @@ -/** - * Helper lines hook for node alignment during drag - * - * Provides visual alignment guides that appear when dragging nodes, - * helping to align nodes horizontally and vertically with other nodes. - */ -import { useState, useCallback, useRef, useEffect } from "react"; -import type { Node, XYPosition } from "@xyflow/react"; - -/** Distance threshold in pixels for triggering alignment snap */ -const SNAP_THRESHOLD = 5; - -/** Types of alignment detected */ -export interface HelperLinePositions { - /** Horizontal line Y position (when node centers align horizontally) */ - horizontal: number | null; - /** Vertical line X position (when node centers align vertically) */ - vertical: number | null; - /** Horizontal midpoint line Y position (when node is centered between two nodes vertically) */ - horizontalMidpoint: number | null; - /** Vertical midpoint line X position (when node is centered between two nodes horizontally) */ - verticalMidpoint: number | null; -} - -/** Alignment result with optional snapped position */ -export interface AlignmentResult { - /** Helper line positions to display */ - lines: HelperLinePositions; - /** Snapped position if alignment detected, null otherwise */ - snappedPosition: XYPosition | null; -} - -const EMPTY_HELPER_LINES: HelperLinePositions = { - horizontal: null, - vertical: null, - horizontalMidpoint: null, - verticalMidpoint: null -}; - -function areHelperLinesEqual(left: HelperLinePositions, right: HelperLinePositions): boolean { - return ( - left.horizontal === right.horizontal && - left.vertical === right.vertical && - left.horizontalMidpoint === right.horizontalMidpoint && - left.verticalMidpoint === right.verticalMidpoint - ); -} - -/** Get node dimensions with defaults */ -function getNodeDimensions(node: Node): { width: number; height: number } { - return { - width: node.measured?.width ?? node.width ?? 40, - height: node.measured?.height ?? node.height ?? 40 - }; -} - -/** Get node edges (top, right, bottom, left) */ -function getNodeEdges(node: Node) { - const { width, height } = getNodeDimensions(node); - return { - top: node.position.y, - right: node.position.x + width, - bottom: node.position.y + height, - left: node.position.x, - centerX: node.position.x + width / 2, - centerY: node.position.y + height / 2 - }; -} - -type AxisAlignmentResult = { - line: number | null; - snap: number | null; - distance: number; -}; - -function findClosestAxisAlignment( - dragPositions: Array<{ value: number; snapOffset: number }>, - targetPositions: number[], - threshold: number -): AxisAlignmentResult { - let bestDistance = threshold + 1; - let bestLine: number | null = null; - let bestSnap: number | null = null; - - for (const drag of dragPositions) { - for (const target of targetPositions) { - const dist = Math.abs(drag.value - target); - if (dist < bestDistance) { - bestDistance = dist; - bestLine = target; - bestSnap = target + drag.snapOffset; - } - } - } - - return { - line: bestDistance <= threshold ? bestLine : null, - snap: bestDistance <= threshold ? bestSnap : null, - distance: bestDistance - }; -} - -type MidpointAlignmentResult = { - horizontalMidpoint: number | null; - verticalMidpoint: number | null; - snapX: number | null; - snapY: number | null; - distanceX: number; - distanceY: number; -}; - -function computeMidpointAlignments( - draggingEdges: ReturnType, - draggingDims: { width: number; height: number }, - otherNodes: Node[], - threshold: number -): MidpointAlignmentResult { - let closestMidpointXDist = threshold + 1; - let closestMidpointYDist = threshold + 1; - let midpointSnapX: number | null = null; - let midpointSnapY: number | null = null; - let horizontalMidpoint: number | null = null; - let verticalMidpoint: number | null = null; - - for (let i = 0; i < otherNodes.length; i++) { - const edgesA = getNodeEdges(otherNodes[i]); - for (let j = i + 1; j < otherNodes.length; j++) { - const edgesB = getNodeEdges(otherNodes[j]); - const midpointX = (edgesA.centerX + edgesB.centerX) / 2; - const midpointY = (edgesA.centerY + edgesB.centerY) / 2; - - const midpointYDist = Math.abs(draggingEdges.centerY - midpointY); - if (midpointYDist < closestMidpointYDist) { - closestMidpointYDist = midpointYDist; - horizontalMidpoint = midpointY; - midpointSnapY = midpointY - draggingDims.height / 2; - } - - const midpointXDist = Math.abs(draggingEdges.centerX - midpointX); - if (midpointXDist < closestMidpointXDist) { - closestMidpointXDist = midpointXDist; - verticalMidpoint = midpointX; - midpointSnapX = midpointX - draggingDims.width / 2; - } - } - } - - return { - horizontalMidpoint: closestMidpointYDist <= threshold ? horizontalMidpoint : null, - verticalMidpoint: closestMidpointXDist <= threshold ? verticalMidpoint : null, - snapX: closestMidpointXDist <= threshold ? midpointSnapX : null, - snapY: closestMidpointYDist <= threshold ? midpointSnapY : null, - distanceX: closestMidpointXDist, - distanceY: closestMidpointYDist - }; -} - -/** - * Calculate alignment helper lines and snap position for a dragged node - * - * @param draggingNode - The node being dragged (with current drag position) - * @param allNodes - All nodes in the graph - * @param threshold - Pixel threshold for snapping - * @returns Alignment result with helper line positions and optional snap position - */ -export function calculateAlignments( - draggingNode: Node, - allNodes: Node[], - threshold: number = SNAP_THRESHOLD -): AlignmentResult { - const result: AlignmentResult = { - lines: { horizontal: null, vertical: null, horizontalMidpoint: null, verticalMidpoint: null }, - snappedPosition: null - }; - - // Get dragging node dimensions and edges - const draggingEdges = getNodeEdges(draggingNode); - const draggingDims = getNodeDimensions(draggingNode); - - const otherNodes = allNodes.filter((n) => n.id !== draggingNode.id && n.hidden !== true); - - const dragYPositions = [ - { value: draggingEdges.top, snapOffset: 0 }, - { value: draggingEdges.centerY, snapOffset: -draggingDims.height / 2 }, - { value: draggingEdges.bottom, snapOffset: -draggingDims.height } - ]; - const dragXPositions = [ - { value: draggingEdges.left, snapOffset: 0 }, - { value: draggingEdges.centerX, snapOffset: -draggingDims.width / 2 }, - { value: draggingEdges.right, snapOffset: -draggingDims.width } - ]; - - const targetYPositions = otherNodes.flatMap((node) => { - const edges = getNodeEdges(node); - return [edges.top, edges.centerY, edges.bottom]; - }); - const targetXPositions = otherNodes.flatMap((node) => { - const edges = getNodeEdges(node); - return [edges.left, edges.centerX, edges.right]; - }); - - const horizontalResult = findClosestAxisAlignment(dragYPositions, targetYPositions, threshold); - const verticalResult = findClosestAxisAlignment(dragXPositions, targetXPositions, threshold); - - result.lines.horizontal = horizontalResult.line; - result.lines.vertical = verticalResult.line; - - let snapX = verticalResult.snap; - let snapY = horizontalResult.snap; - - if (otherNodes.length >= 2) { - const midpointResult = computeMidpointAlignments( - draggingEdges, - draggingDims, - otherNodes, - threshold - ); - result.lines.horizontalMidpoint = midpointResult.horizontalMidpoint; - result.lines.verticalMidpoint = midpointResult.verticalMidpoint; - - if ( - midpointResult.snapX !== null && - (snapX === null || midpointResult.distanceX < verticalResult.distance) - ) { - snapX = midpointResult.snapX; - } - if ( - midpointResult.snapY !== null && - (snapY === null || midpointResult.distanceY < horizontalResult.distance) - ) { - snapY = midpointResult.snapY; - } - } - - // Only provide snapped position if at least one alignment was found - if (snapX !== null || snapY !== null) { - result.snappedPosition = { - x: snapX ?? draggingNode.position.x, - y: snapY ?? draggingNode.position.y - }; - } - - return result; -} - -/** - * Hook to manage helper lines state during node dragging - */ -export function useHelperLines() { - const [helperLines, setHelperLines] = useState(EMPTY_HELPER_LINES); - - // Track if we're currently showing lines (to avoid unnecessary state updates) - const hasLinesRef = useRef(false); - const helperLinesRef = useRef(EMPTY_HELPER_LINES); - const pendingLinesRef = useRef(null); - const rafIdRef = useRef(null); - - const flushPendingLines = useCallback(() => { - rafIdRef.current = null; - const pending = pendingLinesRef.current; - if (!pending) return; - pendingLinesRef.current = null; - if (areHelperLinesEqual(helperLinesRef.current, pending)) return; - helperLinesRef.current = pending; - setHelperLines(pending); - }, []); - - const scheduleHelperLineUpdate = useCallback( - (nextLines: HelperLinePositions) => { - pendingLinesRef.current = nextLines; - if (rafIdRef.current !== null) return; - rafIdRef.current = window.requestAnimationFrame(flushPendingLines); - }, - [flushPendingLines] - ); - - useEffect( - () => () => { - if (rafIdRef.current !== null) { - window.cancelAnimationFrame(rafIdRef.current); - } - }, - [] - ); - - /** - * Update helper lines based on dragged node position - * Returns the snapped position if alignment detected - */ - const updateHelperLines = useCallback( - (draggingNode: Node, allNodes: Node[], enableSnap: boolean = true): XYPosition | null => { - const { lines, snappedPosition } = calculateAlignments(draggingNode, allNodes); - - const hasLines = - lines.horizontal !== null || - lines.vertical !== null || - lines.horizontalMidpoint !== null || - lines.verticalMidpoint !== null; - - // Only update state if lines changed - if (hasLines || hasLinesRef.current) { - scheduleHelperLineUpdate(lines); - hasLinesRef.current = hasLines; - } - - return enableSnap ? snappedPosition : null; - }, - [scheduleHelperLineUpdate] - ); - - /** Clear helper lines (call on drag end) */ - const clearHelperLines = useCallback(() => { - if (rafIdRef.current !== null) { - window.cancelAnimationFrame(rafIdRef.current); - rafIdRef.current = null; - } - pendingLinesRef.current = null; - - if (!hasLinesRef.current && areHelperLinesEqual(helperLinesRef.current, EMPTY_HELPER_LINES)) { - return; - } - - helperLinesRef.current = EMPTY_HELPER_LINES; - setHelperLines(EMPTY_HELPER_LINES); - hasLinesRef.current = false; - }, []); - - return { - helperLines, - updateHelperLines, - clearHelperLines - }; -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/useNetworkCreation.ts b/src/reactTopoViewer/webview/hooks/canvas/useNetworkCreation.ts deleted file mode 100644 index be1a9a109..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/useNetworkCreation.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * useNetworkCreation - Hook for creating network nodes (host, mgmt-net, macvlan, vxlan, etc.) - * - * Networks are external endpoints that connect to resources outside the containerlab topology. - * They are rendered as network nodes with special styling and dashed link connections. - */ -import { useCallback, useRef } from "react"; - -import { log } from "../../utils/logger"; -import type { TopoNode, NetworkNodeData } from "../../../shared/types/graph"; - -/** Network type definitions */ -export type NetworkType = - | "host" - | "mgmt-net" - | "macvlan" - | "vxlan" - | "vxlan-stitch" - | "dummy" - | "bridge" - | "ovs-bridge"; - -interface NetworkCreationOptions { - mode: "edit" | "view"; - isLocked: boolean; - getExistingNodeIds: () => Set; - getExistingNetworkNodes: () => Array<{ id: string; kind: NetworkType }>; - onNetworkCreated: ( - networkId: string, - networkElement: TopoNode, - position: { x: number; y: number } - ) => void; - onLockedClick?: () => void; -} - -interface NetworkData { - id: string; - name: string; - topoViewerRole: "cloud"; - kind: NetworkType; - type: NetworkType; - extraData: NetworkExtraData; -} - -interface NetworkExtraData { - kind: NetworkType; - extType: NetworkType; - [key: string]: unknown; -} - -/** Counters for each network type to generate unique IDs */ -const networkCounters: Record = { - host: 0, - "mgmt-net": 0, - macvlan: 0, - vxlan: 0, - "vxlan-stitch": 0, - dummy: 0, - bridge: 0, - "ovs-bridge": 0 -}; - -// Regex patterns for extracting counter values from network IDs -const INTERFACE_PATTERN = /:eth(\d+)$|:net(\d+)$|:(\d+)$/; -// eslint-disable-next-line sonarjs/slow-regex -- Simple pattern, no backtracking risk -const TRAILING_NUMBER_PATTERN = /(\d+)$/; -const VXLAN_PATTERN = /:vxlan(\d+)$/; - -/** - * Extract counter value from a regex match result. - */ -function extractCounterFromMatch(match: RegExpExecArray | null): number | null { - if (!match) return null; - // Find the first captured group that has a value - for (let i = 1; i < match.length; i++) { - const value = Number.parseInt(match[i], 10); - if (!Number.isNaN(value)) { - return value; - } - } - return null; -} - -/** - * Update counter for a specific network type if the extracted number is higher. - */ -function updateCounter(kind: NetworkType, num: number): void { - if (num >= networkCounters[kind]) { - networkCounters[kind] = num + 1; - } -} - -/** - * Process interface-type networks (host, mgmt-net, macvlan). - */ -function processInterfaceNetwork(nodeId: string, kind: NetworkType): void { - const num = extractCounterFromMatch(INTERFACE_PATTERN.exec(nodeId)); - if (num !== null) { - updateCounter(kind, num); - } -} - -/** - * Process trailing-number networks (dummy, bridge, ovs-bridge). - */ -function processTrailingNumberNetwork(nodeId: string, kind: NetworkType): void { - const num = extractCounterFromMatch(TRAILING_NUMBER_PATTERN.exec(nodeId)); - if (num !== null) { - updateCounter(kind, num); - } -} - -/** - * Process VXLAN-type networks. - */ -function processVxlanNetwork(nodeId: string, kind: NetworkType): void { - const num = extractCounterFromMatch(VXLAN_PATTERN.exec(nodeId)); - if (num !== null) { - updateCounter(kind, num); - } -} - -/** - * Initialize network counters based on existing network nodes - */ -function initializeNetworkCounters(networkNodes: Array<{ id: string; kind: NetworkType }>): void { - networkNodes.forEach((node) => { - const nodeId = node.id; - const kind = node.kind; - - // Parse existing ID to extract counter value - // Format examples: "host:eth0", "host:eth1", "mgmt-net:net0", "bridge1" - if (kind === "host" || kind === "mgmt-net" || kind === "macvlan") { - processInterfaceNetwork(nodeId, kind); - } else if (kind === "dummy" || kind === "bridge" || kind === "ovs-bridge") { - processTrailingNumberNetwork(nodeId, kind); - } else { - processVxlanNetwork(nodeId, kind); - } - }); -} - -/** - * Generate a unique network ID based on type - */ -function generateNetworkId(networkType: NetworkType, existingIds: Set): string { - let id: string; - let counter = networkCounters[networkType]; - - do { - switch (networkType) { - case "host": - id = `host:eth${counter}`; - break; - case "mgmt-net": - id = `mgmt-net:net${counter}`; - break; - case "macvlan": - id = `macvlan:${counter}`; - break; - case "vxlan": - id = `vxlan:vxlan${counter}`; - break; - case "vxlan-stitch": - id = `vxlan-stitch:vxlan${counter}`; - break; - case "dummy": - id = `dummy${counter}`; - break; - case "bridge": - id = `bridge${counter}`; - break; - case "ovs-bridge": - id = `ovs-bridge${counter}`; - break; - default: - id = `network${counter}`; - } - counter++; - } while (existingIds.has(id)); - - networkCounters[networkType] = counter; - return id; -} - -/** - * Generate display label for a network node. - * Labels use the full ID format (e.g., "host:eth0", "vxlan:vxlan0") - * to clearly indicate both the type and the specific instance. - */ -function generateNetworkLabel(networkId: string, _networkType: NetworkType): string { - // Use the full ID as the label for clarity - return networkId; -} - -/** - * Create network node data - */ -function createNetworkData(networkId: string, networkType: NetworkType): NetworkData { - const label = generateNetworkLabel(networkId, networkType); - - return { - id: networkId, - name: label, - topoViewerRole: "cloud", - kind: networkType, - type: networkType, - extraData: { - kind: networkType, - extType: networkType - } - }; -} - -/** - * Convert NetworkData to TopoNode format (NetworkRFNode) - */ -function networkDataToTopoNode(data: NetworkData, position: { x: number; y: number }): TopoNode { - const networkNodeData: NetworkNodeData = { - label: data.name, - nodeType: data.kind, - extraData: data.extraData - }; - return { - id: data.id, - type: "network-node", - position, - data: networkNodeData - }; -} - -/** - * Hook for creating network nodes - */ -export function useNetworkCreation(options: NetworkCreationOptions): { - createNetworkAtPosition: ( - position: { x: number; y: number }, - networkType: NetworkType - ) => string | null; -} { - const { onNetworkCreated } = options; - const optionsRef = useRef(options); - optionsRef.current = options; - const countersInitializedRef = useRef(false); - const reservedIdsRef = useRef>(new Set()); - - const createNetworkAtPosition = useCallback( - (position: { x: number; y: number }, networkType: NetworkType): string | null => { - const { mode, isLocked, onLockedClick } = optionsRef.current; - - // Only allow in edit mode - if (mode !== "edit") { - log.debug("[NetworkCreation] Not in edit mode"); - return null; - } - - // Check if locked - if (isLocked) { - log.debug("[NetworkCreation] Canvas is locked"); - onLockedClick?.(); - return null; - } - - // Initialize counters on first use - if (!countersInitializedRef.current) { - initializeNetworkCounters(optionsRef.current.getExistingNetworkNodes()); - countersInitializedRef.current = true; - } - - // Get existing IDs to avoid duplicates - const existingIds = optionsRef.current.getExistingNodeIds(); - for (const id of reservedIdsRef.current) existingIds.add(id); - - // Generate unique ID - const networkId = generateNetworkId(networkType, existingIds); - const networkData = createNetworkData(networkId, networkType); - - // Create TopoNode for state update - const topoNode = networkDataToTopoNode(networkData, position); - - log.info( - `[NetworkCreation] Created network: ${networkId} (${networkType}) at (${position.x}, ${position.y})` - ); - - reservedIdsRef.current.add(networkId); - onNetworkCreated(networkId, topoNode, position); - return networkId; - }, - [onNetworkCreated] - ); - - return { createNetworkAtPosition }; -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/useNodeCreation.ts b/src/reactTopoViewer/webview/hooks/canvas/useNodeCreation.ts deleted file mode 100644 index d56351f45..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/useNodeCreation.ts +++ /dev/null @@ -1,308 +0,0 @@ -/** - * useNodeCreation - Hook for creating nodes via Shift+Click on canvas - * - * Uses ReactFlow instance for viewport operations. - * The event handling should be integrated via ReactFlow's onPaneClick callback. - */ -import { useCallback, useEffect, useRef } from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; - -import { log } from "../../utils/logger"; -import type { TopoNode, TopologyNodeData } from "../../../shared/types/graph"; -import type { CustomNodeTemplate } from "../../../shared/types/editors"; -import { getUniqueId } from "../../../shared/utilities/idUtils"; -import { convertEditorDataToYaml } from "../../../shared/utilities/nodeEditorConversions"; - -interface NodeCreationOptions { - customNodes: CustomNodeTemplate[]; - defaultNode: string; - getUsedNodeIds: () => Set; - onNodeCreated: ( - nodeId: string, - nodeElement: TopoNode, - position: { x: number; y: number } - ) => void; - onLockedClick?: () => void; -} - -interface NodeData { - id: string; - name: string; - editor: string; - weight: string; - parent: string; - topoViewerRole: string; - iconColor?: string; - iconCornerRadius?: number; - sourceEndpoint: string; - targetEndpoint: string; - containerDockerExtraAttribute: { state: string; status: string }; - extraData: NodeExtraData; - [key: string]: unknown; -} - -interface NodeExtraData { - kind: string; - longname: string; - image: string; - type?: string; - mgmtIpv4Address: string; - fromCustomTemplate?: boolean; - [key: string]: unknown; -} - -/** - * Generate a unique node name based on template or default - * Uses getUniqueId to match legacy behavior (srl → srl1, srl2, etc.) - */ -function generateNodeName(baseName: string, usedIds: Set): string { - return getUniqueId(baseName, usedIds); -} - -function resolveTemplate( - template: CustomNodeTemplate | undefined, - options: NodeCreationOptions -): CustomNodeTemplate | undefined { - if (template) return template; - if (options.defaultNode) { - const defaultTemplate = options.customNodes.find((node) => node.name === options.defaultNode); - if (defaultTemplate) return defaultTemplate; - } - return options.customNodes[0]; -} - -function resolveBaseName(template: CustomNodeTemplate): string | null { - const baseName = (template.baseName ?? template.name).trim(); - return baseName ? baseName : null; -} - -function toNonEmptyString(value: string | undefined): string | undefined { - if (value === undefined || value.length === 0) return undefined; - return value; -} - -function resolveTemplateRole(template: CustomNodeTemplate | undefined): string { - return toNonEmptyString(template?.icon) ?? "pe"; -} - -function applyOptionalNodeStyle( - nodeData: NodeData, - template: CustomNodeTemplate | undefined -): void { - const iconColor = toNonEmptyString(template?.iconColor); - if (iconColor !== undefined) { - nodeData.iconColor = iconColor; - } - if (template?.iconCornerRadius !== undefined) { - nodeData.iconCornerRadius = template.iconCornerRadius; - } -} - -function getResolvedKind(template: CustomNodeTemplate): string { - return template.kind.length > 0 ? template.kind : "nokia_srlinux"; -} - -/** - * Determine the type for a node - */ -function determineType(kind: string, template?: CustomNodeTemplate): string | undefined { - if (template?.type !== undefined && template.type.length > 0) { - return template.type; - } - - const nokiaKinds = ["nokia_srlinux", "nokia_srsim", "nokia_sros"]; - if (!nokiaKinds.includes(kind)) { - return undefined; - } - - // If this template represents a custom node (has a name) but no explicit type, - // avoid assigning a default type. - if (template?.name !== undefined && template.name.length > 0) { - return undefined; - } - - return "ixr-d2l"; -} - -/** - * Fields that are handled separately and should not be included in extraData. - * These are either top-level node properties or annotation-only fields. - */ -const TEMPLATE_EXCLUDED_FIELDS = new Set([ - "name", - "kind", - "type", - "image", - "icon", - "iconColor", - "iconCornerRadius", - "setDefault", - "baseName", - "interfacePattern", - "oldName" -]); - -/** - * Extract extra template data and convert to kebab-case for YAML compatibility. - * Template fields are stored in camelCase (e.g., autoRemove) but extraData - * needs kebab-case (e.g., auto-remove) for proper YAML persistence. - */ -function extractExtraTemplate(template?: CustomNodeTemplate): Record { - if (!template) return {}; - - // Filter out excluded fields first - const templateData = Object.fromEntries( - Object.entries(template).filter(([key]) => !TEMPLATE_EXCLUDED_FIELDS.has(key)) - ); - - // If no extra fields, return empty object - if (Object.keys(templateData).length === 0) return {}; - - // Convert camelCase fields to kebab-case using the same conversion as node editor - // This ensures fields like autoRemove -> auto-remove, startupConfig -> startup-config - const yamlData = convertEditorDataToYaml(templateData); - - // Filter out null values (used to signal deletion) and undefined values - return Object.fromEntries( - Object.entries(yamlData).filter(([, v]) => v !== null && v !== undefined) - ); -} - -/** - * Create node data for a new containerlab node - */ -function createNodeData( - nodeId: string, - nodeName: string, - template: CustomNodeTemplate | undefined, - kind: string -): NodeData { - const interfacePattern = toNonEmptyString(template?.interfacePattern); - - const extraData: NodeExtraData = { - kind, - longname: "", - image: template?.image ?? "", - mgmtIpv4Address: "", - fromCustomTemplate: Boolean(template?.name), - ...(interfacePattern !== undefined ? { interfacePattern } : {}), - ...extractExtraTemplate(template) - }; - - const type = determineType(kind, template); - if (type !== undefined && type.length > 0) { - extraData.type = type; - } - - const nodeData: NodeData = { - id: nodeId, - editor: "true", - weight: "30", - name: nodeName, - parent: "", - topoViewerRole: resolveTemplateRole(template), - sourceEndpoint: "", - targetEndpoint: "", - containerDockerExtraAttribute: { state: "", status: "" }, - extraData - }; - - applyOptionalNodeStyle(nodeData, template); - - return nodeData; -} - -/** - * Convert NodeData to TopoNode format - */ -function nodeDataToTopoNode(data: NodeData, position: { x: number; y: number }): TopoNode { - const nodeData: TopologyNodeData = { - label: data.name, - role: data.topoViewerRole.length > 0 ? data.topoViewerRole : "pe", - kind: data.extraData.kind, - image: data.extraData.image, - iconColor: data.iconColor, - iconCornerRadius: data.iconCornerRadius, - extraData: data.extraData - }; - - return { - id: data.id, - type: "topology-node", - position, - data: nodeData - }; -} - -/** - * Hook for handling node creation via Shift+Click on canvas - * - * For ReactFlow integration, use onPaneClick handler to call createNodeAtPosition - * when the user shift-clicks on the canvas. - */ -export function useNodeCreation( - _rfInstance: ReactFlowInstance | null, - options: NodeCreationOptions -): { - createNodeAtPosition: (position: { x: number; y: number }, template?: CustomNodeTemplate) => void; -} { - const { onNodeCreated } = options; - const optionsRef = useRef(options); - optionsRef.current = options; - const reservedIdsRef = useRef>(new Set()); - - useEffect(() => { - if (reservedIdsRef.current.size === 0) return; - const usedIds = options.getUsedNodeIds(); - for (const id of reservedIdsRef.current) { - if (!usedIds.has(id)) { - reservedIdsRef.current.delete(id); - } - } - }, [options.getUsedNodeIds]); - - const getUsedIds = () => { - const base = optionsRef.current.getUsedNodeIds(); - const combined = new Set(base); - for (const id of reservedIdsRef.current) combined.add(id); - return combined; - }; - - /** - * Create a node at a specific position - */ - const createNodeAtPosition = useCallback( - (position: { x: number; y: number }, template?: CustomNodeTemplate) => { - const resolvedTemplate = resolveTemplate(template, optionsRef.current); - if (!resolvedTemplate) { - log.warn("[NodeCreation] No custom node templates available for creation"); - return; - } - - const baseName = resolveBaseName(resolvedTemplate); - if (baseName === null) { - log.warn(`[NodeCreation] Custom node template '${resolvedTemplate.name}' has no base name`); - return; - } - - const nodeId = generateNodeName(baseName, getUsedIds()); - const nodeName = nodeId; - const kind = getResolvedKind(resolvedTemplate); - const nodeData = createNodeData(nodeId, nodeName, resolvedTemplate, kind); - - // Create TopoNode for state update - const topoNode = nodeDataToTopoNode(nodeData, position); - - log.info(`[NodeCreation] Created node: ${nodeId} at (${position.x}, ${position.y})`); - - reservedIdsRef.current.add(nodeId); - onNodeCreated(nodeId, topoNode, position); - }, - [onNodeCreated] - ); - - // Shift+Click to create nodes should be handled via ReactFlow's onPaneClick callback - // with something like: if (event.shiftKey) createNodeAtPosition(screenToFlowPosition({ x, y })) - - return { createNodeAtPosition }; -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/useReactFlowCanvasHooks.ts b/src/reactTopoViewer/webview/hooks/canvas/useReactFlowCanvasHooks.ts deleted file mode 100644 index c86eaa5d8..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/useReactFlowCanvasHooks.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * Custom hooks extracted from ReactFlowCanvas to reduce complexity - */ -import type React from "react"; -import { useRef, useEffect, useCallback, useMemo, useState } from "react"; -import type { Node, Edge, ReactFlowInstance } from "@xyflow/react"; - -import { applyLayout, type LayoutName } from "../../components/canvas/layout"; -import { useGraphStore } from "../../stores/graphStore"; -import { log } from "../../utils/logger"; -import { allocateEndpointsForLink } from "../../utils/endpointAllocator"; -import { buildEdgeId } from "../../utils/edgeId"; - -/** - * Hook for delete node/edge handlers - */ -export function useDeleteHandlers( - selectNode: (id: string | null) => void, - selectEdge: (id: string | null) => void, - closeContextMenu: () => void, - onNodeDelete?: (nodeId: string) => void, - onEdgeDelete?: (edgeId: string) => void -) { - const handleDeleteNode = useCallback( - (nodeId: string) => { - log.info(`[ReactFlowCanvas] Deleting node: ${nodeId}`); - onNodeDelete?.(nodeId); - selectNode(null); - selectEdge(null); - closeContextMenu(); - }, - [selectNode, selectEdge, onNodeDelete, closeContextMenu] - ); - - const handleDeleteEdge = useCallback( - (edgeId: string) => { - log.info(`[ReactFlowCanvas] Deleting edge: ${edgeId}`); - onEdgeDelete?.(edgeId); - selectEdge(null); - selectNode(null); - closeContextMenu(); - }, - [selectNode, selectEdge, onEdgeDelete, closeContextMenu] - ); - - return { handleDeleteNode, handleDeleteEdge }; -} - -/** - * Hook for link creation mode - */ -export function useLinkCreation( - onEdgeCreated?: ( - sourceId: string, - targetId: string, - edgeData: { - id: string; - source: string; - target: string; - sourceEndpoint: string; - targetEndpoint: string; - } - ) => void -) { - const [linkSourceNode, setLinkSourceNode] = useState(null); - const linkCreationSeedRef = useRef(null); - - const startLinkCreation = useCallback((nodeId: string) => { - log.info(`[ReactFlowCanvas] Starting link creation from: ${nodeId}`); - linkCreationSeedRef.current = Date.now(); - setLinkSourceNode(nodeId); - }, []); - - const cancelLinkCreation = useCallback(() => { - log.info("[ReactFlowCanvas] Cancelling link creation"); - linkCreationSeedRef.current = null; - setLinkSourceNode(null); - }, []); - - const completeLinkCreation = useCallback( - (targetNodeId: string) => { - if (linkSourceNode === null || linkSourceNode.length === 0) return; - - const isLoopLink = linkSourceNode === targetNodeId; - log.info( - `[ReactFlowCanvas] Completing ${isLoopLink ? "loop " : ""}link: ${linkSourceNode} -> ${targetNodeId}` - ); - const { nodes, edges } = useGraphStore.getState(); - const { sourceEndpoint, targetEndpoint } = allocateEndpointsForLink( - nodes, - edges, - linkSourceNode, - targetNodeId - ); - const edgeId = buildEdgeId( - linkSourceNode, - targetNodeId, - sourceEndpoint, - targetEndpoint, - linkCreationSeedRef.current ?? Date.now() - ); - - const edgeData = { - id: edgeId, - source: linkSourceNode, - target: targetNodeId, - sourceEndpoint, - targetEndpoint - }; - - // Use the unified callback which handles: - // 1. Adding edge to React state - // 2. Persisting via TopologyHost commands - // 3. Undo/redo support - if (onEdgeCreated) { - onEdgeCreated(linkSourceNode, targetNodeId, edgeData); - } - - linkCreationSeedRef.current = null; - setLinkSourceNode(null); - }, - [linkSourceNode, onEdgeCreated] - ); - - useEffect(() => { - if (linkSourceNode === null || linkSourceNode.length === 0) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") cancelLinkCreation(); - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [linkSourceNode, cancelLinkCreation]); - - return { - linkSourceNode, - startLinkCreation, - completeLinkCreation, - cancelLinkCreation, - linkCreationSeed: linkCreationSeedRef.current - }; -} - -/** - * Hook for calculating source node position for link creation line. - * Only recalculates when linkSourceNode changes, not on every node position update. - * Uses the node's initial position when link creation starts. - */ -export function useSourceNodePosition(linkSourceNode: string | null, nodes: Node[]) { - const ICON_SIZE = 40; - // Store position and the linkSourceNode it was calculated for - const positionRef = useRef<{ x: number; y: number } | null>(null); - const lastSourceNodeRef = useRef(null); - - // Only update position when linkSourceNode changes (not on every node position update) - if (linkSourceNode !== lastSourceNodeRef.current) { - lastSourceNodeRef.current = linkSourceNode; - if (linkSourceNode === null || linkSourceNode.length === 0) { - positionRef.current = null; - } else { - const node = nodes.find((n) => n.id === linkSourceNode); - if (node) { - const nodeWidth = node.measured?.width ?? ICON_SIZE; - positionRef.current = { - x: node.position.x + nodeWidth / 2, - y: node.position.y + ICON_SIZE / 2 - }; - } - } - } - - return positionRef.current; -} - -/** - * Hook for keyboard delete handlers - */ -export function useKeyboardDeleteHandlers( - mode: "view" | "edit", - isLocked: boolean, - selectedNode: string | null, - selectedEdge: string | null, - handleDeleteNode: (nodeId: string) => void, - handleDeleteEdge: (edgeId: string) => void -) { - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Delete" && event.key !== "Backspace") return; - if (mode !== "edit" || isLocked) return; - - if (!(event.target instanceof HTMLElement)) return; - const tagName = event.target.tagName; - if (tagName === "INPUT" || tagName === "TEXTAREA") return; - - if (selectedNode !== null && selectedNode.length > 0) { - handleDeleteNode(selectedNode); - } else if (selectedEdge !== null && selectedEdge.length > 0) { - handleDeleteEdge(selectedEdge); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [mode, isLocked, selectedNode, selectedEdge, handleDeleteNode, handleDeleteEdge]); -} - -/** Position entry for undo/redo */ -interface PositionEntry { - id: string; - position: { x: number; y: number }; -} - -/** Apply position update to a single node */ -function applyPositionToNode(node: Node, positions: PositionEntry[]): Node { - const posEntry = positions.find((p) => p.id === node.id); - return posEntry ? { ...node, position: posEntry.position } : node; -} - -/** Create node updater function for position changes */ -function createPositionUpdater(positions: PositionEntry[]) { - return (currentNodes: Node[]) => currentNodes.map((node) => applyPositionToNode(node, positions)); -} - -/** - * Hook to create imperative handle methods - */ -/** Schedule a fit view after layout application */ -function scheduleFitView(rfRef: React.RefObject): void { - setTimeout(() => { - const fitViewPromise = rfRef.current?.fitView({ padding: 0.2, duration: 200 }); - fitViewPromise?.catch(() => { - /* ignore */ - }); - }, 100); -} - -function isLayoutName(value: string): value is LayoutName { - return value === "preset" || value === "force"; -} - -export function useCanvasRefMethods( - reactFlowInstanceRef: React.RefObject, - nodes: Node[], - edges: Edge[], - setNodes: React.Dispatch>, - setEdges: React.Dispatch> -) { - return useMemo( - () => ({ - fit: () => reactFlowInstanceRef.current?.fitView({ padding: 0.2, duration: 200 }), - runLayout: (layoutName: string) => { - const resolvedLayout = isLayoutName(layoutName) ? layoutName : "preset"; - setNodes(applyLayout(resolvedLayout, nodes, edges)); - scheduleFitView(reactFlowInstanceRef); - }, - getReactFlowInstance: () => reactFlowInstanceRef.current, - getNodes: () => nodes, - getEdges: () => edges, - setNodePositions: (positions: PositionEntry[]) => { - setNodes(createPositionUpdater(positions)); - }, - updateNodes: (updater: (nodes: Node[]) => Node[]) => setNodes(updater), - updateEdges: (updater: (edges: Edge[]) => Edge[]) => setEdges(updater) - }), - [nodes, edges, setNodes, setEdges, reactFlowInstanceRef] - ); -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/useShapeAnnotations.ts b/src/reactTopoViewer/webview/hooks/canvas/useShapeAnnotations.ts deleted file mode 100644 index 3ba592721..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/useShapeAnnotations.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { useCallback, useMemo, useRef } from "react"; - -import type { FreeShapeAnnotation } from "../../../shared/types/topology"; -import type { AnnotationUIActions, AnnotationUIState } from "../../stores/annotationUIStore"; -import * as annotationServices from "../../services"; -import * as logger from "../../utils/logger"; - -import type { UseDerivedAnnotationsReturn } from "./useDerivedAnnotations"; -import { findDeepestGroupAtPosition } from "./groupUtils"; -import { readThemeColor } from "./themeColor"; -interface UseShapeAnnotationsParams { - isLocked: boolean; - onLockedAction: () => void; - derived: UseDerivedAnnotationsReturn; - uiState: Pick; - uiActions: Pick< - AnnotationUIActions, - | "setAddShapeMode" - | "disableAddShapeMode" - | "setEditingShapeAnnotation" - | "removeFromShapeSelection" - >; -} - -export interface ShapeAnnotationActions { - handleAddShapes: (shapeType?: string) => void; - createShapeAtPosition: (position: { x: number; y: number }, shapeType?: string) => void; - editShapeAnnotation: (id: string) => void; - saveShapeAnnotation: (annotation: FreeShapeAnnotation) => void; - deleteShapeAnnotation: (id: string) => void; - deleteSelectedShapeAnnotations: () => void; - onShapeRotationStart: (id: string) => void; - onShapeRotationEnd: (id: string) => void; - handleShapeCanvasClick: (position: { x: number; y: number }) => void; -} - -export function useShapeAnnotations(params: UseShapeAnnotationsParams): ShapeAnnotationActions { - const { isLocked, onLockedAction, derived, uiState, uiActions } = params; - const canEditAnnotations = !isLocked; - - const lastShapeStyleRef = useRef>({}); - const pendingRotationRef = useRef(null); - - const persist = useCallback(() => { - void annotationServices.saveAnnotationNodesFromGraph(); - }, []); - - const handleAddShapes = useCallback( - (shapeType?: string) => { - if (!canEditAnnotations) { - onLockedAction(); - return; - } - const normalizedShape: FreeShapeAnnotation["shapeType"] = - shapeType === "circle" || shapeType === "line" || shapeType === "rectangle" - ? shapeType - : "rectangle"; - uiActions.setAddShapeMode(true, normalizedShape); - }, - [canEditAnnotations, onLockedAction, uiActions] - ); - - const buildShapeAnnotation = useCallback( - (position: { x: number; y: number }, shapeType?: string): FreeShapeAnnotation => { - const normalizedShape: FreeShapeAnnotation["shapeType"] = - shapeType === "circle" || shapeType === "line" || shapeType === "rectangle" - ? shapeType - : "rectangle"; - const parentGroup = findDeepestGroupAtPosition(position, derived.groups); - return { - id: `freeShape_${Date.now()}`, - shapeType: normalizedShape, - position, - endPosition: { x: position.x + 120, y: position.y + 60 }, - rotation: 0, - fillColor: lastShapeStyleRef.current.fillColor ?? "rgba(127, 127, 127, 0.16)", - fillOpacity: lastShapeStyleRef.current.fillOpacity ?? 0.2, - borderColor: - lastShapeStyleRef.current.borderColor ?? - readThemeColor("--vscode-editor-foreground", "#666666"), - borderWidth: lastShapeStyleRef.current.borderWidth ?? 1, - borderStyle: lastShapeStyleRef.current.borderStyle ?? "solid", - borderRadius: lastShapeStyleRef.current.borderRadius ?? 4, - groupId: parentGroup?.id - }; - }, - [derived.groups] - ); - - const createShapeAtPosition = useCallback( - (position: { x: number; y: number }, shapeType?: string) => { - if (!canEditAnnotations) { - onLockedAction(); - return; - } - const newAnnotation = buildShapeAnnotation(position, shapeType); - derived.addShapeAnnotation(newAnnotation); - persist(); - logger.log.info(`[FreeShape] Created annotation at (${position.x}, ${position.y})`); - }, - [canEditAnnotations, onLockedAction, buildShapeAnnotation, derived, persist] - ); - - const editShapeAnnotation = useCallback( - (id: string) => { - const annotation = derived.shapeAnnotations.find((a) => a.id === id); - if (annotation) { - uiActions.setEditingShapeAnnotation(annotation); - } - }, - [derived.shapeAnnotations, uiActions] - ); - - const saveShapeAnnotation = useCallback( - (annotation: FreeShapeAnnotation) => { - const isNew = !derived.shapeAnnotations.some((s) => s.id === annotation.id); - - if (isNew) { - derived.addShapeAnnotation(annotation); - } else { - derived.updateShapeAnnotation(annotation.id, annotation); - } - - lastShapeStyleRef.current = { - fillColor: annotation.fillColor, - fillOpacity: annotation.fillOpacity, - borderColor: annotation.borderColor, - borderWidth: annotation.borderWidth, - borderStyle: annotation.borderStyle, - borderRadius: annotation.borderRadius, - rotation: annotation.rotation - }; - - persist(); - }, - [derived, persist] - ); - - const deleteShapeAnnotation = useCallback( - (id: string) => { - derived.deleteShapeAnnotation(id); - uiActions.removeFromShapeSelection(id); - persist(); - }, - [derived, uiActions, persist] - ); - - const deleteSelectedShapeAnnotations = useCallback(() => { - const ids = Array.from(uiState.selectedShapeIds); - if (ids.length === 0) return; - ids.forEach((id) => { - derived.deleteShapeAnnotation(id); - uiActions.removeFromShapeSelection(id); - }); - persist(); - }, [derived, uiActions, persist, uiState.selectedShapeIds]); - - const onShapeRotationStart = useCallback((id: string) => { - pendingRotationRef.current = id; - }, []); - - const onShapeRotationEnd = useCallback( - (id: string) => { - if (pendingRotationRef.current === id) { - pendingRotationRef.current = null; - persist(); - } - }, - [persist] - ); - - const handleShapeCanvasClick = useCallback( - (position: { x: number; y: number }) => { - if (!uiState.isAddShapeMode) return; - const newAnnotation = buildShapeAnnotation(position, uiState.pendingShapeType); - uiActions.setEditingShapeAnnotation(newAnnotation); - uiActions.disableAddShapeMode(); - logger.log.info(`[FreeShape] Creating annotation at (${position.x}, ${position.y})`); - }, - [uiState.isAddShapeMode, uiState.pendingShapeType, buildShapeAnnotation, uiActions] - ); - - return useMemo( - () => ({ - handleAddShapes, - createShapeAtPosition, - editShapeAnnotation, - saveShapeAnnotation, - deleteShapeAnnotation, - deleteSelectedShapeAnnotations, - onShapeRotationStart, - onShapeRotationEnd, - handleShapeCanvasClick - }), - [ - handleAddShapes, - createShapeAtPosition, - editShapeAnnotation, - saveShapeAnnotation, - deleteShapeAnnotation, - deleteSelectedShapeAnnotations, - onShapeRotationStart, - onShapeRotationEnd, - handleShapeCanvasClick - ] - ); -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/useTextAnnotations.ts b/src/reactTopoViewer/webview/hooks/canvas/useTextAnnotations.ts deleted file mode 100644 index 4220220cf..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/useTextAnnotations.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { useCallback, useMemo, useRef } from "react"; - -import type { FreeTextAnnotation } from "../../../shared/types/topology"; -import type { AnnotationUIActions, AnnotationUIState } from "../../stores/annotationUIStore"; -import { saveAnnotationNodesFromGraph } from "../../services"; -import { log } from "../../utils/logger"; - -import type { UseDerivedAnnotationsReturn } from "./useDerivedAnnotations"; -import { findDeepestGroupAtPosition } from "./groupUtils"; -import { readThemeColor } from "./themeColor"; -interface UseTextAnnotationsParams { - isLocked: boolean; - onLockedAction: () => void; - derived: UseDerivedAnnotationsReturn; - uiState: Pick; - uiActions: Pick< - AnnotationUIActions, - "setAddTextMode" | "disableAddTextMode" | "setEditingTextAnnotation" | "removeFromTextSelection" - >; -} - -export interface TextAnnotationActions { - handleAddText: () => void; - createTextAtPosition: (position: { x: number; y: number }) => void; - editTextAnnotation: (id: string) => void; - saveTextAnnotation: (annotation: FreeTextAnnotation) => void; - deleteTextAnnotation: (id: string) => void; - deleteSelectedTextAnnotations: () => void; - onTextRotationStart: (id: string) => void; - onTextRotationEnd: (id: string) => void; - handleTextCanvasClick: (position: { x: number; y: number }) => void; -} - -export function useTextAnnotations(params: UseTextAnnotationsParams): TextAnnotationActions { - const { isLocked, onLockedAction, derived, uiState, uiActions } = params; - const canEditAnnotations = !isLocked; - - const lastTextStyleRef = useRef>({}); - const pendingRotationRef = useRef(null); - - const buildTextAnnotation = useCallback( - (position: { x: number; y: number }): FreeTextAnnotation => { - const parentGroup = findDeepestGroupAtPosition(position, derived.groups); - return { - id: `freeText_${Date.now()}`, - text: "", - position, - fontSize: lastTextStyleRef.current.fontSize ?? 14, - fontColor: - lastTextStyleRef.current.fontColor ?? - readThemeColor("--vscode-editor-foreground", "#333333"), - backgroundColor: lastTextStyleRef.current.backgroundColor, - fontWeight: lastTextStyleRef.current.fontWeight ?? "normal", - fontStyle: lastTextStyleRef.current.fontStyle ?? "normal", - textDecoration: lastTextStyleRef.current.textDecoration ?? "none", - textAlign: lastTextStyleRef.current.textAlign ?? "left", - fontFamily: lastTextStyleRef.current.fontFamily ?? "Arial", - groupId: parentGroup?.id - }; - }, - [derived.groups] - ); - - const startTextEditingAtPosition = useCallback( - (position: { x: number; y: number }) => { - const newAnnotation = buildTextAnnotation(position); - uiActions.setEditingTextAnnotation(newAnnotation); - uiActions.disableAddTextMode(); - log.info(`[FreeText] Creating annotation at (${position.x}, ${position.y})`); - }, - [buildTextAnnotation, uiActions] - ); - - const handleAddText = useCallback(() => { - if (!canEditAnnotations) { - onLockedAction(); - return; - } - uiActions.setAddTextMode(true); - }, [canEditAnnotations, onLockedAction, uiActions]); - - const createTextAtPosition = useCallback( - (position: { x: number; y: number }) => { - if (!canEditAnnotations) { - onLockedAction(); - return; - } - startTextEditingAtPosition(position); - }, - [canEditAnnotations, onLockedAction, startTextEditingAtPosition] - ); - - const editTextAnnotation = useCallback( - (id: string) => { - const annotation = derived.textAnnotations.find((a) => a.id === id); - if (annotation) { - uiActions.setEditingTextAnnotation(annotation); - } - }, - [derived.textAnnotations, uiActions] - ); - - const persist = useCallback(() => { - void saveAnnotationNodesFromGraph(); - }, []); - - const persistQuiet = useCallback(() => { - void saveAnnotationNodesFromGraph(undefined, { applySnapshot: false }); - }, []); - - const saveTextAnnotation = useCallback( - (annotation: FreeTextAnnotation) => { - const isNew = !derived.textAnnotations.some((t) => t.id === annotation.id); - - if (isNew) { - derived.addTextAnnotation(annotation); - } else { - derived.updateTextAnnotation(annotation.id, annotation); - } - - lastTextStyleRef.current = { - fontSize: annotation.fontSize, - fontColor: annotation.fontColor, - backgroundColor: annotation.backgroundColor, - fontWeight: annotation.fontWeight, - fontStyle: annotation.fontStyle, - textDecoration: annotation.textDecoration, - textAlign: annotation.textAlign, - fontFamily: annotation.fontFamily - }; - - persist(); - }, - [derived, persist] - ); - - const deleteTextAnnotation = useCallback( - (id: string) => { - derived.deleteTextAnnotation(id); - uiActions.removeFromTextSelection(id); - persist(); - }, - [derived, uiActions, persist] - ); - - const deleteSelectedTextAnnotations = useCallback(() => { - const ids = Array.from(uiState.selectedTextIds); - if (ids.length === 0) return; - ids.forEach((id) => { - derived.deleteTextAnnotation(id); - uiActions.removeFromTextSelection(id); - }); - persist(); - }, [derived, uiActions, persist, uiState.selectedTextIds]); - - const onTextRotationStart = useCallback((id: string) => { - pendingRotationRef.current = id; - }, []); - - const onTextRotationEnd = useCallback( - (id: string) => { - if (pendingRotationRef.current === id) { - pendingRotationRef.current = null; - persistQuiet(); - } - }, - [persistQuiet] - ); - - const handleTextCanvasClick = useCallback( - (position: { x: number; y: number }) => { - if (!uiState.isAddTextMode) return; - startTextEditingAtPosition(position); - }, - [uiState.isAddTextMode, startTextEditingAtPosition] - ); - - return useMemo( - () => ({ - handleAddText, - createTextAtPosition, - editTextAnnotation, - saveTextAnnotation, - deleteTextAnnotation, - deleteSelectedTextAnnotations, - onTextRotationStart, - onTextRotationEnd, - handleTextCanvasClick - }), - [ - handleAddText, - createTextAtPosition, - editTextAnnotation, - saveTextAnnotation, - deleteTextAnnotation, - deleteSelectedTextAnnotations, - onTextRotationStart, - onTextRotationEnd, - handleTextCanvasClick - ] - ); -} diff --git a/src/reactTopoViewer/webview/hooks/canvas/useTrafficRateAnnotations.ts b/src/reactTopoViewer/webview/hooks/canvas/useTrafficRateAnnotations.ts deleted file mode 100644 index 2b3c6aba7..000000000 --- a/src/reactTopoViewer/webview/hooks/canvas/useTrafficRateAnnotations.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { useCallback, useMemo } from "react"; - -import type { TrafficRateAnnotation } from "../../../shared/types/topology"; -import type { AnnotationUIActions, AnnotationUIState } from "../../stores/annotationUIStore"; -import { saveAnnotationNodesFromGraph } from "../../services"; -import { useGraphStore } from "../../stores/graphStore"; -import { getTrafficMonitorOptions } from "../../utils/trafficRateAnnotation"; - -import type { UseDerivedAnnotationsReturn } from "./useDerivedAnnotations"; -import { findDeepestGroupAtPosition } from "./groupUtils"; - -interface UseTrafficRateAnnotationsParams { - isLocked: boolean; - onLockedAction: () => void; - derived: UseDerivedAnnotationsReturn; - uiState: Pick; - uiActions: Pick< - AnnotationUIActions, - "setEditingTrafficRateAnnotation" | "removeFromTrafficRateSelection" - >; -} - -export interface TrafficRateAnnotationActions { - createTrafficRateAtPosition: (position: { x: number; y: number }) => void; - editTrafficRateAnnotation: (id: string) => void; - saveTrafficRateAnnotation: (annotation: TrafficRateAnnotation) => void; - deleteTrafficRateAnnotation: (id: string) => void; - deleteSelectedTrafficRateAnnotations: () => void; -} - -function resolveDefaultTarget(): { nodeId?: string; interfaceName?: string } { - const edges = useGraphStore.getState().edges; - const options = getTrafficMonitorOptions(edges); - const nodeId = options.nodeIds[0]; - if (!nodeId) return {}; - const interfaceName = options.interfacesByNode.get(nodeId)?.[0]; - return { - nodeId, - interfaceName - }; -} - -function createTrafficRateAnnotationId(existingIds: Iterable): string { - const usedIds = new Set(existingIds); - let nextIndex = usedIds.size + 1; - let candidate = `traffic-rate-${nextIndex}`; - while (usedIds.has(candidate)) { - nextIndex += 1; - candidate = `traffic-rate-${nextIndex}`; - } - return candidate; -} - -export function useTrafficRateAnnotations( - params: UseTrafficRateAnnotationsParams -): TrafficRateAnnotationActions { - const { isLocked, onLockedAction, derived, uiState, uiActions } = params; - const canEditAnnotations = !isLocked; - - const persist = useCallback(() => { - void saveAnnotationNodesFromGraph(); - }, []); - - const createTrafficRateAtPosition = useCallback( - (position: { x: number; y: number }) => { - if (!canEditAnnotations) { - onLockedAction(); - return; - } - - const parentGroup = findDeepestGroupAtPosition(position, derived.groups); - const defaults = resolveDefaultTarget(); - const annotation: TrafficRateAnnotation = { - id: createTrafficRateAnnotationId(derived.trafficRateAnnotations.map((entry) => entry.id)), - position, - nodeId: defaults.nodeId, - interfaceName: defaults.interfaceName, - mode: "chart", - textMetric: "combined", - width: 280, - height: 170, - backgroundOpacity: 20, - borderWidth: 1, - borderRadius: 8, - groupId: parentGroup?.id - }; - - derived.addTrafficRateAnnotation(annotation); - persist(); - uiActions.setEditingTrafficRateAnnotation(annotation); - }, - [ - canEditAnnotations, - onLockedAction, - derived.addTrafficRateAnnotation, - derived.groups, - derived.trafficRateAnnotations, - uiActions, - persist - ] - ); - - const editTrafficRateAnnotation = useCallback( - (id: string) => { - const annotation = derived.trafficRateAnnotations.find((entry) => entry.id === id); - if (!annotation) return; - uiActions.setEditingTrafficRateAnnotation(annotation); - }, - [derived.trafficRateAnnotations, uiActions] - ); - - const saveTrafficRateAnnotation = useCallback( - (annotation: TrafficRateAnnotation) => { - const isNew = !derived.trafficRateAnnotations.some((entry) => entry.id === annotation.id); - if (isNew) { - derived.addTrafficRateAnnotation(annotation); - } else { - derived.updateTrafficRateAnnotation(annotation.id, annotation); - } - persist(); - }, - [derived, persist] - ); - - const deleteTrafficRateAnnotation = useCallback( - (id: string) => { - derived.deleteTrafficRateAnnotation(id); - uiActions.removeFromTrafficRateSelection(id); - persist(); - }, - [derived, uiActions, persist] - ); - - const deleteSelectedTrafficRateAnnotations = useCallback(() => { - const ids = Array.from(uiState.selectedTrafficRateIds); - if (ids.length === 0) return; - for (const id of ids) { - derived.deleteTrafficRateAnnotation(id); - uiActions.removeFromTrafficRateSelection(id); - } - persist(); - }, [derived, uiActions, persist, uiState.selectedTrafficRateIds]); - - return useMemo( - () => ({ - createTrafficRateAtPosition, - editTrafficRateAnnotation, - saveTrafficRateAnnotation, - deleteTrafficRateAnnotation, - deleteSelectedTrafficRateAnnotations - }), - [ - createTrafficRateAtPosition, - editTrafficRateAnnotation, - saveTrafficRateAnnotation, - deleteTrafficRateAnnotation, - deleteSelectedTrafficRateAnnotations - ] - ); -} diff --git a/src/reactTopoViewer/webview/hooks/editor/formChangeTracking.ts b/src/reactTopoViewer/webview/hooks/editor/formChangeTracking.ts deleted file mode 100644 index fc834441a..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/formChangeTracking.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Dispatch, SetStateAction } from "react"; - -export function hasFormChanges(formData: T | null, initialData: T | null): boolean { - return formData !== null && initialData !== null - ? JSON.stringify(formData) !== JSON.stringify(initialData) - : false; -} - -export function discardFormChanges( - initialData: T | null, - setFormData: Dispatch> -): void { - if (initialData !== null) { - setFormData({ ...initialData }); - } -} diff --git a/src/reactTopoViewer/webview/hooks/editor/formState.ts b/src/reactTopoViewer/webview/hooks/editor/formState.ts deleted file mode 100644 index 5061495a7..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/formState.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Dispatch, SetStateAction } from "react"; - -export function applyFormUpdates( - readOnly: boolean, - setFormData: Dispatch>, - updates: Partial -): void { - if (readOnly) return; - setFormData((prev) => (prev ? { ...prev, ...updates } : null)); -} diff --git a/src/reactTopoViewer/webview/hooks/editor/index.ts b/src/reactTopoViewer/webview/hooks/editor/index.ts deleted file mode 100644 index 3376c7b4d..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Editor hooks (panels + editor data) - */ - -// Panel state -export { useLabSettingsState } from "./useLabSettings"; -export type { UseLabSettingsStateResult } from "./useLabSettings"; - -// Editor handlers -export { - useNodeEditorHandlers, - useLinkEditorHandlers, - useNetworkEditorHandlers, - useNodeCreationHandlers -} from "./useEditorHandlers"; -export type { NodeCreationState } from "./useEditorHandlers"; - -// Generic form utilities -export { useGenericFormState, useEditorHandlers } from "./useGenericFormState"; -export { useEditorHandlersWithFooterRef } from "./useEditorHandlersWithFooterRef"; - -// Custom node template editor -export { useCustomTemplateEditor } from "./useCustomTemplateEditor"; -export type { - CustomTemplateEditorHandlers, - CustomTemplateEditorResult -} from "./useCustomTemplateEditor"; - -// Editor form hooks (extracted from view components) -export { useNodeEditorForm, hasFieldChanged, YAML_TO_EDITOR_MAP } from "./useNodeEditorForm"; -export type { UseNodeEditorFormReturn } from "./useNodeEditorForm"; -export { useLinkEditorForm } from "./useLinkEditorForm"; -export type { UseLinkEditorFormReturn } from "./useLinkEditorForm"; -export { useNetworkEditorForm } from "./useNetworkEditorForm"; -export type { UseNetworkEditorFormReturn } from "./useNetworkEditorForm"; -export { useLinkImpairmentForm } from "./useLinkImpairmentForm"; -export type { UseLinkImpairmentFormReturn } from "./useLinkImpairmentForm"; - -// Editor data helpers -export { useSchema } from "./useSchema"; -export type { SrosComponentTypes } from "./useSchema"; -export { useDockerImages } from "./useDockerImages"; diff --git a/src/reactTopoViewer/webview/hooks/editor/useCustomTemplateEditor.ts b/src/reactTopoViewer/webview/hooks/editor/useCustomTemplateEditor.ts deleted file mode 100644 index 749604ab7..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/useCustomTemplateEditor.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Hook for managing custom node template editor state and handlers - */ -import { useMemo } from "react"; - -import type { CustomTemplateEditorData, NodeEditorData } from "../../../shared/types/editors"; -import { - convertCustomTemplateToEditorData, - convertEditorDataToSaveData, - convertEditorDataToTemplateData -} from "../../../shared/utilities/customNodeConversions"; -import { sendSaveCustomNode } from "../../messaging/extensionMessaging"; - -export interface CustomTemplateEditorHandlers { - handleClose: () => void; - handleSave: (data: NodeEditorData) => void; - handleApply: (data: NodeEditorData) => void; -} - -export interface CustomTemplateEditorResult { - editorData: NodeEditorData | null; - handlers: CustomTemplateEditorHandlers; -} - -/** - * Hook for custom template editor state and handlers - * - * @param editingCustomTemplate - Current custom template being edited (from context state) - * @param editCustomTemplate - Action to set/clear the editing template (from context) - * @returns Editor data converted for NodeEditorPanel and handlers for save/close/apply - */ -export function useCustomTemplateEditor( - editingCustomTemplate: CustomTemplateEditorData | null, - editCustomTemplate: (data: CustomTemplateEditorData | null) => void -): CustomTemplateEditorResult { - const editorData = useMemo(() => { - if (!editingCustomTemplate) return null; - return convertCustomTemplateToEditorData(editingCustomTemplate); - }, [editingCustomTemplate]); - - const handlers = useMemo( - () => ({ - handleClose: () => editCustomTemplate(null), - handleSave: (data: NodeEditorData) => { - const saveData = convertEditorDataToSaveData(data, editingCustomTemplate?.originalName); - sendSaveCustomNode(saveData); - editCustomTemplate(null); - }, - handleApply: (data: NodeEditorData) => { - const saveData = convertEditorDataToSaveData(data, editingCustomTemplate?.originalName); - sendSaveCustomNode(saveData); - // Update editingCustomTemplate with applied values to keep form in sync - // This prevents the form from resetting when custom-nodes-updated triggers a re-render - const updatedTemplate = convertEditorDataToTemplateData(data, editingCustomTemplate); - editCustomTemplate(updatedTemplate); - } - }), - [editingCustomTemplate, editCustomTemplate] - ); - - return { editorData, handlers }; -} diff --git a/src/reactTopoViewer/webview/hooks/editor/useDockerImages.ts b/src/reactTopoViewer/webview/hooks/editor/useDockerImages.ts deleted file mode 100644 index 66385d3ea..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/useDockerImages.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * useDockerImages - Hook to access docker images for image/version dropdowns - * - * Docker images are loaded by the extension and passed via window.__DOCKER_IMAGES__ - * Updates are received via the 'docker-images-updated' custom event - */ -import { useState, useEffect, useMemo, useCallback } from "react"; - -import { log } from "../../utils/logger"; - -declare global { - interface Window { - __DOCKER_IMAGES__?: string[]; - } -} - -interface ImageVersionMap { - baseImages: string[]; - versionsByImage: Map; -} - -interface UseDockerImagesResult { - dockerImages: string[]; - baseImages: string[]; - getVersionsForImage: (baseImage: string) => string[]; - parseImageString: (fullImage: string) => { base: string; version: string }; - combineImageVersion: (base: string, version: string) => string; - isLoaded: boolean; - hasImages: boolean; -} - -/** - * Sort versions (latest first, then reverse alphanumeric) - */ -function sortVersions(versions: string[]): void { - versions.sort((a, b) => { - if (a === "latest") return -1; - if (b === "latest") return 1; - return b.localeCompare(a); - }); -} - -/** - * Sort base images (Nokia first, then alphabetical) - */ -function sortBaseImages(images: string[]): string[] { - return images.sort((a, b) => { - const aIsNokia = a.includes("nokia"); - const bIsNokia = b.includes("nokia"); - if (aIsNokia && !bIsNokia) return -1; - if (!aIsNokia && bIsNokia) return 1; - return a.localeCompare(b); - }); -} - -/** - * Parse a single docker image string into base and version - */ -function parseImageTag(image: string, versionsByImage: Map): void { - const lastColonIndex = image.lastIndexOf(":"); - if (lastColonIndex > 0) { - const baseImage = image.substring(0, lastColonIndex); - const version = image.substring(lastColonIndex + 1); - if (!versionsByImage.has(baseImage)) { - versionsByImage.set(baseImage, []); - } - versionsByImage.get(baseImage)!.push(version); - } else if (!versionsByImage.has(image)) { - versionsByImage.set(image, ["latest"]); - } -} - -/** - * Parse docker images into base images and versions map - */ -function parseDockerImages(images: string[]): ImageVersionMap { - const versionsByImage = new Map(); - - for (const image of images) { - parseImageTag(image, versionsByImage); - } - - for (const versions of versionsByImage.values()) { - sortVersions(versions); - } - - const baseImages = sortBaseImages(Array.from(versionsByImage.keys())); - return { baseImages, versionsByImage }; -} - -/** - * Parse full image string into base and version components - */ -function splitImageString( - fullImage: string, - defaultBase: string -): { base: string; version: string } { - if (!fullImage) { - return { base: defaultBase, version: "latest" }; - } - const lastColonIndex = fullImage.lastIndexOf(":"); - if (lastColonIndex > 0) { - return { - base: fullImage.substring(0, lastColonIndex), - version: fullImage.substring(lastColonIndex + 1) - }; - } - return { base: fullImage, version: "latest" }; -} - -/** - * Combine base image and version into full image string - */ -function joinImageVersion(base: string, version: string): string { - return base ? `${base}:${version || "latest"}` : ""; -} - -/** - * Hook to access docker images with base/version parsing - */ -export function useDockerImages(): UseDockerImagesResult { - const [dockerImages, setDockerImages] = useState(() => window.__DOCKER_IMAGES__ ?? []); - - useEffect(() => { - const handleUpdate: EventListener = (event) => { - if (!(event instanceof CustomEvent) || !Array.isArray(event.detail)) { - return; - } - const images = event.detail.filter((item): item is string => typeof item === "string"); - log.info(`[useDockerImages] Received update with ${images.length} images`); - setDockerImages(images); - }; - window.addEventListener("docker-images-updated", handleUpdate); - return () => window.removeEventListener("docker-images-updated", handleUpdate); - }, []); - - const { baseImages, versionsByImage } = useMemo( - () => parseDockerImages(dockerImages), - [dockerImages] - ); - - const getVersionsForImage = useCallback( - (baseImage: string): string[] => versionsByImage.get(baseImage) ?? ["latest"], - [versionsByImage] - ); - - const parseImageString = useCallback( - (fullImage: string) => splitImageString(fullImage, baseImages[0] || ""), - [baseImages] - ); - - const combineImageVersion = useCallback(joinImageVersion, []); - - return { - dockerImages, - baseImages, - getVersionsForImage, - parseImageString, - combineImageVersion, - isLoaded: dockerImages.length > 0 || typeof window.__DOCKER_IMAGES__ !== "undefined", - hasImages: baseImages.length > 0 - }; -} diff --git a/src/reactTopoViewer/webview/hooks/editor/useEditorHandlers.ts b/src/reactTopoViewer/webview/hooks/editor/useEditorHandlers.ts deleted file mode 100644 index bf9df12e6..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/useEditorHandlers.ts +++ /dev/null @@ -1,1108 +0,0 @@ -/** - * Editor handler hooks for node, link, and network editors. - */ -import React from "react"; -import type { ReactFlowInstance } from "@xyflow/react"; - -import type { - CustomNodeTemplate, - LinkEditorData, - NetworkEditorData, - NodeEditorData -} from "../../../shared/types/editors"; -import type { EdgeAnnotation, NodeAnnotation } from "../../../shared/types/topology"; -import { - convertEditorDataToYaml, - convertEditorDataToNodeSaveData, - convertNetworkEditorDataToYaml -} from "../../../shared/utilities"; -import { - executeTopologyCommand, - saveEdgeAnnotations, - buildNetworkNodeAnnotations -} from "../../services"; -import { requestSnapshot } from "../../services/topologyHostClient"; -import { useGraphStore } from "../../stores/graphStore"; -import { - findEdgeAnnotation, - upsertEdgeLabelOffsetAnnotation -} from "../../annotations/edgeAnnotations"; -import { convertEditorDataToLinkSaveData } from "../../utils/linkEditorConversions"; -import { BRIDGE_NETWORK_TYPES, getNetworkType } from "../../utils/networkNodeTypes"; -import { getViewportCenter } from "../../utils/viewportUtils"; - -// ============================================================================ -// Types -// ============================================================================ - -interface EdgeAnnotationHandlers { - edgeAnnotations: EdgeAnnotation[]; - setEdgeAnnotations: (annotations: EdgeAnnotation[]) => void; -} - -/** State shape for node creation handlers */ -export interface NodeCreationState { - isLocked: boolean; - customNodes: CustomNodeTemplate[]; - defaultNode: string; -} - -/** Position type */ -type Position = { x: number; y: number }; - -/** Callback to rename a node in the graph state */ -type RenameNodeCallback = (oldId: string, newId: string, nameOverride?: string) => void; - -/** Callback to update node extraData in graph state */ -type UpdateNodeDataCallback = (nodeId: string, extraData: Record) => void; - -type BasicNode = { id: string; data?: unknown; position?: { x: number; y: number } }; -type BasicEdge = { id: string; source: string; target: string; data?: unknown }; -type AliasEdgeInfo = { edge: BasicEdge; interfaceName?: string }; -type GraphState = ReturnType; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function toRecord(value: unknown): Record | undefined { - return isRecord(value) ? value : undefined; -} - -function toOptionalNumber(value: string | undefined): number | undefined { - if (value == null || value.length === 0) return undefined; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function getEdgeData(edge: BasicEdge): { - sourceEndpoint?: string; - targetEndpoint?: string; - extraData?: Record; -} { - const data = toRecord(edge.data); - return { - sourceEndpoint: typeof data?.sourceEndpoint === "string" ? data.sourceEndpoint : undefined, - targetEndpoint: typeof data?.targetEndpoint === "string" ? data.targetEndpoint : undefined, - extraData: toRecord(data?.extraData) - }; -} - -function getNodeExtraData(node: { data?: unknown } | undefined): Record { - const data = toRecord(node?.data); - const extraData = toRecord(data?.extraData); - return extraData ? { ...extraData } : {}; -} - -// ============================================================================ -// Shared Helper Functions -// ============================================================================ - -function updateNodeExtraData(data: NodeEditorData): Record { - const recordData: Record = {}; - for (const [key, value] of Object.entries(data)) { - recordData[key] = value; - } - const yamlExtraData = convertEditorDataToYaml(recordData); - const newExtraData: Record = { ...yamlExtraData }; - for (const key of Object.keys(newExtraData)) { - if (newExtraData[key] === null) { - delete newExtraData[key]; - } - } - // Include annotation properties for visual updates (icon, color, corner radius) - // These are needed by graphStore.updateNodeData to update the canvas rendering - if (data.icon !== undefined) { - newExtraData.topoViewerRole = data.icon; - } - if (data.iconColor !== undefined) { - newExtraData.iconColor = data.iconColor; - } - if (data.iconCornerRadius !== undefined) { - newExtraData.iconCornerRadius = data.iconCornerRadius; - } - if (data.labelPosition !== undefined) { - newExtraData.labelPosition = data.labelPosition; - } - if (data.direction !== undefined) { - newExtraData.direction = data.direction; - } - if ("labelBackgroundColor" in data) { - newExtraData.labelBackgroundColor = data.labelBackgroundColor; - } - return newExtraData; -} - -function updateNodeVisualPreview( - nodeId: string, - labelPosition: string | undefined, - direction: string | undefined, - labelBackgroundColor: string | undefined -): void { - const graphState = useGraphStore.getState(); - const node = graphState.nodes.find((entry) => entry.id === nodeId); - if (!node) return; - const currentData = node.data; - if ( - currentData.labelPosition === labelPosition && - currentData.direction === direction && - currentData.labelBackgroundColor === labelBackgroundColor - ) { - return; - } - graphState.updateNode(nodeId, { - data: { - labelPosition, - direction, - labelBackgroundColor - } - }); -} - -function applyNodeChanges( - data: NodeEditorData, - oldName: string | undefined, - deps: { - renameNode?: RenameNodeCallback; - updateNodeData?: UpdateNodeDataCallback; - refreshEditorData?: () => void; - } -): void { - const { renameNode, updateNodeData, refreshEditorData } = deps; - const hasOldName = oldName != null && oldName.length > 0; - if (hasOldName && renameNode) { - renameNode(oldName, data.name, data.name); - } - - if (updateNodeData) { - const nodeIdForUpdate = hasOldName ? data.name : data.id; - updateNodeData(nodeIdForUpdate, updateNodeExtraData(data)); - } - - refreshEditorData?.(); -} - -function needsDefaultCleanup(data: NodeEditorData | null): boolean { - if (!data) return false; - return ( - data.autoRemove === false || - data.enforceStartupConfig === false || - data.suppressStartupConfig === false || - data.startupDelay === 0 - ); -} - -function isBridgeAliasCandidate( - data: NetworkEditorData, - newNodeId: string, - nodes: BasicNode[] -): boolean { - if (!BRIDGE_NETWORK_TYPES.has(data.networkType)) return false; - if (!newNodeId || newNodeId === data.id) return false; - const baseNode = nodes.find((node) => node.id === newNodeId); - if (!baseNode) return false; - const baseType = getNetworkType(toRecord(baseNode.data) ?? {}); - return baseType != null && baseType.length > 0 && BRIDGE_NETWORK_TYPES.has(baseType); -} - -function collectAliasEdgeInfos(edges: BasicEdge[], aliasId: string): AliasEdgeInfo[] { - const edgeInfos: AliasEdgeInfo[] = []; - for (const edge of edges) { - if (edge.source !== aliasId && edge.target !== aliasId) continue; - const edgeData = getEdgeData(edge); - const interfaceName = - edge.source === aliasId ? edgeData.sourceEndpoint : edgeData.targetEndpoint; - edgeInfos.push({ edge, interfaceName }); - } - return edgeInfos; -} - -function extractInterfaceCandidates(edgeInfos: AliasEdgeInfo[]): { - interfaceSet: Set; - interfaceCandidates: string[]; -} { - const interfaceSet = new Set(); - for (const info of edgeInfos) { - if (typeof info.interfaceName === "string" && info.interfaceName.length > 0) { - interfaceSet.add(info.interfaceName); - } - } - return { interfaceSet, interfaceCandidates: Array.from(interfaceSet) }; -} - -function resolvePrimaryInterface( - existingInterface: string | undefined, - interfaceSet: Set, - interfaceCandidates: string[] -): string | undefined { - if ( - existingInterface != null && - existingInterface.length > 0 && - (interfaceSet.has(existingInterface) || interfaceSet.size === 0) - ) { - return existingInterface; - } - return interfaceCandidates[0]; -} - -function resolveYamlEndpoint( - extra: Record | undefined, - key: "yamlSourceNodeId" | "yamlTargetNodeId", - aliasAlreadyMapped: boolean, - aliasMatches: boolean, - newNodeId: string, - fallback: string -): string { - const value = extra?.[key]; - if (typeof value === "string" && value.length > 0) { - return value; - } - if (aliasAlreadyMapped && aliasMatches) { - return newNodeId; - } - return fallback; -} - -function buildAliasLinkCommand( - info: AliasEdgeInfo, - aliasId: string, - newNodeId: string, - aliasAlreadyMapped: boolean -) { - const edgeData = getEdgeData(info.edge); - const extra = edgeData.extraData; - const yamlSource = resolveYamlEndpoint( - extra, - "yamlSourceNodeId", - aliasAlreadyMapped, - info.edge.source === aliasId, - newNodeId, - info.edge.source - ); - const yamlTarget = resolveYamlEndpoint( - extra, - "yamlTargetNodeId", - aliasAlreadyMapped, - info.edge.target === aliasId, - newNodeId, - info.edge.target - ); - const nextSource = info.edge.source === aliasId ? newNodeId : info.edge.source; - const nextTarget = info.edge.target === aliasId ? newNodeId : info.edge.target; - return { - command: "editLink" as const, - payload: { - id: info.edge.id, - source: nextSource, - target: nextTarget, - sourceEndpoint: edgeData.sourceEndpoint, - targetEndpoint: edgeData.targetEndpoint, - extraData: sanitizeLinkExtraData(extra), - originalSource: yamlSource, - originalTarget: yamlTarget, - originalSourceEndpoint: edgeData.sourceEndpoint, - originalTargetEndpoint: edgeData.targetEndpoint - } - }; -} - -function updateAliasNodeInGraph( - graphState: GraphState, - aliasId: string, - aliasLabel: string, - data: NetworkEditorData, - newNodeId: string -): BasicNode | undefined { - const aliasNode = graphState.nodes.find((node) => node.id === aliasId) as BasicNode | undefined; - const existingExtra = getNodeExtraData(aliasNode); - const nextExtra = { - ...existingExtra, - ...convertNetworkEditorDataToYaml(data), - extYamlNodeId: newNodeId - }; - graphState.updateNode(aliasId, { data: { label: aliasLabel, name: aliasLabel } }); - graphState.updateNodeData(aliasId, nextExtra); - return aliasNode; -} - -function buildUpdatedAliasAnnotations( - nodeAnnotations: NodeAnnotation[], - existingAnn: NodeAnnotation | undefined, - aliasId: string, - newNodeId: string, - primaryInterface: string, - aliasLabel: string, - aliasPosition?: { x: number; y: number } -): NodeAnnotation[] { - const updatedAnnotations: NodeAnnotation[] = nodeAnnotations.filter((ann) => ann.id !== aliasId); - const aliasAnnotation: NodeAnnotation = { - ...(existingAnn ?? { id: aliasId }), - id: aliasId, - yamlNodeId: newNodeId, - yamlInterface: primaryInterface, - label: aliasLabel - }; - if (!aliasAnnotation.position && aliasPosition) { - aliasAnnotation.position = aliasPosition; - } - updatedAnnotations.push(aliasAnnotation); - return updatedAnnotations; -} - -function updateAliasYamlIds( - info: AliasEdgeInfo, - extra: Record, - aliasId: string, - newNodeId: string, - shouldStayOnAlias: boolean -): void { - if (info.edge.source === aliasId) { - if (shouldStayOnAlias) { - extra.yamlSourceNodeId = newNodeId; - } else { - delete extra.yamlSourceNodeId; - } - } - if (info.edge.target === aliasId) { - if (shouldStayOnAlias) { - extra.yamlTargetNodeId = newNodeId; - } else { - delete extra.yamlTargetNodeId; - } - } -} - -function buildAliasEdgeUpdate( - info: AliasEdgeInfo, - aliasId: string, - newNodeId: string, - primaryInterface: string -): { - edgeData: Record; - extra: Record; - nextSource: string; - nextTarget: string; -} { - const edgeData = toRecord(info.edge.data) ?? {}; - const extra: Record = {}; - const existingExtra = toRecord(edgeData.extraData); - if (existingExtra) { - Object.assign(extra, existingExtra); - } - const shouldStayOnAlias = info.interfaceName === primaryInterface; - const nextSource = - !shouldStayOnAlias && info.edge.source === aliasId ? newNodeId : info.edge.source; - const nextTarget = - !shouldStayOnAlias && info.edge.target === aliasId ? newNodeId : info.edge.target; - updateAliasYamlIds(info, extra, aliasId, newNodeId, shouldStayOnAlias); - return { edgeData, extra, nextSource, nextTarget }; -} - -function updateGraphEdgesForAlias( - graphState: GraphState, - edgeInfos: AliasEdgeInfo[], - aliasId: string, - newNodeId: string, - primaryInterface: string -): void { - for (const info of edgeInfos) { - const { edgeData, extra, nextSource, nextTarget } = buildAliasEdgeUpdate( - info, - aliasId, - newNodeId, - primaryInterface - ); - graphState.updateEdge(info.edge.id, { - source: nextSource, - target: nextTarget, - data: { ...edgeData, extraData: extra } - }); - } -} - -// ============================================================================ -// useNodeEditorHandlers -// ============================================================================ - -export function useNodeEditorHandlers( - editNode: (id: string | null) => void, - editingNodeData: NodeEditorData | null, - renameNode?: RenameNodeCallback, - updateNodeData?: UpdateNodeDataCallback, - refreshEditorData?: () => void -) { - const initialDataRef = React.useRef(null); - - React.useEffect(() => { - const previous = initialDataRef.current; - if (previous && previous.id !== editingNodeData?.id) { - updateNodeVisualPreview( - previous.id, - previous.labelPosition, - previous.direction, - previous.labelBackgroundColor - ); - } - if (editingNodeData) { - initialDataRef.current = { ...editingNodeData }; - } else { - initialDataRef.current = null; - } - }, [editingNodeData?.id]); - - const handleClose = React.useCallback(() => { - const initialData = initialDataRef.current; - if (initialData) { - updateNodeVisualPreview( - initialData.id, - initialData.labelPosition, - initialData.direction, - initialData.labelBackgroundColor - ); - } - initialDataRef.current = null; - editNode(null); - }, [editNode]); - - const persistDeps = React.useMemo( - () => ({ renameNode, updateNodeData, refreshEditorData }), - [renameNode, updateNodeData, refreshEditorData] - ); - - const handleSave = React.useCallback( - (data: NodeEditorData) => { - const beforeData = initialDataRef.current; - const hasChanges = beforeData ? JSON.stringify(beforeData) !== JSON.stringify(data) : true; - if (!hasChanges && !needsDefaultCleanup(data)) { - editNode(null); - return; - } - - const oldName = beforeData?.name !== data.name ? beforeData?.name : undefined; - applyNodeChanges(data, oldName, persistDeps); - - const saveData = convertEditorDataToNodeSaveData(data, oldName); - void executeTopologyCommand({ command: "editNode", payload: saveData }); - - initialDataRef.current = null; - editNode(null); - }, - [editNode, persistDeps] - ); - - const handleApply = React.useCallback( - (data: NodeEditorData) => { - const beforeData = initialDataRef.current; - const hasChanges = beforeData ? JSON.stringify(beforeData) !== JSON.stringify(data) : true; - if (!hasChanges && !needsDefaultCleanup(data)) return; - - const oldName = beforeData?.name !== data.name ? beforeData?.name : undefined; - applyNodeChanges(data, oldName, persistDeps); - - const saveData = convertEditorDataToNodeSaveData(data, oldName); - void executeTopologyCommand({ command: "editNode", payload: saveData }); - - initialDataRef.current = { ...data }; - }, - [persistDeps] - ); - - const previewVisuals = React.useCallback((data: NodeEditorData) => { - const initialData = initialDataRef.current; - if (!initialData) return; - updateNodeVisualPreview( - initialData.id, - data.labelPosition, - data.direction, - data.labelBackgroundColor - ); - }, []); - - return { handleClose, handleSave, handleApply, previewVisuals }; -} - -// ============================================================================ -// useLinkEditorHandlers -// ============================================================================ - -/** Dependencies for persisting link editor changes */ -interface LinkPersistDeps { - edgeAnnotationHandlers?: EdgeAnnotationHandlers; - updateEdgeData?: (edgeId: string, data: LinkEditorData) => void; -} - -function enableLinkEndpointOffset(data: LinkEditorData): LinkEditorData { - if (data.endpointLabelOffsetEnabled === true) return data; - return { ...data, endpointLabelOffsetEnabled: true }; -} - -function stripLinkOffsetFields( - data: LinkEditorData -): Omit { - const { - endpointLabelOffset: _endpointLabelOffset, - endpointLabelOffsetEnabled: _endpointLabelOffsetEnabled, - ...rest - } = data; - return rest; -} - -function isOffsetOnlyChange(before: LinkEditorData | null, after: LinkEditorData): boolean { - if (!before) return false; - return ( - JSON.stringify(stripLinkOffsetFields(before)) === JSON.stringify(stripLinkOffsetFields(after)) - ); -} - -function mergeOffsetBaseline( - current: LinkEditorData | null, - next: LinkEditorData -): LinkEditorData | null { - if (!current) return current; - return { - ...current, - endpointLabelOffset: next.endpointLabelOffset, - endpointLabelOffsetEnabled: next.endpointLabelOffsetEnabled - }; -} - -function applyLinkChanges(data: LinkEditorData, deps: LinkPersistDeps): void { - const { updateEdgeData } = deps; - const saveData = convertEditorDataToLinkSaveData(data); - if (updateEdgeData) { - updateEdgeData(data.id, { - ...data, - source: saveData.source, - target: saveData.target, - sourceEndpoint: saveData.sourceEndpoint ?? data.sourceEndpoint, - targetEndpoint: saveData.targetEndpoint ?? data.targetEndpoint - }); - } -} - -export function useLinkEditorHandlers( - editEdge: (id: string | null) => void, - editingLinkData: LinkEditorData | null, - edgeAnnotationHandlers?: EdgeAnnotationHandlers, - updateEdgeData?: (edgeId: string, data: LinkEditorData) => void -) { - const initialDataRef = React.useRef(null); - - React.useEffect(() => { - if (editingLinkData) { - initialDataRef.current = { ...editingLinkData }; - } else { - initialDataRef.current = null; - } - }, [editingLinkData?.id]); - - const persistOffset = React.useCallback( - (data: LinkEditorData) => { - if (!edgeAnnotationHandlers) return; - const next = upsertEdgeLabelOffsetAnnotation(edgeAnnotationHandlers.edgeAnnotations, data); - if (!next) return; - edgeAnnotationHandlers.setEdgeAnnotations(next); - void saveEdgeAnnotations(next); - }, - [edgeAnnotationHandlers] - ); - - const handleClose = React.useCallback(() => { - initialDataRef.current = null; - editEdge(null); - }, [editEdge]); - - const persistDeps = React.useMemo( - () => ({ edgeAnnotationHandlers, updateEdgeData }), - [edgeAnnotationHandlers, updateEdgeData] - ); - - const handleSave = React.useCallback( - (data: LinkEditorData) => { - const beforeData = initialDataRef.current; - const normalized = enableLinkEndpointOffset(data); - const hasChanges = beforeData - ? JSON.stringify(beforeData) !== JSON.stringify(normalized) - : true; - if (!hasChanges) { - editEdge(null); - return; - } - - if (isOffsetOnlyChange(beforeData, normalized)) { - persistOffset(normalized); - initialDataRef.current = null; - editEdge(null); - return; - } - - applyLinkChanges(normalized, persistDeps); - const saveData = convertEditorDataToLinkSaveData(normalized); - void executeTopologyCommand({ command: "editLink", payload: saveData }); - - if (edgeAnnotationHandlers) { - const existing = findEdgeAnnotation(edgeAnnotationHandlers.edgeAnnotations, normalized); - const shouldUpdate = - existing != null && - (normalized.endpointLabelOffset !== existing.endpointLabelOffset || - normalized.endpointLabelOffsetEnabled !== existing.endpointLabelOffsetEnabled); - if (shouldUpdate) { - persistOffset(normalized); - } - } - - initialDataRef.current = null; - editEdge(null); - }, - [editEdge, persistDeps, persistOffset, edgeAnnotationHandlers] - ); - - const handleApply = React.useCallback( - (data: LinkEditorData) => { - const beforeData = initialDataRef.current; - const normalized = enableLinkEndpointOffset(data); - const hasChanges = beforeData - ? JSON.stringify(beforeData) !== JSON.stringify(normalized) - : true; - if (!hasChanges) return; - - if (isOffsetOnlyChange(beforeData, normalized)) { - persistOffset(normalized); - initialDataRef.current = mergeOffsetBaseline(initialDataRef.current, normalized); - return; - } - - applyLinkChanges(normalized, persistDeps); - const saveData = convertEditorDataToLinkSaveData(normalized); - void executeTopologyCommand({ command: "editLink", payload: saveData }); - - initialDataRef.current = { ...normalized }; - }, - [persistDeps, persistOffset] - ); - - // Visual-only offset preview (no persist) - const previewOffset = React.useCallback( - (data: LinkEditorData) => { - if (!edgeAnnotationHandlers) return; - const normalized = enableLinkEndpointOffset(data); - const next = upsertEdgeLabelOffsetAnnotation( - edgeAnnotationHandlers.edgeAnnotations, - normalized - ); - if (!next) return; - edgeAnnotationHandlers.setEdgeAnnotations(next); - }, - [edgeAnnotationHandlers] - ); - - // Revert offset to initial state (for discard / unmount) - const revertOffset = React.useCallback(() => { - if (!edgeAnnotationHandlers || !initialDataRef.current) return; - const next = upsertEdgeLabelOffsetAnnotation( - edgeAnnotationHandlers.edgeAnnotations, - initialDataRef.current - ); - if (!next) return; - edgeAnnotationHandlers.setEdgeAnnotations(next); - }, [edgeAnnotationHandlers]); - - return { handleClose, handleSave, handleApply, previewOffset, revertOffset }; -} - -// ============================================================================ -// useNetworkEditorHandlers -// ============================================================================ - -/** VXLAN types that need link property updates */ -const VXLAN_NETWORK_TYPES = new Set(["vxlan", "vxlan-stitch"]); - -/** Host-like types that have host-interface property */ -const HOST_INTERFACE_TYPES = new Set(["host", "mgmt-net", "macvlan"]); - -/** Network types that are stored as link types (not YAML nodes) */ -const LINK_BASED_NETWORK_TYPES = new Set([ - "host", - "mgmt-net", - "macvlan", - "vxlan", - "vxlan-stitch", - "dummy" -]); - -/** Bridge types that are stored as YAML nodes */ -function calculateExpectedNodeId(data: NetworkEditorData): string { - if (data.networkType === "host") { - return `host:${data.interfaceName || "eth0"}`; - } - if (data.networkType === "mgmt-net") { - return `mgmt-net:${data.interfaceName || "net0"}`; - } - if (data.networkType === "macvlan") { - return `macvlan:${data.interfaceName || "eth1"}`; - } - if (BRIDGE_NETWORK_TYPES.has(data.networkType)) { - return data.interfaceName || data.id; - } - return data.id; -} - -function applyVxlanFields(extraData: Record, data: NetworkEditorData): void { - if (!VXLAN_NETWORK_TYPES.has(data.networkType)) return; - Object.assign(extraData, { - extRemote: data.vxlanRemote ?? undefined, - extVni: toOptionalNumber(data.vxlanVni), - extDstPort: toOptionalNumber(data.vxlanDstPort), - extSrcPort: toOptionalNumber(data.vxlanSrcPort) - }); -} - -function applyHostInterfaceFields( - extraData: Record, - data: NetworkEditorData -): void { - if (!HOST_INTERFACE_TYPES.has(data.networkType)) return; - extraData.extHostInterface = data.interfaceName || undefined; - extraData.extMode = data.networkType === "macvlan" ? (data.macvlanMode ?? undefined) : undefined; -} - -function applyCommonNetworkFields( - extraData: Record, - data: NetworkEditorData -): void { - Object.assign(extraData, { - extMtu: toOptionalNumber(data.mtu), - extMac: data.mac ?? undefined, - extVars: data.vars && Object.keys(data.vars).length > 0 ? data.vars : undefined, - extLabels: data.labels && Object.keys(data.labels).length > 0 ? data.labels : undefined - }); -} - -function buildNetworkExtraData(data: NetworkEditorData): Record { - const extraData: Record = { extType: data.networkType }; - applyVxlanFields(extraData, data); - applyHostInterfaceFields(extraData, data); - applyCommonNetworkFields(extraData, data); - return extraData; -} - -function sanitizeLinkExtraData( - extraData?: Record -): Record | undefined { - if (!extraData) return undefined; - const cleaned = { ...extraData }; - delete cleaned.yamlSourceNodeId; - delete cleaned.yamlTargetNodeId; - return Object.keys(cleaned).length > 0 ? cleaned : undefined; -} - -function getConnectedNetworkEdges(edges: BasicEdge[], networkNodeId: string): BasicEdge[] { - return edges.filter((edge) => edge.source === networkNodeId || edge.target === networkNodeId); -} - -export function useNetworkEditorHandlers( - editNetwork: (id: string | null) => void, - editingNetworkData: NetworkEditorData | null, - renameNode?: RenameNodeCallback -) { - const initialDataRef = React.useRef(null); - const getCurrentEdges = React.useCallback(() => useGraphStore.getState().edges, []); - const getCurrentNodes = React.useCallback(() => useGraphStore.getState().nodes, []); - - React.useEffect(() => { - if (editingNetworkData) { - initialDataRef.current = { ...editingNetworkData }; - } else { - initialDataRef.current = null; - } - }, [editingNetworkData?.id]); - - const handleClose = React.useCallback(() => { - initialDataRef.current = null; - editNetwork(null); - }, [editNetwork]); - - const applyGraphUpdates = React.useCallback( - (data: NetworkEditorData, newNodeId: string) => { - let label = data.label || newNodeId; - if (HOST_INTERFACE_TYPES.has(data.networkType)) { - const isDefaultLabel = !data.label || data.label === data.id; - if (isDefaultLabel) { - label = newNodeId; - } - } - if (data.id !== newNodeId && renameNode) { - renameNode(data.id, newNodeId, label); - } - const graphState = useGraphStore.getState(); - const updateNode = graphState.updateNode; - const updateNodeData = graphState.updateNodeData; - const existingNode = - graphState.nodes.find((node) => node.id === newNodeId) ?? - graphState.nodes.find((node) => node.id === data.id); - const existingExtra = getNodeExtraData(existingNode); - - let nextExtraData: Record; - if (BRIDGE_NETWORK_TYPES.has(data.networkType)) { - // Preserve bridge metadata (e.g., extYamlNodeId) while updating kind. - nextExtraData = { ...existingExtra, ...convertNetworkEditorDataToYaml(data) }; - } else if (LINK_BASED_NETWORK_TYPES.has(data.networkType)) { - // Mirror main-branch behavior: keep only network link properties on the node. - nextExtraData = buildNetworkExtraData(data); - } else { - nextExtraData = convertNetworkEditorDataToYaml(data); - } - - updateNode(newNodeId, { data: { label, name: label } }); - updateNodeData(newNodeId, nextExtraData); - }, - [renameNode] - ); - - const persistLinkBasedNetwork = React.useCallback( - async (data: NetworkEditorData, newNodeId: string, connectedEdges: BasicEdge[]) => { - if (!LINK_BASED_NETWORK_TYPES.has(data.networkType)) return; - - const extraData = buildNetworkExtraData(data); - const linkCommands = connectedEdges.map((edge) => { - const edgeData = getEdgeData(edge); - const sourceEndpoint = edgeData.sourceEndpoint; - const targetEndpoint = edgeData.targetEndpoint; - const nextSource = edge.source === data.id ? newNodeId : edge.source; - const nextTarget = edge.target === data.id ? newNodeId : edge.target; - return { - command: "editLink" as const, - payload: { - id: edge.id, - source: nextSource, - target: nextTarget, - sourceEndpoint, - targetEndpoint, - extraData, - originalSource: edge.source, - originalTarget: edge.target, - originalSourceEndpoint: sourceEndpoint, - originalTargetEndpoint: targetEndpoint - } - }; - }); - - const graphNodes = useGraphStore.getState().nodes; - const networkNodeAnnotations = buildNetworkNodeAnnotations(graphNodes); - const commands = [ - ...linkCommands, - { - command: "setAnnotations" as const, - payload: { networkNodeAnnotations } - } - ]; - - if (commands.length > 0) { - await executeTopologyCommand( - { command: "batch", payload: { commands } }, - { applySnapshot: false } - ); - } - }, - [] - ); - - const persistBridgeNetwork = React.useCallback((data: NetworkEditorData, newNodeId: string) => { - if (!BRIDGE_NETWORK_TYPES.has(data.networkType)) return; - - const trimmedLabel = data.label.trim(); - const saveData = { - id: data.id, - name: newNodeId, - extraData: { - kind: data.networkType, - label: trimmedLabel.length > 0 ? trimmedLabel : null - } - }; - void executeTopologyCommand({ command: "editNode", payload: saveData }); - }, []); - - const persistBridgeAlias = React.useCallback( - async (data: NetworkEditorData, newNodeId: string) => { - const currentNodes = getCurrentNodes(); - if (!isBridgeAliasCandidate(data, newNodeId, currentNodes as BasicNode[])) return false; - - const aliasId = data.id; - const currentEdges = getCurrentEdges(); - const edgeInfos = collectAliasEdgeInfos(currentEdges as BasicEdge[], aliasId); - const { interfaceSet, interfaceCandidates } = extractInterfaceCandidates(edgeInfos); - - const snapshot = await requestSnapshot(); - const annotations = snapshot.annotations; - const nodeAnnotations = [...(annotations.nodeAnnotations ?? [])]; - const existingAnn = nodeAnnotations.find((ann) => ann.id === aliasId); - const existingInterface = - typeof existingAnn?.yamlInterface === "string" && existingAnn.yamlInterface.trim() - ? existingAnn.yamlInterface.trim() - : undefined; - const aliasAlreadyMapped = - existingAnn?.yamlNodeId === newNodeId && Boolean(existingInterface); - const primaryInterface = resolvePrimaryInterface( - existingInterface, - interfaceSet, - interfaceCandidates - ); - - if (primaryInterface == null || primaryInterface.length === 0) { - return false; - } - - const graphState = useGraphStore.getState(); - const trimmedLabel = data.label.trim(); - const aliasLabel = trimmedLabel.length > 0 ? trimmedLabel : aliasId; - const aliasNode = updateAliasNodeInGraph(graphState, aliasId, aliasLabel, data, newNodeId); - const updatedAnnotations = buildUpdatedAliasAnnotations( - nodeAnnotations, - existingAnn, - aliasId, - newNodeId, - primaryInterface, - aliasLabel, - aliasNode?.position - ); - - const linkCommands = edgeInfos.map((info) => - buildAliasLinkCommand(info, aliasId, newNodeId, aliasAlreadyMapped) - ); - - const aliasCommands = [ - ...linkCommands, - { command: "deleteNode" as const, payload: { id: aliasId } }, - { command: "setAnnotations" as const, payload: { nodeAnnotations: updatedAnnotations } } - ]; - - await executeTopologyCommand( - { command: "batch", payload: { commands: aliasCommands } }, - { applySnapshot: false } - ); - - updateGraphEdgesForAlias(graphState, edgeInfos, aliasId, newNodeId, primaryInterface); - - return true; - }, - [getCurrentEdges, getCurrentNodes] - ); - - const persistNetworkEdits = React.useCallback( - async (data: NetworkEditorData, closeAfterSave: boolean) => { - const beforeData = initialDataRef.current; - const hasChanges = beforeData ? JSON.stringify(beforeData) !== JSON.stringify(data) : true; - if (!hasChanges) { - if (closeAfterSave) { - editNetwork(null); - } - return; - } - - const newNodeId = calculateExpectedNodeId(data); - const preRenameConnectedEdges = LINK_BASED_NETWORK_TYPES.has(data.networkType) - ? getConnectedNetworkEdges(getCurrentEdges() as BasicEdge[], data.id) - : []; - - if (BRIDGE_NETWORK_TYPES.has(data.networkType)) { - const aliasHandled = await persistBridgeAlias(data, newNodeId); - if (aliasHandled) { - if (closeAfterSave) { - initialDataRef.current = null; - editNetwork(null); - return; - } - initialDataRef.current = { ...data }; - return; - } - } - - applyGraphUpdates(data, newNodeId); - - if (LINK_BASED_NETWORK_TYPES.has(data.networkType)) { - await persistLinkBasedNetwork(data, newNodeId, preRenameConnectedEdges); - } else if (BRIDGE_NETWORK_TYPES.has(data.networkType)) { - persistBridgeNetwork(data, newNodeId); - } - - if (closeAfterSave) { - initialDataRef.current = null; - editNetwork(null); - return; - } - - initialDataRef.current = { ...data }; - }, - [ - editNetwork, - getCurrentEdges, - applyGraphUpdates, - persistLinkBasedNetwork, - persistBridgeNetwork, - persistBridgeAlias - ] - ); - - const handleSave = React.useCallback( - (data: NetworkEditorData) => { - return persistNetworkEdits(data, true); - }, - [persistNetworkEdits] - ); - - const handleApply = React.useCallback( - (data: NetworkEditorData) => { - return persistNetworkEdits(data, false); - }, - [persistNetworkEdits] - ); - - return { handleClose, handleSave, handleApply }; -} - -// ============================================================================ -// useNodeCreationHandlers -// ============================================================================ - -export function useNodeCreationHandlers( - onLockedAction: (() => void) | undefined, - state: NodeCreationState, - rfInstance: ReactFlowInstance | null, - createNodeAtPosition: (position: Position, template?: CustomNodeTemplate) => void, - onNewCustomNode: () => void -) { - const handleAddNodeFromPanel = React.useCallback( - (templateName?: string) => { - if (templateName === "__new__") { - onNewCustomNode(); - return; - } - - if (state.isLocked) { - onLockedAction?.(); - return; - } - - let template: CustomNodeTemplate | undefined; - if (templateName != null && templateName.length > 0) { - template = state.customNodes.find((n) => n.name === templateName); - } else if (state.defaultNode.length > 0) { - template = state.customNodes.find((n) => n.name === state.defaultNode); - } - - const position = getViewportCenter(rfInstance); - createNodeAtPosition(position, template); - }, - [ - state.isLocked, - state.customNodes, - state.defaultNode, - createNodeAtPosition, - onLockedAction, - onNewCustomNode, - rfInstance - ] - ); - - return { handleAddNodeFromPanel }; -} diff --git a/src/reactTopoViewer/webview/hooks/editor/useEditorHandlersWithFooterRef.ts b/src/reactTopoViewer/webview/hooks/editor/useEditorHandlersWithFooterRef.ts deleted file mode 100644 index e2c76959a..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/useEditorHandlersWithFooterRef.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useFooterControlsRef, type FooterControlsRef } from "../ui"; - -import { useEditorHandlers } from "./useGenericFormState"; - -interface UseEditorHandlersWithFooterRefOptions { - formData: T | null; - onSave: (data: T) => void; - onClose: () => void; - onDelete?: (id: string) => void; - resetInitialData: () => void; - discardChanges?: () => void; - canSave?: (data: T) => boolean; - - onFooterRef?: (ref: FooterControlsRef | null) => void; - hasChangesForFooter: boolean; -} - -/** - * Wraps the standard editor handlers with context-panel footer wiring. - * This keeps per-view components smaller and avoids clone-heavy boilerplate. - */ -export function useEditorHandlersWithFooterRef( - options: UseEditorHandlersWithFooterRefOptions -) { - const { - formData, - onSave, - onClose, - onDelete, - resetInitialData, - discardChanges, - canSave, - onFooterRef, - hasChangesForFooter - } = options; - - const handlers = useEditorHandlers({ - formData, - onSave, - onClose, - onDelete, - resetInitialData, - discardChanges, - canSave - }); - - useFooterControlsRef( - onFooterRef, - Boolean(formData), - handlers.handleApply, - handlers.handleSaveAndClose, - hasChangesForFooter, - handlers.handleDiscard - ); - - return handlers; -} diff --git a/src/reactTopoViewer/webview/hooks/editor/useGenericFormState.ts b/src/reactTopoViewer/webview/hooks/editor/useGenericFormState.ts deleted file mode 100644 index b57b4183e..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/useGenericFormState.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Generic form state hook for editor panels. -import type React from "react"; -import { useState, useEffect, useCallback } from "react"; - -interface UseGenericFormStateOptions { - /** Calculate isNew based on data */ - getIsNew?: (data: T | null) => boolean; - /** Transform data before setting formData (e.g., deep clone nested objects) */ - transformData?: (data: T) => T; -} - -interface UseGenericFormStateReturn { - formData: T | null; - updateField: (field: K, value: T[K]) => void; - hasChanges: boolean; - resetInitialData: () => void; - discardChanges: () => void; - isNew: boolean; - setFormData: React.Dispatch>; -} - -/** - * Generic form state hook with change tracking - * @param data The initial data to populate the form - * @param options Optional configuration - */ -export function useGenericFormState( - data: T | null, - options: UseGenericFormStateOptions = {} -): UseGenericFormStateReturn { - const { getIsNew, transformData } = options; - - const [formData, setFormData] = useState(null); - const [initialData, setInitialData] = useState(null); - - useEffect(() => { - if (data) { - const transformed = transformData ? transformData(data) : { ...data }; - setFormData(transformed); - setInitialData(transformed); - } - }, [data, transformData]); - - const updateField = useCallback((field: K, value: T[K]) => { - setFormData((prev) => (prev ? { ...prev, [field]: value } : null)); - }, []); - - const resetInitialData = useCallback(() => { - if (formData) { - const transformed = transformData ? transformData(formData) : { ...formData }; - setInitialData(transformed); - } - }, [formData, transformData]); - - const discardChanges = useCallback(() => { - if (initialData !== null) { - const transformed = transformData ? transformData(initialData) : { ...initialData }; - setFormData(transformed); - } - }, [initialData, transformData]); - - const hasChanges = - formData !== null && initialData !== null - ? JSON.stringify(formData) !== JSON.stringify(initialData) - : false; - const isNew = getIsNew ? getIsNew(data) : false; - - return { - formData, - updateField, - hasChanges, - resetInitialData, - discardChanges, - isNew, - setFormData - }; -} - -interface UseEditorHandlersOptions { - formData: T | null; - onSave: (data: T) => void; - onClose: () => void; - onDelete?: (id: string) => void; - resetInitialData: () => void; - discardChanges?: () => void; - /** Validation function - if returns false, save is blocked */ - canSave?: (data: T) => boolean; -} - -interface UseEditorHandlersReturn { - handleApply: () => void; - handleSaveAndClose: () => void; - handleDelete: () => void; - handleDiscard: () => void; -} - -/** - * Creates standard editor panel handlers (Apply, Save & Close, Delete) - */ -export function useEditorHandlers( - options: UseEditorHandlersOptions -): UseEditorHandlersReturn { - const { formData, onSave, onClose, onDelete, resetInitialData, discardChanges, canSave } = - options; - - const handleApply = useCallback(() => { - if (formData && (!canSave || canSave(formData))) { - onSave(formData); - resetInitialData(); - } - }, [formData, onSave, resetInitialData, canSave]); - - const handleSaveAndClose = useCallback(() => { - if (formData && (!canSave || canSave(formData))) { - onSave(formData); - onClose(); - } - }, [formData, onSave, onClose, canSave]); - - const handleDelete = useCallback(() => { - if (formData && onDelete) { - onDelete(formData.id); - onClose(); - } - }, [formData, onDelete, onClose]); - - const handleDiscard = useCallback(() => { - discardChanges?.(); - }, [discardChanges]); - - return { handleApply, handleSaveAndClose, handleDelete, handleDiscard }; -} diff --git a/src/reactTopoViewer/webview/hooks/editor/useLabSettings.ts b/src/reactTopoViewer/webview/hooks/editor/useLabSettings.ts deleted file mode 100644 index cb0aec354..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/useLabSettings.ts +++ /dev/null @@ -1,291 +0,0 @@ -/** - * Hook for managing Lab Settings state (host-authoritative). - */ -import { useState, useCallback, useEffect } from "react"; - -import type { - LabSettings, - PrefixType, - IpType, - DriverOption, - BasicSettingsState, - MgmtSettingsState -} from "../../components/panels/lab-settings/types"; -import { executeTopologyCommand } from "../../services"; - -export interface UseLabSettingsStateResult { - basic: BasicSettingsState; - mgmt: MgmtSettingsState; - setBasic: { - setLabName: (v: string) => void; - setPrefixType: (v: PrefixType) => void; - setCustomPrefix: (v: string) => void; - }; - setMgmt: { - setNetworkName: (v: string) => void; - setIpv4Type: (v: IpType) => void; - setIpv4Subnet: (v: string) => void; - setIpv4Gateway: (v: string) => void; - setIpv4Range: (v: string) => void; - setIpv6Type: (v: IpType) => void; - setIpv6Subnet: (v: string) => void; - setIpv6Gateway: (v: string) => void; - setMtu: (v: string) => void; - setBridge: (v: string) => void; - setExternalAccess: (v: boolean) => void; - }; - driverOpts: { - add: () => void; - remove: (index: number) => void; - update: (index: number, field: "key" | "value", value: string) => void; - setAll: (options: DriverOption[]) => void; - }; - handleSave: () => Promise; -} - -/** Parses prefix settings from lab settings */ -function parsePrefixSettings( - settings?: LabSettings -): Pick { - if (!settings || settings.prefix === undefined) { - return { prefixType: "default", customPrefix: "" }; - } - if (settings.prefix === "") { - return { prefixType: "no-prefix", customPrefix: "" }; - } - if (settings.prefix !== null && settings.prefix !== "clab") { - return { prefixType: "custom", customPrefix: settings.prefix }; - } - return { prefixType: "default", customPrefix: "" }; -} - -function parseIpv4Settings( - mgmt?: LabSettings["mgmt"] -): Pick { - const result = { ipv4Type: "default" as IpType, ipv4Subnet: "", ipv4Gateway: "", ipv4Range: "" }; - const ipv4Subnet = mgmt?.["ipv4-subnet"]; - if (ipv4Subnet === undefined || ipv4Subnet === "") return result; - - if (ipv4Subnet === "auto") { - return { ...result, ipv4Type: "auto" }; - } - return { - ipv4Type: "custom", - ipv4Subnet, - ipv4Gateway: mgmt?.["ipv4-gw"] ?? "", - ipv4Range: mgmt?.["ipv4-range"] ?? "" - }; -} - -function parseIpv6Settings( - mgmt?: LabSettings["mgmt"] -): Pick { - const result = { ipv6Type: "default" as IpType, ipv6Subnet: "", ipv6Gateway: "" }; - const ipv6Subnet = mgmt?.["ipv6-subnet"]; - if (ipv6Subnet === undefined || ipv6Subnet === "") return result; - - if (ipv6Subnet === "auto") { - return { ...result, ipv6Type: "auto" }; - } - return { - ipv6Type: "custom", - ipv6Subnet, - ipv6Gateway: mgmt?.["ipv6-gw"] ?? "" - }; -} - -function parseDriverOptions(mgmt?: LabSettings["mgmt"]): DriverOption[] { - const opts = mgmt?.["driver-opts"]; - if (!opts) return []; - return Object.entries(opts).map(([key, value]) => ({ key, value })); -} - -function buildBasicSettings(basic: BasicSettingsState): Partial { - const settings: Partial = {}; - if (basic.labName) settings.name = basic.labName; - - if (basic.prefixType === "custom") { - settings.prefix = basic.customPrefix; - } else if (basic.prefixType === "no-prefix") { - settings.prefix = ""; - } else { - settings.prefix = null; - } - return settings; -} - -function gatherIpv4Settings(mgmtState: MgmtSettingsState): Record { - const ipv4: Record = {}; - if (mgmtState.ipv4Type === "auto") { - ipv4["ipv4-subnet"] = "auto"; - return ipv4; - } - if (mgmtState.ipv4Type === "custom") { - if (mgmtState.ipv4Subnet) ipv4["ipv4-subnet"] = mgmtState.ipv4Subnet; - if (mgmtState.ipv4Gateway) ipv4["ipv4-gw"] = mgmtState.ipv4Gateway; - if (mgmtState.ipv4Range) ipv4["ipv4-range"] = mgmtState.ipv4Range; - } - return ipv4; -} - -function gatherIpv6Settings(mgmtState: MgmtSettingsState): Record { - const ipv6: Record = {}; - if (mgmtState.ipv6Type === "auto") { - ipv6["ipv6-subnet"] = "auto"; - return ipv6; - } - if (mgmtState.ipv6Type === "custom") { - if (mgmtState.ipv6Subnet) ipv6["ipv6-subnet"] = mgmtState.ipv6Subnet; - if (mgmtState.ipv6Gateway) ipv6["ipv6-gw"] = mgmtState.ipv6Gateway; - } - return ipv6; -} - -function gatherMgmtSettings(mgmtState: MgmtSettingsState): Record | null { - const mgmt: Record = {}; - let hasMgmtSettings = false; - - if (mgmtState.networkName) { - mgmt.network = mgmtState.networkName; - hasMgmtSettings = true; - } - - const ipv4 = gatherIpv4Settings(mgmtState); - if (Object.keys(ipv4).length > 0) { - Object.assign(mgmt, ipv4); - hasMgmtSettings = true; - } - - const ipv6 = gatherIpv6Settings(mgmtState); - if (Object.keys(ipv6).length > 0) { - Object.assign(mgmt, ipv6); - hasMgmtSettings = true; - } - - if (mgmtState.mtu) { - mgmt.mtu = parseInt(mgmtState.mtu, 10); - hasMgmtSettings = true; - } - if (mgmtState.bridge) { - mgmt.bridge = mgmtState.bridge; - hasMgmtSettings = true; - } - if (!mgmtState.externalAccess) { - mgmt["external-access"] = false; - hasMgmtSettings = true; - } - - const driverOpts: Record = {}; - mgmtState.driverOptions.forEach((opt) => { - if (opt.key && opt.value) driverOpts[opt.key] = opt.value; - }); - if (Object.keys(driverOpts).length > 0) { - mgmt["driver-opts"] = driverOpts; - hasMgmtSettings = true; - } - - return hasMgmtSettings ? mgmt : null; -} - -function buildInitialBasic(settings?: LabSettings): BasicSettingsState { - const prefix = parsePrefixSettings(settings); - return { - labName: settings?.name ?? "", - prefixType: prefix.prefixType, - customPrefix: prefix.customPrefix - }; -} - -function buildInitialMgmt(settings?: LabSettings): MgmtSettingsState { - const mgmt = settings?.mgmt ?? undefined; - const ipv4 = parseIpv4Settings(mgmt ?? undefined); - const ipv6 = parseIpv6Settings(mgmt ?? undefined); - - return { - networkName: mgmt?.network ?? "", - ipv4Type: ipv4.ipv4Type, - ipv4Subnet: ipv4.ipv4Subnet, - ipv4Gateway: ipv4.ipv4Gateway, - ipv4Range: ipv4.ipv4Range, - ipv6Type: ipv6.ipv6Type, - ipv6Subnet: ipv6.ipv6Subnet, - ipv6Gateway: ipv6.ipv6Gateway, - mtu: mgmt?.mtu !== undefined ? String(mgmt.mtu) : "", - bridge: mgmt?.bridge ?? "", - externalAccess: mgmt?.["external-access"] !== false, - driverOptions: parseDriverOptions(mgmt ?? undefined) - }; -} - -export function useLabSettingsState(labSettings?: LabSettings): UseLabSettingsStateResult { - const [basic, setBasicState] = useState(() => buildInitialBasic(labSettings)); - const [mgmt, setMgmtState] = useState(() => buildInitialMgmt(labSettings)); - - useEffect(() => { - setBasicState(buildInitialBasic(labSettings)); - setMgmtState(buildInitialMgmt(labSettings)); - }, [labSettings]); - - const setBasic = { - setLabName: (v: string) => setBasicState((prev) => ({ ...prev, labName: v })), - setPrefixType: (v: PrefixType) => setBasicState((prev) => ({ ...prev, prefixType: v })), - setCustomPrefix: (v: string) => setBasicState((prev) => ({ ...prev, customPrefix: v })) - }; - - const setMgmt = { - setNetworkName: (v: string) => setMgmtState((prev) => ({ ...prev, networkName: v })), - setIpv4Type: (v: IpType) => setMgmtState((prev) => ({ ...prev, ipv4Type: v })), - setIpv4Subnet: (v: string) => setMgmtState((prev) => ({ ...prev, ipv4Subnet: v })), - setIpv4Gateway: (v: string) => setMgmtState((prev) => ({ ...prev, ipv4Gateway: v })), - setIpv4Range: (v: string) => setMgmtState((prev) => ({ ...prev, ipv4Range: v })), - setIpv6Type: (v: IpType) => setMgmtState((prev) => ({ ...prev, ipv6Type: v })), - setIpv6Subnet: (v: string) => setMgmtState((prev) => ({ ...prev, ipv6Subnet: v })), - setIpv6Gateway: (v: string) => setMgmtState((prev) => ({ ...prev, ipv6Gateway: v })), - setMtu: (v: string) => setMgmtState((prev) => ({ ...prev, mtu: v })), - setBridge: (v: string) => setMgmtState((prev) => ({ ...prev, bridge: v })), - setExternalAccess: (v: boolean) => setMgmtState((prev) => ({ ...prev, externalAccess: v })) - }; - - const driverOpts = { - add: () => - setMgmtState((prev) => ({ - ...prev, - driverOptions: [...prev.driverOptions, { key: "", value: "" }] - })), - remove: (index: number) => - setMgmtState((prev) => ({ - ...prev, - driverOptions: prev.driverOptions.filter((_, i) => i !== index) - })), - update: (index: number, field: "key" | "value", value: string) => - setMgmtState((prev) => ({ - ...prev, - driverOptions: prev.driverOptions.map((opt, i) => - i === index ? { ...opt, [field]: value } : opt - ) - })), - setAll: (options: DriverOption[]) => - setMgmtState((prev) => ({ ...prev, driverOptions: options })) - }; - - const handleSave = useCallback(async () => { - const settings = buildBasicSettings(basic); - const mgmtSettings = gatherMgmtSettings(mgmt); - - const payload: LabSettings = { - ...settings, - ...(mgmtSettings === null ? { mgmt: null } : { mgmt: mgmtSettings as LabSettings["mgmt"] }) - }; - - await executeTopologyCommand({ command: "setLabSettings", payload }); - }, [basic, mgmt]); - - return { - basic, - mgmt, - setBasic, - setMgmt, - driverOpts, - handleSave - }; -} diff --git a/src/reactTopoViewer/webview/hooks/editor/useLinkEditorForm.ts b/src/reactTopoViewer/webview/hooks/editor/useLinkEditorForm.ts deleted file mode 100644 index 2a931f6ab..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/useLinkEditorForm.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * useLinkEditorForm - Form state management for the Link Editor - * Extracted from LinkEditorView.tsx - */ -import { useState, useEffect, useCallback, useRef } from "react"; - -import type { LinkEditorData, LinkEditorTabId } from "../../components/panels/link-editor/types"; - -import { applyFormUpdates } from "./formState"; -import { discardFormChanges, hasFormChanges } from "./formChangeTracking"; - -export interface UseLinkEditorFormReturn { - activeTab: LinkEditorTabId; - setActiveTab: (tab: LinkEditorTabId) => void; - formData: LinkEditorData | null; - handleChange: (updates: Partial) => void; - hasChanges: boolean; - resetAfterApply: () => void; - discardChanges: () => void; -} - -export function useLinkEditorForm( - linkData: LinkEditorData | null, - readOnly = false -): UseLinkEditorFormReturn { - const [activeTab, setActiveTab] = useState("basic"); - const [formData, setFormData] = useState(null); - const [initialData, setInitialData] = useState(null); - const initialDataRef = useRef(null); - - useEffect(() => { - initialDataRef.current = initialData; - }, [initialData]); - - useEffect(() => { - if (!linkData) return; - setFormData((prev) => { - const isNewLink = prev === null || prev.id !== linkData.id; - const hasPendingChanges = - prev !== null && initialDataRef.current !== null - ? JSON.stringify(prev) !== JSON.stringify(initialDataRef.current) - : false; - if (isNewLink || !hasPendingChanges) { - const nextInitialData = { ...linkData }; - initialDataRef.current = nextInitialData; - setInitialData(nextInitialData); - if (isNewLink) setActiveTab("basic"); - return { ...linkData }; - } - return prev; - }); - }, [linkData]); - - const handleChange = useCallback( - (updates: Partial) => { - applyFormUpdates(readOnly, setFormData, updates); - }, - [readOnly] - ); - - const resetAfterApply = useCallback(() => { - if (formData) { - const updatedFormData: LinkEditorData = { - ...formData, - originalSource: formData.source, - originalTarget: formData.target, - originalSourceEndpoint: formData.sourceEndpoint, - originalTargetEndpoint: formData.targetEndpoint - }; - setFormData(updatedFormData); - setInitialData(updatedFormData); - } - }, [formData]); - - const discardChanges = useCallback(() => { - discardFormChanges(initialData, setFormData); - }, [initialData]); - - const hasChanges = hasFormChanges(formData, initialData); - - return { - activeTab, - setActiveTab, - formData, - handleChange, - hasChanges, - resetAfterApply, - discardChanges - }; -} diff --git a/src/reactTopoViewer/webview/hooks/editor/useLinkImpairmentForm.ts b/src/reactTopoViewer/webview/hooks/editor/useLinkImpairmentForm.ts deleted file mode 100644 index 8e786aa25..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/useLinkImpairmentForm.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * useLinkImpairmentForm - Form state management for the Link Impairment Editor - * Extracted from LinkImpairmentView.tsx - */ -import { useState, useEffect, useCallback, useMemo, useRef } from "react"; - -import type { - EndpointWithNetem, - LinkImpairmentData, - LinkImpairmentTabId, - NetemState -} from "../../components/panels/link-impairment/types"; -import { - formatNetemData, - validateLinkImpairmentState -} from "../../components/panels/link-impairment/LinkImpairmentUtils"; - -export interface UseLinkImpairmentFormReturn { - activeTab: LinkImpairmentTabId; - setActiveTab: (tab: LinkImpairmentTabId) => void; - formData: LinkImpairmentData | null; - handleChange: (updates: Partial) => void; - hasChanges: boolean; - resetAfterApply: () => void; - discardChanges: () => void; - validationErrors: string[]; -} - -export function useLinkImpairmentForm( - netemData: LinkImpairmentData | null, - readOnly = false -): UseLinkImpairmentFormReturn { - const [activeTab, setActiveTab] = useState("source"); - const [formData, setFormData] = useState(null); - const initialDataRef = useRef(null); - - const hasChanges = useMemo(() => { - if (formData) { - const curr = { source: formData.sourceNetem, target: formData.targetNetem }; - const prev = { - source: formData.extraData?.clabSourceNetem, - target: formData.extraData?.clabTargetNetem - }; - return JSON.stringify(curr) !== JSON.stringify(prev); - } - return false; - }, [formData]); - - useEffect(() => { - if (!netemData) return; - setFormData((prev) => { - const isNewLink = !prev || prev.id !== netemData.id; - if (!isNewLink && hasChanges) return prev; - - const currentBaseline = { - source: netemData.extraData?.clabSourceNetem, - target: netemData.extraData?.clabTargetNetem - }; - - const hasBaseline = prev !== null && initialDataRef.current !== null; - const isBaselineUpdated = hasBaseline - ? JSON.stringify(currentBaseline) !== initialDataRef.current - : true; - - if (isNewLink || isBaselineUpdated) { - const formatted = formatNetemData(netemData); - const serialized = JSON.stringify({ - source: formatted.extraData?.clabSourceNetem, - target: formatted.extraData?.clabTargetNetem - }); - initialDataRef.current = serialized; - if (isNewLink) setActiveTab("source"); - return { ...formatted }; - } - return prev; - }); - }, [hasChanges, netemData]); - - const handleChange = useCallback( - (updates: Partial) => { - if (readOnly) return; - setFormData((prev) => { - if (!prev) return prev; - const newState = { ...prev }; - if (activeTab === "source") { - newState.sourceNetem = { ...newState.sourceNetem, ...updates }; - } else { - newState.targetNetem = { ...newState.targetNetem, ...updates }; - } - return newState; - }); - }, - [activeTab, readOnly] - ); - - const resetAfterApply = useCallback(() => { - if (!formData) return; - const updated: LinkImpairmentData = { - ...formData, - extraData: { - ...formData.extraData, - clabSourceNetem: formData.sourceNetem, - clabTargetNetem: formData.targetNetem - } - }; - setFormData(updated); - }, [formData]); - - const validationErrors: string[] = useMemo(() => { - if (!formData) return []; - const endpoints: EndpointWithNetem[] = [ - { node: formData.source, iface: formData.sourceEndpoint, netem: formData.sourceNetem }, - { node: formData.target, iface: formData.targetEndpoint, netem: formData.targetNetem } - ]; - return endpoints.flatMap((endpoint) => { - if (!endpoint.netem) return []; - const errors = validateLinkImpairmentState(endpoint.netem); - return errors.map((error) => `${endpoint.node}:${endpoint.iface} ${error}`); - }); - }, [formData]); - - const discardChanges = useCallback(() => { - if (!formData) return; - const restored: LinkImpairmentData = { - ...formData, - sourceNetem: formData.extraData?.clabSourceNetem ?? formData.sourceNetem, - targetNetem: formData.extraData?.clabTargetNetem ?? formData.targetNetem - }; - setFormData(restored); - }, [formData]); - - return { - activeTab, - setActiveTab, - formData, - handleChange, - hasChanges, - resetAfterApply, - discardChanges, - validationErrors - }; -} diff --git a/src/reactTopoViewer/webview/hooks/editor/useNetworkEditorForm.ts b/src/reactTopoViewer/webview/hooks/editor/useNetworkEditorForm.ts deleted file mode 100644 index 66cb0ad1a..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/useNetworkEditorForm.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * useNetworkEditorForm - Form state management for the Network Editor - * Extracted from NetworkEditorView.tsx - */ -import { useState, useEffect, useCallback } from "react"; - -import type { NetworkEditorData } from "../../components/panels/network-editor/types"; -import { discardFormChanges, hasFormChanges } from "./formChangeTracking"; - -export interface UseNetworkEditorFormReturn { - formData: NetworkEditorData | null; - handleChange: (updates: Partial) => void; - hasChanges: boolean; - resetInitialData: () => void; - discardChanges: () => void; -} - -export function useNetworkEditorForm( - nodeData: NetworkEditorData | null, - readOnly = false -): UseNetworkEditorFormReturn { - const [formData, setFormData] = useState(null); - const [initialData, setInitialData] = useState(null); - - useEffect(() => { - if (nodeData) { - const nextData = { ...nodeData }; - setFormData(nextData); - setInitialData(nextData); - } - }, [nodeData]); - - const handleChange = useCallback( - (updates: Partial) => { - if (readOnly) return; - setFormData((prev) => (prev ? { ...prev, ...updates } : null)); - }, - [readOnly] - ); - - const resetInitialData = useCallback(() => { - if (formData) setInitialData({ ...formData }); - }, [formData]); - - const discardChanges = useCallback(() => { - discardFormChanges(initialData, setFormData); - }, [initialData]); - - const hasChanges = hasFormChanges(formData, initialData); - - return { formData, handleChange, hasChanges, resetInitialData, discardChanges }; -} diff --git a/src/reactTopoViewer/webview/hooks/editor/useNodeEditorForm.ts b/src/reactTopoViewer/webview/hooks/editor/useNodeEditorForm.ts deleted file mode 100644 index 62848b749..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/useNodeEditorForm.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * useNodeEditorForm - Form state management for the Node Editor - * Extracted from NodeEditorView.tsx - */ -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; - -import type { NodeEditorData, NodeEditorTabId } from "../../components/panels/node-editor/types"; -import { convertEditorDataToNodeSaveData } from "../../../shared/utilities"; - -import { applyFormUpdates } from "./formState"; - -/** Maps YAML kebab-case keys to camelCase NodeEditorData keys */ -export const YAML_TO_EDITOR_MAP: Partial> = { - "startup-config": "startupConfig", - "enforce-startup-config": "enforceStartupConfig", - "suppress-startup-config": "suppressStartupConfig", - "env-files": "envFiles", - "restart-policy": "restartPolicy", - "auto-remove": "autoRemove", - "startup-delay": "startupDelay", - "mgmt-ipv4": "mgmtIpv4", - "mgmt-ipv6": "mgmtIpv6", - "network-mode": "networkMode", - "cpu-set": "cpuSet", - "shm-size": "shmSize", - "cap-add": "capAdd", - "image-pull-policy": "imagePullPolicy" -}; - -export function hasFieldChanged( - yamlKey: string, - formData: NodeEditorData, - initialData: NodeEditorData -): boolean { - const editorKey = YAML_TO_EDITOR_MAP[yamlKey] ?? yamlKey; - const currentVal: unknown = Reflect.get(formData, editorKey); - const initialVal: unknown = Reflect.get(initialData, editorKey); - return JSON.stringify(currentVal) !== JSON.stringify(initialVal); -} - -export interface UseNodeEditorFormReturn { - activeTab: NodeEditorTabId; - setActiveTab: (tab: NodeEditorTabId) => void; - formData: NodeEditorData | null; - handleChange: (updates: Partial) => void; - hasChanges: boolean; - resetAfterApply: () => void; - discardChanges: () => void; - originalData: NodeEditorData | null; -} - -function normalizeForDirtyCheck(value: unknown): unknown { - if (Array.isArray(value)) { - return value - .map((entry) => normalizeForDirtyCheck(entry)) - .filter((entry) => entry !== undefined && entry !== null); - } - - if (typeof value === "object" && value !== null) { - const entries = Object.entries(value) - .map(([key, entry]) => [key, normalizeForDirtyCheck(entry)] as const) - .filter(([, entry]) => entry !== undefined && entry !== null) - .sort(([a], [b]) => a.localeCompare(b)); - - if (entries.length === 0) return undefined; - - const normalized: Record = {}; - for (const [key, entry] of entries) { - normalized[key] = entry; - } - return normalized; - } - - return value; -} - -function toDirtySnapshot(data: NodeEditorData): string { - const saveData = convertEditorDataToNodeSaveData(data); - const normalized = normalizeForDirtyCheck(saveData); - return JSON.stringify(normalized); -} - -export function useNodeEditorForm( - nodeData: NodeEditorData | null, - readOnly = false -): UseNodeEditorFormReturn { - const [activeTab, setActiveTab] = useState("basic"); - const [formData, setFormData] = useState(null); - const [lastAppliedData, setLastAppliedData] = useState(null); - const [originalData, setOriginalData] = useState(null); - const [loadedNodeId, setLoadedNodeId] = useState(null); - const skipNextSyncRef = useRef(false); - const formDataRef = useRef(null); - const lastAppliedDataRef = useRef(null); - - useEffect(() => { - formDataRef.current = formData; - lastAppliedDataRef.current = lastAppliedData; - }, [formData, lastAppliedData]); - - useEffect(() => { - if (nodeData && nodeData.id !== loadedNodeId) { - setFormData({ ...nodeData }); - setLastAppliedData({ ...nodeData }); - setOriginalData({ ...nodeData }); - setLoadedNodeId(nodeData.id); - setActiveTab("basic"); - skipNextSyncRef.current = false; - } else if (nodeData && nodeData.id === loadedNodeId) { - if (skipNextSyncRef.current) { - skipNextSyncRef.current = false; - return; - } - const currentFormData = formDataRef.current; - const currentLastAppliedData = lastAppliedDataRef.current; - const hasLocalChanges = - currentFormData !== null && - currentLastAppliedData !== null && - toDirtySnapshot(currentFormData) !== toDirtySnapshot(currentLastAppliedData); - if (hasLocalChanges) { - return; - } - const nextSnapshot = toDirtySnapshot(nodeData); - const formSnapshot = currentFormData ? toDirtySnapshot(currentFormData) : null; - const appliedSnapshot = currentLastAppliedData - ? toDirtySnapshot(currentLastAppliedData) - : null; - if (formSnapshot === nextSnapshot && appliedSnapshot === nextSnapshot) { - return; - } - setFormData({ ...nodeData }); - setLastAppliedData({ ...nodeData }); - } else if (nodeData === null && loadedNodeId !== null) { - setLoadedNodeId(null); - skipNextSyncRef.current = false; - } - }, [nodeData, loadedNodeId]); - - const handleChange = useCallback( - (updates: Partial) => { - applyFormUpdates(readOnly, setFormData, updates); - }, - [readOnly] - ); - - const resetAfterApply = useCallback(() => { - if (formData) { - setLastAppliedData({ ...formData }); - skipNextSyncRef.current = true; - } - }, [formData]); - - const discardChanges = useCallback(() => { - if (lastAppliedData) setFormData({ ...lastAppliedData }); - }, [lastAppliedData]); - - const hasChanges = useMemo(() => { - if (!formData || !lastAppliedData) return false; - return toDirtySnapshot(formData) !== toDirtySnapshot(lastAppliedData); - }, [formData, lastAppliedData]); - - return { - activeTab, - setActiveTab, - formData, - handleChange, - hasChanges, - resetAfterApply, - discardChanges, - originalData - }; -} diff --git a/src/reactTopoViewer/webview/hooks/editor/useSchema.ts b/src/reactTopoViewer/webview/hooks/editor/useSchema.ts deleted file mode 100644 index 03f2d02f3..000000000 --- a/src/reactTopoViewer/webview/hooks/editor/useSchema.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * useSchema - Hook to access containerlab schema data for kind/type options - * - * Schema data is loaded by the extension and passed via window.__SCHEMA_DATA__ - */ -import { useState, useEffect, useMemo, useCallback } from "react"; - -import { log } from "../../utils/logger"; - -/** - * SROS component types for nokia_srsim nodes - */ -export interface SrosComponentTypes { - sfm: string[]; - cpm: string[]; - card: string[]; - mda: string[]; - xiom: string[]; - xiomMda: string[]; -} - -// Extend Window to include schema data -declare global { - interface Window { - __SCHEMA_DATA__?: { - kinds: string[]; - typesByKind: Record; - srosComponentTypes?: SrosComponentTypes; - }; - } -} - -const EMPTY_SROS_TYPES: SrosComponentTypes = { - sfm: [], - cpm: [], - card: [], - mda: [], - xiom: [], - xiomMda: [] -}; - -interface SchemaData { - kinds: string[]; - typesByKind: Map; - kindsWithTypeSupport: Set; - srosComponentTypes: SrosComponentTypes; - isLoaded: boolean; - error: string | null; -} - -interface UseSchemaResult extends SchemaData { - getTypesForKind: (kind: string) => string[]; - kindSupportsType: (kind: string) => boolean; -} - -/** - * Hook to access containerlab schema data - */ -export function useSchema(): UseSchemaResult { - const [schemaData, setSchemaData] = useState({ - kinds: [], - typesByKind: new Map(), - kindsWithTypeSupport: new Set(), - srosComponentTypes: EMPTY_SROS_TYPES, - isLoaded: false, - error: null - }); - - // Load schema data from window on mount - useEffect(() => { - const data = window.__SCHEMA_DATA__; - - if (!data) { - log.warn("Schema data not available"); - setSchemaData((prev) => ({ ...prev, isLoaded: true })); - return; - } - - const kinds = data.kinds; - const typesByKind = new Map(); - const kindsWithTypeSupport = new Set(); - - // Convert Record to Map and build kindsWithTypeSupport set - for (const [kind, types] of Object.entries(data.typesByKind)) { - typesByKind.set(kind, types); - kindsWithTypeSupport.add(kind); - } - - // Get SROS component types - const srosComponentTypes = data.srosComponentTypes ?? EMPTY_SROS_TYPES; - - log.info( - `Schema loaded: ${kinds.length} kinds, ${typesByKind.size} kinds with type options, SROS types: sfm=${srosComponentTypes.sfm.length}, cpm=${srosComponentTypes.cpm.length}, card=${srosComponentTypes.card.length}, mda=${srosComponentTypes.mda.length}` - ); - - setSchemaData({ - kinds, - typesByKind, - kindsWithTypeSupport, - srosComponentTypes, - isLoaded: true, - error: null - }); - }, []); - - // Get types for a specific kind - const getTypesForKind = useCallback( - (kind: string): string[] => { - return schemaData.typesByKind.get(kind) ?? []; - }, - [schemaData.typesByKind] - ); - - // Check if a kind supports type field - const kindSupportsType = useCallback( - (kind: string): boolean => { - return schemaData.kindsWithTypeSupport.has(kind); - }, - [schemaData.kindsWithTypeSupport] - ); - - return useMemo( - () => ({ - ...schemaData, - getTypesForKind, - kindSupportsType - }), - [schemaData, getTypesForKind, kindSupportsType] - ); -} diff --git a/src/reactTopoViewer/webview/hooks/index.ts b/src/reactTopoViewer/webview/hooks/index.ts deleted file mode 100644 index a89fed0ef..000000000 --- a/src/reactTopoViewer/webview/hooks/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * React TopoViewer hooks (public API) - * - * Keep this surface small: import leaf hooks from feature folders (e.g. `./canvas`, `./editor`) - * when wiring the App/context layers. - */ - -export { - useCustomNodeCommands, - useNavbarCommands, - useE2ETestingExposure, - useClipboardHandlers, - useAppKeyboardShortcuts, - useGraphCreation -} from "./app"; -export type { - CustomNodeCommands, - NavbarCommands, - E2ETestingConfig, - ClipboardHandlersConfig, - ClipboardHandlersReturn, - AppKeyboardShortcutsConfig, - GraphCreationConfig, - GraphCreationReturn -} from "./app"; diff --git a/src/reactTopoViewer/webview/hooks/state/index.ts b/src/reactTopoViewer/webview/hooks/state/index.ts deleted file mode 100644 index 423861936..000000000 --- a/src/reactTopoViewer/webview/hooks/state/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * State management hooks - */ - -export { useGraphHandlersWithContext } from "./useGraphCommandHandlers"; diff --git a/src/reactTopoViewer/webview/hooks/state/useGraphCommandHandlers.ts b/src/reactTopoViewer/webview/hooks/state/useGraphCommandHandlers.ts deleted file mode 100644 index 5b568d688..000000000 --- a/src/reactTopoViewer/webview/hooks/state/useGraphCommandHandlers.ts +++ /dev/null @@ -1,389 +0,0 @@ -/** - * Graph mutation handlers backed by host commands. - */ -import React from "react"; -import type { Node } from "@xyflow/react"; - -import type { - TopoNode, - TopoEdge, - TopologyEdgeData, - EdgeCreatedData, - EdgeCreatedHandler, - NodeCreatedHandler -} from "../../../shared/types/graph"; -import type { NodeSaveData } from "../../../shared/io/NodePersistenceIO"; -import type { TopologyHostCommand } from "../../../shared/types/messages"; -import { - createLink, - createNode, - deleteLink, - deleteNode, - saveAnnotationNodesFromGraph, - saveNetworkNodesFromGraph, - executeTopologyCommand -} from "../../services"; -import { - isAnnotationNodeType, - nodesToAnnotations -} from "../../annotations/annotationNodeConverters"; -import { toLinkSaveData } from "../../services/linkSaveData"; -import { - BRIDGE_NETWORK_TYPES, - SPECIAL_NETWORK_TYPES, - getNetworkType -} from "../../utils/networkNodeTypes"; -import { buildNetworkNodeAnnotations } from "../../utils/networkNodeAnnotations"; -import { useGraphStore } from "../../stores/graphStore"; - -// ============================================================================ -// Types -// ============================================================================ - -interface MenuHandlers { - handleDeleteNode: (id: string) => void; - handleDeleteLink: (id: string) => void; -} - -interface UseGraphHandlersWithContextParams { - getNodes: () => Node[]; - getEdges: () => TopoEdge[]; - addNode: (node: TopoNode) => void; - addEdge: (edge: TopoEdge) => void; - removeNodeAndEdges: (nodeId: string) => void; - removeEdge: (edgeId: string) => void; - menuHandlers: MenuHandlers; -} - -interface GraphHandlersResult { - handleEdgeCreated: EdgeCreatedHandler; - handleNodeCreatedCallback: NodeCreatedHandler; - handleBatchPaste: (payload: { nodes: TopoNode[]; edges: TopoEdge[] }) => void; - handleDeleteNode: (nodeId: string) => void; - handleDeleteLink: (edgeId: string) => void; -} - -// ============================================================================ -// Helpers -// ============================================================================ - -type NodeElementData = Record & { extraData?: Record }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isTopoNode(node: Node): node is TopoNode { - return ( - node.type === "topology-node" || - node.type === "network-node" || - node.type === "group-node" || - node.type === "free-text-node" || - node.type === "free-shape-node" || - node.type === "traffic-rate-node" - ); -} - -function getNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function getExtraDataRecord(data: Record): Record | undefined { - const { extraData } = data; - return isRecord(extraData) ? extraData : undefined; -} - -const NODE_FALLBACK_PROPS = [ - "kind", - "type", - "image", - "group", - "groupId", - "topoViewerRole", - "iconColor", - "iconCornerRadius", - "labelPosition", - "direction", - "labelBackgroundColor", - "interfacePattern" -] as const; - -const NETWORK_NODE_TYPE = "network-node"; - -function mergeNodeExtraData(data: NodeElementData): NodeSaveData["extraData"] { - const ed = data.extraData ?? {}; - const result: Record = { ...ed }; - for (const key of NODE_FALLBACK_PROPS) { - if (result[key] === undefined) { - const topLevelValue = (data as Record)[key]; - if (topLevelValue !== undefined) { - result[key] = topLevelValue; - } - } - } - - // New nodes carry the visual icon in data.role; persist it as topoViewerRole - // so host snapshots keep custom icons instead of falling back to defaults. - if (result.topoViewerRole === undefined) { - const role = data.role; - if (typeof role === "string" && role.trim().length > 0) { - result.topoViewerRole = role; - } - } - - return result; -} - -function toNodeSaveData(node: TopoNode): NodeSaveData { - const data = node.data as NodeElementData; - const name = getNonEmptyString(data.label) ?? getNonEmptyString(data.name) ?? node.id; - return { - id: node.id, - name, - position: node.position, - extraData: mergeNodeExtraData(data) - }; -} - -function isSpecialNetworkNode(node: TopoNode): boolean { - if (node.type !== NETWORK_NODE_TYPE) return false; - const data = node.data; - const type = getNetworkType(data); - return type !== undefined && type.length > 0 && SPECIAL_NETWORK_TYPES.has(type); -} - -function isBridgeNetworkNode(node: TopoNode): boolean { - const data = node.data; - const type = getNetworkType(data); - return type !== undefined && type.length > 0 && BRIDGE_NETWORK_TYPES.has(type); -} - -const VXLAN_NETWORK_TYPES = new Set(["vxlan", "vxlan-stitch"]); -const VXLAN_DEFAULTS = { extRemote: "127.0.0.1", extVni: "100", extDstPort: "4789" }; - -type LinkTypeDetectionResult = { linkType: string; networkNodeId: string } | undefined; - -function detectSpecialLinkType( - nodes: TopoNode[], - sourceId: string, - targetId: string -): LinkTypeDetectionResult { - const sourceNode = nodes.find((node) => node.id === sourceId); - if (sourceNode?.type === NETWORK_NODE_TYPE) { - const data = sourceNode.data; - const type = getNetworkType(data); - if (type !== undefined && type.length > 0 && SPECIAL_NETWORK_TYPES.has(type)) { - return { linkType: type, networkNodeId: sourceId }; - } - } - - const targetNode = nodes.find((node) => node.id === targetId); - if (targetNode?.type === NETWORK_NODE_TYPE) { - const data = targetNode.data; - const type = getNetworkType(data); - if (type !== undefined && type.length > 0 && SPECIAL_NETWORK_TYPES.has(type)) { - return { linkType: type, networkNodeId: targetId }; - } - } - - return undefined; -} - -function getAliasYamlNodeId(node: TopoNode | undefined): string | undefined { - if (!node) return undefined; - const data = node.data; - const extraData = getExtraDataRecord(data) ?? {}; - const yamlNodeId = - typeof extraData.extYamlNodeId === "string" ? extraData.extYamlNodeId.trim() : ""; - if (yamlNodeId.length === 0 || yamlNodeId === node.id) return undefined; - const type = getNetworkType(data); - if (type === undefined || type.length === 0 || !BRIDGE_NETWORK_TYPES.has(type)) return undefined; - return yamlNodeId; -} - -// ============================================================================ -// Core handlers -// ============================================================================ - -export function useGraphHandlersWithContext( - params: UseGraphHandlersWithContextParams -): GraphHandlersResult { - const { getNodes, getEdges, addNode, addEdge, removeNodeAndEdges, removeEdge, menuHandlers } = - params; - - const handleEdgeCreated = React.useCallback( - (_sourceId: string, _targetId: string, edgeData: EdgeCreatedData) => { - const nodes = getNodes().filter(isTopoNode); - const detection = detectSpecialLinkType(nodes, edgeData.source, edgeData.target); - const edgeExtraData: Record = {}; - if (detection) { - edgeExtraData.extType = detection.linkType; - } - const sourceAliasYaml = getAliasYamlNodeId(nodes.find((node) => node.id === edgeData.source)); - const targetAliasYaml = getAliasYamlNodeId(nodes.find((node) => node.id === edgeData.target)); - if (sourceAliasYaml !== undefined) { - edgeExtraData.yamlSourceNodeId = sourceAliasYaml; - } - if (targetAliasYaml !== undefined) { - edgeExtraData.yamlTargetNodeId = targetAliasYaml; - } - const extraData = Object.keys(edgeExtraData).length > 0 ? edgeExtraData : undefined; - const edge: TopoEdge = { - id: edgeData.id, - source: edgeData.source, - target: edgeData.target, - type: "topology-edge", - data: { - sourceEndpoint: edgeData.sourceEndpoint, - targetEndpoint: edgeData.targetEndpoint, - ...(extraData ? { extraData } : {}) - } as TopologyEdgeData - }; - addEdge(edge); - void createLink(toLinkSaveData(edge)); - - if (detection && VXLAN_NETWORK_TYPES.has(detection.linkType)) { - const node = nodes.find((n) => n.id === detection.networkNodeId); - const existingExtra = node ? (getExtraDataRecord(node.data) ?? {}) : {}; - const nextExtra = { - ...existingExtra, - extRemote: existingExtra.extRemote ?? VXLAN_DEFAULTS.extRemote, - extVni: existingExtra.extVni ?? VXLAN_DEFAULTS.extVni, - extDstPort: existingExtra.extDstPort ?? VXLAN_DEFAULTS.extDstPort - }; - useGraphStore.getState().updateNodeData(detection.networkNodeId, nextExtra); - } - }, - [addEdge, getNodes] - ); - - const handleNodeCreatedCallback = React.useCallback( - (nodeId: string, nodeElement: TopoNode, position: { x: number; y: number }) => { - const nextNode = - nodeElement.id === nodeId && - nodeElement.position.x === position.x && - nodeElement.position.y === position.y - ? nodeElement - : { ...nodeElement, id: nodeId, position }; - - addNode(nextNode); - - if (isAnnotationNodeType(nextNode.type)) { - void saveAnnotationNodesFromGraph(); - return; - } - - if (isSpecialNetworkNode(nextNode) && !isBridgeNetworkNode(nextNode)) { - void saveNetworkNodesFromGraph(); - return; - } - - void createNode(toNodeSaveData(nextNode)); - }, - [addNode] - ); - - const handleBatchPaste = React.useCallback( - (payload: { nodes: TopoNode[]; edges: TopoEdge[] }) => { - const { nodes: pastedNodes, edges: pastedEdges } = payload; - if (pastedNodes.length === 0 && pastedEdges.length === 0) return; - - const commands: TopologyHostCommand[] = []; - - for (const node of pastedNodes) { - if (isAnnotationNodeType(node.type)) continue; - if (isSpecialNetworkNode(node) && !isBridgeNetworkNode(node)) continue; - commands.push({ command: "addNode", payload: toNodeSaveData(node) }); - } - - for (const edge of pastedEdges) { - commands.push({ command: "addLink", payload: toLinkSaveData(edge) }); - } - - const graphNodes = useGraphStore.getState().nodes; - const { freeTextAnnotations, freeShapeAnnotations, trafficRateAnnotations, groups } = - nodesToAnnotations(graphNodes); - const networkNodeAnnotations = buildNetworkNodeAnnotations(graphNodes); - const shouldSaveAnnotations = - freeTextAnnotations.length > 0 || - freeShapeAnnotations.length > 0 || - trafficRateAnnotations.length > 0 || - groups.length > 0 || - networkNodeAnnotations.length > 0; - - if (shouldSaveAnnotations) { - commands.push({ - command: "setAnnotations", - payload: { - freeTextAnnotations, - freeShapeAnnotations, - trafficRateAnnotations, - groupStyleAnnotations: groups, - networkNodeAnnotations - } - }); - } - - if (commands.length === 0) return; - void executeTopologyCommand( - { command: "batch", payload: { commands } }, - { applySnapshot: false } - ); - }, - [ - buildNetworkNodeAnnotations, - executeTopologyCommand, - isAnnotationNodeType, - isBridgeNetworkNode, - isSpecialNetworkNode, - nodesToAnnotations, - toLinkSaveData, - toNodeSaveData - ] - ); - - const handleDeleteNode = React.useCallback( - (nodeId: string) => { - const nodes = getNodes().filter(isTopoNode); - const node = nodes.find((n) => n.id === nodeId); - - removeNodeAndEdges(nodeId); - menuHandlers.handleDeleteNode(nodeId); - - if (node && isAnnotationNodeType(node.type)) { - void saveAnnotationNodesFromGraph(); - return; - } - - if (node && isSpecialNetworkNode(node) && !isBridgeNetworkNode(node)) { - // Network nodes are stored in annotations, update from graph state - void saveNetworkNodesFromGraph(); - } - - // Always let host handle YAML removal + link cleanup - void deleteNode(nodeId); - }, - [getNodes, removeNodeAndEdges, menuHandlers] - ); - - const handleDeleteLink = React.useCallback( - (edgeId: string) => { - const edges = getEdges(); - const edge = edges.find((e) => e.id === edgeId); - removeEdge(edgeId); - menuHandlers.handleDeleteLink(edgeId); - if (edge) { - void deleteLink(toLinkSaveData(edge)); - } - }, - [getEdges, removeEdge, menuHandlers] - ); - - return { - handleEdgeCreated, - handleNodeCreatedCallback, - handleBatchPaste, - handleDeleteNode, - handleDeleteLink - }; -} diff --git a/src/reactTopoViewer/webview/hooks/ui/index.ts b/src/reactTopoViewer/webview/hooks/ui/index.ts deleted file mode 100644 index 731ec3cf3..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -// UI hooks barrel. - -// Commands -export { useDeploymentCommands, usePanelVisibility } from "./usePanelCommands"; -export type { DeploymentCommands, PanelVisibility } from "./usePanelCommands"; - -// Context Panel Content -export { useContextPanelContent } from "./useContextPanelContent"; -export type { PanelView, PanelViewKind } from "./useContextPanelContent"; - -// Panel Tab Visibility -export { usePanelTabVisibility } from "./usePanelTabVisibility"; -export type { PanelTabVisibility } from "./usePanelTabVisibility"; - -// Footer Refs -export { useFooterControlsRef } from "./useFooterControlsRef"; -export type { FooterControlsRef } from "./useFooterControlsRef"; - -// Editor Button Handlers -export { useApplySaveHandlers } from "./useApplySaveHandlers"; - -// Shake Animation -export { useShakeAnimation } from "./useShakeAnimation"; - -// Keyboard & Shortcuts -export { useKeyboardShortcuts } from "./useKeyboardShortcuts"; -export { useShortcutDisplay } from "./useShortcutDisplay"; - -// App Handlers -export { useAppHandlers } from "./useAppHandlers"; - -// Click, Escape, Hover -export { useClickOutside, useEscapeKey, useDelayedHover } from "./useDomInteractions"; -export type { UseDelayedHoverReturn } from "./useDomInteractions"; - -// Dropdown Hooks -export { - useDropdown, - useDropdownState, - useDropdownKeyboard, - useFloatingDropdownKeyboard, - useFocusOnOpen -} from "./useDropdown"; -export type { - UseDropdownReturn, - DropdownKeyboardActions, - DropdownKeyboardState -} from "./useDropdown"; -export { useFilterableDropdown } from "./useFilterableDropdown"; -export type { - FilterableDropdownOption, - UseFilterableDropdownReturn -} from "./useFilterableDropdown"; - -// App State -export { - useLayoutControls, - useContextMenuHandlers, - snapToGrid, - DEFAULT_GRID_LINE_WIDTH, - DEFAULT_GRID_STYLE -} from "./useAppState"; -export type { CanvasRef, LayoutOption, GridStyle, NodeData, LinkData } from "./useAppState"; diff --git a/src/reactTopoViewer/webview/hooks/ui/useAppHandlers.ts b/src/reactTopoViewer/webview/hooks/ui/useAppHandlers.ts deleted file mode 100644 index 5a3ebbe4c..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/useAppHandlers.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * App-level handlers hook - * Combines common handlers to reduce App.tsx complexity - */ -import React from "react"; -import type { Edge, Node } from "@xyflow/react"; - -interface SelectionCallbacks { - selectNode: (id: string | null) => void; - selectEdge: (id: string | null) => void; - editNode: (id: string | null) => void; - editEdge: (id: string | null) => void; -} - -interface UseAppHandlersOptions { - selectionCallbacks: SelectionCallbacks; - rfInstance?: { - getNodes?: () => Node[]; - getEdges?: () => Edge[]; - setNodes?: (payload: Node[] | ((nodes: Node[]) => Node[])) => void; - setEdges?: (payload: Edge[] | ((edges: Edge[]) => Edge[])) => void; - } | null; -} - -export function useAppHandlers({ selectionCallbacks, rfInstance }: UseAppHandlersOptions) { - const { selectNode, selectEdge, editNode, editEdge } = selectionCallbacks; - - // Handle deselect all callback - const handleDeselectAll = React.useCallback(() => { - // Clear React Flow element selection (multi-select) if available - if ( - rfInstance !== null && - rfInstance !== undefined && - rfInstance.getNodes !== undefined && - rfInstance.setNodes !== undefined - ) { - const nodes = rfInstance.getNodes(); - if (nodes.some((node) => node.selected === true)) { - rfInstance.setNodes( - nodes.map((node) => (node.selected === true ? { ...node, selected: false } : node)) - ); - } - } - if ( - rfInstance !== null && - rfInstance !== undefined && - rfInstance.getEdges !== undefined && - rfInstance.setEdges !== undefined - ) { - const edges = rfInstance.getEdges(); - if (edges.some((edge) => edge.selected === true)) { - rfInstance.setEdges( - edges.map((edge) => (edge.selected === true ? { ...edge, selected: false } : edge)) - ); - } - } - selectNode(null); - selectEdge(null); - editNode(null); - editEdge(null); - }, [selectNode, selectEdge, editNode, editEdge, rfInstance]); - - return { - handleDeselectAll - }; -} diff --git a/src/reactTopoViewer/webview/hooks/ui/useAppState.ts b/src/reactTopoViewer/webview/hooks/ui/useAppState.ts deleted file mode 100644 index 35d82c499..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/useAppState.ts +++ /dev/null @@ -1,315 +0,0 @@ -/** - * App State Hook - * Manages layout controls and context menu handlers for React TopoViewer - */ -import type React from "react"; -import { useCallback, useEffect, useState } from "react"; - -import { useTopoViewerStore } from "../../stores/topoViewerStore"; - -/** - * Canvas ref interface for layout controls. - */ -export interface CanvasRef { - runLayout(name: string): void; -} - -export type GridStyle = "dotted" | "quadratic"; -export type LayoutOption = "preset" | "force" | "geo"; -export const DEFAULT_GRID_LINE_WIDTH = 0.5; -export const DEFAULT_GRID_STYLE: GridStyle = "dotted"; - -/** - * Node data interface for info panels - */ -export interface NodeData { - id: string; - label?: string; - name?: string; - kind?: string; - state?: string; - image?: string; - mgmtIpv4?: string; - mgmtIpv6?: string; - fqdn?: string; - [key: string]: unknown; -} - -/** - * Link data interface for info panels - */ -export interface LinkData { - id: string; - source: string; - target: string; - sourceEndpoint?: string; - targetEndpoint?: string; - [key: string]: unknown; -} - -function normalizeLayoutName(option: LayoutOption): string { - if (option === "geo") return "preset"; - return option; -} - -const GRID_SPACING = 14; -const GRID_LINE_WIDTH_MIN = 0.00001; -const GRID_LINE_WIDTH_MAX = 2; -const GRID_LINE_WIDTH_STORAGE_KEY = "react-topoviewer-grid-line-width"; -const GRID_STYLE_STORAGE_KEY = "react-topoviewer-grid-style"; - -function clampLineWidth(width: number): number { - const w = Number(width); - if (!Number.isFinite(w)) return DEFAULT_GRID_LINE_WIDTH; - return Math.min(GRID_LINE_WIDTH_MAX, Math.max(GRID_LINE_WIDTH_MIN, w)); -} - -function getStoredGridLineWidth(): number { - try { - const raw = window.localStorage.getItem(GRID_LINE_WIDTH_STORAGE_KEY); - if (raw !== null && raw.length > 0) { - return clampLineWidth(parseFloat(raw)); - } - } catch { - /* ignore storage errors */ - } - return DEFAULT_GRID_LINE_WIDTH; -} - -function storeGridLineWidth(width: number): void { - try { - window.localStorage.setItem(GRID_LINE_WIDTH_STORAGE_KEY, String(clampLineWidth(width))); - } catch { - /* ignore storage errors */ - } -} - -function parseGridStyle(raw: string | null): GridStyle { - if (raw === "quadratic" || raw === "dotted") return raw; - return DEFAULT_GRID_STYLE; -} - -function getStoredGridStyle(): GridStyle { - try { - return parseGridStyle(window.localStorage.getItem(GRID_STYLE_STORAGE_KEY)); - } catch { - /* ignore storage errors */ - } - return DEFAULT_GRID_STYLE; -} - -function storeGridStyle(style: GridStyle): void { - try { - window.localStorage.setItem(GRID_STYLE_STORAGE_KEY, style); - } catch { - /* ignore storage errors */ - } -} - -/** - * Snap a position to the nearest grid cell center. - * Grid lines are at 0, 14, 28, ... so cell centers are at 7, 21, 35, ... - * Exported for use by ReactFlow components. - */ -export function snapToGrid(value: number): number { - const halfSpacing = GRID_SPACING / 2; - return Math.round((value - halfSpacing) / GRID_SPACING) * GRID_SPACING + halfSpacing; -} - -export function useLayoutControls(canvasRef: React.RefObject): { - layout: LayoutOption; - setLayout: (layout: LayoutOption) => void; - isGeoLayout: boolean; - gridLineWidth: number; - setGridLineWidth: (width: number) => void; - gridStyle: GridStyle; - setGridStyle: (style: GridStyle) => void; - gridColor: string | null; - setGridColor: (color: string | null) => void; - gridBgColor: string | null; - setGridBgColor: (color: string | null) => void; - resetGridColors: () => void; -} { - const [layout, setLayoutState] = useState("preset"); - - const gridLineWidth = useTopoViewerStore((s) => s.gridLineWidth); - const gridStyle = useTopoViewerStore((s) => s.gridStyle); - const storeSetGridLineWidth = useTopoViewerStore((s) => s.setGridLineWidth); - const storeSetGridStyle = useTopoViewerStore((s) => s.setGridStyle); - const gridColor = useTopoViewerStore((s) => s.gridColor); - const gridBgColor = useTopoViewerStore((s) => s.gridBgColor); - const storeSetGridColor = useTopoViewerStore((s) => s.setGridColor); - const storeSetGridBgColor = useTopoViewerStore((s) => s.setGridBgColor); - - useEffect(() => { - storeSetGridLineWidth(getStoredGridLineWidth()); - storeSetGridStyle(getStoredGridStyle()); - }, [storeSetGridLineWidth, storeSetGridStyle]); - - useEffect(() => { - storeGridLineWidth(gridLineWidth); - }, [gridLineWidth]); - - useEffect(() => { - storeGridStyle(gridStyle); - }, [gridStyle]); - - const setGridLineWidth = useCallback( - (width: number) => { - const clamped = clampLineWidth(width); - storeSetGridLineWidth(clamped); - }, - [storeSetGridLineWidth] - ); - - const setGridStyle = useCallback( - (style: GridStyle) => { - storeSetGridStyle(style); - }, - [storeSetGridStyle] - ); - - const setGridColor = useCallback( - (color: string | null) => { - storeSetGridColor(color); - }, - [storeSetGridColor] - ); - - const setGridBgColor = useCallback( - (color: string | null) => { - storeSetGridBgColor(color); - }, - [storeSetGridBgColor] - ); - - const resetGridColors = useCallback(() => { - storeSetGridColor(null); - storeSetGridBgColor(null); - }, [storeSetGridColor, storeSetGridBgColor]); - - const setLayout = useCallback( - (nextLayout: LayoutOption) => { - setLayoutState(nextLayout); - const cyApi = canvasRef.current; - if (!cyApi) return; - const normalized = normalizeLayoutName(nextLayout); - cyApi.runLayout(normalized); - }, - [canvasRef] - ); - - return { - layout, - setLayout, - isGeoLayout: layout === "geo", - gridLineWidth, - setGridLineWidth, - gridStyle, - setGridStyle, - gridColor, - setGridColor, - gridBgColor, - setGridBgColor, - resetGridColors - }; -} - -interface SelectionCallbacks { - selectNode: (id: string | null) => void; - selectEdge: (id: string | null) => void; - editNode: (id: string | null) => void; - editEdge: (id: string | null) => void; - editNetwork: (id: string | null) => void; - onDeleteNode?: (id: string) => void; - onDeleteEdge?: (id: string) => void; -} - -interface ContextMenuHandlersResult { - handleEditNode: (nodeId: string) => void; - handleEditNetwork: (nodeId: string) => void; - handleDeleteNode: (nodeId: string) => void; - handleCreateLinkFromNode: (nodeId: string) => void; - handleEditLink: (edgeId: string) => void; - handleDeleteLink: (edgeId: string) => void; - handleShowNodeProperties: (nodeId: string) => void; - handleShowLinkProperties: (edgeId: string) => void; - handleCloseNodePanel: () => void; - handleCloseLinkPanel: () => void; -} - -/** - * Hook for context menu handlers - */ -export function useContextMenuHandlers(callbacks: SelectionCallbacks): ContextMenuHandlersResult { - const { selectNode, selectEdge, editNode, editEdge, editNetwork, onDeleteNode, onDeleteEdge } = - callbacks; - - const handleEditNode = useCallback( - (nodeId: string) => { - editNode(nodeId); - }, - [editNode] - ); - - const handleEditNetwork = useCallback( - (nodeId: string) => { - editNetwork(nodeId); - }, - [editNetwork] - ); - - const handleCreateLinkFromNode = useCallback((_nodeId: string) => { - // Link creation handled by ReactFlow edge handles - }, []); - - const handleShowNodeProperties = useCallback( - (nodeId: string) => { - selectNode(nodeId); - }, - [selectNode] - ); - - const handleShowLinkProperties = useCallback( - (edgeId: string) => { - selectEdge(edgeId); - }, - [selectEdge] - ); - - const handleEditLink = useCallback( - (edgeId: string) => { - editEdge(edgeId); - }, - [editEdge] - ); - const handleCloseNodePanel = useCallback(() => selectNode(null), [selectNode]); - const handleCloseLinkPanel = useCallback(() => selectEdge(null), [selectEdge]); - - const handleDeleteNode = useCallback( - (nodeId: string) => { - onDeleteNode?.(nodeId); - }, - [onDeleteNode] - ); - - const handleDeleteLink = useCallback( - (edgeId: string) => { - onDeleteEdge?.(edgeId); - }, - [onDeleteEdge] - ); - - return { - handleEditNode, - handleEditNetwork, - handleDeleteNode, - handleCreateLinkFromNode, - handleEditLink, - handleDeleteLink, - handleShowNodeProperties, - handleShowLinkProperties, - handleCloseNodePanel, - handleCloseLinkPanel - }; -} diff --git a/src/reactTopoViewer/webview/hooks/ui/useApplySaveHandlers.ts b/src/reactTopoViewer/webview/hooks/ui/useApplySaveHandlers.ts deleted file mode 100644 index 3503fc900..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/useApplySaveHandlers.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useCallback } from "react"; - -/** - * Small helper hook for editors that expose Apply + Save callbacks - * based on an optional formData object. - */ -export function useApplySaveHandlers( - formData: T | null, - onApply: (data: T) => void, - onSave: (data: T) => void, - afterApply?: () => void -): { handleApply: () => void; handleSave: () => void } { - const handleApply = useCallback(() => { - if (formData === null) return; - onApply(formData); - afterApply?.(); - }, [formData, onApply, afterApply]); - - const handleSave = useCallback(() => { - if (formData === null) return; - onSave(formData); - }, [formData, onSave]); - - return { handleApply, handleSave }; -} diff --git a/src/reactTopoViewer/webview/hooks/ui/useContextPanelContent.ts b/src/reactTopoViewer/webview/hooks/ui/useContextPanelContent.ts deleted file mode 100644 index afd68c0fe..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/useContextPanelContent.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * useContextPanelContent - Determines what the ContextPanel should display - * based on selection/editing state from stores. - * - * Priority: editing states > selection states > palette (default) - */ -import { useTopoViewerState } from "../../stores"; -import type { TopoViewerState } from "../../stores/topoViewerStore"; -import { useAnnotationUIStore } from "../../stores/annotationUIStore"; -import type { AnnotationUIState } from "../../stores/annotationUIStore"; - -export type PanelViewKind = - | "palette" - | "nodeInfo" - | "linkInfo" - | "nodeEditor" - | "linkEditor" - | "networkEditor" - | "linkImpairment" - | "freeTextEditor" - | "freeShapeEditor" - | "trafficRateEditor" - | "groupEditor"; - -export interface PanelView { - kind: PanelViewKind; - title: string; - /** Whether the view has editor footer (Apply button) */ - hasFooter: boolean; -} - -const PALETTE_VIEW: PanelView = { kind: "palette", title: "Palette", hasFooter: false }; - -function hasId(value: string | null): value is string { - return value !== null && value.length > 0; -} - -function resolveEditingView( - state: Pick< - TopoViewerState, - "editingNode" | "editingEdge" | "editingNetwork" | "editingImpairment" - > -): PanelView | null { - if (hasId(state.editingNode)) - return { kind: "nodeEditor", title: "Node Editor", hasFooter: true }; - if (hasId(state.editingEdge)) - return { kind: "linkEditor", title: "Link Editor", hasFooter: true }; - if (hasId(state.editingNetwork)) - return { kind: "networkEditor", title: "Network Editor", hasFooter: true }; - if (hasId(state.editingImpairment)) - return { kind: "linkImpairment", title: "Link Impairments", hasFooter: true }; - return null; -} - -function resolveAnnotationView(annotationUI: AnnotationUIState): PanelView | null { - if (annotationUI.editingTextAnnotation) { - const isNew = annotationUI.editingTextAnnotation.text === ""; - return { kind: "freeTextEditor", title: isNew ? "Add Text" : "Edit Text", hasFooter: true }; - } - if (annotationUI.editingShapeAnnotation) { - const shapeType = annotationUI.editingShapeAnnotation.shapeType; - const prefix = shapeType.charAt(0).toUpperCase() + shapeType.slice(1); - return { kind: "freeShapeEditor", title: `Edit ${prefix}`, hasFooter: true }; - } - if (annotationUI.editingTrafficRateAnnotation) { - return { kind: "trafficRateEditor", title: "Edit Traffic Rate", hasFooter: true }; - } - if (annotationUI.editingGroup) - return { kind: "groupEditor", title: "Edit Group", hasFooter: true }; - return null; -} - -function resolveSelectionView( - state: Pick -): PanelView | null { - if (hasId(state.selectedNode) && state.mode === "view") - return { - kind: "nodeInfo", - title: "Node Properties", - // In unlocked view mode, node selection can also drive visual edits (Icon/Label/Direction). - hasFooter: !state.isLocked - }; - if (hasId(state.selectedEdge) && state.mode === "view") - return { kind: "linkInfo", title: "Link Properties", hasFooter: false }; - return null; -} - -export function useContextPanelContent(): PanelView { - const state = useTopoViewerState(); - const annotationUI = useAnnotationUIStore(); - - return ( - resolveEditingView(state) ?? - resolveAnnotationView(annotationUI) ?? - resolveSelectionView(state) ?? - PALETTE_VIEW - ); -} diff --git a/src/reactTopoViewer/webview/hooks/ui/useDomInteractions.ts b/src/reactTopoViewer/webview/hooks/ui/useDomInteractions.ts deleted file mode 100644 index 4082e7db7..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/useDomInteractions.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * DOM Interaction Hooks - Consolidated hooks for common DOM interactions - * Includes: useEscapeKey, useClickOutside, useDelayedHover - */ -import type { RefObject } from "react"; -import { useEffect, useState, useRef, useCallback } from "react"; - -// ============================================================================ -// useEscapeKey - Hook for handling ESC key to close modals/panels -// ============================================================================ - -/** - * Hook that calls onClose when ESC key is pressed while isOpen is true - */ -export function useEscapeKey(isOpen: boolean, onClose: () => void): void { - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && isOpen) onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [isOpen, onClose]); -} - -// ============================================================================ -// useClickOutside - Hook to detect clicks outside an element -// ============================================================================ - -/** - * Hook that calls a callback when clicking outside the referenced element - */ -export function useClickOutside( - ref: RefObject, - callback: () => void, - enabled: boolean = true -): void { - useEffect(() => { - if (!enabled) return; - - const handleClick = (e: MouseEvent) => { - const target = e.target; - if (!(target instanceof Node)) return; - const element = ref.current; - if (element !== null && !element.contains(target)) { - callback(); - } - }; - - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [ref, callback, enabled]); -} - -// ============================================================================ -// useDelayedHover - Hook for delayed hover state with timeout -// ============================================================================ - -export interface UseDelayedHoverReturn { - isHovered: boolean; - onEnter: () => void; - onLeave: () => void; -} - -/** - * Hook that manages hover state with a delay before unhover - * Useful for showing/hiding UI elements that shouldn't disappear immediately - */ -export function useDelayedHover(delay: number = 150): UseDelayedHoverReturn { - const [isHovered, setIsHovered] = useState(false); - const timeoutRef = useRef | null>(null); - - const onEnter = useCallback(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - setIsHovered(true); - }, []); - - const onLeave = useCallback(() => { - timeoutRef.current = setTimeout(() => setIsHovered(false), delay); - }, [delay]); - - return { isHovered, onEnter, onLeave }; -} diff --git a/src/reactTopoViewer/webview/hooks/ui/useDropdown.ts b/src/reactTopoViewer/webview/hooks/ui/useDropdown.ts deleted file mode 100644 index edf9d9c56..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/useDropdown.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Dropdown hooks for various dropdown patterns in the UI - * Consolidates: simple toggle, hover-based with filter, and keyboard navigation - */ -import type React from "react"; -import { useState, useRef, useEffect, useCallback } from "react"; - -// ============================================================================ -// Simple Toggle Dropdown (used by Navbar) -// ============================================================================ - -export interface UseDropdownReturn { - isOpen: boolean; - toggle: () => void; - close: () => void; - ref: React.RefObject; -} - -/** - * Simple dropdown state hook with click-outside detection - * Used by navbar dropdowns and similar components - */ -export function useDropdown(): UseDropdownReturn { - const [isOpen, setIsOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (ref.current && event.target instanceof Node && !ref.current.contains(event.target)) { - setIsOpen(false); - } - }; - const handlePaneClick = () => { - setIsOpen(false); - }; - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("topoviewer:pane-click", handlePaneClick as EventListener); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("topoviewer:pane-click", handlePaneClick as EventListener); - }; - }, []); - - return { - isOpen, - toggle: () => setIsOpen((prev) => !prev), - close: () => setIsOpen(false), - ref - }; -} - -// ============================================================================ -// Hover-based Dropdown with Filter (used by FloatingPanel) -// ============================================================================ - -/** - * Hook for dropdown state management with hover behavior - * Used by toolbar dropdown menus - */ -export function useDropdownState(disabled: boolean) { - const [isOpen, setIsOpen] = useState(false); - const [filter, setFilter] = useState(""); - const [focusedIndex, setFocusedIndex] = useState(-1); - const closeTimeoutRef = useRef | null>(null); - - const resetState = useCallback(() => { - setIsOpen(false); - setFilter(""); - setFocusedIndex(-1); - }, []); - - const handleMouseEnter = useCallback(() => { - if (disabled) return; - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current); - closeTimeoutRef.current = null; - } - setIsOpen(true); - }, [disabled]); - - const handleMouseLeave = useCallback(() => { - closeTimeoutRef.current = setTimeout(resetState, 150); - }, [resetState]); - - useEffect(() => { - return () => { - if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); - }; - }, []); - - return { - isOpen, - filter, - focusedIndex, - setFilter, - setFocusedIndex, - resetState, - handleMouseEnter, - handleMouseLeave - }; -} - -/** - * Hook for keyboard navigation in toolbar dropdowns - * Different from useDropdownKeyboard which is for form field dropdowns - */ -interface FloatingDropdownKeyboardParams { - isOpen: boolean; - itemCount: number; - focusedIndex: number; - setFocusedIndex: React.Dispatch>; - onSelectFocused: () => void; - onEscape: () => void; -} - -export function useFloatingDropdownKeyboard(params: FloatingDropdownKeyboardParams) { - const { isOpen, itemCount, focusedIndex, setFocusedIndex, onSelectFocused, onEscape } = params; - - return useCallback( - (e: React.KeyboardEvent) => { - if (!isOpen) return; - if (e.key === "ArrowDown") { - e.preventDefault(); - setFocusedIndex((prev) => Math.min(prev + 1, itemCount - 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setFocusedIndex((prev) => Math.max(prev - 1, -1)); - } else if (e.key === "Enter") { - e.preventDefault(); - if (focusedIndex >= 0 && focusedIndex < itemCount) onSelectFocused(); - } else if (e.key === "Escape") { - onEscape(); - } - }, - [isOpen, itemCount, focusedIndex, setFocusedIndex, onSelectFocused, onEscape] - ); -} - -/** - * Focus input when dropdown opens - */ -export function useFocusOnOpen( - isOpen: boolean, - inputRef: React.RefObject -) { - useEffect(() => { - if (isOpen && inputRef.current) { - setTimeout(() => inputRef.current?.focus(), 50); - } - }, [isOpen, inputRef]); -} - -// ============================================================================ -// Form Field Dropdown Keyboard Navigation -// ============================================================================ - -export interface DropdownKeyboardActions { - setIsOpen: (open: boolean) => void; - setHighlightedIndex: (index: number) => void; - onSelect: (index: number) => void; - onCommit: () => void; -} - -export interface DropdownKeyboardState { - highlightedIndex: number; - optionsLength: number; - allowFreeText: boolean; -} - -/** - * Hook that provides keyboard event handler for dropdown navigation - * Used by form field dropdowns (FilterableDropdown, etc.) - */ -export function useDropdownKeyboard( - state: DropdownKeyboardState, - actions: DropdownKeyboardActions -): (e: React.KeyboardEvent) => void { - const { highlightedIndex, optionsLength, allowFreeText } = state; - const { setIsOpen, setHighlightedIndex, onSelect, onCommit } = actions; - - return useCallback( - (e: React.KeyboardEvent) => { - switch (e.key) { - case "ArrowDown": - e.preventDefault(); - setIsOpen(true); - setHighlightedIndex(Math.min(highlightedIndex + 1, optionsLength - 1)); - break; - case "ArrowUp": - e.preventDefault(); - setIsOpen(true); - setHighlightedIndex(Math.max(highlightedIndex - 1, 0)); - break; - case "Enter": - e.preventDefault(); - if (highlightedIndex >= 0) { - onSelect(highlightedIndex); - } else if (allowFreeText) { - onCommit(); - setIsOpen(false); - } - break; - case "Escape": - setIsOpen(false); - setHighlightedIndex(-1); - break; - case "Tab": - onCommit(); - setIsOpen(false); - break; - } - }, - [ - highlightedIndex, - optionsLength, - allowFreeText, - setIsOpen, - setHighlightedIndex, - onSelect, - onCommit - ] - ); -} diff --git a/src/reactTopoViewer/webview/hooks/ui/useFilterableDropdown.ts b/src/reactTopoViewer/webview/hooks/ui/useFilterableDropdown.ts deleted file mode 100644 index 66a920743..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/useFilterableDropdown.ts +++ /dev/null @@ -1,304 +0,0 @@ -/** - * useFilterableDropdown - Hook to manage filterable dropdown state and behavior - */ -import type React from "react"; -import { useState, useRef, useEffect, useCallback, useMemo } from "react"; - -import { useClickOutside } from "./useDomInteractions"; -import { useDropdownKeyboard } from "./useDropdown"; - -export interface FilterableDropdownOption { - value: string; - label: string; - icon?: string; -} - -interface UseFilterableDropdownProps { - options: FilterableDropdownOption[]; - value: string; - onChange: (value: string) => void; - allowFreeText: boolean; -} - -/** - * Find the best matching option for the given text - */ -function findBestMatch( - text: string, - options: FilterableDropdownOption[] -): FilterableDropdownOption | null { - if (!text.trim()) return null; - const lower = text.toLowerCase(); - const exactMatch = options.find( - (o) => o.value.toLowerCase() === lower || o.label.toLowerCase() === lower - ); - if (exactMatch) return exactMatch; - const startsWithMatch = options.find( - (o) => o.value.toLowerCase().startsWith(lower) || o.label.toLowerCase().startsWith(lower) - ); - if (startsWithMatch) return startsWithMatch; - return ( - options.find( - (o) => o.value.toLowerCase().includes(lower) || o.label.toLowerCase().includes(lower) - ) ?? null - ); -} - -/** - * Filter options based on filter text - */ -function filterOptions( - options: FilterableDropdownOption[], - filterText: string -): FilterableDropdownOption[] { - const lower = filterText.toLowerCase(); - return options.filter( - (o) => o.label.toLowerCase().includes(lower) || o.value.toLowerCase().includes(lower) - ); -} - -/** - * Resolve the commit value based on current state - */ -function resolveCommitValue( - filterText: string, - currentValue: string, - options: FilterableDropdownOption[], - allowFreeText: boolean -): { newFilterText: string; newValue: string | null } { - if (allowFreeText) { - return { newFilterText: filterText, newValue: filterText !== currentValue ? filterText : null }; - } - const match = findBestMatch(filterText, options); - if (match) { - return { - newFilterText: match.value, - newValue: match.value !== currentValue ? match.value : null - }; - } - return { newFilterText: currentValue, newValue: null }; -} - -/** - * Hook to manage dropdown open/close state - */ -function useDropdownOpenState(value: string) { - const [isOpen, setIsOpen] = useState(false); - const [filterText, setFilterText] = useState(value); - const [highlightedIndex, setHighlightedIndex] = useState(-1); - const [isFiltering, setIsFiltering] = useState(false); - - useEffect(() => { - setFilterText(value); - setIsFiltering(false); - }, [value]); - - useEffect(() => { - const closeDropdown = () => { - setIsOpen(false); - setIsFiltering(false); - }; - window.addEventListener("blur", closeDropdown); - document.addEventListener("visibilitychange", closeDropdown); - return () => { - window.removeEventListener("blur", closeDropdown); - document.removeEventListener("visibilitychange", closeDropdown); - }; - }, []); - - return { - isOpen, - setIsOpen, - filterText, - setFilterText, - highlightedIndex, - setHighlightedIndex, - isFiltering, - setIsFiltering - }; -} - -/** - * Hook to manage commit behavior - */ -function useCommitValue( - filterText: string, - value: string, - options: FilterableDropdownOption[], - allowFreeText: boolean, - onChange: (value: string) => void, - setFilterText: (text: string) => void -) { - return useCallback(() => { - const result = resolveCommitValue(filterText, value, options, allowFreeText); - setFilterText(result.newFilterText); - if (result.newValue !== null) { - onChange(result.newValue); - } - }, [filterText, value, options, allowFreeText, onChange, setFilterText]); -} - -/** - * Hook to manage selection behavior - */ -function useSelectionHandlers( - filteredOptions: FilterableDropdownOption[], - onChange: (value: string) => void, - setFilterText: (text: string) => void, - setIsOpen: (open: boolean) => void, - setHighlightedIndex: (index: number) => void, - setIsFiltering: (filtering: boolean) => void -) { - const handleSelect = useCallback( - (option: FilterableDropdownOption) => { - setFilterText(option.value); - onChange(option.value); - setIsOpen(false); - setHighlightedIndex(-1); - setIsFiltering(false); - }, - [onChange, setFilterText, setIsOpen, setHighlightedIndex, setIsFiltering] - ); - - const handleSelectByIndex = useCallback( - (index: number) => { - if (index < 0 || index >= filteredOptions.length) return; - handleSelect(filteredOptions[index]); - }, - [filteredOptions, handleSelect] - ); - - return { handleSelect, handleSelectByIndex }; -} - -export interface UseFilterableDropdownReturn { - containerRef: React.RefObject; - inputRef: React.RefObject; - menuRef: React.RefObject; - isOpen: boolean; - filterText: string; - highlightedIndex: number; - filteredOptions: FilterableDropdownOption[]; - handleSelect: (option: FilterableDropdownOption) => void; - handleKeyDown: (e: React.KeyboardEvent) => void; - handleBlur: () => void; - handleInputChange: (e: React.ChangeEvent) => void; - handleToggle: () => void; - handleFocus: () => void; - setHighlightedIndex: (index: number) => void; -} - -/** - * Main hook for filterable dropdown behavior - */ -export function useFilterableDropdown({ - options, - value, - onChange, - allowFreeText -}: UseFilterableDropdownProps): UseFilterableDropdownReturn { - const state = useDropdownOpenState(value); - const { - isOpen, - setIsOpen, - filterText, - setFilterText, - highlightedIndex, - setHighlightedIndex, - isFiltering, - setIsFiltering - } = state; - - const containerRef = useRef(null); - const inputRef = useRef(null); - const menuRef = useRef(null); - - const filteredOptions = useMemo( - () => (isFiltering ? filterOptions(options, filterText) : options), - [isFiltering, options, filterText] - ); - - const commitValue = useCommitValue( - filterText, - value, - options, - allowFreeText, - onChange, - setFilterText - ); - const { handleSelect, handleSelectByIndex } = useSelectionHandlers( - filteredOptions, - onChange, - setFilterText, - setIsOpen, - setHighlightedIndex, - setIsFiltering - ); - - const handleKeyDown = useDropdownKeyboard( - { highlightedIndex, optionsLength: filteredOptions.length, allowFreeText }, - { setIsOpen, setHighlightedIndex, onSelect: handleSelectByIndex, onCommit: commitValue } - ); - - const handleClickOutside = useCallback(() => { - commitValue(); - setIsOpen(false); - setIsFiltering(false); - }, [commitValue, setIsOpen, setIsFiltering]); - - useClickOutside(containerRef, handleClickOutside, true); - - useEffect(() => { - if (highlightedIndex >= 0 && menuRef.current) { - const item = menuRef.current - .querySelectorAll("[data-dropdown-item]") - .item(highlightedIndex); - item.scrollIntoView({ block: "nearest" }); - } - }, [highlightedIndex]); - - const handleBlur = useCallback(() => { - setTimeout(() => { - const container = containerRef.current; - if (container === null || !container.contains(document.activeElement)) { - commitValue(); - setIsOpen(false); - setIsFiltering(false); - } - }, 150); - }, [commitValue, setIsOpen, setIsFiltering]); - - const handleInputChange = useCallback( - (e: React.ChangeEvent) => { - setFilterText(e.target.value); - setIsOpen(true); - setHighlightedIndex(-1); - setIsFiltering(true); - }, - [setFilterText, setIsOpen, setHighlightedIndex, setIsFiltering] - ); - - const handleToggle = useCallback(() => { - setIsOpen(!isOpen); - inputRef.current?.focus(); - }, [isOpen, setIsOpen]); - - const handleFocus = useCallback(() => setIsOpen(true), [setIsOpen]); - - return { - containerRef, - inputRef, - menuRef, - isOpen, - filterText, - highlightedIndex, - filteredOptions, - handleSelect, - handleKeyDown, - handleBlur, - handleInputChange, - handleToggle, - handleFocus, - setHighlightedIndex - }; -} diff --git a/src/reactTopoViewer/webview/hooks/ui/useFooterControlsRef.ts b/src/reactTopoViewer/webview/hooks/ui/useFooterControlsRef.ts deleted file mode 100644 index f1836ffa8..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/useFooterControlsRef.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useEffect } from "react"; - -export type FooterControlsRef = { - handleApply: () => void; - handleSave: () => void; - handleDiscard: () => void; - hasChanges: boolean; -}; - -// Standardizes footer ref wiring for editor panels. -export function useFooterControlsRef( - onFooterRef: ((ref: FooterControlsRef | null) => void) | undefined, - enabled: boolean, - handleApply: () => void, - handleSave: () => void, - hasChanges: boolean, - handleDiscard?: () => void -): void { - useEffect(() => { - if (!onFooterRef) return; - if (!enabled) { - onFooterRef(null); - return; - } - onFooterRef({ - handleApply, - handleSave, - handleDiscard: handleDiscard ?? (() => {}), - hasChanges - }); - }, [onFooterRef, enabled, handleApply, handleSave, handleDiscard, hasChanges]); - - useEffect( - () => () => { - onFooterRef?.(null); - }, - [onFooterRef] - ); -} diff --git a/src/reactTopoViewer/webview/hooks/ui/useKeyboardShortcuts.ts b/src/reactTopoViewer/webview/hooks/ui/useKeyboardShortcuts.ts deleted file mode 100644 index 43fef4f3f..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/useKeyboardShortcuts.ts +++ /dev/null @@ -1,667 +0,0 @@ -/** - * useKeyboardShortcuts - Hook for keyboard shortcuts - */ -import { useEffect, useCallback } from "react"; - -import { log } from "../../utils/logger"; -import { useGraphStore } from "../../stores/graphStore"; -import { - FREE_TEXT_NODE_TYPE, - FREE_SHAPE_NODE_TYPE, - TRAFFIC_RATE_NODE_TYPE, - GROUP_NODE_TYPE -} from "../../annotations/annotationNodeConverters"; - -interface KeyboardShortcutsOptions { - mode: "edit" | "view"; - isLocked: boolean; - selectedNode: string | null; - selectedEdge: string | null; - onDeleteNode: (nodeId: string) => void; - onDeleteEdge: (edgeId: string) => void; - onDeleteSelection?: () => void; - onDeselectAll: () => void; - /** Undo handler (Ctrl+Z) */ - onUndo?: () => void; - /** Redo handler (Ctrl+Y / Ctrl+Shift+Z) */ - onRedo?: () => void; - /** Whether undo is available */ - canUndo?: boolean; - /** Whether redo is available */ - canRedo?: boolean; - /** Copy handler (Ctrl+C) */ - onCopy?: () => void; - /** Paste handler (Ctrl+V) */ - onPaste?: () => void; - /** Duplicate handler (Ctrl+D) */ - onDuplicate?: () => void; - /** Selected annotation IDs */ - selectedAnnotationIds?: Set; - /** Copy annotations handler */ - onCopyAnnotations?: () => void; - /** Paste annotations handler */ - onPasteAnnotations?: () => void; - /** Duplicate annotations handler */ - onDuplicateAnnotations?: () => void; - /** Delete selected annotations handler */ - onDeleteAnnotations?: () => void; - /** Clear annotation selection */ - onClearAnnotationSelection?: () => void; - /** Check if annotation clipboard has content */ - hasAnnotationClipboard?: () => boolean; - /** Check if graph clipboard has content */ - hasGraphClipboard?: () => boolean; - /** Create group from selected nodes (Ctrl+G) */ - onCreateGroup?: () => void; -} - -const EDITABLE_SELECTOR = [ - "input", - "textarea", - "[contenteditable='']", - "[contenteditable='true']", - "[contenteditable='plaintext-only']", - "[role='textbox']", - ".monaco-editor", - ".monaco-inputbox", - ".monaco-findInput" -].join(","); - -function isEditableElement(element: Element | null): boolean { - if (element == null) return false; - if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) return true; - if (element instanceof HTMLElement && element.isContentEditable) return true; - return element.matches(EDITABLE_SELECTOR) || Boolean(element.closest(EDITABLE_SELECTOR)); -} - -function hasMonacoFocus(): boolean { - const activeElement = document.activeElement; - if (!(activeElement instanceof Element)) return false; - if (activeElement.classList.contains("inputarea")) return true; - if (isEditableElement(activeElement)) return true; - return Boolean(activeElement.closest(".monaco-editor, .monaco-inputbox, .monaco-findInput")); -} - -/** - * Check if keyboard focus is currently in an editable/input context. - */ -function isInputElement(event: KeyboardEvent): boolean { - if (hasMonacoFocus()) return true; - - if (event.target instanceof Element && isEditableElement(event.target)) { - return true; - } - - const path = event.composedPath(); - for (const entry of path) { - if (entry instanceof Element && isEditableElement(entry)) { - return true; - } - } - - if (document.activeElement instanceof Element && isEditableElement(document.activeElement)) { - return true; - } - - return false; -} - -/** - * Handle Ctrl+Z: Undo - */ -function handleUndo( - event: KeyboardEvent, - mode: "edit" | "view", - canUndo: boolean, - onUndo?: () => void -): boolean { - if (mode !== "edit") return false; - if (!(event.ctrlKey || event.metaKey)) return false; - if (event.key !== "z" || event.shiftKey) return false; - if (!canUndo || !onUndo) return false; - - log.info("[Keyboard] Undo"); - onUndo(); - event.preventDefault(); - return true; -} - -/** - * Handle Ctrl+Y or Ctrl+Shift+Z: Redo - */ -function handleRedo( - event: KeyboardEvent, - mode: "edit" | "view", - canRedo: boolean, - onRedo?: () => void -): boolean { - if (mode !== "edit") return false; - if (!(event.ctrlKey || event.metaKey)) return false; - if (!canRedo || !onRedo) return false; - - // Ctrl+Y or Ctrl+Shift+Z - const isCtrlY = event.key === "y"; - const isCtrlShiftZ = event.key === "z" && event.shiftKey; - if (!isCtrlY && !isCtrlShiftZ) return false; - - log.info("[Keyboard] Redo"); - onRedo(); - event.preventDefault(); - return true; -} - -/** - * Handle Ctrl+C: Copy (nodes/edges and/or annotations) - */ -function handleCopy( - event: KeyboardEvent, - onCopy?: () => void, - selectedAnnotationIds?: Set, - onCopyAnnotations?: () => void -): boolean { - if (!(event.ctrlKey || event.metaKey)) return false; - if (event.key !== "c") return false; - - let handled = false; - - // Copy annotations if any are selected - if (selectedAnnotationIds && selectedAnnotationIds.size > 0 && onCopyAnnotations) { - log.info("[Keyboard] Copy annotations"); - onCopyAnnotations(); - handled = true; - } - - // Also copy graph elements if any are selected - // Note: Selection state is managed by ReactFlow - // The onCopy handler should check selection state internally - if (onCopy) { - log.info("[Keyboard] Copy graph elements"); - onCopy(); - handled = true; - } - - if (handled) { - event.preventDefault(); - } - return handled; -} - -/** - * Handle Ctrl+V: Paste (nodes/edges and/or annotations) - */ -function handlePaste( - event: KeyboardEvent, - mode: "edit" | "view", - isLocked: boolean, - onPaste?: () => void, - onPasteAnnotations?: () => void, - hasAnnotationClipboard?: () => boolean, - hasGraphClipboard?: () => boolean -): boolean { - if (mode !== "edit") return false; - if (isLocked) return false; - if (!(event.ctrlKey || event.metaKey)) return false; - if (event.key !== "v") return false; - - let handled = false; - - // Paste annotations if clipboard has any - if (onPasteAnnotations && hasAnnotationClipboard && hasAnnotationClipboard()) { - log.info("[Keyboard] Paste annotations"); - onPasteAnnotations(); - handled = true; - } - - // Also paste graph elements if clipboard has any - if (onPaste && (!hasGraphClipboard || hasGraphClipboard())) { - log.info("[Keyboard] Paste graph elements"); - onPaste(); - handled = true; - } - - if (handled) { - event.preventDefault(); - } - return handled; -} - -/** - * Handle Ctrl+D: Duplicate (nodes/edges and/or annotations) - */ -function handleDuplicate( - event: KeyboardEvent, - mode: "edit" | "view", - isLocked: boolean, - onDuplicate?: () => void, - selectedAnnotationIds?: Set, - onDuplicateAnnotations?: () => void -): boolean { - if (mode !== "edit") return false; - if (isLocked) return false; - if (!(event.ctrlKey || event.metaKey)) return false; - if (event.key !== "d") return false; - - let handled = false; - - // Duplicate annotations if any are selected - if (selectedAnnotationIds && selectedAnnotationIds.size > 0 && onDuplicateAnnotations) { - log.info("[Keyboard] Duplicate annotations"); - onDuplicateAnnotations(); - handled = true; - } - - // Also duplicate graph elements if any are selected - // Note: Selection state is managed by ReactFlow - // The onDuplicate handler should check selection state internally - if (onDuplicate) { - log.info("[Keyboard] Duplicate graph elements"); - onDuplicate(); - handled = true; - } - - if (handled) { - event.preventDefault(); - } - return handled; -} - -/** - * Handle Ctrl+G: Create group from selected nodes - * Note: Selection state and node filtering is handled by the onCreateGroup callback - * since ReactFlow manages selection state directly - */ -function handleCreateGroup( - event: KeyboardEvent, - mode: "edit" | "view", - onCreateGroup?: () => void -): boolean { - if (mode !== "edit") return false; - if (!(event.ctrlKey || event.metaKey)) return false; - if (event.key.toLowerCase() !== "g") return false; - if (!onCreateGroup) return false; - - log.info("[Keyboard] Creating group from selected nodes"); - onCreateGroup(); - event.preventDefault(); - return true; -} - -/** - * Handle Ctrl+A: Select all nodes - * Note: Selection is now handled by ReactFlow natively via its built-in select all - * Returns true when the shortcut is recognized (but doesn't prevent default), - * false when the key combination doesn't match. - */ -function handleSelectAll(event: KeyboardEvent): boolean { - if (!(event.ctrlKey || event.metaKey) || event.key !== "a") return false; - - const target = event.target; - if (target instanceof HTMLElement) { - if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) { - return false; - } - } - - const { nodes, edges, setNodes, setEdges } = useGraphStore.getState(); - setNodes(nodes.map((n) => ({ ...n, selected: true }))); - setEdges(edges.map((e) => ({ ...e, selected: true }))); - - log.info("[Keyboard] Select all nodes and edges"); - event.preventDefault(); - return true; -} - -function hasSelectedId(value: string | null): value is string { - return value !== null && value.length > 0; -} - -/** - * Delete annotations if any are selected. - */ -function deleteSelectedAnnotations( - selectedAnnotationIds: Set | undefined, - onDeleteAnnotations: (() => void) | undefined -): boolean { - if (!selectedAnnotationIds || selectedAnnotationIds.size === 0 || !onDeleteAnnotations) - return false; - log.info(`[Keyboard] Deleting ${selectedAnnotationIds.size} annotations`); - onDeleteAnnotations(); - return true; -} - -/** - * Delete selected elements (nodes and edges). - * Note: Selection state is now managed by ReactFlow. - * This function uses the selectedNode/selectedEdge params passed from the parent. - */ -function deleteSelectedElements( - selectedNode: string | null, - selectedEdge: string | null, - onDeleteNode: (nodeId: string) => void, - onDeleteEdge: (edgeId: string) => void -): boolean { - let handled = false; - - if (!hasSelectedId(selectedNode) && !hasSelectedId(selectedEdge)) { - const { nodes, edges } = useGraphStore.getState(); - const selectedNodes = nodes.filter((n) => n.selected === true); - const selectedEdges = edges.filter((e) => e.selected === true); - - if (selectedNodes.length > 0) { - log.info(`[Keyboard] Deleting ${selectedNodes.length} selected nodes`); - selectedNodes.forEach((node) => onDeleteNode(node.id)); - return true; - } - - if (selectedEdges.length > 0) { - log.info(`[Keyboard] Deleting ${selectedEdges.length} selected edges`); - selectedEdges.forEach((edge) => onDeleteEdge(edge.id)); - return true; - } - } - - if (hasSelectedId(selectedNode)) { - log.info(`[Keyboard] Deleting node: ${selectedNode}`); - onDeleteNode(selectedNode); - handled = true; - } - - if (hasSelectedId(selectedEdge)) { - log.info(`[Keyboard] Deleting edge: ${selectedEdge}`); - onDeleteEdge(selectedEdge); - handled = true; - } - - return handled; -} - -function isAnnotationType(type: string | undefined): boolean { - return ( - type === FREE_TEXT_NODE_TYPE || - type === FREE_SHAPE_NODE_TYPE || - type === GROUP_NODE_TYPE || - type === TRAFFIC_RATE_NODE_TYPE - ); -} - -function handleDeleteInViewMode( - event: KeyboardEvent, - selectedNode: string | null, - selectedEdge: string | null, - onDeleteSelection: (() => void) | undefined, - selectedAnnotationIds: Set | undefined, - onDeleteAnnotations: (() => void) | undefined -): boolean { - const { nodes, edges } = useGraphStore.getState(); - const selectedNodes = nodes.filter((node) => node.selected === true); - const hasSelectedEdges = - edges.some((edge) => edge.selected === true) || hasSelectedId(selectedEdge); - const hasSelectedAnnotationNodes = selectedNodes.some((node) => isAnnotationType(node.type)); - const hasSelectedNonAnnotationNode = selectedNodes.some((node) => !isAnnotationType(node.type)); - - // If canvas selection includes only annotation nodes, use batched delete path - // so deletion works even when annotation UI selection is out of sync. - if ( - onDeleteSelection && - hasSelectedAnnotationNodes && - !hasSelectedEdges && - !hasSelectedNonAnnotationNode && - !hasSelectedId(selectedNode) - ) { - log.info("[Keyboard] Deleting selected annotation nodes (view mode)"); - onDeleteSelection(); - event.preventDefault(); - return true; - } - - const handled = deleteSelectedAnnotations(selectedAnnotationIds, onDeleteAnnotations); - if (handled) event.preventDefault(); - return handled; -} - -function handleBatchedDeleteInEditMode( - event: KeyboardEvent, - selectedNode: string | null, - selectedEdge: string | null, - onDeleteSelection: (() => void) | undefined, - selectedAnnotationIds: Set | undefined -): boolean { - if (!onDeleteSelection) return false; - - const { nodes, edges } = useGraphStore.getState(); - const selectedNodeIds = nodes.filter((node) => node.selected === true).map((node) => node.id); - const selectedEdgeIds = edges.filter((edge) => edge.selected === true).map((edge) => edge.id); - let totalSelected = - selectedNodeIds.length + selectedEdgeIds.length + (selectedAnnotationIds?.size ?? 0); - - if (hasSelectedId(selectedNode) && !selectedNodeIds.includes(selectedNode)) { - totalSelected += 1; - } - if (hasSelectedId(selectedEdge) && !selectedEdgeIds.includes(selectedEdge)) { - totalSelected += 1; - } - - if (totalSelected === 0) { - return false; - } - - log.info(`[Keyboard] Deleting ${totalSelected} selected items (batched)`); - onDeleteSelection(); - event.preventDefault(); - return true; -} - -/** - * Handle Delete/Backspace: Delete selected element (nodes/edges and/or annotations) - */ -function handleDelete( - event: KeyboardEvent, - mode: "edit" | "view", - isLocked: boolean, - selectedNode: string | null, - selectedEdge: string | null, - onDeleteNode: (nodeId: string) => void, - onDeleteEdge: (edgeId: string) => void, - onDeleteSelection: (() => void) | undefined, - selectedAnnotationIds?: Set, - onDeleteAnnotations?: () => void -): boolean { - if (event.key !== "Delete" && event.key !== "Backspace") return false; - if (isLocked) return false; - - // In view mode (running/deployed labs), allow deleting annotations only when unlocked. - if (mode !== "edit") { - return handleDeleteInViewMode( - event, - selectedNode, - selectedEdge, - onDeleteSelection, - selectedAnnotationIds, - onDeleteAnnotations - ); - } - - if ( - handleBatchedDeleteInEditMode( - event, - selectedNode, - selectedEdge, - onDeleteSelection, - selectedAnnotationIds - ) - ) { - return true; - } - - let handled = deleteSelectedAnnotations(selectedAnnotationIds, onDeleteAnnotations); - - // Delete selected graph elements - if (deleteSelectedElements(selectedNode, selectedEdge, onDeleteNode, onDeleteEdge)) { - handled = true; - } - - if (handled) event.preventDefault(); - return handled; -} - -/** - * Handle Escape: Deselect all / close panels - */ -function handleEscape( - event: KeyboardEvent, - selectedNode: string | null, - selectedEdge: string | null, - onDeselectAll: () => void, - selectedAnnotationIds?: Set, - onClearAnnotationSelection?: () => void -): boolean { - if (event.key !== "Escape") return false; - - // Clear annotation selection - if (selectedAnnotationIds && selectedAnnotationIds.size > 0 && onClearAnnotationSelection) { - log.debug("[Keyboard] Clearing annotation selection"); - onClearAnnotationSelection(); - event.preventDefault(); - return true; - } - - // NOTE: Element deselection is handled via onDeselectAll callback - // ReactFlow manages selection state internally - if (hasSelectedId(selectedNode) || hasSelectedId(selectedEdge)) { - log.debug("[Keyboard] Deselecting all"); - onDeselectAll(); - event.preventDefault(); - return true; - } - - // Also clear multi-selection even when there is no single selected element - onDeselectAll(); - event.preventDefault(); - return true; -} - -/** - * Hook for managing keyboard shortcuts - */ -export function useKeyboardShortcuts(options: KeyboardShortcutsOptions): void { - const { - mode, - isLocked, - selectedNode, - selectedEdge, - onDeleteNode, - onDeleteEdge, - onDeleteSelection, - onDeselectAll, - onUndo, - onRedo, - canUndo = false, - canRedo = false, - onCopy, - onPaste, - onDuplicate, - selectedAnnotationIds, - onCopyAnnotations, - onPasteAnnotations, - onDuplicateAnnotations, - onDeleteAnnotations, - onClearAnnotationSelection, - hasAnnotationClipboard, - hasGraphClipboard, - onCreateGroup - } = options; - - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if (isInputElement(event)) return; - - // Undo/Redo must be checked before other shortcuts - if (handleUndo(event, mode, canUndo, onUndo)) return; - if (handleRedo(event, mode, canRedo, onRedo)) return; - // Copy/Paste/Duplicate (with annotation support) - if (handleCopy(event, onCopy, selectedAnnotationIds, onCopyAnnotations)) return; - if ( - handlePaste( - event, - mode, - isLocked, - onPaste, - onPasteAnnotations, - hasAnnotationClipboard, - hasGraphClipboard - ) - ) - return; - if ( - handleDuplicate( - event, - mode, - isLocked, - onDuplicate, - selectedAnnotationIds, - onDuplicateAnnotations - ) - ) - return; - // Group shortcut (Ctrl+G) - if (handleCreateGroup(event, mode, onCreateGroup)) return; - // Other shortcuts - if (handleSelectAll(event)) return; - if ( - handleDelete( - event, - mode, - isLocked, - selectedNode, - selectedEdge, - onDeleteNode, - onDeleteEdge, - onDeleteSelection, - selectedAnnotationIds, - onDeleteAnnotations - ) - ) - return; - handleEscape( - event, - selectedNode, - selectedEdge, - onDeselectAll, - selectedAnnotationIds, - onClearAnnotationSelection - ); - }, - [ - mode, - isLocked, - selectedNode, - selectedEdge, - onDeleteNode, - onDeleteEdge, - onDeleteSelection, - onDeselectAll, - onUndo, - onRedo, - canUndo, - canRedo, - onCopy, - onPaste, - onDuplicate, - selectedAnnotationIds, - onCopyAnnotations, - onPasteAnnotations, - onDuplicateAnnotations, - onDeleteAnnotations, - onClearAnnotationSelection, - hasAnnotationClipboard, - hasGraphClipboard, - onCreateGroup - ] - ); - - useEffect(() => { - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [handleKeyDown]); -} diff --git a/src/reactTopoViewer/webview/hooks/ui/usePanelCommands.ts b/src/reactTopoViewer/webview/hooks/ui/usePanelCommands.ts deleted file mode 100644 index dcd72f8e2..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/usePanelCommands.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * usePanelCommands - Hooks providing deployment callbacks and panel visibility management. - * - * Simplified for the new UI model: - * - ContextPanel (left drawer) with auto-open on selection - * - MUI Dialogs for modals (LabSettings, Shortcuts, SvgExport, BulkLink, About) - * - MUI Popovers for Grid and Find (anchor-based) - */ -import { useCallback, useState } from "react"; - -import { sendCommandToExtension } from "../../messaging/extensionMessaging"; - -export interface DeploymentCommands { - onDeploy: () => void; - onDeployCleanup: () => void; - onDestroy: () => void; - onDestroyCleanup: () => void; - onRedeploy: () => void; - onRedeployCleanup: () => void; -} - -// Keep deployment commands - they need extension to run containerlab CLI -export function useDeploymentCommands(): DeploymentCommands { - return { - onDeploy: useCallback(() => sendCommandToExtension("deployLab"), []), - onDeployCleanup: useCallback(() => sendCommandToExtension("deployLabCleanup"), []), - onDestroy: useCallback(() => sendCommandToExtension("destroyLab"), []), - onDestroyCleanup: useCallback(() => sendCommandToExtension("destroyLabCleanup"), []), - onRedeploy: useCallback(() => sendCommandToExtension("redeployLab"), []), - onRedeployCleanup: useCallback(() => sendCommandToExtension("redeployLabCleanup"), []) - }; -} - -// ============================================================================ -// Panel Visibility Management -// ============================================================================ - -export interface PanelVisibility { - // Context panel (left drawer) - isContextPanelOpen: boolean; - /** Why the panel is open. Used to decide how pane-click should behave. */ - contextPanelOpenReason: "manual" | "auto" | null; - /** Which side the context panel is on. */ - panelSide: "left" | "right"; - /** - * Open the ContextPanel. - * Note: this is also used directly as an `onClick` handler, so it may receive a mouse event; - * non-string inputs are treated as a manual open. - */ - handleOpenContextPanel: (reason?: "manual" | "auto" | MouseEvent) => void; - handleCloseContextPanel: () => void; - handleToggleContextPanel: () => void; - handleTogglePanelSide: () => void; - - // Modals - showLabSettingsModal: boolean; - showShortcutsModal: boolean; - showSvgExportModal: boolean; - showBulkLinkModal: boolean; - showAboutPanel: boolean; - handleShowLabSettings: () => void; - handleShowShortcuts: () => void; - handleShowSvgExport: () => void; - handleShowBulkLink: () => void; - handleShowAbout: () => void; - handleCloseLabSettings: () => void; - handleCloseShortcuts: () => void; - handleCloseSvgExport: () => void; - handleCloseBulkLink: () => void; - handleCloseAbout: () => void; - - // Popovers (position based) - gridPopoverPosition: { top: number; left: number } | null; - findPopoverPosition: { top: number; left: number } | null; - handleOpenGridPopover: (position: { top: number; left: number }) => void; - handleCloseGridPopover: () => void; - handleOpenFindPopover: (position: { top: number; left: number }) => void; - handleCloseFindPopover: () => void; -} - -const PANEL_SIDE_KEY = "contextPanelSide"; - -function useContextPanel() { - const [isContextPanelOpen, setIsContextPanelOpen] = useState(true); - const [contextPanelOpenReason, setContextPanelOpenReason] = useState<"manual" | "auto" | null>( - "manual" - ); - const [panelSide, setPanelSide] = useState<"left" | "right">(() => { - try { - const stored = window.localStorage.getItem(PANEL_SIDE_KEY); - if (stored === "left" || stored === "right") return stored; - } catch { - /* ignore */ - } - return "right"; - }); - - return { - isContextPanelOpen, - contextPanelOpenReason, - panelSide, - handleOpenContextPanel: useCallback((reason?: unknown) => { - // React passes the click event as the first arg when used as an onClick handler. - const normalizedReason: "manual" | "auto" = reason === "auto" ? "auto" : "manual"; - setIsContextPanelOpen(true); - setContextPanelOpenReason(normalizedReason); - }, []), - handleCloseContextPanel: useCallback(() => { - setIsContextPanelOpen(false); - setContextPanelOpenReason(null); - }, []), - handleToggleContextPanel: useCallback(() => { - setIsContextPanelOpen((prev) => { - const next = !prev; - setContextPanelOpenReason(next ? "manual" : null); - return next; - }); - }, []), - handleTogglePanelSide: useCallback(() => { - setPanelSide((prev) => { - const next = prev === "left" ? "right" : "left"; - try { - window.localStorage.setItem(PANEL_SIDE_KEY, next); - } catch { - /* ignore */ - } - return next; - }); - }, []) - }; -} - -function useModals() { - const [showLabSettingsModal, setShowLabSettingsModal] = useState(false); - const [showShortcutsModal, setShowShortcutsModal] = useState(false); - const [showSvgExportModal, setShowSvgExportModal] = useState(false); - const [showBulkLinkModal, setShowBulkLinkModal] = useState(false); - const [showAboutPanel, setShowAboutPanel] = useState(false); - - return { - showLabSettingsModal, - showShortcutsModal, - showSvgExportModal, - showBulkLinkModal, - showAboutPanel, - handleShowLabSettings: useCallback(() => setShowLabSettingsModal(true), []), - handleShowShortcuts: useCallback(() => setShowShortcutsModal(true), []), - handleShowSvgExport: useCallback(() => setShowSvgExportModal(true), []), - handleShowBulkLink: useCallback(() => setShowBulkLinkModal(true), []), - handleShowAbout: useCallback(() => setShowAboutPanel((prev) => !prev), []), - handleCloseLabSettings: useCallback(() => setShowLabSettingsModal(false), []), - handleCloseShortcuts: useCallback(() => setShowShortcutsModal(false), []), - handleCloseSvgExport: useCallback(() => setShowSvgExportModal(false), []), - handleCloseBulkLink: useCallback(() => setShowBulkLinkModal(false), []), - handleCloseAbout: useCallback(() => setShowAboutPanel(false), []) - }; -} - -function usePopovers() { - const [gridPopoverPosition, setGridPopoverPosition] = useState<{ - top: number; - left: number; - } | null>(null); - const [findPopoverPosition, setFindPopoverPosition] = useState<{ - top: number; - left: number; - } | null>(null); - - return { - gridPopoverPosition, - findPopoverPosition, - handleOpenGridPopover: useCallback( - (position: { top: number; left: number }) => setGridPopoverPosition(position), - [] - ), - handleCloseGridPopover: useCallback(() => setGridPopoverPosition(null), []), - handleOpenFindPopover: useCallback( - (position: { top: number; left: number }) => setFindPopoverPosition(position), - [] - ), - handleCloseFindPopover: useCallback(() => setFindPopoverPosition(null), []) - }; -} - -export function usePanelVisibility(): PanelVisibility { - const contextPanel = useContextPanel(); - const modals = useModals(); - const popovers = usePopovers(); - - return { ...contextPanel, ...modals, ...popovers }; -} diff --git a/src/reactTopoViewer/webview/hooks/ui/usePanelTabVisibility.ts b/src/reactTopoViewer/webview/hooks/ui/usePanelTabVisibility.ts deleted file mode 100644 index 929923861..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/usePanelTabVisibility.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * usePanelTabVisibility - Centralizes mode-based tab visibility rules. - * - * Rules: - * - Info tab: visible in view mode for selected node/link. - * - Edit tab: visible when an editor is active in any mode. - * - Extra view-mode behavior: for unlocked selected nodes, also show Edit tab - * so icon/label/direction can be adjusted while running. - */ -import { useTopoViewerState } from "../../stores"; -import { useAnnotationUIStore } from "../../stores/annotationUIStore"; - -import { useContextPanelContent } from "./useContextPanelContent"; - -export interface PanelTabVisibility { - showInfoTab: boolean; - showEditTab: boolean; - infoTabTitle?: string; - editTabTitle?: string; -} - -export function usePanelTabVisibility(): PanelTabVisibility { - const state = useTopoViewerState(); - const panelView = useContextPanelContent(); - const annotationUI = useAnnotationUIStore(); - - const isViewMode = state.mode === "view"; - - // Info tab: ONLY in view mode, when node or link is selected - const showInfoTab = - isViewMode && (panelView.kind === "nodeInfo" || panelView.kind === "linkInfo"); - let infoTabTitle: string | undefined; - if (panelView.kind === "nodeInfo") { - infoTabTitle = "Node Properties"; - } else if (panelView.kind === "linkInfo") { - infoTabTitle = "Link Properties"; - } - - // Edit tab: visible whenever an editor is active (any mode). - // Some editors are view-mode features (Link Impairments, annotation editing). - const hasEditor = [ - state.editingNode, - state.editingEdge, - state.editingNetwork, - state.editingImpairment, - annotationUI.editingTextAnnotation, - annotationUI.editingShapeAnnotation, - annotationUI.editingTrafficRateAnnotation, - annotationUI.editingGroup - ].some((value) => value !== null); - - // In unlocked view mode, selected topology nodes can open a visual-only editor tab. - const canEditSelectedNodeInViewMode = - isViewMode && - state.isLocked === false && - panelView.kind === "nodeInfo" && - state.selectedNode !== null; - const showEditTab = hasEditor || canEditSelectedNodeInViewMode; - - let editTabTitle: string | undefined; - if (hasEditor) { - editTabTitle = panelView.title; - } else if (canEditSelectedNodeInViewMode) { - editTabTitle = "Node Editor"; - } - - return { showInfoTab, showEditTab, infoTabTitle, editTabTitle }; -} diff --git a/src/reactTopoViewer/webview/hooks/ui/useShakeAnimation.ts b/src/reactTopoViewer/webview/hooks/ui/useShakeAnimation.ts deleted file mode 100644 index b6550569f..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/useShakeAnimation.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * useShakeAnimation - Simple shake animation state hook - */ -import { useCallback, useState } from "react"; - -export function useShakeAnimation() { - const [isShaking, setIsShaking] = useState(false); - const trigger = useCallback(() => { - setIsShaking(true); - setTimeout(() => setIsShaking(false), 300); - }, []); - return { isShaking, trigger }; -} diff --git a/src/reactTopoViewer/webview/hooks/ui/useShortcutDisplay.ts b/src/reactTopoViewer/webview/hooks/ui/useShortcutDisplay.ts deleted file mode 100644 index b58200c4b..000000000 --- a/src/reactTopoViewer/webview/hooks/ui/useShortcutDisplay.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * useShortcutDisplay - Hook for displaying keyboard and mouse shortcuts - * Migrated from legacy TopoViewer ShortcutDisplayManager.ts - */ -import { useState, useEffect, useCallback, useRef } from "react"; - -interface ShortcutDisplayItem { - id: number; - text: string; -} - -interface UseShortcutDisplayResult { - /** Whether shortcut display is enabled */ - isEnabled: boolean; - /** Toggle shortcut display on/off */ - toggle: () => void; - /** Currently displayed shortcuts */ - shortcuts: ShortcutDisplayItem[]; -} - -/** Platform detection for keyboard symbols */ -const isMac = - typeof window !== "undefined" && - typeof window.navigator !== "undefined" && - /macintosh/i.test(window.navigator.userAgent); - -/** Maximum number of shortcuts to display at once */ -const MAX_SHORTCUTS = 8; - -/** Duration to show each shortcut (ms) */ -const DISPLAY_DURATION = 2000; - -/** Friendly key mappings */ -const FRIENDLY_KEYS: Record = { - Control: "Ctrl", - Shift: "Shift", - Alt: "Alt", - Meta: "Meta", - " ": "Space", - ArrowUp: "↑", - ArrowDown: "↓", - ArrowLeft: "←", - ArrowRight: "→", - PageUp: "Page Up", - PageDown: "Page Down", - Enter: "Enter", - Escape: "Esc", - CapsLock: "Caps Lock" -}; - -/** Modifier keys to ignore when pressed alone */ -const MODIFIER_KEYS = ["Control", "Shift", "Alt", "Meta"]; - -/** Tags to ignore for keyboard events */ -const KEYBOARD_IGNORE_TAGS = ["INPUT", "TEXTAREA"]; - -/** Tags to ignore for mouse events */ -const MOUSE_IGNORE_TAGS = ["INPUT", "BUTTON", "SELECT"]; - -/** Get modifier keys from event */ -function getModifiers(e: KeyboardEvent | MouseEvent): string[] { - const modifiers: [boolean, string][] = [ - [e.ctrlKey, isMac ? "⌃" : "Ctrl"], - [e.shiftKey, isMac ? "⇧" : "Shift"], - [e.altKey, isMac ? "⌥" : "Alt"], - [e.metaKey, isMac ? "⌘" : "Meta"] - ]; - return modifiers.filter(([pressed]) => pressed).map(([, display]) => display); -} - -/** Convert mouse button to friendly name */ -function getMouseButtonName(button: number): string | null { - const names = ["Left Click", "Middle Click", "Right Click"]; - return names[button] ?? null; -} - -/** Check if event target is an ignored tag */ -function isIgnoredTag(target: EventTarget | null, ignoredTags: string[]): boolean { - if (!target) return false; - if (!(target instanceof Element)) return false; - const tag = target.tagName; - return ignoredTags.includes(tag); -} - -/** Format keyboard shortcut string */ -function formatKeyboardShortcut(e: KeyboardEvent): string | null { - if (MODIFIER_KEYS.includes(e.key)) return null; - const modifiers = getModifiers(e); - const key = FRIENDLY_KEYS[e.key] ?? e.key.toUpperCase(); - return [...modifiers, key].join(" + "); -} - -/** Format mouse shortcut string */ -function formatMouseShortcut(e: MouseEvent): string | null { - const modifiers = getModifiers(e); - const click = getMouseButtonName(e.button); - if (click === null) return null; - return [...modifiers, click].join(" + "); -} - -/** Filter out a shortcut by id */ -function filterShortcut(id: number): (prev: ShortcutDisplayItem[]) => ShortcutDisplayItem[] { - return (prev) => prev.filter((s) => s.id !== id); -} - -/** Append shortcut and limit to max */ -function appendShortcut( - id: number, - text: string -): (prev: ShortcutDisplayItem[]) => ShortcutDisplayItem[] { - return (prev) => [...prev, { id, text }].slice(-MAX_SHORTCUTS); -} - -/** - * Hook for displaying keyboard and mouse shortcuts - */ -export function useShortcutDisplay(): UseShortcutDisplayResult { - const [isEnabled, setIsEnabled] = useState(false); - const [shortcuts, setShortcuts] = useState([]); - const nextIdRef = useRef(0); - - const toggle = useCallback(() => { - setIsEnabled((prev) => { - if (prev) setShortcuts([]); - return !prev; - }); - }, []); - - const addShortcut = useCallback((text: string) => { - const id = nextIdRef.current++; - setShortcuts(appendShortcut(id, text)); - setTimeout(() => setShortcuts(filterShortcut(id)), DISPLAY_DURATION); - }, []); - - useEffect(() => { - if (!isEnabled) return; - - function handleKeydown(e: KeyboardEvent) { - if (e.repeat || isIgnoredTag(e.target, KEYBOARD_IGNORE_TAGS)) return; - const shortcut = formatKeyboardShortcut(e); - if (shortcut !== null && shortcut.length > 0) addShortcut(shortcut); - } - - function handleMousedown(e: MouseEvent) { - if (isIgnoredTag(e.target, MOUSE_IGNORE_TAGS)) return; - const shortcut = formatMouseShortcut(e); - if (shortcut !== null && shortcut.length > 0) addShortcut(shortcut); - } - - window.addEventListener("keydown", handleKeydown); - window.addEventListener("mousedown", handleMousedown); - return () => { - window.removeEventListener("keydown", handleKeydown); - window.removeEventListener("mousedown", handleMousedown); - }; - }, [isEnabled, addShortcut]); - - return { isEnabled, toggle, shortcuts }; -} diff --git a/src/reactTopoViewer/webview/icons/SvgGenerator.ts b/src/reactTopoViewer/webview/icons/SvgGenerator.ts deleted file mode 100644 index 5b6e17aac..000000000 --- a/src/reactTopoViewer/webview/icons/SvgGenerator.ts +++ /dev/null @@ -1,563 +0,0 @@ -// SvgGenerator.ts - -// Import logger for webview -import { log } from "../utils/logger"; - -/** - * Supported node types for SVG generation - */ -export type NodeType = - | "pe" // Provider Edge Router - | "dcgw" // Data Center Gateway - | "leaf" // Leaf Node - | "switch" // Switch - | "spine" // Spine Node - | "super-spine" // Super Spine Router - | "server" // Server - | "pon" // PON - | "controller" // Controller - | "rgw" // Residential Gateway - | "ue" // User Equipment - | "cloud" // Cloud - | "client" // Client - | "bridge"; // Bridge - -const svgCache = new Map(); - -function buildSvgString(nodeType: NodeType, fillColor: string): string { - let svgString = ""; - - function renderLeafOrDcgw(fill: string): string { - return ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - `; - } - - switch (nodeType) { - case "pe": // Provider Edge Router - svgString = ` - - - - - - - - - - - - - - - - - - - - - - `; - break; - - case "dcgw": // Data Center Gateway - svgString = renderLeafOrDcgw(fillColor); - break; - - case "leaf": // Leaf Node - svgString = renderLeafOrDcgw(fillColor); - break; - - case "bridge": // Bridge uses switch icon - case "switch": // Switch - svgString = ` - - - - - - - - - - - - - - - - - - - - `; - break; - - case "spine": // Spine Node - svgString = ` - - - - - - - - - - - - - - - - - - - - - - - - `; - break; - - case "super-spine": // Super Spine Router - svgString = ` - - - - - - - - - - - - - - - - - - - - - - - - `; - break; - - case "server": // Server - svgString = ` - - - - - - - - - - - - - `; - break; - - case "pon": // PON - svgString = ` - - - - - - - - - - - - `; - break; - - case "controller": // Controller - svgString = ` - - - - - - - - - - - - - - - - - - - `; - break; - - case "rgw": // Residential Gateway - svgString = ` - - - - - - - - - - - - - `; - break; - - case "ue": // User Equipment - svgString = ` - - - - - - - - `; - break; - - case "cloud": // Cloud - svgString = ` - - - - - - - - `; - break; - - case "client": // Client - svgString = ` - - - - - - - - - `; - break; - - default: - // For unknown node types, fall back to PE (Provider Edge Router) SVG - log.warn(`Unknown nodeType: ${nodeType}, using default PE SVG`); - svgString = ` - - - - - - - - - - - - - - - - - - - - - - `; - } - return svgString; -} - -/** - * Generates an encoded SVG data URI for a given node type and fill color. - * - * @param nodeType - The type of network node to generate SVG for - * @param fillColor - The fill color for the SVG background (e.g., "#FF0000", "blue") - * @returns Encoded SVG data URI suitable for use as CSS background-image - */ -export function generateEncodedSVG(nodeType: NodeType, fillColor: string): string { - const cacheKey = `${nodeType}:${fillColor}`; - const cached = svgCache.get(cacheKey); - if (cached !== undefined) return cached; - - const svgString = buildSvgString(nodeType, fillColor); - const encoded = "data:image/svg+xml;utf8," + encodeURIComponent(svgString); - svgCache.set(cacheKey, encoded); - return encoded; -} diff --git a/src/reactTopoViewer/webview/index.tsx b/src/reactTopoViewer/webview/index.tsx deleted file mode 100644 index dc72c3560..000000000 --- a/src/reactTopoViewer/webview/index.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** - * React TopoViewer Webview Entry Point - */ -import React from "react"; -import { createRoot } from "react-dom/client"; - -import { App } from "./App"; -import { log } from "./utils/logger"; -import "./styles/global.css"; -import { subscribeToWebviewMessages } from "./messaging/webviewMessageBus"; - -// Get the initial data from the window object (injected by extension) -const initialData = window.__INITIAL_DATA__ ?? {}; - -// Extract and store schema data on window for useSchema hook -if (initialData.schemaData) { - // Schema data is validated at runtime by useSchema hook - window.__SCHEMA_DATA__ = initialData.schemaData as typeof window.__SCHEMA_DATA__; -} - -// Extract and store docker images on window for useDockerImages hook -if (initialData.dockerImages) { - window.__DOCKER_IMAGES__ = initialData.dockerImages; -} - -// Listen for docker images updates from extension -// Note: In VS Code webviews, messages always come from the extension (trusted source) -subscribeToWebviewMessages((event) => { - // VS Code webviews are sandboxed - messages come from the extension host - const message = event.data as { type?: string; dockerImages?: string[] } | undefined; - if (message?.type === "docker-images-updated" && message.dockerImages) { - window.__DOCKER_IMAGES__ = message.dockerImages; - // Dispatch a custom event so hooks can react to the update - window.dispatchEvent( - new CustomEvent("docker-images-updated", { - detail: message.dockerImages - }) - ); - } -}); - -// Log bootstrap data -const customNodeCount = Array.isArray(initialData.customNodes) ? initialData.customNodes.length : 0; -const iconCount = Array.isArray(initialData.customIcons) ? initialData.customIcons.length : 0; -log.info( - `[ReactTopoViewer] Bootstrap data loaded (customNodes: ${customNodeCount}, customIcons: ${iconCount})` -); - -/** - * Bootstrap the application. - */ -function bootstrap(): void { - // Find the root element - const container = document.getElementById("root"); - if (!container) { - throw new Error("Root element not found"); - } - - // Create React root and render - const root = createRoot(container); - root.render( - - - - ); -} - -// Start the app -bootstrap(); diff --git a/src/reactTopoViewer/webview/messaging/extensionMessaging.ts b/src/reactTopoViewer/webview/messaging/extensionMessaging.ts deleted file mode 100644 index d37971a9d..000000000 --- a/src/reactTopoViewer/webview/messaging/extensionMessaging.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * VS Code extension messaging helpers. - * Use for VS Code/CLI operations and settings updates. Topology persistence goes - * through the host command pipeline, not these messages. - */ -import type { ExtensionCommandType } from "../../shared/messages/extension"; -import { MSG_CANCEL_LAB_LIFECYCLE } from "../../shared/messages/extension"; -import type { SaveCustomNodeData } from "../../shared/utilities/customNodeConversions"; -import { log } from "../utils/logger"; - -declare global { - interface Window { - vscode?: { postMessage(data: unknown): void; __isDevMock__?: boolean }; - } -} - -/** - * Get VS Code API instance exposed by the extension host. - */ -function getVscodeApi(): { postMessage(data: unknown): void } | undefined { - return typeof window !== "undefined" ? window.vscode : undefined; -} - -/** - * Send a fire-and-forget command message to the extension. - */ -export function sendCommandToExtension( - command: ExtensionCommandType, - payload?: Record -): void { - const vscodeApi = getVscodeApi(); - if (!vscodeApi) { - log.warn(`[ExtensionMessaging] VS Code API unavailable, command skipped: ${command}`); - return; - } - - const message = payload ? { command, ...payload } : { command }; - vscodeApi.postMessage(message); - log.debug(`[ExtensionMessaging] Sent command: ${command}`); -} - -// ============================================================================ -// CUSTOM NODE TEMPLATE COMMANDS -// ============================================================================ -// These commands manage custom node templates stored in VS Code workspace settings. -// They use messaging because they interact with VS Code's configuration API. -// DO NOT confuse with node CRUD operations (create-node, save-node-editor, etc.) -// which use services for YAML/annotation persistence. - -/** - * Delete a custom node template from VS Code settings. - * - * This removes a user-defined node template stored in workspace configuration. - * Handled by: extension `MessageRouter` - */ -export function sendDeleteCustomNode(nodeName: string): void { - sendCommandToExtension("delete-custom-node", { name: nodeName }); -} - -/** - * Set a custom node template as the default for new nodes. - * - * This updates VS Code settings to mark a template as the default. - * Handled by: extension `MessageRouter` - */ -export function sendSetDefaultCustomNode(nodeName: string): void { - sendCommandToExtension("set-default-custom-node", { name: nodeName }); -} - -/** - * Save a custom node template to VS Code settings. - * - * This creates or updates a user-defined node template in workspace configuration. - * Templates define reusable node configurations (kind, image, icon, etc.) - * and are stored in VS Code workspace settings, NOT in topology files. - * - * Handled by: extension `MessageRouter` - */ -export function sendSaveCustomNode(data: SaveCustomNodeData): void { - sendCommandToExtension("save-custom-node", data); -} - -/** - * Alias for sendCommandToExtension - simpler name for common use cases. - */ -export const postCommand = sendCommandToExtension; - -// ============================================================================ -// ICON RECONCILIATION -// ============================================================================ - -/** - * Trigger icon reconciliation on the extension side. - * This copies used custom icons from global to workspace, and removes unused ones. - * - * @param usedIcons - Array of custom icon names currently used by nodes - */ -export function sendIconReconcile(usedIcons: string[]): void { - sendCommandToExtension("icon-reconcile", { usedIcons }); -} - -/** - * Request cancellation of the currently running lab lifecycle command. - */ -export function sendCancelLabLifecycle(): void { - sendCommandToExtension(MSG_CANCEL_LAB_LIFECYCLE); -} diff --git a/src/reactTopoViewer/webview/messaging/webviewMessageBus.ts b/src/reactTopoViewer/webview/messaging/webviewMessageBus.ts deleted file mode 100644 index 61375ed04..000000000 --- a/src/reactTopoViewer/webview/messaging/webviewMessageBus.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * webviewMessageBus - Single window.message listener with fan-out subscriptions. - * - * The webview previously registered multiple `window.addEventListener('message', ...)` listeners - * across hooks and services. This module centralizes the listener and allows scoped subscriptions. - */ - -/** - * Base webview message structure from the extension. - * All messages have a type field, and may have additional data. - */ -export interface WebviewMessageBase { - type: string; - [key: string]: unknown; -} - -/** - * Typed MessageEvent with known data structure - */ -export type TypedMessageEvent = MessageEvent; - -export type WebviewMessagePredicate = (event: TypedMessageEvent) => boolean; -export type WebviewMessageHandler = (event: TypedMessageEvent) => void; - -interface Subscriber { - handler: WebviewMessageHandler; - predicate?: WebviewMessagePredicate; -} - -let started = false; -let windowListener: ((event: TypedMessageEvent) => void) | null = null; -const subscribers = new Set(); - -function ensureStarted(): void { - if (started) return; - windowListener = (event: TypedMessageEvent) => { - for (const sub of Array.from(subscribers)) { - if (!sub.predicate || sub.predicate(event)) { - sub.handler(event); - } - } - }; - - window.addEventListener("message", windowListener); - started = true; -} - -function maybeStop(): void { - if (!started) return; - if (subscribers.size > 0) return; - if (!windowListener) return; - window.removeEventListener("message", windowListener); - windowListener = null; - started = false; -} - -export function subscribeToWebviewMessages( - handler: WebviewMessageHandler, - predicate?: WebviewMessagePredicate -): () => void { - ensureStarted(); - const sub: Subscriber = { handler, predicate }; - subscribers.add(sub); - return () => { - subscribers.delete(sub); - maybeStop(); - }; -} diff --git a/src/reactTopoViewer/webview/services/annotationPayloads.ts b/src/reactTopoViewer/webview/services/annotationPayloads.ts deleted file mode 100644 index 9aca56290..000000000 --- a/src/reactTopoViewer/webview/services/annotationPayloads.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Node } from "@xyflow/react"; - -import type { TopologyAnnotations } from "../../shared/types/topology"; -import { nodesToAnnotations } from "../annotations/annotationNodeConverters"; -import { useGraphStore } from "../stores/graphStore"; - -type AnnotationPayloadKeys = - | "freeTextAnnotations" - | "freeShapeAnnotations" - | "trafficRateAnnotations" - | "groupStyleAnnotations"; - -export type AnnotationNodesPayload = Pick; - -export function buildAnnotationNodesPayload(nodes?: Node[]): AnnotationNodesPayload { - const graphNodes = nodes ?? useGraphStore.getState().nodes; - const { freeTextAnnotations, freeShapeAnnotations, trafficRateAnnotations, groups } = - nodesToAnnotations(graphNodes); - - return { - freeTextAnnotations, - freeShapeAnnotations, - trafficRateAnnotations, - groupStyleAnnotations: groups - }; -} diff --git a/src/reactTopoViewer/webview/services/annotationSaveHelpers.ts b/src/reactTopoViewer/webview/services/annotationSaveHelpers.ts deleted file mode 100644 index 76806f253..000000000 --- a/src/reactTopoViewer/webview/services/annotationSaveHelpers.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Annotation Save Helpers (Host-authoritative) - */ - -import type { Node } from "@xyflow/react"; - -import type { - FreeTextAnnotation, - FreeShapeAnnotation, - GroupStyleAnnotation, - EdgeAnnotation, - TopologyAnnotations -} from "../../shared/types/topology"; - -import { buildAnnotationNodesPayload } from "./annotationPayloads"; -import { executeTopologyCommand } from "./topologyHostCommands"; - -const WARN_COMMAND_FAILED = "[Host] Annotation command failed"; - -export async function saveFreeTextAnnotations(annotations: FreeTextAnnotation[]): Promise { - try { - await executeTopologyCommand({ - command: "setAnnotations", - payload: { freeTextAnnotations: annotations } - }); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: setAnnotations(freeTextAnnotations)`, err); - } -} - -export interface SaveAnnotationNodesOptions { - /** Skip re-applying snapshot to avoid position snapback during continuous updates */ - applySnapshot?: boolean; -} - -export async function saveAnnotationNodesFromGraph( - nodes?: Node[], - options: SaveAnnotationNodesOptions = {} -): Promise { - try { - await executeTopologyCommand( - { - command: "setAnnotations", - payload: buildAnnotationNodesPayload(nodes) - }, - { applySnapshot: options.applySnapshot ?? true } - ); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: setAnnotations(annotationNodes)`, err); - } -} - -export async function saveAnnotationNodesWithMemberships( - memberships: Array<{ id: string; groupId?: string }>, - nodes?: Node[] -): Promise { - try { - await executeTopologyCommand({ - command: "setAnnotationsWithMemberships", - payload: { - annotations: buildAnnotationNodesPayload(nodes), - memberships: memberships.map((m) => ({ nodeId: m.id, groupId: m.groupId ?? null })) - } - }); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: setAnnotationsWithMemberships`, err); - } -} - -export async function saveFreeShapeAnnotations(annotations: FreeShapeAnnotation[]): Promise { - try { - await executeTopologyCommand({ - command: "setAnnotations", - payload: { freeShapeAnnotations: annotations } - }); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: setAnnotations(freeShapeAnnotations)`, err); - } -} - -export async function saveGroupStyleAnnotations( - annotations: GroupStyleAnnotation[] -): Promise { - try { - await executeTopologyCommand({ - command: "setAnnotations", - payload: { groupStyleAnnotations: annotations } - }); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: setAnnotations(groupStyleAnnotations)`, err); - } -} - -export async function saveEdgeAnnotations(annotations: EdgeAnnotation[]): Promise { - try { - await executeTopologyCommand({ command: "setEdgeAnnotations", payload: annotations }); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: setEdgeAnnotations`, err); - } -} - -export async function saveViewerSettings( - settings: NonNullable -): Promise { - try { - await executeTopologyCommand({ command: "setViewerSettings", payload: settings }); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: setViewerSettings`, err); - } -} - -export async function saveNodeGroupMembership( - nodeId: string, - groupId: string | null -): Promise { - try { - // Avoid snapshot re-apply here to prevent position snapback when membership changes - // are sent separately from position saves during drag/drop. - await executeTopologyCommand( - { command: "setNodeGroupMembership", payload: { nodeId, groupId } }, - { applySnapshot: false } - ); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: setNodeGroupMembership`, err); - } -} - -export async function saveAllNodeGroupMemberships( - memberships: Array<{ id: string; groupId?: string }> -): Promise { - try { - // Avoid snapshot re-apply here for the same reason as saveNodeGroupMembership. - await executeTopologyCommand( - { - command: "setNodeGroupMemberships", - payload: memberships.map((m) => ({ nodeId: m.id, groupId: m.groupId ?? null })) - }, - { applySnapshot: false } - ); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: setNodeGroupMemberships`, err); - } -} diff --git a/src/reactTopoViewer/webview/services/index.ts b/src/reactTopoViewer/webview/services/index.ts deleted file mode 100644 index a951a8abc..000000000 --- a/src/reactTopoViewer/webview/services/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Webview Services (Host-authoritative) - */ - -export { executeTopologyCommand, executeTopologyCommands } from "./topologyHostCommands"; -export { toLinkSaveData } from "./linkSaveData"; - -export { - saveEdgeAnnotations, - saveViewerSettings, - saveNodeGroupMembership, - saveAllNodeGroupMemberships, - saveAnnotationNodesFromGraph, - saveAnnotationNodesWithMemberships -} from "./annotationSaveHelpers"; - -export { - createNode, - deleteNode, - createLink, - deleteLink, - buildNetworkNodeAnnotations, - saveNetworkNodesFromGraph, - saveNodePositions, - saveNodePositionsWithAnnotations, - saveNodePositionsWithMemberships -} from "./topologyCrud"; - -export type { NodeSaveData, LinkSaveData, NetworkNodeData } from "./topologyCrud"; - -export { getCustomIconMap, buildCustomIconMap } from "../utils/iconUtils"; diff --git a/src/reactTopoViewer/webview/services/linkSaveData.ts b/src/reactTopoViewer/webview/services/linkSaveData.ts deleted file mode 100644 index f9bfc277b..000000000 --- a/src/reactTopoViewer/webview/services/linkSaveData.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Helpers for converting graph edges into host persistence payloads. - */ -import type { LinkSaveData } from "../../shared/io/LinkPersistenceIO"; -import type { TopoEdge } from "../../shared/types/graph"; - -export function toLinkSaveData(edge: TopoEdge): LinkSaveData { - const data = edge.data; - const extra = data?.extraData; - const yamlSource = extra?.yamlSourceNodeId; - const yamlTarget = extra?.yamlTargetNodeId; - return { - id: edge.id, - source: typeof yamlSource === "string" && yamlSource.length > 0 ? yamlSource : edge.source, - target: typeof yamlTarget === "string" && yamlTarget.length > 0 ? yamlTarget : edge.target, - sourceEndpoint: data?.sourceEndpoint, - targetEndpoint: data?.targetEndpoint, - ...(data?.extraData ? { extraData: data.extraData } : {}) - }; -} diff --git a/src/reactTopoViewer/webview/services/topologyCrud.ts b/src/reactTopoViewer/webview/services/topologyCrud.ts deleted file mode 100644 index 2d0269cb9..000000000 --- a/src/reactTopoViewer/webview/services/topologyCrud.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Topology CRUD Helpers (Host-authoritative) - * - * Dispatches topology commands to the host and applies snapshots. - */ - -import type { Node } from "@xyflow/react"; - -import type { NodeSaveData } from "../../shared/io/NodePersistenceIO"; -import type { LinkSaveData } from "../../shared/io/LinkPersistenceIO"; -import { collectNodeGroupMemberships } from "../annotations/groupMembership"; -import { useGraphStore } from "../stores/graphStore"; -import { BRIDGE_NETWORK_TYPES } from "../utils/networkNodeTypes"; -import { buildNetworkNodeAnnotations } from "../utils/networkNodeAnnotations"; - -import { buildAnnotationNodesPayload } from "./annotationPayloads"; -import { executeTopologyCommand } from "./topologyHostCommands"; - -// Re-export types for convenience -export type { NodeSaveData, LinkSaveData }; - -const WARN_COMMAND_FAILED = "[Host] Topology command failed"; - -export { buildNetworkNodeAnnotations }; - -export async function createNode(nodeData: NodeSaveData): Promise { - try { - await executeTopologyCommand({ command: "addNode", payload: nodeData }); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: addNode`, err); - } -} - -export async function editNode(nodeData: NodeSaveData): Promise { - try { - await executeTopologyCommand({ command: "editNode", payload: nodeData }); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: editNode`, err); - } -} - -export async function deleteNode(nodeId: string): Promise { - try { - await executeTopologyCommand({ command: "deleteNode", payload: { id: nodeId } }); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: deleteNode`, err); - } -} - -export async function createLink(linkData: LinkSaveData): Promise { - try { - await executeTopologyCommand({ command: "addLink", payload: linkData }); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: addLink`, err); - } -} - -export async function editLink(linkData: LinkSaveData): Promise { - try { - await executeTopologyCommand({ command: "editLink", payload: linkData }); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: editLink`, err); - } -} - -export async function deleteLink(linkData: LinkSaveData): Promise { - try { - await executeTopologyCommand({ command: "deleteLink", payload: linkData }); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: deleteLink`, err); - } -} - -/** Data for network node creation (for non-bridge types) */ -export interface NetworkNodeData { - id: string; - label: string; - type: - | "host" - | "mgmt-net" - | "macvlan" - | "vxlan" - | "vxlan-stitch" - | "dummy" - | "bridge" - | "ovs-bridge"; - position: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; -} - -/** - * Persist network nodes (non-bridge types) via annotations. - * Assumes the graph store already contains the latest network nodes. - */ -export async function saveNetworkNodesFromGraph(nodes?: Node[]): Promise { - try { - const graphNodes = nodes ?? useGraphStore.getState().nodes; - const annotations = buildNetworkNodeAnnotations(graphNodes); - await executeTopologyCommand({ - command: "setAnnotations", - payload: { networkNodeAnnotations: annotations } - }); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: setAnnotations(networkNodeAnnotations)`, err); - } -} - -/** - * Create a network node stored in annotations (non-bridge types). - * Bridge types should be persisted via addNode/editNode instead. - */ -export async function createNetworkNode(data: NetworkNodeData): Promise { - if (BRIDGE_NETWORK_TYPES.has(data.type)) { - console.warn(`[Host] Bridge network nodes should be created via addNode: ${data.type}`); - return; - } - await saveNetworkNodesFromGraph(); -} - -/** - * Save node positions via host command. - * Note: We set applySnapshot: false because position-only changes should not - * trigger a full topology reload, which would reset geo-mode positions. - */ -export async function saveNodePositions( - positions: Array<{ - id: string; - position?: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - }> -): Promise { - try { - await executeTopologyCommand( - { command: "savePositions", payload: positions }, - { applySnapshot: false } - ); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: savePositions`, err); - } -} - -/** - * Save node positions and annotation nodes in a single host command. - * This keeps related moves (e.g., groups + members) as one undo entry. - */ -export async function saveNodePositionsWithAnnotations( - positions: Array<{ - id: string; - position?: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - }>, - nodes?: Node[] -): Promise { - try { - await executeTopologyCommand( - { - command: "savePositionsAndAnnotations", - payload: { - positions, - annotations: buildAnnotationNodesPayload(nodes) - } - }, - { applySnapshot: false } - ); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: savePositionsAndAnnotations`, err); - } -} - -/** - * Save node positions + group memberships as a single batch command (one undo entry). - * Used when a node drag may change group membership. - */ -export async function saveNodePositionsWithMemberships( - positions: Array<{ - id: string; - position?: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - }> -): Promise { - try { - const memberships = collectNodeGroupMemberships(useGraphStore.getState().nodes); - await executeTopologyCommand( - { - command: "batch", - payload: { - commands: [ - { command: "savePositions", payload: positions }, - { - command: "setNodeGroupMemberships", - payload: memberships.map((m) => ({ nodeId: m.id, groupId: m.groupId })) - } - ] - } - }, - { applySnapshot: false } - ); - } catch (err) { - console.error(`${WARN_COMMAND_FAILED}: savePositionsWithMemberships(batch)`, err); - } -} diff --git a/src/reactTopoViewer/webview/services/topologyHostClient.ts b/src/reactTopoViewer/webview/services/topologyHostClient.ts deleted file mode 100644 index 9b20ad852..000000000 --- a/src/reactTopoViewer/webview/services/topologyHostClient.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * TopologyHost client - dispatches commands and snapshot requests to the host. - * - * Supports: - * - VS Code webview messaging - * - Dev server HTTP endpoints - */ - -import type { - TopologyHostCommand, - TopologyHostResponseMessage, - TopologySnapshot -} from "../../shared/types/messages"; -import { TOPOLOGY_HOST_PROTOCOL_VERSION } from "../../shared/types/messages"; -import type { DeploymentState } from "../../shared/types/topology"; -import { subscribeToWebviewMessages } from "../messaging/webviewMessageBus"; - -declare global { - interface Window { - vscode?: { postMessage(data: unknown): void; __isDevMock__?: boolean }; - } -} - -interface HostContext { - path?: string; - mode?: "edit" | "view"; - deploymentState?: DeploymentState; - sessionId?: string; -} - -interface PendingRequest { - resolve: (value: TopologyHostResponseMessage | TopologySnapshot) => void; - reject: (err: Error) => void; - expectedType: "snapshot" | "command"; -} - -const pending = new Map(); -let revision = 1; -let hostContext: HostContext = {}; -let listenerStarted = false; - -type HostMessageType = - | "topology-host:snapshot" - | "topology-host:ack" - | "topology-host:reject" - | "topology-host:error"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isHostMessageType(value: unknown): value is HostMessageType { - return ( - value === "topology-host:snapshot" || - value === "topology-host:ack" || - value === "topology-host:reject" || - value === "topology-host:error" - ); -} - -function isDeploymentState(value: unknown): value is DeploymentState { - return value === "deployed" || value === "undeployed" || value === "unknown"; -} - -function hasSnapshotRevision(value: Record): boolean { - return typeof value.revision === "number" && Number.isFinite(value.revision); -} - -function hasSnapshotCollections(value: Record): boolean { - return Array.isArray(value.nodes) && Array.isArray(value.edges) && isRecord(value.annotations); -} - -function hasSnapshotTextFields(value: Record): boolean { - const textFields = [ - "yamlFileName", - "annotationsFileName", - "yamlContent", - "annotationsContent", - "labName" - ] as const; - return textFields.every((field) => typeof value[field] === "string"); -} - -function hasSnapshotModeAndState(value: Record): boolean { - return ( - (value.mode === "edit" || value.mode === "view") && isDeploymentState(value.deploymentState) - ); -} - -function hasSnapshotHistoryFlags(value: Record): boolean { - return typeof value.canUndo === "boolean" && typeof value.canRedo === "boolean"; -} - -function isTopologySnapshot(value: unknown): value is TopologySnapshot { - if (!isRecord(value)) return false; - return ( - hasSnapshotRevision(value) && - hasSnapshotCollections(value) && - hasSnapshotTextFields(value) && - hasSnapshotModeAndState(value) && - hasSnapshotHistoryFlags(value) - ); -} - -function hasValidHostMessageEnvelope(value: Record): value is Record< - string, - unknown -> & { - type: HostMessageType; - requestId: string; - protocolVersion: number; -} { - return ( - isHostMessageType(value.type) && - typeof value.requestId === "string" && - typeof value.protocolVersion === "number" && - Number.isFinite(value.protocolVersion) - ); -} - -function isHostAckPayload(value: Record): boolean { - return ( - typeof value.revision === "number" && - Number.isFinite(value.revision) && - (value.snapshot === undefined || isTopologySnapshot(value.snapshot)) - ); -} - -function isHostRejectPayload(value: Record): boolean { - return ( - typeof value.revision === "number" && - Number.isFinite(value.revision) && - value.reason === "stale" && - isTopologySnapshot(value.snapshot) - ); -} - -function isTopologyHostResponseMessage(value: unknown): value is TopologyHostResponseMessage { - if (!isRecord(value)) return false; - if (!hasValidHostMessageEnvelope(value)) return false; - - switch (value.type) { - case "topology-host:snapshot": - return isTopologySnapshot(value.snapshot); - case "topology-host:ack": - return isHostAckPayload(value); - case "topology-host:reject": - return isHostRejectPayload(value); - case "topology-host:error": - return typeof value.error === "string"; - default: - return false; - } -} - -function ensureListener(): void { - if (listenerStarted) return; - subscribeToWebviewMessages( - (event) => { - if (!isRecord(event.data)) return; - const { data } = event; - if (!isHostMessageType(data.type)) return; - if (typeof data.requestId !== "string" || data.requestId.length === 0) return; - const requestId = data.requestId; - - const pendingRequest = pending.get(requestId); - if (!pendingRequest) return; - - if (data.type === "topology-host:snapshot") { - if (pendingRequest.expectedType !== "snapshot") { - pendingRequest.reject(new Error("Unexpected snapshot response")); - pending.delete(requestId); - return; - } - if (!isTopologySnapshot(data.snapshot)) { - pendingRequest.reject(new Error("Snapshot message missing payload")); - pending.delete(requestId); - return; - } - pendingRequest.resolve(data.snapshot); - pending.delete(requestId); - return; - } - - if (pendingRequest.expectedType !== "command") { - pendingRequest.reject(new Error("Unexpected command response")); - pending.delete(requestId); - return; - } - if (!isTopologyHostResponseMessage(data)) { - pendingRequest.reject(new Error("Invalid command response payload")); - pending.delete(requestId); - return; - } - pendingRequest.resolve(data); - pending.delete(requestId); - }, - (event) => { - if (!isRecord(event.data)) return false; - return isHostMessageType(event.data.type); - } - ); - listenerStarted = true; -} - -function isVsCode(): boolean { - if (typeof window === "undefined" || !window.vscode) { - return false; - } - // In dev mode, window.vscode is a mock with __isDevMock__ marker - // We should use HTTP endpoints instead of VS Code messaging - return window.vscode.__isDevMock__ !== true; -} - -function buildApiUrl(path: string, sessionId?: string): string { - if (sessionId === undefined || sessionId.length === 0) return path; - const delimiter = path.includes("?") ? "&" : "?"; - return `${path}${delimiter}sessionId=${encodeURIComponent(sessionId)}`; -} - -/** Send a message to VS Code with timeout handling */ -function sendVsCodeRequest( - message: Record, - expectedType: "snapshot", - timeoutMs?: number -): Promise; -function sendVsCodeRequest( - message: Record, - expectedType: "command", - timeoutMs?: number -): Promise; -function sendVsCodeRequest( - message: Record, - expectedType: "snapshot" | "command", - timeoutMs = 30000 -): Promise { - ensureListener(); - const requestId = globalThis.crypto.randomUUID(); - return new Promise((resolve, reject) => { - pending.set(requestId, { - resolve, - reject, - expectedType - }); - window.vscode?.postMessage({ ...message, requestId }); - setTimeout(() => { - if (pending.has(requestId)) { - pending.delete(requestId); - reject( - new Error(`${expectedType === "snapshot" ? "Snapshot" : "Command"} request timed out`) - ); - } - }, timeoutMs); - }); -} - -export function setHostContext(update: Partial): void { - hostContext = { ...hostContext, ...update }; -} - -export function getHostContext(): HostContext { - return hostContext; -} - -export function getHostRevision(): number { - return revision; -} - -export function setHostRevision(nextRevision: number): void { - revision = nextRevision; -} - -export async function requestSnapshot( - options: { externalChange?: boolean } = {} -): Promise { - if (isVsCode()) { - return sendVsCodeRequest( - { type: "topology-host:get-snapshot", protocolVersion: TOPOLOGY_HOST_PROTOCOL_VERSION }, - "snapshot" - ); - } - - if (hostContext.path === undefined || hostContext.path.length === 0) { - throw new Error("Dev host context missing topology path"); - } - - const response = await fetch(buildApiUrl("/api/topology/snapshot", hostContext.sessionId), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - path: hostContext.path, - mode: hostContext.mode, - deploymentState: hostContext.deploymentState, - externalChange: options.externalChange ?? false - }) - }); - - if (!response.ok) { - throw new Error(`Failed to fetch snapshot: ${response.statusText}`); - } - - const payload: unknown = await response.json(); - if (!isRecord(payload) || !isTopologySnapshot(payload.snapshot)) { - throw new Error("Snapshot response payload is invalid"); - } - return payload.snapshot; -} - -export async function dispatchTopologyCommand( - command: TopologyHostCommand -): Promise { - if (isVsCode()) { - return sendVsCodeRequest( - { - type: "topology-host:command", - protocolVersion: TOPOLOGY_HOST_PROTOCOL_VERSION, - baseRevision: revision, - command - }, - "command" - ); - } - - if (hostContext.path === undefined || hostContext.path.length === 0) { - throw new Error("Dev host context missing topology path"); - } - - const response = await fetch(buildApiUrl("/api/topology/command", hostContext.sessionId), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - path: hostContext.path, - baseRevision: revision, - command, - mode: hostContext.mode, - deploymentState: hostContext.deploymentState - }) - }); - - if (!response.ok) { - throw new Error(`Failed to send command: ${response.statusText}`); - } - - const payload: unknown = await response.json(); - if (!isTopologyHostResponseMessage(payload)) { - throw new Error("Command response payload is invalid"); - } - return payload; -} diff --git a/src/reactTopoViewer/webview/services/topologyHostCommands.ts b/src/reactTopoViewer/webview/services/topologyHostCommands.ts deleted file mode 100644 index 085ce86f3..000000000 --- a/src/reactTopoViewer/webview/services/topologyHostCommands.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * TopologyHost command helpers. - * - * Wraps host command dispatch with revision/snapshot handling. - */ - -import type { - TopologyHostCommand, - TopologyHostResponseMessage, - TopologySnapshot -} from "../../shared/types/messages"; -import { getRecordUnknown } from "../../shared/utilities/typeHelpers"; -import { useTopoViewerStore } from "../stores/topoViewerStore"; - -import { dispatchTopologyCommand, requestSnapshot, setHostRevision } from "./topologyHostClient"; -import { enqueueHostCommand } from "./topologyHostQueue"; -import { applySnapshotToStores } from "./topologyHostSync"; - -const HOST_ACK = "topology-host:ack" as const; -const HOST_REJECT = "topology-host:reject" as const; - -interface ExecuteOptions { - applySnapshot?: boolean; -} - -function isVoidCallback(value: unknown): value is () => void { - return typeof value === "function"; -} - -function getDevHostUpdateHandler(): (() => void) | undefined { - if (typeof window === "undefined") return undefined; - const dev = getRecordUnknown(getRecordUnknown(window)?.__DEV__) ?? {}; - const handler = dev.onHostUpdate; - if (!isVoidCallback(handler)) { - return undefined; - } - return handler; -} - -function notifyDevHostUpdate(): void { - getDevHostUpdateHandler()?.(); -} - -async function handleHostResponse( - response: TopologyHostResponseMessage, - applySnapshot: boolean -): Promise { - const syncUndoRedo = (snapshot: TopologySnapshot) => { - useTopoViewerStore.getState().setInitialData({ - canUndo: snapshot.canUndo, - canRedo: snapshot.canRedo - }); - }; - - const syncSource = (snapshot: TopologySnapshot) => { - useTopoViewerStore.getState().setInitialData({ - yamlFileName: snapshot.yamlFileName, - annotationsFileName: snapshot.annotationsFileName, - yamlContent: snapshot.yamlContent, - annotationsContent: snapshot.annotationsContent - }); - }; - - const applySnapshotAndNotify = (snapshot: TopologySnapshot) => { - applySnapshotToStores(snapshot); - notifyDevHostUpdate(); - }; - - const setRevisionAndNotify = (revision: number, snapshot?: TopologySnapshot) => { - setHostRevision(revision); - if (snapshot) { - syncUndoRedo(snapshot); - // Even when applySnapshot=false (quiet updates), keep source editors in sync. - syncSource(snapshot); - } - notifyDevHostUpdate(); - }; - - switch (response.type) { - case HOST_ACK: - return handleAckResponse( - response, - applySnapshot, - applySnapshotAndNotify, - setRevisionAndNotify - ); - case HOST_REJECT: - return handleRejectResponse( - response, - applySnapshot, - applySnapshotAndNotify, - setRevisionAndNotify - ); - case "topology-host:error": - throw new Error(response.error); - default: - return response; - } -} - -async function handleAckResponse( - response: TopologyHostResponseMessage, - applySnapshot: boolean, - applySnapshotAndNotify: (snapshot: TopologySnapshot) => void, - setRevisionAndNotify: (revision: number, snapshot?: TopologySnapshot) => void -): Promise { - if (response.type !== HOST_ACK) return response; - - if (response.snapshot) { - if (applySnapshot) { - applySnapshotAndNotify(response.snapshot); - } else { - setRevisionAndNotify(response.snapshot.revision, response.snapshot); - } - return response; - } - - if (applySnapshot) { - const snapshot = await requestSnapshot(); - applySnapshotAndNotify(snapshot); - return response; - } - - setRevisionAndNotify(response.revision); - return response; -} - -function handleRejectResponse( - response: TopologyHostResponseMessage, - applySnapshot: boolean, - applySnapshotAndNotify: (snapshot: TopologySnapshot) => void, - setRevisionAndNotify: (revision: number, snapshot?: TopologySnapshot) => void -): TopologyHostResponseMessage { - if (response.type !== HOST_REJECT) return response; - - if (applySnapshot) { - applySnapshotAndNotify(response.snapshot); - } else { - setRevisionAndNotify(response.snapshot.revision, response.snapshot); - } - return response; -} - -export async function executeTopologyCommand( - command: TopologyHostCommand, - options: ExecuteOptions = {} -): Promise { - if (useTopoViewerStore.getState().isProcessing) { - throw new Error("TopoViewer is processing; edits are temporarily disabled."); - } - const run = async () => { - const applySnapshot = options.applySnapshot ?? true; - const response = await dispatchTopologyCommand(command); - return handleHostResponse(response, applySnapshot); - }; - return enqueueHostCommand(run); -} - -export async function executeTopologyCommands( - commands: TopologyHostCommand[], - options: ExecuteOptions = {} -): Promise { - if (useTopoViewerStore.getState().isProcessing) { - throw new Error("TopoViewer is processing; edits are temporarily disabled."); - } - const run = async () => { - const applySnapshot = options.applySnapshot ?? true; - let lastResponse: TopologyHostResponseMessage | null = null; - - for (const command of commands) { - const response = await dispatchTopologyCommand(command); - lastResponse = await handleHostResponse(response, false); - - if (response.type === HOST_REJECT) { - if (applySnapshot) { - applySnapshotToStores(response.snapshot); - } - return response; - } - } - - if (applySnapshot) { - if (lastResponse?.type === HOST_ACK && lastResponse.snapshot) { - applySnapshotToStores(lastResponse.snapshot); - } else { - const snapshot = await requestSnapshot(); - applySnapshotToStores(snapshot); - } - } - - return lastResponse; - }; - return enqueueHostCommand(run); -} - -export async function refreshTopologySnapshot( - options: { externalChange?: boolean } = {} -): Promise { - const snapshot = await requestSnapshot(options); - applySnapshotToStores(snapshot); - notifyDevHostUpdate(); - return snapshot; -} diff --git a/src/reactTopoViewer/webview/services/topologyHostQueue.ts b/src/reactTopoViewer/webview/services/topologyHostQueue.ts deleted file mode 100644 index 4fe0264a9..000000000 --- a/src/reactTopoViewer/webview/services/topologyHostQueue.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * TopologyHost command queue. - * - * Serializes host command execution to keep baseRevision aligned. - */ - -let hostCommandQueue: Promise = Promise.resolve(); - -export function enqueueHostCommand(task: () => Promise): Promise { - const queued = hostCommandQueue.then(task, task); - hostCommandQueue = queued.catch(() => undefined); - return queued; -} diff --git a/src/reactTopoViewer/webview/services/topologyHostSync.ts b/src/reactTopoViewer/webview/services/topologyHostSync.ts deleted file mode 100644 index 1fce22756..000000000 --- a/src/reactTopoViewer/webview/services/topologyHostSync.ts +++ /dev/null @@ -1,673 +0,0 @@ -/** - * TopologyHost snapshot application helpers. - */ - -import type { Node } from "@xyflow/react"; - -import type { TopologySnapshot } from "../../shared/types/messages"; -import type { - FreeTextAnnotation, - FreeShapeAnnotation, - TrafficRateAnnotation, - GroupStyleAnnotation, - NodeAnnotation, - NetworkNodeAnnotation, - TopologyAnnotations -} from "../../shared/types/topology"; -import type { TopoNode } from "../../shared/types/graph"; -import { - annotationsToNodes, - applyGroupMembershipToNodes, - isNonEmptyString, - parseEndpointLabelOffset, - parseLegacyGroupIdentity, - pruneEdgeAnnotations, - toFiniteNumber, - toPosition -} from "../annotations"; -import { useGraphStore } from "../stores/graphStore"; -import { useTopoViewerStore } from "../stores/topoViewerStore"; -import type { - LinkLabelMode, - NonTelemetryLinkLabelMode, - TopoViewerState -} from "../stores/topoViewerStore"; -import { useCanvasStore } from "../stores/canvasStore"; -import { applyForceLayout, hasPresetPositions } from "../components/canvas/layout"; -import { snapToGrid } from "../utils/grid"; -import { - clampTelemetryInterfaceSizePercent, - clampTelemetryNodeSizePx -} from "../utils/telemetryInterfaceLabels"; - -import { dispatchTopologyCommand, setHostRevision } from "./topologyHostClient"; -import { enqueueHostCommand } from "./topologyHostQueue"; - -export interface ApplySnapshotOptions { - /** If true, apply auto-layout when nodes have no preset positions */ - isInitialLoad?: boolean; -} - -const LAYOUTABLE_NODE_TYPES = new Set(["topology-node", "network-node"]); -const LEGACY_GROUP_PADDING = 40; -const LEGACY_NODE_WIDTH = 100; -const LEGACY_NODE_HEIGHT = 100; -const DEFAULT_GROUP_WIDTH = 300; -const DEFAULT_GROUP_HEIGHT = 200; -const LEGACY_DEFAULT_MEDIA_TEXT_WIDTH = 120; -const LEGACY_MEDIA_TEXT_HEIGHT_RATIO = 0.62; -const LEGACY_MIN_MEDIA_TEXT_HEIGHT = 48; -type TelemetryStyle = NonNullable["style"]>; -const GRID_LINE_WIDTH_MIN = 0.00001; -const GRID_LINE_WIDTH_MAX = 2; - -function isStandaloneMarkdownImage(value: unknown): boolean { - if (!isNonEmptyString(value)) return false; - return /^\s*!\[[^\]]*\]\([^)]+\)\s*$/u.test(value); -} - -function inferLegacyMediaTextHeight(width: number): number { - return Math.max(LEGACY_MIN_MEDIA_TEXT_HEIGHT, Math.round(width * LEGACY_MEDIA_TEXT_HEIGHT_RATIO)); -} - -function nodeBelongsToGroup( - annotation: NodeAnnotation, - groupId: string, - groupName: string, - groupLevel: string -): boolean { - if (isNonEmptyString(annotation.groupId)) { - return annotation.groupId === groupId; - } - if (!isNonEmptyString(annotation.group)) { - return false; - } - if (annotation.group !== groupId && annotation.group !== groupName) { - return false; - } - - const nodeLevel = isNonEmptyString(annotation.level) ? annotation.level : "1"; - return nodeLevel === groupLevel; -} - -function deriveLegacyGroupBounds( - groupId: string, - groupName: string, - groupLevel: string, - nodeAnnotations: NodeAnnotation[] -): { position: { x: number; y: number }; width: number; height: number } | undefined { - let minX = Number.POSITIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - let maxX = Number.NEGATIVE_INFINITY; - let maxY = Number.NEGATIVE_INFINITY; - - for (const annotation of nodeAnnotations) { - if (!nodeBelongsToGroup(annotation, groupId, groupName, groupLevel)) continue; - const position = toPosition(annotation.position); - if (!position) continue; - - minX = Math.min(minX, position.x); - minY = Math.min(minY, position.y); - maxX = Math.max(maxX, position.x + LEGACY_NODE_WIDTH); - maxY = Math.max(maxY, position.y + LEGACY_NODE_HEIGHT); - } - - if ( - !Number.isFinite(minX) || - !Number.isFinite(minY) || - !Number.isFinite(maxX) || - !Number.isFinite(maxY) - ) { - return undefined; - } - - return { - position: { x: minX - LEGACY_GROUP_PADDING, y: minY - LEGACY_GROUP_PADDING }, - width: Math.max(DEFAULT_GROUP_WIDTH, maxX - minX + LEGACY_GROUP_PADDING * 2), - height: Math.max(DEFAULT_GROUP_HEIGHT, maxY - minY + LEGACY_GROUP_PADDING * 2) - }; -} - -function normalizeFreeTextAnnotations(annotations: FreeTextAnnotation[]): FreeTextAnnotation[] { - return annotations.map((annotation) => { - const position = toPosition(annotation.position) ?? { x: 0, y: 0 }; - const width = toFiniteNumber(annotation.width); - const height = toFiniteNumber(annotation.height); - const isMedia = isStandaloneMarkdownImage(annotation.text); - const mediaWidth = width ?? LEGACY_DEFAULT_MEDIA_TEXT_WIDTH; - - const normalizedWidth = isMedia ? mediaWidth : width; - const normalizedHeight = isMedia ? (height ?? inferLegacyMediaTextHeight(mediaWidth)) : height; - - const normalizedAnnotation: FreeTextAnnotation = { - ...annotation, - position - }; - if (normalizedWidth !== undefined) { - normalizedAnnotation.width = normalizedWidth; - } else { - delete normalizedAnnotation.width; - } - if (normalizedHeight !== undefined) { - normalizedAnnotation.height = normalizedHeight; - } else { - delete normalizedAnnotation.height; - } - - return normalizedAnnotation; - }); -} - -function normalizeFreeShapeAnnotations(annotations: FreeShapeAnnotation[]): FreeShapeAnnotation[] { - return annotations.map((annotation) => { - const normalizedEnd = toPosition(annotation.endPosition); - return { - ...annotation, - position: toPosition(annotation.position) ?? { x: 0, y: 0 }, - endPosition: normalizedEnd - }; - }); -} - -function normalizeTrafficRateModeValue(value: unknown): TrafficRateAnnotation["mode"] | undefined { - if (value === "text") return "text"; - if (value === "chart" || value === "current") return "chart"; - return undefined; -} - -function normalizeTrafficRateTextMetricValue( - value: unknown -): TrafficRateAnnotation["textMetric"] | undefined { - if (value === "combined" || value === "rx" || value === "tx") return value; - return undefined; -} - -function setOptionalTrafficRateField( - normalized: TrafficRateAnnotation, - key: K, - value: TrafficRateAnnotation[K] | undefined -): void { - if (value === undefined) { - delete normalized[key]; - return; - } - normalized[key] = value; -} - -function normalizeTrafficRateShowLegend( - annotation: TrafficRateAnnotation, - normalized: TrafficRateAnnotation -): void { - if (annotation.showLegend === false) { - normalized.showLegend = false; - return; - } - delete normalized.showLegend; -} - -function normalizeTrafficRateAnnotation(annotation: TrafficRateAnnotation): TrafficRateAnnotation { - const normalized: TrafficRateAnnotation = { - ...annotation, - position: toPosition(annotation.position) ?? { x: 0, y: 0 } - }; - setOptionalTrafficRateField(normalized, "mode", normalizeTrafficRateModeValue(annotation.mode)); - setOptionalTrafficRateField( - normalized, - "textMetric", - normalizeTrafficRateTextMetricValue(annotation.textMetric) - ); - setOptionalTrafficRateField(normalized, "width", toFiniteNumber(annotation.width)); - setOptionalTrafficRateField(normalized, "height", toFiniteNumber(annotation.height)); - normalizeTrafficRateShowLegend(annotation, normalized); - setOptionalTrafficRateField( - normalized, - "backgroundOpacity", - toFiniteNumber(annotation.backgroundOpacity) - ); - setOptionalTrafficRateField(normalized, "borderWidth", toFiniteNumber(annotation.borderWidth)); - setOptionalTrafficRateField(normalized, "borderRadius", toFiniteNumber(annotation.borderRadius)); - setOptionalTrafficRateField(normalized, "zIndex", toFiniteNumber(annotation.zIndex)); - return normalized; -} - -function normalizeTrafficRateAnnotations( - annotations: TrafficRateAnnotation[] -): TrafficRateAnnotation[] { - return annotations.map((annotation) => normalizeTrafficRateAnnotation(annotation)); -} - -function resolveGroupIdentity( - group: GroupStyleAnnotation, - index: number -): { id: string; name: string; level: string } { - const id = isNonEmptyString(group.id) ? group.id : `legacy-group-${index + 1}`; - const identity = parseLegacyGroupIdentity(id); - const name = isNonEmptyString(group.name) ? group.name : identity.name; - const level = isNonEmptyString(group.level) ? group.level : identity.level; - return { id, name, level }; -} - -function resolveGroupBounds( - group: GroupStyleAnnotation, - identity: { id: string; name: string; level: string }, - nodeAnnotations: NodeAnnotation[] -): { position: { x: number; y: number }; width: number; height: number } { - const normalizedPosition = toPosition(group.position); - const normalizedWidth = toFiniteNumber(group.width); - const normalizedHeight = toFiniteNumber(group.height); - - if ( - normalizedPosition !== undefined && - normalizedWidth !== undefined && - normalizedHeight !== undefined - ) { - return { - position: normalizedPosition, - width: normalizedWidth, - height: normalizedHeight - }; - } - - const derivedBounds = deriveLegacyGroupBounds( - identity.id, - identity.name, - identity.level, - nodeAnnotations - ); - - return { - position: normalizedPosition ?? derivedBounds?.position ?? { x: 0, y: 0 }, - width: normalizedWidth ?? derivedBounds?.width ?? DEFAULT_GROUP_WIDTH, - height: normalizedHeight ?? derivedBounds?.height ?? DEFAULT_GROUP_HEIGHT - }; -} - -function resolveGroupLabelColor(group: GroupStyleAnnotation): string | undefined { - if (isNonEmptyString(group.labelColor)) { - return group.labelColor; - } - const legacyColor = (group as Record).color; - return isNonEmptyString(legacyColor) ? legacyColor : undefined; -} - -function normalizeGroupStyleAnnotation( - group: GroupStyleAnnotation, - index: number, - nodeAnnotations: NodeAnnotation[] -): GroupStyleAnnotation { - const identity = resolveGroupIdentity(group, index); - const bounds = resolveGroupBounds(group, identity, nodeAnnotations); - - return { - ...group, - id: identity.id, - name: identity.name, - level: identity.level, - position: bounds.position, - width: bounds.width, - height: bounds.height, - labelColor: resolveGroupLabelColor(group) - }; -} - -function normalizeGroupStyleAnnotations( - groups: GroupStyleAnnotation[], - nodeAnnotations: NodeAnnotation[] -): GroupStyleAnnotation[] { - return groups.map((group, index) => normalizeGroupStyleAnnotation(group, index, nodeAnnotations)); -} - -function syncUndoRedo(snapshot: TopologySnapshot): void { - useTopoViewerStore.getState().setInitialData({ - canUndo: snapshot.canUndo, - canRedo: snapshot.canRedo - }); -} - -function isLayoutableNode(node: Node): boolean { - return LAYOUTABLE_NODE_TYPES.has(node.type ?? ""); -} - -function snapLayoutPositions(nodes: Node[]): { - nodes: Node[]; - positions: Array<{ id: string; position: { x: number; y: number } }>; -} { - const positions: Array<{ id: string; position: { x: number; y: number } }> = []; - const snappedNodes = nodes.map((node) => { - if (!isLayoutableNode(node)) { - return node; - } - const snappedPosition = snapToGrid(node.position); - positions.push({ id: node.id, position: snappedPosition }); - if (snappedPosition.x === node.position.x && snappedPosition.y === node.position.y) { - return node; - } - return { - ...node, - position: snappedPosition - }; - }); - - return { nodes: snappedNodes, positions }; -} - -async function persistLayoutPositions( - positions: Array<{ id: string; position: { x: number; y: number } }> -): Promise { - if (positions.length === 0) return; - try { - const response = await enqueueHostCommand(() => - dispatchTopologyCommand({ - command: "savePositions", - payload: positions, - skipHistory: true - }) - ); - if (response.type === "topology-host:ack") { - if (response.snapshot) { - setHostRevision(response.snapshot.revision); - syncUndoRedo(response.snapshot); - } else if (typeof response.revision === "number") { - setHostRevision(response.revision); - } - return; - } - if (response.type === "topology-host:reject") { - setHostRevision(response.snapshot.revision); - syncUndoRedo(response.snapshot); - return; - } - if (response.type === "topology-host:error") { - throw new Error(response.error); - } - } catch (err) { - console.error("[TopologyHost] Failed to persist layout positions", err); - } -} - -function buildMergedNodes( - newNodes: TopoNode[], - nodeAnnotations: NodeAnnotation[] | undefined, - networkNodeAnnotations: NetworkNodeAnnotation[] | undefined, - groupStyleAnnotations: GroupStyleAnnotation[], - freeTextAnnotations: FreeTextAnnotation[], - freeShapeAnnotations: FreeShapeAnnotation[], - trafficRateAnnotations: TrafficRateAnnotation[] -): Node[] { - const topoWithMembership = applyGroupMembershipToNodes( - newNodes, - nodeAnnotations, - groupStyleAnnotations - ); - const topoWithGeoCoordinates = applyGeoCoordinatesToNodes( - topoWithMembership, - nodeAnnotations, - networkNodeAnnotations - ); - const annotationNodes = annotationsToNodes( - freeTextAnnotations, - freeShapeAnnotations, - groupStyleAnnotations, - trafficRateAnnotations - ); - const mergedNodes = [...topoWithGeoCoordinates, ...annotationNodes]; - return Array.from(new Map(mergedNodes.map((n) => [n.id, n])).values()); -} - -function normalizeAnnotations(annotations?: TopologyAnnotations): Required { - const { - freeTextAnnotations = [], - freeShapeAnnotations = [], - trafficRateAnnotations = [], - groupStyleAnnotations = [], - nodeAnnotations = [], - networkNodeAnnotations = [], - edgeAnnotations = [], - aliasEndpointAnnotations = [], - viewerSettings = {} - } = annotations ?? {}; - - const normalizedNodeAnnotations = nodeAnnotations; - return { - freeTextAnnotations: normalizeFreeTextAnnotations(freeTextAnnotations), - freeShapeAnnotations: normalizeFreeShapeAnnotations(freeShapeAnnotations), - trafficRateAnnotations: normalizeTrafficRateAnnotations(trafficRateAnnotations), - groupStyleAnnotations: normalizeGroupStyleAnnotations( - groupStyleAnnotations, - normalizedNodeAnnotations - ), - nodeAnnotations: normalizedNodeAnnotations, - networkNodeAnnotations, - edgeAnnotations, - aliasEndpointAnnotations, - viewerSettings - }; -} - -function applyGeoCoordinatesToNodes( - nodes: TopoNode[], - nodeAnnotations: NodeAnnotation[] | undefined, - networkNodeAnnotations: NetworkNodeAnnotation[] | undefined -): Node[] { - if ( - (!nodeAnnotations || nodeAnnotations.length === 0) && - (!networkNodeAnnotations || networkNodeAnnotations.length === 0) - ) { - return nodes; - } - - const geoMap = new Map(); - for (const annotation of nodeAnnotations ?? []) { - if (annotation.geoCoordinates) { - geoMap.set(annotation.id, annotation.geoCoordinates); - } - } - for (const annotation of networkNodeAnnotations ?? []) { - if (annotation.geoCoordinates) { - geoMap.set(annotation.id, annotation.geoCoordinates); - } - } - - if (geoMap.size === 0) return nodes; - - return nodes.map((node) => { - const geo = geoMap.get(node.id); - if (!geo) return node; - const data = node.data; - return { - ...node, - data: { ...data, geoCoordinates: geo } - }; - }); -} - -function hasGeoCoordinates(annotations: Required): boolean { - return ( - annotations.nodeAnnotations.some((ann) => Boolean(ann.geoCoordinates)) || - annotations.networkNodeAnnotations.some((ann) => Boolean(ann.geoCoordinates)) - ); -} - -function parseLinkLabelMode(value: unknown): LinkLabelMode | null { - if ( - value === "show-all" || - value === "on-select" || - value === "hide" || - value === "telemetry-style" - ) { - return value; - } - return null; -} - -function parseNonTelemetryLinkLabelMode(value: unknown): NonTelemetryLinkLabelMode | null { - if (value === "show-all" || value === "on-select" || value === "hide") { - return value; - } - return null; -} - -function parseTelemetryStyle(value: unknown): TelemetryStyle | null { - if (value === "default" || value === "telemetry-style") { - return value; - } - return null; -} - -function parseGridStyle(value: unknown): "dotted" | "quadratic" | null { - if (value === "dotted" || value === "quadratic") { - return value; - } - return null; -} - -function parseGridLineWidth(value: unknown): number | null { - const parsed = toFiniteNumber(value); - if (parsed === undefined) return null; - return Math.min(GRID_LINE_WIDTH_MAX, Math.max(GRID_LINE_WIDTH_MIN, parsed)); -} - -function parseTelemetryNodeSizePx(value: unknown): number | null { - const parsed = toFiniteNumber(value); - if (parsed === undefined) return null; - return clampTelemetryNodeSizePx(parsed); -} - -function parseTelemetryInterfaceSizePercent(value: unknown): number | null { - const parsed = toFiniteNumber(value); - if (parsed === undefined) return null; - return clampTelemetryInterfaceSizePercent(parsed); -} - -function resolveLinkLabelModes(viewerSettings: Required["viewerSettings"]): { - resolvedLinkLabelMode: LinkLabelMode | null; - resolvedLastNonTelemetryLinkLabelMode: NonTelemetryLinkLabelMode | null; -} { - const telemetryStyle = parseTelemetryStyle(viewerSettings.style); - const parsedLinkLabelMode = parseLinkLabelMode(viewerSettings.linkLabelMode); - const parsedLastNonTelemetryLinkLabelMode = parseNonTelemetryLinkLabelMode( - viewerSettings.lastNonTelemetryLinkLabelMode - ); - const resolvedLastNonTelemetryLinkLabelMode = - parsedLastNonTelemetryLinkLabelMode ?? - (parsedLinkLabelMode !== null && parsedLinkLabelMode !== "telemetry-style" - ? parsedLinkLabelMode - : null); - - const useTelemetryStyle = - telemetryStyle === "telemetry-style" || - (telemetryStyle === null && parsedLinkLabelMode === "telemetry-style"); - if (useTelemetryStyle) { - return { - resolvedLinkLabelMode: "telemetry-style", - resolvedLastNonTelemetryLinkLabelMode - }; - } - - if (parsedLinkLabelMode !== null && parsedLinkLabelMode !== "telemetry-style") { - return { - resolvedLinkLabelMode: parsedLinkLabelMode, - resolvedLastNonTelemetryLinkLabelMode - }; - } - - if (resolvedLastNonTelemetryLinkLabelMode !== null) { - return { - resolvedLinkLabelMode: resolvedLastNonTelemetryLinkLabelMode, - resolvedLastNonTelemetryLinkLabelMode - }; - } - - return { - resolvedLinkLabelMode: null, - resolvedLastNonTelemetryLinkLabelMode - }; -} - -function buildInitialTopoViewerData( - snapshot: TopologySnapshot, - edgeAnnotations: TopologyAnnotations["edgeAnnotations"], - viewerSettings: Required["viewerSettings"] -): Partial { - const offset = parseEndpointLabelOffset(viewerSettings.endpointLabelOffset); - const { gridColor, gridBgColor } = viewerSettings; - const gridLineWidth = parseGridLineWidth(viewerSettings.gridLineWidth); - const gridStyle = parseGridStyle(viewerSettings.gridStyle); - const telemetryNodeSizePx = parseTelemetryNodeSizePx(viewerSettings.telemetryNodeSizePx); - const telemetryInterfaceSizePercent = parseTelemetryInterfaceSizePercent( - viewerSettings.telemetryInterfaceSizePercent - ); - const { resolvedLinkLabelMode, resolvedLastNonTelemetryLinkLabelMode } = - resolveLinkLabelModes(viewerSettings); - - return { - labName: snapshot.labName, - mode: snapshot.mode, - deploymentState: snapshot.deploymentState, - labSettings: snapshot.labSettings, - yamlFileName: snapshot.yamlFileName, - annotationsFileName: snapshot.annotationsFileName, - yamlContent: snapshot.yamlContent, - annotationsContent: snapshot.annotationsContent, - edgeAnnotations, - ...(offset !== null ? { endpointLabelOffset: offset } : {}), - ...(gridLineWidth !== null ? { gridLineWidth } : {}), - ...(gridStyle !== null ? { gridStyle } : {}), - gridColor: gridColor ?? null, - gridBgColor: gridBgColor ?? null, - ...(telemetryNodeSizePx !== null ? { telemetryNodeSizePx } : {}), - ...(telemetryInterfaceSizePercent !== null ? { telemetryInterfaceSizePercent } : {}), - ...(resolvedLinkLabelMode !== null ? { linkLabelMode: resolvedLinkLabelMode } : {}), - ...(resolvedLastNonTelemetryLinkLabelMode !== null - ? { lastNonTelemetryLinkLabelMode: resolvedLastNonTelemetryLinkLabelMode } - : {}), - canUndo: snapshot.canUndo, - canRedo: snapshot.canRedo - }; -} - -export function applySnapshotToStores( - snapshot: TopologySnapshot, - options: ApplySnapshotOptions = {} -): void { - setHostRevision(snapshot.revision); - - const annotations = normalizeAnnotations(snapshot.annotations); - const edges = snapshot.edges; - const nodes = snapshot.nodes; - - let mergedNodes = buildMergedNodes( - nodes, - annotations.nodeAnnotations, - annotations.networkNodeAnnotations, - annotations.groupStyleAnnotations, - annotations.freeTextAnnotations, - annotations.freeShapeAnnotations, - annotations.trafficRateAnnotations - ); - - // Apply force layout when no preset positions exist and geo coordinates are not driving layout. - // This handles the case when annotation.json doesn't exist or positions were cleared (e.g. undo). - if (!hasPresetPositions(mergedNodes) && !hasGeoCoordinates(annotations)) { - const layoutNodes = applyForceLayout(mergedNodes, edges); - const { nodes: snappedNodes, positions } = snapLayoutPositions(layoutNodes); - mergedNodes = snappedNodes; - void persistLayoutPositions(positions); - } - - const cleanedEdgeAnnotations = pruneEdgeAnnotations(annotations.edgeAnnotations, edges); - - const graphStore = useGraphStore.getState(); - graphStore.setGraph(mergedNodes, edges); - - const initialData = buildInitialTopoViewerData( - snapshot, - cleanedEdgeAnnotations, - annotations.viewerSettings - ); - useTopoViewerStore.getState().setInitialData(initialData); - - if (options.isInitialLoad === true) { - useCanvasStore.getState().requestFitView(); - } -} diff --git a/src/reactTopoViewer/webview/stores/annotationUIStore.ts b/src/reactTopoViewer/webview/stores/annotationUIStore.ts deleted file mode 100644 index fcb4c9fd6..000000000 --- a/src/reactTopoViewer/webview/stores/annotationUIStore.ts +++ /dev/null @@ -1,463 +0,0 @@ -/** - * annotationUIStore - Zustand store for annotation UI state - * - * This store manages annotation-specific UI state like selections and editing. - * Annotation data is derived from graphStore via useDerivedAnnotations hook. - */ -import { createWithEqualityFn } from "zustand/traditional"; -import { shallow } from "zustand/shallow"; - -import type { - FreeTextAnnotation, - FreeShapeAnnotation, - TrafficRateAnnotation -} from "../../shared/types/topology"; -import type { GroupEditorData } from "../hooks/canvas/groupTypes"; - -// ============================================================================ -// Types -// ============================================================================ - -type ShapeAnnotationType = FreeShapeAnnotation["shapeType"]; - -export interface AnnotationUIState { - // Group UI state - selectedGroupIds: Set; - editingGroup: GroupEditorData | null; - - // Text annotation UI state - selectedTextIds: Set; - editingTextAnnotation: FreeTextAnnotation | null; - isAddTextMode: boolean; - - // Shape annotation UI state - selectedShapeIds: Set; - editingShapeAnnotation: FreeShapeAnnotation | null; - isAddShapeMode: boolean; - pendingShapeType: ShapeAnnotationType; - - // Traffic-rate annotation UI state - selectedTrafficRateIds: Set; - editingTrafficRateAnnotation: TrafficRateAnnotation | null; -} - -export interface AnnotationUIActions { - // Group selection - selectGroup: (id: string) => void; - toggleGroupSelection: (id: string) => void; - boxSelectGroups: (ids: string[]) => void; - clearGroupSelection: () => void; - - // Group editing - setEditingGroup: (data: GroupEditorData | null) => void; - closeGroupEditor: () => void; - - // Text annotation selection - selectTextAnnotation: (id: string) => void; - toggleTextAnnotationSelection: (id: string) => void; - boxSelectTextAnnotations: (ids: string[]) => void; - clearTextAnnotationSelection: () => void; - - // Text annotation editing - setEditingTextAnnotation: (annotation: FreeTextAnnotation | null) => void; - closeTextEditor: () => void; - setAddTextMode: (enabled: boolean) => void; - disableAddTextMode: () => void; - - // Shape annotation selection - selectShapeAnnotation: (id: string) => void; - toggleShapeAnnotationSelection: (id: string) => void; - boxSelectShapeAnnotations: (ids: string[]) => void; - clearShapeAnnotationSelection: () => void; - - // Shape annotation editing - setEditingShapeAnnotation: (annotation: FreeShapeAnnotation | null) => void; - closeShapeEditor: () => void; - setAddShapeMode: (enabled: boolean, shapeType?: ShapeAnnotationType) => void; - disableAddShapeMode: () => void; - setPendingShapeType: (shapeType: ShapeAnnotationType) => void; - - // Traffic-rate annotation selection - selectTrafficRateAnnotation: (id: string) => void; - toggleTrafficRateAnnotationSelection: (id: string) => void; - boxSelectTrafficRateAnnotations: (ids: string[]) => void; - clearTrafficRateAnnotationSelection: () => void; - - // Traffic-rate annotation editing - setEditingTrafficRateAnnotation: (annotation: TrafficRateAnnotation | null) => void; - closeTrafficRateEditor: () => void; - - // Utility - clearAllSelections: () => void; - - // For deletion cleanup - removeFromGroupSelection: (id: string) => void; - removeFromTextSelection: (id: string) => void; - removeFromShapeSelection: (id: string) => void; - removeFromTrafficRateSelection: (id: string) => void; -} - -export type AnnotationUIStore = AnnotationUIState & AnnotationUIActions; - -// ============================================================================ -// Initial State -// ============================================================================ - -const initialState: AnnotationUIState = { - selectedGroupIds: new Set(), - editingGroup: null, - selectedTextIds: new Set(), - editingTextAnnotation: null, - isAddTextMode: false, - selectedShapeIds: new Set(), - editingShapeAnnotation: null, - isAddShapeMode: false, - pendingShapeType: "rectangle", - selectedTrafficRateIds: new Set(), - editingTrafficRateAnnotation: null -}; - -// ============================================================================ -// Store Creation -// ============================================================================ - -export const useAnnotationUIStore = createWithEqualityFn((set) => ({ - ...initialState, - - // Group selection - selectGroup: (id) => { - set({ selectedGroupIds: new Set([id]) }); - }, - - toggleGroupSelection: (id) => { - set((state) => { - const next = new Set(state.selectedGroupIds); - if (next.has(id)) next.delete(id); - else next.add(id); - return { selectedGroupIds: next }; - }); - }, - - boxSelectGroups: (ids) => { - set({ selectedGroupIds: new Set(ids) }); - }, - - clearGroupSelection: () => { - set({ selectedGroupIds: new Set() }); - }, - - // Group editing - setEditingGroup: (editingGroup) => { - set({ - editingGroup, - ...(editingGroup - ? { - // Ensure only one annotation editor is active at a time. - editingTextAnnotation: null, - editingShapeAnnotation: null, - editingTrafficRateAnnotation: null - } - : {}) - }); - }, - - closeGroupEditor: () => { - set({ editingGroup: null }); - }, - - // Text annotation selection - selectTextAnnotation: (id) => { - set({ selectedTextIds: new Set([id]) }); - }, - - toggleTextAnnotationSelection: (id) => { - set((state) => { - const next = new Set(state.selectedTextIds); - if (next.has(id)) next.delete(id); - else next.add(id); - return { selectedTextIds: next }; - }); - }, - - boxSelectTextAnnotations: (ids) => { - set({ selectedTextIds: new Set(ids) }); - }, - - clearTextAnnotationSelection: () => { - set({ selectedTextIds: new Set() }); - }, - - // Text annotation editing - setEditingTextAnnotation: (editingTextAnnotation) => { - set({ - editingTextAnnotation, - ...(editingTextAnnotation - ? { - // Ensure only one annotation editor is active at a time. - editingGroup: null, - editingShapeAnnotation: null, - editingTrafficRateAnnotation: null - } - : {}) - }); - }, - - closeTextEditor: () => { - set({ editingTextAnnotation: null }); - }, - - setAddTextMode: (isAddTextMode) => { - set({ isAddTextMode, isAddShapeMode: false }); - }, - - disableAddTextMode: () => { - set({ isAddTextMode: false }); - }, - - // Shape annotation selection - selectShapeAnnotation: (id) => { - set({ selectedShapeIds: new Set([id]) }); - }, - - toggleShapeAnnotationSelection: (id) => { - set((state) => { - const next = new Set(state.selectedShapeIds); - if (next.has(id)) next.delete(id); - else next.add(id); - return { selectedShapeIds: next }; - }); - }, - - boxSelectShapeAnnotations: (ids) => { - set({ selectedShapeIds: new Set(ids) }); - }, - - clearShapeAnnotationSelection: () => { - set({ selectedShapeIds: new Set() }); - }, - - // Shape annotation editing - setEditingShapeAnnotation: (editingShapeAnnotation) => { - set({ - editingShapeAnnotation, - ...(editingShapeAnnotation - ? { - // Ensure only one annotation editor is active at a time. - editingGroup: null, - editingTextAnnotation: null, - editingTrafficRateAnnotation: null - } - : {}) - }); - }, - - closeShapeEditor: () => { - set({ editingShapeAnnotation: null }); - }, - - setAddShapeMode: (enabled, shapeType) => { - set({ - isAddShapeMode: enabled, - isAddTextMode: false, - ...(shapeType ? { pendingShapeType: shapeType } : {}) - }); - }, - - disableAddShapeMode: () => { - set({ isAddShapeMode: false }); - }, - - setPendingShapeType: (pendingShapeType) => { - set({ pendingShapeType }); - }, - - // Traffic-rate annotation selection - selectTrafficRateAnnotation: (id) => { - set({ selectedTrafficRateIds: new Set([id]) }); - }, - - toggleTrafficRateAnnotationSelection: (id) => { - set((state) => { - const next = new Set(state.selectedTrafficRateIds); - if (next.has(id)) next.delete(id); - else next.add(id); - return { selectedTrafficRateIds: next }; - }); - }, - - boxSelectTrafficRateAnnotations: (ids) => { - set({ selectedTrafficRateIds: new Set(ids) }); - }, - - clearTrafficRateAnnotationSelection: () => { - set({ selectedTrafficRateIds: new Set() }); - }, - - // Traffic-rate annotation editing - setEditingTrafficRateAnnotation: (editingTrafficRateAnnotation) => { - set({ - editingTrafficRateAnnotation, - ...(editingTrafficRateAnnotation - ? { - // Ensure only one annotation editor is active at a time. - editingGroup: null, - editingTextAnnotation: null, - editingShapeAnnotation: null - } - : {}) - }); - }, - - closeTrafficRateEditor: () => { - set({ editingTrafficRateAnnotation: null }); - }, - - // Utility - clearAllSelections: () => { - set({ - selectedGroupIds: new Set(), - selectedTextIds: new Set(), - selectedShapeIds: new Set(), - selectedTrafficRateIds: new Set() - }); - }, - - // For deletion cleanup - removeFromGroupSelection: (id) => { - set((state) => { - if (!state.selectedGroupIds.has(id)) return state; - const next = new Set(state.selectedGroupIds); - next.delete(id); - return { selectedGroupIds: next }; - }); - }, - - removeFromTextSelection: (id) => { - set((state) => { - if (!state.selectedTextIds.has(id)) return state; - const next = new Set(state.selectedTextIds); - next.delete(id); - return { selectedTextIds: next }; - }); - }, - - removeFromShapeSelection: (id) => { - set((state) => { - if (!state.selectedShapeIds.has(id)) return state; - const next = new Set(state.selectedShapeIds); - next.delete(id); - return { selectedShapeIds: next }; - }); - }, - - removeFromTrafficRateSelection: (id) => { - set((state) => { - if (!state.selectedTrafficRateIds.has(id)) return state; - const next = new Set(state.selectedTrafficRateIds); - next.delete(id); - return { selectedTrafficRateIds: next }; - }); - } -})); - -// ============================================================================ -// Selector Hooks (for convenience) -// ============================================================================ - -/** Get selected group IDs */ -export const useSelectedGroupIds = () => useAnnotationUIStore((state) => state.selectedGroupIds); - -/** Get editing group */ -export const useEditingGroup = () => useAnnotationUIStore((state) => state.editingGroup); - -/** Get selected text IDs */ -export const useSelectedTextIds = () => useAnnotationUIStore((state) => state.selectedTextIds); - -/** Get editing text annotation */ -export const useEditingTextAnnotation = () => - useAnnotationUIStore((state) => state.editingTextAnnotation); - -/** Get add text mode */ -export const useIsAddTextMode = () => useAnnotationUIStore((state) => state.isAddTextMode); - -/** Get selected shape IDs */ -export const useSelectedShapeIds = () => useAnnotationUIStore((state) => state.selectedShapeIds); - -/** Get editing shape annotation */ -export const useEditingShapeAnnotation = () => - useAnnotationUIStore((state) => state.editingShapeAnnotation); - -/** Get add shape mode */ -export const useIsAddShapeMode = () => useAnnotationUIStore((state) => state.isAddShapeMode); - -/** Get pending shape type */ -export const usePendingShapeType = () => useAnnotationUIStore((state) => state.pendingShapeType); - -/** Get selected traffic-rate IDs */ -export const useSelectedTrafficRateIds = () => - useAnnotationUIStore((state) => state.selectedTrafficRateIds); - -/** Get editing traffic-rate annotation */ -export const useEditingTrafficRateAnnotation = () => - useAnnotationUIStore((state) => state.editingTrafficRateAnnotation); - -/** Get annotation UI state (group/text/shape selections and edit modes) */ -export const useAnnotationUIState = () => - useAnnotationUIStore( - (state) => ({ - selectedGroupIds: state.selectedGroupIds, - editingGroup: state.editingGroup, - selectedTextIds: state.selectedTextIds, - editingTextAnnotation: state.editingTextAnnotation, - isAddTextMode: state.isAddTextMode, - selectedShapeIds: state.selectedShapeIds, - editingShapeAnnotation: state.editingShapeAnnotation, - isAddShapeMode: state.isAddShapeMode, - pendingShapeType: state.pendingShapeType, - selectedTrafficRateIds: state.selectedTrafficRateIds, - editingTrafficRateAnnotation: state.editingTrafficRateAnnotation - }), - shallow - ); - -/** Get annotation UI actions (stable) */ -export const useAnnotationUIActions = () => - useAnnotationUIStore( - (state) => ({ - selectGroup: state.selectGroup, - toggleGroupSelection: state.toggleGroupSelection, - boxSelectGroups: state.boxSelectGroups, - clearGroupSelection: state.clearGroupSelection, - setEditingGroup: state.setEditingGroup, - closeGroupEditor: state.closeGroupEditor, - selectTextAnnotation: state.selectTextAnnotation, - toggleTextAnnotationSelection: state.toggleTextAnnotationSelection, - boxSelectTextAnnotations: state.boxSelectTextAnnotations, - clearTextAnnotationSelection: state.clearTextAnnotationSelection, - setEditingTextAnnotation: state.setEditingTextAnnotation, - closeTextEditor: state.closeTextEditor, - setAddTextMode: state.setAddTextMode, - disableAddTextMode: state.disableAddTextMode, - selectShapeAnnotation: state.selectShapeAnnotation, - toggleShapeAnnotationSelection: state.toggleShapeAnnotationSelection, - boxSelectShapeAnnotations: state.boxSelectShapeAnnotations, - clearShapeAnnotationSelection: state.clearShapeAnnotationSelection, - setEditingShapeAnnotation: state.setEditingShapeAnnotation, - closeShapeEditor: state.closeShapeEditor, - setAddShapeMode: state.setAddShapeMode, - disableAddShapeMode: state.disableAddShapeMode, - setPendingShapeType: state.setPendingShapeType, - selectTrafficRateAnnotation: state.selectTrafficRateAnnotation, - toggleTrafficRateAnnotationSelection: state.toggleTrafficRateAnnotationSelection, - boxSelectTrafficRateAnnotations: state.boxSelectTrafficRateAnnotations, - clearTrafficRateAnnotationSelection: state.clearTrafficRateAnnotationSelection, - setEditingTrafficRateAnnotation: state.setEditingTrafficRateAnnotation, - closeTrafficRateEditor: state.closeTrafficRateEditor, - clearAllSelections: state.clearAllSelections, - removeFromGroupSelection: state.removeFromGroupSelection, - removeFromTextSelection: state.removeFromTextSelection, - removeFromShapeSelection: state.removeFromShapeSelection, - removeFromTrafficRateSelection: state.removeFromTrafficRateSelection - }), - shallow - ); diff --git a/src/reactTopoViewer/webview/stores/canvasStore.ts b/src/reactTopoViewer/webview/stores/canvasStore.ts deleted file mode 100644 index cf3959cc4..000000000 --- a/src/reactTopoViewer/webview/stores/canvasStore.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * canvasStore - Zustand store for canvas-scoped state - * - * Manages link creation, render config, and annotation handlers. - * Edge info (parallel/loop) is computed separately as derived data. - */ -import { createWithEqualityFn } from "zustand/traditional"; -import type { Edge } from "@xyflow/react"; - -import type { AnnotationHandlers, EdgeLabelMode } from "../components/canvas/types"; - -export interface EdgeRenderConfig { - labelMode: EdgeLabelMode; - suppressLabels: boolean; - suppressHitArea: boolean; -} - -export interface NodeRenderConfig { - suppressLabels: boolean; -} - -/** RGB color for easter egg glow effects */ -export interface RGBColor { - r: number; - g: number; - b: number; -} - -/** Easter egg glow state for node visual effects */ -export interface EasterEggGlow { - color: RGBColor; - intensity: number; -} - -export interface ParallelEdgeInfo { - /** Index of this edge within its parallel group */ - index: number; - /** Total number of edges in this parallel group */ - total: number; - /** True if edge direction matches canonical direction (smaller nodeId → larger nodeId) */ - isCanonicalDirection: boolean; -} - -export interface LoopEdgeInfo { - /** Index of this loop edge on the node */ - loopIndex: number; -} - -export interface EdgeInfo { - getParallelInfo: (edgeId: string) => ParallelEdgeInfo | null; - getLoopInfo: (edgeId: string) => LoopEdgeInfo | null; -} - -export interface CanvasState { - linkSourceNode: string | null; - edgeRenderConfig: EdgeRenderConfig; - nodeRenderConfig: NodeRenderConfig; - annotationHandlers: AnnotationHandlers | null; - easterEggGlow: EasterEggGlow | null; - fitViewRequestId: number; -} - -export interface CanvasActions { - setLinkSourceNode: (nodeId: string | null) => void; - setEdgeRenderConfig: (config: EdgeRenderConfig) => void; - setNodeRenderConfig: (config: NodeRenderConfig) => void; - setAnnotationHandlers: (handlers: AnnotationHandlers | null) => void; - setEasterEggGlow: (glow: EasterEggGlow | null) => void; - requestFitView: () => void; -} - -export type CanvasStore = CanvasState & CanvasActions; - -// ============================================================================ -// Edge Info Computation (extracted for reuse) -// ============================================================================ - -/** Group edges by loop/regular and categorize by node pair */ -function groupEdges(edges: Edge[]) { - const edgesByPair = new Map(); - const loopEdgesByNode = new Map(); - - for (const edge of edges) { - if (edge.source === edge.target) { - const loops = loopEdgesByNode.get(edge.source) ?? []; - loops.push(edge.id); - loopEdgesByNode.set(edge.source, loops); - } else { - const [nodeA, nodeB] = - edge.source.localeCompare(edge.target) <= 0 - ? [edge.source, edge.target] - : [edge.target, edge.source]; - const pairKey = `${nodeA}|||${nodeB}`; - - const group = edgesByPair.get(pairKey) ?? []; - group.push({ id: edge.id, source: edge.source, target: edge.target }); - edgesByPair.set(pairKey, group); - } - } - - return { edgesByPair, loopEdgesByNode }; -} - -/** Process loop edges and populate the loop info map */ -function processLoopEdges( - loopEdgesByNode: Map, - loopInfoMap: Map -) { - for (const [, loopEdges] of loopEdgesByNode) { - loopEdges.sort((a, b) => a.localeCompare(b)); - for (let i = 0; i < loopEdges.length; i++) { - loopInfoMap.set(loopEdges[i], { loopIndex: i }); - } - } -} - -/** Process parallel edge groups and populate the parallel info map */ -function processParallelEdges( - edgesByPair: Map, - parallelInfoMap: Map -) { - for (const [, group] of edgesByPair) { - group.sort((a, b) => a.id.localeCompare(b.id)); - - for (let i = 0; i < group.length; i++) { - const edge = group[i]; - const isCanonicalDirection = edge.source.localeCompare(edge.target) <= 0; - parallelInfoMap.set(edge.id, { - index: i, - total: group.length, - isCanonicalDirection - }); - } - } -} - -/** Build edge info from edges array */ -export function buildEdgeInfo(edges: Edge[]): EdgeInfo { - const parallelInfoMap = new Map(); - const loopInfoMap = new Map(); - - const { edgesByPair, loopEdgesByNode } = groupEdges(edges); - processLoopEdges(loopEdgesByNode, loopInfoMap); - processParallelEdges(edgesByPair, parallelInfoMap); - - return { - getParallelInfo: (edgeId: string) => parallelInfoMap.get(edgeId) ?? null, - getLoopInfo: (edgeId: string) => loopInfoMap.get(edgeId) ?? null - }; -} - -// ============================================================================ -// Initial State -// ============================================================================ - -const defaultEdgeRenderConfig: EdgeRenderConfig = { - labelMode: "show-all", - suppressLabels: false, - suppressHitArea: false -}; - -const defaultNodeRenderConfig: NodeRenderConfig = { - suppressLabels: false -}; - -const initialState: CanvasState = { - linkSourceNode: null, - edgeRenderConfig: defaultEdgeRenderConfig, - nodeRenderConfig: defaultNodeRenderConfig, - annotationHandlers: null, - easterEggGlow: null, - fitViewRequestId: 0 -}; - -// ============================================================================ -// Module-level Edge Info Cache (avoids setState during render) -// ============================================================================ - -let edgeInfoCache: { - edgesRef: Edge[] | null; - info: EdgeInfo | null; -} = { - edgesRef: null, - info: null -}; - -// ============================================================================ -// Store Creation -// ============================================================================ - -export const useCanvasStore = createWithEqualityFn((set) => ({ - ...initialState, - - setLinkSourceNode: (linkSourceNode) => { - set({ linkSourceNode }); - }, - - setEdgeRenderConfig: (edgeRenderConfig) => { - set({ edgeRenderConfig }); - }, - - setNodeRenderConfig: (nodeRenderConfig) => { - set({ nodeRenderConfig }); - }, - - setAnnotationHandlers: (annotationHandlers) => { - set({ annotationHandlers }); - }, - - setEasterEggGlow: (easterEggGlow) => { - set({ easterEggGlow }); - }, - requestFitView: () => { - set((state) => ({ fitViewRequestId: state.fitViewRequestId + 1 })); - } -})); - -/** - * Get edge info with caching (module-level cache to avoid setState during render). - * This function is safe to call during render because it doesn't trigger React updates. - */ -export function getEdgeInfo(edges: Edge[]): EdgeInfo { - // If same reference, return cached - if (edgeInfoCache.edgesRef === edges && edgeInfoCache.info) { - return edgeInfoCache.info; - } - - // Compute new edge info - const info = buildEdgeInfo(edges); - - // Update module-level cache (no React state update) - edgeInfoCache = { edgesRef: edges, info }; - - return info; -} - -// ============================================================================ -// Selector Hooks (for convenience) -// ============================================================================ - -/** Get link source node */ -export const useLinkSourceNode = () => useCanvasStore((state) => state.linkSourceNode); - -/** Get edge render config */ -export const useEdgeRenderConfig = () => useCanvasStore((state) => state.edgeRenderConfig); - -/** Get node render config */ -export const useNodeRenderConfig = () => useCanvasStore((state) => state.nodeRenderConfig); - -/** Get annotation handlers */ -export const useAnnotationHandlers = () => useCanvasStore((state) => state.annotationHandlers); - -/** Get easter egg glow state */ -export const useEasterEggGlow = () => useCanvasStore((state) => state.easterEggGlow); - -/** Get set easter egg glow action */ -export const useSetEasterEggGlow = () => useCanvasStore((state) => state.setEasterEggGlow); - -/** Get fitView request id */ -export const useFitViewRequestId = () => useCanvasStore((state) => state.fitViewRequestId); - -/** Get link creation context (legacy API shape) */ -export const useLinkCreationContext = () => { - const linkSourceNode = useCanvasStore((state) => state.linkSourceNode); - return { linkSourceNode }; -}; - -/** - * Hook to get edge info computed from edges array. - * Uses module-level cached computation (safe to call during render). - */ -export function useEdgeInfo(edges: Edge[]): EdgeInfo { - return getEdgeInfo(edges); -} diff --git a/src/reactTopoViewer/webview/stores/graphStore.ts b/src/reactTopoViewer/webview/stores/graphStore.ts deleted file mode 100644 index e037aafe2..000000000 --- a/src/reactTopoViewer/webview/stores/graphStore.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * graphStore - Zustand store for React Flow graph state (nodes/edges) - * - * This store owns the React Flow nodes/edges state and provides - * all graph manipulation operations. React Flow is the source of truth. - */ -import { createWithEqualityFn } from "zustand/traditional"; -import { shallow } from "zustand/shallow"; -import { applyNodeChanges, applyEdgeChanges } from "@xyflow/react"; -import type { Node, Edge, NodeChange, EdgeChange } from "@xyflow/react"; - -// ============================================================================ -// Types -// ============================================================================ - -export interface GraphState { - nodes: Node[]; - edges: Edge[]; -} - -export interface GraphActions { - // Core setters - setNodes: (nodesOrUpdater: Node[] | ((prev: Node[]) => Node[])) => void; - setEdges: (edgesOrUpdater: Edge[] | ((prev: Edge[]) => Edge[])) => void; - setGraph: (nodes: Node[], edges: Edge[]) => void; - - // React Flow change handlers - onNodesChange: (changes: NodeChange[]) => void; - onEdgesChange: (changes: EdgeChange[]) => void; - - // Node mutations - addNode: (node: Node) => void; - removeNode: (nodeId: string) => void; - removeNodeAndEdges: (nodeId: string) => void; - updateNode: (nodeId: string, updates: Partial) => void; - replaceNode: (nodeId: string, newNode: Node) => void; - renameNode: (oldId: string, newId: string, name?: string) => void; - updateNodePositions: ( - positions: Array<{ id: string; position: { x: number; y: number } }> - ) => void; - updateNodeData: (nodeId: string, data: Partial>) => void; - - // Edge mutations - addEdge: (edge: Edge) => void; - removeEdge: (edgeId: string) => void; - updateEdge: (edgeId: string, updates: Partial) => void; - updateEdgeData: (edgeId: string, data: Partial>) => void; -} - -export type GraphStore = GraphState & GraphActions; - -// ============================================================================ -// Store Creation -// ============================================================================ - -export const useGraphStore = createWithEqualityFn((set, get) => ({ - // Initial state - nodes: [], - edges: [], - - // Core setters - setNodes: (nodesOrUpdater) => { - set((state) => ({ - nodes: typeof nodesOrUpdater === "function" ? nodesOrUpdater(state.nodes) : nodesOrUpdater - })); - }, - - setEdges: (edgesOrUpdater) => { - set((state) => ({ - edges: typeof edgesOrUpdater === "function" ? edgesOrUpdater(state.edges) : edgesOrUpdater - })); - }, - - setGraph: (nodes, edges) => { - set({ nodes, edges }); - }, - - // React Flow change handlers - onNodesChange: (changes) => { - set((state) => ({ - nodes: applyNodeChanges(changes, state.nodes) - })); - }, - - onEdgesChange: (changes) => { - set((state) => ({ - edges: applyEdgeChanges(changes, state.edges) - })); - }, - - // Node mutations - addNode: (node) => { - set((state) => { - if (state.nodes.some((n) => n.id === node.id)) return state; - return { nodes: [...state.nodes, node] }; - }); - }, - - removeNode: (nodeId) => { - set((state) => ({ - nodes: state.nodes.filter((n) => n.id !== nodeId) - })); - }, - - removeNodeAndEdges: (nodeId) => { - set((state) => ({ - nodes: state.nodes.filter((n) => n.id !== nodeId), - edges: state.edges.filter((e) => e.source !== nodeId && e.target !== nodeId) - })); - }, - - updateNode: (nodeId, updates) => { - set((state) => ({ - nodes: state.nodes.map((node) => { - if (node.id !== nodeId) return node; - const mergedData = updates.data ? { ...node.data, ...updates.data } : node.data; - return { ...node, ...updates, data: mergedData }; - }) - })); - }, - - replaceNode: (nodeId, newNode) => { - set((state) => ({ - nodes: state.nodes.map((node) => (node.id === nodeId ? newNode : node)) - })); - }, - - renameNode: (oldId, newId, name) => { - const nextName = name ?? newId; - set((state) => ({ - nodes: state.nodes.map((node) => { - if (node.id !== oldId) return node; - return { - ...node, - id: newId, - data: { ...node.data, label: nextName } - }; - }), - edges: state.edges.map((edge) => { - if (edge.source !== oldId && edge.target !== oldId) return edge; - return { - ...edge, - source: edge.source === oldId ? newId : edge.source, - target: edge.target === oldId ? newId : edge.target - }; - }) - })); - }, - - updateNodePositions: (positions) => { - if (positions.length === 0) return; - const updates = new Map(positions.map((p) => [p.id, p.position])); - set((state) => ({ - nodes: state.nodes.map((node) => { - const pos = updates.get(node.id); - if (!pos) return node; - return { ...node, position: pos }; - }) - })); - }, - - updateNodeData: (nodeId, extraData) => { - set((state) => ({ - nodes: state.nodes.map((node) => { - if (node.id !== nodeId) return node; - const currentData = node.data; - const updatedData: Record = { - ...currentData, - extraData - }; - // Also update top-level visual properties - if (extraData.topoViewerRole !== undefined) { - updatedData.role = extraData.topoViewerRole; - } - if (extraData.iconColor !== undefined) { - updatedData.iconColor = extraData.iconColor; - } - if (extraData.iconCornerRadius !== undefined) { - updatedData.iconCornerRadius = extraData.iconCornerRadius; - } - if ("labelPosition" in extraData) { - updatedData.labelPosition = - typeof extraData.labelPosition === "string" ? extraData.labelPosition : undefined; - } - if ("direction" in extraData) { - updatedData.direction = - typeof extraData.direction === "string" ? extraData.direction : undefined; - } - if ("labelBackgroundColor" in extraData) { - updatedData.labelBackgroundColor = - typeof extraData.labelBackgroundColor === "string" - ? extraData.labelBackgroundColor - : undefined; - } - return { ...node, data: updatedData }; - }) - })); - }, - - // Edge mutations - addEdge: (edge) => { - set((state) => { - if (state.edges.some((e) => e.id === edge.id)) return state; - return { edges: [...state.edges, edge] }; - }); - }, - - removeEdge: (edgeId) => { - set((state) => ({ - edges: state.edges.filter((e) => e.id !== edgeId) - })); - }, - - updateEdge: (edgeId, updates) => { - set((state) => ({ - edges: state.edges.map((edge) => { - if (edge.id !== edgeId) return edge; - const mergedData = updates.data ? { ...edge.data, ...updates.data } : edge.data; - return { ...edge, ...updates, data: mergedData }; - }) - })); - }, - - updateEdgeData: (edgeId, data) => { - get().updateEdge(edgeId, { data }); - } -})); - -// ============================================================================ -// Selector Hooks (for convenience) -// ============================================================================ - -/** Get nodes array */ -export const useNodes = () => useGraphStore((state) => state.nodes); - -/** Get edges array */ -export const useEdges = () => useGraphStore((state) => state.edges); - -/** Get both nodes and edges */ -export const useGraphState = () => - useGraphStore((state) => ({ nodes: state.nodes, edges: state.edges }), shallow); - -/** Get graph actions (stable reference) */ -export const useGraphActions = () => - useGraphStore( - (state) => ({ - setNodes: state.setNodes, - setEdges: state.setEdges, - setGraph: state.setGraph, - onNodesChange: state.onNodesChange, - onEdgesChange: state.onEdgesChange, - addNode: state.addNode, - removeNode: state.removeNode, - removeNodeAndEdges: state.removeNodeAndEdges, - updateNode: state.updateNode, - replaceNode: state.replaceNode, - renameNode: state.renameNode, - updateNodePositions: state.updateNodePositions, - updateNodeData: state.updateNodeData, - addEdge: state.addEdge, - removeEdge: state.removeEdge, - updateEdge: state.updateEdge, - updateEdgeData: state.updateEdgeData - }), - shallow - ); diff --git a/src/reactTopoViewer/webview/stores/index.ts b/src/reactTopoViewer/webview/stores/index.ts deleted file mode 100644 index 4711bdd61..000000000 --- a/src/reactTopoViewer/webview/stores/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Zustand Stores - Barrel export - * - * This module exports the main store hooks. - * For types and additional selectors, import directly from sub-modules. - */ - -// Core store hooks -export { useGraphStore, useGraphActions, useGraphState } from "./graphStore"; -export { - useTopoViewerStore, - parseInitialData, - useTopoViewerActions, - useTopoViewerState, - useMode, - useIsLocked -} from "./topoViewerStore"; -export { - useAnnotationUIStore, - useAnnotationUIActions, - useAnnotationUIState -} from "./annotationUIStore"; -export { useCanvasStore, useFitViewRequestId, buildEdgeInfo, useEdgeInfo } from "./canvasStore"; - -// Essential types (import other types directly from sub-modules) -export type { GraphState, GraphActions, GraphStore } from "./graphStore"; -export type { TopoViewerState, DeploymentState, LinkLabelMode } from "./topoViewerStore"; -export type { AnnotationUIState } from "./annotationUIStore"; -export type { EdgeRenderConfig, NodeRenderConfig, EdgeInfo } from "./canvasStore"; diff --git a/src/reactTopoViewer/webview/stores/topoViewerStore.ts b/src/reactTopoViewer/webview/stores/topoViewerStore.ts deleted file mode 100644 index e78f81db2..000000000 --- a/src/reactTopoViewer/webview/stores/topoViewerStore.ts +++ /dev/null @@ -1,710 +0,0 @@ -// Zustand store for TopoViewer UI state. -import { createWithEqualityFn } from "zustand/traditional"; -import { shallow } from "zustand/shallow"; - -import type { CustomNodeTemplate, CustomTemplateEditorData } from "../../shared/types/editors"; -import type { EdgeAnnotation } from "../../shared/types/topology"; -import type { CustomIconInfo } from "../../shared/types/icons"; -import type { LabSettings } from "../../shared/types/labSettings"; -import { upsertEdgeAnnotation } from "../annotations/edgeAnnotations"; -import { - DEFAULT_ENDPOINT_LABEL_OFFSET, - clampEndpointLabelOffset -} from "../annotations/endpointLabelOffset"; - -import { useAnnotationUIStore } from "./annotationUIStore"; - -// ============================================================================ -// Types -// ============================================================================ - -export type DeploymentState = "deployed" | "undeployed" | "unknown"; -export type LinkLabelMode = "show-all" | "on-select" | "hide" | "telemetry-style"; -export type NonTelemetryLinkLabelMode = Exclude; -export type GridStyle = "dotted" | "quadratic"; -export type ProcessingMode = "deploy" | "destroy" | null; -export type LifecycleLogStream = "stdout" | "stderr"; -export type LifecycleStatus = "running" | "success" | "error" | null; - -export interface LifecycleLogEntry { - line: string; - stream: LifecycleLogStream; -} - -export interface TopoViewerState { - labName: string; - mode: "edit" | "view"; - deploymentState: DeploymentState; - labSettings?: LabSettings; - yamlFileName: string; - annotationsFileName: string; - /** Raw YAML content from host snapshot (used by Monaco source editors). */ - yamlContent: string; - /** Raw annotations JSON content from host snapshot (used by Monaco source editors). */ - annotationsContent: string; - selectedNode: string | null; - selectedEdge: string | null; - editingImpairment: string | null; - editingNode: string | null; - editingEdge: string | null; - editingNetwork: string | null; - isLocked: boolean; - linkLabelMode: LinkLabelMode; - lastNonTelemetryLinkLabelMode: NonTelemetryLinkLabelMode; - showDummyLinks: boolean; - endpointLabelOffsetEnabled: boolean; - endpointLabelOffset: number; - telemetryNodeSizePx: number; - telemetryInterfaceSizePercent: number; - telemetryGlobalInterfaceOverrideSelection: string; - telemetryInterfaceLabelOverrides: Record; - gridLineWidth: number; - gridStyle: GridStyle; - gridColor: string | null; - gridBgColor: string | null; - edgeAnnotations: EdgeAnnotation[]; - canUndo: boolean; - canRedo: boolean; - customNodes: CustomNodeTemplate[]; - defaultNode: string; - customIcons: CustomIconInfo[]; - editingCustomTemplate: CustomTemplateEditorData | null; - isProcessing: boolean; - processingMode: ProcessingMode; - lifecycleModalOpen: boolean; - lifecycleStatus: LifecycleStatus; - lifecycleStatusMessage: string | null; - lifecycleLogs: LifecycleLogEntry[]; - editorDataVersion: number; - customNodeError: string | null; -} - -export interface TopoViewerActions { - // Selection (mutually exclusive) - selectNode: (nodeId: string | null) => void; - selectEdge: (edgeId: string | null) => void; - - // Editing (mutually exclusive, clears selection) - editNode: (nodeId: string | null) => void; - editEdge: (edgeId: string | null) => void; - editImpairment: (edgeId: string | null) => void; - editNetwork: (nodeId: string | null) => void; - - // Mode and state - setMode: (mode: "edit" | "view") => void; - setDeploymentState: (state: DeploymentState) => void; - toggleLock: () => void; - - // Rendering settings - setLinkLabelMode: (mode: LinkLabelMode) => void; - toggleDummyLinks: () => void; - toggleEndpointLabelOffset: () => void; - setEndpointLabelOffset: (value: number) => void; - setTelemetryNodeSizePx: (value: number) => void; - setTelemetryInterfaceSizePercent: (value: number) => void; - setTelemetryGlobalInterfaceOverrideSelection: (value: string) => void; - setTelemetryInterfaceLabelOverrides: (overrides: Record) => void; - setTelemetryInterfaceLabelOverride: (endpoint: string, override: string | null) => void; - setGridLineWidth: (width: number) => void; - setGridStyle: (style: GridStyle) => void; - setGridColor: (color: string | null) => void; - setGridBgColor: (color: string | null) => void; - - // Edge annotations - setEdgeAnnotations: (annotations: EdgeAnnotation[]) => void; - upsertEdgeAnnotation: (annotation: EdgeAnnotation) => void; - - // Custom nodes - setCustomNodes: (customNodes: CustomNodeTemplate[], defaultNode: string) => void; - setCustomIcons: (icons: CustomIconInfo[]) => void; - editCustomTemplate: (data: CustomTemplateEditorData | null) => void; - setCustomNodeError: (error: string | null) => void; - clearCustomNodeError: () => void; - - // Processing state - setProcessing: (isProcessing: boolean, mode?: "deploy" | "destroy") => void; - setLifecycleStatus: (status: LifecycleStatus, message?: string | null) => void; - appendLifecycleLog: (line: string, stream?: LifecycleLogStream) => void; - clearLifecycleLogs: () => void; - closeLifecycleModal: () => void; - - // Data refresh - refreshEditorData: () => void; - - // Cleanup helpers - clearSelectionForDeletedNode: (nodeId: string) => void; - clearSelectionForDeletedEdge: (edgeId: string) => void; - - // Initial data - setInitialData: (data: Partial) => void; -} - -export type TopoViewerStore = TopoViewerState & TopoViewerActions; - -// ============================================================================ -// Initial State -// ============================================================================ - -const initialState: TopoViewerState = { - labName: "", - mode: "edit", - deploymentState: "unknown", - labSettings: undefined, - yamlFileName: "topology.clab.yml", - annotationsFileName: "topology.clab.yml.annotations.json", - yamlContent: "", - annotationsContent: "{}\n", - selectedNode: null, - selectedEdge: null, - editingImpairment: null, - editingNode: null, - editingEdge: null, - editingNetwork: null, - isLocked: true, - linkLabelMode: "show-all", - lastNonTelemetryLinkLabelMode: "show-all", - showDummyLinks: true, - endpointLabelOffsetEnabled: true, - endpointLabelOffset: DEFAULT_ENDPOINT_LABEL_OFFSET, - telemetryNodeSizePx: 40, - telemetryInterfaceSizePercent: 100, - telemetryGlobalInterfaceOverrideSelection: "__auto__", - telemetryInterfaceLabelOverrides: {}, - gridLineWidth: 0.5, - gridStyle: "dotted", - gridColor: null, - gridBgColor: null, - edgeAnnotations: [], - canUndo: false, - canRedo: false, - customNodes: [], - defaultNode: "", - customIcons: [], - editingCustomTemplate: null, - isProcessing: false, - processingMode: null, - lifecycleModalOpen: false, - lifecycleStatus: null, - lifecycleStatusMessage: null, - lifecycleLogs: [], - editorDataVersion: 0, - customNodeError: null -}; - -const MAX_LIFECYCLE_LOG_LINES = 500; - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isCustomNodeTemplate(value: unknown): value is CustomNodeTemplate { - return isRecord(value) && typeof value.name === "string" && typeof value.kind === "string"; -} - -function parseCustomNodeTemplates(value: unknown): CustomNodeTemplate[] { - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is CustomNodeTemplate => isCustomNodeTemplate(entry)); -} - -function isCustomIconInfo(value: unknown): value is CustomIconInfo { - return ( - isRecord(value) && - typeof value.name === "string" && - (value.source === "workspace" || value.source === "global") && - typeof value.dataUri === "string" && - (value.format === "svg" || value.format === "png") - ); -} - -function parseCustomIconInfos(value: unknown): CustomIconInfo[] { - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is CustomIconInfo => isCustomIconInfo(entry)); -} - -/** Parse non-topology bootstrap data from extension/dev host */ -export function parseInitialData(data: unknown): Partial { - if (!isRecord(data)) return {}; - const obj = data; - const defaultNode = typeof obj.defaultNode === "string" ? obj.defaultNode : ""; - return { - customNodes: parseCustomNodeTemplates(obj.customNodes), - defaultNode, - customIcons: parseCustomIconInfos(obj.customIcons) - }; -} - -// ============================================================================ -// Store Creation -// ============================================================================ - -export const useTopoViewerStore = createWithEqualityFn((set, get) => ({ - ...initialState, - - // Selection (mutually exclusive) - selectNode: (nodeId) => { - set({ selectedNode: nodeId, selectedEdge: null, editingImpairment: null }); - }, - - selectEdge: (edgeId) => { - set({ selectedEdge: edgeId, selectedNode: null, editingImpairment: null }); - }, - - // Editing (mutually exclusive, clears selection) - editNode: (nodeId) => { - set({ - editingNode: nodeId, - editingEdge: null, - editingImpairment: null, - editingNetwork: null, - selectedNode: null, - selectedEdge: null - }); - }, - - editEdge: (edgeId) => { - set({ - editingEdge: edgeId, - editingNode: null, - editingImpairment: null, - editingNetwork: null, - selectedNode: null, - selectedEdge: null - }); - }, - - editImpairment: (edgeId) => { - set({ - editingImpairment: edgeId, - editingNode: null, - editingEdge: null, - editingNetwork: null, - selectedNode: null, - selectedEdge: null - }); - }, - - editNetwork: (nodeId) => { - set({ - editingNetwork: nodeId, - editingNode: null, - editingEdge: null, - editingImpairment: null, - selectedNode: null, - selectedEdge: null - }); - }, - - // Mode and state — clear selection & editing so stale tabs disappear - setMode: (mode) => { - set({ - mode, - selectedNode: null, - selectedEdge: null, - editingNode: null, - editingEdge: null, - editingNetwork: null, - editingImpairment: null, - editingCustomTemplate: null - }); - // Also clear annotation editing state (separate store) - const annotationUI = useAnnotationUIStore.getState(); - if (annotationUI.editingTextAnnotation) annotationUI.setEditingTextAnnotation(null); - if (annotationUI.editingShapeAnnotation) annotationUI.setEditingShapeAnnotation(null); - if (annotationUI.editingTrafficRateAnnotation) annotationUI.closeTrafficRateEditor(); - if (annotationUI.editingGroup) annotationUI.closeGroupEditor(); - }, - - setDeploymentState: (deploymentState) => { - set({ deploymentState }); - }, - - toggleLock: () => { - set((state) => ({ isLocked: !state.isLocked })); - }, - - // Rendering settings - setLinkLabelMode: (linkLabelMode) => { - set((state) => ({ - linkLabelMode, - lastNonTelemetryLinkLabelMode: - linkLabelMode === "telemetry-style" ? state.lastNonTelemetryLinkLabelMode : linkLabelMode - })); - }, - - toggleDummyLinks: () => { - set((state) => ({ showDummyLinks: !state.showDummyLinks })); - }, - - toggleEndpointLabelOffset: () => { - set((state) => ({ endpointLabelOffsetEnabled: !state.endpointLabelOffsetEnabled })); - }, - - setEndpointLabelOffset: (value) => { - const next = Number.isFinite(value) - ? clampEndpointLabelOffset(value) - : DEFAULT_ENDPOINT_LABEL_OFFSET; - set({ endpointLabelOffset: next }); - }, - - setTelemetryNodeSizePx: (value) => { - const next = Number.isFinite(value) ? value : 40; - set({ telemetryNodeSizePx: next }); - }, - - setTelemetryInterfaceSizePercent: (value) => { - const next = Number.isFinite(value) ? value : 100; - set({ telemetryInterfaceSizePercent: next }); - }, - - setTelemetryGlobalInterfaceOverrideSelection: (value) => { - set({ telemetryGlobalInterfaceOverrideSelection: value }); - }, - - setTelemetryInterfaceLabelOverrides: (overrides) => { - set({ telemetryInterfaceLabelOverrides: { ...overrides } }); - }, - - setTelemetryInterfaceLabelOverride: (endpoint, override) => { - set((state) => { - const next = { ...state.telemetryInterfaceLabelOverrides }; - if (override === null || override.trim().length === 0) { - delete next[endpoint]; - } else { - next[endpoint] = override.trim(); - } - return { telemetryInterfaceLabelOverrides: next }; - }); - }, - - setGridLineWidth: (gridLineWidth) => { - set({ gridLineWidth }); - }, - - setGridStyle: (gridStyle) => { - set({ gridStyle }); - }, - - setGridColor: (color) => { - set({ gridColor: color }); - }, - - setGridBgColor: (color) => { - set({ gridBgColor: color }); - }, - - // Edge annotations - setEdgeAnnotations: (edgeAnnotations) => { - set({ edgeAnnotations }); - }, - - upsertEdgeAnnotation: (annotation) => { - set((state) => ({ - edgeAnnotations: upsertEdgeAnnotation(state.edgeAnnotations, annotation) - })); - }, - - // Custom nodes - setCustomNodes: (customNodes, defaultNode) => { - set({ customNodes, defaultNode, customNodeError: null }); - }, - - setCustomIcons: (customIcons) => { - set({ customIcons }); - }, - - editCustomTemplate: (editingCustomTemplate) => { - set((state) => ({ - editingCustomTemplate, - editingNode: editingCustomTemplate ? null : state.editingNode, - editingEdge: editingCustomTemplate ? null : state.editingEdge, - editingNetwork: editingCustomTemplate ? null : state.editingNetwork, - selectedNode: editingCustomTemplate ? null : state.selectedNode, - selectedEdge: editingCustomTemplate ? null : state.selectedEdge - })); - }, - - setCustomNodeError: (customNodeError) => { - set({ customNodeError }); - }, - - clearCustomNodeError: () => { - set({ customNodeError: null }); - }, - - // Processing state - setProcessing: (isProcessing, mode) => { - set((state) => { - const next: Partial = { - isProcessing - }; - - if (isProcessing) { - next.processingMode = mode ?? null; - next.lifecycleModalOpen = true; - next.lifecycleStatus = "running"; - next.lifecycleStatusMessage = null; - next.editingNode = null; - next.editingEdge = null; - next.editingImpairment = null; - next.editingNetwork = null; - next.editingCustomTemplate = null; - next.selectedNode = null; - next.selectedEdge = null; - next.lifecycleLogs = []; - } else if (mode) { - next.processingMode = mode; - } - - return { ...state, ...next }; - }); - }, - - setLifecycleStatus: (lifecycleStatus, lifecycleStatusMessage = null) => { - set({ lifecycleStatus, lifecycleStatusMessage }); - }, - - appendLifecycleLog: (line, stream = "stdout") => { - set((state) => { - const trimmedLine = line.trim(); - if (!trimmedLine) { - return state; - } - - const nextLogs = [...state.lifecycleLogs, { line: trimmedLine, stream }]; - if (nextLogs.length > MAX_LIFECYCLE_LOG_LINES) { - nextLogs.splice(0, nextLogs.length - MAX_LIFECYCLE_LOG_LINES); - } - - return { lifecycleLogs: nextLogs }; - }); - }, - - clearLifecycleLogs: () => { - set({ lifecycleLogs: [] }); - }, - - closeLifecycleModal: () => { - set({ - lifecycleModalOpen: false, - lifecycleStatus: null, - lifecycleStatusMessage: null, - lifecycleLogs: [], - processingMode: null - }); - }, - - // Data refresh - refreshEditorData: () => { - set((state) => ({ editorDataVersion: state.editorDataVersion + 1 })); - }, - - // Cleanup helpers - clearSelectionForDeletedNode: (nodeId) => { - set((state) => ({ - selectedNode: state.selectedNode === nodeId ? null : state.selectedNode, - editingNode: state.editingNode === nodeId ? null : state.editingNode, - editingNetwork: state.editingNetwork === nodeId ? null : state.editingNetwork - })); - }, - - clearSelectionForDeletedEdge: (edgeId) => { - set((state) => ({ - selectedEdge: state.selectedEdge === edgeId ? null : state.selectedEdge, - editingEdge: state.editingEdge === edgeId ? null : state.editingEdge, - editingImpairment: state.editingImpairment === edgeId ? null : state.editingImpairment - })); - }, - - // Initial data — if mode changes, clear selection & editing so stale tabs disappear - setInitialData: (data) => { - if (data.mode && data.mode !== get().mode) { - set({ - ...data, - selectedNode: null, - selectedEdge: null, - editingNode: null, - editingEdge: null, - editingNetwork: null, - editingImpairment: null, - editingCustomTemplate: null - }); - const annotationUI = useAnnotationUIStore.getState(); - if (annotationUI.editingTextAnnotation) annotationUI.setEditingTextAnnotation(null); - if (annotationUI.editingShapeAnnotation) annotationUI.setEditingShapeAnnotation(null); - if (annotationUI.editingTrafficRateAnnotation) annotationUI.closeTrafficRateEditor(); - if (annotationUI.editingGroup) annotationUI.closeGroupEditor(); - } else { - set(data); - } - } -})); - -// ============================================================================ -// Selector Hooks (for convenience) -// ============================================================================ - -/** Get mode */ -export const useMode = () => useTopoViewerStore((state) => state.mode); - -/** Get lab name */ -export const useLabName = () => useTopoViewerStore((state) => state.labName); - -/** Get deployment state */ -export const useDeploymentState = () => useTopoViewerStore((state) => state.deploymentState); - -/** Get selected node */ -export const useSelectedNode = () => useTopoViewerStore((state) => state.selectedNode); - -/** Get selected edge */ -export const useSelectedEdge = () => useTopoViewerStore((state) => state.selectedEdge); - -/** Get editing node */ -export const useEditingNode = () => useTopoViewerStore((state) => state.editingNode); - -/** Get editing edge */ -export const useEditingEdge = () => useTopoViewerStore((state) => state.editingEdge); - -/** Get editing impairment edge */ -export const useEditingImpairment = () => useTopoViewerStore((state) => state.editingImpairment); - -/** Get lock state */ -export const useIsLocked = () => - useTopoViewerStore((state) => state.isLocked || state.isProcessing); - -/** Get link label mode */ -export const useLinkLabelMode = () => useTopoViewerStore((state) => state.linkLabelMode); - -/** Get dummy link visibility */ -export const useShowDummyLinks = () => useTopoViewerStore((state) => state.showDummyLinks); - -/** Get endpoint label offset */ -export const useEndpointLabelOffset = () => - useTopoViewerStore((state) => state.endpointLabelOffset); - -/** Get Telemetry label rendering settings */ -export const useTelemetryLabelSettings = () => - useTopoViewerStore( - (state) => ({ - nodeSizePx: state.telemetryNodeSizePx, - interfaceSizePercent: state.telemetryInterfaceSizePercent, - globalInterfaceOverrideSelection: state.telemetryGlobalInterfaceOverrideSelection, - interfaceLabelOverrides: state.telemetryInterfaceLabelOverrides - }), - shallow - ); - -export const useGridColor = () => useTopoViewerStore((state) => state.gridColor); -export const useGridBgColor = () => useTopoViewerStore((state) => state.gridBgColor); - -/** Get processing state */ -export const useIsProcessing = () => useTopoViewerStore((state) => state.isProcessing); - -/** Get processing mode */ -export const useProcessingMode = () => useTopoViewerStore((state) => state.processingMode); - -/** Get edge annotations */ -export const useEdgeAnnotations = () => useTopoViewerStore((state) => state.edgeAnnotations); - -/** Get custom nodes */ -export const useCustomNodes = () => useTopoViewerStore((state) => state.customNodes); - -/** Get custom icons */ -export const useCustomIcons = () => useTopoViewerStore((state) => state.customIcons); - -/** Get TopoViewer state (convenience) */ -export const useTopoViewerState = () => - useTopoViewerStore( - (state) => ({ - labName: state.labName, - mode: state.mode, - deploymentState: state.deploymentState, - labSettings: state.labSettings, - yamlFileName: state.yamlFileName, - annotationsFileName: state.annotationsFileName, - yamlContent: state.yamlContent, - annotationsContent: state.annotationsContent, - canUndo: state.canUndo, - canRedo: state.canRedo, - selectedNode: state.selectedNode, - selectedEdge: state.selectedEdge, - editingImpairment: state.editingImpairment, - editingNode: state.editingNode, - editingEdge: state.editingEdge, - editingNetwork: state.editingNetwork, - isLocked: state.isLocked, - linkLabelMode: state.linkLabelMode, - lastNonTelemetryLinkLabelMode: state.lastNonTelemetryLinkLabelMode, - showDummyLinks: state.showDummyLinks, - endpointLabelOffsetEnabled: state.endpointLabelOffsetEnabled, - endpointLabelOffset: state.endpointLabelOffset, - telemetryNodeSizePx: state.telemetryNodeSizePx, - telemetryInterfaceSizePercent: state.telemetryInterfaceSizePercent, - telemetryGlobalInterfaceOverrideSelection: state.telemetryGlobalInterfaceOverrideSelection, - telemetryInterfaceLabelOverrides: state.telemetryInterfaceLabelOverrides, - gridLineWidth: state.gridLineWidth, - gridStyle: state.gridStyle, - edgeAnnotations: state.edgeAnnotations, - customNodes: state.customNodes, - defaultNode: state.defaultNode, - customIcons: state.customIcons, - editingCustomTemplate: state.editingCustomTemplate, - isProcessing: state.isProcessing, - processingMode: state.processingMode, - lifecycleModalOpen: state.lifecycleModalOpen, - lifecycleStatus: state.lifecycleStatus, - lifecycleStatusMessage: state.lifecycleStatusMessage, - lifecycleLogs: state.lifecycleLogs, - editorDataVersion: state.editorDataVersion, - customNodeError: state.customNodeError - }), - shallow - ); - -/** Get TopoViewer actions (stable reference) */ -export const useTopoViewerActions = () => - useTopoViewerStore( - (state) => ({ - selectNode: state.selectNode, - selectEdge: state.selectEdge, - editNode: state.editNode, - editEdge: state.editEdge, - editImpairment: state.editImpairment, - editNetwork: state.editNetwork, - setMode: state.setMode, - setDeploymentState: state.setDeploymentState, - toggleLock: state.toggleLock, - setLinkLabelMode: state.setLinkLabelMode, - toggleDummyLinks: state.toggleDummyLinks, - toggleEndpointLabelOffset: state.toggleEndpointLabelOffset, - setEndpointLabelOffset: state.setEndpointLabelOffset, - setTelemetryNodeSizePx: state.setTelemetryNodeSizePx, - setTelemetryInterfaceSizePercent: state.setTelemetryInterfaceSizePercent, - setTelemetryGlobalInterfaceOverrideSelection: - state.setTelemetryGlobalInterfaceOverrideSelection, - setTelemetryInterfaceLabelOverrides: state.setTelemetryInterfaceLabelOverrides, - setTelemetryInterfaceLabelOverride: state.setTelemetryInterfaceLabelOverride, - setGridLineWidth: state.setGridLineWidth, - setGridStyle: state.setGridStyle, - setEdgeAnnotations: state.setEdgeAnnotations, - upsertEdgeAnnotation: state.upsertEdgeAnnotation, - setCustomNodes: state.setCustomNodes, - setCustomIcons: state.setCustomIcons, - editCustomTemplate: state.editCustomTemplate, - setCustomNodeError: state.setCustomNodeError, - clearCustomNodeError: state.clearCustomNodeError, - setProcessing: state.setProcessing, - setLifecycleStatus: state.setLifecycleStatus, - appendLifecycleLog: state.appendLifecycleLog, - clearLifecycleLogs: state.clearLifecycleLogs, - closeLifecycleModal: state.closeLifecycleModal, - refreshEditorData: state.refreshEditorData, - clearSelectionForDeletedNode: state.clearSelectionForDeletedNode, - clearSelectionForDeletedEdge: state.clearSelectionForDeletedEdge, - setInitialData: state.setInitialData - }), - shallow - ); diff --git a/src/reactTopoViewer/webview/styles/global.css b/src/reactTopoViewer/webview/styles/global.css deleted file mode 100644 index 69a5bc38f..000000000 --- a/src/reactTopoViewer/webview/styles/global.css +++ /dev/null @@ -1,43 +0,0 @@ -/* ============================================================================= - * Global CSS Entry Point for React TopoViewer - * ============================================================================= */ - -/* Third-party CSS imports */ -/* Use explicit paths so Madge can resolve these imports (lint:circular). */ -@import "../../../../node_modules/maplibre-gl/dist/maplibre-gl.css"; -@import "../../../../node_modules/@xyflow/react/dist/style.css"; -@import "../../../../node_modules/monaco-editor/min/vs/editor/editor.main.css"; - -/* Webview reset: - * VS Code webviews default to a non-zero body margin and allow document scrolling. - * The TopoViewer app is a full-viewport canvas UI and should never scroll at the - * document level (only internal panels may scroll). - */ -html, -body, -#root { - width: 100%; - height: 100%; - margin: 0; - padding: 0; - overflow: hidden; -} - -*, -*::before, -*::after { - box-sizing: border-box; -} - -/* In GeoMap mode, empty-canvas drag should reach MapLibre for panning. */ -.react-flow-canvas.maplibre-active .react-flow__pane { - pointer-events: none; -} - -/* During active map motion, promote key layers to help compositor smoothness. */ -.react-flow-canvas.maplibre-moving .react-flow__viewport, -.react-flow-canvas.maplibre-moving .react-flow__edges, -.react-flow-canvas.maplibre-moving .react-flow__nodes, -.react-flow-canvas.maplibre-moving #react-topoviewer-geo-map .maplibregl-canvas { - will-change: transform; -} diff --git a/src/reactTopoViewer/webview/theme/MuiThemeProvider.tsx b/src/reactTopoViewer/webview/theme/MuiThemeProvider.tsx deleted file mode 100644 index d99b3d1ec..000000000 --- a/src/reactTopoViewer/webview/theme/MuiThemeProvider.tsx +++ /dev/null @@ -1,17 +0,0 @@ -// MUI ThemeProvider wrapper — static theme with CSS var() palette. -import React from "react"; -import { ThemeProvider } from "@mui/material/styles"; -import CssBaseline from "@mui/material/CssBaseline"; -import "@fontsource/roboto/300.css"; -import "@fontsource/roboto/400.css"; -import "@fontsource/roboto/500.css"; -import "@fontsource/roboto/700.css"; - -import { vscodeTheme } from "./vscodeTheme"; - -export const MuiThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - - {children} - -); diff --git a/src/reactTopoViewer/webview/theme/devTheme.ts b/src/reactTopoViewer/webview/theme/devTheme.ts deleted file mode 100644 index 0449cd713..000000000 --- a/src/reactTopoViewer/webview/theme/devTheme.ts +++ /dev/null @@ -1,182 +0,0 @@ -// Dev mode --vscode-* CSS variable maps for the Vite dev view. - -export interface VarMap { - [cssVar: string]: string; -} - -// Dark palette -export const DARK_VARS: VarMap = { - "--vscode-editor-background": "#1e1e1e", - "--vscode-editor-foreground": "#cccccc", - "--vscode-sideBar-background": "#252526", - "--vscode-sideBar-foreground": "#cccccc", - "--vscode-panel-background": "#1e1e1e", - "--vscode-panel-border": "#3c3c3c", - "--vscode-button-background": "#0e639c", - "--vscode-button-foreground": "#ffffff", - "--vscode-button-hoverBackground": "#1177bb", - "--vscode-button-secondaryBackground": "#3a3d41", - "--vscode-button-secondaryForeground": "#ffffff", - "--vscode-button-secondaryHoverBackground": "#45494e", - "--vscode-input-background": "#3c3c3c", - "--vscode-input-foreground": "#cccccc", - "--vscode-input-border": "#3c3c3c", - "--vscode-input-placeholderForeground": "#a6a6a6", - "--vscode-dropdown-background": "#3c3c3c", - "--vscode-dropdown-foreground": "#f0f0f0", - "--vscode-dropdown-border": "#3c3c3c", - "--vscode-focusBorder": "#007fd4", - "--vscode-foreground": "#cccccc", - "--vscode-descriptionForeground": "#9d9d9d", - "--vscode-errorForeground": "#f48771", - "--vscode-icon-foreground": "#c5c5c5", - "--vscode-list-hoverBackground": "#2a2d2e", - "--vscode-list-activeSelectionBackground": "#094771", - "--vscode-list-activeSelectionForeground": "#ffffff", - "--vscode-list-inactiveSelectionBackground": "#37373d", - "--vscode-list-inactiveSelectionForeground": "#cccccc", - "--vscode-badge-background": "#4d4d4d", - "--vscode-badge-foreground": "#ffffff", - "--vscode-scrollbarSlider-background": "rgba(121, 121, 121, 0.4)", - "--vscode-scrollbarSlider-hoverBackground": "rgba(100, 100, 100, 0.7)", - "--vscode-scrollbarSlider-activeBackground": "rgba(191, 191, 191, 0.4)", - "--vscode-statusBar-background": "#007acc", - "--vscode-statusBar-foreground": "#ffffff", - "--vscode-tab-activeBackground": "#1e1e1e", - "--vscode-tab-activeForeground": "#ffffff", - "--vscode-tab-inactiveBackground": "#2d2d2d", - "--vscode-tab-inactiveForeground": "#ffffff80", - "--vscode-tab-activeBorderTop": "#007fd4", - "--vscode-tab-hoverBackground": "#2a2d2e", - "--vscode-textLink-foreground": "#3794ff", - "--vscode-textLink-activeForeground": "#3794ff", - "--vscode-textCodeBlock-background": "#2d2d2d", - "--vscode-settings-textInputBackground": "#292929", - "--vscode-settings-textInputForeground": "#cccccc", - "--vscode-settings-textInputBorder": "transparent", - "--vscode-notifications-background": "#252526", - "--vscode-notifications-foreground": "#cccccc", - "--vscode-notifications-border": "#3c3c3c", - "--vscode-disabledForeground": "#6a6a6a", - "--vscode-checkbox-border": "#c5c5c5", - "--vscode-editorWidget-background": "#252526", - "--vscode-editorWidget-foreground": "#cccccc", - "--vscode-editorWidget-border": "#3c3c3c", - "--vscode-widget-shadow": "rgba(0, 0, 0, 0.36)", - "--vscode-menu-background": "#3c3c3c", - "--vscode-menu-foreground": "#f0f0f0", - "--vscode-menu-border": "#3c3c3c", - "--vscode-menu-selectionBackground": "#094771", - "--vscode-menu-selectionForeground": "#ffffff", - "--vscode-inputValidation-errorBackground": "#5a1d1d", - "--vscode-inputValidation-errorBorder": "#be1100", - "--vscode-inputValidation-errorForeground": "#ffffff", - "--vscode-inputValidation-warningBackground": "#352a05", - "--vscode-inputValidation-warningBorder": "#9d8600", - "--vscode-inputValidation-warningForeground": "#000000", - "--vscode-inputValidation-infoBackground": "#063b49", - "--vscode-inputValidation-infoBorder": "#007acc", - "--vscode-inputValidation-infoForeground": "#ffffff", - "--vscode-editorError-foreground": "#f48771", - "--vscode-editorWarning-foreground": "#cca700", - "--vscode-editorInfo-foreground": "#3794ff", - "--vscode-charts-yellow": "#cca700", - "--vscode-testing-iconPassed": "#73c991", - "--vscode-charts-green": "#4ec9b0", - "--vscode-progressBar-background": "#0e70c0", - "--vscode-font-family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", - "--vscode-font-size": "13px" -}; - -// Light palette -export const LIGHT_VARS: VarMap = { - "--vscode-editor-background": "#ffffff", - "--vscode-editor-foreground": "#333333", - "--vscode-sideBar-background": "#f3f3f3", - "--vscode-sideBar-foreground": "#333333", - "--vscode-panel-background": "#ffffff", - "--vscode-panel-border": "#e7e7e7", - "--vscode-button-background": "#007acc", - "--vscode-button-foreground": "#ffffff", - "--vscode-button-hoverBackground": "#0062a3", - "--vscode-button-secondaryBackground": "#5f6a79", - "--vscode-button-secondaryForeground": "#ffffff", - "--vscode-button-secondaryHoverBackground": "#4c5561", - "--vscode-input-background": "#ffffff", - "--vscode-input-foreground": "#333333", - "--vscode-input-border": "#cecece", - "--vscode-input-placeholderForeground": "#767676", - "--vscode-dropdown-background": "#ffffff", - "--vscode-dropdown-foreground": "#333333", - "--vscode-dropdown-border": "#cecece", - "--vscode-focusBorder": "#0090f1", - "--vscode-foreground": "#333333", - "--vscode-descriptionForeground": "#717171", - "--vscode-errorForeground": "#a1260d", - "--vscode-icon-foreground": "#424242", - "--vscode-list-hoverBackground": "#e8e8e8", - "--vscode-list-activeSelectionBackground": "#0060c0", - "--vscode-list-activeSelectionForeground": "#ffffff", - "--vscode-list-inactiveSelectionBackground": "#e5ebf1", - "--vscode-list-inactiveSelectionForeground": "#333333", - "--vscode-badge-background": "#c4c4c4", - "--vscode-badge-foreground": "#333333", - "--vscode-scrollbarSlider-background": "rgba(100, 100, 100, 0.4)", - "--vscode-scrollbarSlider-hoverBackground": "rgba(100, 100, 100, 0.7)", - "--vscode-scrollbarSlider-activeBackground": "rgba(0, 0, 0, 0.6)", - "--vscode-statusBar-background": "#007acc", - "--vscode-statusBar-foreground": "#ffffff", - "--vscode-tab-activeBackground": "#ffffff", - "--vscode-tab-activeForeground": "#333333", - "--vscode-tab-inactiveBackground": "#ececec", - "--vscode-tab-inactiveForeground": "#33333380", - "--vscode-tab-activeBorderTop": "#0090f1", - "--vscode-tab-hoverBackground": "#e8e8e8", - "--vscode-textLink-foreground": "#006ab1", - "--vscode-textLink-activeForeground": "#006ab1", - "--vscode-textCodeBlock-background": "#f3f3f3", - "--vscode-settings-textInputBackground": "#ffffff", - "--vscode-settings-textInputForeground": "#333333", - "--vscode-settings-textInputBorder": "#cecece", - "--vscode-notifications-background": "#f3f3f3", - "--vscode-notifications-foreground": "#333333", - "--vscode-notifications-border": "#e7e7e7", - "--vscode-disabledForeground": "#999999", - "--vscode-checkbox-border": "#424242", - "--vscode-editorWidget-background": "#f3f3f3", - "--vscode-editorWidget-foreground": "#333333", - "--vscode-editorWidget-border": "#cecece", - "--vscode-widget-shadow": "rgba(0, 0, 0, 0.16)", - "--vscode-menu-background": "#ffffff", - "--vscode-menu-foreground": "#333333", - "--vscode-menu-border": "#cecece", - "--vscode-menu-selectionBackground": "#0060c0", - "--vscode-menu-selectionForeground": "#ffffff", - "--vscode-inputValidation-errorBackground": "#fce4e4", - "--vscode-inputValidation-errorBorder": "#be1100", - "--vscode-inputValidation-errorForeground": "#333333", - "--vscode-inputValidation-warningBackground": "#fefce4", - "--vscode-inputValidation-warningBorder": "#9d8600", - "--vscode-inputValidation-warningForeground": "#333333", - "--vscode-inputValidation-infoBackground": "#e6f3fb", - "--vscode-inputValidation-infoBorder": "#007acc", - "--vscode-inputValidation-infoForeground": "#333333", - "--vscode-editorError-foreground": "#a1260d", - "--vscode-editorWarning-foreground": "#bf8803", - "--vscode-editorInfo-foreground": "#1a85ff", - "--vscode-charts-yellow": "#bf8803", - "--vscode-testing-iconPassed": "#388a34", - "--vscode-charts-green": "#16825d", - "--vscode-progressBar-background": "#007acc", - "--vscode-font-family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", - "--vscode-font-size": "13px" -}; - -// Apply CSS variable map as inline styles on for dev mode. -export function applyDevVars(mode: "light" | "dark"): void { - const vars = mode === "light" ? LIGHT_VARS : DARK_VARS; - const root = document.documentElement; - for (const [key, value] of Object.entries(vars)) { - root.style.setProperty(key, value); - } -} diff --git a/src/reactTopoViewer/webview/theme/index.ts b/src/reactTopoViewer/webview/theme/index.ts deleted file mode 100644 index adc996366..000000000 --- a/src/reactTopoViewer/webview/theme/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { MuiThemeProvider } from "./MuiThemeProvider"; -export { vscodeTheme, vscodePalette, structuralOverrides } from "./vscodeTheme"; -export { DARK_VARS, LIGHT_VARS, applyDevVars } from "./devTheme"; -export type { VarMap } from "./devTheme"; diff --git a/src/reactTopoViewer/webview/theme/vscodeTheme.ts b/src/reactTopoViewer/webview/theme/vscodeTheme.ts deleted file mode 100644 index 4e09506f1..000000000 --- a/src/reactTopoViewer/webview/theme/vscodeTheme.ts +++ /dev/null @@ -1,234 +0,0 @@ -// VS Code MUI theme config. -// Palette values are CSS var() references — VS Code swaps them for light/dark. -import { createTheme, type ThemeOptions } from "@mui/material/styles"; - -const BUTTON_BACKGROUND = "var(--vscode-button-background)"; -const BUTTON_SECONDARY_BACKGROUND = "var(--vscode-button-secondaryBackground)"; -const EDITOR_ERROR_FOREGROUND = "var(--vscode-editorError-foreground)"; -const EDITOR_WARNING_FOREGROUND = "var(--vscode-editorWarning-foreground)"; -const EDITOR_INFO_FOREGROUND = "var(--vscode-editorInfo-foreground)"; -const TESTING_ICON_PASSED = "var(--vscode-testing-iconPassed, var(--vscode-charts-green))"; -const FOCUS_BORDER = "var(--vscode-focusBorder)"; -const EXPLORER_FONT_FAMILY = - "var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif)"; -const EXPLORER_FONT_SIZE = "var(--vscode-font-size, 13px)"; -const EXPLORER_SCOPE_SELECTORS = [ - "body[data-webview-kind='containerlab-explorer']", - ".containerlab-explorer-root" -] as const; - -const explorerScopedSelector = (suffix: string) => - EXPLORER_SCOPE_SELECTORS.map((selector) => `${selector}${suffix}`).join(", "); - -const buildPaletteColor = (main: string, contrastText: string) => ({ - main, - dark: main, - light: main, - contrastText -}); - -// Palette — single source of truth for all colors. -// dark/light repeat main to prevent createTheme from deriving them (crashes on CSS vars). -export const vscodePalette = { - divider: "var(--vscode-panel-border)", - background: { - default: "var(--vscode-editor-background)", - paper: "var(--vscode-sideBar-background)" - }, - text: { - primary: "var(--vscode-foreground)", - secondary: "var(--vscode-descriptionForeground)", - disabled: "var(--vscode-disabledForeground)" - }, - primary: buildPaletteColor(BUTTON_BACKGROUND, "var(--vscode-button-foreground)"), - secondary: buildPaletteColor( - BUTTON_SECONDARY_BACKGROUND, - "var(--vscode-button-secondaryForeground)" - ), - error: buildPaletteColor( - EDITOR_ERROR_FOREGROUND, - "var(--vscode-inputValidation-errorForeground)" - ), - warning: buildPaletteColor( - EDITOR_WARNING_FOREGROUND, - "var(--vscode-inputValidation-warningForeground)" - ), - info: buildPaletteColor(EDITOR_INFO_FOREGROUND, "var(--vscode-inputValidation-infoForeground)"), - success: buildPaletteColor(TESTING_ICON_PASSED, "var(--vscode-button-foreground)"), - action: { - active: "var(--vscode-icon-foreground)", - hover: "var(--vscode-list-hoverBackground)", - selected: "var(--vscode-list-inactiveSelectionBackground)", - disabled: "var(--vscode-disabledForeground)", - disabledBackground: "var(--vscode-input-background)", - focus: FOCUS_BORDER - } -} as const; - -// Component overrides -export const structuralOverrides: NonNullable = { - MuiCssBaseline: { - styleOverrides: { - // Bridge vars for non-MUI components (React Flow canvas) - ":root": { - "--topoviewer-surface-panel": vscodePalette.background.paper, - "--topoviewer-surface-elevated": vscodePalette.background.paper, - "--topoviewer-grid-color": vscodePalette.divider, - "--topoviewer-node-label-background": "var(--vscode-badge-background)", - "--topoviewer-node-label-foreground": vscodePalette.text.primary, - "--topoviewer-node-label-outline": vscodePalette.background.default, - "--topoviewer-edge-label-background": vscodePalette.background.default, - "--topoviewer-edge-label-foreground": vscodePalette.text.primary, - "--topoviewer-edge-label-outline": vscodePalette.background.default, - "--topoviewer-network-node-background": vscodePalette.background.paper - }, - "*::-webkit-scrollbar": { width: 8, height: 8 }, - "*::-webkit-scrollbar-track": { background: "transparent" }, - "*::-webkit-scrollbar-thumb": { borderRadius: 4 }, - "*::-webkit-scrollbar-corner": { background: "transparent" }, - [explorerScopedSelector("")]: { - fontFamily: EXPLORER_FONT_FAMILY - }, - [explorerScopedSelector(" .MuiTypography-root")]: { - fontFamily: EXPLORER_FONT_FAMILY - }, - [explorerScopedSelector(" .MuiInputBase-root")]: { - fontFamily: EXPLORER_FONT_FAMILY - }, - [explorerScopedSelector(" .MuiInputBase-input")]: { - fontFamily: EXPLORER_FONT_FAMILY, - fontSize: EXPLORER_FONT_SIZE - }, - [explorerScopedSelector(" .explorer-node-label")]: { - fontSize: EXPLORER_FONT_SIZE, - fontWeight: 500, - lineHeight: 1.15 - }, - [explorerScopedSelector(" .explorer-node-inline-icon")]: { - fontSize: "15px", - flex: "0 0 auto" - }, - [explorerScopedSelector(" .explorer-node-inline-icon-button")]: { - width: 20, - height: 20, - padding: 0, - color: "inherit" - }, - [explorerScopedSelector(" .explorer-node-inline-icon-favorite")]: { - color: "var(--vscode-charts-yellow, var(--vscode-editorWarning-foreground))" - }, - [explorerScopedSelector(" .explorer-node-inline-icon-shared")]: { - color: "var(--vscode-icon-foreground, var(--vscode-foreground))" - }, - [explorerScopedSelector(" .explorer-section-title")]: { - fontSize: EXPLORER_FONT_SIZE, - fontWeight: 500, - lineHeight: 1.2 - }, - "@keyframes shortcutFade": { - "0%": { opacity: 0, transform: "translateY(8px) scale(0.95)" }, - "15%": { opacity: 1, transform: "translateY(0) scale(1)" }, - "85%": { opacity: 1, transform: "translateY(0) scale(1)" }, - "100%": { opacity: 0, transform: "translateY(-4px) scale(0.98)" } - } - } - }, - MuiButton: { defaultProps: { disableElevation: true, variant: "contained" } }, - MuiTabs: { styleOverrides: { root: { minHeight: 36 } } }, - MuiTab: { styleOverrides: { root: { minHeight: 36, padding: "6px 12px" } } }, - MuiPaper: { styleOverrides: { root: { backgroundImage: "none" } } }, - MuiAppBar: { - styleOverrides: { - root: { backgroundColor: vscodePalette.background.paper, color: vscodePalette.text.primary } - } - }, - MuiDrawer: { - styleOverrides: { - paper: { backgroundColor: vscodePalette.background.paper, color: vscodePalette.text.primary } - } - }, - MuiInputBase: { - styleOverrides: { - root: { - color: "var(--vscode-input-foreground)" - } - } - }, - MuiOutlinedInput: { - defaultProps: { - notched: true - }, - styleOverrides: { - root: { - "&:hover .MuiOutlinedInput-notchedOutline": { - borderColor: FOCUS_BORDER - }, - "&.Mui-focused .MuiOutlinedInput-notchedOutline": { - borderColor: FOCUS_BORDER - } - }, - notchedOutline: { - borderColor: "var(--vscode-input-border)" - } - } - }, - MuiInputLabel: { - defaultProps: { shrink: true } - }, - MuiTextField: { defaultProps: { size: "small", variant: "outlined" } }, - MuiSelect: { defaultProps: { size: "small" } }, - MuiMenu: { - styleOverrides: { - paper: { - backgroundColor: vscodePalette.background.paper, - color: vscodePalette.text.primary, - border: `1px solid ${vscodePalette.divider}` - } - } - }, - MuiPopover: { - styleOverrides: { - paper: { - backgroundColor: vscodePalette.background.paper, - color: vscodePalette.text.primary, - border: `1px solid ${vscodePalette.divider}` - } - } - }, - MuiDialog: { - styleOverrides: { - paper: { - backgroundColor: vscodePalette.background.paper, - color: vscodePalette.text.primary, - border: `1px solid ${vscodePalette.divider}` - } - }, - defaultProps: { - slotProps: { backdrop: { sx: { backgroundColor: "rgba(0, 0, 0, 0.5)" } } } - } - }, - MuiBackdrop: { styleOverrides: { root: { backgroundColor: "transparent" } } } -}; - -// Theme instance -const baseTheme = createTheme({ - palette: { ...vscodePalette }, - typography: { - fontFamily: "'Roboto', sans-serif", - overline: { fontWeight: 500, letterSpacing: "0.5px" } - }, - shape: { borderRadius: 4 }, - components: structuralOverrides -}); - -// Patch MUI color utils — default implementations crash on CSS var() strings. -// alpha uses color-mix so hover/focus overlays resolve correctly at runtime. -baseTheme.alpha = (color: string, opacity: number | string) => { - const opacityValue = typeof opacity === "number" ? `${Math.round(opacity * 100)}%` : opacity; - return `color-mix(in srgb, ${color} ${opacityValue}, transparent)`; -}; -baseTheme.lighten = (color: string) => color; -baseTheme.darken = (color: string) => color; -baseTheme.palette.getContrastText = () => vscodePalette.text.primary; - -export const vscodeTheme = baseTheme; diff --git a/src/reactTopoViewer/webview/types/assets.d.ts b/src/reactTopoViewer/webview/types/assets.d.ts deleted file mode 100644 index ce7005092..000000000 --- a/src/reactTopoViewer/webview/types/assets.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "*.svg" { - const url: string; - export default url; -} diff --git a/src/reactTopoViewer/webview/types/devMode.d.ts b/src/reactTopoViewer/webview/types/devMode.d.ts deleted file mode 100644 index 3afb446a3..000000000 --- a/src/reactTopoViewer/webview/types/devMode.d.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Type declarations for development mode globals. - * The __DEV__ interface is exposed on window only in development builds - * to support E2E testing and debugging. - */ - -import type { ReactFlowInstance } from "@xyflow/react"; - -import type { GroupStyleAnnotation } from "../../shared/types/topology"; -import type { NetworkType } from "../../shared/types/editors"; -import type { TopoNode } from "../../shared/types/graph"; -import type { CustomIconInfo } from "../../shared/types/icons"; -import type { CustomNodeTemplate, SchemaData } from "../../shared/schema"; - -/** Layout option type */ -type LayoutOption = "preset" | "force" | "geo"; - -/** - * Development mode interface for E2E testing and debugging. - * Exposed on window.__DEV__ only in development builds. - */ -export interface DevModeInterface { - /** Check if topology is locked */ - isLocked?: () => boolean; - /** Get current mode */ - mode?: () => "edit" | "view"; - /** Set locked state */ - setLocked?: (locked: boolean) => void; - /** Set mode state directly */ - setModeState?: (mode: "edit" | "view") => void; - /** Undo/redo state */ - undoRedo?: { - canUndo: boolean; - canRedo: boolean; - }; - /** Handler for edge creation with undo support */ - handleEdgeCreated?: ( - sourceId: string, - targetId: string, - edgeData: { - id: string; - source: string; - target: string; - sourceEndpoint: string; - targetEndpoint: string; - } - ) => void; - /** Handler for node creation with undo support */ - handleNodeCreatedCallback?: ( - nodeId: string, - nodeElement: TopoNode, - position: { x: number; y: number } - ) => void; - /** Create group from selected nodes */ - createGroupFromSelected?: () => void; - /** Create a network node at a position */ - createNetworkAtPosition?: ( - position: { x: number; y: number }, - networkType: NetworkType - ) => string | null; - /** Open or close the network editor panel */ - openNetworkEditor?: (nodeId: string | null) => void; - /** Open or close the node editor panel */ - openNodeEditor?: (nodeId: string | null) => void; - /** Get React groups state */ - getReactGroups?: () => GroupStyleAnnotation[]; - /** Current group count */ - groupsCount?: number; - /** Get React elements state */ - getElements?: () => unknown[]; - /** Set layout for the graph (for E2E testing) */ - setLayout?: (layout: LayoutOption) => void; - /** Check if currently in geo layout mode */ - isGeoLayout?: () => boolean; - /** React Flow instance for E2E testing (replaces Cytoscape cy) */ - rfInstance?: ReactFlowInstance; - /** Get current selected node ID */ - selectedNode?: () => string | null; - /** Get current selected edge ID */ - selectedEdge?: () => string | null; - /** Select a node by ID (for E2E testing) */ - selectNode?: (nodeId: string | null) => void; - /** Select an edge by ID (for E2E testing) */ - selectEdge?: (edgeId: string | null) => void; - /** Select multiple nodes for clipboard operations (for E2E testing) */ - selectNodesForClipboard?: (nodeIds: string[]) => void; - /** Clear all node selections (for E2E testing) */ - clearNodeSelection?: () => void; - /** Toggle dummy links visibility (for E2E testing) */ - toggleDummyLinks?: () => void; -} - -/** - * Initial bootstrap data passed from extension/dev host (non-topology). - */ -export interface WebviewInitialData { - schemaData?: SchemaData; - dockerImages?: string[]; - customNodes?: CustomNodeTemplate[]; - defaultNode?: string; - customIcons?: CustomIconInfo[]; - [key: string]: unknown; -} - -declare global { - interface Window { - __DEV__?: DevModeInterface; - __INITIAL_DATA__?: WebviewInitialData; - // Note: __SCHEMA_DATA__ is typed in hooks/editor/useSchema.ts - __DOCKER_IMAGES__?: string[]; - maplibreWorkerUrl?: string; - maplibreWorkerSourceBase64?: string; - } -} diff --git a/src/reactTopoViewer/webview/types/monacoYaml.d.ts b/src/reactTopoViewer/webview/types/monacoYaml.d.ts deleted file mode 100644 index 058a54f56..000000000 --- a/src/reactTopoViewer/webview/types/monacoYaml.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module "monaco-editor/esm/vs/basic-languages/yaml/yaml.js" { - import type { languages } from "monaco-editor"; - export const conf: languages.LanguageConfiguration; - export const language: languages.IMonarchLanguage; -} diff --git a/src/reactTopoViewer/webview/utils/clipboard.ts b/src/reactTopoViewer/webview/utils/clipboard.ts deleted file mode 100644 index 1bb31e7ac..000000000 --- a/src/reactTopoViewer/webview/utils/clipboard.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Clipboard utilities for copying text - */ - -/** - * Copy text to clipboard. - * Returns true on success, false on failure. - */ -export async function copyToClipboard(text: string): Promise { - try { - await window.navigator.clipboard.writeText(text); - return true; - } catch { - return false; - } -} diff --git a/src/reactTopoViewer/webview/utils/color.ts b/src/reactTopoViewer/webview/utils/color.ts deleted file mode 100644 index 29ff5c907..000000000 --- a/src/reactTopoViewer/webview/utils/color.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Color normalization helpers for form inputs and annotation persistence. - */ -import type { FreeShapeAnnotation } from "../../shared/types/topology"; - -function clampByte(value: number): number { - if (Number.isNaN(value)) return 0; - return Math.min(255, Math.max(0, value)); -} - -function toHexByte(value: number): string { - return clampByte(value).toString(16).padStart(2, "0"); -} - -function expandShortHex(value: string): string { - const hex = value.replace("#", ""); - const expanded = hex - .split("") - .map((ch) => ch + ch) - .join(""); - return "#" + expanded; -} - -// Regex pattern for parsing rgba() or rgb() values -const RGB_REGEX = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i; - -function parseRgb(value: string): { hex: string; alpha?: number } | null { - const match = RGB_REGEX.exec(value); - if (!match) return null; - const r = parseInt(match[1], 10); - const g = parseInt(match[2], 10); - const b = parseInt(match[3], 10); - const alphaParsed = Number.parseFloat(match[4]); - const alphaRaw = Number.isFinite(alphaParsed) ? alphaParsed : undefined; - const alpha = alphaRaw !== undefined ? Math.min(1, Math.max(0, alphaRaw)) : undefined; - return { hex: "#" + toHexByte(r) + toHexByte(g) + toHexByte(b), alpha }; -} - -/** - * Apply alpha transparency to a color. - * Supports hex colors (#RRGGBB). Falls back to returning original color for other formats. - */ -export function applyAlphaToColor(color: string, alpha: number): string { - const normalizedAlpha = Math.min(1, Math.max(0, alpha)); - const hexMatch = /^#([0-9a-f]{6})$/i.exec(color); - if (hexMatch) { - const r = parseInt(hexMatch[1].slice(0, 2), 16); - const g = parseInt(hexMatch[1].slice(2, 4), 16); - const b = parseInt(hexMatch[1].slice(4, 6), 16); - return `rgba(${r}, ${g}, ${b}, ${normalizedAlpha})`; - } - return color; -} - -/** - * Parse a CSS color string (hex or rgb/rgba) and return perceived luminance. - * Returns a number 0..1 where 0 = black, 1 = white, or null if unparseable. - */ -export function parseLuminance(color: string): number | null { - let r = 0; - let g = 0; - let b = 0; - - const hexMatch = /^#([0-9a-f]{3,8})$/i.exec(color.trim()); - if (hexMatch) { - let hex = hexMatch[1]; - if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; - r = parseInt(hex.slice(0, 2), 16); - g = parseInt(hex.slice(2, 4), 16); - b = parseInt(hex.slice(4, 6), 16); - } else { - const rgbMatch = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(color); - if (!rgbMatch) return null; - r = parseInt(rgbMatch[1], 10); - g = parseInt(rgbMatch[2], 10); - b = parseInt(rgbMatch[3], 10); - } - - return (0.299 * r + 0.587 * g + 0.114 * b) / 255; -} - -export function normalizeHexColor(value: string | undefined, fallback = "#000000"): string { - if (value === undefined || value.length === 0) return fallback; - const trimmed = value.trim(); - if (trimmed.startsWith("#")) { - const hex = trimmed.toLowerCase(); - if (hex.length === 4) return expandShortHex(hex); - if (hex.length === 7) return hex; - if (hex.length === 9) return hex.slice(0, 7); - if (hex.length === 5) return expandShortHex(hex.slice(0, 4)); - return fallback; - } - const rgb = parseRgb(trimmed); - if (rgb) return rgb.hex; - return fallback; -} - -export function resolveComputedColor(cssVar: string, fallback: string): string { - try { - const raw = window.getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim(); - if (!raw) return fallback; - if (/^#[0-9a-f]{3,8}$/i.test(raw)) return normalizeHexColor(raw, fallback); - const rgb = parseRgb(raw); - if (rgb) return rgb.hex; - return fallback; - } catch { - return fallback; - } -} - -export function invertHexColor(hex: string, fallback = "#ffffff", strength = 0.5): string { - const normalized = normalizeHexColor(hex, ""); - if (!normalized) return fallback; - const sr = parseInt(normalized.slice(1, 3), 16); - const sg = parseInt(normalized.slice(3, 5), 16); - const sb = parseInt(normalized.slice(5, 7), 16); - const r = Math.round(sr + (255 - 2 * sr) * strength); - const g = Math.round(sg + (255 - 2 * sg) * strength); - const b = Math.round(sb + (255 - 2 * sb) * strength); - return "#" + toHexByte(r) + toHexByte(g) + toHexByte(b); -} - -export function normalizeShapeAnnotationColors( - annotation: FreeShapeAnnotation -): FreeShapeAnnotation { - const fillColorValue = annotation.fillColor; - const hasFillColor = annotation.fillColor !== undefined; - const hasFillColorValue = typeof fillColorValue === "string" && fillColorValue.length > 0; - const fill = hasFillColor && hasFillColorValue ? parseRgb(fillColorValue) : null; - const fillColor = hasFillColor - ? normalizeHexColor(annotation.fillColor, "#ffffff") - : annotation.fillColor; - const fillOpacity = annotation.fillOpacity ?? fill?.alpha; - const hasBorderColor = - typeof annotation.borderColor === "string" && annotation.borderColor.length > 0; - - return { - ...annotation, - fillColor, - fillOpacity, - borderColor: hasBorderColor - ? normalizeHexColor(annotation.borderColor, "#000000") - : annotation.borderColor - }; -} diff --git a/src/reactTopoViewer/webview/utils/edgeId.ts b/src/reactTopoViewer/webview/utils/edgeId.ts deleted file mode 100644 index 6b5929041..000000000 --- a/src/reactTopoViewer/webview/utils/edgeId.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Edge ID helper for locally-created links. - */ -export function buildEdgeId( - source: string, - target: string, - sourceEndpoint?: string, - targetEndpoint?: string, - timestamp?: number -): string { - const hasSourceEndpoint = sourceEndpoint !== undefined && sourceEndpoint.length > 0; - const hasTargetEndpoint = targetEndpoint !== undefined && targetEndpoint.length > 0; - const src = hasSourceEndpoint ? `${source}:${sourceEndpoint}` : source; - const dst = hasTargetEndpoint ? `${target}:${targetEndpoint}` : target; - const time = timestamp ?? Date.now(); - return `${src}--${dst}-${time}`; -} diff --git a/src/reactTopoViewer/webview/utils/endpointAllocator.ts b/src/reactTopoViewer/webview/utils/endpointAllocator.ts deleted file mode 100644 index a5881831f..000000000 --- a/src/reactTopoViewer/webview/utils/endpointAllocator.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Endpoint allocation helpers for link creation. - */ -import { isSpecialEndpointId } from "../../shared/utilities/LinkTypes"; -import { DEFAULT_INTERFACE_PATTERNS } from "../../shared/constants/interfacePatterns"; -import type { Node, Edge } from "@xyflow/react"; - -const DEFAULT_INTERFACE_PATTERN = "eth{n}"; -const INTERFACE_PATTERN_REGEX = /^(.+)?\{n(?::(\d+)(?:-(\d+))?)?\}(.+)?$/; - -export type ParsedInterfacePattern = { - prefix: string; - suffix: string; - startIndex: number; - endIndex?: number; -}; - -type NodeExtraData = { - interfacePattern?: string; - kind?: string; -}; - -type PatternAllocator = { - parsed: ParsedInterfacePattern; - usedIndices: Set; -}; - -export type EndpointAllocator = { - patterns: PatternAllocator[]; -}; - -function asRecord(value: unknown): Record { - if (typeof value !== "object" || value === null) return {}; - return Object.fromEntries(Object.entries(value)); -} - -function toNodeExtraData(value: unknown): NodeExtraData | undefined { - const record = asRecord(value); - const interfacePattern = - typeof record.interfacePattern === "string" ? record.interfacePattern : undefined; - const kind = typeof record.kind === "string" ? record.kind : undefined; - if (interfacePattern === undefined && kind === undefined) return undefined; - return { interfacePattern, kind }; -} - -function splitInterfacePatterns(patternList: string): string[] { - const patterns: string[] = []; - let current = ""; - let braceDepth = 0; - - for (const char of patternList) { - if (char === "{") braceDepth += 1; - if (char === "}") braceDepth = Math.max(0, braceDepth - 1); - - if (char === "," && braceDepth === 0) { - const trimmed = current.trim(); - if (trimmed) patterns.push(trimmed); - current = ""; - continue; - } - - current += char; - } - - const trimmed = current.trim(); - if (trimmed) patterns.push(trimmed); - return patterns; -} - -function parseInterfacePattern(pattern: string): ParsedInterfacePattern { - const trimmed = pattern.trim(); - const match = INTERFACE_PATTERN_REGEX.exec(trimmed); - if (!match) { - return { prefix: trimmed || "eth", suffix: "", startIndex: 1 }; - } - const [, prefix = "", startStr, endStr, suffix = ""] = match; - const parsedStart = startStr ? parseInt(startStr, 10) : NaN; - const startIndex = Number.isFinite(parsedStart) ? parsedStart : 1; - const parsedEnd = endStr ? parseInt(endStr, 10) : NaN; - const endIndex = Number.isFinite(parsedEnd) && parsedEnd >= startIndex ? parsedEnd : undefined; - return { prefix, suffix, startIndex, ...(endIndex !== undefined ? { endIndex } : {}) }; -} - -function parseInterfacePatternList(patternList: string): ParsedInterfacePattern[] { - const parts = splitInterfacePatterns(patternList); - if (parts.length === 0) { - return [parseInterfacePattern(DEFAULT_INTERFACE_PATTERN)]; - } - return parts.map(parseInterfacePattern); -} - -function generateInterfaceName(parsed: ParsedInterfacePattern, index: number): string { - const num = parsed.startIndex + index; - return `${parsed.prefix}${num}${parsed.suffix}`; -} - -function extractInterfaceIndex(endpoint: string, parsed: ParsedInterfacePattern): number { - const escapedPrefix = parsed.prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const escapedSuffix = parsed.suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const regex = new RegExp(`^${escapedPrefix}(\\d+)${escapedSuffix}$`); - const match = regex.exec(endpoint); - if (match) { - return parseInt(match[1], 10) - parsed.startIndex; - } - return -1; -} - -function getNodeInterfacePattern( - extraData: NodeExtraData | undefined, - interfacePatternMapping: Record = DEFAULT_INTERFACE_PATTERNS -): string { - if (extraData?.interfacePattern !== undefined && extraData.interfacePattern.length > 0) { - return extraData.interfacePattern; - } - - const kind = extraData?.kind; - if (kind !== undefined && kind.length > 0) { - const mappedPattern = interfacePatternMapping[kind]; - if (typeof mappedPattern === "string" && mappedPattern.length > 0) { - return mappedPattern; - } - } - - return DEFAULT_INTERFACE_PATTERN; -} - -function readEndpointFromEdge( - edge: Edge, - key: "sourceEndpoint" | "targetEndpoint" -): string | undefined { - const data = edge.data; - const fromData = data?.[key]; - if (typeof fromData === "string" && fromData.length > 0) return fromData; - - const fromTopLevel = asRecord(edge)[key]; - return typeof fromTopLevel === "string" && fromTopLevel.length > 0 ? fromTopLevel : undefined; -} - -function tryAddIndexForEndpoint(patterns: PatternAllocator[], endpoint: string | undefined) { - if (endpoint === undefined || endpoint.length === 0) return; - for (const pattern of patterns) { - const idx = extractInterfaceIndex(endpoint, pattern.parsed); - if (idx >= 0) { - pattern.usedIndices.add(idx); - return; - } - } -} - -function collectUsedIndices(edges: Edge[], nodeId: string, patterns: PatternAllocator[]): void { - for (const edge of edges) { - if (edge.source === nodeId) { - const sourceEndpoint = readEndpointFromEdge(edge, "sourceEndpoint"); - tryAddIndexForEndpoint(patterns, sourceEndpoint); - } - if (edge.target === nodeId) { - const targetEndpoint = readEndpointFromEdge(edge, "targetEndpoint"); - tryAddIndexForEndpoint(patterns, targetEndpoint); - } - } -} - -export function getOrCreateAllocator( - allocators: Map, - nodes: Node[], - edges: Edge[], - nodeId: string -): EndpointAllocator { - const cached = allocators.get(nodeId); - if (cached) return cached; - - const node = nodes.find((candidate) => candidate.id === nodeId); - if (!node) { - const parsedPatterns = parseInterfacePatternList(DEFAULT_INTERFACE_PATTERN); - const patterns = parsedPatterns.map((parsed) => ({ parsed, usedIndices: new Set() })); - const createdFallback = { patterns }; - allocators.set(nodeId, createdFallback); - return createdFallback; - } - - const data = node.data; - const extraData = toNodeExtraData(data.extraData); - const pattern = getNodeInterfacePattern(extraData); - const parsedPatterns = parseInterfacePatternList(pattern); - const patterns = parsedPatterns.map((parsed) => ({ parsed, usedIndices: new Set() })); - - const connectedEdges = edges.filter((edge) => edge.source === nodeId || edge.target === nodeId); - collectUsedIndices(connectedEdges, nodeId, patterns); - - const created = { patterns }; - allocators.set(nodeId, created); - return created; -} - -function getNextAvailableIndex( - pattern: PatternAllocator, - ignoreEndRange = false -): number | undefined { - let nextIndex = 0; - while (pattern.usedIndices.has(nextIndex)) nextIndex++; - - if (!ignoreEndRange && pattern.parsed.endIndex !== undefined) { - const maxIndex = pattern.parsed.endIndex - pattern.parsed.startIndex; - if (nextIndex > maxIndex) return undefined; - } - - return nextIndex; -} - -export function allocateEndpoint( - allocators: Map, - nodes: Node[], - edges: Edge[], - nodeId: string -): string { - if (isSpecialEndpointId(nodeId)) return ""; - - const allocator = getOrCreateAllocator(allocators, nodes, edges, nodeId); - for (const pattern of allocator.patterns) { - const nextIndex = getNextAvailableIndex(pattern); - if (nextIndex !== undefined) { - pattern.usedIndices.add(nextIndex); - return generateInterfaceName(pattern.parsed, nextIndex); - } - } - - const fallbackPattern = allocator.patterns[allocator.patterns.length - 1]; - const nextIndex = getNextAvailableIndex(fallbackPattern, true) ?? 0; - fallbackPattern.usedIndices.add(nextIndex); - return generateInterfaceName(fallbackPattern.parsed, nextIndex); -} - -export function allocateEndpointsForLink( - nodes: Node[], - edges: Edge[], - sourceId: string, - targetId: string -): { sourceEndpoint: string; targetEndpoint: string } { - const allocators = new Map(); - const sourceEndpoint = allocateEndpoint(allocators, nodes, edges, sourceId); - const targetEndpoint = allocateEndpoint(allocators, nodes, edges, targetId); - return { sourceEndpoint, targetEndpoint }; -} diff --git a/src/reactTopoViewer/webview/utils/graphQueryUtils.ts b/src/reactTopoViewer/webview/utils/graphQueryUtils.ts deleted file mode 100644 index fa81f25a0..000000000 --- a/src/reactTopoViewer/webview/utils/graphQueryUtils.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Graph Query Utilities - * Helper functions to query nodes and edges in React Flow graphs. - */ -import type { TopoNode, TopoEdge } from "../../shared/types/graph"; - -/** - * Get a node by its ID - */ -export function getNodeById(nodes: TopoNode[], id: string): TopoNode | null { - return nodes.find((node) => node.id === id) ?? null; -} - -/** - * Runtime guard for TopoNode-like objects. - */ -export function isTopoNodeLike(value: unknown): value is TopoNode { - if (typeof value !== "object" || value === null) return false; - const id: unknown = Reflect.get(value, "id"); - const position: unknown = Reflect.get(value, "position"); - if (typeof id !== "string" || typeof position !== "object" || position === null) return false; - const x: unknown = Reflect.get(position, "x"); - const y: unknown = Reflect.get(position, "y"); - return typeof x === "number" && typeof y === "number"; -} - -/** - * Runtime guard for TopoEdge-like objects. - */ -export function isTopoEdgeLike(value: unknown): value is TopoEdge { - if (typeof value !== "object" || value === null) return false; - const id: unknown = Reflect.get(value, "id"); - const source: unknown = Reflect.get(value, "source"); - const target: unknown = Reflect.get(value, "target"); - return typeof id === "string" && typeof source === "string" && typeof target === "string"; -} - -/** - * Check if an edge exists between two nodes (in either direction) - */ -export function hasEdgeBetween(edges: TopoEdge[], sourceId: string, targetId: string): boolean { - return edges.some( - (edge) => - (edge.source === sourceId && edge.target === targetId) || - (edge.source === targetId && edge.target === sourceId) - ); -} - -/** - * Get all edges connected to a node (as source or target) - */ -export function getConnectedEdges(edges: TopoEdge[], nodeId: string): TopoEdge[] { - return edges.filter((edge) => edge.source === nodeId || edge.target === nodeId); -} - -/** - * Search nodes by a query string (matches id, label, kind, or role) - * Case-insensitive substring matching - */ -export function searchNodes(nodes: TopoNode[], query: string): TopoNode[] { - if (!query.trim()) return []; - const lowerQuery = query.toLowerCase(); - - return nodes.filter((node) => { - // Check node ID - if (node.id.toLowerCase().includes(lowerQuery)) return true; - - // Check node data properties based on node type - const data = node.data as Record; - - // Check label (common to all node types) - const label = data.label; - if (typeof label === "string" && label.toLowerCase().includes(lowerQuery)) return true; - - // Check topology-specific fields - const role = data.role; - if (typeof role === "string" && role.toLowerCase().includes(lowerQuery)) return true; - - const kind = data.kind; - if (typeof kind === "string" && kind.toLowerCase().includes(lowerQuery)) return true; - - // Check network node type - const nodeType = data.nodeType; - if (typeof nodeType === "string" && nodeType.toLowerCase().includes(lowerQuery)) return true; - - return false; - }); -} - -/** - * Get the bounding box of selected nodes - */ -export function getNodesBoundingBox( - nodes: TopoNode[] -): { x: number; y: number; width: number; height: number } | null { - if (nodes.length === 0) return null; - - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - for (const node of nodes) { - const { x, y } = node.position; - const width = node.measured?.width ?? 100; - const height = node.measured?.height ?? 100; - - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x + width); - maxY = Math.max(maxY, y + height); - } - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY - }; -} diff --git a/src/reactTopoViewer/webview/utils/grid.ts b/src/reactTopoViewer/webview/utils/grid.ts deleted file mode 100644 index 7649eb26d..000000000 --- a/src/reactTopoViewer/webview/utils/grid.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { XYPosition } from "@xyflow/react"; - -// Grid size for snapping -export const GRID_SIZE = 20; - -// Snap position to grid -export function snapToGrid(position: XYPosition): XYPosition { - return { - x: Math.round(position.x / GRID_SIZE) * GRID_SIZE, - y: Math.round(position.y / GRID_SIZE) * GRID_SIZE - }; -} diff --git a/src/reactTopoViewer/webview/utils/iconUtils.ts b/src/reactTopoViewer/webview/utils/iconUtils.ts deleted file mode 100644 index e139c6fe3..000000000 --- a/src/reactTopoViewer/webview/utils/iconUtils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { CustomIconInfo } from "../../shared/types/icons"; - -const customIconMapCache = new WeakMap>(); - -export function buildCustomIconMap(customIcons: CustomIconInfo[]): Map { - const map = new Map(); - for (const icon of customIcons) { - map.set(icon.name, icon.dataUri); - } - return map; -} - -export function getCustomIconMap(customIcons: CustomIconInfo[]): Map { - const cached = customIconMapCache.get(customIcons); - if (cached) return cached; - const map = buildCustomIconMap(customIcons); - customIconMapCache.set(customIcons, map); - return map; -} diff --git a/src/reactTopoViewer/webview/utils/lifecycleTimer.ts b/src/reactTopoViewer/webview/utils/lifecycleTimer.ts deleted file mode 100644 index 7e06b30d7..000000000 --- a/src/reactTopoViewer/webview/utils/lifecycleTimer.ts +++ /dev/null @@ -1,16 +0,0 @@ -export function calculateElapsedSeconds(startedAtMs: number, nowMs: number = Date.now()): number { - return Math.max(0, Math.floor((nowMs - startedAtMs) / 1000)); -} - -export function formatElapsedSeconds(elapsedSeconds: number): string { - const totalSeconds = Math.max(0, Math.floor(elapsedSeconds)); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - - if (hours > 0) { - return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; - } - - return `${minutes}:${String(seconds).padStart(2, "0")}`; -} diff --git a/src/reactTopoViewer/webview/utils/linkEditorConversions.ts b/src/reactTopoViewer/webview/utils/linkEditorConversions.ts deleted file mode 100644 index a0924b63a..000000000 --- a/src/reactTopoViewer/webview/utils/linkEditorConversions.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Conversions for link editor data. - */ -import type { LinkEditorData } from "../../shared/types/editors"; -import type { LinkSaveData } from "../../shared/io/LinkPersistenceIO"; -import { isSpecialEndpointId } from "../../shared/utilities/LinkTypes"; - -type LinkExtraData = NonNullable; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -/** Parse MTU from raw value (can be string or number) */ -function parseMtu(raw: unknown): number | undefined { - if (raw === undefined || raw === "") return undefined; - const parsed = typeof raw === "number" ? raw : parseInt(String(raw), 10); - return Number.isNaN(parsed) ? undefined : parsed; -} - -/** Get string value from object with fallback */ -function getStr(obj: Record, key: string, fallback = ""): string { - const value = obj[key]; - return typeof value === "string" ? value : fallback; -} - -/** Get MAC address from extended data or legacy endpoint object */ -function getEndpointValue( - extraData: Record, - extKey: string, - rawData: Record, - endpointKey: string, - endpointField: string -): string { - const extVal = extraData[extKey]; - if (typeof extVal === "string" && extVal.length > 0) return extVal; - const endpoint = rawData[endpointKey]; - if (!isRecord(endpoint)) return ""; - const endpointValue = endpoint[endpointField]; - return typeof endpointValue === "string" ? endpointValue : ""; -} - -/** Get MAC address from extended data or legacy endpoint object */ -function getMac( - extraData: Record, - extKey: string, - rawData: Record, - endpointKey: string -): string { - return getEndpointValue(extraData, extKey, rawData, endpointKey, "mac"); -} - -/** Get endpoint IP address from extended data or legacy endpoint object */ -function getIp( - extraData: Record, - extKey: string, - rawData: Record, - endpointKey: string, - ipVersion: "ipv4" | "ipv6" -): string { - return getEndpointValue(extraData, extKey, rawData, endpointKey, ipVersion); -} - -/** Get key-value map from extended data or raw data */ -function getMap( - extraData: Record, - extKey: string, - rawData: Record, - rawKey: string -): Record { - const parse = (value: unknown): Record | undefined => { - if (!isRecord(value)) return undefined; - const output: Record = {}; - for (const [k, v] of Object.entries(value)) { - if (typeof v === "string") { - output[k] = v; - } - } - return Object.keys(output).length > 0 ? output : undefined; - }; - return parse(extraData[extKey]) ?? parse(rawData[rawKey]) ?? {}; -} - -function assignExtraString( - extraData: LinkExtraData, - key: keyof LinkExtraData, - value: string | undefined -): void { - if (value === undefined || value.length === 0) return; - extraData[key] = value; -} - -function assignExtraMap( - extraData: LinkExtraData, - key: "extVars" | "extLabels", - value: Record | undefined -): void { - if (!value || Object.keys(value).length === 0) return; - extraData[key] = value; -} - -/** - * Converts raw Edge data to LinkEditorData. - */ -export function convertToLinkEditorData( - rawData: Record | null -): LinkEditorData | null { - if (!rawData) return null; - - const source = getStr(rawData, "source"); - const target = getStr(rawData, "target"); - const sourceEndpoint = getStr(rawData, "sourceEndpoint"); - const targetEndpoint = getStr(rawData, "targetEndpoint"); - const extraData = isRecord(rawData.extraData) ? rawData.extraData : {}; - const yamlSource = - typeof extraData.yamlSourceNodeId === "string" && extraData.yamlSourceNodeId.length > 0 - ? extraData.yamlSourceNodeId - : source; - const yamlTarget = - typeof extraData.yamlTargetNodeId === "string" && extraData.yamlTargetNodeId.length > 0 - ? extraData.yamlTargetNodeId - : target; - - return { - id: getStr(rawData, "id"), - source, - target, - sourceEndpoint, - targetEndpoint, - type: getStr(extraData, "extType") || getStr(rawData, "linkType", "veth"), - sourceMac: getMac(extraData, "extSourceMac", rawData, "endpointA"), - targetMac: getMac(extraData, "extTargetMac", rawData, "endpointB"), - sourceIpv4: getIp(extraData, "extSourceIpv4", rawData, "endpointA", "ipv4"), - sourceIpv6: getIp(extraData, "extSourceIpv6", rawData, "endpointA", "ipv6"), - targetIpv4: getIp(extraData, "extTargetIpv4", rawData, "endpointB", "ipv4"), - targetIpv6: getIp(extraData, "extTargetIpv6", rawData, "endpointB", "ipv6"), - mtu: parseMtu(extraData.extMtu), - vars: getMap(extraData, "extVars", rawData, "vars"), - labels: getMap(extraData, "extLabels", rawData, "labels"), - originalSource: yamlSource, - originalTarget: yamlTarget, - originalSourceEndpoint: sourceEndpoint, - originalTargetEndpoint: targetEndpoint, - sourceIsNetwork: isSpecialEndpointId(source), - targetIsNetwork: isSpecialEndpointId(target) - }; -} - -/** - * Converts LinkEditorData to LinkSaveData for host commands. - * This is used when saving link editor changes via the host pipeline. - * - * @param data - LinkEditorData from the editor panel - * @returns LinkSaveData for host editLink command - */ -export function convertEditorDataToLinkSaveData(data: LinkEditorData): LinkSaveData { - // Build extraData with extended properties - const extraData: LinkExtraData = {}; - - if (data.type !== undefined && data.type.length > 0 && data.type !== "veth") { - extraData.extType = data.type; - } - if (data.mtu !== undefined && data.mtu !== "") { - extraData.extMtu = data.mtu; - } - assignExtraString(extraData, "extSourceMac", data.sourceMac); - assignExtraString(extraData, "extTargetMac", data.targetMac); - assignExtraString(extraData, "extSourceIpv4", data.sourceIpv4); - assignExtraString(extraData, "extSourceIpv6", data.sourceIpv6); - assignExtraString(extraData, "extTargetIpv4", data.targetIpv4); - assignExtraString(extraData, "extTargetIpv6", data.targetIpv6); - assignExtraMap(extraData, "extVars", data.vars); - assignExtraMap(extraData, "extLabels", data.labels); - - const saveData: LinkSaveData = { - id: data.id, - source: data.source, - target: data.target, - sourceEndpoint: data.sourceEndpoint, - targetEndpoint: data.targetEndpoint, - extraData: Object.keys(extraData).length > 0 ? extraData : undefined, - // Include original values if endpoints changed - originalSource: data.originalSource, - originalTarget: data.originalTarget, - originalSourceEndpoint: data.originalSourceEndpoint, - originalTargetEndpoint: data.originalTargetEndpoint - }; - - return saveData; -} diff --git a/src/reactTopoViewer/webview/utils/logger.ts b/src/reactTopoViewer/webview/utils/logger.ts deleted file mode 100644 index c8c232003..000000000 --- a/src/reactTopoViewer/webview/utils/logger.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Logger utility for React TopoViewer webview - * Posts log messages to the extension host via VS Code API - */ - -import { - type LogLevel, - formatMessage, - getCallerFileLine, - createLogger -} from "../../shared/utilities/loggerUtils"; - -declare global { - interface Window { - vscode?: { postMessage(data: unknown): void; __isDevMock__?: boolean }; - } -} - -/** - * Send log message to extension host - */ -function logMessage(level: LogLevel, message: unknown): void { - const formatted = formatMessage(message); - const fileLine = getCallerFileLine(1); - - const vscodeApi = typeof window !== "undefined" ? window.vscode : undefined; - if (vscodeApi && typeof vscodeApi.postMessage === "function") { - vscodeApi.postMessage({ - command: "reactTopoViewerLog", - level, - message: formatted, - fileLine - }); - } -} - -/** - * Logger for React TopoViewer webview - */ -export const log = createLogger(logMessage); diff --git a/src/reactTopoViewer/webview/utils/markdownRenderer.ts b/src/reactTopoViewer/webview/utils/markdownRenderer.ts deleted file mode 100644 index 13ddd8a2e..000000000 --- a/src/reactTopoViewer/webview/utils/markdownRenderer.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Markdown renderer utility for React TopoViewer - * Uses markdown-it with syntax highlighting and emoji support - */ -import MarkdownIt from "markdown-it"; -import { full as markdownItEmoji } from "markdown-it-emoji"; -import hljs from "highlight.js"; -import DOMPurify from "dompurify"; - -/** - * Escape HTML entities for safe display - */ -function escapeHtml(text: string): string { - const escapeMap: Record = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'" - }; - return text.replace(/[&<>"']/g, (char) => escapeMap[char] || char); -} - -/** - * Configured markdown-it instance with: - * - Syntax highlighting via highlight.js - * - Emoji support via markdown-it-emoji - * - Security: HTML disabled, linkify enabled - */ -const markdownRenderer = new MarkdownIt({ - html: false, // Disable raw HTML for security - linkify: true, // Auto-convert URLs to links - typographer: true, // Smart quotes and dashes - breaks: false, // Don't convert \n to
    (like GitHub) - langPrefix: "hljs language-", - highlight(code: string, lang: string): string { - try { - if (lang && hljs.getLanguage(lang)) { - return hljs.highlight(code, { language: lang }).value; - } - return hljs.highlightAuto(code).value; - } catch { - return escapeHtml(code); - } - } -}).use(markdownItEmoji); - -/** - * Post-process HTML to fix common markdown rendering issues - * - Merges consecutive badge/image paragraphs into single line (like GitHub) - * - Removes paragraph wrappers inside list items (fixes bullet alignment) - */ -function postProcessHtml(html: string): string { - let result = html; - - // 1. Merge consecutive paragraphs containing only image links (badges) - // Match:

    + any whitespace +

    <\/p>\s*

    )/g, " tags wrapping list item content to fix bullet alignment - //

  • content

  • ->
  • content
  • - result = result.replace(/
  • \s*

    /g, "

  • "); - result = result.replace(/<\/p>\s*<\/li>/g, "
  • "); - - // 3. For list items with multiple paragraphs, replace intermediate

    with
    - result = result.replace(/

  • ([\s\S]*?)<\/li>/g, (match) => { - return match.replace(/<\/p>\s*

    /g, "
    "); - }); - - return result; -} - -/** - * Render markdown text to sanitized HTML - * @param text - Raw markdown text - * @returns Sanitized HTML string - */ -export function renderMarkdown(text: string): string { - if (text.trim().length === 0) { - return ""; - } - const rendered = markdownRenderer.render(text); - const processed = postProcessHtml(rendered); - return DOMPurify.sanitize(processed); -} diff --git a/src/reactTopoViewer/webview/utils/netemNormalization.ts b/src/reactTopoViewer/webview/utils/netemNormalization.ts deleted file mode 100644 index bd5bc1bd0..000000000 --- a/src/reactTopoViewer/webview/utils/netemNormalization.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function normalizeNetemValue(value?: string | number): string { - if (value === undefined) return ""; - return String(value).trim(); -} - -export function normalizeNetemPercentage(value?: string | number): string { - const raw = normalizeNetemValue(value); - if (!raw) return ""; - const stripped = raw.endsWith("%") ? raw.slice(0, -1) : raw; - const parsed = Number(stripped); - if (Number.isFinite(parsed)) { - return parsed.toString(); - } - return stripped; -} diff --git a/src/reactTopoViewer/webview/utils/netemOverrides.ts b/src/reactTopoViewer/webview/utils/netemOverrides.ts deleted file mode 100644 index 7aff7bec7..000000000 --- a/src/reactTopoViewer/webview/utils/netemOverrides.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { NetemState } from "../../shared/parsing"; - -import { normalizeNetemPercentage, normalizeNetemValue } from "./netemNormalization"; - -export const PENDING_NETEM_KEY = "clabPendingNetem"; -const PENDING_NETEM_TTL_MS = 10000; - -export interface PendingNetemOverride { - source?: NetemState; - target?: NetemState; - appliedAt: number; -} - -export function createPendingNetemOverride( - source?: NetemState, - target?: NetemState -): PendingNetemOverride { - return { - source, - target, - appliedAt: Date.now() - }; -} - -export function isPendingNetemFresh(pending?: PendingNetemOverride): boolean { - if (!pending) return false; - return Date.now() - pending.appliedAt <= PENDING_NETEM_TTL_MS; -} - -function normalizeNetemForCompare(netem?: NetemState): Record { - return { - delay: normalizeNetemValue(netem?.delay), - jitter: normalizeNetemValue(netem?.jitter), - loss: normalizeNetemPercentage(netem?.loss), - rate: normalizeNetemValue(netem?.rate), - corruption: normalizeNetemPercentage(netem?.corruption) - }; -} - -export function areNetemEquivalent(a?: NetemState, b?: NetemState): boolean { - const normalizedA = normalizeNetemForCompare(a); - const normalizedB = normalizeNetemForCompare(b); - return JSON.stringify(normalizedA) === JSON.stringify(normalizedB); -} diff --git a/src/reactTopoViewer/webview/utils/networkNodeAnnotations.ts b/src/reactTopoViewer/webview/utils/networkNodeAnnotations.ts deleted file mode 100644 index a64a9c55e..000000000 --- a/src/reactTopoViewer/webview/utils/networkNodeAnnotations.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Node } from "@xyflow/react"; - -import type { NetworkNodeAnnotation } from "../../shared/types/topology"; -import { getRecordUnknown, getString } from "../../shared/utilities/typeHelpers"; - -import { SPECIAL_NETWORK_TYPES, getNetworkType } from "./networkNodeTypes"; - -const NETWORK_NODE_TYPE = "network-node"; - -function isNetworkNode(node: Node): boolean { - return node.type === NETWORK_NODE_TYPE; -} - -function toGeoCoordinates(value: unknown): { lat: number; lng: number } | undefined { - const record = getRecordUnknown(value); - if (record === undefined) return undefined; - const lat = record.lat; - const lng = record.lng; - if (typeof lat !== "number" || !Number.isFinite(lat)) return undefined; - if (typeof lng !== "number" || !Number.isFinite(lng)) return undefined; - return { lat, lng }; -} - -function isNetworkAnnotationType(value: string): value is NetworkNodeAnnotation["type"] { - return ( - value === "host" || - value === "mgmt-net" || - value === "macvlan" || - value === "vxlan" || - value === "vxlan-stitch" || - value === "dummy" || - value === "bridge" || - value === "ovs-bridge" - ); -} - -export function buildNetworkNodeAnnotations(nodes: Node[]): NetworkNodeAnnotation[] { - const annotations: NetworkNodeAnnotation[] = []; - - for (const node of nodes) { - if (!isNetworkNode(node)) continue; - - const data = getRecordUnknown(node.data); - if (data === undefined) continue; - - const type = getNetworkType(data); - if (type === undefined || !SPECIAL_NETWORK_TYPES.has(type) || !isNetworkAnnotationType(type)) { - continue; - } - - const labelFromData = getString(data.label); - const labelFromName = getString(data.name); - let label = node.id; - if (labelFromData !== undefined && labelFromData.length > 0) { - label = labelFromData; - } else if (labelFromName !== undefined && labelFromName.length > 0) { - label = labelFromName; - } - const geoCoordinates = toGeoCoordinates(data.geoCoordinates); - - annotations.push({ - id: node.id, - type, - label, - position: node.position, - ...(geoCoordinates !== undefined ? { geoCoordinates } : {}), - ...(typeof data.group === "string" ? { group: data.group } : {}), - ...(typeof data.level === "string" ? { level: data.level } : {}) - }); - } - - return annotations; -} diff --git a/src/reactTopoViewer/webview/utils/networkNodeTypes.ts b/src/reactTopoViewer/webview/utils/networkNodeTypes.ts deleted file mode 100644 index fb07adc72..000000000 --- a/src/reactTopoViewer/webview/utils/networkNodeTypes.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Shared helpers for network node classification. - */ -import { getRecordUnknown } from "../../shared/utilities/typeHelpers"; - -export const SPECIAL_NETWORK_TYPES = new Set([ - "host", - "mgmt-net", - "macvlan", - "vxlan", - "vxlan-stitch", - "dummy" -]); - -export const BRIDGE_NETWORK_TYPES = new Set(["bridge", "ovs-bridge"]); - -export function getNetworkType(data: Record): string | undefined { - const kind = data.kind; - if (typeof kind === "string") return kind; - const nodeType = data.nodeType; - if (typeof nodeType === "string") return nodeType; - const extraData = getRecordUnknown(data.extraData); - const extraKind = extraData?.kind; - if (typeof extraKind === "string") return extraKind; - return undefined; -} diff --git a/src/reactTopoViewer/webview/utils/telemetryInterfaceLabels.ts b/src/reactTopoViewer/webview/utils/telemetryInterfaceLabels.ts deleted file mode 100644 index 80d36e7a2..000000000 --- a/src/reactTopoViewer/webview/utils/telemetryInterfaceLabels.ts +++ /dev/null @@ -1,140 +0,0 @@ -export const DEFAULT_TELEMETRY_NODE_SIZE_PX = 40; -export const DEFAULT_TELEMETRY_INTERFACE_SIZE_PERCENT = 100; - -export const INTERFACE_SELECT_AUTO = "__auto__"; -export const INTERFACE_SELECT_FULL = "__full__"; -export const INTERFACE_SELECT_TOKEN_PREFIX = "__token__:"; -export const GLOBAL_INTERFACE_PART_INDEX_PREFIX = "__part-index__:"; - -export function parseBoundedNumber( - value: string, - min: number, - max: number, - fallback: number -): number { - const parsed = Number.parseFloat(value); - if (!Number.isFinite(parsed)) return fallback; - return Math.max(min, Math.min(max, parsed)); -} - -export function clampTelemetryNodeSizePx(value: number): number { - if (!Number.isFinite(value)) return DEFAULT_TELEMETRY_NODE_SIZE_PX; - return Math.max(12, Math.min(240, value)); -} - -export function clampTelemetryInterfaceSizePercent(value: number): number { - if (!Number.isFinite(value)) return DEFAULT_TELEMETRY_INTERFACE_SIZE_PERCENT; - return Math.max(40, Math.min(400, value)); -} - -export function splitInterfaceParts(endpoint: string): string[] { - const baseParts = endpoint - .split(/[^A-Za-z0-9]+/g) - .map((part) => part.trim()) - .filter((part) => part.length > 0); - - const uniqueParts: string[] = []; - const seen = new Set(); - const addUnique = (part: string): void => { - if (seen.has(part)) return; - seen.add(part); - uniqueParts.push(part); - }; - - for (const part of baseParts) { - addUnique(part); - const numericSegments = part.match(/\d+/g); - if (!numericSegments) continue; - for (const numeric of numericSegments) { - addUnique(numeric); - } - } - - return uniqueParts; -} - -export function getAutoCompactInterfaceLabel(endpoint: string): string { - const trimmed = endpoint.trim(); - if (!trimmed) return ""; - - let end = trimmed.length - 1; - while (end >= 0 && (trimmed[end] < "0" || trimmed[end] > "9")) { - end -= 1; - } - if (end >= 0) { - let start = end; - while (start >= 0 && trimmed[start] >= "0" && trimmed[start] <= "9") { - start -= 1; - } - return trimmed.slice(start + 1, end + 1); - } - - const token = - trimmed - .split(/[:/.-]/) - .filter((part) => part.length > 0) - .pop() ?? trimmed; - return token.length <= 3 ? token : token.slice(-3); -} - -function parseGlobalInterfacePartIndex(selectedValue: string): number | null { - if (!selectedValue.startsWith(GLOBAL_INTERFACE_PART_INDEX_PREFIX)) return null; - const raw = selectedValue.slice(GLOBAL_INTERFACE_PART_INDEX_PREFIX.length); - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed) || parsed < 1) return null; - return parsed; -} - -export function resolveGlobalInterfaceOverrideValue( - endpoint: string, - selectedValue: string -): string | null { - if (selectedValue === INTERFACE_SELECT_AUTO) return null; - if (selectedValue === INTERFACE_SELECT_FULL) return endpoint; - - const partIndex = parseGlobalInterfacePartIndex(selectedValue); - if (partIndex === null) return null; - - const parts = splitInterfaceParts(endpoint); - return parts[partIndex - 1] ?? null; -} - -export function resolveInterfaceOverrideValue( - endpoint: string, - selectedValue: string -): string | null { - if (selectedValue === INTERFACE_SELECT_AUTO) return null; - if (selectedValue === INTERFACE_SELECT_FULL) return endpoint; - if (selectedValue.startsWith(INTERFACE_SELECT_TOKEN_PREFIX)) { - const token = selectedValue.slice(INTERFACE_SELECT_TOKEN_PREFIX.length).trim(); - return token.length > 0 ? token : null; - } - return null; -} - -export function getInterfaceSelectionValue( - endpoint: string, - interfaceLabelOverrides: Record -): string { - if (!(endpoint in interfaceLabelOverrides)) return INTERFACE_SELECT_AUTO; - const override = interfaceLabelOverrides[endpoint]; - if (override.length === 0) return INTERFACE_SELECT_AUTO; - if (override === endpoint) return INTERFACE_SELECT_FULL; - return `${INTERFACE_SELECT_TOKEN_PREFIX}${override}`; -} - -export function resolveTelemetryInterfaceLabel( - endpoint: string, - globalSelection: string, - interfaceLabelOverrides: Record -): string { - const endpointOverride = interfaceLabelOverrides[endpoint]; - if (typeof endpointOverride === "string" && endpointOverride.trim().length > 0) { - return endpointOverride.trim(); - } - - const globalOverride = resolveGlobalInterfaceOverrideValue(endpoint, globalSelection); - if (globalOverride !== null) return globalOverride; - - return getAutoCompactInterfaceLabel(endpoint); -} diff --git a/src/reactTopoViewer/webview/utils/trafficRateAnnotation.ts b/src/reactTopoViewer/webview/utils/trafficRateAnnotation.ts deleted file mode 100644 index 4fd3ad011..000000000 --- a/src/reactTopoViewer/webview/utils/trafficRateAnnotation.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { Edge } from "@xyflow/react"; - -import type { TopologyEdgeData } from "../../shared/types/graph"; -import type { InterfaceStatsPayload } from "../../shared/types/topology"; - -const TRAFFIC_STAT_KEYS: Array = [ - "rxBps", - "txBps", - "rxPps", - "txPps", - "rxBytes", - "txBytes", - "rxPackets", - "txPackets" -]; - -const INTERVAL_KEY: keyof InterfaceStatsPayload = "statsIntervalSeconds"; - -interface EdgeDataWithStats extends Partial { - extraData?: Record; -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function toStatsPayload(value: unknown): InterfaceStatsPayload | undefined { - if (!isRecord(value)) return undefined; - const source = value; - const stats: InterfaceStatsPayload = {}; - - for (const key of TRAFFIC_STAT_KEYS) { - const raw = source[key]; - if (typeof raw === "number" && Number.isFinite(raw)) { - stats[key] = raw; - } - } - - const interval = source[INTERVAL_KEY]; - if (typeof interval === "number" && Number.isFinite(interval) && interval > 0) { - stats.statsIntervalSeconds = interval; - } - - return Object.keys(stats).length > 0 ? stats : undefined; -} - -function addStats( - acc: InterfaceStatsPayload | undefined, - next: InterfaceStatsPayload | undefined -): InterfaceStatsPayload | undefined { - if (!next) return acc; - if (!acc) return { ...next }; - - const merged: InterfaceStatsPayload = { ...acc }; - for (const key of TRAFFIC_STAT_KEYS) { - const left = merged[key]; - const right = next[key]; - if (typeof right !== "number") continue; - merged[key] = (typeof left === "number" ? left : 0) + right; - } - - const leftInterval = merged.statsIntervalSeconds; - const rightInterval = next.statsIntervalSeconds; - if (typeof leftInterval === "number" && typeof rightInterval === "number") { - merged.statsIntervalSeconds = Math.min(leftInterval, rightInterval); - } else if (typeof rightInterval === "number") { - merged.statsIntervalSeconds = rightInterval; - } - - return merged; -} - -function getEdgeData(edge: Edge): EdgeDataWithStats { - return (edge.data ?? {}) as EdgeDataWithStats; -} - -export interface TrafficMonitorOptions { - nodeIds: string[]; - interfacesByNode: Map; -} - -/** - * Build node/interface selection options from current graph edges. - */ -export function getTrafficMonitorOptions(edges: Edge[]): TrafficMonitorOptions { - const interfacesByNode = new Map>(); - - const ensureNode = (nodeId: string) => { - if (!interfacesByNode.has(nodeId)) { - interfacesByNode.set(nodeId, new Set()); - } - return interfacesByNode.get(nodeId)!; - }; - - for (const edge of edges) { - const data = getEdgeData(edge); - const sourceInterfaces = ensureNode(edge.source); - const targetInterfaces = ensureNode(edge.target); - - if (isNonEmptyString(data.sourceEndpoint)) { - sourceInterfaces.add(data.sourceEndpoint); - } - if (isNonEmptyString(data.targetEndpoint)) { - targetInterfaces.add(data.targetEndpoint); - } - } - - const nodeIds = Array.from(interfacesByNode.keys()).sort((a, b) => a.localeCompare(b)); - const normalizedMap = new Map(); - for (const [nodeId, interfaces] of interfacesByNode.entries()) { - normalizedMap.set( - nodeId, - Array.from(interfaces).sort((a, b) => a.localeCompare(b)) - ); - } - - return { - nodeIds, - interfacesByNode: normalizedMap - }; -} - -export interface TrafficRateResolution { - stats: InterfaceStatsPayload | undefined; - endpointCount: number; - endpointKey: string; -} - -/** - * Resolve live interface stats for a selected node/interface pair. - * If multiple edges match, stats are summed. - */ -export function resolveTrafficRateStats( - edges: Edge[], - nodeId: string | undefined, - interfaceName: string | undefined -): TrafficRateResolution { - if (!isNonEmptyString(nodeId) || !isNonEmptyString(interfaceName)) { - return { stats: undefined, endpointCount: 0, endpointKey: "traffic-rate:unconfigured" }; - } - - let stats: InterfaceStatsPayload | undefined; - const endpointIds: string[] = []; - - for (const edge of edges) { - const data = getEdgeData(edge); - const extra = data.extraData ?? {}; - - if (edge.source === nodeId && data.sourceEndpoint === interfaceName) { - endpointIds.push(`s:${edge.id}`); - stats = addStats(stats, toStatsPayload(extra.clabSourceStats)); - } - - if (edge.target === nodeId && data.targetEndpoint === interfaceName) { - endpointIds.push(`t:${edge.id}`); - stats = addStats(stats, toStatsPayload(extra.clabTargetStats)); - } - } - - endpointIds.sort((a, b) => a.localeCompare(b)); - const endpointKey = - endpointIds.length > 0 - ? `traffic-rate:${nodeId}:${interfaceName}:${endpointIds.join("|")}` - : `traffic-rate:${nodeId}:${interfaceName}:none`; - - return { - stats, - endpointCount: endpointIds.length, - endpointKey - }; -} - -function formatMetric(value: number | undefined, units: string[]): string { - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { - return "--"; - } - - let scaled = value; - let unitIndex = 0; - while (scaled >= 1000 && unitIndex < units.length - 1) { - scaled /= 1000; - unitIndex += 1; - } - - const digits = scaled >= 100 ? 0 : 1; - return `${scaled.toFixed(digits)} ${units[unitIndex]}`; -} - -/** - * Format a bits-per-second metric with adaptive units. - */ -export function formatBitsPerSecond(value: number | undefined): string { - return formatMetric(value, ["bps", "Kbps", "Mbps", "Gbps", "Tbps"]); -} - -/** - * Format bits-per-second as Mbit/s (fixed unit). - */ -export function formatMegabitsPerSecond(value: number | undefined): string { - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { - return "-- Mbit/s"; - } - - const valueInMbit = value / 1_000_000; - let digits = 2; - if (valueInMbit >= 100) { - digits = 0; - } else if (valueInMbit >= 10) { - digits = 1; - } - return `${valueInMbit.toFixed(digits)} Mbit/s`; -} - -/** - * Format a packets-per-second metric with adaptive units. - */ -export function formatPacketsPerSecond(value: number | undefined): string { - return formatMetric(value, ["pps", "Kpps", "Mpps", "Gpps"]); -} diff --git a/src/reactTopoViewer/webview/utils/viewportUtils.ts b/src/reactTopoViewer/webview/utils/viewportUtils.ts deleted file mode 100644 index 5cc66d8bc..000000000 --- a/src/reactTopoViewer/webview/utils/viewportUtils.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Viewport Utilities - * Helper functions for viewport operations in React Flow. - */ -import type { ReactFlowInstance } from "@xyflow/react"; - -/** Selector for the React Flow container element */ -const REACT_FLOW_CONTAINER_SELECTOR = ".react-flow"; - -/** - * Get the center of the visible viewport in model (flow) coordinates. - */ -export function getViewportCenter(rfInstance: ReactFlowInstance | null): { x: number; y: number } { - if (!rfInstance) { - return { x: 0, y: 0 }; - } - - const viewport = rfInstance.getViewport(); - const container = document.querySelector(REACT_FLOW_CONTAINER_SELECTOR); - if (!container) { - return { x: 0, y: 0 }; - } - - const { width, height } = container.getBoundingClientRect(); - - // Convert screen center to model coordinates - // Screen center is (width/2, height/2) - // Model coords = (screenCoords - pan) / zoom - return { - x: (width / 2 - viewport.x) / viewport.zoom, - y: (height / 2 - viewport.y) / viewport.zoom - }; -} diff --git a/src/treeView/common.ts b/src/treeView/common.ts index 0cb43ce4b..dd5f4df55 100644 --- a/src/treeView/common.ts +++ b/src/treeView/common.ts @@ -1,7 +1,14 @@ import * as vscode from "vscode"; import type { ClabInterfaceStats } from "../types/containerlab"; -import type { NetemState } from "../reactTopoViewer/shared/parsing/types"; + +export interface NetemState { + delay?: string; + jitter?: string; + loss?: string; + rate?: string; + corruption?: string; +} // LabPath interface export interface LabPath { diff --git a/src/treeView/localLabsProvider.ts b/src/treeView/localLabsProvider.ts index 8a1a8b315..cda882a0f 100644 --- a/src/treeView/localLabsProvider.ts +++ b/src/treeView/localLabsProvider.ts @@ -1,4 +1,4 @@ -import path = require("path"); +import * as path from "path"; import * as vscode from "vscode"; @@ -98,7 +98,8 @@ export class LocalLabTreeDataProvider implements vscode.TreeDataProvider< outputChannel.debug(`[LocalTreeDataProvider] Scan found ${this.cachedUris.size} lab files`); this.refresh(); } catch (err: unknown) { - outputChannel.error(`[LocalTreeDataProvider] File scan failed: ${err}`); + const message = err instanceof Error ? err.message : String(err); + outputChannel.error(`[LocalTreeDataProvider] File scan failed: ${message}`); this.cachedUris ??= new Map(); } finally { this.scanPromise = undefined; @@ -164,7 +165,7 @@ export class LocalLabTreeDataProvider implements vscode.TreeDataProvider< const dirPath = dir ?? workspaceRoot; const { labNodes, folderNodes } = this.collectNodes(labs, dirPath, workspaceRoot); - labNodes.sort(this.compareLabs); + labNodes.sort((a, b) => LocalLabTreeDataProvider.compareLabs(a, b)); const result: (c.ClabFolderTreeNode | c.ClabLabTreeNode)[] = [...labNodes, ...folderNodes]; const isEmpty = result.length === 0 && dirPath === workspaceRoot; @@ -236,7 +237,7 @@ export class LocalLabTreeDataProvider implements vscode.TreeDataProvider< const filter = FilterUtils.createFilter(this.treeFilter); for (const [p, node] of Object.entries(labs)) { const rel = path.relative(workspaceRoot, p); - const lbl = String(node.label); + const lbl = node.label; if (!filter(lbl) && !filter(rel)) { delete labs[p]; } @@ -272,7 +273,7 @@ export class LocalLabTreeDataProvider implements vscode.TreeDataProvider< return { labNodes, folderNodes }; } - private compareLabs(a: c.ClabLabTreeNode, b: c.ClabLabTreeNode): number { + private static compareLabs(a: c.ClabLabTreeNode, b: c.ClabLabTreeNode): number { if (a.favorite && !b.favorite) { return -1; } diff --git a/src/treeView/runningLabsProvider.ts b/src/treeView/runningLabsProvider.ts index 4b90fffdf..68e5bd587 100644 --- a/src/treeView/runningLabsProvider.ts +++ b/src/treeView/runningLabsProvider.ts @@ -1,4 +1,4 @@ -import path = require("path"); +import * as path from "path"; import * as vscode from "vscode"; @@ -62,6 +62,59 @@ function hasNonEmptyString(value: string | undefined): value is string { return value !== undefined && value !== ""; } +function isTreeItemLabel(value: unknown): value is vscode.TreeItemLabel { + if (typeof value !== "object" || value === null) { + return false; + } + return typeof Reflect.get(value, "label") === "string"; +} + +function getTreeItemLabelText(label: vscode.TreeItemLabel | string | undefined): string { + if (typeof label === "string") { + return label; + } + if (label !== undefined && typeof label.label === "string") { + return label.label; + } + return ""; +} + +function getTooltipText(tooltip: vscode.TreeItem["tooltip"]): string { + if (typeof tooltip === "string") { + return tooltip; + } + if (tooltip instanceof vscode.MarkdownString) { + return tooltip.value; + } + return ""; +} + +function toComparableText(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + if (value === undefined || value === null) { + return ""; + } + if (isTreeItemLabel(value)) { + return value.label; + } + if (value instanceof vscode.MarkdownString) { + return value.value; + } + try { + if (typeof value === "function" || typeof value === "symbol") { + return ""; + } + return JSON.stringify(value); + } catch { + return ""; + } +} + function isLinkTreeNode(item: vscode.TreeItem): item is LinkTreeNode { return "link" in item && typeof item.link === "string"; } @@ -283,19 +336,23 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider< lab: c.ClabLabTreeNode, filter: ReturnType ): boolean { - if (filter(String(lab.label))) return true; + if (filter(lab.label)) return true; const containers = lab.containers ?? []; return containers.some((cn) => { if (cn instanceof c.ClabContainerGroupTreeNode) { return ( - filter(String(cn.label)) || + filter(getTreeItemLabelText(cn.label)) || cn.children.some( (child) => - filter(String(child.label)) || child.interfaces.some((it) => filter(String(it.label))) + filter(getTreeItemLabelText(child.label)) || + child.interfaces.some((it) => filter(getTreeItemLabelText(it.label))) ) ); } - return filter(String(cn.label)) || cn.interfaces.some((it) => filter(String(it.label))); + return ( + filter(getTreeItemLabelText(cn.label)) || + cn.interfaces.some((it) => filter(getTreeItemLabelText(it.label))) + ); }); } @@ -311,7 +368,7 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider< if (!this.treeFilter) return containers; const filter = FilterUtils.createFilter(this.treeFilter); - const labMatch = filter(String(element.label)); + const labMatch = filter(element.label); if (labMatch) return containers; return containers.filter((cn) => { @@ -330,7 +387,7 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider< group: c.ClabContainerGroupTreeNode, filter: ReturnType ): boolean { - if (filter(String(group.label))) return true; + if (filter(getTreeItemLabelText(group.label))) return true; return group.children.some((cn) => this.containerMatchesFilter(cn, filter)); } @@ -338,17 +395,19 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider< cn: c.ClabContainerTreeNode, filter: ReturnType ): boolean { - if (filter(String(cn.label))) return true; // Keep entire container with all interfaces + if (filter(getTreeItemLabelText(cn.label))) return true; // Keep entire container with all interfaces const ifaces = cn.interfaces; - return ifaces.some((it) => filter(String(it.label))); + return ifaces.some((it) => filter(getTreeItemLabelText(it.label))); } private getContainerChildren(element: c.ClabContainerTreeNode) { let interfaces = element.interfaces; if (!this.treeFilter) return interfaces; const filter = FilterUtils.createFilter(this.treeFilter); - const containerMatches = filter(String(element.label)); - return containerMatches ? interfaces : interfaces.filter((it) => filter(String(it.label))); + const containerMatches = filter(getTreeItemLabelText(element.label)); + return containerMatches + ? interfaces + : interfaces.filter((it) => filter(getTreeItemLabelText(it.label))); } private async discoverLabs(): Promise { @@ -459,7 +518,7 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider< let branchStructureChanged = false; const containersToRefresh: Set = new Set(); - if (String(target.label) !== String(source.label)) { + if (target.label !== source.label) { target.label = source.label; labChanged = true; } @@ -702,7 +761,7 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider< // Always update tooltip (VS Code fetches it lazily on hover) // but don't mark as changed to avoid dismissing visible tooltips - if (String(target.tooltip ?? "") !== String(source.tooltip ?? "")) { + if (getTooltipText(target.tooltip) !== getTooltipText(source.tooltip)) { target.tooltip = source.tooltip; } @@ -792,7 +851,7 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider< // Always update tooltip (VS Code fetches it lazily on hover) // but don't mark as changed to avoid dismissing visible tooltips - if (String(target.tooltip ?? "") !== String(source.tooltip ?? "")) { + if (getTooltipText(target.tooltip) !== getTooltipText(source.tooltip)) { target.tooltip = source.tooltip; } @@ -815,7 +874,7 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider< } private getNodeKey(node: { name?: string; label?: vscode.TreeItemLabel | string }): string { - return node.name ?? String(node.label ?? ""); + return node.name ?? getTreeItemLabelText(node.label); } private areObjectValuesEqual(a: T | undefined, b: T | undefined): boolean { @@ -862,7 +921,7 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider< ): boolean { const current = Reflect.get(target, key) as unknown; const equal = compareAsString - ? String(current ?? "") === String(value ?? "") + ? toComparableText(current) === toComparableText(value) : current === value; if (equal) { return false; @@ -879,8 +938,8 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider< if (!(a instanceof vscode.ThemeIcon) || !(b instanceof vscode.ThemeIcon)) { return false; } - const colorA = a.color?.id ?? a.color?.toString(); - const colorB = b.color?.id ?? b.color?.toString(); + const colorA = a.color?.id; + const colorB = b.color?.id; return a.id === b.id && colorA === colorB; } @@ -903,7 +962,7 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider< if (!a && !b) return true; if (!a || !b) return false; - if (String(a.label) !== String(b.label)) return false; + if (getTreeItemLabelText(a.label) !== getTreeItemLabelText(b.label)) return false; if (a.tooltip !== b.tooltip) return false; if (a.collapsibleState !== b.collapsibleState) return false; if (!this.iconsEqual(a.iconPath, b.iconPath)) return false; @@ -1412,7 +1471,9 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider< result.push(groupNode); } - return result.sort((a, b) => String(a.label).localeCompare(String(b.label))); + return result.sort((a, b) => + getTreeItemLabelText(a.label).localeCompare(getTreeItemLabelText(b.label)) + ); } private discoverContainerInterfaces( diff --git a/src/utils/docker/docker.ts b/src/utils/docker/docker.ts index 5fc233a94..6cc897e07 100644 --- a/src/utils/docker/docker.ts +++ b/src/utils/docker/docker.ts @@ -5,7 +5,7 @@ import { dockerClient, outputChannel } from "../../globals"; import { ContainerAction, ImagePullPolicy } from "../consts"; // Internal helper to pull the docker image using dockerode client -async function pullDockerImage(image: string): Promise { +export async function pullDockerImage(image: string): Promise { return vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, diff --git a/src/utils/docker/images.ts b/src/utils/docker/images.ts index 20440ce50..29c9b6557 100644 --- a/src/utils/docker/images.ts +++ b/src/utils/docker/images.ts @@ -12,24 +12,59 @@ export function getDockerImages(): string[] { return [...dockerImagesCache]; } +export interface DockerImageSummary { + id: string; + shortId?: string; + repoTags: string[]; + repoDigests: string[]; + created?: number; + size?: number; + virtualSize?: number; +} + +function validDockerReference(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + !value.endsWith(":") && + !value.startsWith("") && + !value.includes("@") + ); +} + +export async function listDockerImageSummaries(): Promise { + const images = await dockerClient.listImages({ all: true }); + return images + .map((img) => { + const id = typeof img.Id === "string" ? img.Id : ""; + const shortId = id.replace(/^sha256:/, "").slice(0, 12); + return { + id, + shortId, + repoTags: (Array.isArray(img.RepoTags) ? img.RepoTags : []).filter(validDockerReference), + repoDigests: (Array.isArray(img.RepoDigests) ? img.RepoDigests : []).filter( + validDockerReference + ), + created: typeof img.Created === "number" ? img.Created : undefined, + size: typeof img.Size === "number" ? img.Size : undefined, + virtualSize: typeof img.VirtualSize === "number" ? img.VirtualSize : undefined + }; + }) + .sort((a, b) => (b.created ?? 0) - (a.created ?? 0)); +} + // Internal func to fetch all docker images async function fetchDockerImages(): Promise { - const images = await dockerClient.listImages(); + const images = await listDockerImageSummaries(); type TagEntry = { tag: string; created: number }; const entries: TagEntry[] = []; const seen = new Set(); for (const img of images) { - const repoTags = Array.isArray(img.RepoTags) ? img.RepoTags : []; - for (const tag of repoTags) { - const isValid = - typeof tag === "string" && - tag.length > 0 && - !tag.endsWith(":") && - !tag.startsWith(""); - if (isValid && !seen.has(tag)) { + for (const tag of img.repoTags) { + if (!seen.has(tag)) { seen.add(tag); - entries.push({ tag, created: typeof img.Created === "number" ? img.Created : 0 }); + entries.push({ tag, created: img.created ?? 0 }); } } } @@ -61,6 +96,11 @@ export async function refreshDockerImages() { } } +export async function removeDockerImage(reference: string, force = false): Promise { + await dockerClient.getImage(reference).remove({ force }); + await refreshDockerImages(); +} + // Create disposable handle to let the image monitor get cleaned up by VSC. let monitorHandle: vscode.Disposable | undefined; diff --git a/src/webviews/explorer/containerlabExplorerView.webview.tsx b/src/webviews/explorer/containerlabExplorerView.webview.tsx deleted file mode 100644 index 46f5f5caa..000000000 --- a/src/webviews/explorer/containerlabExplorerView.webview.tsx +++ /dev/null @@ -1,2251 +0,0 @@ -import { - AccountTree as AccountTreeIcon, - ArticleOutlined as ArticleOutlinedIcon, - Build as BuildIcon, - ChevronRight as ChevronRightIcon, - ContentCopy as ContentCopyIcon, - DescriptionOutlined as DescriptionOutlinedIcon, - DeleteOutline as DeleteOutlineIcon, - DownloadOutlined as DownloadOutlinedIcon, - EditOutlined as EditOutlinedIcon, - ExpandMore as ExpandMoreIcon, - FilterAlt as FilterAltIcon, - FormatListBulleted as FormatListBulletedIcon, - Folder as FolderIcon, - FolderOpen as FolderOpenIcon, - ForumOutlined as ForumOutlinedIcon, - Link as LinkIcon, - LinkOff as LinkOffIcon, - ManageSearch as ManageSearchIcon, - MoreVert as MoreVertIcon, - NoteAdd as NoteAddIcon, - OpenInBrowser as OpenInBrowserIcon, - OpenInNew as OpenInNewIcon, - PlayArrow as PlayArrowIcon, - PlayCircleOutline as PlayCircleOutlineIcon, - PauseCircleOutline as PauseCircleOutlineIcon, - Refresh as RefreshIcon, - SaveOutlined as SaveOutlinedIcon, - Search as SearchIcon, - SettingsEthernet as SettingsEthernetIcon, - Source as SourceIcon, - Star as StarIcon, - StarBorder as StarBorderIcon, - Stop as StopIcon, - Terminal as TerminalIcon, - Tune as TuneIcon, - Visibility as VisibilityIcon, - VisibilityOff as VisibilityOffIcon, - type SvgIconComponent, - Dvr as DvrIcon -} from "@mui/icons-material"; -import { - Alert, - Box, - IconButton, - InputAdornment, - Paper, - Stack, - TextField, - Tooltip, - Typography -} from "@mui/material"; -import type { Theme } from "@mui/material/styles"; -import { createRoot } from "react-dom/client"; -import { - type Dispatch, - type DragEvent, - type MouseEvent, - type RefObject, - type SetStateAction, - useCallback, - useEffect, - useMemo, - useRef, - useState -} from "react"; - -import { MuiThemeProvider } from "../../reactTopoViewer/webview/theme"; -import { - ContextMenu, - type ContextMenuItem -} from "../../reactTopoViewer/webview/components/context-menu/ContextMenu"; -import { useMessageListener, usePostMessage, useReadySignal } from "../shared/hooks"; -import { - EXPLORER_SECTION_ORDER, - type ExplorerAction, - type ExplorerIncomingMessage, - type ExplorerNode, - type ExplorerSectionId, - type ExplorerSectionSnapshot, - type ExplorerUiState -} from "../shared/explorer/types"; - -import { resolveQuickActionsForNode } from "./quickActions"; - -const COLOR_ERROR_MAIN = "error.main"; -const COLOR_TEXT_PRIMARY = "text.primary"; -const COLOR_TEXT_SECONDARY = "text.secondary"; -const COLOR_TEXT_DISABLED = "text.disabled"; -const FILTER_UPDATE_DEBOUNCE_MS = 250; -const UI_STATE_UPDATE_DEBOUNCE_MS = 160; -const DEFAULT_EXPANDED_SECTIONS = new Set([ - "runningLabs", - "localLabs", - "helpFeedback" -]); -const TREE_DEPTH_INDENT = 1.6; -const TREE_DISCLOSURE_SLOT_PX = 14; -const TREE_ROW_GAP = 0.2; -const TREE_ROW_MIN_HEIGHT_PX = 20; -const NODE_MARKER_SLOT_PX = 14; -const RESIZE_DIVIDER_HEIGHT_PX = 4; -const MIN_SECTION_BODY_HEIGHT_PX = 40; -const FIXED_HEIGHT_SECTIONS: ReadonlySet = new Set(["helpFeedback"]); - -const STATUS_COLOR_MAP: Record = { - green: "success.main", - red: COLOR_ERROR_MAIN, - yellow: "warning.main", - blue: "info.main", - gray: COLOR_TEXT_DISABLED -}; - -const TOOLBAR_ICON_BUTTON_SX = { - width: 24, - height: 24, - borderRadius: 1, - color: COLOR_TEXT_PRIMARY, - "&:hover": { - bgcolor: (theme: Theme) => theme.alpha(theme.palette.primary.main, 0.14) - } -} as const; - -interface ExplorerNodeLabelProps { - node: ExplorerNode; - sectionId: ExplorerSectionId; - onInvokeAction: (action: ExplorerAction) => void; -} - -type ActionGroupId = - | "topology" - | "graph" - | "lifecycle" - | "save" - | "access" - | "sharing" - | "network" - | "inspect" - | "copy" - | "tools" - | "view" - | "danger" - | "other"; - -type ExplorerNodeKind = "lab" | "container" | "interface" | "link" | "other"; - -interface ExplorerActionGroup { - id: ActionGroupId; - label: string; - actions: ExplorerAction[]; -} - -type CommandMatcher = (command: string) => boolean; - -interface CommandIconRule { - match: CommandMatcher; - icon: SvgIconComponent; -} - -interface CommandActionGroupRule { - match: CommandMatcher; - group: ActionGroupId; -} - -type SharingBucket = "sshx" | "gotty" | "other"; - -const ACTION_GROUP_ORDER_DEFAULT: ActionGroupId[] = [ - "topology", - "graph", - "lifecycle", - "save", - "access", - "sharing", - "network", - "inspect", - "copy", - "tools", - "view", - "other", - "danger" -]; - -const ACTION_GROUP_ORDER_BY_NODE_KIND: Record = { - lab: [ - "lifecycle", - "save", - "topology", - "graph", - "access", - "sharing", - "inspect", - "tools", - "copy", - "view", - "network", - "other", - "danger" - ], - container: [ - "lifecycle", - "save", - "access", - "inspect", - "network", - "copy", - "sharing", - "tools", - "view", - "topology", - "graph", - "other" - ], - interface: ACTION_GROUP_ORDER_DEFAULT, - link: [ - "sharing", - "copy", - "view", - "topology", - "graph", - "lifecycle", - "save", - "network", - "inspect", - "tools", - "other", - "access" - ], - other: ACTION_GROUP_ORDER_DEFAULT -}; - -const ACTION_ICON_BY_COMMAND: Partial> = { - "containerlab.inspectall": ManageSearchIcon, - "containerlab.treeview.runninglabs.hidenonownedlabs": VisibilityOffIcon, - "containerlab.treeview.runninglabs.shownonownedlabs": VisibilityIcon, - "containerlab.editor.topoviewereditor": NoteAddIcon, - "containerlab.lab.openfile": EditOutlinedIcon, - "containerlab.lab.clonerepo": SourceIcon, - "containerlab.lab.togglefavorite": StarBorderIcon, - "containerlab.lab.addtoworkspace": FolderOpenIcon, - "containerlab.lab.save": SaveOutlinedIcon, - "containerlab.node.save": SaveOutlinedIcon, - "containerlab.node.attachshell": DvrIcon, - "containerlab.node.ssh": TerminalIcon, - "containerlab.node.showlogs": FormatListBulletedIcon, - "containerlab.node.stop": StopIcon, - "containerlab.node.pause": PauseCircleOutlineIcon, - "containerlab.node.unpause": PlayCircleOutlineIcon, - "containerlab.interface.setdelay": TuneIcon, - "containerlab.interface.setjitter": TuneIcon, - "containerlab.interface.setloss": TuneIcon, - "containerlab.interface.setrate": TuneIcon, - "containerlab.interface.setcorruption": TuneIcon, - "containerlab.lab.sshx.attach": LinkIcon, - "containerlab.lab.sshx.detach": LinkOffIcon, - "containerlab.lab.sshx.reattach": LinkIcon, - "containerlab.lab.sshx.copylink": LinkIcon, - "containerlab.lab.gotty.attach": OpenInBrowserIcon, - "containerlab.lab.gotty.detach": OpenInBrowserIcon, - "containerlab.lab.gotty.reattach": OpenInBrowserIcon, - "containerlab.lab.gotty.copylink": OpenInBrowserIcon -}; - -const ACTION_ICON_BY_THEME_ICON_ID: Partial> = { - "vm-connect": LinkIcon, - remote: TerminalIcon, - terminal: DvrIcon, - globe: OpenInBrowserIcon, - "open-preview": OpenInBrowserIcon, - "open-external": OpenInNewIcon, - "list-unordered": FormatListBulletedIcon, - pencil: EditOutlinedIcon, - "graph-line": AccountTreeIcon, - copy: ContentCopyIcon, - save: SaveOutlinedIcon, - trash: DeleteOutlineIcon, - star: StarIcon, - play: PlayArrowIcon, - refresh: RefreshIcon, - pause: PauseCircleOutlineIcon, - "debug-pause": PauseCircleOutlineIcon, - "debug-continue": PlayCircleOutlineIcon, - "repo-clone": SourceIcon -}; - -const ACTION_ICON_RULES: ReadonlyArray = [ - { match: (command) => command.includes("copy"), icon: ContentCopyIcon }, - { - match: (command) => - command.includes("destroy") || command.includes("delete") || command.includes("detach"), - icon: DeleteOutlineIcon - }, - { - match: (command) => command.includes("redeploy"), - icon: RefreshIcon - }, - { match: (command) => command.includes("stop"), icon: StopIcon }, - { match: (command) => command.includes("unpause"), icon: PlayCircleOutlineIcon }, - { match: (command) => command.includes("pause"), icon: PauseCircleOutlineIcon }, - { match: (command) => command.includes("ssh"), icon: TerminalIcon }, - { - match: (command) => command.includes("shell") || command.includes("telnet"), - icon: DvrIcon - }, - { match: (command) => command.includes("filter"), icon: FilterAltIcon }, - { match: (command) => command.includes(".save"), icon: SaveOutlinedIcon }, - { - match: (command) => command.includes("showlogs") || command.includes("logs"), - icon: FormatListBulletedIcon - }, - { match: (command) => command.startsWith("containerlab.lab.fcli."), icon: BuildIcon }, - { match: (command) => command.includes(".gotty."), icon: OpenInBrowserIcon }, - { match: (command) => command.startsWith("containerlab.lab.graph."), icon: AccountTreeIcon }, - { - match: (command) => - command.includes("open") || command.includes("graph") || command.includes("inspect"), - icon: OpenInNewIcon - }, - { match: (command) => command.includes("folder"), icon: FolderOpenIcon }, - { - match: (command) => command.includes("capture") || command.includes("impairment"), - icon: SettingsEthernetIcon - }, - { - match: (command) => - command.includes("delay") || - command.includes("jitter") || - command.includes("loss") || - command.includes("rate") || - command.includes("corruption"), - icon: TuneIcon - }, - { - match: (command) => - command.includes("deploy") || command.includes("start") || command.includes("run"), - icon: PlayArrowIcon - }, - { match: (command) => command.includes("link"), icon: LinkIcon } -]; - -const ACTION_GROUP_RULES: ReadonlyArray = [ - { match: (command) => command.startsWith("containerlab.lab.graph."), group: "graph" }, - { match: (command) => command.includes(".save"), group: "save" }, - { match: (command) => command.startsWith("containerlab.lab.fcli."), group: "tools" }, - { - match: (command) => - command.startsWith("containerlab.interface.") || command.includes("impairment"), - group: "network" - }, - { - match: (command) => command.includes(".sshx.") || command.includes(".gotty."), - group: "sharing" - }, - { match: (command) => command.includes("copy"), group: "copy" }, - { - match: (command) => command.includes("inspect") || command.includes("showlogs"), - group: "inspect" - }, - { - match: (command) => - command.includes("ssh") || - command.includes("shell") || - command.includes("telnet") || - command.includes("openbrowser"), - group: "access" - }, - { - match: (command) => - command.includes("deploy") || - command.includes("destroy") || - command.includes("redeploy") || - command.includes("start") || - command.includes("stop") || - command.includes("pause") || - command.includes("unpause"), - group: "lifecycle" - }, - { - match: (command) => - command.includes("openfile") || - command.includes("topoviewer") || - command.includes("openfolder") || - command.includes("addtoworkspace") || - command.includes("togglefavorite") || - command.includes("clonerepo"), - group: "topology" - }, - { - match: (command) => command.includes("delete"), - group: "danger" - }, - { - match: (command) => - command.includes("filter") || command.includes("hide") || command.includes("show"), - group: "view" - } -]; - -const ACTION_GROUP_SECTION_DEFAULT_BY_NODE_KIND: Record = { - lab: 4, - container: 3, - interface: 1, - link: 2, - other: 1 -}; - -const ACTION_GROUP_SECTION_BY_NODE_KIND: Partial< - Record>> -> = { - lab: { - lifecycle: 1, - save: 1, - topology: 2, - graph: 2, - access: 3, - sharing: 3, - inspect: 3, - tools: 3, - danger: 5 - }, - container: { - lifecycle: 1, - save: 1, - access: 2, - inspect: 2, - network: 2 - }, - link: { - sharing: 1, - copy: 1 - } -}; - -interface SectionTreeProps { - section: ExplorerSectionSnapshot; - expandedItems: string[]; - onExpandedItemsChange: (itemIds: string[]) => void; - onInvokeAction: (action: ExplorerAction) => void; -} - -interface SectionToolbarProps { - actions: ExplorerAction[]; - onInvokeAction: (action: ExplorerAction) => void; -} - -interface ExplorerSectionCardProps { - section: ExplorerSectionSnapshot; - expandedItems: string[]; - isCollapsed: boolean; - isDropTarget: boolean; - isBeingDragged: boolean; - flexStyle: string; - onSetSectionRef: (sectionId: ExplorerSectionId, element: HTMLDivElement | null) => void; - onSectionDragStart: (sectionId: ExplorerSectionId) => (event: DragEvent) => void; - onSectionDragOver: (sectionId: ExplorerSectionId) => (event: DragEvent) => void; - onSectionDrop: (sectionId: ExplorerSectionId) => (event: DragEvent) => void; - onSectionDragEnd: () => void; - onToggleSectionCollapsed: (sectionId: ExplorerSectionId) => void; - onInvokeAction: (action: ExplorerAction) => void; - onExpandedItemsChange: (sectionId: ExplorerSectionId, itemIds: string[]) => void; - onExpandAllInSection: (sectionId: ExplorerSectionId, nodes: ExplorerNode[]) => void; - onCollapseAllInSection: (sectionId: ExplorerSectionId) => void; -} - -type SnapshotExplorerMessage = Extract; -type FilterStateExplorerMessage = Extract; -type UiStateExplorerMessage = Extract; -type ErrorExplorerMessage = Extract; - -function statusColor(indicator: string | undefined): string { - if (indicator === undefined || indicator.length === 0) { - return COLOR_TEXT_DISABLED; - } - return STATUS_COLOR_MAP[indicator] || COLOR_TEXT_DISABLED; -} - -function formatSectionTitle(section: ExplorerSectionSnapshot): string { - if (section.id === "helpFeedback") { - return section.label; - } - return `${section.label} (${section.count})`; -} - -function flattenNodeIds(nodes: ExplorerNode[]): string[] { - const ids: string[] = []; - for (const node of nodes) { - ids.push(node.id); - ids.push(...flattenNodeIds(node.children)); - } - return ids; -} - -function flattenExpandableNodeIds(nodes: ExplorerNode[]): string[] { - const ids: string[] = []; - for (const node of nodes) { - if (node.children.length > 0) { - ids.push(node.id); - ids.push(...flattenExpandableNodeIds(node.children)); - } - } - return ids; -} - -function mergeSectionOrder( - currentOrder: ExplorerSectionId[], - sections: ExplorerSectionSnapshot[] -): ExplorerSectionId[] { - const visibleIds = sections.map((section) => section.id); - const visibleSet = new Set(visibleIds); - - const nextOrder = currentOrder.filter((id) => visibleSet.has(id)); - for (const sectionId of visibleIds) { - if (!nextOrder.includes(sectionId)) { - nextOrder.push(sectionId); - } - } - - return nextOrder; -} - -function reorderSections( - currentOrder: ExplorerSectionId[], - sourceId: ExplorerSectionId, - targetId: ExplorerSectionId -): ExplorerSectionId[] { - if (sourceId === targetId) { - return currentOrder; - } - - const nextOrder = currentOrder.filter((sectionId) => sectionId !== sourceId); - const targetIndex = nextOrder.indexOf(targetId); - if (targetIndex < 0) { - return currentOrder; - } - - nextOrder.splice(targetIndex, 0, sourceId); - return nextOrder; -} - -function isExplorerSectionId(value: string): value is ExplorerSectionId { - return (EXPLORER_SECTION_ORDER as string[]).includes(value); -} - -function nodeKindFromContext(contextValue: string | undefined): ExplorerNodeKind { - if (contextValue === undefined || contextValue.length === 0) { - return "other"; - } - if (contextValue.includes("containerlabLab")) { - return "lab"; - } - if (contextValue === "containerlabContainer" || contextValue === "containerlabContainerGroup") { - return "container"; - } - if (contextValue === "containerlabInterfaceUp" || contextValue === "containerlabInterfaceDown") { - return "interface"; - } - if (contextValue === "containerlabSSHXLink" || contextValue === "containerlabGottyLink") { - return "link"; - } - return "other"; -} - -function isFavoriteLabNode(contextValue: string | undefined): boolean { - return ( - typeof contextValue === "string" && - contextValue.includes("containerlabLab") && - contextValue.includes("Favorite") - ); -} - -function isSharedLabNode(node: ExplorerNode): boolean { - return Boolean(node.shareAction); -} - -function actionIcon(action: ExplorerAction): SvgIconComponent { - const iconId = action.iconId?.toLowerCase(); - if (iconId !== undefined && iconId.length > 0) { - const iconFromThemeIcon = ACTION_ICON_BY_THEME_ICON_ID[iconId]; - if (iconFromThemeIcon !== undefined) { - return iconFromThemeIcon; - } - } - - const command = action.commandId.toLowerCase(); - const commandIcon = ACTION_ICON_BY_COMMAND[command]; - if (commandIcon !== undefined) { - return commandIcon; - } - - for (const rule of ACTION_ICON_RULES) { - if (rule.match(command)) { - return rule.icon; - } - } - - return BuildIcon; -} - -function actionGroupId(action: ExplorerAction): ActionGroupId { - const command = action.commandId.toLowerCase(); - - for (const rule of ACTION_GROUP_RULES) { - if (rule.match(command)) { - return rule.group; - } - } - - return "other"; -} - -function actionGroupLabel(groupId: ActionGroupId): string { - const labels: Record = { - topology: "Topology", - graph: "Graph", - lifecycle: "Lifecycle", - save: "Save", - access: "Access", - sharing: "Sharing", - network: "Network", - inspect: "Inspect", - copy: "Copy", - tools: "Tools", - view: "View", - danger: "Danger", - other: "Other" - }; - return labels[groupId]; -} - -function actionGroupIcon(groupId: ActionGroupId): SvgIconComponent { - const icons: Record = { - topology: FolderOpenIcon, - graph: AccountTreeIcon, - lifecycle: PlayArrowIcon, - save: SaveOutlinedIcon, - access: TerminalIcon, - sharing: LinkIcon, - network: SettingsEthernetIcon, - inspect: ManageSearchIcon, - copy: ContentCopyIcon, - tools: BuildIcon, - view: FilterAltIcon, - danger: DeleteOutlineIcon, - other: BuildIcon - }; - return icons[groupId]; -} - -function sortGroupActions(groupId: ActionGroupId, actions: ExplorerAction[]): ExplorerAction[] { - if (groupId !== "graph") { - return actions; - } - - const graphCommandOrder = new Map([ - ["containerlab.lab.graph.topoviewer", 1], - ["containerlab.lab.graph.drawio.interactive", 2], - ["containerlab.lab.graph.drawio.horizontal", 3], - ["containerlab.lab.graph.drawio.vertical", 4] - ]); - - return [...actions].sort((a, b) => { - const aOrder = graphCommandOrder.get(a.commandId.toLowerCase()) ?? Number.MAX_SAFE_INTEGER; - const bOrder = graphCommandOrder.get(b.commandId.toLowerCase()) ?? Number.MAX_SAFE_INTEGER; - if (aOrder !== bOrder) { - return aOrder - bOrder; - } - return a.label.localeCompare(b.label); - }); -} - -function groupActions( - actions: ExplorerAction[], - nodeKind: ExplorerNodeKind -): ExplorerActionGroup[] { - const grouped = new Map(); - const order = ACTION_GROUP_ORDER_BY_NODE_KIND[nodeKind] ?? ACTION_GROUP_ORDER_DEFAULT; - - for (const action of actions) { - const groupId = actionGroupId(action); - const bucket = grouped.get(groupId) ?? []; - bucket.push(action); - grouped.set(groupId, bucket); - } - - return order - .map((groupId) => ({ - id: groupId, - label: actionGroupLabel(groupId), - actions: sortGroupActions(groupId, grouped.get(groupId) ?? []) - })) - .filter((group) => group.actions.length > 0); -} - -function isInterfaceTimingAction(commandId: string): boolean { - return ( - commandId === "containerlab.interface.setdelay" || - commandId === "containerlab.interface.setjitter" || - commandId === "containerlab.interface.setloss" || - commandId === "containerlab.interface.setrate" || - commandId === "containerlab.interface.setcorruption" - ); -} - -function actionGroupSection(groupId: ActionGroupId, nodeKind: ExplorerNodeKind): number { - const nodeKindSections = ACTION_GROUP_SECTION_BY_NODE_KIND[nodeKind]; - const section = nodeKindSections?.[groupId]; - if (section !== undefined) { - return section; - } - return ACTION_GROUP_SECTION_DEFAULT_BY_NODE_KIND[nodeKind] ?? 1; -} - -function withSectionDividers( - groups: ExplorerActionGroup[], - nodeKind: ExplorerNodeKind, - renderGroup: (group: ExplorerActionGroup) => ContextMenuItem[] -): ContextMenuItem[] { - if (groups.length === 0) { - return []; - } - - const items: ContextMenuItem[] = []; - let previousSection: number | null = null; - for (const group of groups) { - const section = actionGroupSection(group.id, nodeKind); - const rendered = renderGroup(group); - if (rendered.length === 0) { - continue; - } - if (items.length > 0 && previousSection !== null && section !== previousSection) { - items.push({ - id: `divider:${nodeKind}:${group.id}:${items.length}`, - label: "", - divider: true - }); - } - items.push(...rendered); - previousSection = section; - } - return items; -} - -function isHelpFeedbackLinkNode(node: ExplorerNode, sectionId: ExplorerSectionId): boolean { - if (sectionId !== "helpFeedback") { - return false; - } - return node.primaryAction?.commandId.toLowerCase() === "containerlab.openlink"; -} - -function helpFeedbackIconForNode(node: ExplorerNode): SvgIconComponent { - const label = node.label.toLowerCase(); - if (label.includes("discord")) { - return ForumOutlinedIcon; - } - if (label.includes("github")) { - return SourceIcon; - } - if (label.includes("download")) { - return DownloadOutlinedIcon; - } - if (label.includes("find")) { - return SearchIcon; - } - if (label.includes("extension")) { - return ArticleOutlinedIcon; - } - return DescriptionOutlinedIcon; -} - -function nodeLeadingIcon( - node: ExplorerNode, - sectionId: ExplorerSectionId -): { Icon: SvgIconComponent; color: string } | undefined { - if (isHelpFeedbackLinkNode(node, sectionId)) { - return { Icon: helpFeedbackIconForNode(node), color: COLOR_TEXT_SECONDARY }; - } - - const context = node.contextValue; - if (context === "containerlabInterfaceUp") { - return { Icon: SettingsEthernetIcon, color: "success.main" }; - } - if (context === "containerlabInterfaceDown") { - return { Icon: LinkOffIcon, color: "error.main" }; - } - if (context === "containerlabFolder") { - return { Icon: FolderIcon, color: COLOR_TEXT_SECONDARY }; - } - if (typeof context === "string" && context.includes("containerlabLabUndeployed")) { - return { Icon: DescriptionOutlinedIcon, color: COLOR_TEXT_SECONDARY }; - } - return undefined; -} - -function toContextMenuItem( - action: ExplorerAction, - onInvokeAction: (action: ExplorerAction) => void -): ContextMenuItem { - const ActionIcon = actionIcon(action); - return { - id: action.id, - label: action.label, - icon: , - danger: Boolean(action.destructive), - onClick: () => onInvokeAction(action) - }; -} - -function sharingBucketForCommand(commandId: string): SharingBucket { - const command = commandId.toLowerCase(); - if (command.includes(".sshx.")) { - return "sshx"; - } - if (command.includes(".gotty.")) { - return "gotty"; - } - return "other"; -} - -function buildSharingGroupChildren( - actions: ExplorerAction[], - groupId: ActionGroupId, - onInvokeAction: (action: ExplorerAction) => void -): ContextMenuItem[] { - const sharingChildren: ContextMenuItem[] = []; - let previousBucket: SharingBucket | null = null; - - for (const action of actions) { - const bucket = sharingBucketForCommand(action.commandId); - if (sharingChildren.length > 0 && previousBucket !== null && bucket !== previousBucket) { - sharingChildren.push({ - id: `group:${groupId}:divider:${action.id}`, - label: "", - divider: true - }); - } - sharingChildren.push(toContextMenuItem(action, onInvokeAction)); - previousBucket = bucket; - } - - return sharingChildren; -} - -function toGroupMenuItem( - group: ExplorerActionGroup, - onInvokeAction: (action: ExplorerAction) => void -): ContextMenuItem { - if (group.actions.length === 1) { - return toContextMenuItem(group.actions[0], onInvokeAction); - } - - const GroupIcon = actionGroupIcon(group.id); - const children = - group.id === "sharing" - ? buildSharingGroupChildren(group.actions, group.id, onInvokeAction) - : group.actions.map((action) => toContextMenuItem(action, onInvokeAction)); - - return { - id: `group:${group.id}`, - label: group.label, - icon: , - children - }; -} - -function toGroupMenuItems( - group: ExplorerActionGroup, - onInvokeAction: (action: ExplorerAction) => void -): ContextMenuItem[] { - if (group.id === "lifecycle") { - return group.actions.map((action) => toContextMenuItem(action, onInvokeAction)); - } - return [toGroupMenuItem(group, onInvokeAction)]; -} - -function buildInterfaceMenuItems( - actions: ExplorerAction[], - onInvokeAction: (action: ExplorerAction) => void -): ContextMenuItem[] { - const interfaceItems: ContextMenuItem[] = []; - let inTimingGroup = false; - for (const action of actions) { - const commandId = action.commandId.toLowerCase(); - const isTimingAction = isInterfaceTimingAction(commandId); - - if (isTimingAction && !inTimingGroup && interfaceItems.length > 0) { - interfaceItems.push({ - id: `group:interface:timing-start:${action.id}`, - label: "", - divider: true - }); - } - if (!isTimingAction && inTimingGroup) { - interfaceItems.push({ - id: `group:interface:timing-end:${action.id}`, - label: "", - divider: true - }); - } - - interfaceItems.push(toContextMenuItem(action, onInvokeAction)); - inTimingGroup = isTimingAction; - } - - return interfaceItems; -} - -function buildNodeContextMenuItems( - menuActions: ExplorerAction[], - nodeKind: ExplorerNodeKind, - onInvokeAction: (action: ExplorerAction) => void -): ContextMenuItem[] { - if (nodeKind === "interface") { - return buildInterfaceMenuItems(menuActions, onInvokeAction); - } - - const groupedActions = groupActions(menuActions, nodeKind); - return withSectionDividers(groupedActions, nodeKind, (group) => - toGroupMenuItems(group, onInvokeAction) - ); -} - -function filterNodeMenuActions( - nodeActions: ExplorerAction[], - nodeKind: ExplorerNodeKind -): ExplorerAction[] { - if (nodeKind !== "lab") { - return nodeActions; - } - return nodeActions.filter( - (action) => action.commandId.toLowerCase() !== "containerlab.lab.graph.topoviewer" - ); -} - -function useExplorerNodeMenu(params: { hasActions: boolean; hasContextMenuItems: boolean }) { - const { hasActions, hasContextMenuItems } = params; - const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null); - const [menuOpenToLeft, setMenuOpenToLeft] = useState(false); - - const openMenuFromElement = useCallback((element: HTMLElement, openToLeft = true) => { - const rect = element.getBoundingClientRect(); - setMenuOpenToLeft(openToLeft); - setMenuPosition({ x: Math.round(rect.right), y: Math.round(rect.bottom + 2) }); - }, []); - - const handleMenuOpen = useCallback( - (event: MouseEvent) => { - if (!hasActions) { - return; - } - event.preventDefault(); - event.stopPropagation(); - openMenuFromElement(event.currentTarget, true); - }, - [hasActions, openMenuFromElement] - ); - - const handleRowContextMenu = useCallback( - (event: MouseEvent) => { - if (!hasActions) { - return; - } - event.preventDefault(); - event.stopPropagation(); - const trigger = event.currentTarget.querySelector( - '[data-node-actions-trigger="true"]' - ); - openMenuFromElement(trigger ?? event.currentTarget, true); - }, - [hasActions, openMenuFromElement] - ); - - const handleMenuClose = useCallback(() => { - setMenuOpenToLeft(false); - setMenuPosition(null); - }, []); - - const handleBackdropContextMenu = useCallback( - (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - const relayTarget = document - .elementsFromPoint(event.clientX, event.clientY) - .map((element) => element.closest('[data-explorer-node-row="true"]')) - .find((element): element is HTMLElement => Boolean(element)); - if (!relayTarget) { - return; - } - - handleMenuClose(); - relayTarget.dispatchEvent( - new window.MouseEvent("contextmenu", { - bubbles: true, - cancelable: true, - clientX: event.clientX, - clientY: event.clientY, - button: 2, - buttons: 2 - }) - ); - }, - [handleMenuClose] - ); - - const menuOpen = Boolean(menuPosition) && hasContextMenuItems; - - return { - menuPosition, - menuOpenToLeft, - menuOpen, - handleMenuOpen, - handleRowContextMenu, - handleMenuClose, - handleBackdropContextMenu - }; -} - -function usePrimaryActionHandler( - primaryAction: ExplorerNode["primaryAction"], - onInvokeAction: (action: ExplorerAction) => void -) { - return useCallback( - (event: MouseEvent) => { - if (!primaryAction) { - return; - } - event.preventDefault(); - event.stopPropagation(); - onInvokeAction(primaryAction); - }, - [primaryAction, onInvokeAction] - ); -} - -function useShareActionHandler( - shareAction: ExplorerNode["shareAction"], - onInvokeAction: (action: ExplorerAction) => void -) { - return useCallback( - (event: MouseEvent) => { - if (!shareAction) { - return; - } - event.preventDefault(); - event.stopPropagation(); - onInvokeAction(shareAction); - }, - [shareAction, onInvokeAction] - ); -} - -interface ExplorerNodeTextBlockProps { - node: ExplorerNode; - hasEntryTooltip: boolean; - leadingIcon: ReturnType; - showStatusDot: boolean; - showFavoriteIcon: boolean; - showSharedIcon: boolean; - inlineContainerStatus: string | undefined; - showSecondaryLine: boolean; - secondaryText: string | undefined; - handlePrimaryAction: (event: MouseEvent) => void; - handleShareAction: (event: MouseEvent) => void; -} - -function ExplorerNodeTextBlock({ - node, - hasEntryTooltip, - leadingIcon, - showStatusDot, - showFavoriteIcon, - showSharedIcon, - inlineContainerStatus, - showSecondaryLine, - secondaryText, - handlePrimaryAction, - handleShareAction -}: Readonly) { - return ( - - - - - {leadingIcon && ( - - )} - {!leadingIcon && showStatusDot && ( - - )} - - - {node.label} - - {showFavoriteIcon && ( - - - {showSecondaryLine && ( - - {secondaryText} - - )} - - ); -} - -interface ExplorerNodeActionsProps { - hasActions: boolean; - quickActions: ExplorerAction[]; - node: ExplorerNode; - menuOpen: boolean; - menuPosition: { x: number; y: number } | null; - contextMenuItems: ContextMenuItem[]; - menuOpenToLeft: boolean; - handleMenuOpen: (event: MouseEvent) => void; - handleMenuClose: () => void; - handleBackdropContextMenu: (event: MouseEvent) => void; - onInvokeAction: (action: ExplorerAction) => void; -} - -function ExplorerNodeActions({ - hasActions, - quickActions, - node, - menuOpen, - menuPosition, - contextMenuItems, - menuOpenToLeft, - handleMenuOpen, - handleMenuClose, - handleBackdropContextMenu, - onInvokeAction -}: Readonly) { - if (!hasActions && quickActions.length === 0) { - return null; - } - - return ( - <> - - {quickActions.map((action) => { - const IconComponent = actionIcon(action); - return ( - - { - event.preventDefault(); - event.stopPropagation(); - onInvokeAction(action); - }} - > - - - - ); - })} - {hasActions && ( - - - - )} - - - - ); -} - -function ExplorerNodeLabel({ node, sectionId, onInvokeAction }: Readonly) { - const hasEntryTooltip = Boolean(node.tooltip); - const leadingIcon = nodeLeadingIcon(node, sectionId); - const nodeKind = nodeKindFromContext(node.contextValue); - const menuActions = useMemo( - () => filterNodeMenuActions(node.actions, nodeKind), - [node.actions, nodeKind] - ); - const quickActions = useMemo( - () => resolveQuickActionsForNode(node.contextValue, menuActions), - [node.contextValue, menuActions] - ); - const hasActions = menuActions.length > 0; - const contextMenuItems = useMemo( - () => buildNodeContextMenuItems(menuActions, nodeKind, onInvokeAction), - [menuActions, nodeKind, onInvokeAction] - ); - const secondaryText = node.description ?? node.statusDescription; - const isContainer = - node.contextValue === "containerlabContainer" || - node.contextValue === "containerlabContainerGroup"; - const isInterface = - node.contextValue === "containerlabInterfaceUp" || - node.contextValue === "containerlabInterfaceDown"; - const inlineContainerStatus = isContainer ? secondaryText?.trim() : undefined; - const showSecondaryLine = Boolean(secondaryText) && !isContainer && !isInterface; - const showStatusDot = Boolean(node.statusIndicator) && !isInterface; - const showFavoriteIcon = isFavoriteLabNode(node.contextValue); - const showSharedIcon = isSharedLabNode(node); - const { - menuPosition, - menuOpenToLeft, - menuOpen, - handleMenuOpen, - handleRowContextMenu, - handleMenuClose, - handleBackdropContextMenu - } = useExplorerNodeMenu({ - hasActions, - hasContextMenuItems: contextMenuItems.length > 0 - }); - const handlePrimaryAction = usePrimaryActionHandler(node.primaryAction, onInvokeAction); - const handleShareAction = useShareActionHandler(node.shareAction, onInvokeAction); - - return ( - theme.alpha(theme.palette.primary.main, 0.12) - }) - }} - > - - - - ); -} - -interface SectionTreeNodeProps { - node: ExplorerNode; - sectionId: ExplorerSectionId; - depth: number; - expandedItems: string[]; - onToggleExpanded: (nodeId: string) => void; - onInvokeAction: (action: ExplorerAction) => void; -} - -function SectionTreeNode({ - node, - sectionId, - depth, - expandedItems, - onToggleExpanded, - onInvokeAction -}: Readonly) { - const hasChildren = node.children.length > 0; - const isExpanded = expandedItems.includes(node.id); - - return ( - - - - {hasChildren && ( - { - event.preventDefault(); - event.stopPropagation(); - onToggleExpanded(node.id); - }} - aria-label={isExpanded ? `Collapse ${node.label}` : `Expand ${node.label}`} - > - {isExpanded ? ( - - ) : ( - - )} - - )} - - - - - - - - {hasChildren && isExpanded && ( - - {node.children.map((child) => ( - - ))} - - )} - - ); -} - -function SectionTree({ - section, - expandedItems, - onExpandedItemsChange, - onInvokeAction -}: Readonly) { - const toggleExpanded = useCallback( - (nodeId: string) => { - if (expandedItems.includes(nodeId)) { - onExpandedItemsChange(expandedItems.filter((id) => id !== nodeId)); - return; - } - onExpandedItemsChange([...expandedItems, nodeId]); - }, - [expandedItems, onExpandedItemsChange] - ); - - if (section.nodes.length === 0) { - return ( - - No items found. - - ); - } - - return ( - - {section.nodes.map((node) => ( - - ))} - - ); -} - -function SectionToolbarActions({ actions, onInvokeAction }: Readonly) { - return ( - - {actions.map((action) => { - const IconComponent = actionIcon(action); - return ( - - { - event.preventDefault(); - event.stopPropagation(); - onInvokeAction(action); - }} - > - - - - ); - })} - - ); -} - -interface ResizeDividerProps { - aboveId: ExplorerSectionId; - belowId: ExplorerSectionId; - onResizeStart: (aboveId: ExplorerSectionId, belowId: ExplorerSectionId, startY: number) => void; -} - -function ResizeDivider({ aboveId, belowId, onResizeStart }: Readonly) { - return ( - { - e.preventDefault(); - onResizeStart(aboveId, belowId, e.clientY); - }} - sx={{ - height: RESIZE_DIVIDER_HEIGHT_PX, - flex: `0 0 ${RESIZE_DIVIDER_HEIGHT_PX}px`, - cursor: "row-resize", - display: "flex", - alignItems: "center", - justifyContent: "center", - "&:hover": { - bgcolor: (theme: Theme) => theme.alpha(theme.palette.primary.main, 0.18) - } - }} - /> - ); -} - -function usePaneResize( - containerRef: RefObject, - heightRatioBySection: Partial>, - setHeightRatioBySection: Dispatch>>>, - collapsedBySection: Partial>, - orderedSectionIds: ExplorerSectionId[] -) { - const [isResizing, setIsResizing] = useState(false); - const isResizingRef = useRef(false); - - const handleResizeStart = useCallback( - (aboveId: ExplorerSectionId, belowId: ExplorerSectionId, startY: number) => { - const container = containerRef.current; - if (!container) return; - - isResizingRef.current = true; - setIsResizing(true); - - const expandedIds = orderedSectionIds.filter( - (id) => collapsedBySection[id] !== true && !FIXED_HEIGHT_SECTIONS.has(id) - ); - const headerHeight = 28; - const dividerCount = Math.max(0, expandedIds.length - 1); - const containerHeight = container.clientHeight; - const availableBody = - containerHeight - - expandedIds.length * headerHeight - - dividerCount * RESIZE_DIVIDER_HEIGHT_PX; - - const initialAboveRatio = heightRatioBySection[aboveId] ?? 1 / expandedIds.length; - const initialBelowRatio = heightRatioBySection[belowId] ?? 1 / expandedIds.length; - const combinedRatio = initialAboveRatio + initialBelowRatio; - - const onMouseMove = (ev: globalThis.MouseEvent) => { - if (!isResizingRef.current) return; - - const deltaY = ev.clientY - startY; - const ratioDelta = availableBody > 0 ? deltaY / availableBody : 0; - - const minRatio = availableBody > 0 ? MIN_SECTION_BODY_HEIGHT_PX / availableBody : 0; - let newAboveRatio = initialAboveRatio + ratioDelta; - let newBelowRatio = initialBelowRatio - ratioDelta; - - if (newAboveRatio < minRatio) { - newAboveRatio = minRatio; - newBelowRatio = combinedRatio - minRatio; - } - if (newBelowRatio < minRatio) { - newBelowRatio = minRatio; - newAboveRatio = combinedRatio - minRatio; - } - - setHeightRatioBySection((current) => ({ - ...current, - [aboveId]: newAboveRatio, - [belowId]: newBelowRatio - })); - }; - - const onMouseUp = () => { - isResizingRef.current = false; - setIsResizing(false); - document.removeEventListener("mousemove", onMouseMove); - document.removeEventListener("mouseup", onMouseUp); - }; - - document.addEventListener("mousemove", onMouseMove); - document.addEventListener("mouseup", onMouseUp); - }, - [ - containerRef, - heightRatioBySection, - setHeightRatioBySection, - collapsedBySection, - orderedSectionIds - ] - ); - - return { isResizing, handleResizeStart }; -} - -function normalizeHeightRatios( - currentRatios: Partial>, - expandedIds: ExplorerSectionId[] -): Partial> { - const n = expandedIds.length; - if (n === 0) return currentRatios; - - const nextRatios: Partial> = { ...currentRatios }; - for (const id of expandedIds) { - if (nextRatios[id] === undefined || nextRatios[id] === 0) { - nextRatios[id] = 1 / n; - } - } - const total = expandedIds.reduce((sum, id) => sum + (nextRatios[id] ?? 0), 0); - if (total > 0) { - for (const id of expandedIds) { - nextRatios[id] = (nextRatios[id] ?? 0) / total; - } - } - return nextRatios; -} - -function getSectionPaperSx(isDropTarget: boolean, flexStyle: string) { - return { - flex: flexStyle, - minHeight: 0, - display: "flex", - flexDirection: "column" as const, - overflow: "hidden", - borderRadius: 0, - border: "none", - boxShadow: isDropTarget - ? (theme: Theme) => `inset 0 0 0 1px ${theme.alpha(theme.palette.primary.main, 0.35)}` - : "none" - }; -} - -function getSectionHeaderSx(_isCollapsed: boolean, isBeingDragged: boolean) { - return { - px: 0.75, - py: 0.2, - height: 28, - minHeight: 28, - maxHeight: 28, - display: "flex", - alignItems: "center", - gap: 0.35, - cursor: isBeingDragged ? "grabbing" : "grab", - userSelect: "none", - bgcolor: (theme: Theme) => - isBeingDragged - ? theme.alpha(theme.palette.primary.main, 0.1) - : theme.alpha(theme.palette.background.default, 0.55) - }; -} - -function ExplorerSectionCard({ - section, - expandedItems, - isCollapsed, - isDropTarget, - isBeingDragged, - flexStyle, - onSetSectionRef, - onSectionDragStart, - onSectionDragOver, - onSectionDrop, - onSectionDragEnd, - onToggleSectionCollapsed, - onInvokeAction, - onExpandedItemsChange, - onExpandAllInSection, - onCollapseAllInSection -}: Readonly) { - const expandableIds = useMemo(() => flattenExpandableNodeIds(section.nodes), [section.nodes]); - - const allExpanded = useMemo(() => { - return expandableIds.length > 0 && expandableIds.every((id) => expandedItems.includes(id)); - }, [expandableIds, expandedItems]); - - const showExpandAllControl = section.id !== "helpFeedback" && expandableIds.length > 0; - - return ( - { - onSetSectionRef(section.id, element); - }} - sx={getSectionPaperSx(isDropTarget, flexStyle)} - onDragOver={onSectionDragOver(section.id)} - onDrop={onSectionDrop(section.id)} - > - - { - event.preventDefault(); - event.stopPropagation(); - onToggleSectionCollapsed(section.id); - }} - aria-label={isCollapsed ? `Expand ${section.label}` : `Collapse ${section.label}`} - sx={{ color: COLOR_TEXT_PRIMARY, p: 0.25 }} - > - {isCollapsed ? ( - - ) : ( - - )} - - - onToggleSectionCollapsed(section.id)} - sx={{ minWidth: 0, flex: 1, cursor: "pointer" }} - > - - {formatSectionTitle(section)} - - - - - - {showExpandAllControl && ( - - { - event.preventDefault(); - event.stopPropagation(); - if (allExpanded) { - onCollapseAllInSection(section.id); - } else { - onExpandAllInSection(section.id, section.nodes); - } - }} - > - {allExpanded ? ( - - ) : ( - - )} - - - )} - - - {!isCollapsed && ( - - onExpandedItemsChange(section.id, itemIds)} - onInvokeAction={onInvokeAction} - /> - - )} - - ); -} - -const EXPLORER_WEBVIEW_KIND = "containerlab-explorer"; - -export function ContainerlabExplorerView() { - const postMessage = usePostMessage(); - const [sections, setSections] = useState([]); - const [sectionOrder, setSectionOrder] = useState(EXPLORER_SECTION_ORDER); - const [collapsedBySection, setCollapsedBySection] = useState< - Partial> - >({}); - const [expandedBySection, setExpandedBySection] = useState< - Partial> - >({ - runningLabs: [], - localLabs: [] - }); - const [filterText, setFilterText] = useState(""); - const [errorMessage, setErrorMessage] = useState(null); - const [draggingSection, setDraggingSection] = useState(null); - const [dragOverSection, setDragOverSection] = useState(null); - const [heightRatioBySection, setHeightRatioBySection] = useState< - Partial> - >({}); - const [uiStateHydrated, setUiStateHydrated] = useState(false); - const paneContainerRef = useRef(null); - const sectionRefs = useRef>>({}); - const pendingFilterSyncRef = useRef(null); - const filterTimeoutRef = useRef(null); - const uiStateTimeoutRef = useRef(null); - const expandedBeforeFilterRef = useRef> | null>(null); - - useReadySignal(); - - const handleSnapshotMessage = useCallback((message: SnapshotExplorerMessage) => { - const pending = pendingFilterSyncRef.current; - if (pending !== null && message.filterText !== pending) { - return; - } - if (pending !== null && message.filterText === pending) { - pendingFilterSyncRef.current = null; - } - - const filterActive = message.filterText.length > 0; - setSections(message.sections); - setSectionOrder((currentOrder) => mergeSectionOrder(currentOrder, message.sections)); - setCollapsedBySection((current) => { - const next: Partial> = {}; - for (const section of message.sections) { - next[section.id] = current[section.id] ?? !DEFAULT_EXPANDED_SECTIONS.has(section.id); - } - if (filterActive) { - next.runningLabs = false; - next.localLabs = false; - } - return next; - }); - - setExpandedBySection((current) => { - if (filterActive) { - expandedBeforeFilterRef.current ??= current; - const next: Partial> = { ...current }; - for (const section of message.sections) { - if (section.id === "runningLabs" || section.id === "localLabs") { - next[section.id] = flattenExpandableNodeIds(section.nodes); - } - } - return next; - } - - if (expandedBeforeFilterRef.current) { - const restored = expandedBeforeFilterRef.current; - expandedBeforeFilterRef.current = null; - return restored; - } - - return current; - }); - - setFilterText(message.filterText); - }, []); - - const handleFilterStateMessage = useCallback((message: FilterStateExplorerMessage) => { - const pending = pendingFilterSyncRef.current; - if (pending !== null && message.filterText !== pending) { - return; - } - if (pending !== null && message.filterText === pending) { - pendingFilterSyncRef.current = null; - } - setFilterText(message.filterText); - }, []); - - const handleUiStateMessage = useCallback((message: UiStateExplorerMessage) => { - const state = message.state; - if (Array.isArray(state.sectionOrder) && state.sectionOrder.length > 0) { - setSectionOrder(state.sectionOrder.filter((id) => isExplorerSectionId(id))); - } - if (state.collapsedBySection) { - setCollapsedBySection(state.collapsedBySection); - } - if (state.expandedBySection) { - setExpandedBySection(state.expandedBySection); - } - if (state.heightRatioBySection) { - setHeightRatioBySection(state.heightRatioBySection); - } - setUiStateHydrated(true); - }, []); - - const handleErrorMessage = useCallback((message: ErrorExplorerMessage) => { - setErrorMessage(message.message); - }, []); - - useMessageListener( - useCallback( - (message) => { - switch (message.command) { - case "snapshot": - handleSnapshotMessage(message); - return; - case "filterState": - handleFilterStateMessage(message); - return; - case "uiState": - handleUiStateMessage(message); - return; - case "error": - handleErrorMessage(message); - return; - default: - break; - } - }, - [handleErrorMessage, handleFilterStateMessage, handleSnapshotMessage, handleUiStateMessage] - ) - ); - - const invokeAction = useCallback( - (action: ExplorerAction) => { - postMessage({ - command: "invokeAction", - actionRef: action.actionRef - }); - }, - [postMessage] - ); - - const handleFilterChange = useCallback( - (value: string) => { - setFilterText(value); - pendingFilterSyncRef.current = value.trim(); - - if (filterTimeoutRef.current !== null) { - window.clearTimeout(filterTimeoutRef.current); - filterTimeoutRef.current = null; - } - - if (value.trim().length === 0) { - postMessage({ command: "setFilter", value: "" }); - return; - } - - filterTimeoutRef.current = window.setTimeout(() => { - filterTimeoutRef.current = null; - postMessage({ command: "setFilter", value }); - }, FILTER_UPDATE_DEBOUNCE_MS); - }, - [postMessage] - ); - - const handleExpandedItemsChange = useCallback( - (sectionId: ExplorerSectionId, itemIds: string[]) => { - setExpandedBySection((current) => ({ ...current, [sectionId]: itemIds })); - }, - [] - ); - - const expandAllInSection = useCallback((sectionId: ExplorerSectionId, nodes: ExplorerNode[]) => { - setExpandedBySection((current) => ({ ...current, [sectionId]: flattenNodeIds(nodes) })); - }, []); - - const collapseAllInSection = useCallback((sectionId: ExplorerSectionId) => { - setExpandedBySection((current) => ({ ...current, [sectionId]: [] })); - }, []); - - const sectionsById = useMemo(() => { - const map = new Map(); - for (const section of sections) { - map.set(section.id, section); - } - return map; - }, [sections]); - - const orderedSections = useMemo(() => { - const visible: ExplorerSectionSnapshot[] = []; - for (const sectionId of sectionOrder) { - const section = sectionsById.get(sectionId); - if (section) { - visible.push(section); - } - } - return visible; - }, [sectionOrder, sectionsById]); - - const orderedSectionIds = useMemo(() => orderedSections.map((s) => s.id), [orderedSections]); - - const toggleSectionCollapsed = useCallback( - (sectionId: ExplorerSectionId) => { - setCollapsedBySection((current) => { - const wasCollapsed = current[sectionId] ?? false; - const next = { ...current, [sectionId]: !wasCollapsed }; - - const expandedAfter = orderedSectionIds.filter( - (id) => next[id] !== true && !FIXED_HEIGHT_SECTIONS.has(id) - ); - setHeightRatioBySection((currentRatios) => - normalizeHeightRatios(currentRatios, expandedAfter) - ); - - return next; - }); - }, - [orderedSectionIds] - ); - - const setSectionRef = useCallback( - (sectionId: ExplorerSectionId, element: HTMLDivElement | null) => { - sectionRefs.current[sectionId] = element; - }, - [] - ); - - const handleSectionDragStart = useCallback( - (sectionId: ExplorerSectionId) => (event: DragEvent) => { - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", sectionId); - setDraggingSection(sectionId); - setDragOverSection(sectionId); - }, - [] - ); - - const handleSectionDragOver = useCallback( - (sectionId: ExplorerSectionId) => (event: DragEvent) => { - event.preventDefault(); - if (draggingSection && draggingSection !== sectionId) { - setDragOverSection(sectionId); - } - }, - [draggingSection] - ); - - const handleSectionDrop = useCallback( - (targetId: ExplorerSectionId) => (event: DragEvent) => { - event.preventDefault(); - const sourceValue = event.dataTransfer.getData("text/plain"); - const sourceId = isExplorerSectionId(sourceValue) ? sourceValue : draggingSection; - - if (!sourceId || sourceId === targetId) { - setDraggingSection(null); - setDragOverSection(null); - return; - } - - setSectionOrder((currentOrder) => reorderSections(currentOrder, sourceId, targetId)); - setDraggingSection(null); - setDragOverSection(null); - }, - [draggingSection] - ); - - const handleSectionDragEnd = useCallback(() => { - setDraggingSection(null); - setDragOverSection(null); - }, []); - - const { isResizing, handleResizeStart } = usePaneResize( - paneContainerRef, - heightRatioBySection, - setHeightRatioBySection, - collapsedBySection, - orderedSectionIds - ); - - const sectionFlexStyles = useMemo(() => { - const styles: Partial> = {}; - const expandedIds = orderedSectionIds.filter( - (id) => collapsedBySection[id] !== true && !FIXED_HEIGHT_SECTIONS.has(id) - ); - const n = expandedIds.length; - for (const id of orderedSectionIds) { - if (collapsedBySection[id] === true || FIXED_HEIGHT_SECTIONS.has(id)) { - styles[id] = "0 0 auto"; - } else { - const ratio = heightRatioBySection[id] ?? (n > 0 ? 1 / n : 1); - styles[id] = `${ratio} 1 0px`; - } - } - return styles; - }, [orderedSectionIds, collapsedBySection, heightRatioBySection]); - - useEffect( - () => () => { - if (filterTimeoutRef.current !== null) { - window.clearTimeout(filterTimeoutRef.current); - } - if (uiStateTimeoutRef.current !== null) { - window.clearTimeout(uiStateTimeoutRef.current); - } - }, - [] - ); - - useEffect(() => { - if (!uiStateHydrated) { - return; - } - - const uiState: ExplorerUiState = { - sectionOrder, - collapsedBySection, - expandedBySection, - heightRatioBySection - }; - - if (uiStateTimeoutRef.current !== null) { - window.clearTimeout(uiStateTimeoutRef.current); - } - uiStateTimeoutRef.current = window.setTimeout(() => { - uiStateTimeoutRef.current = null; - postMessage({ - command: "persistUiState", - state: uiState - }); - }, UI_STATE_UPDATE_DEBOUNCE_MS); - }, [ - sectionOrder, - collapsedBySection, - expandedBySection, - heightRatioBySection, - uiStateHydrated, - postMessage - ]); - - return ( - - {errorMessage !== null && errorMessage.length > 0 && ( - setErrorMessage(null)} sx={{ mx: 1.5 }}> - {errorMessage} - - )} - - - handleFilterChange(event.target.value)} - slotProps={{ - input: { - startAdornment: ( - - - - ), - endAdornment: undefined - } - }} - /> - - - - {orderedSections.map((section, index) => { - const isExpanded = !(collapsedBySection[section.id] ?? false); - const prevExpandedId = (() => { - for (let i = index - 1; i >= 0; i--) { - if (!(collapsedBySection[orderedSections[i].id] ?? false)) { - return orderedSections[i].id; - } - } - return null; - })(); - - return ( - - {isExpanded && - prevExpandedId && - !FIXED_HEIGHT_SECTIONS.has(section.id) && - !FIXED_HEIGHT_SECTIONS.has(prevExpandedId) && ( - - )} - - - ); - })} - - - ); -} - -function mount(): void { - const container = document.getElementById("root"); - if (!container) { - return; - } - - const root = createRoot(container); - root.render( - - - - ); -} - -if (document.body.dataset.webviewKind === EXPLORER_WEBVIEW_KIND) { - mount(); -} diff --git a/src/webviews/explorer/containerlabExplorerViewProvider.ts b/src/webviews/explorer/containerlabExplorerViewProvider.ts index 918c98768..23b8629f2 100644 --- a/src/webviews/explorer/containerlabExplorerViewProvider.ts +++ b/src/webviews/explorer/containerlabExplorerViewProvider.ts @@ -1,18 +1,13 @@ -import { randomBytes } from "crypto"; - import * as vscode from "vscode"; +import { + type ExplorerOutgoingMessage, + type ExplorerSnapshotOptions, + type ExplorerSnapshotProviders, + type ExplorerUiState +} from "@srl-labs/clab-ui/explorer"; +import { createExplorerController } from "@srl-labs/clab-ui/host"; import { hideNonOwnedLabsState } from "../../globals"; -import { EXPLORER_SECTION_LABELS, EXPLORER_SECTION_ORDER } from "../shared/explorer/types"; -import type { - ExplorerIncomingMessage, - ExplorerInvokeActionMessage, - ExplorerOutgoingMessage, - ExplorerPersistUiStateMessage, - ExplorerSetFilterMessage, - ExplorerSnapshotMessage, - ExplorerUiState -} from "../shared/explorer/types"; import type { HelpFeedbackProvider, LocalLabTreeDataProvider, @@ -20,14 +15,11 @@ import type { } from "../../treeView"; import { - buildExplorerSnapshot, + type ExplorerCommandMetadata, + getExplorerCommandMetadata, invalidateExplorerContributionCache } from "./explorerSnapshotAdapter"; -import type { - ExplorerActionInvocation, - ExplorerSnapshotOptions, - ExplorerSnapshotProviders -} from "./explorerSnapshotAdapter"; +import { createReactWebviewHtml } from "../shared/reactWebviewHtml"; const REFRESH_DEBOUNCE_MS = 120; const UI_STATE_KEY = "containerlabExplorer.uiState"; @@ -39,22 +31,6 @@ interface FilterableTreeProvider { onDidChangeTreeData: vscode.Event; } -function isSetFilterMessage(message: ExplorerOutgoingMessage): message is ExplorerSetFilterMessage { - return message.command === "setFilter"; -} - -function isInvokeActionMessage( - message: ExplorerOutgoingMessage -): message is ExplorerInvokeActionMessage { - return message.command === "invokeAction"; -} - -function isPersistUiStateMessage( - message: ExplorerOutgoingMessage -): message is ExplorerPersistUiStateMessage { - return message.command === "persistUiState"; -} - export interface ContainerlabExplorerProviderArgs { runningProvider: RunningLabTreeDataProvider; localProvider: LocalLabTreeDataProvider; @@ -68,29 +44,23 @@ export class ContainerlabExplorerViewProvider public static readonly viewType = "containerlabExplorerWebview"; private readonly context: vscode.ExtensionContext; - private readonly providers: ExplorerSnapshotProviders; + private readonly providers: ContainerlabExplorerProviderArgs; private readonly filterableProviders: FilterableTreeProvider[]; - private readonly options: ExplorerSnapshotOptions; + private readonly options: ExplorerSnapshotOptions & { + commandMetadata?: ExplorerCommandMetadata; + }; private readonly disposables: vscode.Disposable[] = []; private readonly visibilityEmitter = new vscode.EventEmitter(); + private readonly explorerController: ReturnType; private webviewView?: vscode.WebviewView; - private isReady = false; private filterText = ""; - private refreshTimer?: ReturnType; - private snapshotInFlight = false; - private snapshotPending = false; - private actionBindings: Map = new Map(); public readonly onDidChangeVisibility = this.visibilityEmitter.event; constructor(context: vscode.ExtensionContext, args: ContainerlabExplorerProviderArgs) { this.context = context; - this.providers = { - runningProvider: args.runningProvider, - localProvider: args.localProvider, - helpProvider: args.helpProvider - }; + this.providers = args; this.options = { hideNonOwnedLabs: hideNonOwnedLabsState, isLocalCaptureAllowed: args.isLocalCaptureAllowed @@ -103,6 +73,45 @@ export class ContainerlabExplorerViewProvider provider.setTreeFilter(this.filterText); } } + this.explorerController = createExplorerController({ + initialFilterText: this.filterText, + initialUiState: context.workspaceState.get(UI_STATE_KEY, {}), + debounceMs: REFRESH_DEBOUNCE_MS, + buildProviders: async () => this.providers as ExplorerSnapshotProviders, + getSnapshotOptions: async () => { + this.options.hideNonOwnedLabs = hideNonOwnedLabsState; + this.options.commandMetadata = await getExplorerCommandMetadata(); + return this.options; + }, + executeAction: async (binding) => { + await vscode.commands.executeCommand(binding.commandId, ...binding.args); + }, + onFilterTextChanged: async (filterText) => { + this.filterText = filterText; + for (const provider of this.filterableProviders) { + if (filterText.length > 0) { + provider.setTreeFilter(filterText); + } else { + provider.clearTreeFilter(); + } + } + await this.context.workspaceState.update(FILTER_STATE_KEY, this.filterText); + await vscode.commands.executeCommand( + "setContext", + "containerlabExplorerFilterActive", + this.filterText.length > 0 + ); + }, + onUiStateChanged: async (state) => { + await this.context.workspaceState.update(UI_STATE_KEY, state); + }, + publish: async (message) => { + if (!this.webviewView) { + return; + } + await this.webviewView.webview.postMessage(message); + } + }); this.registerDataListeners(); this.disposables.push( @@ -114,7 +123,7 @@ export class ContainerlabExplorerViewProvider } private registerDataListeners(): void { - const allProviders = [ + const allProviders: Array<{ onDidChangeTreeData: vscode.Event }> = [ this.providers.runningProvider, this.providers.localProvider, this.providers.helpProvider @@ -129,7 +138,6 @@ export class ContainerlabExplorerViewProvider public resolveWebviewView(webviewView: vscode.WebviewView): void { this.webviewView = webviewView; - this.isReady = false; webviewView.webview.options = { enableScripts: true, @@ -142,7 +150,7 @@ export class ContainerlabExplorerViewProvider this.disposables.push( webviewView.webview.onDidReceiveMessage((message: ExplorerOutgoingMessage) => { - void this.handleMessage(message); + void this.explorerController.handleMessage(message); }) ); this.disposables.push( @@ -160,7 +168,7 @@ export class ContainerlabExplorerViewProvider ); this.disposables.push( webviewView.onDidDispose(() => { - this.isReady = false; + this.explorerController.dispose(); this.webviewView = undefined; this.visibilityEmitter.fire(false); }) @@ -175,217 +183,33 @@ export class ContainerlabExplorerViewProvider } public async setFilter(filterText: string): Promise { - const normalized = filterText.trim(); - this.filterText = normalized; - - for (const provider of this.filterableProviders) { - if (normalized.length > 0) { - provider.setTreeFilter(normalized); - } else { - provider.clearTreeFilter(); - } - } - - await this.context.workspaceState.update(FILTER_STATE_KEY, this.filterText); - await vscode.commands.executeCommand( - "setContext", - "containerlabExplorerFilterActive", - this.filterText.length > 0 - ); - this.postFilterState(); - this.scheduleSnapshot(0); + await this.explorerController.setFilter(filterText); } public async clearFilter(): Promise { - await this.setFilter(""); + await this.explorerController.clearFilter(); } public isFilterActive(): boolean { - return this.filterText.length > 0; - } - - private async handleMessage(message: ExplorerOutgoingMessage): Promise { - if (message.command === "ready") { - this.isReady = true; - this.postFilterState(); - this.postUiState(); - this.scheduleSnapshot(0); - return; - } - - if (isSetFilterMessage(message)) { - await this.setFilter(message.value); - return; - } - - if (isInvokeActionMessage(message)) { - await this.executeAction(message); - return; - } - - if (isPersistUiStateMessage(message)) { - await this.persistUiState(message.state); - } - } - - private async executeAction(message: ExplorerInvokeActionMessage): Promise { - const binding = this.actionBindings.get(message.actionRef); - if (!binding) { - const msg = "Action is no longer available. Reopen the explorer and try again."; - this.postError(msg); - return; - } - - try { - await vscode.commands.executeCommand(binding.commandId, ...binding.args); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.postError(`Failed to execute command: ${errorMessage}`); - } - } - - private async persistUiState(state: ExplorerUiState): Promise { - await this.context.workspaceState.update(UI_STATE_KEY, state); - } - - private postUiState(): void { - if (!this.webviewView || !this.isReady) { - return; - } - - const state = this.context.workspaceState.get(UI_STATE_KEY, {}); - const message: ExplorerIncomingMessage = { - command: "uiState", - state - }; - void this.webviewView.webview.postMessage(message); - } - - private postFilterState(): void { - if (!this.webviewView || !this.isReady) { - return; - } - - const message: ExplorerIncomingMessage = { - command: "filterState", - filterText: this.filterText - }; - void this.webviewView.webview.postMessage(message); - } - - private postError(message: string): void { - if (!this.webviewView || !this.isReady) { - return; - } - - const payload: ExplorerIncomingMessage = { command: "error", message }; - void this.webviewView.webview.postMessage(payload); + return this.explorerController.isFilterActive(); } private scheduleSnapshot(delay: number = REFRESH_DEBOUNCE_MS): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - } - - this.refreshTimer = setTimeout(() => { - this.refreshTimer = undefined; - void this.postSnapshot(); - }, delay); - } - - private async postSnapshot(): Promise { - if (!this.webviewView || !this.isReady) { - return; - } - - if (this.snapshotInFlight) { - this.snapshotPending = true; - return; - } - - this.snapshotInFlight = true; - try { - this.options.hideNonOwnedLabs = hideNonOwnedLabsState; - const { snapshot, actionBindings } = await buildExplorerSnapshot( - this.providers, - this.filterText, - this.options - ); - this.actionBindings = actionBindings; - void this.webviewView.webview.postMessage(snapshot); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error("[containerlab explorer] failed to build snapshot", error); - this.actionBindings = new Map(); - this.postError(`Explorer refresh failed: ${message}`); - this.postFallbackSnapshot(); - } finally { - this.snapshotInFlight = false; - if (this.snapshotPending) { - this.snapshotPending = false; - this.scheduleSnapshot(0); - } - } - } - - private postFallbackSnapshot(): void { - if (!this.webviewView || !this.isReady) { - return; - } - - const snapshot: ExplorerSnapshotMessage = { - command: "snapshot", - filterText: this.filterText, - sections: EXPLORER_SECTION_ORDER.map((sectionId) => ({ - id: sectionId, - label: EXPLORER_SECTION_LABELS[sectionId], - count: 0, - nodes: [], - toolbarActions: [] - })) - }; - void this.webviewView.webview.postMessage(snapshot); + this.explorerController.scheduleSnapshot(delay); } private getWebviewHtml(webview: vscode.Webview): string { - const nonce = randomBytes(16).toString("hex"); - const scriptUri = webview.asWebviewUri( - vscode.Uri.joinPath(this.context.extensionUri, "dist", "containerlabExplorerView.js") - ); - const csp = webview.cspSource; - - return ` - - - - - - - - -

    - - -`; + return createReactWebviewHtml({ + webview, + extensionUri: this.context.extensionUri, + scriptFile: "containerlabExplorerView.js", + title: "Containerlab Explorer", + webviewKind: "containerlab-explorer" + }); } public dispose(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - this.refreshTimer = undefined; - } - + this.explorerController.dispose(); this.visibilityEmitter.dispose(); for (const disposable of this.disposables) { disposable.dispose(); diff --git a/src/webviews/explorer/entry.tsx b/src/webviews/explorer/entry.tsx new file mode 100644 index 000000000..e0ca42dce --- /dev/null +++ b/src/webviews/explorer/entry.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; + +import { ContainerlabExplorerView } from "@srl-labs/clab-ui/explorer"; +import { + ClabUiRuntimeProvider, + createClabUiRuntime, + createWindowClabUiHost +} from "@srl-labs/clab-ui/host"; +import { MuiThemeProvider } from "@srl-labs/clab-ui/theme"; + +const runtime = createClabUiRuntime({ host: createWindowClabUiHost() }); + +function bootstrap(): void { + const container = document.getElementById("root"); + if (!container) { + throw new Error("Explorer root element not found"); + } + + const root = createRoot(container); + root.render( + + + + + + + + ); +} + +bootstrap(); diff --git a/src/webviews/explorer/explorerSnapshotAdapter.ts b/src/webviews/explorer/explorerSnapshotAdapter.ts index e3f99ee61..2b65264f8 100644 --- a/src/webviews/explorer/explorerSnapshotAdapter.ts +++ b/src/webviews/explorer/explorerSnapshotAdapter.ts @@ -1,183 +1,33 @@ import * as vscode from "vscode"; -import type { - HelpFeedbackProvider, - LocalLabTreeDataProvider, - RunningLabTreeDataProvider -} from "../../treeView"; -import { - EXPLORER_SECTION_LABELS, - EXPLORER_SECTION_ORDER, - type ExplorerAction, - type ExplorerNode, - type ExplorerSectionId, - type ExplorerSectionSnapshot, - type ExplorerSnapshotMessage -} from "../shared/explorer/types"; - -interface ExplorerTreeProvider { - getChildren(element?: unknown): vscode.ProviderResult; -} - -type ExplorerTreeItemLike = vscode.TreeItem & { - id?: string; - contextValue?: string; - state?: string; - status?: string; - link?: string; -}; - -interface LabShareInfo { - kind: "sshx" | "gotty"; - url: string; -} - -export interface ExplorerSnapshotProviders { - runningProvider: RunningLabTreeDataProvider; - localProvider: LocalLabTreeDataProvider; - helpProvider: HelpFeedbackProvider; +export interface ExplorerContributedMenuItem { + commandId: string; + label?: string; + iconId?: string; } -export interface ExplorerSnapshotOptions { - hideNonOwnedLabs: boolean; - isLocalCaptureAllowed: boolean; +export interface ExplorerCommandMetadata { + contributedContainerActions?: readonly ExplorerContributedMenuItem[]; + commandLabels?: ReadonlyMap; + commandIcons?: ReadonlyMap; } -export interface ExplorerActionInvocation { - commandId: string; - args: unknown[]; +interface ExtensionContributes { + menus?: unknown; + commands?: unknown; } -export interface ExplorerSnapshotBuildResult { - snapshot: ExplorerSnapshotMessage; - actionBindings: Map; +interface ParsedContributedMenuItem extends ExplorerContributedMenuItem { + when?: string; } -const COMMAND_LABELS: Record = { - "containerlab.lab.openFile": "Edit Topology", - "containerlab.editor.topoViewerEditor.open": "Edit Topology (TopoViewer)", - "containerlab.lab.copyPath": "Copy File Path", - "containerlab.lab.addToWorkspace": "Add To Workspace", - "containerlab.lab.openFolderInNewWindow": "Open Folder In New Window", - "containerlab.lab.toggleFavorite": "Toggle Favorite", - "containerlab.lab.deploy": "Deploy", - "containerlab.lab.deploy.cleanup": "Deploy (Cleanup)", - "containerlab.lab.destroy": "Destroy", - "containerlab.lab.destroy.cleanup": "Destroy (Cleanup)", - "containerlab.lab.redeploy": "Redeploy", - "containerlab.lab.redeploy.cleanup": "Redeploy (Cleanup)", - "containerlab.lab.save": "Save Configs", - "containerlab.lab.delete": "Delete Lab File", - "containerlab.lab.sshToAllNodes": "SSH To All Nodes", - "containerlab.inspectOneLab": "Inspect", - "containerlab.lab.sshx.attach": "Attach SSHX Session", - "containerlab.lab.sshx.detach": "Detach SSHX Session", - "containerlab.lab.sshx.reattach": "Reattach SSHX Session", - "containerlab.lab.gotty.attach": "Attach GoTTY Session", - "containerlab.lab.gotty.detach": "Detach GoTTY Session", - "containerlab.lab.gotty.reattach": "Reattach GoTTY Session", - "containerlab.lab.sshx.copyLink": "Copy SSHX Link", - "containerlab.lab.gotty.copyLink": "Copy GoTTY Link", - "containerlab.lab.graph.topoViewer": "Open TopoViewer", - "containerlab.lab.graph.drawio.horizontal": "Graph (draw.io, Horizontal)", - "containerlab.lab.graph.drawio.vertical": "Graph (draw.io, Vertical)", - "containerlab.lab.graph.drawio.interactive": "Graph (draw.io, Interactive)", - "containerlab.node.start": "Start Node", - "containerlab.node.stop": "Stop Node", - "containerlab.node.pause": "Pause Node", - "containerlab.node.unpause": "Unpause Node", - "containerlab.node.save": "Save Node Config", - "containerlab.node.attachShell": "Attach Shell", - "containerlab.node.ssh": "SSH", - "containerlab.node.telnet": "Telnet", - "containerlab.node.showLogs": "Show Logs", - "containerlab.node.manageImpairments": "Manage Impairments", - "containerlab.node.openBrowser": "Open Browser", - "containerlab.node.copyName": "Copy Node Name", - "containerlab.node.copyID": "Copy Node ID", - "containerlab.node.copyIPv4Address": "Copy IPv4 Address", - "containerlab.node.copyIPv6Address": "Copy IPv6 Address", - "containerlab.node.copyKind": "Copy Node Kind", - "containerlab.node.copyImage": "Copy Node Image", - "containerlab.interface.capture": "Capture", - "containerlab.interface.captureWithEdgeshark": "Capture With Edgeshark", - "containerlab.interface.captureWithEdgesharkVNC": "Capture With Edgeshark VNC", - "containerlab.interface.setDelay": "Set Delay", - "containerlab.interface.setJitter": "Set Jitter", - "containerlab.interface.setLoss": "Set Loss", - "containerlab.interface.setRate": "Set Rate", - "containerlab.interface.setCorruption": "Set Corruption", - "containerlab.interface.copyMACAddress": "Copy MAC Address", - "containerlab.lab.fcli.bgpPeers": "Run fcli bgp-peers", - "containerlab.lab.fcli.bgpRib": "Run fcli bgp-rib", - "containerlab.lab.fcli.ipv4Rib": "Run fcli ipv4-rib", - "containerlab.lab.fcli.lldp": "Run fcli lldp", - "containerlab.lab.fcli.mac": "Run fcli mac", - "containerlab.lab.fcli.ni": "Run fcli ni", - "containerlab.lab.fcli.subif": "Run fcli subif", - "containerlab.lab.fcli.sysInfo": "Run fcli sys-info", - "containerlab.lab.fcli.custom": "Run Custom fcli", - "containerlab.lab.deploy.specificFile": "Deploy Lab File", - "containerlab.inspectAll": "Inspect All Labs", - "containerlab.treeView.runningLabs.hideNonOwnedLabs": "Hide Non-Owned Labs", - "containerlab.treeView.runningLabs.showNonOwnedLabs": "Show Non-Owned Labs", - "containerlab.editor.topoViewerEditor": "New Topology File", - "containerlab.lab.cloneRepo": "Clone Repository" -}; - -const DESTRUCTIVE_COMMANDS = new Set([ - "containerlab.lab.delete", - "containerlab.lab.destroy", - "containerlab.lab.destroy.cleanup", - "containerlab.lab.sshx.detach", - "containerlab.lab.gotty.detach" -]); -const SECTION_BUILD_TIMEOUT_MS = 4000; -const TREE_ITEM_COLLAPSIBLE_NONE = 0; const CONTAINER_NODE_CONTEXT_MENU_ID = "containerlab/node/context"; const LEGACY_VIEW_ITEM_CONTEXT_MENU_ID = "view/item/context"; const LEGACY_NODE_CONTEXT_WHEN_REGEX = /\bviewItem\s*==\s*["']?containerlabContainer(?:Group)?["']?\b/; -const BUILTIN_CONTAINER_ACTION_COMMANDS: readonly string[] = [ - "containerlab.node.showLogs", - "containerlab.node.attachShell", - "containerlab.node.ssh", - "containerlab.node.telnet", - "containerlab.node.openBrowser", - "containerlab.node.start", - "containerlab.node.stop", - "containerlab.node.pause", - "containerlab.node.unpause", - "containerlab.node.save", - "containerlab.node.manageImpairments", - "containerlab.node.copyName", - "containerlab.node.copyID", - "containerlab.node.copyIPv4Address", - "containerlab.node.copyIPv6Address", - "containerlab.node.copyKind", - "containerlab.node.copyImage" -]; -interface ContributedMenuItem { - commandId: string; - when?: string; - label?: string; - iconId?: string; -} - -interface ContributedContainerActions { - commands: ContributedMenuItem[]; - commandLabels: Map; - commandIcons: Map; -} - -interface ExtensionContributes { - menus?: unknown; - commands?: unknown; -} - -let contributedContainerActionsCache: ContributedContainerActions | undefined; -let contributedContainerActionsCachePromise: Promise | undefined; +let commandMetadataCache: ExplorerCommandMetadata | undefined; +let commandMetadataCachePromise: Promise | undefined; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -200,12 +50,7 @@ function extractCommandId(value: unknown): string | undefined { return undefined; } - const id = asNonEmptyString(value.id); - if (id !== undefined) { - return id; - } - - return asNonEmptyString(value.command); + return asNonEmptyString(value.id) ?? asNonEmptyString(value.command); } function extractCommandLabel(value: unknown): string | undefined { @@ -217,12 +62,7 @@ function extractCommandLabel(value: unknown): string | undefined { return undefined; } - const title = asNonEmptyString(value.title); - if (title !== undefined) { - return title; - } - - return asNonEmptyString(value.value); + return asNonEmptyString(value.title) ?? asNonEmptyString(value.value); } function parseThemeIconId(value: unknown): string | undefined { @@ -240,12 +80,7 @@ function parseThemeIconId(value: unknown): string | undefined { return undefined; } - const iconWithModifiers = match[1]; - if (iconWithModifiers.length === 0) { - return undefined; - } - - const [iconId] = iconWithModifiers.split("~"); + const [iconId] = match[1].split("~"); return iconId.length > 0 ? iconId : undefined; } @@ -258,15 +93,13 @@ function extractCommandIconId(value: unknown): string | undefined { if (typeof icon === "string") { return parseThemeIconId(icon); } - if (isRecord(icon)) { return asNonEmptyString(icon.id); } - return undefined; } -function parseContributedMenuItem(value: unknown): ContributedMenuItem | undefined { +function parseContributedMenuItem(value: unknown): ParsedContributedMenuItem | undefined { if (!isRecord(value)) { return undefined; } @@ -281,6 +114,7 @@ function parseContributedMenuItem(value: unknown): ContributedMenuItem | undefin if (label === undefined && isRecord(commandField)) { label = extractCommandLabel(commandField.title); } + const iconId = isRecord(commandField) ? (extractCommandIconId(commandField) ?? extractCommandIconId(value)) : extractCommandIconId(value); @@ -293,12 +127,12 @@ function parseContributedMenuItem(value: unknown): ContributedMenuItem | undefin }; } -function parseContributedMenuItems(value: unknown): ContributedMenuItem[] { +function parseContributedMenuItems(value: unknown): ParsedContributedMenuItem[] { if (!Array.isArray(value)) { return []; } - const items: ContributedMenuItem[] = []; + const items: ParsedContributedMenuItem[] = []; for (const candidate of value) { const item = parseContributedMenuItem(candidate); if (item !== undefined) { @@ -324,8 +158,8 @@ function getExtensionContributes( return contributes as ExtensionContributes; } -function getPackageContributionItems(menuId: string): ContributedMenuItem[] { - const items: ContributedMenuItem[] = []; +function getPackageContributionItems(menuId: string): ParsedContributedMenuItem[] { + const items: ParsedContributedMenuItem[] = []; for (const extension of vscode.extensions.all) { const contributes = getExtensionContributes(extension); @@ -342,7 +176,7 @@ function getPackageContributionItems(menuId: string): ContributedMenuItem[] { return items; } -async function getContributedMenuItems(menuId: string): Promise { +async function getContributedMenuItems(menuId: string): Promise { try { const result = await vscode.commands.executeCommand( "_builtin.getContributedMenuItems", @@ -402,8 +236,8 @@ function buildCommandMetadataMaps(): { labels: Map; icons: Map(); for (const item of items) { @@ -425,15 +259,15 @@ function legacyViewItemMatchesContainer(when: string | undefined): boolean { return LEGACY_NODE_CONTEXT_WHEN_REGEX.test(when); } -async function computeContributedContainerActions(): Promise { +async function computeExplorerCommandMetadata(): Promise { const { labels: commandLabels, icons: commandIcons } = buildCommandMetadataMaps(); const menuItems = await getContributedMenuItems(CONTAINER_NODE_CONTEXT_MENU_ID); const legacyMenuItems = (await getContributedMenuItems(LEGACY_VIEW_ITEM_CONTEXT_MENU_ID)).filter( (item) => legacyViewItemMatchesContainer(item.when) ); - const commands = dedupeMenuItems([...menuItems, ...legacyMenuItems]); + const contributedContainerActions = dedupeMenuItems([...menuItems, ...legacyMenuItems]); - for (const item of commands) { + for (const item of contributedContainerActions) { if (item.label !== undefined && !commandLabels.has(item.commandId)) { commandLabels.set(item.commandId, item.label); } @@ -443,810 +277,35 @@ async function computeContributedContainerActions(): Promise { - if (contributedContainerActionsCache !== undefined) { - return contributedContainerActionsCache; +export async function getExplorerCommandMetadata(): Promise { + if (commandMetadataCache !== undefined) { + return commandMetadataCache; } - contributedContainerActionsCachePromise ??= computeContributedContainerActions() + commandMetadataCachePromise ??= computeExplorerCommandMetadata() .catch((error: unknown) => { - console.error("[containerlab explorer] failed to resolve contributed node actions", error); + console.error("[containerlab explorer] failed to resolve command metadata", error); return { - commands: [], + contributedContainerActions: [], commandLabels: new Map(), commandIcons: new Map() }; }) .finally(() => { - contributedContainerActionsCachePromise = undefined; + commandMetadataCachePromise = undefined; }); - contributedContainerActionsCache = await contributedContainerActionsCachePromise; - return contributedContainerActionsCache; + commandMetadataCache = await commandMetadataCachePromise; + return commandMetadataCache; } export function invalidateExplorerContributionCache(): void { - contributedContainerActionsCache = undefined; - contributedContainerActionsCachePromise = undefined; -} - -function labelToText(label: string | vscode.TreeItemLabel | undefined): string { - if (label === undefined) { - return ""; - } - return typeof label === "string" ? label : label.label; -} - -function descriptionToText(description: string | boolean | undefined): string | undefined { - if (typeof description === "string" && description.trim().length > 0) { - return description; - } - return undefined; -} - -function tooltipToText(tooltip: vscode.MarkdownString | string | undefined): string | undefined { - if (typeof tooltip === "string") { - return tooltip; - } - if ( - tooltip && - typeof tooltip === "object" && - "value" in tooltip && - typeof (tooltip as { value?: unknown }).value === "string" - ) { - return (tooltip as { value: string }).value; - } - return undefined; -} - -function commandLabel(commandId: string, fallback?: string): string { - return (fallback ?? COMMAND_LABELS[commandId]) || commandId; -} - -function isLabContext(contextValue: string | undefined): boolean { - return typeof contextValue === "string" && contextValue.includes("containerlabLab"); -} - -function isDeployedLab(contextValue: string | undefined): boolean { - return typeof contextValue === "string" && contextValue.includes("containerlabLabDeployed"); -} - -function isUndeployedLab(contextValue: string | undefined): boolean { - return typeof contextValue === "string" && contextValue.includes("containerlabLabUndeployed"); -} - -function isFavoriteLab(contextValue: string | undefined): boolean { - return typeof contextValue === "string" && contextValue.includes("Favorite"); -} - -function shouldHideNodeDescription(contextValue: string | undefined): boolean { - return isLabContext(contextValue); -} - -function collectContainerIndicators(children: ExplorerNode[]): ExplorerNode["statusIndicator"][] { - const indicators: ExplorerNode["statusIndicator"][] = []; - for (const child of children) { - if (child.contextValue === "containerlabContainer") { - indicators.push(child.statusIndicator); - } else if (child.contextValue === "containerlabContainerGroup") { - // Recurse into group children to get actual container indicators - indicators.push(...collectContainerIndicators(child.children)); - } - } - return indicators; -} - -function aggregateStatusFromIndicators( - indicators: ExplorerNode["statusIndicator"][] -): ExplorerNode["statusIndicator"] { - if (indicators.length === 0) { - return undefined; - } - - let healthyRunning = 0; - let notRunning = 0; - let unhealthyRunning = 0; - - for (const indicator of indicators) { - if (indicator === "green") { - healthyRunning += 1; - continue; - } - if (indicator === "yellow") { - unhealthyRunning += 1; - continue; - } - notRunning += 1; - } - - if (healthyRunning === indicators.length) { - return "green"; - } - if (healthyRunning > 0 && notRunning > 0 && unhealthyRunning === 0) { - return "yellow"; - } - return "red"; -} - -function getStatusIndicator(item: ExplorerTreeItemLike): ExplorerNode["statusIndicator"] { - const context = item.contextValue; - if (context === "containerlabInterfaceUp") { - return "green"; - } - if (context === "containerlabInterfaceDown") { - return "red"; - } - if (context === "containerlabContainer") { - const state = String(item.state ?? "").toLowerCase(); - const status = String(item.status ?? "").toLowerCase(); - if ( - state === "running" && - (status.includes("unhealthy") || status.includes("health: starting")) - ) { - return "yellow"; - } - if (state === "running") { - return "green"; - } - return "red"; - } - return undefined; -} - -class ExplorerActionRegistry { - private counter = 0; - private readonly bindings = new Map(); - - public createAction( - commandId: string, - label: string, - args: unknown[] = [], - destructive = false, - iconId?: string - ): ExplorerAction { - const actionRef = `action:${this.counter++}`; - this.bindings.set(actionRef, { commandId, args }); - return { - id: `${commandId}:${label}:${actionRef}`, - actionRef, - label, - commandId, - iconId, - destructive - }; - } - - public getBindings(): Map { - return this.bindings; - } -} - -function pushAction( - actions: ExplorerAction[], - seen: Set, - registry: ExplorerActionRegistry, - commandId: string, - args: unknown[] = [], - label?: string, - destructive?: boolean, - iconId?: string -): void { - const resolvedLabel = commandLabel(commandId, label); - const key = `${commandId}:${resolvedLabel}`; - if (seen.has(key)) { - return; - } - - seen.add(key); - actions.push( - registry.createAction( - commandId, - resolvedLabel, - args, - destructive ?? DESTRUCTIVE_COMMANDS.has(commandId), - iconId - ) - ); -} - -function applyCommandIcons( - actions: ExplorerAction[], - commandIcons: ReadonlyMap -): ExplorerAction[] { - for (const action of actions) { - if (action.iconId !== undefined) { - continue; - } - const iconId = commandIcons.get(action.commandId); - if (iconId !== undefined && iconId.length > 0) { - action.iconId = iconId; - } - } - return actions; -} - -function getLinkArgument(item: ExplorerTreeItemLike): string | undefined { - const link = item.link; - if (typeof link === "string" && link.length > 0) { - return link; - } - return undefined; -} - -function isShareLinkNode(contextValue: string | undefined): boolean { - return contextValue === "containerlabSSHXLink" || contextValue === "containerlabGottyLink"; -} - -function getLabShareInfo(childrenItems: ExplorerTreeItemLike[]): LabShareInfo | undefined { - let sshxUrl: string | undefined; - let gottyUrl: string | undefined; - - for (const child of childrenItems) { - const contextValue = child.contextValue; - if (contextValue === "containerlabSSHXLink") { - sshxUrl = getLinkArgument(child); - continue; - } - if (contextValue === "containerlabGottyLink") { - gottyUrl = getLinkArgument(child); - } - } - - if (sshxUrl !== undefined && sshxUrl.length > 0) { - return { kind: "sshx", url: sshxUrl }; - } - if (gottyUrl !== undefined && gottyUrl.length > 0) { - return { kind: "gotty", url: gottyUrl }; - } - return undefined; -} - -function appendLabActions( - actions: ExplorerAction[], - seen: Set, - registry: ExplorerActionRegistry, - sectionId: ExplorerSectionId, - item: ExplorerTreeItemLike -): void { - const contextValue = item.contextValue; - const isDeployed = isDeployedLab(contextValue); - const isUndeployed = isUndeployedLab(contextValue); - const isFavorite = isFavoriteLab(contextValue); - - pushAction(actions, seen, registry, "containerlab.lab.openFile", [item]); - pushAction(actions, seen, registry, "containerlab.lab.copyPath", [item]); - pushAction(actions, seen, registry, "containerlab.lab.openFolderInNewWindow", [item]); - - if (isUndeployed) { - pushAction( - actions, - seen, - registry, - "containerlab.editor.topoViewerEditor.open", - [item], - "Edit Topology (TopoViewer)" - ); - } - - if (contextValue === "containerlabLabDeployed") { - pushAction(actions, seen, registry, "containerlab.lab.addToWorkspace", [item]); - } - - pushAction( - actions, - seen, - registry, - "containerlab.lab.toggleFavorite", - [item], - isFavorite ? "Remove From Favorites" : "Add To Favorites" - ); - - if (isUndeployed) { - pushAction(actions, seen, registry, "containerlab.lab.deploy", [item]); - pushAction(actions, seen, registry, "containerlab.lab.deploy.cleanup", [item]); - pushAction(actions, seen, registry, "containerlab.lab.delete", [item], undefined, true); - } - - if (isDeployed) { - pushAction(actions, seen, registry, "containerlab.lab.destroy", [item], undefined, true); - pushAction( - actions, - seen, - registry, - "containerlab.lab.destroy.cleanup", - [item], - undefined, - true - ); - pushAction(actions, seen, registry, "containerlab.lab.redeploy", [item]); - pushAction(actions, seen, registry, "containerlab.lab.redeploy.cleanup", [item]); - pushAction(actions, seen, registry, "containerlab.lab.save", [item]); - pushAction(actions, seen, registry, "containerlab.inspectOneLab", [item]); - pushAction(actions, seen, registry, "containerlab.lab.sshToAllNodes", [item]); - pushAction(actions, seen, registry, "containerlab.lab.sshx.attach", [item]); - pushAction(actions, seen, registry, "containerlab.lab.sshx.detach", [item], undefined, true); - pushAction(actions, seen, registry, "containerlab.lab.sshx.reattach", [item]); - pushAction(actions, seen, registry, "containerlab.lab.gotty.attach", [item]); - pushAction(actions, seen, registry, "containerlab.lab.gotty.detach", [item], undefined, true); - pushAction(actions, seen, registry, "containerlab.lab.gotty.reattach", [item]); - pushAction(actions, seen, registry, "containerlab.lab.fcli.bgpPeers", [item]); - pushAction(actions, seen, registry, "containerlab.lab.fcli.bgpRib", [item]); - pushAction(actions, seen, registry, "containerlab.lab.fcli.ipv4Rib", [item]); - pushAction(actions, seen, registry, "containerlab.lab.fcli.lldp", [item]); - pushAction(actions, seen, registry, "containerlab.lab.fcli.mac", [item]); - pushAction(actions, seen, registry, "containerlab.lab.fcli.ni", [item]); - pushAction(actions, seen, registry, "containerlab.lab.fcli.subif", [item]); - pushAction(actions, seen, registry, "containerlab.lab.fcli.sysInfo", [item]); - pushAction(actions, seen, registry, "containerlab.lab.fcli.custom", [item]); - } - - pushAction(actions, seen, registry, "containerlab.lab.graph.drawio.horizontal", [item]); - pushAction(actions, seen, registry, "containerlab.lab.graph.drawio.vertical", [item]); - pushAction(actions, seen, registry, "containerlab.lab.graph.drawio.interactive", [item]); - pushAction(actions, seen, registry, "containerlab.lab.graph.topoViewer", [item]); - - if (sectionId === "localLabs" && !isDeployed && !isUndeployed) { - // Keep local section behavior consistent for edge nodes without known lab context. - pushAction(actions, seen, registry, "containerlab.lab.openFile", [item]); - } -} - -function appendContainerActions( - actions: ExplorerAction[], - seen: Set, - registry: ExplorerActionRegistry, - item: ExplorerTreeItemLike, - contributedActions: ContributedMenuItem[], - commandLabels: ReadonlyMap, - commandIcons: ReadonlyMap -): void { - for (const commandId of BUILTIN_CONTAINER_ACTION_COMMANDS) { - pushAction(actions, seen, registry, commandId, [item]); - } - - const existingCommands = new Set(actions.map((action) => action.commandId)); - for (const contributedAction of contributedActions) { - if (existingCommands.has(contributedAction.commandId)) { - continue; - } - - pushAction( - actions, - seen, - registry, - contributedAction.commandId, - [item], - commandLabels.get(contributedAction.commandId), - undefined, - commandIcons.get(contributedAction.commandId) - ); - existingCommands.add(contributedAction.commandId); - } -} - -function appendInterfaceActions( - actions: ExplorerAction[], - seen: Set, - registry: ExplorerActionRegistry, - item: ExplorerTreeItemLike, - isLocalCaptureAllowed: boolean -): void { - if (isLocalCaptureAllowed) { - pushAction(actions, seen, registry, "containerlab.interface.capture", [item]); - } - pushAction(actions, seen, registry, "containerlab.interface.captureWithEdgeshark", [item]); - pushAction(actions, seen, registry, "containerlab.interface.captureWithEdgesharkVNC", [item]); - pushAction(actions, seen, registry, "containerlab.interface.setDelay", [item]); - pushAction(actions, seen, registry, "containerlab.interface.setJitter", [item]); - pushAction(actions, seen, registry, "containerlab.interface.setLoss", [item]); - pushAction(actions, seen, registry, "containerlab.interface.setRate", [item]); - pushAction(actions, seen, registry, "containerlab.interface.setCorruption", [item]); - pushAction(actions, seen, registry, "containerlab.interface.copyMACAddress", [item]); -} - -function appendLinkActions( - actions: ExplorerAction[], - seen: Set, - registry: ExplorerActionRegistry, - item: ExplorerTreeItemLike -): void { - const linkArg = getLinkArgument(item); - if (item.contextValue === "containerlabSSHXLink" && linkArg !== undefined && linkArg.length > 0) { - pushAction(actions, seen, registry, "containerlab.lab.sshx.copyLink", [linkArg]); - } else if ( - item.contextValue === "containerlabGottyLink" && - linkArg !== undefined && - linkArg.length > 0 - ) { - pushAction(actions, seen, registry, "containerlab.lab.gotty.copyLink", [linkArg]); - } -} - -function appendHelpFeedbackActions( - actions: ExplorerAction[], - seen: Set, - registry: ExplorerActionRegistry, - item: ExplorerTreeItemLike -): void { - const linkArg = getLinkArgument(item); - if (linkArg === undefined || linkArg.length === 0) { - return; - } - pushAction(actions, seen, registry, "containerlab.openLink", [linkArg], "Open Link"); -} - -function getNodeActions( - sectionId: ExplorerSectionId, - item: ExplorerTreeItemLike, - registry: ExplorerActionRegistry, - options: ExplorerSnapshotOptions, - contributedActions: ContributedMenuItem[], - commandLabels: ReadonlyMap, - commandIcons: ReadonlyMap -): ExplorerAction[] { - const actions: ExplorerAction[] = []; - const seen = new Set(); - - const contextValue = item.contextValue; - if (sectionId === "helpFeedback") { - appendHelpFeedbackActions(actions, seen, registry, item); - } else if (isLabContext(contextValue)) { - appendLabActions(actions, seen, registry, sectionId, item); - } else if ( - contextValue === "containerlabContainer" || - contextValue === "containerlabContainerGroup" - ) { - appendContainerActions( - actions, - seen, - registry, - item, - contributedActions, - commandLabels, - commandIcons - ); - } else if (contextValue === "containerlabInterfaceUp") { - appendInterfaceActions(actions, seen, registry, item, options.isLocalCaptureAllowed); - } else if (contextValue === "containerlabSSHXLink" || contextValue === "containerlabGottyLink") { - appendLinkActions(actions, seen, registry, item); - } - - return applyCommandIcons(actions, commandIcons); -} - -function resolvePrimaryAction( - contextValue: string | undefined, - nodeActions: ExplorerAction[] -): ExplorerAction | undefined { - if (isLabContext(contextValue)) { - return nodeActions.find((action) => action.commandId === "containerlab.lab.graph.topoViewer"); - } - - if ( - contextValue === "containerlabContainer" || - contextValue === "containerlabContainerGroup" || - contextValue === "containerlabInterfaceUp" || - contextValue === "containerlabInterfaceDown" || - isShareLinkNode(contextValue) - ) { - return undefined; - } - - return nodeActions.length > 0 ? nodeActions[0] : undefined; -} - -async function getProviderChildren( - provider: ExplorerTreeProvider, - element?: ExplorerTreeItemLike -): Promise { - const result = await Promise.resolve(provider.getChildren(element)); - if (!Array.isArray(result)) { - return []; - } - return result as ExplorerTreeItemLike[]; -} - -function shouldResolveChildren(item: ExplorerTreeItemLike): boolean { - return item.collapsibleState !== TREE_ITEM_COLLAPSIBLE_NONE; -} - -async function buildNode( - provider: ExplorerTreeProvider, - item: ExplorerTreeItemLike, - sectionId: ExplorerSectionId, - options: ExplorerSnapshotOptions, - registry: ExplorerActionRegistry, - contributedActions: ContributedMenuItem[], - commandLabels: ReadonlyMap, - commandIcons: ReadonlyMap, - pathId: string -): Promise { - const contextValue = item.contextValue; - const rawLabel = labelToText(item.label); - const label = isLabContext(contextValue) ? rawLabel.replace(/^🔗\s*/u, "") : rawLabel; - const description = shouldHideNodeDescription(contextValue) - ? undefined - : descriptionToText(item.description); - const tooltip = tooltipToText(item.tooltip); - const rawChildrenItems = shouldResolveChildren(item) - ? await getProviderChildren(provider, item) - : []; - const shareInfo = isLabContext(contextValue) ? getLabShareInfo(rawChildrenItems) : undefined; - const childrenItems = isLabContext(contextValue) - ? rawChildrenItems.filter((child) => !isShareLinkNode(child.contextValue)) - : rawChildrenItems; - const children = await Promise.all( - childrenItems.map((child, index) => - buildNode( - provider, - child, - sectionId, - options, - registry, - contributedActions, - commandLabels, - commandIcons, - `${pathId}/${index}` - ) - ) - ); - const nodeActions = getNodeActions( - sectionId, - item, - registry, - options, - contributedActions, - commandLabels, - commandIcons - ); - if (shareInfo) { - const copyCommandId = - shareInfo.kind === "sshx" - ? "containerlab.lab.sshx.copyLink" - : "containerlab.lab.gotty.copyLink"; - const hasCopyAction = nodeActions.some((action) => action.commandId === copyCommandId); - if (!hasCopyAction) { - nodeActions.push( - registry.createAction( - copyCommandId, - commandLabel(copyCommandId), - [shareInfo.url], - DESTRUCTIVE_COMMANDS.has(copyCommandId) - ) - ); - } - } - let shareAction: ExplorerAction | undefined; - if (shareInfo) { - const label = shareInfo.kind === "sshx" ? "Open Shared Terminal" : "Open Web Terminal"; - shareAction = registry.createAction("containerlab.openLink", label, [shareInfo.url]); - } else { - shareAction = undefined; - } - const primaryAction = resolvePrimaryAction(contextValue, nodeActions); - const statusIndicator = - isDeployedLab(contextValue) || contextValue === "containerlabContainerGroup" - ? aggregateStatusFromIndicators(collectContainerIndicators(children)) - : getStatusIndicator(item); - - return { - id: item.id ?? pathId, - label, - description, - tooltip, - contextValue, - statusIndicator, - statusDescription: description, - primaryAction, - shareAction, - actions: nodeActions, - children - }; -} - -async function buildSectionNodes( - provider: ExplorerTreeProvider, - sectionId: ExplorerSectionId, - options: ExplorerSnapshotOptions, - registry: ExplorerActionRegistry, - contributedActions: ContributedMenuItem[], - commandLabels: ReadonlyMap, - commandIcons: ReadonlyMap -): Promise { - const roots = await getProviderChildren(provider); - return Promise.all( - roots.map((item, index) => - buildNode( - provider, - item, - sectionId, - options, - registry, - contributedActions, - commandLabels, - commandIcons, - `${sectionId}/${index}` - ) - ) - ); -} - -function countNodes(nodes: ExplorerNode[], predicate: (node: ExplorerNode) => boolean): number { - let total = 0; - for (const node of nodes) { - if (predicate(node)) { - total += 1; - } - total += countNodes(node.children, predicate); - } - return total; -} - -function countForSection(sectionId: ExplorerSectionId, nodes: ExplorerNode[]): number { - if (sectionId === "runningLabs") { - return countNodes(nodes, (node) => isDeployedLab(node.contextValue)); - } - if (sectionId === "localLabs") { - return countNodes(nodes, (node) => isUndeployedLab(node.contextValue)); - } - return nodes.length; -} - -function toolbarActionsForSection( - sectionId: ExplorerSectionId, - registry: ExplorerActionRegistry, - options: ExplorerSnapshotOptions -): ExplorerAction[] { - const actions: ExplorerAction[] = []; - const seen = new Set(); - - if (sectionId === "runningLabs") { - pushAction(actions, seen, registry, "containerlab.lab.deploy.specificFile"); - pushAction(actions, seen, registry, "containerlab.inspectAll"); - if (options.hideNonOwnedLabs) { - pushAction(actions, seen, registry, "containerlab.treeView.runningLabs.showNonOwnedLabs"); - } else { - pushAction(actions, seen, registry, "containerlab.treeView.runningLabs.hideNonOwnedLabs"); - } - return actions; - } - - if (sectionId === "localLabs") { - pushAction(actions, seen, registry, "containerlab.editor.topoViewerEditor"); - pushAction(actions, seen, registry, "containerlab.lab.cloneRepo"); - } - - return actions; -} - -async function buildSectionSnapshot( - sectionId: ExplorerSectionId, - provider: ExplorerTreeProvider, - options: ExplorerSnapshotOptions, - registry: ExplorerActionRegistry, - contributedActions: ContributedMenuItem[], - commandLabels: ReadonlyMap, - commandIcons: ReadonlyMap -): Promise { - const nodes = await buildSectionNodes( - provider, - sectionId, - options, - registry, - contributedActions, - commandLabels, - commandIcons - ); - return { - id: sectionId, - label: EXPLORER_SECTION_LABELS[sectionId], - count: countForSection(sectionId, nodes), - nodes, - toolbarActions: toolbarActionsForSection(sectionId, registry, options) - }; -} - -function withTimeout( - promise: Promise, - timeoutMs: number, - timeoutMessage: string -): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error(timeoutMessage)); - }, timeoutMs); - - promise.then( - (result) => { - clearTimeout(timer); - resolve(result); - }, - (error: unknown) => { - clearTimeout(timer); - reject(error); - } - ); - }); -} - -async function buildSectionSnapshotSafe( - sectionId: ExplorerSectionId, - provider: ExplorerTreeProvider, - options: ExplorerSnapshotOptions, - registry: ExplorerActionRegistry, - contributedActions: ContributedMenuItem[], - commandLabels: ReadonlyMap, - commandIcons: ReadonlyMap -): Promise { - try { - return await withTimeout( - buildSectionSnapshot( - sectionId, - provider, - options, - registry, - contributedActions, - commandLabels, - commandIcons - ), - SECTION_BUILD_TIMEOUT_MS, - `Timed out while building section '${sectionId}'` - ); - } catch (error: unknown) { - console.error(`[containerlab explorer] failed to build section '${sectionId}'`, error); - return { - id: sectionId, - label: EXPLORER_SECTION_LABELS[sectionId], - count: 0, - nodes: [], - toolbarActions: toolbarActionsForSection(sectionId, registry, options) - }; - } -} - -export async function buildExplorerSnapshot( - providers: ExplorerSnapshotProviders, - filterText: string, - options: ExplorerSnapshotOptions -): Promise { - const registry = new ExplorerActionRegistry(); - const { - commands: contributedActions, - commandLabels, - commandIcons - } = await getContributedContainerActions(); - const providersBySection: Record = { - runningLabs: providers.runningProvider, - localLabs: providers.localProvider, - helpFeedback: providers.helpProvider - }; - - const sections = await Promise.all( - EXPLORER_SECTION_ORDER.map((sectionId) => - buildSectionSnapshotSafe( - sectionId, - providersBySection[sectionId], - options, - registry, - contributedActions, - commandLabels, - commandIcons - ) - ) - ); - - return { - snapshot: { - command: "snapshot", - filterText, - sections - }, - actionBindings: registry.getBindings() - }; + commandMetadataCache = undefined; + commandMetadataCachePromise = undefined; } diff --git a/src/webviews/explorer/quickActions.ts b/src/webviews/explorer/quickActions.ts index 81c951b57..067e0eab6 100644 --- a/src/webviews/explorer/quickActions.ts +++ b/src/webviews/explorer/quickActions.ts @@ -1,4 +1,4 @@ -import type { ExplorerAction } from "../shared/explorer/types"; +import type { ExplorerAction } from "@srl-labs/clab-ui/explorer"; const CONTAINER_QUICK_ACTION_COMMANDS = ["containerlab.node.attachShell", "containerlab.node.ssh"]; const LAB_QUICK_ACTION_COMMANDS = ["containerlab.lab.openFile"]; diff --git a/src/webviews/imageManager/entry.tsx b/src/webviews/imageManager/entry.tsx new file mode 100644 index 000000000..8d070427f --- /dev/null +++ b/src/webviews/imageManager/entry.tsx @@ -0,0 +1,6 @@ +import { bootstrapImageManagerWebview } from "@srl-labs/clab-ui/image-manager"; +import { createClabUiRuntime, createWindowClabUiHost } from "@srl-labs/clab-ui/host"; + +const runtime = createClabUiRuntime({ host: createWindowClabUiHost() }); + +bootstrapImageManagerWebview(runtime); diff --git a/src/webviews/imageManager/imageManagerWebviewHtml.ts b/src/webviews/imageManager/imageManagerWebviewHtml.ts new file mode 100644 index 000000000..67cb2868b --- /dev/null +++ b/src/webviews/imageManager/imageManagerWebviewHtml.ts @@ -0,0 +1,19 @@ +import type { Uri, Webview } from "vscode"; +import type { ImageManagerInitialData } from "@srl-labs/clab-ui/image-manager"; + +import { createReactWebviewHtml } from "../shared/reactWebviewHtml"; + +export function getImageManagerWebviewHtml( + webview: Webview, + extensionUri: Uri, + initialData: ImageManagerInitialData +): string { + return createReactWebviewHtml({ + webview, + extensionUri, + scriptFile: "imageManagerWebview.js", + title: "Containerlab Images", + initialData, + webviewKind: "containerlab-image-manager" + }); +} diff --git a/src/webviews/index.ts b/src/webviews/index.ts new file mode 100644 index 000000000..2d6316fd9 --- /dev/null +++ b/src/webviews/index.ts @@ -0,0 +1,7 @@ +// Dependency-cruiser entry roots for webview bundles built via esbuild.config.js. +import "./explorer/entry"; +import "./inspect/entry"; +import "./nodeImpairments/entry"; +import "./reactTopoViewer/entry"; +import "./welcome/entry"; +import "./wiresharkVnc/entry"; diff --git a/src/webviews/inspect/entry.tsx b/src/webviews/inspect/entry.tsx new file mode 100644 index 000000000..f819c0293 --- /dev/null +++ b/src/webviews/inspect/entry.tsx @@ -0,0 +1,6 @@ +import { bootstrapInspectWebview } from "@srl-labs/clab-ui/inspect"; +import { createClabUiRuntime, createWindowClabUiHost } from "@srl-labs/clab-ui/host"; + +const runtime = createClabUiRuntime({ host: createWindowClabUiHost() }); + +bootstrapInspectWebview(runtime); diff --git a/src/webviews/inspect/inspect.webview.tsx b/src/webviews/inspect/inspect.webview.tsx deleted file mode 100644 index 0032516cf..000000000 --- a/src/webviews/inspect/inspect.webview.tsx +++ /dev/null @@ -1,641 +0,0 @@ -import RefreshIcon from "@mui/icons-material/Refresh"; -import SearchIcon from "@mui/icons-material/Search"; -import { - Alert, - Box, - Button, - Chip, - InputAdornment, - Paper, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TableSortLabel, - TextField, - Typography -} from "@mui/material"; -import React from "react"; -import { createRoot } from "react-dom/client"; - -import { MuiThemeProvider } from "../../reactTopoViewer/webview/theme"; -import { usePostMessage } from "../shared/hooks"; - -import type { ContainerPort, InspectContainerData, InspectWebviewInitialData } from "./types"; - -type InspectOutgoingMessage = - | { command: "refresh" } - | { - command: "openPort"; - containerName: string; - containerId: string; - port: string | number; - protocol: string; - }; - -interface InspectRow { - containerName: string; - kind: string; - type: string; - image: string; - state: string; - status: string; - pid: string; - ipv4: string; - ipv6: string; - network: string; - owner: string; - ports: ContainerPort[]; - containerId: string; - searchText: string; -} - -interface InspectGroup { - labName: string; - rows: InspectRow[]; -} - -type ColumnId = - | "containerName" - | "kind" - | "type" - | "image" - | "state" - | "status" - | "pid" - | "ipv4" - | "ipv6" - | "network" - | "owner" - | "ports"; - -interface ColumnDefinition { - id: ColumnId; - label: string; - value: (row: InspectRow) => string; - sx?: Record; -} - -function isInspectContainerData(value: unknown): value is InspectContainerData { - return typeof value === "object" && value !== null; -} - -function toRecord(value: unknown): Record { - if (typeof value !== "object" || value === null) { - return {}; - } - const record: Record = {}; - for (const [key, entryValue] of Object.entries(value)) { - record[key] = entryValue; - } - return record; -} - -function parseInitialData(value: unknown): InspectWebviewInitialData { - const containers = toRecord(value).containers; - return { - containers: Array.isArray(containers) ? containers.filter(isInspectContainerData) : [] - }; -} - -interface SortState { - columnId: ColumnId; - direction: "asc" | "desc"; -} - -interface InspectGroupPanelProps { - readonly group: InspectGroup; - readonly activeSort?: SortState; - readonly onToggleSort: (labName: string, columnId: ColumnId) => void; - readonly onOpenPort: (payload: { - containerName: string; - containerId: string; - port: string | number; - protocol: string; - }) => void; -} - -const COLUMNS: ReadonlyArray = [ - { id: "containerName", label: "Name", value: (row) => row.containerName }, - { id: "kind", label: "Kind", value: (row) => row.kind }, - { id: "type", label: "Type", value: (row) => row.type }, - { id: "image", label: "Image", value: (row) => row.image, sx: { minWidth: 180, maxWidth: 240 } }, - { id: "state", label: "State", value: (row) => row.state }, - { id: "status", label: "Status", value: (row) => row.status }, - { id: "pid", label: "PID", value: (row) => row.pid }, - { id: "ipv4", label: "IPv4", value: (row) => row.ipv4 }, - { id: "ipv6", label: "IPv6", value: (row) => row.ipv6 }, - { id: "network", label: "Network", value: (row) => row.network }, - { id: "owner", label: "Owner", value: (row) => row.owner }, - { - id: "ports", - label: "Ports", - value: (row) => row.ports.map((port) => `${port.port}/${port.protocol}`).join(", ") - } -]; - -function firstTruthyString(...values: Array): string { - for (const value of values) { - if (typeof value === "string" && value.length > 0) { - return value; - } - } - return ""; -} - -function getLabName(container: InspectContainerData): string { - const labelFromPath = container.Labels?.["clab-topo-file"] - ?.split("/") - .slice(-1)[0] - ?.replace(".clab.yml", ""); - - return firstTruthyString( - container.lab_name, - container.labPath, - container.Labels?.containerlab, - labelFromPath, - "unknown-lab" - ); -} - -function buildInspectRow(container: InspectContainerData): InspectRow { - const containerName = firstTruthyString( - container.name, - Array.isArray(container.Names) ? container.Names[0] : "", - container.Labels?.["clab-node-longname"] - ); - - const kind = firstTruthyString(container.kind, container.Labels?.["clab-node-kind"]); - const type = firstTruthyString(container.node_type, container.Labels?.["clab-node-type"]); - const image = firstTruthyString(container.image, container.Image); - const state = firstTruthyString(container.state, container.State); - const status = firstTruthyString(container.status, container.Status); - const pid = typeof container.Pid === "number" ? String(container.Pid) : ""; - const network = firstTruthyString(container.network_name, container.NetworkName); - const owner = container.Labels?.["clab-owner"] ?? ""; - - const ipv4 = firstTruthyString( - container.ipv4_address, - container.NetworkSettings?.IPv4addr, - container.NetworkSettings?.ipv4_address - ); - - const ipv6 = firstTruthyString( - container.ipv6_address, - container.NetworkSettings?.IPv6addr, - container.NetworkSettings?.ipv6_address - ); - - const containerId = firstTruthyString( - container.ID, - container.id, - container.ShortID, - container.container_id - ); - const ports = Array.isArray(container.Ports) ? container.Ports : []; - - const searchText = [ - containerName, - kind, - type, - image, - state, - status, - pid, - ipv4, - ipv6, - network, - owner, - ports.map((port) => `${port.port}/${port.protocol}`).join(" ") - ] - .join(" ") - .toLowerCase(); - - return { - containerName, - kind, - type, - image, - state, - status, - pid, - ipv4, - ipv6, - network, - owner, - ports, - containerId, - searchText - }; -} - -function buildGroups(containers: InspectContainerData[]): InspectGroup[] { - const grouped = new Map(); - - for (const container of containers) { - const labName = getLabName(container); - const rows = grouped.get(labName) ?? []; - rows.push(buildInspectRow(container)); - grouped.set(labName, rows); - } - - return [...grouped.entries()] - .sort((left, right) => left[0].localeCompare(right[0])) - .map(([labName, rows]) => ({ labName, rows })); -} - -function parseNumericValue(value: string): number | null { - const normalized = value.trim(); - if (!/^-?\d+(\.\d+)?$/.test(normalized)) { - return null; - } - - const parsed = Number.parseFloat(normalized); - return Number.isNaN(parsed) ? null : parsed; -} - -function sortRows(rows: InspectRow[], sortState: SortState | undefined): InspectRow[] { - if (!sortState) { - return rows; - } - - const column = COLUMNS.find((candidate) => candidate.id === sortState.columnId); - if (!column) { - return rows; - } - - return [...rows].sort((left, right) => { - const leftText = column.value(left).trim(); - const rightText = column.value(right).trim(); - - const leftNumeric = parseNumericValue(leftText); - const rightNumeric = parseNumericValue(rightText); - - let result = 0; - if (leftNumeric !== null && rightNumeric !== null) { - result = leftNumeric - rightNumeric; - } else { - result = leftText.localeCompare(rightText, undefined, { sensitivity: "base" }); - } - - return sortState.direction === "asc" ? result : -result; - }); -} - -function createFilter(query: string): (value: string) => boolean { - const normalized = query.trim(); - if (!normalized) { - return () => true; - } - - try { - const looksLikeRegex = - normalized.includes("\\") || - normalized.includes("[") || - normalized.includes("(") || - normalized.includes("|") || - normalized.includes("^") || - normalized.includes("$") || - normalized.includes(".*") || - normalized.includes(".+"); - - let processedPattern = normalized; - if (!looksLikeRegex) { - const hasWildcards = /[*?#]/.test(normalized); - processedPattern = normalized - .replaceAll("*", ".*") - .replaceAll("?", ".") - .replaceAll("#", "\\d+"); - if (hasWildcards) { - processedPattern = `^${processedPattern}$`; - } - } - - const regex = new RegExp(processedPattern, "i"); - return (value: string) => regex.test(value); - } catch { - const queryLower = normalized.toLowerCase(); - return (value: string) => value.toLowerCase().includes(queryLower); - } -} - -function stateToColorToken(state: string): string { - const normalized = state.trim().toLowerCase(); - switch (normalized) { - case "running": - return "success.main"; - case "exited": - case "stopped": - return "error.main"; - default: - return "warning.main"; - } -} - -interface PortsCellProps { - readonly row: InspectRow; - readonly onOpenPort: InspectGroupPanelProps["onOpenPort"]; -} - -function PortsCell({ row, onOpenPort }: Readonly): React.JSX.Element { - if (row.ports.length === 0) { - return <>-; - } - - return ( - - {row.ports.map((port) => { - const key = `${row.containerId}-${port.port}-${port.protocol}`; - return ( - { - onOpenPort({ - containerName: row.containerName, - containerId: row.containerId, - port: port.port, - protocol: port.protocol - }); - }} - /> - ); - })} - - ); -} - -function InspectGroupPanel({ - group, - activeSort, - onToggleSort, - onOpenPort -}: Readonly): React.JSX.Element { - return ( - - - - {group.labName} - - - - - - - - - {COLUMNS.map((column) => ( - - { - onToggleSort(group.labName, column.id); - }} - > - {column.label} - - - ))} - - - - {group.rows.map((row) => ( - - {row.containerName || "-"} - {row.kind || "-"} - {row.type || "-"} - - - {row.image || "-"} - - - - - - {row.status || "-"} - {row.pid || "-"} - {row.ipv4 || "-"} - {row.ipv6 || "-"} - {row.network || "-"} - {row.owner || "-"} - - - - - ))} - -
    -
    -
    - ); -} - -function InspectApp(): React.JSX.Element { - const initialData = parseInitialData(window.__INITIAL_DATA__); - const containers = Array.isArray(initialData.containers) ? initialData.containers : []; - - const postMessage = usePostMessage(); - - const [searchText, setSearchText] = React.useState(""); - const [sortByLab, setSortByLab] = React.useState>({}); - - const groupedRows = React.useMemo(() => buildGroups(containers), [containers]); - const filter = React.useMemo(() => createFilter(searchText), [searchText]); - - const filteredGroups = React.useMemo(() => { - const hasSearch = searchText.trim().length > 0; - - return groupedRows - .map((group) => { - if (!hasSearch) { - return { - labName: group.labName, - rows: sortRows(group.rows, sortByLab[group.labName]) - }; - } - - const labMatches = filter(group.labName); - const matchingRows = labMatches - ? group.rows - : group.rows.filter((row) => filter(row.containerName) || filter(row.searchText)); - - if (matchingRows.length === 0) { - return undefined; - } - - return { - labName: group.labName, - rows: sortRows(matchingRows, sortByLab[group.labName]) - }; - }) - .filter((group): group is InspectGroup => Boolean(group)); - }, [filter, groupedRows, searchText, sortByLab]); - - const hasData = filteredGroups.length > 0; - - const handleToggleSort = React.useCallback((labName: string, columnId: ColumnId) => { - setSortByLab((current) => { - const currentSort = current[labName]; - const nextDirection = - currentSort?.columnId === columnId && currentSort.direction === "asc" ? "desc" : "asc"; - - return { - ...current, - [labName]: { - columnId, - direction: nextDirection - } - }; - }); - }, []); - - const handleOpenPort = React.useCallback( - (payload: { - containerName: string; - containerId: string; - port: string | number; - protocol: string; - }) => { - postMessage({ - command: "openPort", - containerName: payload.containerName, - containerId: payload.containerId, - port: payload.port, - protocol: payload.protocol - }); - }, - [postMessage] - ); - - return ( - - - - - Containerlab Inspect - - - - { - setSearchText(event.target.value); - }} - placeholder="Search labs or nodes" - slotProps={{ - input: { - startAdornment: ( - - - - ) - } - }} - /> - - - - - - {!hasData ? ( - - No containers found. - - ) : null} - - - {filteredGroups.map((group) => ( - - ))} - - - - - ); -} - -function bootstrapInspectWebview(): void { - const container = document.getElementById("root"); - if (!container) { - throw new Error("Inspect webview root element not found"); - } - - const root = createRoot(container); - root.render( - - - - ); -} - -bootstrapInspectWebview(); diff --git a/src/webviews/inspect/inspectWebviewHtml.ts b/src/webviews/inspect/inspectWebviewHtml.ts index 5e788a91d..00520662e 100644 --- a/src/webviews/inspect/inspectWebviewHtml.ts +++ b/src/webviews/inspect/inspectWebviewHtml.ts @@ -1,9 +1,8 @@ import type { Uri, Webview } from "vscode"; +import type { InspectWebviewInitialData } from "@srl-labs/clab-ui/inspect"; import { createReactWebviewHtml } from "../shared/reactWebviewHtml"; -import type { InspectWebviewInitialData } from "./types"; - export function getInspectWebviewHtml( webview: Webview, extensionUri: Uri, diff --git a/src/webviews/inspect/types.ts b/src/webviews/inspect/types.ts deleted file mode 100644 index 11a6c649e..000000000 --- a/src/webviews/inspect/types.ts +++ /dev/null @@ -1,53 +0,0 @@ -export interface ContainerPort { - port: string | number; - protocol: string; -} - -export interface ContainerLabels { - containerlab?: string; - "clab-topo-file"?: string; - "clab-node-longname"?: string; - "clab-node-kind"?: string; - "clab-node-type"?: string; - "clab-owner"?: string; - [key: string]: string | undefined; -} - -export interface ContainerNetworkSettings { - IPv4addr?: string; - IPv6addr?: string; - ipv4_address?: string; - ipv6_address?: string; -} - -export interface InspectContainerData { - "topo-file"?: string; - name?: string; - lab_name?: string; - labPath?: string; - state?: string; - kind?: string; - node_type?: string; - image?: string; - network_name?: string; - status?: string; - ipv4_address?: string; - ipv6_address?: string; - id?: string; - container_id?: string; - Ports?: ContainerPort[]; - Names?: string[]; - State?: string; - Image?: string; - NetworkName?: string; - Status?: string; - ID?: string; - ShortID?: string; - Pid?: number; - Labels?: ContainerLabels; - NetworkSettings?: ContainerNetworkSettings; -} - -export interface InspectWebviewInitialData { - containers: InspectContainerData[]; -} diff --git a/src/webviews/nodeImpairments/entry.tsx b/src/webviews/nodeImpairments/entry.tsx new file mode 100644 index 000000000..42a5f2ea7 --- /dev/null +++ b/src/webviews/nodeImpairments/entry.tsx @@ -0,0 +1,6 @@ +import { createClabUiRuntime, createWindowClabUiHost } from "@srl-labs/clab-ui/host"; +import { bootstrapNodeImpairmentsWebview } from "@srl-labs/clab-ui/node-impairments"; + +const runtime = createClabUiRuntime({ host: createWindowClabUiHost() }); + +bootstrapNodeImpairmentsWebview(runtime); diff --git a/src/webviews/nodeImpairments/nodeImpairments.webview.tsx b/src/webviews/nodeImpairments/nodeImpairments.webview.tsx deleted file mode 100644 index f99c1e751..000000000 --- a/src/webviews/nodeImpairments/nodeImpairments.webview.tsx +++ /dev/null @@ -1,285 +0,0 @@ -import { - Alert, - Box, - Button, - InputAdornment, - Paper, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TextField, - Typography -} from "@mui/material"; -import React from "react"; -import { createRoot } from "react-dom/client"; - -import { MuiThemeProvider } from "../../reactTopoViewer/webview/theme"; -import { useMessageListener, usePostMessage } from "../shared/hooks"; - -import type { NetemDataMap, NetemFields, NodeImpairmentsInitialData } from "./types"; - -type NodeImpairmentsOutgoingMessage = - | { command: "apply"; data: NetemDataMap } - | { command: "clearAll" } - | { command: "refresh" }; - -interface NodeImpairmentsUpdateMessage { - command: "updateFields"; - data?: Record>; -} - -type NodeImpairmentsIncomingMessage = NodeImpairmentsUpdateMessage; - -const FIELD_META: ReadonlyArray<{ - key: keyof NetemFields; - label: string; - unit: string; - placeholder: string; - inputType: "text" | "number"; -}> = [ - { key: "delay", label: "Delay", unit: "ms/s/m", placeholder: "50", inputType: "text" }, - { key: "jitter", label: "Jitter", unit: "ms/s", placeholder: "10", inputType: "text" }, - { key: "loss", label: "Loss", unit: "%", placeholder: "0", inputType: "text" }, - { key: "rate", label: "Rate-limit", unit: "kb/s", placeholder: "1000", inputType: "number" }, - { - key: "corruption", - label: "Corruption", - unit: "%", - placeholder: "0", - inputType: "text" - } -]; - -function normalizeNetemFields(fields?: unknown): NetemFields { - const readField = (key: keyof NetemFields): string => { - if (typeof fields !== "object" || fields === null) { - return ""; - } - const value: unknown = Reflect.get(fields, key); - return typeof value === "string" ? value : ""; - }; - - return { - delay: readField("delay"), - jitter: readField("jitter"), - loss: readField("loss"), - rate: readField("rate"), - corruption: readField("corruption") - }; -} - -function normalizeNetemMap(data?: unknown): NetemDataMap { - const normalized: NetemDataMap = {}; - if (typeof data !== "object" || data === null) { - return normalized; - } - for (const [iface, fields] of Object.entries(data)) { - normalized[iface] = normalizeNetemFields(fields); - } - return normalized; -} - -function parseInitialData(value: unknown): NodeImpairmentsInitialData { - const nodeNameRaw: unknown = - typeof value === "object" && value !== null ? Reflect.get(value, "nodeName") : undefined; - const interfacesDataRaw: unknown = - typeof value === "object" && value !== null ? Reflect.get(value, "interfacesData") : undefined; - return { - nodeName: typeof nodeNameRaw === "string" ? nodeNameRaw : "", - interfacesData: normalizeNetemMap(interfacesDataRaw) - }; -} - -function hasDelayValidationError(fields: NetemFields): boolean { - const jitter = Number.parseFloat(fields.jitter) || 0; - const delay = Number.parseFloat(fields.delay) || 0; - return jitter > 0 && delay <= 0; -} - -function NodeImpairmentsApp(): React.JSX.Element { - const initialData = parseInitialData(window.__INITIAL_DATA__); - const nodeName = initialData.nodeName; - - const postMessage = usePostMessage(); - - const [netemByInterface, setNetemByInterface] = React.useState(() => - normalizeNetemMap(initialData.interfacesData) - ); - - const sortedInterfaces = React.useMemo( - () => Object.keys(netemByInterface).sort((left, right) => left.localeCompare(right)), - [netemByInterface] - ); - - useMessageListener((message) => { - setNetemByInterface(normalizeNetemMap(message.data)); - }); - - const updateField = React.useCallback( - (iface: string, field: keyof NetemFields, nextValue: string) => { - setNetemByInterface((current) => { - const currentFields = current[iface] ?? normalizeNetemFields(); - return { - ...current, - [iface]: { - ...currentFields, - [field]: nextValue - } - }; - }); - }, - [] - ); - - return ( - - - - - Link Impairments: {nodeName} - - - - - - - - - - - {sortedInterfaces.length === 0 ? ( - - No interfaces available for this node. - - ) : ( - - - - - Interface - {FIELD_META.map((field) => ( - - {field.label} - - ))} - - - - {sortedInterfaces.map((iface) => { - const fields = netemByInterface[iface]; - const hasValidationError = hasDelayValidationError(fields); - - return ( - - - {iface} - - {FIELD_META.map((field) => { - const showDelayMessage = field.key === "delay" && hasValidationError; - const isErrorField = - hasValidationError && (field.key === "delay" || field.key === "jitter"); - - return ( - - { - updateField(iface, field.key, event.target.value); - }} - slotProps={{ - input: { - endAdornment: ( - {field.unit} - ) - } - }} - /> - - ); - })} - - ); - })} - -
    -
    - )} -
    -
    -
    - ); -} - -function bootstrapNodeImpairmentsWebview(): void { - const container = document.getElementById("root"); - if (!container) { - throw new Error("Node impairments root element not found"); - } - - const root = createRoot(container); - root.render( - - - - ); -} - -bootstrapNodeImpairmentsWebview(); diff --git a/src/webviews/nodeImpairments/nodeImpairmentsWebviewHtml.ts b/src/webviews/nodeImpairments/nodeImpairmentsWebviewHtml.ts index 4929d087e..94edb8ceb 100644 --- a/src/webviews/nodeImpairments/nodeImpairmentsWebviewHtml.ts +++ b/src/webviews/nodeImpairments/nodeImpairmentsWebviewHtml.ts @@ -1,9 +1,8 @@ import type { Uri, Webview } from "vscode"; +import type { NodeImpairmentsInitialData } from "@srl-labs/clab-ui/node-impairments"; import { createReactWebviewHtml } from "../shared/reactWebviewHtml"; -import type { NodeImpairmentsInitialData } from "./types"; - export function getNodeImpairmentsWebviewHtml( webview: Webview, extensionUri: Uri, diff --git a/src/webviews/nodeImpairments/types.ts b/src/webviews/nodeImpairments/types.ts deleted file mode 100644 index c89b93930..000000000 --- a/src/webviews/nodeImpairments/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface NetemFields { - delay: string; - jitter: string; - loss: string; - rate: string; - corruption: string; -} - -export type NetemDataMap = Record; - -export interface NodeImpairmentsInitialData { - nodeName: string; - interfacesData: NetemDataMap; -} diff --git a/src/webviews/reactTopoViewer/entry.tsx b/src/webviews/reactTopoViewer/entry.tsx new file mode 100644 index 000000000..f477397bc --- /dev/null +++ b/src/webviews/reactTopoViewer/entry.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; + +import { App, subscribeToWebviewMessages, log } from "@srl-labs/clab-ui"; +import { createClabUiRuntime, createWindowClabUiHost } from "@srl-labs/clab-ui/host"; +import "@srl-labs/clab-ui/styles/global.css"; + +type TopoViewerWindow = Window & { + __SCHEMA_DATA__?: unknown; + __DOCKER_IMAGES__?: string[]; + __INITIAL_DATA__?: unknown; +}; + +const topoViewerWindow = window as TopoViewerWindow; + +const runtime = createClabUiRuntime({ host: createWindowClabUiHost() }); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function asStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + return value.every((entry) => typeof entry === "string") + ? value + : value.filter((entry): entry is string => typeof entry === "string"); +} + +const initialDataSource = topoViewerWindow.__INITIAL_DATA__; +const initialData = isRecord(initialDataSource) ? initialDataSource : {}; + +if ("schemaData" in initialData) { + topoViewerWindow.__SCHEMA_DATA__ = initialData.schemaData; +} + +const bootstrapDockerImages = asStringArray(initialData.dockerImages); +if (bootstrapDockerImages !== undefined) { + topoViewerWindow.__DOCKER_IMAGES__ = bootstrapDockerImages; +} + +subscribeToWebviewMessages( + (event) => { + const message = isRecord(event.data) ? event.data : undefined; + const dockerImages = asStringArray(message?.dockerImages); + if (message?.type === "docker-images-updated" && dockerImages !== undefined) { + topoViewerWindow.__DOCKER_IMAGES__ = dockerImages; + topoViewerWindow.dispatchEvent( + new CustomEvent("docker-images-updated", { + detail: dockerImages + }) + ); + } + }, + undefined, + runtime.host +); + +const customNodeCount = Array.isArray(initialData.customNodes) ? initialData.customNodes.length : 0; +const iconCount = Array.isArray(initialData.customIcons) ? initialData.customIcons.length : 0; +log.info( + `[ReactTopoViewer] Bootstrap data loaded (customNodes: ${customNodeCount}, customIcons: ${iconCount})` +); + +function bootstrap(): void { + const container = document.getElementById("root"); + if (!container) { + throw new Error("Root element not found"); + } + + const root = createRoot(container); + root.render( + + + + ); +} + +bootstrap(); diff --git a/src/webviews/shared/explorer/types.ts b/src/webviews/shared/explorer/types.ts deleted file mode 100644 index 0750d0efc..000000000 --- a/src/webviews/shared/explorer/types.ts +++ /dev/null @@ -1,103 +0,0 @@ -export type ExplorerSectionId = "runningLabs" | "localLabs" | "helpFeedback"; - -export const EXPLORER_SECTION_ORDER: ExplorerSectionId[] = [ - "runningLabs", - "localLabs", - "helpFeedback" -]; - -export const EXPLORER_SECTION_LABELS: Record = { - runningLabs: "Running Labs", - localLabs: "Undeployed Local Labs", - helpFeedback: "Help & Feedback" -}; - -export interface ExplorerAction { - id: string; - actionRef: string; - label: string; - commandId: string; - iconId?: string; - destructive?: boolean; -} - -export interface ExplorerNode { - id: string; - label: string; - description?: string; - tooltip?: string; - contextValue?: string; - statusIndicator?: "green" | "red" | "yellow" | "blue" | "gray"; - statusDescription?: string; - primaryAction?: ExplorerAction; - shareAction?: ExplorerAction; - actions: ExplorerAction[]; - children: ExplorerNode[]; -} - -export interface ExplorerSectionSnapshot { - id: ExplorerSectionId; - label: string; - count: number; - nodes: ExplorerNode[]; - toolbarActions: ExplorerAction[]; -} - -export interface ExplorerUiState { - sectionOrder?: ExplorerSectionId[]; - collapsedBySection?: Partial>; - expandedBySection?: Partial>; - heightRatioBySection?: Partial>; -} - -export interface ExplorerSnapshotMessage { - command: "snapshot"; - filterText: string; - sections: ExplorerSectionSnapshot[]; -} - -export interface ExplorerFilterStateMessage { - command: "filterState"; - filterText: string; -} - -export interface ExplorerUiStateMessage { - command: "uiState"; - state: ExplorerUiState; -} - -export interface ExplorerErrorMessage { - command: "error"; - message: string; -} - -export type ExplorerIncomingMessage = - | ExplorerSnapshotMessage - | ExplorerFilterStateMessage - | ExplorerUiStateMessage - | ExplorerErrorMessage; - -export interface ExplorerReadyMessage { - command: "ready"; -} - -export interface ExplorerSetFilterMessage { - command: "setFilter"; - value: string; -} - -export interface ExplorerInvokeActionMessage { - command: "invokeAction"; - actionRef: string; -} - -export interface ExplorerPersistUiStateMessage { - command: "persistUiState"; - state: ExplorerUiState; -} - -export type ExplorerOutgoingMessage = - | ExplorerReadyMessage - | ExplorerSetFilterMessage - | ExplorerInvokeActionMessage - | ExplorerPersistUiStateMessage; diff --git a/src/webviews/shared/globals.d.ts b/src/webviews/shared/globals.d.ts new file mode 100644 index 000000000..68301b948 --- /dev/null +++ b/src/webviews/shared/globals.d.ts @@ -0,0 +1,28 @@ +declare module "*.svg" { + const src: string; + export default src; +} + +declare module "*.png" { + const src: string; + export default src; +} + +declare module "*.jpg" { + const src: string; + export default src; +} + +declare module "*.gif" { + const src: string; + export default src; +} + +interface Window { + __INITIAL_DATA__?: unknown; + vscode?: { + postMessage(message: unknown): void; + getState?(): unknown; + setState?(state: unknown): void; + }; +} diff --git a/src/webviews/shared/hooks/index.ts b/src/webviews/shared/hooks/index.ts deleted file mode 100644 index 00e9b1f7f..000000000 --- a/src/webviews/shared/hooks/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { useMessageListener } from "./useMessageListener"; -export { usePostMessage } from "./usePostMessage"; -export { useReadySignal } from "./useReadySignal"; diff --git a/src/webviews/shared/hooks/useMessageListener.ts b/src/webviews/shared/hooks/useMessageListener.ts deleted file mode 100644 index bea41ed0a..000000000 --- a/src/webviews/shared/hooks/useMessageListener.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useEffect, useRef } from "react"; - -interface WebviewMessage { - command: string; -} - -export function useMessageListener(handler: (message: T) => void): void { - const handlerRef = useRef(handler); - - useEffect(() => { - handlerRef.current = handler; - }, [handler]); - - useEffect(() => { - const listener = (event: MessageEvent) => { - handlerRef.current(event.data); - }; - window.addEventListener("message", listener); - return () => window.removeEventListener("message", listener); - }, []); -} diff --git a/src/webviews/shared/hooks/usePostMessage.ts b/src/webviews/shared/hooks/usePostMessage.ts deleted file mode 100644 index 27d175285..000000000 --- a/src/webviews/shared/hooks/usePostMessage.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useCallback } from "react"; - -import { getVSCodeApi } from "./useVsCodeApi"; - -export function usePostMessage(): (message: T) => void { - return useCallback((message: T) => { - getVSCodeApi().postMessage(message); - }, []); -} diff --git a/src/webviews/shared/hooks/useReadySignal.ts b/src/webviews/shared/hooks/useReadySignal.ts deleted file mode 100644 index 7fbc8189e..000000000 --- a/src/webviews/shared/hooks/useReadySignal.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useEffect, useRef } from "react"; - -import { usePostMessage } from "./usePostMessage"; - -export function useReadySignal(): void { - const postMessage = usePostMessage(); - const sentRef = useRef(false); - - useEffect(() => { - if (sentRef.current) { - return; - } - - sentRef.current = true; - postMessage({ command: "ready" }); - }, [postMessage]); -} diff --git a/src/webviews/shared/hooks/useVsCodeApi.ts b/src/webviews/shared/hooks/useVsCodeApi.ts deleted file mode 100644 index 173d04b67..000000000 --- a/src/webviews/shared/hooks/useVsCodeApi.ts +++ /dev/null @@ -1,84 +0,0 @@ -interface VsCodeApiLike { - postMessage(message: unknown): void; - getState(): unknown; - setState(state: unknown): void; -} -type WindowVsCodeWithState = { - postMessage(message: unknown): void; - getState?: () => unknown; - setState?: (state: unknown) => void; -}; - -let vscodeApi: VsCodeApiLike | undefined; -let fallbackState: unknown; - -function hasVsCodeApi(value: unknown): value is VsCodeApiLike { - if (typeof value !== "object" || value === null) { - return false; - } - - const maybeApi = value as Partial; - return ( - typeof maybeApi.postMessage === "function" && - typeof maybeApi.getState === "function" && - typeof maybeApi.setState === "function" - ); -} - -function fromAcquire(): VsCodeApiLike | undefined { - const maybeAcquire = ( - globalThis as typeof globalThis & { - acquireVsCodeApi?: () => unknown; - } - ).acquireVsCodeApi; - - if (typeof maybeAcquire !== "function") { - return undefined; - } - - try { - const api = maybeAcquire(); - return hasVsCodeApi(api) ? api : undefined; - } catch { - return undefined; - } -} - -function fromWindow(): VsCodeApiLike | undefined { - if (typeof window === "undefined" || !window.vscode) { - return undefined; - } - - const vscode = window.vscode as WindowVsCodeWithState; - - return { - postMessage: (message: unknown) => vscode.postMessage(message), - getState: () => { - const getter = vscode.getState; - if (typeof getter === "function") { - return getter(); - } - return fallbackState; - }, - setState: (state: unknown) => { - const setter = vscode.setState; - fallbackState = state; - if (typeof setter === "function") { - setter(state); - } - } - }; -} - -export function getVSCodeApi(): VsCodeApiLike { - // Prefer an already-acquired API instance if present. - // Some webviews bootstrap window.vscode up-front, and a second - // acquireVsCodeApi() call can throw. - vscodeApi ??= fromWindow() ?? fromAcquire(); - - if (!vscodeApi) { - throw new Error("VS Code API is unavailable in this webview context."); - } - - return vscodeApi; -} diff --git a/src/webviews/shared/reactWebviewHtml.ts b/src/webviews/shared/reactWebviewHtml.ts index 0f8acfe11..3a79337e9 100644 --- a/src/webviews/shared/reactWebviewHtml.ts +++ b/src/webviews/shared/reactWebviewHtml.ts @@ -30,7 +30,9 @@ export function createReactWebviewHtml(options: ReactWebviewHtmlOptions): string frameSrc } = options; - const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, "dist", scriptFile)); + const scriptUri = webview + .asWebviewUri(vscode.Uri.joinPath(extensionUri, "dist", scriptFile)) + .toString(); const nonce = randomBytes(16).toString("hex"); const connectDirective = uniqueSources([webview.cspSource, ...(connectSrc ?? [])]); diff --git a/src/webviews/welcome/entry.tsx b/src/webviews/welcome/entry.tsx new file mode 100644 index 000000000..d5b9cf07c --- /dev/null +++ b/src/webviews/welcome/entry.tsx @@ -0,0 +1,6 @@ +import { createClabUiRuntime, createWindowClabUiHost } from "@srl-labs/clab-ui/host"; +import { bootstrapWelcomePage } from "@srl-labs/clab-ui/welcome"; + +const runtime = createClabUiRuntime({ host: createWindowClabUiHost() }); + +bootstrapWelcomePage(runtime); diff --git a/src/webviews/welcome/welcomePage.webview.tsx b/src/webviews/welcome/welcomePage.webview.tsx deleted file mode 100644 index ad9c39c37..000000000 --- a/src/webviews/welcome/welcomePage.webview.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import LaunchIcon from "@mui/icons-material/Launch"; -import StarIcon from "@mui/icons-material/Star"; -import { - Alert, - Box, - Button, - Chip, - CircularProgress, - Container, - Divider, - FormControlLabel, - Link, - List, - ListItem, - ListItemButton, - Paper, - Stack, - Typography, - Checkbox -} from "@mui/material"; -import React from "react"; -import { createRoot } from "react-dom/client"; - -import { MuiThemeProvider } from "../../reactTopoViewer/webview/theme"; -import { useMessageListener, usePostMessage } from "../shared/hooks"; -import containerlabLogo from "../../../resources/containerlab.svg"; - -interface PopularRepo { - name: string; - html_url: string; - description: string; - stargazers_count: number; -} - -interface WelcomeInitialData { - extensionVersion?: string; -} - -interface WelcomeReposLoadedMessage { - command: "reposLoaded"; - repos?: PopularRepo[]; - usingFallback?: boolean; -} - -type WelcomeIncomingMessage = WelcomeReposLoadedMessage; - -type WelcomeOutgoingMessage = - | { command: "createExample" } - | { command: "dontShowAgain"; value: boolean } - | { command: "getRepos" }; - -const RESOURCE_LINKS: ReadonlyArray<{ label: string; href: string }> = [ - { label: "Containerlab Documentation", href: "https://containerlab.dev/" }, - { - label: "VS Code Extension Documentation", - href: "https://containerlab.dev/manual/vsc-extension/" - }, - { label: "Browse Labs on GitHub (srl-labs)", href: "https://github.com/srl-labs/" }, - { - label: 'Find more labs tagged with "clab-topo"', - href: "https://github.com/search?q=topic%3Aclab-topo++fork%3Atrue&type=repositories" - }, - { label: "Join our Discord server", href: "https://discord.gg/vAyddtaEV9" }, - { - label: "Download cshargextcap Wireshark plugin", - href: "https://github.com/siemens/cshargextcap/releases/latest" - } -]; - -const COMMUNITY_LINKS: ReadonlyArray<{ label: string; href: string }> = [ - { - label: "Extension Releases", - href: "https://github.com/srl-labs/vscode-containerlab/releases/" - }, - { - label: "Containerlab Latest Release", - href: "https://github.com/srl-labs/containerlab/releases/latest" - }, - { - label: "Containerlab Release History", - href: "https://github.com/srl-labs/containerlab/releases/" - }, - { label: "Discord", href: "https://discord.gg/vAyddtaEV9" } -]; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function toWelcomeInitialData(value: unknown): WelcomeInitialData { - if (!isRecord(value)) { - return {}; - } - return { - extensionVersion: - typeof value.extensionVersion === "string" ? value.extensionVersion : undefined - }; -} - -function WelcomePageApp(): React.JSX.Element { - const initialData = toWelcomeInitialData(window.__INITIAL_DATA__); - const extensionVersion = initialData.extensionVersion ?? "unknown"; - - const postMessage = usePostMessage(); - - const [dontShowAgain, setDontShowAgain] = React.useState(false); - const [repos, setRepos] = React.useState([]); - const [usingFallback, setUsingFallback] = React.useState(false); - const [isLoadingRepos, setIsLoadingRepos] = React.useState(true); - - useMessageListener((message) => { - setRepos(Array.isArray(message.repos) ? message.repos : []); - setUsingFallback(Boolean(message.usingFallback)); - setIsLoadingRepos(false); - }); - - React.useEffect(() => { - postMessage({ command: "getRepos" }); - }, [postMessage]); - - return ( - - - - theme.alpha(theme.palette.background.paper, 0.92) - }} - > - - - - - - Welcome to Containerlab - - - - {COMMUNITY_LINKS.map((link) => ( - } - /> - ))} - - - - - - - - - - Getting Started - - The Containerlab extension integrates containerlab directly into VS Code, - providing an explorer for managing labs and containers. - - - Create, deploy, and manage network topologies with just a few clicks. - - - - - Creates `example.clab.yml` in your current workspace. - - - - - - Documentation and Resources - - {RESOURCE_LINKS.map((link) => ( - - - {link.label} - - - ))} - - - { - const checked = event.target.checked; - setDontShowAgain(checked); - postMessage({ command: "dontShowAgain", value: checked }); - }} - /> - } - label="Don't show this page again" - /> - - - - - Popular Topologies - - {usingFallback ? ( - - Using cached repository data due to GitHub API limits or temporary failures. - - ) : null} - - {isLoadingRepos ? ( - - - Loading popular repositories... - - ) : null} - - {!isLoadingRepos && repos.length === 0 ? ( - - No repositories found. - - ) : null} - - {!isLoadingRepos && repos.length > 0 ? ( - - {repos.map((repo) => ( - - - - - - {repo.name} - - } - label={repo.stargazers_count} - sx={{ - "& .MuiChip-icon": { - color: "warning.main" - } - }} - /> - - - {repo.description || "No description available"} - - - - - ))} - - ) : null} - - - - - - - - ); -} - -function bootstrapWelcomePage(): void { - const container = document.getElementById("root"); - if (!container) { - throw new Error("Welcome page root element not found"); - } - - const root = createRoot(container); - root.render( - - - - ); -} - -bootstrapWelcomePage(); diff --git a/src/webviews/wiresharkVnc/entry.tsx b/src/webviews/wiresharkVnc/entry.tsx new file mode 100644 index 000000000..1df8b45fe --- /dev/null +++ b/src/webviews/wiresharkVnc/entry.tsx @@ -0,0 +1,6 @@ +import { createClabUiRuntime, createWindowClabUiHost } from "@srl-labs/clab-ui/host"; +import { bootstrapWiresharkVncWebview } from "@srl-labs/clab-ui/wireshark-vnc"; + +const runtime = createClabUiRuntime({ host: createWindowClabUiHost() }); + +bootstrapWiresharkVncWebview(runtime); diff --git a/src/webviews/wiresharkVnc/types.ts b/src/webviews/wiresharkVnc/types.ts deleted file mode 100644 index 273d2d429..000000000 --- a/src/webviews/wiresharkVnc/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface WiresharkVncInitialData { - iframeUrl: string; - showVolumeTip: boolean; -} diff --git a/src/webviews/wiresharkVnc/wiresharkVnc.webview.tsx b/src/webviews/wiresharkVnc/wiresharkVnc.webview.tsx deleted file mode 100644 index 798b82ef2..000000000 --- a/src/webviews/wiresharkVnc/wiresharkVnc.webview.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { Alert, Box, CircularProgress, Paper, Stack, Typography } from "@mui/material"; -import React from "react"; -import { createRoot } from "react-dom/client"; - -import { MuiThemeProvider } from "../../reactTopoViewer/webview/theme"; -import { usePostMessage } from "../shared/hooks"; - -import type { WiresharkVncInitialData } from "./types"; - -type WiresharkVncOutgoingMessage = { type: "retry-check" }; - -interface VncProgressMessage { - type: "vnc-progress"; - attempt?: number; - maxAttempts?: number; -} - -interface VncReadyMessage { - type: "vnc-ready"; - url?: string; -} - -interface VncTimeoutMessage { - type: "vnc-timeout"; - url?: string; -} - -type WiresharkVncIncomingMessage = VncProgressMessage | VncReadyMessage | VncTimeoutMessage; - -function parseInitialData(value: unknown): WiresharkVncInitialData { - const iframeUrlRaw: unknown = - typeof value === "object" && value !== null ? Reflect.get(value, "iframeUrl") : undefined; - const showVolumeTipRaw: unknown = - typeof value === "object" && value !== null ? Reflect.get(value, "showVolumeTip") : undefined; - return { - iframeUrl: typeof iframeUrlRaw === "string" ? iframeUrlRaw : "", - showVolumeTip: showVolumeTipRaw === true - }; -} - -function isIncomingMessage(value: unknown): value is WiresharkVncIncomingMessage { - if (typeof value !== "object" || value === null) { - return false; - } - const type: unknown = Reflect.get(value, "type"); - return type === "vnc-progress" || type === "vnc-ready" || type === "vnc-timeout"; -} - -function appendCacheBuster(url: string): string { - const separator = url.includes("?") ? "&" : "?"; - return `${url}${separator}t=${Date.now()}`; -} - -function WiresharkVncApp(): React.JSX.Element { - const initialData: WiresharkVncInitialData = parseInitialData(window.__INITIAL_DATA__); - const fallbackUrl = initialData.iframeUrl; - const showVolumeTip = initialData.showVolumeTip; - - const postMessage = usePostMessage(); - - const latestUrlRef = React.useRef(fallbackUrl); - const pendingRetryRef = React.useRef(false); - - const [isLoading, setIsLoading] = React.useState(true); - const [isFrameVisible, setIsFrameVisible] = React.useState(false); - const [iframeSrc, setIframeSrc] = React.useState(""); - const [retryInfo, setRetryInfo] = React.useState(""); - - const loadVnc = React.useCallback( - (url?: string, forceReload = false) => { - const nextUrl = ((url ?? latestUrlRef.current) || fallbackUrl).trim(); - if (!nextUrl) { - return; - } - - latestUrlRef.current = nextUrl; - const targetUrl = forceReload ? appendCacheBuster(nextUrl) : nextUrl; - setIsLoading(true); - setIsFrameVisible(false); - setIframeSrc(targetUrl); - }, - [fallbackUrl] - ); - - React.useEffect(() => { - const listener = (event: MessageEvent) => { - const message = event.data; - if (!isIncomingMessage(message)) { - return; - } - - switch (message.type) { - case "vnc-progress": { - pendingRetryRef.current = false; - const attempt = typeof message.attempt === "number" ? message.attempt : 0; - const maxAttempts = typeof message.maxAttempts === "number" ? message.maxAttempts : 0; - - if (attempt <= 1) { - setRetryInfo("Waiting for VNC server..."); - } else if (maxAttempts > 0) { - setRetryInfo(`Waiting for VNC server... (attempt ${attempt}/${maxAttempts})`); - } else { - setRetryInfo(`Waiting for VNC server... (attempt ${attempt})`); - } - - break; - } - case "vnc-ready": { - pendingRetryRef.current = false; - setRetryInfo("VNC server ready, loading..."); - loadVnc(message.url, false); - break; - } - case "vnc-timeout": { - pendingRetryRef.current = false; - setRetryInfo("Connection timeout - attempting to load anyway..."); - loadVnc(message.url, true); - break; - } - } - }; - - window.addEventListener("message", listener); - return () => window.removeEventListener("message", listener); - }, [loadVnc]); - - React.useEffect(() => { - postMessage({ type: "retry-check" }); - }, [postMessage]); - - return ( - - - { - setIsLoading(false); - setIsFrameVisible(true); - setRetryInfo(""); - pendingRetryRef.current = false; - }} - onError={() => { - setIsLoading(true); - setIsFrameVisible(false); - setRetryInfo("Connection failed - retrying..."); - - if (!pendingRetryRef.current) { - pendingRetryRef.current = true; - postMessage({ type: "retry-check" }); - } - }} - sx={{ - border: 0, - position: "absolute", - inset: 0, - width: "100%", - height: "100%", - display: isFrameVisible ? "block" : "none" - }} - /> - - {isLoading ? ( - - theme.alpha(theme.palette.background.paper, 0.92) - }} - > - - - Loading Wireshark... - {showVolumeTip ? ( - - Tip: Save pcap files to `/pcaps` to persist them in the lab directory. - - ) : null} - {retryInfo ? ( - - {retryInfo} - - ) : null} - - - - ) : null} - - - ); -} - -function bootstrapWiresharkVncWebview(): void { - const container = document.getElementById("root"); - if (!container) { - throw new Error("Wireshark VNC root element not found"); - } - - const root = createRoot(container); - root.render( - - - - ); -} - -bootstrapWiresharkVncWebview(); diff --git a/src/webviews/wiresharkVnc/wiresharkVncWebviewHtml.ts b/src/webviews/wiresharkVnc/wiresharkVncWebviewHtml.ts index 45ded40d1..cba3e904b 100644 --- a/src/webviews/wiresharkVnc/wiresharkVncWebviewHtml.ts +++ b/src/webviews/wiresharkVnc/wiresharkVncWebviewHtml.ts @@ -1,9 +1,8 @@ import type { Uri, Webview } from "vscode"; +import type { WiresharkVncInitialData } from "@srl-labs/clab-ui/wireshark-vnc"; import { createReactWebviewHtml } from "../shared/reactWebviewHtml"; -import type { WiresharkVncInitialData } from "./types"; - export function getWiresharkVncWebviewHtml( webview: Webview, extensionUri: Uri, diff --git a/test/e2e/fixtures/topoviewer.ts b/test/e2e/fixtures/topoviewer.ts deleted file mode 100644 index feae1a363..000000000 --- a/test/e2e/fixtures/topoviewer.ts +++ /dev/null @@ -1,1794 +0,0 @@ -import { randomUUID } from "crypto"; -import { writeFileSync } from "fs"; -import * as path from "path"; - -import type { Locator, TestInfo } from "@playwright/test"; -import { test as base } from "@playwright/test"; - -// Test selectors -const CANVAS_SELECTOR = ".react-flow"; - -// Node type constants (used in browser-side code) -const TOPOLOGY_NODE_TYPE = "topology-node"; -const NETWORK_NODE_TYPE = "network-node"; - -// Topologies directory path (must match dev server config) -const TOPOLOGIES_DIR = path.resolve(__dirname, "../../../dev/topologies"); - -/** - * Generate a unique session ID for test isolation - */ -function generateSessionId(): string { - return `test-${randomUUID()}`; -} - -// Type definitions for browser-side evaluation results -interface CreateGroupResult { - method: string; - selectedBefore: string[]; - selectedAfter?: string[]; - mode?: string; - isLocked?: boolean; - groupsBefore: number; - groupsAfter: number | null; - reactGroupsBefore: number; - reactGroupsAfter?: number; - hasRf?: boolean; -} - -interface GroupDebugInfo { - reactGroupCount: number; - reactGroupsDirectCount: number; - stateManagerGroupCount: number; - reactGroupIds: string[]; - stateManagerGroupIds: string[]; -} - -interface EdgeMatchParams { - sourceId: string; - targetId: string; - sourceEndpoint: string; - targetEndpoint: string; -} - -type BrowserMode = "edit" | "view"; - -interface BrowserNodeLike { - id: string; - type?: string; - selected?: boolean; - position: { x: number; y: number }; - data?: { - sourceEndpoint?: string; - targetEndpoint?: string; - [key: string]: unknown; - }; - [key: string]: unknown; -} - -interface BrowserEdgeLike { - id: string; - source: string; - target: string; - selected?: boolean; - sourceEndpoint?: string; - targetEndpoint?: string; - data?: { - sourceEndpoint?: string; - targetEndpoint?: string; - [key: string]: unknown; - }; - [key: string]: unknown; -} - -interface BrowserReactFlowLike { - getNodes?: () => BrowserNodeLike[]; - setNodes?: (nodes: BrowserNodeLike[]) => void; - getEdges?: () => BrowserEdgeLike[]; - setEdges?: (edges: BrowserEdgeLike[]) => void; - getViewport?: () => { x: number; y: number; zoom: number }; - setViewport?: (viewport: { x: number; y: number; zoom: number }) => void; - fitView?: (options?: { padding?: number; maxZoom?: number }) => void; -} - -interface BrowserDevApi { - rfInstance?: BrowserReactFlowLike | null; - mode?: () => string; - setMode?: (mode: BrowserMode) => void; - setModeState?: (mode: BrowserMode) => void; - isLocked?: () => boolean; - setLocked?: (locked: boolean) => void; - setLayout?: (layout: string) => void; - getReactGroups?: () => Array<{ id: string }>; - groupsCount?: number; - createGroupFromSelected?: () => void; - loadTopologyFile?: (file: string, sessionId: string) => Promise; - getCurrentFile?: () => unknown; - selectNodesForClipboard?: (ids: string[]) => void; - selectNode?: (id: string) => void; - selectedNode?: () => string | null; - clearNodeSelection?: () => void; - selectEdge?: (id: string) => void; - selectedEdge?: () => string | null; - undoRedo?: { canUndo?: boolean; canRedo?: boolean }; - handleNodeCreatedCallback?: ( - nodeId: string, - nodeElement: Record, - position: { x: number; y: number } - ) => void; - handleEdgeCreated?: ( - sourceId: string, - targetId: string, - edgeData: { - id: string; - source: string; - target: string; - sourceEndpoint: string; - targetEndpoint: string; - } - ) => void; - createNetworkAtPosition?: (position: { x: number; y: number }, networkType: string) => unknown; - stateManager?: { - groups?: { selectGroup?: (id: string) => void }; - getAnnotations?: () => { nodeAnnotations?: Array<{ id: string; group?: string }> } | undefined; - }; - groups?: { selectGroup?: (id: string) => void }; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -const dispatchGroupKeyboardEvent = (): void => { - const event = new KeyboardEvent("keydown", { - key: "g", - ctrlKey: true, - bubbles: true, - cancelable: true - }); - window.dispatchEvent(event); -}; - -/** - * Browser-side function to create a group from selected nodes. - * Note: Helper functions must be inlined because page.evaluate() serializes only the function body. - */ -function browserCreateGroup(): CreateGroupResult { - const dev = ( - window as { - __DEV__?: BrowserDevApi; - } - ).__DEV__; - const rf = dev?.rfInstance; - const getSelected = (): string[] => { - if (rf == null) return []; - const nodes = rf.getNodes?.() ?? []; - return nodes.filter((node) => node.selected === true).map((node) => node.id); - }; - const getGroupCount = (): number => (dev?.getReactGroups?.() ?? []).length; - - const base = { - selectedBefore: getSelected(), - mode: dev?.mode?.(), - isLocked: dev?.isLocked?.(), - groupsBefore: getGroupCount(), - reactGroupsBefore: getGroupCount() - }; - - if (!dev?.createGroupFromSelected) { - dispatchGroupKeyboardEvent(); - return { method: "keyboard", ...base, groupsAfter: null }; - } - - dev.createGroupFromSelected(); - return { - method: "direct", - ...base, - selectedAfter: getSelected(), - groupsAfter: getGroupCount(), - reactGroupsAfter: getGroupCount(), - hasRf: rf != null - }; -} - -/** - * Browser-side edge matcher for createLink waitForFunction. - * Note: Helper functions must be inlined because page.waitForFunction() serializes only the function body. - */ -function browserHasMatchingEdge({ - sourceId, - targetId, - sourceEndpoint, - targetEndpoint -}: EdgeMatchParams): boolean { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (rf == null) return false; - const edges = rf.getEdges?.() ?? []; - const specialPrefixes = ["host:", "mgmt-net:", "macvlan:", "vxlan:", "vxlan-stitch:", "dummy"]; - const isSpecial = (id: string) => specialPrefixes.some((prefix) => id.startsWith(prefix)); - const matchNode = (edgeNode: string, expected: string) => { - if (edgeNode === expected) return true; - return isSpecial(expected) && edgeNode.startsWith(`${expected}:`); - }; - const matches = ( - edge: BrowserEdgeLike, - expectedSource: string, - expectedTarget: string, - expectedSourceEndpoint: string, - expectedTargetEndpoint: string - ) => { - if (!matchNode(edge.source, expectedSource) || !matchNode(edge.target, expectedTarget)) { - return false; - } - const edgeSourceEndpoint = edge.data?.sourceEndpoint ?? edge.sourceEndpoint; - const edgeTargetEndpoint = edge.data?.targetEndpoint ?? edge.targetEndpoint; - const sourceEndpointMatches = - edgeSourceEndpoint === expectedSourceEndpoint || - (isSpecial(expectedSource) && (edgeSourceEndpoint === "" || edgeSourceEndpoint == null)); - const targetEndpointMatches = - edgeTargetEndpoint === expectedTargetEndpoint || - (isSpecial(expectedTarget) && (edgeTargetEndpoint === "" || edgeTargetEndpoint == null)); - return sourceEndpointMatches && targetEndpointMatches; - }; - - return edges.some( - (edge) => - matches(edge, sourceId, targetId, sourceEndpoint, targetEndpoint) || - matches(edge, targetId, sourceId, targetEndpoint, sourceEndpoint) - ); -} - -/** - * Browser-side function to get group debug info. - * Note: Helper logic is inlined for page.evaluate() compatibility. - */ -function browserGetGroupDebugInfo(): GroupDebugInfo { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const reactGroups = dev?.getReactGroups?.() ?? []; - - // Inline helper: get group IDs - const getGroupIds = (groups: Array<{ id: string }>): string[] => groups.map((g) => g.id); - - return { - reactGroupCount: reactGroups.length, - reactGroupsDirectCount: dev?.groupsCount ?? -1, - stateManagerGroupCount: reactGroups.length, // Same as React groups now - reactGroupIds: getGroupIds(reactGroups), - stateManagerGroupIds: getGroupIds(reactGroups) // Same as React groups now - }; -} - -/** - * Topology files available in dev/topologies/ (file-based) - */ -type TopologyFileName = string; - -/** - * Annotations structure from file API - */ -interface TopologyAnnotations { - nodeAnnotations?: Array<{ - id: string; - position?: { x: number; y: number }; - groupId?: string; - group?: string; - level?: string; - icon?: string; - labelPosition?: string; - direction?: string; - labelBackgroundColor?: string; - }>; - freeTextAnnotations?: Array<{ id: string; text: string; position: { x: number; y: number } }>; - freeShapeAnnotations?: Array<{ - id: string; - shapeType: string; - position: { x: number; y: number }; - }>; - groupStyleAnnotations?: Array<{ - id: string; - name: string; - parentId?: string; - position?: { x: number; y: number }; - zIndex?: number; - }>; - networkNodeAnnotations?: Array<{ - id: string; - type: string; - label: string; - position: { x: number; y: number }; - }>; - edgeAnnotations?: Array<{ - id?: string; - source?: string; - target?: string; - sourceEndpoint?: string; - targetEndpoint?: string; - endpointLabelOffsetEnabled?: boolean; - endpointLabelOffset?: number; - }>; - viewerSettings?: { - endpointLabelOffsetEnabled?: boolean; - endpointLabelOffset?: number; - gridLineWidth?: number; - gridStyle?: "dotted" | "quadratic"; - style?: "default" | "telemetry-style"; - linkLabelMode?: "show-all" | "on-select" | "hide" | "telemetry-style"; - lastNonTelemetryLinkLabelMode?: "show-all" | "on-select" | "hide"; - telemetryNodeSizePx?: number; - telemetryInterfaceSizePercent?: number; - }; - aliasEndpointAnnotations?: Array<{ id: string }>; -} - -/** - * Helper interface for interacting with TopoViewer - */ -interface TopoViewerPage { - /** Navigate to TopoViewer and load a file-based topology (real file I/O) */ - gotoFile(filename: TopologyFileName): Promise; - - /** Get the currently loaded file path (null if in-memory) */ - getCurrentFile(): Promise; - - /** Read annotations from file API */ - getAnnotationsFromFile(filename: TopologyFileName): Promise; - - /** Read YAML content from file API */ - getYamlFromFile(filename: TopologyFileName): Promise; - - /** List available topology files */ - listTopologyFiles(): Promise>; - - /** Wait for the canvas to be fully initialized */ - waitForCanvasReady(): Promise; - - /** Get the center coordinates of the canvas in page coordinates */ - getCanvasCenter(): Promise<{ x: number; y: number }>; - - /** Get the current number of nodes in the graph */ - getNodeCount(): Promise; - - /** Get a node's position in model coordinates */ - getNodePosition(nodeId: string): Promise<{ x: number; y: number }>; - - /** Get a node's bounding box in page coordinates */ - getNodeBoundingBox(nodeId: string): Promise<{ - x: number; - y: number; - width: number; - height: number; - } | null>; - - /** Get the canvas locator */ - getCanvas(): Locator; - - /** Set mode to edit */ - setEditMode(): Promise; - - /** Set mode to view */ - setViewMode(): Promise; - - /** Unlock the canvas for editing */ - unlock(): Promise; - - /** Lock the canvas */ - lock(): Promise; - - /** Check if canvas is locked */ - isLocked(): Promise; - - /** Get the IDs of all nodes */ - getNodeIds(): Promise; - - /** Select a node by clicking on it */ - selectNode(nodeId: string): Promise; - - /** Get the number of groups */ - getGroupCount(): Promise; - - /** Get all group IDs */ - getGroupIds(): Promise; - - /** Get all edge IDs */ - getEdgeIds(): Promise; - - /** Get edge data objects */ - getEdgesData(): Promise< - Array<{ - id: string; - source: string; - target: string; - sourceEndpoint?: string; - targetEndpoint?: string; - }> - >; - - /** Find an edge by endpoints (order-insensitive) */ - findEdgeByEndpoints( - source: string, - target: string, - sourceEndpoint?: string, - targetEndpoint?: string - ): Promise<{ - id: string; - source: string; - target: string; - sourceEndpoint?: string; - targetEndpoint?: string; - } | null>; - - /** Get edge count */ - getEdgeCount(): Promise; - - /** Select an edge by clicking on it */ - selectEdge(edgeId: string): Promise; - - /** Get the current zoom level */ - getZoom(): Promise; - - /** Get the current pan position */ - getPan(): Promise<{ x: number; y: number }>; - - /** Set zoom level */ - setZoom(zoom: number): Promise; - - /** Set pan position */ - setPan(x: number, y: number): Promise; - - /** Fit graph to viewport */ - fit(): Promise; - - /** Get selected node IDs */ - getSelectedNodeIds(): Promise; - - /** Get selected edge IDs */ - getSelectedEdgeIds(): Promise; - - /** Clear all selections */ - clearSelection(): Promise; - - /** Delete selected elements */ - deleteSelected(): Promise; - - /** Trigger undo */ - undo(): Promise; - - /** Trigger redo */ - redo(): Promise; - - /** Check if can undo */ - canUndo(): Promise; - - /** Check if can redo */ - canRedo(): Promise; - - /** Create a node at a specific position */ - createNode(nodeId: string, position: { x: number; y: number }, kind?: string): Promise; - - /** Create a network node (host, mgmt-net, macvlan, vxlan, vxlan-stitch, dummy, bridge, ovs-bridge) */ - createNetwork(position: { x: number; y: number }, networkType: string): Promise; - - /** Create a link between two nodes */ - createLink( - sourceId: string, - targetId: string, - sourceEndpoint?: string, - targetEndpoint?: string - ): Promise; - - /** Get all network node IDs (nodes with topoViewerRole='cloud') */ - getNetworkNodeIds(): Promise; - - /** Drag a node to a new position */ - dragNode(nodeId: string, delta: { x: number; y: number }): Promise; - - /** Reset all topology and annotation files to defaults (for test isolation) */ - resetFiles(): Promise; - - /** Select a group by ID (programmatic) */ - selectGroup(groupId: string): Promise; - - /** Get member node IDs for a group */ - getGroupMembers(groupId: string): Promise; - - /** Perform copy (Ctrl+C) */ - copy(): Promise; - - /** Perform paste (Ctrl+V) */ - paste(): Promise; - - /** Clear browser clipboard (for deterministic paste tests) */ - clearClipboard(): Promise; - - /** Create a group from selected nodes (Ctrl+G) */ - createGroup(): Promise; - - /** Resize a group by dragging a resize handle */ - resizeGroup( - groupId: string, - corner: "nw" | "ne" | "sw" | "se", - delta: { x: number; y: number } - ): Promise; - - /** Delete a node by ID */ - deleteNode(nodeId: string): Promise; - - /** Delete an edge by ID */ - deleteEdge(edgeId: string): Promise; - - /** Write YAML content to a file (for live update testing) */ - writeYamlFile(filename: TopologyFileName, content: string): Promise; - - /** Write annotations content to a file (for live update testing) */ - writeAnnotationsFile(filename: TopologyFileName, content: object): Promise; - - /** Read YAML content from a file (for verifying persistence) */ - readYamlFile(filename: TopologyFileName): Promise; -} - -/** - * Extended test fixture with TopoViewer helpers - */ -// Base URL for API requests (must be absolute since page hasn't navigated yet) -const API_BASE_URL = "http://localhost:5173"; - -export const test = base.extend<{ topoViewerPage: TopoViewerPage }>({ - topoViewerPage: async ({ page, request }, use, testInfo: TestInfo) => { - // Generate unique session ID for test isolation - const sessionId = generateSessionId(); - - // Capture browser console logs for debugging on failure - const consoleLogs: string[] = []; - page.on("console", (msg) => { - const timestamp = new Date().toISOString(); - const type = msg.type().toUpperCase().padEnd(7); - consoleLogs.push(`[${timestamp}] [${type}] ${msg.text()}`); - }); - - // Helper to add session ID to API URLs (uses absolute URL for API calls) - const withSession = (url: string) => { - const separator = url.includes("?") ? "&" : "?"; - return `${API_BASE_URL}${url}${separator}sessionId=${sessionId}`; - }; - - // Initialize session with default files using request fixture - await request.post(withSession("/api/reset")); - - const topoViewerPage: TopoViewerPage = { - gotoFile: async (filename: string) => { - const resolvedFilePath = path.join(TOPOLOGIES_DIR, filename); - // Pass session ID via URL so auto-load uses correct session - await page.goto(`${API_BASE_URL}/?sessionId=${sessionId}&devExplorer=0`, { - waitUntil: "domcontentloaded", - timeout: 60000 - }); - // Wait for dev hooks to be injected; app shell visibility can lag on cold startup. - await page.waitForFunction( - () => (window as { __DEV__?: BrowserDevApi }).__DEV__ !== undefined, - undefined, - { timeout: 60000 } - ); - - // Wait for React Flow instance to be ready. - await page.waitForFunction( - () => (window as { __DEV__?: BrowserDevApi }).__DEV__?.rfInstance !== undefined, - undefined, - { timeout: 45000 } - ); - - // Wait a bit for any auto-load to settle - await page.waitForTimeout(300); - - // Load file-based topology via dev API with session ID - // Retry up to 3 times if loading fails - const maxRetries = 3; - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - // Load the file - const loadResult = await page.evaluate( - async ({ file, sid }) => { - try { - await (window as { __DEV__?: BrowserDevApi }).__DEV__.loadTopologyFile(file, sid); - return { success: true }; - } catch (e: any) { - return { success: false, error: e?.message ?? String(e) }; - } - }, - { file: resolvedFilePath, sid: sessionId } - ); - - if (!loadResult.success) { - throw new Error(`loadTopologyFile failed: ${loadResult.error}`); - } - - // Wait for the topology data to propagate and confirm the file is loaded - await page.waitForFunction( - (expectedFile) => { - const currentFile = ( - window as { __DEV__?: BrowserDevApi } - ).__DEV__?.getCurrentFile?.(); - if (typeof currentFile !== "string" || currentFile.length === 0) return false; - return currentFile.endsWith(expectedFile); - }, - filename, - { timeout: 5000 } - ); - - // Verify nodes are actually loaded for non-empty topologies - if (filename !== "empty.clab.yml") { - await page.waitForFunction( - (types) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return false; - const nodes = rf.getNodes?.() ?? []; - // Prefer topology nodes, but fall back to any node count - const topoNodes = nodes.filter( - (n: any) => n.type === types.topo || n.type === types.network - ); - return topoNodes.length > 0 || nodes.length > 0; - }, - { topo: TOPOLOGY_NODE_TYPE, network: NETWORK_NODE_TYPE }, - { timeout: 5000 } - ); - } - - // Wait for React Flow to be fully initialized - // Wait for React Flow instance to be initialized - await page.waitForFunction( - () => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - return rf !== undefined && rf !== null; - }, - undefined, - { timeout: 10000, polling: 100 } - ); - - // Success - break out of retry loop - break; - } catch (error) { - if (attempt === maxRetries) { - throw new Error( - `Failed to load ${filename} after ${maxRetries} attempts: ${String(error)}` - ); - } - // Wait before retrying - await page.waitForTimeout(500); - } - } - - // Small additional wait for React effects to settle (useEffect for __DEV__.cy) - await page.waitForTimeout(100); - }, - - getCurrentFile: async () => { - const currentFile = await page.evaluate(() => { - return (window as { __DEV__?: BrowserDevApi }).__DEV__.getCurrentFile(); - }); - if (typeof currentFile !== "string" || currentFile.length === 0) return null; - return path.basename(currentFile); - }, - - getAnnotationsFromFile: async (filename: string) => { - // Build full path to annotations file - const annotationsPath = path.join(TOPOLOGIES_DIR, `${filename}.annotations.json`); - for (let attempt = 0; attempt < 3; attempt++) { - try { - const response = await page.request.get( - withSession(`/file/${encodeURIComponent(annotationsPath)}`) - ); - if (!response.ok()) { - if (response.status() === 404) { - // Return empty annotations if file doesn't exist - return { - nodeAnnotations: [], - freeTextAnnotations: [], - freeShapeAnnotations: [], - groupStyleAnnotations: [], - edgeAnnotations: [], - viewerSettings: {} - }; - } - throw new Error(`Failed to read annotations: ${response.statusText()}`); - } - const text = await response.text(); - return JSON.parse(text); - } catch (err) { - if (attempt === 2) throw err; - await page.waitForTimeout(200); - } - } - return { - nodeAnnotations: [], - freeTextAnnotations: [], - freeShapeAnnotations: [], - groupStyleAnnotations: [], - edgeAnnotations: [], - viewerSettings: {} - }; - }, - - getYamlFromFile: async (filename: string) => { - // Build full path to YAML file - const yamlPath = path.join(TOPOLOGIES_DIR, filename); - for (let attempt = 0; attempt < 3; attempt++) { - try { - const response = await page.request.get( - withSession(`/file/${encodeURIComponent(yamlPath)}`) - ); - if (!response.ok()) { - throw new Error(`Failed to read YAML: ${response.statusText()}`); - } - return response.text(); - } catch (err) { - if (attempt === 2) throw err; - await page.waitForTimeout(200); - } - } - return ""; - }, - - listTopologyFiles: async () => { - const response = await page.request.get(withSession("/files")); - if (!response.ok()) { - throw new Error(`Failed to list files: ${response.statusText()}`); - } - return response.json(); - }, - - waitForCanvasReady: async () => { - // Wait for canvas container - await page.waitForSelector(CANVAS_SELECTOR, { timeout: 30000 }); - - // Wait for React Flow instance to be exposed via __DEV__.rfInstance - await page.waitForFunction( - () => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - return dev?.rfInstance !== undefined; - }, - undefined, - { timeout: 30000 } - ); - - // Wait for React Flow instance to be usable - await page.waitForFunction( - () => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - return rf !== undefined && rf !== null && typeof rf.getNodes === "function"; - }, - undefined, - { timeout: 30000, polling: 200 } - ); - - // If a non-empty file is loaded, wait for topology nodes to exist - const currentFile = await page.evaluate( - () => (window as { __DEV__?: BrowserDevApi }).__DEV__?.getCurrentFile?.() ?? null - ); - const currentFileName = - typeof currentFile === "string" && currentFile.length > 0 - ? path.basename(currentFile) - : null; - if ( - currentFileName != null && - currentFileName.length > 0 && - currentFileName !== "empty.clab.yml" - ) { - await page.waitForFunction( - (types) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return false; - const nodes = rf.getNodes?.() ?? []; - return nodes.some((n: any) => n.type === types.topo || n.type === types.network); - }, - { topo: TOPOLOGY_NODE_TYPE, network: NETWORK_NODE_TYPE }, - { timeout: 20000, polling: 200 } - ); - } - - // If topology nodes exist, wait for at least one to render in the DOM - const firstNodeId = await page.evaluate( - (types) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return null; - const nodes = rf.getNodes?.() ?? []; - const topoNode = nodes.find( - (n: any) => n.type === types.topo || n.type === types.network - ); - return topoNode?.id ?? null; - }, - { topo: TOPOLOGY_NODE_TYPE, network: NETWORK_NODE_TYPE } - ); - - if (firstNodeId != null && firstNodeId.length > 0) { - await page.waitForSelector(`[data-id="${firstNodeId}"]`, { timeout: 20000 }); - await page.waitForFunction( - (nodeId) => { - const el = document.querySelector(`[data-id="${nodeId}"]`); - if (!el) return false; - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }, - firstNodeId, - { timeout: 20000 } - ); - } - - // Check if nodes need layout (all at 0,0) - const needsLayout = await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return false; - const nodes = rf.getNodes?.() ?? []; - if (nodes.length <= 1) return false; // 1 or no nodes don't need layout - - // Check if all nodes are at the same position - const firstPos = nodes[0].position; - return nodes.every( - (n: any) => - Math.abs(n.position.x - firstPos.x) < 1 && Math.abs(n.position.y - firstPos.y) < 1 - ); - }); - - // Run force layout if nodes are overlapping - if (needsLayout === true) { - await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - dev?.setLayout?.("force"); - }); - // Wait for layout animation - await page.waitForTimeout(500); - } - - // Call fitView to ensure proper viewport - await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - dev?.rfInstance?.fitView?.({ padding: 0.2, maxZoom: 1.2 }); - }); - - // Wait for fitView animation - await page.waitForTimeout(300); - }, - - getCanvasCenter: async () => { - const canvas = page.locator(CANVAS_SELECTOR); - const box = await canvas.boundingBox(); - if (!box) throw new Error("Canvas not found"); - return { - x: box.x + box.width / 2, - y: box.y + box.height / 2 - }; - }, - - getNodeCount: async () => { - return await page.evaluate( - (types) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return 0; - const nodes = rf.getNodes?.() ?? []; - // Filter out non-topology nodes (annotations, etc.) - return nodes.filter((n: any) => n.type === types.topo || n.type === types.network) - .length; - }, - { topo: TOPOLOGY_NODE_TYPE, network: NETWORK_NODE_TYPE } - ); - }, - - getNodePosition: async (nodeId: string) => { - return await page.evaluate((id) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return { x: 0, y: 0 }; - const nodes = rf.getNodes?.() ?? []; - const node = nodes.find((n: any) => n.id === id); - if (!node) return { x: 0, y: 0 }; - return node.position; - }, nodeId); - }, - - getNodeBoundingBox: async (nodeId: string) => { - // Try DOM element first - most reliable for visible nodes - const domHandle = await page.$(`[data-id="${nodeId}"]`); - if (domHandle) { - const domBox = await domHandle.boundingBox(); - if (domBox) return domBox; - } - - // Fallback to React Flow calculation (simplified - use default size) - return await page.evaluate( - ({ id, sel }) => { - const rf = (window as { __DEV__?: BrowserDevApi }).__DEV__?.rfInstance; - if (!rf) return null; - - const nodes = rf.getNodes?.() ?? []; - const node = nodes.find((n: any) => n.id === id); - if (!node) return null; - - const vp = rf.getViewport?.() ?? { x: 0, y: 0, zoom: 1 }; - const container = document.querySelector(sel); - const rect = container?.getBoundingClientRect(); - const ox = rect?.left ?? 0; - const oy = rect?.top ?? 0; - - return { - x: ox + node.position.x * vp.zoom + vp.x, - y: oy + node.position.y * vp.zoom + vp.y, - width: 60 * vp.zoom, - height: 60 * vp.zoom - }; - }, - { id: nodeId, sel: CANVAS_SELECTOR } - ); - }, - - getCanvas: () => { - return page.locator(CANVAS_SELECTOR); - }, - - setEditMode: async () => { - await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - if (dev?.setModeState) { - dev.setModeState("edit"); - return; - } - dev?.setMode?.("edit"); - }); - await page.waitForFunction( - () => (window as { __DEV__?: BrowserDevApi }).__DEV__?.mode?.() === "edit", - undefined, - { timeout: 2000 } - ); - }, - - setViewMode: async () => { - await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - if (dev?.setModeState) { - dev.setModeState("view"); - return; - } - dev?.setMode?.("view"); - }); - await page.waitForFunction( - () => (window as { __DEV__?: BrowserDevApi }).__DEV__?.mode?.() === "view", - undefined, - { timeout: 2000 } - ); - }, - - unlock: async () => { - await page.evaluate(() => { - (window as { __DEV__?: BrowserDevApi }).__DEV__.setLocked(false); - }); - await page.waitForFunction( - () => (window as { __DEV__?: BrowserDevApi }).__DEV__?.isLocked?.() === false, - undefined, - { timeout: 2000 } - ); - }, - - lock: async () => { - await page.evaluate(() => { - (window as { __DEV__?: BrowserDevApi }).__DEV__.setLocked(true); - }); - await page.waitForFunction( - () => (window as { __DEV__?: BrowserDevApi }).__DEV__?.isLocked?.() === true, - undefined, - { timeout: 2000 } - ); - }, - - isLocked: async () => { - return await page.evaluate(() => { - return (window as { __DEV__?: BrowserDevApi }).__DEV__.isLocked(); - }); - }, - - getNodeIds: async () => { - return await page.evaluate( - (types) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return []; - const nodes = rf.getNodes?.() ?? []; - return nodes - .filter((n: any) => n.type === types.topo || n.type === types.network) - .map((n: any) => n.id); - }, - { topo: TOPOLOGY_NODE_TYPE, network: NETWORK_NODE_TYPE } - ); - }, - - selectNode: async (nodeId: string) => { - // Use React Flow selection for proper clipboard support - // This sets node.selected = true which is what clipboard copy checks - await page.evaluate((id) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - // Use selectNodesForClipboard for React Flow selection - if (dev?.selectNodesForClipboard) { - dev.selectNodesForClipboard([id]); - } - // Also update TopoViewerContext state for UI sync - if (dev?.selectNode) { - dev.selectNode(id); - } - }, nodeId); - - // Wait for React state to propagate - await page.waitForTimeout(100); - - // Verify React Flow selection was set - const selectedIds = await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - if (!dev?.rfInstance) return []; - const nodes = dev.rfInstance.getNodes(); - return nodes - .filter((n: { selected?: boolean }) => n.selected === true) - .map((n: { id: string }) => n.id); - }); - - if (!selectedIds.includes(nodeId)) { - console.warn( - `Selection verification failed: expected ${nodeId} in ${selectedIds.join(", ")}` - ); - } - }, - - getGroupCount: async () => { - return await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const reactGroups = dev?.getReactGroups?.() ?? []; - return reactGroups.length; - }); - }, - - getGroupIds: async () => { - return await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const reactGroups = dev?.getReactGroups?.() ?? []; - return reactGroups.map((g: any) => g.id); - }); - }, - - getEdgeIds: async () => { - return await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return []; - const edges = rf.getEdges?.() ?? []; - return edges.map((e: any) => e.id); - }); - }, - - getEdgesData: async () => { - return await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return []; - const edges = rf.getEdges?.() ?? []; - return edges.map((e: any) => ({ - id: e.id, - source: e.source, - target: e.target, - sourceEndpoint: e.data?.sourceEndpoint, - targetEndpoint: e.data?.targetEndpoint - })); - }); - }, - - findEdgeByEndpoints: async (source, target, sourceEndpoint, targetEndpoint) => { - return await page.evaluate( - ({ source, target, sourceEndpoint, targetEndpoint }) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return null; - const edges = rf.getEdges?.() ?? []; - const matches = (edge: any) => { - const data = edge.data ?? {}; - const se = data.sourceEndpoint; - const te = data.targetEndpoint; - const direct = - edge.source === source && - edge.target === target && - (sourceEndpoint === undefined || se === sourceEndpoint) && - (targetEndpoint === undefined || te === targetEndpoint); - const flipped = - edge.source === target && - edge.target === source && - (sourceEndpoint === undefined || te === sourceEndpoint) && - (targetEndpoint === undefined || se === targetEndpoint); - return direct || flipped; - }; - const edge = edges.find(matches); - if (!edge) return null; - return { - id: edge.id, - source: edge.source, - target: edge.target, - sourceEndpoint: edge.data?.sourceEndpoint, - targetEndpoint: edge.data?.targetEndpoint - }; - }, - { source, target, sourceEndpoint, targetEndpoint } - ); - }, - - getEdgeCount: async () => { - return await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return 0; - const edges = rf.getEdges?.() ?? []; - return edges.length; - }); - }, - - selectEdge: async (edgeId: string) => { - // Use programmatic selection via __DEV__.selectEdge and also set React Flow edge.selected - await page.evaluate((id) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - // Update context state - if (dev?.selectEdge) { - dev.selectEdge(id); - } - // Also set React Flow edge.selected property - const rf = dev?.rfInstance; - if (rf) { - const edges = rf.getEdges?.() ?? []; - const updatedEdges = edges.map((e: any) => ({ - ...e, - selected: e.id === id - })); - rf.setEdges(updatedEdges); - } - }, edgeId); - - // Wait for React state to propagate - await page.waitForTimeout(100); - - // Verify selection was set - const selectedId = await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - return dev?.selectedEdge?.() ?? null; - }); - - if (selectedId !== edgeId) { - console.warn(`Edge selection verification failed: expected ${edgeId}, got ${selectedId}`); - } - }, - - getZoom: async () => { - return await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - const viewport = rf?.getViewport?.(); - return viewport?.zoom ?? 1; - }); - }, - - getPan: async () => { - return await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - const viewport = rf?.getViewport?.() ?? { x: 0, y: 0 }; - return { x: viewport.x, y: viewport.y }; - }); - }, - - setZoom: async (zoom: number) => { - await page.evaluate( - ({ z, sel }) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (rf?.setViewport) { - const viewport = rf.getViewport?.() ?? { x: 0, y: 0, zoom: 1 }; - const container = document.querySelector(sel); - const rect = container?.getBoundingClientRect(); - const centerX = rect ? rect.width / 2 : 0; - const centerY = rect ? rect.height / 2 : 0; - const modelCenterX = (centerX - viewport.x) / viewport.zoom; - const modelCenterY = (centerY - viewport.y) / viewport.zoom; - rf.setViewport({ - x: centerX - modelCenterX * z, - y: centerY - modelCenterY * z, - zoom: z - }); - } - }, - { z: zoom, sel: CANVAS_SELECTOR } - ); - await page.waitForTimeout(100); - }, - - setPan: async (x: number, y: number) => { - await page.evaluate( - ({ px, py }) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (rf?.setViewport) { - const viewport = rf.getViewport?.() ?? { x: 0, y: 0, zoom: 1 }; - rf.setViewport({ x: px, y: py, zoom: viewport.zoom }); - } - }, - { px: x, py: y } - ); - await page.waitForTimeout(100); - }, - - fit: async () => { - await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - dev?.rfInstance?.fitView?.({ padding: 0.1, maxZoom: 1.2 }); - }); - await page.waitForTimeout(300); - }, - - getSelectedNodeIds: async () => { - return await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return []; - const nodes = rf.getNodes?.() ?? []; - return nodes.filter((n) => n.selected === true).map((n) => n.id); - }); - }, - - getSelectedEdgeIds: async () => { - return await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return []; - const edges = rf.getEdges?.() ?? []; - return edges.filter((e) => e.selected === true).map((e) => e.id); - }); - }, - - clearSelection: async () => { - // Press Escape to clear context selection, then clear React Flow selection - await page.keyboard.press("Escape"); - await page.waitForTimeout(100); - // Also clear React Flow node.selected and edge.selected properties - await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - dev?.clearNodeSelection?.(); - // Clear edge selection too - const rf = dev?.rfInstance; - if (rf) { - const edges = rf.getEdges?.() ?? []; - const updatedEdges = edges.map((e: any) => ({ - ...e, - selected: false - })); - rf.setEdges(updatedEdges); - } - }); - await page.waitForTimeout(50); - }, - - deleteSelected: async () => { - await page.keyboard.press("Delete"); - await page.waitForTimeout(200); - }, - - undo: async () => { - await page - .waitForFunction( - () => (window as { __DEV__?: BrowserDevApi }).__DEV__?.undoRedo?.canUndo === true, - undefined, - { timeout: 2000 } - ) - .catch(() => {}); - await page.keyboard.down("Control"); - await page.keyboard.press("z"); - await page.keyboard.up("Control"); - await page.waitForTimeout(200); - }, - - redo: async () => { - await page - .waitForFunction( - () => (window as { __DEV__?: BrowserDevApi }).__DEV__?.undoRedo?.canRedo === true, - undefined, - { timeout: 2000 } - ) - .catch(() => {}); - await page.keyboard.down("Control"); - await page.keyboard.down("Shift"); - await page.keyboard.press("z"); - await page.keyboard.up("Shift"); - await page.keyboard.up("Control"); - await page.waitForTimeout(200); - }, - - canUndo: async () => { - return await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - return dev?.undoRedo?.canUndo ?? false; - }); - }, - - canRedo: async () => { - return await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - return dev?.undoRedo?.canRedo ?? false; - }); - }, - - createNode: async ( - nodeId: string, - position: { x: number; y: number }, - kind = "nokia_srlinux" - ) => { - // Wait for handleNodeCreatedCallback to be available (exposed via __DEV__) - await page.waitForFunction( - () => - (window as { __DEV__?: BrowserDevApi }).__DEV__?.handleNodeCreatedCallback !== - undefined, - undefined, - { timeout: 10000 } - ); - - await page.evaluate( - ({ nodeId, position, kind, nodeType }) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - if (!dev?.handleNodeCreatedCallback) { - throw new Error("handleNodeCreatedCallback not available"); - } - - // Create node element in React Flow format - const nodeElement = { - id: nodeId, - type: nodeType, - position, - data: { - label: nodeId, - name: nodeId, - role: "pe", - topoViewerRole: "pe", - kind, - image: "ghcr.io/nokia/srlinux:latest", - extraData: { - kind, - image: "ghcr.io/nokia/srlinux:latest", - longname: "", - mgmtIpv4Address: "" - } - } - }; - - // Call handleNodeCreatedCallback which: - // 1. Adds the node to React state - // 2. Persists to YAML via TopologyIO - // 3. Pushes undo action - dev.handleNodeCreatedCallback(nodeId, nodeElement, position); - }, - { nodeId, position, kind, nodeType: TOPOLOGY_NODE_TYPE } - ); - - // Wait for the node to appear in React Flow - await page.waitForFunction( - (id) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return false; - const nodes = rf.getNodes?.() ?? []; - return nodes.some((n: any) => n.id === id); - }, - nodeId, - { timeout: 5000 } - ); - - // Small wait to ensure state fully propagates before next operation - await page.waitForTimeout(100); - }, - - createLink: async ( - sourceId: string, - targetId: string, - sourceEndpoint = "eth1", - targetEndpoint = "eth1" - ) => { - // Wait for handleEdgeCreated to be available (exposed via __DEV__) - await page.waitForFunction( - () => (window as { __DEV__?: BrowserDevApi }).__DEV__?.handleEdgeCreated !== undefined, - undefined, - { timeout: 10000 } - ); - - // Check lock state and mode before proceeding (respects UI restrictions) - const canCreate = await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - // Block if locked (isLocked is a function) or not in edit mode - if (typeof dev?.isLocked === "function" && dev.isLocked() === true) return false; - if (typeof dev?.mode === "function" && dev.mode() !== "edit") return false; - return true; - }); - - if (!canCreate) { - // Silently skip edge creation when locked or in view mode - // (matches UI behavior where edge handles are disabled) - return; - } - - await page.evaluate( - ({ sourceId, targetId, sourceEndpoint, targetEndpoint }) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - if (!dev?.handleEdgeCreated) { - throw new Error("handleEdgeCreated not available"); - } - - const linkData = { - id: `${sourceId}:${sourceEndpoint}--${targetId}:${targetEndpoint}`, - source: sourceId, - target: targetId, - sourceEndpoint, - targetEndpoint - }; - - // Call handleEdgeCreated which: - // 1. Adds the edge to React state - // 2. Sends create-link to extension - // 3. Pushes undo action - dev.handleEdgeCreated(sourceId, targetId, linkData); - }, - { sourceId, targetId, sourceEndpoint, targetEndpoint } - ); - - // Wait for matching edge to appear (ID may be re-written by snapshot sync) - await page.waitForFunction( - browserHasMatchingEdge, - { sourceId, targetId, sourceEndpoint, targetEndpoint }, - { timeout: 5000 } - ); - }, - - createNetwork: async ( - position: { x: number; y: number }, - networkType: string - ): Promise => { - // Wait for createNetworkAtPosition to be available - await page.waitForFunction( - () => - (window as { __DEV__?: BrowserDevApi }).__DEV__?.createNetworkAtPosition !== undefined, - undefined, - { timeout: 10000 } - ); - - // Check lock state and mode before proceeding - const canCreate = await page.evaluate(() => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - if (typeof dev?.isLocked === "function" && dev.isLocked() === true) return false; - if (typeof dev?.mode === "function" && dev.mode() !== "edit") return false; - return true; - }); - - if (!canCreate) { - return null; - } - - // Create the network node - const networkId = await page.evaluate( - ({ pos, type }) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - if (!dev?.createNetworkAtPosition) { - throw new Error("createNetworkAtPosition not available"); - } - return dev.createNetworkAtPosition(pos, type); - }, - { pos: position, type: networkType } - ); - - if (typeof networkId !== "string" || networkId.length === 0) return null; - - // Wait for the network node to appear in React Flow - await page.waitForFunction( - (id) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return false; - const nodes = rf.getNodes?.() ?? []; - return nodes.some((n: any) => n.id === id); - }, - networkId, - { timeout: 5000 } - ); - - return networkId; - }, - - getNetworkNodeIds: async (): Promise => { - return await page.evaluate((cloudType) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const rf = dev?.rfInstance; - if (!rf) return []; - const nodes = rf.getNodes?.() ?? []; - return nodes.filter((n: any) => n.type === cloudType).map((n: any) => n.id); - }, NETWORK_NODE_TYPE); - }, - - dragNode: async (nodeId: string, delta: { x: number; y: number }) => { - const box = await topoViewerPage.getNodeBoundingBox(nodeId); - if (!box) throw new Error(`Node ${nodeId} not found`); - - const startX = box.x + box.width / 2; - const startY = box.y + box.height / 2; - - await page.mouse.move(startX, startY); - await page.mouse.down(); - - // Move in steps for smooth drag - const steps = 10; - for (let i = 1; i <= steps; i++) { - const x = startX + (delta.x * i) / steps; - const y = startY + (delta.y * i) / steps; - await page.mouse.move(x, y); - } - - await page.mouse.up(); - await page.waitForTimeout(300); - }, - - resetFiles: async () => { - const response = await request.post(withSession("/api/reset")); - const result: unknown = await response.json(); - if (!isRecord(result) || result.success !== true) { - const errorMessage = - isRecord(result) && typeof result.error === "string" - ? result.error - : "Failed to reset files"; - throw new Error(errorMessage); - } - // Wait for session reset to settle - await page.waitForTimeout(100); - }, - - selectGroup: async (groupId: string) => { - await page.evaluate((id) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - if (dev?.stateManager?.groups?.selectGroup) { - dev.stateManager.groups.selectGroup(id); - } else if (dev?.groups?.selectGroup) { - dev.groups.selectGroup(id); - } - }, groupId); - await page.waitForTimeout(100); - }, - - getGroupMembers: async (groupId: string) => { - return await page.evaluate((id) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - const annotations = dev?.stateManager?.getAnnotations?.(); - const nodeAnnotations = annotations?.nodeAnnotations; - if (!Array.isArray(nodeAnnotations)) return []; - const groupPrefix = id.split("__")[0]; - return nodeAnnotations - .filter((n: { id: string; group?: string }) => { - if (n.group === id) return true; - return typeof n.group === "string" && n.group.startsWith(groupPrefix); - }) - .map((n: { id: string; group?: string }) => n.id); - }, groupId); - }, - - copy: async () => { - await page.keyboard.down("Control"); - await page.keyboard.press("c"); - await page.keyboard.up("Control"); - await page.waitForTimeout(200); - }, - - paste: async () => { - await page.keyboard.down("Control"); - await page.keyboard.press("v"); - await page.keyboard.up("Control"); - await page.waitForTimeout(300); - }, - - clearClipboard: async () => { - await page.evaluate(async () => { - try { - await window.navigator.clipboard.writeText(""); - } catch { - // Ignore clipboard write failures; tests will fall back to existing state. - } - }); - await page.waitForTimeout(100); - }, - - createGroup: async () => { - // Use direct API call instead of keyboard events for reliability - const result = await page.evaluate(browserCreateGroup); - const selectedBefore = result.selectedBefore.join(", "); - const selectedAfter = (result.selectedAfter ?? []).join(", "); - console.log( - `[DEBUG] createGroup: method=${result.method}, hasRf=${result.hasRf}, selected=${selectedBefore} -> ${selectedAfter}, mode=${result.mode}, isLocked=${result.isLocked}, stateManager: ${result.groupsBefore} -> ${result.groupsAfter}, react: ${result.reactGroupsBefore} -> ${result.reactGroupsAfter}` - ); - // Wait for debounced save (300ms) plus processing time - await page.waitForTimeout(1000); - // Check again after wait - both React state and stateManager - const debugInfo = await page.evaluate(browserGetGroupDebugInfo); - const reactGroupIds = debugInfo.reactGroupIds.join(", "); - const stateManagerGroupIds = debugInfo.stateManagerGroupIds.join(", "); - console.log( - `[DEBUG] After 1000ms: React groups=${debugInfo.reactGroupCount} (direct: ${debugInfo.reactGroupsDirectCount}) (${reactGroupIds}), StateManager groups=${debugInfo.stateManagerGroupCount} (${stateManagerGroupIds})` - ); - }, - - resizeGroup: async ( - groupId: string, - corner: "nw" | "ne" | "sw" | "se", - delta: { x: number; y: number } - ) => { - // Click the group label to ensure resize handles are rendered (selection/hover-driven UI) - const label = page.locator(`[data-testid="group-label-${groupId}"]`); - await label.waitFor({ state: "visible", timeout: 5000 }); - await label.click(); - await page.waitForTimeout(200); - - const handle = page.locator(`[data-testid="resize-${corner}-${groupId}"]`); - await handle.waitFor({ state: "visible", timeout: 5000 }); - const box = await handle.boundingBox(); - if (!box) throw new Error(`Resize handle not found for group ${groupId} (${corner})`); - - const startX = box.x + box.width / 2; - const startY = box.y + box.height / 2; - const endX = startX + delta.x; - const endY = startY + delta.y; - - await page.mouse.move(startX, startY); - await page.mouse.down(); - await page.mouse.move(endX, endY, { steps: 10 }); - await page.mouse.up(); - - // Wait for debounced save (300ms) plus processing time - await page.waitForTimeout(800); - }, - - deleteNode: async (nodeId: string) => { - // Close any open editor panel first to ensure Delete key goes to the canvas - const isContextPanelOpen = await page.evaluate(() => { - const el = document.querySelector('[data-testid="context-panel"]'); - if (!el) return false; - return window.getComputedStyle(el).pointerEvents !== "none"; - }); - - if (isContextPanelOpen) { - // Prefer returning to palette, then closing the panel completely. - const backBtn = page.locator('[data-testid="panel-back-btn"]'); - if (await backBtn.isVisible().catch(() => false)) { - await backBtn.click(); - await page.waitForTimeout(100); - } - - const toggleBtn = page.locator('[data-testid="panel-toggle-btn"]'); - if (await toggleBtn.isVisible().catch(() => false)) { - await toggleBtn.click(); - await page.waitForTimeout(100); - } - } - - // Press Escape to deselect anything and ensure focus is on canvas - await page.keyboard.press("Escape"); - await page.waitForTimeout(100); - - // Select the node programmatically via __DEV__.selectNode (React Flow) - await page.evaluate((id) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - if (dev?.selectNode) { - dev.selectNode(id); - } - }, nodeId); - - // Wait for selection state to propagate - await page.waitForTimeout(100); - - // Verify selection was set - const selected = await page.evaluate((id) => { - const dev = (window as { __DEV__?: BrowserDevApi }).__DEV__; - return dev?.selectedNode?.() === id; - }, nodeId); - - if (!selected) { - throw new Error(`Failed to select node ${nodeId}`); - } - - // Press Delete key - await page.keyboard.press("Delete"); - await page.waitForTimeout(500); - }, - - deleteEdge: async (edgeId: string) => { - // Close any open editor panel first to ensure Delete key goes to the canvas - const isContextPanelOpen = await page.evaluate(() => { - const el = document.querySelector('[data-testid="context-panel"]'); - if (!el) return false; - return window.getComputedStyle(el).pointerEvents !== "none"; - }); - - if (isContextPanelOpen) { - const backBtn = page.locator('[data-testid="panel-back-btn"]'); - if (await backBtn.isVisible().catch(() => false)) { - await backBtn.click(); - await page.waitForTimeout(200); - } - - const toggleBtn = page.locator('[data-testid="panel-toggle-btn"]'); - if (await toggleBtn.isVisible().catch(() => false)) { - await toggleBtn.click(); - await page.waitForTimeout(200); - } - } - - // Click on canvas background to clear any focus/selection state - await page.mouse.click(50, 50); - await page.waitForTimeout(100); - - await topoViewerPage.selectEdge(edgeId); - await page.keyboard.press("Delete"); - await page.waitForTimeout(300); - }, - - writeYamlFile: async (filename: string, content: string) => { - // Build full path to YAML file - const yamlPath = path.join(TOPOLOGIES_DIR, filename); - const response = await request.put(withSession(`/file/${encodeURIComponent(yamlPath)}`), { - data: content, - headers: { "Content-Type": "text/plain; charset=utf-8" } - }); - if (!response.ok()) { - throw new Error(`Failed to write YAML: ${response.statusText()}`); - } - }, - - writeAnnotationsFile: async (filename: string, content: object) => { - // Build full path to annotations file - const annotationsPath = path.join(TOPOLOGIES_DIR, `${filename}.annotations.json`); - const response = await request.put( - withSession(`/file/${encodeURIComponent(annotationsPath)}`), - { - data: JSON.stringify(content, null, 2), - headers: { "Content-Type": "text/plain; charset=utf-8" } - } - ); - if (!response.ok()) { - throw new Error(`Failed to write annotations: ${response.statusText()}`); - } - }, - - readYamlFile: async (filename: string) => { - // Build full path to YAML file - const yamlPath = path.join(TOPOLOGIES_DIR, filename); - const response = await request.get(withSession(`/file/${encodeURIComponent(yamlPath)}`)); - if (!response.ok()) { - throw new Error(`Failed to read YAML: ${response.statusText()}`); - } - return response.text(); - } - }; - - await use(topoViewerPage); - - // Save browser console logs on test failure for debugging - if (testInfo.status !== testInfo.expectedStatus && consoleLogs.length > 0) { - const logsContent = consoleLogs.join("\n"); - - // Write to file in test-results folder - const logFilePath = testInfo.outputPath("browser-console-logs.txt"); - writeFileSync(logFilePath, logsContent); - - // Also attach to HTML report - await testInfo.attach("browser-console-logs", { - body: logsContent, - contentType: "text/plain" - }); - } - } -}); - -export { expect } from "@playwright/test"; diff --git a/test/e2e/global-setup.ts b/test/e2e/global-setup.ts deleted file mode 100644 index bc8b5bf8b..000000000 --- a/test/e2e/global-setup.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Global setup for Playwright E2E tests. - * Resets disk topology files to their original state before tests run. - */ - -import * as fs from "fs"; -import * as path from "path"; - -const TOPOLOGIES_DIR = path.join(__dirname, "../../dev/topologies"); -const TOPOLOGIES_ORIGINAL_DIR = path.join(__dirname, "../../dev/topologies-original"); - -async function listFilesRecursively(dir: string, root = dir): Promise { - const entries = await fs.promises.readdir(dir, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...(await listFilesRecursively(fullPath, root))); - continue; - } - - if (entry.isFile()) { - files.push(path.relative(root, fullPath)); - } - } - - return files; -} - -export default async function globalSetup(): Promise { - console.log("[GlobalSetup] Resetting disk files from topologies-original..."); - - try { - // Ensure topologies directory exists (it's gitignored so may not exist in CI) - await fs.promises.mkdir(TOPOLOGIES_DIR, { recursive: true }); - - // First, delete all annotation files in topologies (clean slate) - const currentFiles = await listFilesRecursively(TOPOLOGIES_DIR); - for (const file of currentFiles) { - if (file.endsWith(".annotations.json")) { - const filePath = path.join(TOPOLOGIES_DIR, file); - try { - await fs.promises.unlink(filePath); - console.log("[GlobalSetup] Deleted:", file); - } catch { - // Ignore errors - } - } - } - - // Copy all files from topologies-original to topologies - const originalFiles = await listFilesRecursively(TOPOLOGIES_ORIGINAL_DIR); - for (const file of originalFiles) { - const srcPath = path.join(TOPOLOGIES_ORIGINAL_DIR, file); - const destPath = path.join(TOPOLOGIES_DIR, file); - - await fs.promises.mkdir(path.dirname(destPath), { recursive: true }); - const content = await fs.promises.readFile(srcPath, "utf8"); - await fs.promises.writeFile(destPath, content, "utf8"); - console.log("[GlobalSetup] Restored:", file); - } - - console.log("[GlobalSetup] Disk reset complete"); - } catch (err) { - console.error("[GlobalSetup] Failed to reset disk files:", err); - throw err; - } -} diff --git a/test/e2e/helpers/react-flow-helpers.ts b/test/e2e/helpers/react-flow-helpers.ts deleted file mode 100644 index 20be2092e..000000000 --- a/test/e2e/helpers/react-flow-helpers.ts +++ /dev/null @@ -1,541 +0,0 @@ -import type { Page, Locator } from "@playwright/test"; - -// Constants for browser-side code -const RF_SELECTOR = ".react-flow"; -const TOPOLOGY_NODE_TYPE = "topology-node"; -const NETWORK_NODE_TYPE = "network-node"; - -/** - * Convert React Flow model coordinates to page/screen coordinates. - * This accounts for pan, zoom, and container position. - */ -export async function modelToPageCoords( - page: Page, - modelX: number, - modelY: number -): Promise<{ x: number; y: number }> { - return await page.evaluate( - ({ mx, my, sel }) => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return { x: 0, y: 0 }; - - const viewport = rf.getViewport?.() ?? { x: 0, y: 0, zoom: 1 }; - const container = document.querySelector(sel); - const rect = container?.getBoundingClientRect() ?? { left: 0, top: 0 }; - - return { - x: rect.left + mx * viewport.zoom + viewport.x, - y: rect.top + my * viewport.zoom + viewport.y - }; - }, - { mx: modelX, my: modelY, sel: RF_SELECTOR } - ); -} - -/** - * Convert page/screen coordinates to React Flow model coordinates. - */ -export async function pageToModelCoords( - page: Page, - pageX: number, - pageY: number -): Promise<{ x: number; y: number }> { - return await page.evaluate( - ({ px, py, sel }) => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return { x: 0, y: 0 }; - - const viewport = rf.getViewport?.() ?? { x: 0, y: 0, zoom: 1 }; - const container = document.querySelector(sel); - const rect = container?.getBoundingClientRect() ?? { left: 0, top: 0 }; - - return { - x: (px - rect.left - viewport.x) / viewport.zoom, - y: (py - rect.top - viewport.y) / viewport.zoom - }; - }, - { px: pageX, py: pageY, sel: RF_SELECTOR } - ); -} - -/** - * Get the current zoom level of the React Flow canvas. - */ -export async function getZoom(page: Page): Promise { - return await page.evaluate(() => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - return rf?.getViewport?.()?.zoom ?? 1; - }); -} - -/** - * Get the current pan position of the React Flow canvas. - */ -export async function getPan(page: Page): Promise<{ x: number; y: number }> { - return await page.evaluate(() => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - const viewport = rf?.getViewport?.() ?? { x: 0, y: 0 }; - return { x: viewport.x, y: viewport.y }; - }); -} - -/** - * Fit the graph to the viewport. - */ -export async function fitGraph(page: Page): Promise { - await page.evaluate(() => { - const dev = (window as any).__DEV__; - dev?.rfInstance?.fitView?.({ padding: 0.1 }); - }); - await page.waitForTimeout(300); -} - -/** - * Get all node IDs in the graph. - */ -export async function getAllNodeIds(page: Page): Promise { - return await page.evaluate( - (types) => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return []; - const nodes = rf.getNodes?.() ?? []; - return nodes - .filter((n: any) => n.type === types.topo || n.type === types.network) - .map((n: any) => n.id); - }, - { topo: TOPOLOGY_NODE_TYPE, network: NETWORK_NODE_TYPE } - ); -} - -/** - * Get all edge IDs in the graph. - */ -export async function getAllEdgeIds(page: Page): Promise { - return await page.evaluate(() => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return []; - const edges = rf.getEdges?.() ?? []; - return edges.map((e: any) => e.id); - }); -} - -/** - * Check if a node is selected. - */ -export async function isNodeSelected(page: Page, nodeId: string): Promise { - return await page.evaluate((id) => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return false; - const nodes = rf.getNodes?.() ?? []; - const node = nodes.find((n: any) => n.id === id); - return node?.selected ?? false; - }, nodeId); -} - -/** - * Perform a drag operation from one point to another. - */ -export async function drag( - page: Page, - from: { x: number; y: number }, - to: { x: number; y: number }, - options?: { steps?: number } -): Promise { - const steps = options?.steps ?? 10; - - await page.mouse.move(from.x, from.y); - await page.mouse.down(); - - // Move in steps for smoother dragging - for (let i = 1; i <= steps; i++) { - const x = from.x + ((to.x - from.x) * i) / steps; - const y = from.y + ((to.y - from.y) * i) / steps; - await page.mouse.move(x, y); - } - - await page.mouse.up(); -} - -/** - * Perform a Shift+Click at the specified position. - * Uses a delay to ensure Shift key is registered before click. - */ -export async function shiftClick(page: Page, x: number, y: number): Promise { - await page.keyboard.down("Shift"); - // Delay to ensure Shift key state is registered before the click event - await page.waitForTimeout(100); - await page.mouse.click(x, y); - await page.keyboard.up("Shift"); -} - -/** - * Perform a Ctrl+Click (or Cmd+Click on Mac) at the specified position. - */ -export async function ctrlClick(page: Page, x: number, y: number): Promise { - await page.keyboard.down("Control"); - await page.mouse.click(x, y); - await page.keyboard.up("Control"); -} - -/** - * Perform an Alt+Click at the specified position. - * Used for deleting elements (nodes, edges, annotations, groups). - */ -export async function altClick(page: Page, x: number, y: number): Promise { - await page.keyboard.down("Alt"); - // Small delay to ensure Alt key state is registered - await page.waitForTimeout(50); - await page.mouse.click(x, y); - await page.keyboard.up("Alt"); -} - -/** - * Perform an Alt+Click directly on an element using dispatchEvent. - * This is useful for narrow or overlapping HTML elements where coordinate-based - * clicking might land on the wrong element. - * Used for deleting HTML overlay elements (text annotations, shape annotations). - */ -export async function altClickElement(page: Page, locator: Locator): Promise { - const handle = await locator.elementHandle(); - if (!handle) throw new Error("Element not found"); - - await page.evaluate((el) => { - const clickEvent = new MouseEvent("click", { - bubbles: true, - cancelable: true, - view: window, - altKey: true - }); - el.dispatchEvent(clickEvent); - }, handle); -} - -/** - * Perform a double-click at the specified position. - */ -export async function doubleClick(page: Page, x: number, y: number): Promise { - await page.mouse.dblclick(x, y); -} - -/** - * Perform a right-click at the specified position. - */ -export async function rightClick(page: Page, x: number, y: number): Promise { - await page.mouse.click(x, y, { button: "right" }); -} - -/** - * Open context menu for a node by calculating its position. - */ -export async function openNodeContextMenu(page: Page, nodeId: string): Promise { - const coords = await page.evaluate( - ({ id, sel }) => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return null; - - const nodes = rf.getNodes?.() ?? []; - const node = nodes.find((n: any) => n.id === id); - if (node === undefined || node === null) return null; - - const viewport = rf.getViewport?.() ?? { x: 0, y: 0, zoom: 1 }; - const container = document.querySelector(sel); - const rect = container?.getBoundingClientRect() ?? { left: 0, top: 0 }; - - // Node center (assuming 60x60 node size) - const nodeCenter = { - x: node.position.x + 30, - y: node.position.y + 30 - }; - - return { - x: rect.left + nodeCenter.x * viewport.zoom + viewport.x, - y: rect.top + nodeCenter.y * viewport.zoom + viewport.y - }; - }, - { id: nodeId, sel: RF_SELECTOR } - ); - - if (!coords) { - throw new Error(`Failed to open context menu for node: ${nodeId}`); - } - - await page.mouse.click(coords.x, coords.y, { button: "right" }); -} - -/** - * Open the network editor panel for a given network node. - */ -export async function openNetworkEditor(page: Page, nodeId: string): Promise { - const opened = await page.evaluate((id) => { - const dev = (window as any).__DEV__; - if ( - dev === undefined || - dev === null || - typeof dev.openNetworkEditor !== "function" - ) { - return false; - } - dev.openNetworkEditor(id); - return true; - }, nodeId); - - if (!opened) { - throw new Error("openNetworkEditor is not available on window.__DEV__"); - } -} - -/** - * Perform zoom via mouse wheel. - */ -export async function mouseWheelZoom( - page: Page, - x: number, - y: number, - deltaY: number -): Promise { - await page.mouse.move(x, y); - await page.mouse.wheel(0, deltaY); -} - -/** - * Get the number of selected nodes. - */ -export async function getSelectedNodeCount(page: Page): Promise { - return await page.evaluate(() => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return 0; - const nodes = rf.getNodes?.() ?? []; - return nodes.filter((n: any) => n.selected).length; - }); -} - -/** - * Get the IDs of selected nodes. - */ -export async function getSelectedNodeIds(page: Page): Promise { - return await page.evaluate(() => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return []; - const nodes = rf.getNodes?.() ?? []; - return nodes.filter((n: any) => n.selected).map((n: any) => n.id); - }); -} - -/** - * Get the number of selected edges. - */ -export async function getSelectedEdgeCount(page: Page): Promise { - return await page.evaluate(() => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return 0; - const edges = rf.getEdges?.() ?? []; - return edges.filter((e: any) => e.selected).length; - }); -} - -/** - * Get the IDs of selected edges. - */ -export async function getSelectedEdgeIds(page: Page): Promise { - return await page.evaluate(() => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return []; - const edges = rf.getEdges?.() ?? []; - return edges.filter((e: any) => e.selected).map((e: any) => e.id); - }); -} - -/** - * Clear all selections in the graph. - */ -export async function clearSelection(page: Page): Promise { - // Press Escape to clear selection - await page.keyboard.press("Escape"); -} - -/** - * Get the edge count in the graph. - */ -export async function getEdgeCount(page: Page): Promise { - return await page.evaluate(() => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return 0; - const edges = rf.getEdges?.() ?? []; - return edges.length; - }); -} - -/** - * Get an edge's bounding box in page coordinates. - */ -export async function getEdgeBoundingBox( - page: Page, - edgeId: string -): Promise<{ x: number; y: number; width: number; height: number } | null> { - return await page.evaluate( - ({ id, sel }) => { - const getEdgeContext = (): { - rf: any; - sourceNode: any; - targetNode: any; - } | null => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return null; - - const edges = rf.getEdges?.() ?? []; - const edge = edges.find((e: any) => e.id === id); - if (edge === undefined || edge === null) return null; - - const nodes = rf.getNodes?.() ?? []; - const sourceNode = nodes.find((n: any) => n.id === edge.source); - const targetNode = nodes.find((n: any) => n.id === edge.target); - if (sourceNode === undefined || sourceNode === null) return null; - if (targetNode === undefined || targetNode === null) return null; - - return { rf, sourceNode, targetNode }; - }; - - const context = getEdgeContext(); - if (context === null) return null; - const { rf, sourceNode, targetNode } = context; - - const viewport = rf.getViewport?.() ?? { x: 0, y: 0, zoom: 1 }; - const container = document.querySelector(sel); - const rect = container?.getBoundingClientRect() ?? { left: 0, top: 0 }; - - // Calculate bounding box from source and target positions - const minX = Math.min(sourceNode.position.x, targetNode.position.x); - const minY = Math.min(sourceNode.position.y, targetNode.position.y); - const maxX = Math.max(sourceNode.position.x, targetNode.position.x) + 60; // Add node width - const maxY = Math.max(sourceNode.position.y, targetNode.position.y) + 60; - - return { - x: rect.left + minX * viewport.zoom + viewport.x, - y: rect.top + minY * viewport.zoom + viewport.y, - width: (maxX - minX) * viewport.zoom, - height: (maxY - minY) * viewport.zoom - }; - }, - { id: edgeId, sel: RF_SELECTOR } - ); -} - -/** - * Get the midpoint of an edge line in page coordinates. - * Uses the geometric midpoint between source and target nodes. - */ -export async function getEdgeMidpoint( - page: Page, - edgeId: string -): Promise<{ x: number; y: number } | null> { - return await page.evaluate( - ({ id, sel }) => { - const getPathMidpoint = (): { x: number; y: number } | null => { - const interactionId = `${id}-interaction`; - const pathEl = document.getElementById(interactionId) ?? document.getElementById(id); - if (!pathEl) return null; - const rect = pathEl.getBoundingClientRect(); - if (rect.width <= 0 && rect.height <= 0) return null; - return { - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2 - }; - }; - - const getGraphMidpoint = (): { x: number; y: number } | null => { - const getEdgeContext = (): { - rf: any; - sourceNode: any; - targetNode: any; - } | null => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return null; - - const edges = rf.getEdges?.() ?? []; - const edge = edges.find((e: any) => e.id === id); - if (edge === undefined || edge === null) return null; - - const nodes = rf.getNodes?.() ?? []; - const sourceNode = nodes.find((n: any) => n.id === edge.source); - const targetNode = nodes.find((n: any) => n.id === edge.target); - if (sourceNode === undefined || sourceNode === null) return null; - if (targetNode === undefined || targetNode === null) return null; - - return { rf, sourceNode, targetNode }; - }; - - const context = getEdgeContext(); - if (context === null) return null; - const { rf, sourceNode, targetNode } = context; - - const viewport = rf.getViewport?.() ?? { x: 0, y: 0, zoom: 1 }; - const container = document.querySelector(sel); - const rect = container?.getBoundingClientRect() ?? { left: 0, top: 0 }; - - // Calculate geometric midpoint (adding 30 for node center offset) - const midX = (sourceNode.position.x + targetNode.position.x) / 2 + 30; - const midY = (sourceNode.position.y + targetNode.position.y) / 2 + 30; - - return { - x: rect.left + midX * viewport.zoom + viewport.x, - y: rect.top + midY * viewport.zoom + viewport.y - }; - }; - - return getPathMidpoint() ?? getGraphMidpoint(); - }, - { id: edgeId, sel: RF_SELECTOR } - ); -} - -/** - * Press a keyboard shortcut. - */ -export async function pressShortcut( - page: Page, - key: string, - modifiers: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean } = {} -): Promise { - if (modifiers.ctrl === true) await page.keyboard.down("Control"); - if (modifiers.shift === true) await page.keyboard.down("Shift"); - if (modifiers.alt === true) await page.keyboard.down("Alt"); - if (modifiers.meta === true) await page.keyboard.down("Meta"); - - await page.keyboard.press(key); - - if (modifiers.meta === true) await page.keyboard.up("Meta"); - if (modifiers.alt === true) await page.keyboard.up("Alt"); - if (modifiers.shift === true) await page.keyboard.up("Shift"); - if (modifiers.ctrl === true) await page.keyboard.up("Control"); -} - -/** - * Perform box selection by dragging from one corner to another. - * Uses a delay after pressing Shift to ensure the key state is registered. - */ -export async function boxSelect( - page: Page, - from: { x: number; y: number }, - to: { x: number; y: number } -): Promise { - await page.keyboard.down("Shift"); - // Delay to ensure Shift key state is registered before the drag (same as shiftClick) - await page.waitForTimeout(100); - await drag(page, from, to, { steps: 5 }); - await page.keyboard.up("Shift"); -} diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts deleted file mode 100644 index 1f466c28e..000000000 --- a/test/e2e/playwright.config.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; - -const isCI = (process.env.CI ?? "").length > 0; - -/** - * Playwright configuration for React TopoViewer E2E tests. - * Tests run against the Vite dev server on port 5173. - * - * Note: Worker count is limited to prevent overwhelming the single dev server. - * Retries are enabled to handle flaky network/timing issues. - */ -export default defineConfig({ - globalSetup: require.resolve("./global-setup"), - testDir: "./specs", - // CI runners are slower and more variable; reduce intra-file parallelism to - // avoid intermittent timeouts while still allowing worker parallelism. - fullyParallel: false, - forbidOnly: isCI, - // Retry flaky tests - helps with timing issues and connection resets - retries: isCI ? 2 : 1, - // Keep at least 4 workers to validate true parallel execution behavior. - workers: isCI ? 2 : 6, - // Increase timeout for slower CI environments - timeout: 90000, - reporter: [["list"], ["html", { open: "never", outputFolder: "../../playwright-report" }]], - use: { - baseURL: "http://localhost:5173", - trace: "on-first-retry", - screenshot: "only-on-failure", - video: "retain-on-failure", - // Timeouts for individual actions - actionTimeout: 20000, - navigationTimeout: 45000 - }, - projects: [ - { - name: "chromium", - use: { - ...devices["Desktop Chrome"], - // Grant clipboard permissions for copy/paste tests - permissions: ["clipboard-read", "clipboard-write"] - } - } - ], - webServer: { - command: "npm run dev", - url: "http://localhost:5173", - reuseExistingServer: !isCI, - timeout: isCI ? 180000 : 120000, - cwd: "../../" // Run from project root - } -}); diff --git a/test/e2e/specs/alt-click-delete.spec.ts b/test/e2e/specs/alt-click-delete.spec.ts deleted file mode 100644 index 8ccbc9196..000000000 --- a/test/e2e/specs/alt-click-delete.spec.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { test, expect } from "../fixtures/topoviewer"; -import { altClick, altClickElement, getEdgeMidpoint } from "../helpers/react-flow-helpers"; - -const SPINE_LEAF_FILE = "spine-leaf.clab.yml"; -const DATACENTER_FILE = "datacenter.clab.yml"; - -/** - * Tests for Alt+Click delete functionality across all element types: - * - Nodes (React Flow elements) - * - Edges (React Flow elements) - * - Groups (HTML overlays) - * - Free Text annotations (HTML overlays) - * - Free Shape annotations (HTML overlays) - */ -test.describe("Alt+Click Delete", () => { - test.describe("Node Deletion", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile("simple.clab.yml"); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - test("Alt+Click deletes a node", async ({ page, topoViewerPage }) => { - const initialNodeCount = await topoViewerPage.getNodeCount(); - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - - const nodeToDelete = nodeIds[0]; - const nodeBB = await topoViewerPage.getNodeBoundingBox(nodeToDelete); - expect(nodeBB).not.toBeNull(); - - // Alt+Click on the node - await altClick(page, nodeBB!.x + nodeBB!.width / 2, nodeBB!.y + nodeBB!.height / 2); - await page.waitForTimeout(300); - - // Node should be deleted - const newNodeCount = await topoViewerPage.getNodeCount(); - expect(newNodeCount).toBe(initialNodeCount - 1); - - const remainingNodeIds = await topoViewerPage.getNodeIds(); - expect(remainingNodeIds).not.toContain(nodeToDelete); - }); - - test("Alt+Click does not delete node when locked", async ({ page, topoViewerPage }) => { - const initialNodeCount = await topoViewerPage.getNodeCount(); - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - - // Lock the canvas - await topoViewerPage.lock(); - - const nodeToDelete = nodeIds[0]; - const nodeBB = await topoViewerPage.getNodeBoundingBox(nodeToDelete); - expect(nodeBB).not.toBeNull(); - - // Alt+Click on the node - await altClick(page, nodeBB!.x + nodeBB!.width / 2, nodeBB!.y + nodeBB!.height / 2); - await page.waitForTimeout(300); - - // Node count should remain the same - const newNodeCount = await topoViewerPage.getNodeCount(); - expect(newNodeCount).toBe(initialNodeCount); - }); - - test("Alt+Click does not delete node in view mode", async ({ page, topoViewerPage }) => { - const initialNodeCount = await topoViewerPage.getNodeCount(); - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - - // Switch to view mode - await topoViewerPage.setViewMode(); - - const nodeToDelete = nodeIds[0]; - const nodeBB = await topoViewerPage.getNodeBoundingBox(nodeToDelete); - expect(nodeBB).not.toBeNull(); - - // Alt+Click on the node - await altClick(page, nodeBB!.x + nodeBB!.width / 2, nodeBB!.y + nodeBB!.height / 2); - await page.waitForTimeout(300); - - // Node count should remain the same - const newNodeCount = await topoViewerPage.getNodeCount(); - expect(newNodeCount).toBe(initialNodeCount); - }); - }); - - test.describe("Edge Deletion", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile("simple.clab.yml"); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - test("Alt+Click deletes an edge", async ({ page, topoViewerPage }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - expect(initialEdgeCount).toBeGreaterThan(0); - - const edgeIds = await topoViewerPage.getEdgeIds(); - expect(edgeIds.length).toBeGreaterThan(0); - - const edgeToDelete = edgeIds[0]; - const edgeMidpoint = await getEdgeMidpoint(page, edgeToDelete); - expect(edgeMidpoint).not.toBeNull(); - - // Alt+Click on the edge - await altClick(page, edgeMidpoint!.x, edgeMidpoint!.y); - await page.waitForTimeout(300); - - // Edge should be deleted - const newEdgeCount = await topoViewerPage.getEdgeCount(); - expect(newEdgeCount).toBe(initialEdgeCount - 1); - }); - - test("Alt+Click does not delete edge when locked", async ({ page, topoViewerPage }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - expect(initialEdgeCount).toBeGreaterThan(0); - - // Lock the canvas - await topoViewerPage.lock(); - - const edgeIds = await topoViewerPage.getEdgeIds(); - const edgeToDelete = edgeIds[0]; - const edgeMidpoint = await getEdgeMidpoint(page, edgeToDelete); - expect(edgeMidpoint).not.toBeNull(); - - // Alt+Click on the edge - await altClick(page, edgeMidpoint!.x, edgeMidpoint!.y); - await page.waitForTimeout(300); - - // Edge count should remain the same - const newEdgeCount = await topoViewerPage.getEdgeCount(); - expect(newEdgeCount).toBe(initialEdgeCount); - }); - }); - - test.describe("Group Deletion", () => { - test("Alt+Click deletes an existing group", async ({ page, topoViewerPage }) => { - // Use datacenter topology which has pre-existing groups - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(DATACENTER_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Wait for groups to be loaded and fit to viewport - await page.waitForTimeout(500); - await topoViewerPage.fit(); - await page.waitForTimeout(300); - - const groupIds = await topoViewerPage.getGroupIds(); - expect(groupIds.length).toBeGreaterThan(0); - - const initialGroupCount = await topoViewerPage.getGroupCount(); - - // Use group-spine (index 1) which has a clearly visible label in the viewport - // group-border (index 0) has its label at y=12 which can be at the edge - const groupToDelete = groupIds[1]; // group-spine - - // Get the group label's bounding box using raw coordinates - const groupLabel = page.locator(`[data-testid="group-label-${groupToDelete}"]`); - await groupLabel.waitFor({ state: "visible", timeout: 5000 }); - const labelBox = await groupLabel.boundingBox(); - expect(labelBox).not.toBeNull(); - - // Use raw mouse coordinates with altClick to avoid Playwright's actionability checks - await altClick(page, labelBox!.x + labelBox!.width / 2, labelBox!.y + labelBox!.height / 2); - - // Group should be deleted - use poll for reliability - await expect - .poll(() => topoViewerPage.getGroupCount(), { - timeout: 5000, - message: "Expected group to be deleted" - }) - .toBe(initialGroupCount - 1); - }); - - test("Alt+Click does not delete group when locked", async ({ page, topoViewerPage }) => { - // Use datacenter topology which has pre-existing groups - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(DATACENTER_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Wait for groups to be loaded and fit to viewport - await page.waitForTimeout(500); - await topoViewerPage.fit(); - await page.waitForTimeout(300); - - const groupIds = await topoViewerPage.getGroupIds(); - const initialGroupCount = await topoViewerPage.getGroupCount(); - expect(groupIds.length).toBeGreaterThan(0); - - // Use group-spine (index 1) which has a clearly visible label - const groupToDelete = groupIds[1]; // group-spine - - // Lock the canvas - await topoViewerPage.lock(); - - // Get label bounding box - const groupLabel = page.locator(`[data-testid="group-label-${groupToDelete}"]`); - const labelBox = await groupLabel.boundingBox(); - expect(labelBox).not.toBeNull(); - - // Alt+Click using raw coordinates - await altClick(page, labelBox!.x + labelBox!.width / 2, labelBox!.y + labelBox!.height / 2); - await page.waitForTimeout(300); - - // Group count should remain the same - const newGroupCount = await topoViewerPage.getGroupCount(); - expect(newGroupCount).toBe(initialGroupCount); - }); - }); - - test.describe("Free Text Deletion", () => { - test("Alt+Click deletes a free text annotation", async ({ page, topoViewerPage }) => { - // Use datacenter topology which has pre-existing text annotations - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(DATACENTER_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Wait for annotations to load and fit to viewport - await page.waitForTimeout(500); - await topoViewerPage.fit(); - await page.waitForTimeout(300); - - // Get initial text annotation count from file - const beforeAnnotations = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const initialTextCount = beforeAnnotations.freeTextAnnotations?.length ?? 0; - expect(initialTextCount).toBeGreaterThan(0); - - const textAnnotation = beforeAnnotations.freeTextAnnotations?.[0]; - expect(textAnnotation).toBeDefined(); - - const textBox = await topoViewerPage.getNodeBoundingBox(textAnnotation!.id); - expect(textBox).not.toBeNull(); - - // Use altClickElement which dispatches the event directly to the element for reliability - const textElement = page.locator(`[data-id="${textAnnotation!.id}"] .free-text-content`); - if (await textElement.count()) { - await altClickElement(page, textElement.first()); - } else { - await altClick(page, textBox!.x + textBox!.width / 2, textBox!.y + textBox!.height / 2); - } - - // Text should be deleted - verify in file - await expect - .poll( - async () => { - const after = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - return after.freeTextAnnotations?.length ?? 0; - }, - { timeout: 5000, message: "Expected free text annotation to be deleted" } - ) - .toBe(initialTextCount - 1); - }); - }); - - test.describe("Free Shape Deletion", () => { - test("Alt+Click deletes a free shape annotation", async ({ page, topoViewerPage }) => { - // Use datacenter topology which has a pre-existing shape annotation - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(DATACENTER_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Wait for annotations to load and fit to viewport - await page.waitForTimeout(500); - await topoViewerPage.fit(); - await page.waitForTimeout(300); - - // Get initial shape annotation count from file - const beforeAnnotations = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const initialShapeCount = beforeAnnotations.freeShapeAnnotations?.length ?? 0; - expect(initialShapeCount).toBeGreaterThan(0); - - // Get the position of the shape from annotations file - const shapeAnnotation = beforeAnnotations.freeShapeAnnotations![0]; - - const shapeBox = await topoViewerPage.getNodeBoundingBox(shapeAnnotation.id); - expect(shapeBox).not.toBeNull(); - - if (shapeAnnotation.shapeType === "line") { - const lineElement = page.locator(`[data-id="${shapeAnnotation.id}"] .free-shape-line line`); - if ((await lineElement.count()) > 0) { - await altClickElement(page, lineElement.first()); - } else { - await altClick(page, shapeBox!.x + shapeBox!.width / 2, shapeBox!.y + shapeBox!.height / 2); - } - } else { - // Prefer direct element dispatch for overlay shapes (more reliable than coordinates). - const shapeElement = page.locator( - `[data-id="${shapeAnnotation.id}"] .free-shape-${shapeAnnotation.shapeType}` - ); - if ((await shapeElement.count()) > 0) { - await altClickElement(page, shapeElement.first()); - } else { - await altClick(page, shapeBox!.x + shapeBox!.width / 2, shapeBox!.y + shapeBox!.height / 2); - } - } - - // Shape should be deleted - verify in file - await expect - .poll( - async () => { - const after = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - return after.freeShapeAnnotations?.length ?? 0; - }, - { timeout: 5000, message: "Expected free shape annotation to be deleted" } - ) - .toBe(initialShapeCount - 1); - }); - }); - - test.describe("File Persistence", () => { - test("Alt+Click delete persists node removal to YAML file", async ({ - page, - topoViewerPage - }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(SPINE_LEAF_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - const initialYaml = await topoViewerPage.getYamlFromFile(SPINE_LEAF_FILE); - expect(initialYaml).toContain("leaf1:"); - - const nodeBB = await topoViewerPage.getNodeBoundingBox("leaf1"); - expect(nodeBB).not.toBeNull(); - - // Alt+Click to delete leaf1 - await altClick(page, nodeBB!.x + nodeBB!.width / 2, nodeBB!.y + nodeBB!.height / 2); - - // Wait for save to complete - await page.waitForTimeout(1000); - - // Read updated YAML - const updatedYaml = await topoViewerPage.getYamlFromFile(SPINE_LEAF_FILE); - - // leaf1 node should be gone - expect(updatedYaml).not.toContain("leaf1:"); - }); - }); -}); diff --git a/test/e2e/specs/box-selection.spec.ts b/test/e2e/specs/box-selection.spec.ts deleted file mode 100644 index 8acfb2af8..000000000 --- a/test/e2e/specs/box-selection.spec.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { test, expect } from "../fixtures/topoviewer"; -import { boxSelect } from "../helpers/react-flow-helpers"; - -const SIMPLE_FILE = "simple.clab.yml"; - -test.describe("Box Selection", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - test("drag on canvas creates selection box", async ({ page, topoViewerPage }) => { - const canvasCenter = await topoViewerPage.getCanvasCenter(); - - // Define box selection area in empty canvas region - const from = { - x: canvasCenter.x - 150, - y: canvasCenter.y - 150 - }; - const to = { - x: canvasCenter.x - 50, - y: canvasCenter.y - 50 - }; - - // Perform box selection (drag) - await boxSelect(page, from, to); - await page.waitForTimeout(300); - - // Selection box should appear during drag (visual check would be manual) - // At minimum, verify no errors occurred - const selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(Array.isArray(selectedIds)).toBe(true); - }); - - test("box selection selects nodes inside the box but not outside", async ({ - page, - topoViewerPage - }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThanOrEqual(2); - - // Get positions of both nodes (simple.clab.yml has srl1 and srl2) - const node1Box = await topoViewerPage.getNodeBoundingBox(nodeIds[0]); - const node2Box = await topoViewerPage.getNodeBoundingBox(nodeIds[1]); - expect(node1Box).not.toBeNull(); - expect(node2Box).not.toBeNull(); - - // Test 1: Select both nodes with a box that encompasses them - const minX = Math.min(node1Box!.x, node2Box!.x) - 20; - const minY = Math.min(node1Box!.y, node2Box!.y) - 20; - const maxX = Math.max(node1Box!.x + node1Box!.width, node2Box!.x + node2Box!.width) + 20; - const maxY = Math.max(node1Box!.y + node1Box!.height, node2Box!.y + node2Box!.height) + 20; - - await boxSelect(page, { x: minX, y: minY }, { x: maxX, y: maxY }); - await page.waitForTimeout(300); - - // Both nodes should be selected - let selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(2); - expect(selectedIds).toContain(nodeIds[0]); - expect(selectedIds).toContain(nodeIds[1]); - - // Clear selection - await page.keyboard.press("Escape"); - await page.waitForTimeout(200); - - // Test 2: Create a small box that only contains the first node - const smallFrom = { - x: node1Box!.x - 10, - y: node1Box!.y - 10 - }; - const smallTo = { - x: node1Box!.x + node1Box!.width + 10, - y: node1Box!.y + node1Box!.height + 10 - }; - - await boxSelect(page, smallFrom, smallTo); - await page.waitForTimeout(300); - - // Only the first node should be selected - selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(1); - expect(selectedIds).toContain(nodeIds[0]); - expect(selectedIds).not.toContain(nodeIds[1]); - }); - - test("box selection with Shift replaces existing selection", async ({ page, topoViewerPage }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThanOrEqual(2); - - // Select first node normally - await topoViewerPage.selectNode(nodeIds[0]); - let selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(1); - expect(selectedIds).toContain(nodeIds[0]); - - // Get position of second node - const node2Box = await topoViewerPage.getNodeBoundingBox(nodeIds[1]); - expect(node2Box).not.toBeNull(); - - // Create a box around the second node with Ctrl held - const from = { - x: node2Box!.x - 10, - y: node2Box!.y - 10 - }; - const to = { - x: node2Box!.x + node2Box!.width + 10, - y: node2Box!.y + node2Box!.height + 10 - }; - - // Perform box selection for the second node - await boxSelect(page, from, to); - await page.waitForTimeout(300); - - // Selection should be replaced with the box selection result - selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(1); - expect(selectedIds).not.toContain(nodeIds[0]); - expect(selectedIds).toContain(nodeIds[1]); - }); - - // Note: Selection is a read operation and is allowed when locked or in view mode. - test("box selection works when canvas is locked or in view mode", async ({ - page, - topoViewerPage - }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThanOrEqual(2); - - // Get positions of both nodes - const node1Box = await topoViewerPage.getNodeBoundingBox(nodeIds[0]); - const node2Box = await topoViewerPage.getNodeBoundingBox(nodeIds[1]); - expect(node1Box).not.toBeNull(); - expect(node2Box).not.toBeNull(); - - // Calculate bounding box that contains both nodes - const minX = Math.min(node1Box!.x, node2Box!.x) - 20; - const minY = Math.min(node1Box!.y, node2Box!.y) - 20; - const maxX = Math.max(node1Box!.x + node1Box!.width, node2Box!.x + node2Box!.width) + 20; - const maxY = Math.max(node1Box!.y + node1Box!.height, node2Box!.y + node2Box!.height) + 20; - - // Test locked state - await topoViewerPage.lock(); - const isLocked = await topoViewerPage.isLocked(); - expect(isLocked).toBe(true); - - await boxSelect(page, { x: minX, y: minY }, { x: maxX, y: maxY }); - await page.waitForTimeout(300); - - let selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(2); - expect(selectedIds).toContain(nodeIds[0]); - expect(selectedIds).toContain(nodeIds[1]); - - // Unlock and clear selection - await topoViewerPage.unlock(); - await page.keyboard.press("Escape"); - await page.waitForTimeout(200); - - // Test view mode - await topoViewerPage.setViewMode(); - - // Re-get positions as they may have changed - const node1BoxView = await topoViewerPage.getNodeBoundingBox(nodeIds[0]); - const node2BoxView = await topoViewerPage.getNodeBoundingBox(nodeIds[1]); - const minXView = Math.min(node1BoxView!.x, node2BoxView!.x) - 20; - const minYView = Math.min(node1BoxView!.y, node2BoxView!.y) - 20; - const maxXView = - Math.max(node1BoxView!.x + node1BoxView!.width, node2BoxView!.x + node2BoxView!.width) + 20; - const maxYView = - Math.max(node1BoxView!.y + node1BoxView!.height, node2BoxView!.y + node2BoxView!.height) + 20; - - await boxSelect(page, { x: minXView, y: minYView }, { x: maxXView, y: maxYView }); - await page.waitForTimeout(300); - - selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(2); - expect(selectedIds).toContain(nodeIds[0]); - expect(selectedIds).toContain(nodeIds[1]); - }); - - test("empty box selection clears existing selection", async ({ page, topoViewerPage }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThanOrEqual(1); - - // Select a node first - await topoViewerPage.selectNode(nodeIds[0]); - let selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(1); - - // Get canvas center - const canvasCenter = await topoViewerPage.getCanvasCenter(); - - // Perform box selection in empty area (far from nodes) - const from = { - x: canvasCenter.x + 200, - y: canvasCenter.y + 200 - }; - const to = { - x: canvasCenter.x + 300, - y: canvasCenter.y + 300 - }; - - await boxSelect(page, from, to); - await page.waitForTimeout(300); - - // Shift-box selection is additive; selecting empty area preserves existing selection - selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(1); - expect(selectedIds).toContain(nodeIds[0]); - }); - - test("box selection works after zoom and pan", async ({ page, topoViewerPage }) => { - // Zoom in - await topoViewerPage.setZoom(1.5); - await page.waitForTimeout(200); - - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThanOrEqual(2); - - // Get updated positions after zoom - let node1Box = await topoViewerPage.getNodeBoundingBox(nodeIds[0]); - let node2Box = await topoViewerPage.getNodeBoundingBox(nodeIds[1]); - expect(node1Box).not.toBeNull(); - expect(node2Box).not.toBeNull(); - - // Calculate bounding box at new zoom level - let minX = Math.min(node1Box!.x, node2Box!.x) - 20; - let minY = Math.min(node1Box!.y, node2Box!.y) - 20; - let maxX = Math.max(node1Box!.x + node1Box!.width, node2Box!.x + node2Box!.width) + 20; - let maxY = Math.max(node1Box!.y + node1Box!.height, node2Box!.y + node2Box!.height) + 20; - - // Perform box selection at zoomed level - await boxSelect(page, { x: minX, y: minY }, { x: maxX, y: maxY }); - await page.waitForTimeout(300); - - let selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(2); - - // Clear selection and test pan - await page.keyboard.press("Escape"); - await page.waitForTimeout(200); - - // Pan the canvas using viewport (selection drag uses mouse) - await topoViewerPage.setPan(100, 100); - - // Get updated positions after pan - node1Box = await topoViewerPage.getNodeBoundingBox(nodeIds[0]); - node2Box = await topoViewerPage.getNodeBoundingBox(nodeIds[1]); - - minX = Math.min(node1Box!.x, node2Box!.x) - 20; - minY = Math.min(node1Box!.y, node2Box!.y) - 20; - maxX = Math.max(node1Box!.x + node1Box!.width, node2Box!.x + node2Box!.width) + 20; - maxY = Math.max(node1Box!.y + node1Box!.height, node2Box!.y + node2Box!.height) + 20; - - // Perform box selection at panned position - await boxSelect(page, { x: minX, y: minY }, { x: maxX, y: maxY }); - await page.waitForTimeout(300); - - selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(2); - }); - - test("box selection from bottom-right to top-left works", async ({ page, topoViewerPage }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThanOrEqual(2); - - // Get positions of both nodes - const node1Box = await topoViewerPage.getNodeBoundingBox(nodeIds[0]); - const node2Box = await topoViewerPage.getNodeBoundingBox(nodeIds[1]); - expect(node1Box).not.toBeNull(); - expect(node2Box).not.toBeNull(); - - // Calculate bounding box - drag from bottom-right to top-left - const minX = Math.min(node1Box!.x, node2Box!.x) - 20; - const minY = Math.min(node1Box!.y, node2Box!.y) - 20; - const maxX = Math.max(node1Box!.x + node1Box!.width, node2Box!.x + node2Box!.width) + 20; - const maxY = Math.max(node1Box!.y + node1Box!.height, node2Box!.y + node2Box!.height) + 20; - - // Perform box selection in reverse direction - await boxSelect(page, { x: maxX, y: maxY }, { x: minX, y: minY }); - await page.waitForTimeout(300); - - // Both nodes should still be selected - const selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(2); - expect(selectedIds).toContain(nodeIds[0]); - expect(selectedIds).toContain(nodeIds[1]); - }); - - test("box selection replaces selection when dragging", async ({ page, topoViewerPage }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThanOrEqual(2); - - // Select first node programmatically for more reliable initial state - await topoViewerPage.selectNode(nodeIds[0]); - await page.waitForTimeout(200); - - let selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(1); - expect(selectedIds).toContain(nodeIds[0]); - - // Get position of second node - const node2Box = await topoViewerPage.getNodeBoundingBox(nodeIds[1]); - expect(node2Box).not.toBeNull(); - - // Create a box around the second node - const from = { - x: node2Box!.x - 10, - y: node2Box!.y - 10 - }; - const to = { - x: node2Box!.x + node2Box!.width + 10, - y: node2Box!.y + node2Box!.height + 10 - }; - - // Perform box selection - replaces selection - await boxSelect(page, from, to); - await page.waitForTimeout(300); - - // Only the second node should be selected - selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(1); - expect(selectedIds).not.toContain(nodeIds[0]); - expect(selectedIds).toContain(nodeIds[1]); - }); -}); diff --git a/test/e2e/specs/bulk-link.spec.ts b/test/e2e/specs/bulk-link.spec.ts deleted file mode 100644 index 9111b2640..000000000 --- a/test/e2e/specs/bulk-link.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { Page } from "@playwright/test"; - -import { test, expect } from "../fixtures/topoviewer"; - -const EMPTY_FILE = "empty.clab.yml"; -const KIND = "nokia_srlinux"; -const SEL_NAVBAR_BULK_LINK = '[data-testid="navbar-bulk-link"]'; -const SEL_BULK_LINK_MODAL = '[data-testid="bulk-link-modal"]'; -const SEL_BULK_LINK_SOURCE = '[data-testid="bulk-link-source"]'; -const SEL_BULK_LINK_TARGET = '[data-testid="bulk-link-target"]'; -const SEL_BULK_LINK_APPLY_BTN = '[data-testid="bulk-link-apply-btn"]'; -const SEL_BULK_LINK_CLOSE_BTN = '[data-testid="bulk-link-close-btn"]'; - -/** - * Bulk Link Modal E2E Tests (MUI Dialog version) - * - * In the new MUI design, bulk link creation opens from the navbar button - * in a Dialog with source/target pattern inputs and an Apply button. After Apply, - * a confirmation dialog shows before creating the links. - */ -test.describe("Bulk Link Devices", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(EMPTY_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - async function openBulkLinkModal(page: Page): Promise { - await page.locator(SEL_NAVBAR_BULK_LINK).click(); - await page.waitForTimeout(300); - } - - test("opens bulk link modal via navbar button", async ({ page }) => { - await openBulkLinkModal(page); - - const modal = page.locator(SEL_BULK_LINK_MODAL); - await expect(modal).toBeVisible(); - }); - - test("bulk link modal has source and target inputs", async ({ page }) => { - await openBulkLinkModal(page); - - await expect(page.locator(SEL_BULK_LINK_SOURCE)).toBeVisible(); - await expect(page.locator(SEL_BULK_LINK_TARGET)).toBeVisible(); - await expect(page.locator(SEL_BULK_LINK_APPLY_BTN)).toBeVisible(); - await expect(page.locator(SEL_BULK_LINK_CLOSE_BTN)).toBeVisible(); - }); - - test("close button closes bulk link modal", async ({ page }) => { - await openBulkLinkModal(page); - - const modal = page.locator(SEL_BULK_LINK_MODAL); - await expect(modal).toBeVisible(); - - await page.locator(SEL_BULK_LINK_CLOSE_BTN).click(); - await page.waitForTimeout(300); - - await expect(modal).not.toBeVisible(); - }); - - test("creates links between matched nodes", async ({ page, topoViewerPage }) => { - // Create nodes first - await topoViewerPage.createNode("leaf1", { x: 200, y: 120 }, KIND); - await topoViewerPage.createNode("leaf2", { x: 200, y: 260 }, KIND); - await topoViewerPage.createNode("spine1", { x: 460, y: 120 }, KIND); - await topoViewerPage.createNode("spine2", { x: 460, y: 260 }, KIND); - await expect.poll(() => topoViewerPage.getNodeCount()).toBe(4); - await expect.poll(() => topoViewerPage.getEdgeCount()).toBe(0); - - // Open bulk link modal via navbar - await openBulkLinkModal(page); - - // Fill source and target patterns - await page.locator(SEL_BULK_LINK_SOURCE).locator("input").fill("leaf*"); - await page.locator(SEL_BULK_LINK_TARGET).locator("input").fill("spine*"); - - // Click Apply to compute candidates - await page.locator(SEL_BULK_LINK_APPLY_BTN).click(); - await page.waitForTimeout(500); - - // Confirm link creation in the confirmation dialog - const createLinksBtn = page.getByRole("button", { name: "Create Links" }); - await expect(createLinksBtn).toBeVisible({ timeout: 3000 }); - await createLinksBtn.click(); - await page.waitForTimeout(500); - - // Verify 4 cross-links were created (leaf1→spine1, leaf1→spine2, leaf2→spine1, leaf2→spine2) - await expect.poll(() => topoViewerPage.getEdgeCount()).toBe(4); - - // Verify YAML has endpoints - const yaml = await topoViewerPage.getYamlFromFile(EMPTY_FILE); - const endpointCount = (yaml.match(/endpoints:/g) ?? []).length; - expect(endpointCount).toBe(4); - - // Batch undo removes all links at once - await topoViewerPage.undo(); - await expect.poll(() => topoViewerPage.getEdgeCount()).toBe(0); - - // Batch redo restores all links - await topoViewerPage.redo(); - await expect.poll(() => topoViewerPage.getEdgeCount()).toBe(4); - }); -}); diff --git a/test/e2e/specs/canvas-interactions.spec.ts b/test/e2e/specs/canvas-interactions.spec.ts deleted file mode 100644 index 91504631c..000000000 --- a/test/e2e/specs/canvas-interactions.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { test, expect } from "../fixtures/topoviewer"; - -test.describe("Canvas Interactions", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.gotoFile("simple.clab.yml"); - await topoViewerPage.waitForCanvasReady(); - }); - - test("canvas is visible and has correct selector", async ({ page }) => { - const canvas = page.locator(".react-flow"); - await expect(canvas).toBeVisible(); - }); - - test("app container is visible", async ({ page }) => { - const app = page.locator('[data-testid="topoviewer-app"]'); - await expect(app).toBeVisible(); - }); - - test("click on empty canvas deselects all", async ({ page, topoViewerPage }) => { - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - - // Select a node - await topoViewerPage.selectNode(nodeIds[0]); - let selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(1); - - // Click on empty canvas area (far from center where nodes are) - await topoViewerPage.clearSelection(); - await page.waitForTimeout(200); - - selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(0); - }); - - test("lock state persists across interactions", async ({ page, topoViewerPage }) => { - await topoViewerPage.setEditMode(); - - // Unlock the canvas - await topoViewerPage.unlock(); - let isLocked = await topoViewerPage.isLocked(); - expect(isLocked).toBe(false); - - // Lock the canvas - await topoViewerPage.lock(); - isLocked = await topoViewerPage.isLocked(); - expect(isLocked).toBe(true); - - // Verify lock persists after some interactions - const canvasCenter = await topoViewerPage.getCanvasCenter(); - await page.mouse.click(canvasCenter.x, canvasCenter.y); - await page.waitForTimeout(100); - - isLocked = await topoViewerPage.isLocked(); - expect(isLocked).toBe(true); - }); - - test("mode switching works correctly", async ({ topoViewerPage }) => { - // Start in edit mode - await topoViewerPage.setEditMode(); - - // Switch to view mode - await topoViewerPage.setViewMode(); - - // Node count should remain the same - const nodeCount = await topoViewerPage.getNodeCount(); - expect(nodeCount).toBeGreaterThan(0); - - // Switch back to edit mode - await topoViewerPage.setEditMode(); - - // Node count should still be the same - const nodeCountAfter = await topoViewerPage.getNodeCount(); - expect(nodeCountAfter).toBe(nodeCount); - }); -}); diff --git a/test/e2e/specs/context-menu-actions.spec.ts b/test/e2e/specs/context-menu-actions.spec.ts deleted file mode 100644 index d43fb5e4b..000000000 --- a/test/e2e/specs/context-menu-actions.spec.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { test, expect } from "../fixtures/topoviewer"; -import { rightClick, getEdgeMidpoint } from "../helpers/react-flow-helpers"; - -const SIMPLE_FILE = "simple.clab.yml"; -const SEL_CONTEXT_MENU = '[data-testid="context-menu"]'; -const SEL_EDIT_NODE_ITEM = '[data-testid="context-menu-item-edit-node"]'; -const SEL_DELETE_NODE_ITEM = '[data-testid="context-menu-item-delete-node"]'; -const SEL_EDIT_EDGE_ITEM = '[data-testid="context-menu-item-edit-edge"]'; -const SEL_CONTEXT_PANEL = '[data-testid="context-panel"]'; - -/** - * Context Menu Actions E2E Tests (MUI version) - * - * Tests context menu functionality for nodes and edges in both edit and view modes. - * In the new MUI design, the context menu uses MUI Menu component, - * and editors open in the ContextPanel sidebar. - */ -test.describe("Context Menu Actions", () => { - test.describe("Node Context Menu - Edit Mode", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - test("right-click on node shows context menu with Edit, Delete, and Create Link options", async ({ - page, - topoViewerPage - }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - - const nodeBox = await topoViewerPage.getNodeBoundingBox(nodeIds[0]); - expect(nodeBox).not.toBeNull(); - - await rightClick(page, nodeBox!.x + nodeBox!.width / 2, nodeBox!.y + nodeBox!.height / 2); - - const contextMenu = page.locator(SEL_CONTEXT_MENU); - await expect(contextMenu).toBeVisible(); - - await expect(page.locator(SEL_EDIT_NODE_ITEM)).toBeVisible(); - await expect(page.locator(SEL_DELETE_NODE_ITEM)).toBeVisible(); - await expect(page.locator('[data-testid="context-menu-item-create-link"]')).toBeVisible(); - }); - - test("clicking Edit opens node editor in context panel", async ({ page, topoViewerPage }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - const nodeBox = await topoViewerPage.getNodeBoundingBox(nodeIds[0]); - - await rightClick(page, nodeBox!.x + nodeBox!.width / 2, nodeBox!.y + nodeBox!.height / 2); - - const editItem = page.locator(SEL_EDIT_NODE_ITEM); - await expect(editItem).toBeVisible(); - await editItem.click(); - - // Context menu should close - await expect(page.locator(SEL_CONTEXT_MENU)).not.toBeVisible(); - - // Node editor should open in context panel - const panel = page.locator(SEL_CONTEXT_PANEL); - await expect(panel).toBeVisible(); - await expect(panel.getByText("Node Editor", { exact: true })).toBeVisible(); - }); - - test("clicking Delete removes the node", async ({ page, topoViewerPage }) => { - const initialNodeCount = await topoViewerPage.getNodeCount(); - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - - const nodeBox = await topoViewerPage.getNodeBoundingBox(nodeIds[0]); - expect(nodeBox).not.toBeNull(); - - await rightClick(page, nodeBox!.x + nodeBox!.width / 2, nodeBox!.y + nodeBox!.height / 2); - await expect(page.locator(SEL_CONTEXT_MENU)).toBeVisible(); - - await page.locator(SEL_DELETE_NODE_ITEM).click(); - await expect(page.locator(SEL_CONTEXT_MENU)).not.toBeVisible(); - - await expect.poll(() => topoViewerPage.getNodeCount(), { timeout: 5000 }).toBe( - initialNodeCount - 1 - ); - }); - - test("context menu closes when clicking elsewhere or pressing Escape", async ({ - page, - topoViewerPage - }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - const nodeBox = await topoViewerPage.getNodeBoundingBox(nodeIds[0]); - const contextMenu = page.locator(SEL_CONTEXT_MENU); - - // Open context menu - await rightClick(page, nodeBox!.x + nodeBox!.width / 2, nodeBox!.y + nodeBox!.height / 2); - await expect(contextMenu).toBeVisible(); - - // Press Escape to close - await page.keyboard.press("Escape"); - await expect(contextMenu).not.toBeVisible(); - - // Open again - await rightClick(page, nodeBox!.x + nodeBox!.width / 2, nodeBox!.y + nodeBox!.height / 2); - await expect(contextMenu).toBeVisible(); - - // Click elsewhere to close - const canvasCenter = await topoViewerPage.getCanvasCenter(); - await page.mouse.click(canvasCenter.x + 200, canvasCenter.y + 200); - await expect(contextMenu).not.toBeVisible(); - }); - - test("locked mode shows context menu with edit actions disabled", async ({ - page, - topoViewerPage - }) => { - await topoViewerPage.lock(); - - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - - const nodeBox = await topoViewerPage.getNodeBoundingBox(nodeIds[0]); - expect(nodeBox).not.toBeNull(); - - await rightClick(page, nodeBox!.x + nodeBox!.width / 2, nodeBox!.y + nodeBox!.height / 2); - - const contextMenu = page.locator(SEL_CONTEXT_MENU); - await expect(contextMenu).toBeVisible(); - - // Locked mode allows opening the menu, but edit/delete/link actions are disabled. - await expect(page.locator(SEL_EDIT_NODE_ITEM)).toBeDisabled(); - await expect(page.locator(SEL_DELETE_NODE_ITEM)).toBeDisabled(); - await expect(page.locator('[data-testid="context-menu-item-create-link"]')).toBeDisabled(); - }); - }); - - test.describe("Node Context Menu - View Mode", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setViewMode(); - await topoViewerPage.unlock(); - }); - - test("right-click on node shows context menu with SSH, Shell, Logs, Info options but NOT Edit", async ({ - page, - topoViewerPage - }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - - const nodeBox = await topoViewerPage.getNodeBoundingBox(nodeIds[0]); - expect(nodeBox).not.toBeNull(); - - await rightClick(page, nodeBox!.x + nodeBox!.width / 2, nodeBox!.y + nodeBox!.height / 2); - - const contextMenu = page.locator(SEL_CONTEXT_MENU); - await expect(contextMenu).toBeVisible(); - - await expect(page.locator('[data-testid="context-menu-item-ssh-node"]')).toBeVisible(); - await expect(page.locator('[data-testid="context-menu-item-shell-node"]')).toBeVisible(); - await expect(page.locator('[data-testid="context-menu-item-logs-node"]')).toBeVisible(); - await expect(page.locator('[data-testid="context-menu-item-info-node"]')).toBeVisible(); - - // Edit option should NOT be visible in view mode - await expect(page.locator(SEL_EDIT_NODE_ITEM)).not.toBeVisible(); - }); - }); - - test.describe("Edge Context Menu - Edit Mode", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - test("right-click on edge shows context menu with Edit and Delete options", async ({ - page, - topoViewerPage - }) => { - const edgeIds = await topoViewerPage.getEdgeIds(); - expect(edgeIds.length).toBeGreaterThan(0); - - const midpoint = await getEdgeMidpoint(page, edgeIds[0]); - expect(midpoint).not.toBeNull(); - - await rightClick(page, midpoint!.x, midpoint!.y); - - const contextMenu = page.locator(SEL_CONTEXT_MENU); - await expect(contextMenu).toBeVisible(); - await expect(page.locator(SEL_EDIT_EDGE_ITEM)).toBeVisible(); - await expect(page.locator('[data-testid="context-menu-item-delete-edge"]')).toBeVisible(); - }); - - test("clicking Edit opens link editor in context panel", async ({ page, topoViewerPage }) => { - const edgeIds = await topoViewerPage.getEdgeIds(); - - const midpoint = await getEdgeMidpoint(page, edgeIds[0]); - await rightClick(page, midpoint!.x, midpoint!.y); - - const editItem = page.locator(SEL_EDIT_EDGE_ITEM); - await expect(editItem).toBeVisible(); - await editItem.click(); - - // Link editor should open in context panel - const panel = page.locator(SEL_CONTEXT_PANEL); - await expect(panel).toBeVisible(); - await expect(panel.getByText("Link Editor", { exact: true })).toBeVisible(); - }); - - test("clicking Delete removes the edge", async ({ page, topoViewerPage }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - const edgeIds = await topoViewerPage.getEdgeIds(); - expect(edgeIds.length).toBeGreaterThan(0); - - const midpoint = await getEdgeMidpoint(page, edgeIds[0]); - expect(midpoint).not.toBeNull(); - - await rightClick(page, midpoint!.x, midpoint!.y); - await expect(page.locator(SEL_CONTEXT_MENU)).toBeVisible(); - - await page.locator('[data-testid="context-menu-item-delete-edge"]').click(); - await expect(page.locator(SEL_CONTEXT_MENU)).not.toBeVisible(); - - await expect.poll(() => topoViewerPage.getEdgeCount(), { timeout: 5000 }).toBe( - initialEdgeCount - 1 - ); - }); - }); - - test.describe("Edge Context Menu - View Mode", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setViewMode(); - await topoViewerPage.unlock(); - }); - - test("right-click on edge shows context menu with Info option but NOT Edit", async ({ - page, - topoViewerPage - }) => { - const edgeIds = await topoViewerPage.getEdgeIds(); - expect(edgeIds.length).toBeGreaterThan(0); - - const midpoint = await getEdgeMidpoint(page, edgeIds[0]); - expect(midpoint).not.toBeNull(); - - await rightClick(page, midpoint!.x, midpoint!.y); - - const contextMenu = page.locator(SEL_CONTEXT_MENU); - await expect(contextMenu).toBeVisible(); - await expect(page.locator('[data-testid="context-menu-item-info-edge"]')).toBeVisible(); - - // Edit should NOT be visible in view mode - await expect(page.locator(SEL_EDIT_EDGE_ITEM)).not.toBeVisible(); - }); - }); -}); diff --git a/test/e2e/specs/copy-paste.spec.ts b/test/e2e/specs/copy-paste.spec.ts deleted file mode 100644 index 1fe9b20ca..000000000 --- a/test/e2e/specs/copy-paste.spec.ts +++ /dev/null @@ -1,654 +0,0 @@ -import { test, expect } from "../fixtures/topoviewer"; -import { shiftClick } from "../helpers/react-flow-helpers"; - -/** - * Copy, Paste, and Cut Operations E2E Tests - * - * This test file focuses specifically on copy/paste/cut functionality in isolation. - * It covers: - * - Single and multiple node copy/paste operations - * - Edge preservation during copy/paste - * - Cut operations (move nodes) - * - YAML and annotation persistence - * - Undo/redo behavior (especially batched undo for paste) - * - Edge cases: empty clipboard, locked canvas, view mode - * - * Coverage gaps addressed: - * - Isolated testing of copy/paste/cut without other operations interfering - * - Verification of unique ID generation for pasted nodes - * - Position offset validation for pasted elements - * - Link preservation between copied nodes - * - Batched undo behavior (critical for UX) - * - Error handling and graceful degradation - */ - -const SIMPLE_FILE = "simple.clab.yml"; -const EMPTY_FILE = "empty.clab.yml"; - -test.describe("Copy, Paste, and Cut Operations", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - // ============================================================================ - // BASIC COPY/PASTE TESTS - // ============================================================================ - - test("copy single node creates clipboard data", async ({ topoViewerPage, page }) => { - console.log("[TEST] copy single node creates clipboard data"); - - // Verify we start with 2 nodes (simple.clab.yml has srl1 and srl2) - const initialNodeCount = await topoViewerPage.getNodeCount(); - expect(initialNodeCount).toBe(2); - - const initialNodeIds = await topoViewerPage.getNodeIds(); - console.log(`[DEBUG] Initial node IDs: ${initialNodeIds.join(", ")}`); - - // Select srl1 - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - - // Verify selection - const selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds).toContain("srl1"); - - // Copy (Ctrl+C) - await topoViewerPage.copy(); - await page.waitForTimeout(300); - - console.log("[DEBUG] Copy operation completed"); - - // Paste (Ctrl+V) - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - // Verify a new node was created - const nodeCount = await topoViewerPage.getNodeCount(); - expect(nodeCount).toBe(3); - - const nodeIds = await topoViewerPage.getNodeIds(); - console.log(`[DEBUG] Node IDs after paste: ${nodeIds.join(", ")}`); - - // Find the new node (should not be srl1 or srl2) - const newNodeId = nodeIds.find((id) => !initialNodeIds.includes(id)); - expect(newNodeId).toBeDefined(); - console.log(`[INFO] Pasted node ID: ${newNodeId}`); - }); - - test("paste creates new node with unique ID", async ({ topoViewerPage, page }) => { - console.log("[TEST] paste creates new node with unique ID"); - - const initialNodeIds = await topoViewerPage.getNodeIds(); - - // Select and copy srl1 - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - await topoViewerPage.copy(); - await page.waitForTimeout(300); - - // Paste - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - const nodeIds = await topoViewerPage.getNodeIds(); - const pastedNodeId = nodeIds.find((id) => !initialNodeIds.includes(id)); - - // Verify unique ID - expect(pastedNodeId).toBeDefined(); - expect(pastedNodeId).not.toBe("srl1"); - expect(pastedNodeId).not.toBe("srl2"); - - console.log(`[INFO] Pasted node has unique ID: ${pastedNodeId}`); - }); - - test("paste places node offset from original", async ({ topoViewerPage, page }) => { - console.log("[TEST] paste places node offset from original"); - - // Get original position of srl1 - const originalPosition = await topoViewerPage.getNodePosition("srl1"); - console.log(`[DEBUG] Original srl1 position: x=${originalPosition.x}, y=${originalPosition.y}`); - - // Select, copy, and paste srl1 - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - await topoViewerPage.copy(); - await page.waitForTimeout(300); - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - // Find the pasted node ID - const nodeIds = await topoViewerPage.getNodeIds(); - const pastedNodeId = nodeIds.find((id) => id !== "srl1" && id !== "srl2"); - expect(pastedNodeId).toBeDefined(); - - // Get pasted node position - const pastedPosition = await topoViewerPage.getNodePosition(pastedNodeId!); - console.log(`[DEBUG] Pasted node position: x=${pastedPosition.x}, y=${pastedPosition.y}`); - - // Verify position is offset (not identical to original) - const offsetX = Math.abs(pastedPosition.x - originalPosition.x); - const offsetY = Math.abs(pastedPosition.y - originalPosition.y); - - console.log(`[DEBUG] Position offset: x=${offsetX}, y=${offsetY}`); - - // Expect some offset to avoid a complete overlap. - // When the copied selection is visible, paste is anchored near it (with a small offset). - const distance = Math.hypot(offsetX, offsetY); - expect(distance).toBeGreaterThan(10); - }); - - test("paste persists to YAML", async ({ topoViewerPage, page }) => { - console.log("[TEST] paste persists to YAML"); - - const initialNodeIds = await topoViewerPage.getNodeIds(); - - // Select, copy, and paste srl1 - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - await topoViewerPage.copy(); - await page.waitForTimeout(300); - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - // Find pasted node - const nodeIds = await topoViewerPage.getNodeIds(); - const pastedNodeId = nodeIds.find((id) => !initialNodeIds.includes(id)); - expect(pastedNodeId).toBeDefined(); - - // Wait for file save - await page.waitForTimeout(500); - - // Verify YAML contains the pasted node - const yaml = await topoViewerPage.getYamlFromFile(SIMPLE_FILE); - expect(yaml).toContain(`${pastedNodeId}:`); - expect(yaml).toContain("kind:"); // Should have kind field - - console.log(`[INFO] Pasted node ${pastedNodeId} persisted to YAML`); - }); - - test("paste persists position to annotations", async ({ topoViewerPage, page }) => { - console.log("[TEST] paste persists position to annotations"); - - const initialNodeIds = await topoViewerPage.getNodeIds(); - - // Select, copy, and paste srl1 - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - await topoViewerPage.copy(); - await page.waitForTimeout(300); - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - // Find pasted node - const nodeIds = await topoViewerPage.getNodeIds(); - const pastedNodeId = nodeIds.find((id) => !initialNodeIds.includes(id)); - expect(pastedNodeId).toBeDefined(); - - // Wait for file save - await page.waitForTimeout(500); - - // Verify annotations contain position for pasted node - const annotations = await topoViewerPage.getAnnotationsFromFile(SIMPLE_FILE); - const pastedAnnotation = annotations.nodeAnnotations?.find((n) => n.id === pastedNodeId); - - expect(pastedAnnotation).toBeDefined(); - expect(pastedAnnotation?.position).toBeDefined(); - expect(pastedAnnotation!.position!.x).toBeGreaterThan(0); - expect(pastedAnnotation!.position!.y).toBeGreaterThan(0); - - console.log( - `[INFO] Pasted node position in annotations: x=${pastedAnnotation!.position!.x}, y=${pastedAnnotation!.position!.y}` - ); - }); - - // ============================================================================ - // MULTIPLE NODE COPY/PASTE - // ============================================================================ - - test("copy and paste multiple nodes", async ({ topoViewerPage, page }) => { - console.log("[TEST] copy and paste multiple nodes"); - - const initialNodeCount = await topoViewerPage.getNodeCount(); - const initialNodeIds = await topoViewerPage.getNodeIds(); - - // Select both srl1 and srl2 - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - - const srl2Box = await topoViewerPage.getNodeBoundingBox("srl2"); - expect(srl2Box).not.toBeNull(); - await shiftClick(page, srl2Box!.x + srl2Box!.width / 2, srl2Box!.y + srl2Box!.height / 2); - await page.waitForTimeout(200); - - // Verify both selected - const selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(2); - expect(selectedIds).toContain("srl1"); - expect(selectedIds).toContain("srl2"); - - // Copy both nodes - await topoViewerPage.copy(); - await page.waitForTimeout(300); - - // Paste - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - // Verify 2 new nodes created - const nodeCount = await topoViewerPage.getNodeCount(); - expect(nodeCount).toBe(initialNodeCount + 2); - - const nodeIds = await topoViewerPage.getNodeIds(); - const pastedNodeIds = nodeIds.filter((id) => !initialNodeIds.includes(id)); - - expect(pastedNodeIds.length).toBe(2); - console.log(`[INFO] Pasted 2 nodes: ${pastedNodeIds.join(", ")}`); - }); - - test("copy and paste preserves edges between copied nodes", async ({ topoViewerPage, page }) => { - console.log("[TEST] copy and paste preserves edges between copied nodes"); - - // First create a link between srl1 and srl2 if it doesn't exist - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - console.log(`[DEBUG] Initial edge count: ${initialEdgeCount}`); - - // Create link if needed - if (initialEdgeCount === 0) { - await topoViewerPage.createLink("srl1", "srl2", "eth1", "eth1"); - await page.waitForTimeout(500); - } - - const edgeCountBeforeCopy = await topoViewerPage.getEdgeCount(); - const initialNodeIds = await topoViewerPage.getNodeIds(); - - // Select both connected nodes - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - - const srl2Box = await topoViewerPage.getNodeBoundingBox("srl2"); - await shiftClick(page, srl2Box!.x + srl2Box!.width / 2, srl2Box!.y + srl2Box!.height / 2); - await page.waitForTimeout(200); - - // Copy and paste - await topoViewerPage.copy(); - await page.waitForTimeout(300); - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - // Check if edge was copied - const edgeCountAfterPaste = await topoViewerPage.getEdgeCount(); - console.log(`[DEBUG] Edge count: before=${edgeCountBeforeCopy}, after=${edgeCountAfterPaste}`); - - // When copying connected nodes, their connecting edge should be duplicated too. - expect(edgeCountAfterPaste).toBe(edgeCountBeforeCopy + 1); - - // Verify nodes were still copied regardless - const nodeIds = await topoViewerPage.getNodeIds(); - const pastedNodeIds = nodeIds.filter((id) => !initialNodeIds.includes(id)); - expect(pastedNodeIds.length).toBe(2); - }); - - // ============================================================================ - // EDGE CASES - // ============================================================================ - - test("paste without copy does nothing", async ({ topoViewerPage, page }) => { - console.log("[TEST] paste without copy does nothing"); - - await topoViewerPage.clearClipboard(); - - const initialNodeCount = await topoViewerPage.getNodeCount(); - - // Try to paste without copying first - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - // Verify no change - const nodeCount = await topoViewerPage.getNodeCount(); - expect(nodeCount).toBe(initialNodeCount); - - console.log("[INFO] Paste without copy did nothing (expected)"); - }); - - test("copy blocked when nothing selected", async ({ topoViewerPage, page }) => { - console.log("[TEST] copy blocked when nothing selected"); - - await topoViewerPage.clearClipboard(); - - // Clear selection - await page.keyboard.press("Escape"); - await page.waitForTimeout(200); - - // Verify nothing selected - const selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(0); - - // Try to copy (should not crash) - await topoViewerPage.copy(); - await page.waitForTimeout(300); - - // Try to paste (should do nothing) - const initialNodeCount = await topoViewerPage.getNodeCount(); - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - const nodeCount = await topoViewerPage.getNodeCount(); - expect(nodeCount).toBe(initialNodeCount); - - console.log("[INFO] Copy with nothing selected handled gracefully"); - }); - - test("paste blocked in view mode", async ({ topoViewerPage, page }) => { - console.log("[TEST] paste blocked in view mode"); - - // Copy a node in edit mode - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - await topoViewerPage.copy(); - await page.waitForTimeout(300); - - // Switch to view mode - await topoViewerPage.setViewMode(); - await page.waitForTimeout(200); - - const initialNodeCount = await topoViewerPage.getNodeCount(); - - // Try to paste in view mode (should be blocked) - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - // Verify no change - const nodeCount = await topoViewerPage.getNodeCount(); - expect(nodeCount).toBe(initialNodeCount); - - console.log("[INFO] Paste blocked in view mode (expected)"); - }); - - test("paste blocked when canvas is locked", async ({ topoViewerPage, page }) => { - console.log("[TEST] paste blocked when canvas is locked"); - - // Copy a node while unlocked - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - await topoViewerPage.copy(); - await page.waitForTimeout(300); - - // Lock the canvas - await topoViewerPage.lock(); - await page.waitForTimeout(200); - - const initialNodeCount = await topoViewerPage.getNodeCount(); - - // Try to paste when locked (should be blocked) - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - // Verify no change - const nodeCount = await topoViewerPage.getNodeCount(); - expect(nodeCount).toBe(initialNodeCount); - - console.log("[INFO] Paste blocked when locked (expected)"); - }); - - // ============================================================================ - // UNDO/REDO TESTS (CRITICAL) - // ============================================================================ - - test("single undo removes all pasted elements (batch undo)", async ({ - topoViewerPage, - page - }) => { - console.log("[TEST] single undo removes all pasted elements (batch undo)"); - - const initialNodeCount = await topoViewerPage.getNodeCount(); - const initialNodeIds = await topoViewerPage.getNodeIds(); - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - - // Select both nodes - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - - const srl2Box = await topoViewerPage.getNodeBoundingBox("srl2"); - await shiftClick(page, srl2Box!.x + srl2Box!.width / 2, srl2Box!.y + srl2Box!.height / 2); - await page.waitForTimeout(200); - - // Copy and paste - await topoViewerPage.copy(); - await page.waitForTimeout(300); - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - // Verify paste created 2 nodes - const pastedNodeIds = (await topoViewerPage.getNodeIds()).filter( - (id) => !initialNodeIds.includes(id) - ); - expect(pastedNodeIds.length).toBe(2); - - console.log(`[DEBUG] Pasted nodes: ${pastedNodeIds.join(", ")}`); - - // Single undo should remove ALL pasted elements (batch command creates single history entry) - await topoViewerPage.undo(); - await page.waitForTimeout(500); - - // Verify batch undo removed all pasted nodes and edges - const nodeCountAfterUndo = await topoViewerPage.getNodeCount(); - const nodeIdsAfterUndo = await topoViewerPage.getNodeIds(); - const edgeCountAfterUndo = await topoViewerPage.getEdgeCount(); - - expect(nodeCountAfterUndo).toBe(initialNodeCount); - expect(edgeCountAfterUndo).toBe(initialEdgeCount); - - const remainingPasted = pastedNodeIds.filter((id) => nodeIdsAfterUndo.includes(id)); - expect(remainingPasted.length).toBe(0); - - console.log("[INFO] Single undo removed all pasted elements (batch undo)"); - }); - - test("redo restores pasted elements", async ({ topoViewerPage, page }) => { - console.log("[TEST] redo restores pasted elements"); - - const initialNodeIds = await topoViewerPage.getNodeIds(); - - // Copy, paste, get pasted IDs - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - await topoViewerPage.copy(); - await page.waitForTimeout(300); - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - const pastedNodeIds = (await topoViewerPage.getNodeIds()).filter( - (id) => !initialNodeIds.includes(id) - ); - expect(pastedNodeIds.length).toBeGreaterThan(0); - - // Undo - await topoViewerPage.undo(); - await page.waitForTimeout(500); - - // Verify removed - let nodeIds = await topoViewerPage.getNodeIds(); - for (const pastedId of pastedNodeIds) { - expect(nodeIds).not.toContain(pastedId); - } - - // Redo - await topoViewerPage.redo(); - await page.waitForTimeout(500); - - // Verify restored - nodeIds = await topoViewerPage.getNodeIds(); - for (const pastedId of pastedNodeIds) { - expect(nodeIds).toContain(pastedId); - } - - console.log("[INFO] Redo successfully restored all pasted elements"); - }); - - test("paste replaces selection and supports undo/redo", async ({ topoViewerPage, page }) => { - console.log("[TEST] paste replaces selection and supports undo/redo"); - - const initialNodeIds = await topoViewerPage.getNodeIds(); - - // Select and copy srl1 - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - await topoViewerPage.copy(); - await page.waitForTimeout(300); - - // Paste once - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - let nodeIds = await topoViewerPage.getNodeIds(); - const pastedAfterFirst = nodeIds.filter((id) => !initialNodeIds.includes(id)); - expect(pastedAfterFirst.length).toBe(1); - const firstPastedId = pastedAfterFirst[0]; - - let selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(1); - expect(selectedIds).toContain(firstPastedId); - expect(selectedIds).not.toContain("srl1"); - - // Paste again - selection replaces with the latest paste - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - nodeIds = await topoViewerPage.getNodeIds(); - const pastedAfterSecond = nodeIds.filter((id) => !initialNodeIds.includes(id)); - expect(pastedAfterSecond.length).toBe(2); - const secondPastedId = pastedAfterSecond.find((id) => id !== firstPastedId); - expect(secondPastedId).toBeDefined(); - - selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds.length).toBe(1); - expect(selectedIds).toContain(secondPastedId!); - expect(selectedIds).not.toContain("srl1"); - - // Undo should remove the last paste - await topoViewerPage.undo(); - await page.waitForTimeout(500); - - nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds).not.toContain(secondPastedId!); - - selectedIds = await topoViewerPage.getSelectedNodeIds(); - expect(selectedIds).not.toContain(secondPastedId!); - - // Redo should restore it - await topoViewerPage.redo(); - await page.waitForTimeout(500); - - nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds).toContain(secondPastedId!); - }); - - // ============================================================================ - // PERSISTENCE VERIFICATION - // ============================================================================ - - test("paste persists correctly after reload", async ({ topoViewerPage, page }) => { - console.log("[TEST] paste persists correctly after reload"); - - const initialNodeIds = await topoViewerPage.getNodeIds(); - - // Copy and paste srl1 - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - await topoViewerPage.copy(); - await page.waitForTimeout(300); - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - const pastedNodeIds = (await topoViewerPage.getNodeIds()).filter( - (id) => !initialNodeIds.includes(id) - ); - expect(pastedNodeIds.length).toBe(1); - const pastedNodeId = pastedNodeIds[0]; - - // Wait for save - await page.waitForTimeout(500); - - // Reload - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - - // Verify pasted node still exists - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds).toContain(pastedNodeId); - - console.log(`[INFO] Pasted node ${pastedNodeId} persisted after reload`); - }); - - // ============================================================================ - // ADDITIONAL EDGE CASES - // ============================================================================ - - test("multiple paste operations create multiple copies", async ({ topoViewerPage, page }) => { - console.log("[TEST] multiple paste operations create multiple copies"); - - const initialNodeCount = await topoViewerPage.getNodeCount(); - const initialNodeIds = await topoViewerPage.getNodeIds(); - - // Copy srl1 - await topoViewerPage.selectNode("srl1"); - await page.waitForTimeout(100); - await topoViewerPage.copy(); - await page.waitForTimeout(300); - - // Paste 3 times - await topoViewerPage.paste(); - await page.waitForTimeout(500); - await topoViewerPage.paste(); - await page.waitForTimeout(500); - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - // Verify 3 new nodes created - const nodeCount = await topoViewerPage.getNodeCount(); - expect(nodeCount).toBe(initialNodeCount + 3); - - const nodeIds = await topoViewerPage.getNodeIds(); - const pastedNodeIds = nodeIds.filter((id) => !initialNodeIds.includes(id)); - expect(pastedNodeIds.length).toBe(3); - - // Verify all have unique IDs - const uniqueIds = new Set(pastedNodeIds); - expect(uniqueIds.size).toBe(3); - - console.log(`[INFO] 3 paste operations created 3 unique nodes: ${pastedNodeIds.join(", ")}`); - }); - - test("copy on empty topology does nothing gracefully", async ({ topoViewerPage, page }) => { - console.log("[TEST] copy on empty topology does nothing gracefully"); - - // Load empty topology - await topoViewerPage.gotoFile(EMPTY_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - await topoViewerPage.clearClipboard(); - - // Verify empty - const nodeCount = await topoViewerPage.getNodeCount(); - expect(nodeCount).toBe(0); - - // Try to copy (nothing selected, nothing to select) - await topoViewerPage.copy(); - await page.waitForTimeout(300); - - // Try to paste - await topoViewerPage.paste(); - await page.waitForTimeout(500); - - // Verify still empty - const nodeCountAfter = await topoViewerPage.getNodeCount(); - expect(nodeCountAfter).toBe(0); - - console.log("[INFO] Copy/paste on empty topology handled gracefully"); - }); -}); diff --git a/test/e2e/specs/edge-creation.spec.ts b/test/e2e/specs/edge-creation.spec.ts deleted file mode 100644 index 38594939f..000000000 --- a/test/e2e/specs/edge-creation.spec.ts +++ /dev/null @@ -1,456 +0,0 @@ -import { test, expect } from "../fixtures/topoviewer"; -import { shiftClick } from "../helpers/react-flow-helpers"; - -// Test file names for file-based tests -const SIMPLE_FILE = "simple.clab.yml"; -const EMPTY_FILE = "empty.clab.yml"; - -/** - * Edge Creation E2E Tests - * - * Tests edge/link creation functionality including: - * - Creating edges via API - * - Edge persistence to YAML - * - Endpoint assignment - * - Self-loop prevention - * - Protection in view mode and locked state - * - Multi-edge scenarios - * - Cascade deletion - */ -test.describe("Edge Creation", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - test("creates edge between two nodes with correct endpoints and persists to YAML", async ({ - page, - topoViewerPage - }) => { - // Get initial edge count - simple.clab.yml has 1 edge - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - expect(initialEdgeCount).toBe(1); - - // Get node IDs - simple.clab.yml has srl1 and srl2 - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds).toContain("srl1"); - expect(nodeIds).toContain("srl2"); - - // Create a new link with specific endpoints - const sourceEndpoint = "e1-4"; - const targetEndpoint = "e1-5"; - await topoViewerPage.createLink("srl1", "srl2", sourceEndpoint, targetEndpoint); - - // Wait for edge to be created and saved - await page.waitForTimeout(1000); - - // Verify edge count increased - const newEdgeCount = await topoViewerPage.getEdgeCount(); - expect(newEdgeCount).toBe(initialEdgeCount + 1); - - // Verify the edge exists in the graph (match by endpoints) - const edge = await topoViewerPage.findEdgeByEndpoints( - "srl1", - "srl2", - sourceEndpoint, - targetEndpoint - ); - expect(edge).not.toBeNull(); - - // Read YAML and verify endpoints - const yaml = await topoViewerPage.getYamlFromFile(SIMPLE_FILE); - expect(yaml).toContain(`srl1:${sourceEndpoint}`); - expect(yaml).toContain(`srl2:${targetEndpoint}`); - - // Verify via browser-side API (via React Flow API) - expect(edge?.source).toBe("srl1"); - expect(edge?.target).toBe("srl2"); - expect(edge?.sourceEndpoint).toBe(sourceEndpoint); - expect(edge?.targetEndpoint).toBe(targetEndpoint); - }); - - test("can create self-loop edge (hairpin)", async ({ page, topoViewerPage }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - - // Create a self-loop/hairpin (edge from node to itself with different endpoints) - await topoViewerPage.createLink("srl1", "srl1", "e1-6", "e1-7"); - - // Edge count should increase by 1 - const newEdgeCount = await topoViewerPage.getEdgeCount(); - expect(newEdgeCount).toBe(initialEdgeCount + 1); - - // Verify self-loop edge exists (via React Flow API) - const selfLoopData = await page.evaluate(() => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return null; - - const edges = rf.getEdges?.() ?? []; - for (const edge of edges) { - if (edge.source === edge.target) { - return { - source: edge.source, - target: edge.target, - sourceEndpoint: edge.data?.sourceEndpoint, - targetEndpoint: edge.data?.targetEndpoint - }; - } - } - return null; - }); - - expect(selfLoopData).not.toBeNull(); - expect(selfLoopData?.source).toBe("srl1"); - expect(selfLoopData?.target).toBe("srl1"); - expect(selfLoopData?.sourceEndpoint).toBe("e1-6"); - expect(selfLoopData?.targetEndpoint).toBe("e1-7"); - }); - - test("starts link creation with Shift+Click on node", async ({ page, topoViewerPage }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - - const sourceBox = await topoViewerPage.getNodeBoundingBox("srl1"); - const targetBox = await topoViewerPage.getNodeBoundingBox("srl2"); - expect(sourceBox).not.toBeNull(); - expect(targetBox).not.toBeNull(); - - await shiftClick( - page, - sourceBox!.x + sourceBox!.width / 2, - sourceBox!.y + sourceBox!.height / 2 - ); - await page.waitForTimeout(150); - - await page.mouse.click( - targetBox!.x + targetBox!.width / 2, - targetBox!.y + targetBox!.height / 2 - ); - - await page.waitForFunction( - (expectedCount) => { - const rf = (window as any).__DEV__?.rfInstance; - if (rf === undefined || rf === null) return false; - const edges = rf.getEdges?.() ?? []; - return edges.length === expectedCount; - }, - initialEdgeCount + 1, - { timeout: 4000 } - ); - - const newEdgeCount = await topoViewerPage.getEdgeCount(); - expect(newEdgeCount).toBe(initialEdgeCount + 1); - }); - - test("edge creation blocked when canvas is locked or in view mode", async ({ - page, - topoViewerPage - }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - - // Test locked state - await topoViewerPage.lock(); - await topoViewerPage.createLink("srl1", "srl2", "e1-8", "e1-8"); - await page.waitForTimeout(500); - - let newEdgeCount = await topoViewerPage.getEdgeCount(); - expect(newEdgeCount).toBe(initialEdgeCount); - - // Unlock for view mode test - await topoViewerPage.unlock(); - - // Test view mode - await topoViewerPage.setViewMode(); - await topoViewerPage.createLink("srl1", "srl2", "e1-9", "e1-9"); - await page.waitForTimeout(500); - - newEdgeCount = await topoViewerPage.getEdgeCount(); - expect(newEdgeCount).toBe(initialEdgeCount); - }); - - test("creates multiple edges between same nodes with different endpoints", async ({ - page, - topoViewerPage - }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - - // Create three additional edges - await topoViewerPage.createLink("srl1", "srl2", "e1-10", "e1-10"); - await page.waitForTimeout(300); - - await topoViewerPage.createLink("srl1", "srl2", "e1-11", "e1-11"); - await page.waitForTimeout(300); - - await topoViewerPage.createLink("srl1", "srl2", "e1-12", "e1-12"); - await page.waitForTimeout(500); - - // Verify all edges were created - const newEdgeCount = await topoViewerPage.getEdgeCount(); - expect(newEdgeCount).toBe(initialEdgeCount + 3); - - // Verify all edges are between the same two nodes (via React Flow API) - const edgeConnections = await page.evaluate(() => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return []; - - const edges = rf.getEdges?.() ?? []; - return edges.map((e: any) => ({ - source: e.source, - target: e.target, - sourceEndpoint: e.data?.sourceEndpoint, - targetEndpoint: e.data?.targetEndpoint - })); - }); - - // Count edges between srl1 and srl2 - const srl1Srl2Edges = edgeConnections.filter( - (e: any) => - (e.source === "srl1" && e.target === "srl2") || (e.source === "srl2" && e.target === "srl1") - ); - expect(srl1Srl2Edges.length).toBeGreaterThanOrEqual(4); - }); - - test("deleting node removes connected edges", async ({ page, topoViewerPage }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - expect(initialEdgeCount).toBe(1); - - // Create additional edge to srl1 - await topoViewerPage.createNode("srl3", { x: 300, y: 300 }, "nokia_srlinux"); - await page.waitForTimeout(300); - - await topoViewerPage.createLink("srl1", "srl3", "e1-13", "e1-13"); - await page.waitForTimeout(500); - - // Verify we now have 2 edges - let edgeCount = await topoViewerPage.getEdgeCount(); - expect(edgeCount).toBe(2); - - // Delete srl1 node - await topoViewerPage.deleteNode("srl1"); - await page.waitForTimeout(500); - - // Both edges connected to srl1 should be deleted - edgeCount = await topoViewerPage.getEdgeCount(); - expect(edgeCount).toBe(0); - - // Verify the edges are truly gone - const edgeIds = await topoViewerPage.getEdgeIds(); - expect(edgeIds).toHaveLength(0); - }); -}); - -/** - * Edge Creation File Persistence Tests - * - * Tests that verify edge creation properly persists to YAML files - */ -test.describe("Edge Creation - File Persistence", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(EMPTY_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - test("created edge persists after reload with correct endpoints", async ({ - page, - topoViewerPage - }) => { - // Create two nodes and a link - await topoViewerPage.createNode("node1", { x: 200, y: 200 }, "nokia_srlinux"); - await topoViewerPage.createNode("node2", { x: 400, y: 200 }, "nokia_srlinux"); - await page.waitForTimeout(500); - - const sourceEndpoint = "eth10"; - const targetEndpoint = "eth20"; - await topoViewerPage.createLink("node1", "node2", sourceEndpoint, targetEndpoint); - - // Wait for save to complete - await page.waitForTimeout(1000); - - // Read YAML and verify endpoints are persisted correctly - let yaml = await topoViewerPage.getYamlFromFile(EMPTY_FILE); - expect(yaml).toContain(`node1:${sourceEndpoint}`); - expect(yaml).toContain(`node2:${targetEndpoint}`); - - // Verify the endpoint appears in a proper endpoints array format - expect(yaml).toMatch( - new RegExp(`endpoints:\\s*\\[.*node1:${sourceEndpoint}.*node2:${targetEndpoint}.*\\]`, "s") - ); - - // Reload the file - await topoViewerPage.gotoFile(EMPTY_FILE); - await topoViewerPage.waitForCanvasReady(); - - // Verify edge count - const edgeCount = await topoViewerPage.getEdgeCount(); - expect(edgeCount).toBe(1); - - // Verify the edge connects the right nodes (via React Flow API) - const edgeData = await page.evaluate(() => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - const edges = rf?.getEdges?.() ?? []; - if (edges.length === 0) return null; - const edge = edges[0]; - return { - source: edge.source, - target: edge.target - }; - }); - expect(edgeData).not.toBeNull(); - expect([edgeData!.source, edgeData!.target].sort((a, b) => a.localeCompare(b))).toEqual([ - "node1", - "node2" - ]); - }); - - test("multiple created edges persist to YAML correctly", async ({ page, topoViewerPage }) => { - // Create three nodes - await topoViewerPage.createNode("router1", { x: 200, y: 100 }, "nokia_srlinux"); - await topoViewerPage.createNode("router2", { x: 100, y: 300 }, "nokia_srlinux"); - await topoViewerPage.createNode("router3", { x: 300, y: 300 }, "nokia_srlinux"); - await page.waitForTimeout(500); - - // Create links in a triangle topology - await topoViewerPage.createLink("router1", "router2", "e1-1", "e1-1"); - await page.waitForTimeout(200); - await topoViewerPage.createLink("router2", "router3", "e1-1", "e1-1"); - await page.waitForTimeout(200); - await topoViewerPage.createLink("router3", "router1", "e1-1", "e1-1"); - await page.waitForTimeout(1000); - - // Verify YAML has all links - const yaml = await topoViewerPage.getYamlFromFile(EMPTY_FILE); - expect(yaml).toContain("router1:e1-1"); - expect(yaml).toContain("router2:e1-1"); - expect(yaml).toContain("router3:e1-1"); - - // Count links in YAML - const endpointsCount = (yaml.match(/endpoints:/g) ?? []).length; - expect(endpointsCount).toBe(3); - - // Verify YAML has proper structure - expect(yaml).toContain("links:"); - expect(yaml).toContain("endpoints:"); - - const structureRegex = /topology:\s*nodes:[\s\S]*links:[\s\S]*endpoints:/; - const hasProperStructure = structureRegex.exec(yaml); - expect(hasProperStructure).not.toBeNull(); - }); -}); - -/** - * Edge Creation Undo/Redo Tests - * - * Tests undo/redo functionality for edge creation - */ -test.describe("Edge Creation - Undo/Redo", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - test("can undo and redo edge creation", async ({ page, topoViewerPage }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - - // Create a new edge - await topoViewerPage.createLink("srl1", "srl2", "e1-14", "e1-14"); - await page.waitForTimeout(500); - - // Verify edge was created - let edgeCount = await topoViewerPage.getEdgeCount(); - expect(edgeCount).toBe(initialEdgeCount + 1); - - // Undo - await topoViewerPage.undo(); - await page.waitForTimeout(500); - - // Edge should be removed - edgeCount = await topoViewerPage.getEdgeCount(); - expect(edgeCount).toBe(initialEdgeCount); - - // Redo - await topoViewerPage.redo(); - await page.waitForTimeout(500); - - // Edge should be back - edgeCount = await topoViewerPage.getEdgeCount(); - expect(edgeCount).toBe(initialEdgeCount + 1); - - // Verify edge exists (by endpoints) - const edge = await topoViewerPage.findEdgeByEndpoints("srl1", "srl2", "e1-14", "e1-14"); - expect(edge).not.toBeNull(); - }); - - test("undo multiple edge creations in reverse order", async ({ page, topoViewerPage }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - - // Create three edges - await topoViewerPage.createLink("srl1", "srl2", "e1-16", "e1-16"); - await page.waitForTimeout(200); - await topoViewerPage.createLink("srl1", "srl2", "e1-17", "e1-17"); - await page.waitForTimeout(200); - await topoViewerPage.createLink("srl1", "srl2", "e1-18", "e1-18"); - await page.waitForTimeout(500); - - // Verify all created - let edgeCount = await topoViewerPage.getEdgeCount(); - expect(edgeCount).toBe(initialEdgeCount + 3); - - // Undo three times - await topoViewerPage.undo(); - await page.waitForTimeout(300); - await topoViewerPage.undo(); - await page.waitForTimeout(300); - await topoViewerPage.undo(); - await page.waitForTimeout(500); - - // Should be back to initial count - edgeCount = await topoViewerPage.getEdgeCount(); - expect(edgeCount).toBe(initialEdgeCount); - }); - - test("undo edge creation removes edge from YAML file", async ({ page, topoViewerPage }) => { - // Get initial YAML - const initialYaml = await topoViewerPage.getYamlFromFile(SIMPLE_FILE); - const initialLinkCount = (initialYaml.match(/endpoints:/g) ?? []).length; - - // Create a new edge - await topoViewerPage.createLink("srl1", "srl2", "e1-20", "e1-20"); - await page.waitForTimeout(500); - - // Verify edge was added to YAML - let yaml = await topoViewerPage.getYamlFromFile(SIMPLE_FILE); - expect(yaml).toContain("e1-20"); - let linkCount = (yaml.match(/endpoints:/g) ?? []).length; - expect(linkCount).toBe(initialLinkCount + 1); - - // Undo - await topoViewerPage.undo(); - await page.waitForTimeout(500); - - // Verify edge was removed from YAML - yaml = await topoViewerPage.getYamlFromFile(SIMPLE_FILE); - expect(yaml).not.toContain("e1-20"); - linkCount = (yaml.match(/endpoints:/g) ?? []).length; - expect(linkCount).toBe(initialLinkCount); - - // Redo - await topoViewerPage.redo(); - await page.waitForTimeout(500); - - // Verify edge is back in YAML - yaml = await topoViewerPage.getYamlFromFile(SIMPLE_FILE); - expect(yaml).toContain("e1-20"); - linkCount = (yaml.match(/endpoints:/g) ?? []).length; - expect(linkCount).toBe(initialLinkCount + 1); - }); -}); diff --git a/test/e2e/specs/edge-deletion.spec.ts b/test/e2e/specs/edge-deletion.spec.ts deleted file mode 100644 index a5d60ce08..000000000 --- a/test/e2e/specs/edge-deletion.spec.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { test, expect } from "../fixtures/topoviewer"; -import { shiftClick, getEdgeMidpoint } from "../helpers/react-flow-helpers"; - -// Test file names for file-based tests -const SPINE_LEAF_FILE = "spine-leaf.clab.yml"; - -/** - * Edge Deletion E2E Tests - * - * Tests edge/link deletion functionality including: - * - Delete via keyboard (Delete and Backspace) - * - Delete multiple edges - * - Undo/redo edge deletion - * - Protection in view mode and locked state - */ -test.describe("Edge Deletion", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile("simple.clab.yml"); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - test("deletes selected edge with Delete or Backspace key", async ({ page, topoViewerPage }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - expect(initialEdgeCount).toBeGreaterThan(0); - - const edgeIds = await topoViewerPage.getEdgeIds(); - const edgeToDelete = edgeIds[0]; - - // Test Delete key - await topoViewerPage.selectEdge(edgeToDelete); - await page.waitForTimeout(200); - - // Verify edge is selected - let selectedIds = await topoViewerPage.getSelectedEdgeIds(); - expect(selectedIds).toContain(edgeToDelete); - - await page.keyboard.press("Delete"); - await page.waitForTimeout(300); - - // Edge count should decrease - let finalEdgeCount = await topoViewerPage.getEdgeCount(); - expect(finalEdgeCount).toBe(initialEdgeCount - 1); - - // Verify deleted edge is gone - let finalEdgeIds = await topoViewerPage.getEdgeIds(); - expect(finalEdgeIds).not.toContain(edgeToDelete); - - // Undo to restore for Backspace test - await topoViewerPage.undo(); - await page.waitForTimeout(300); - - // Test Backspace key - await topoViewerPage.selectEdge(edgeToDelete); - await page.waitForTimeout(200); - await page.keyboard.press("Backspace"); - await page.waitForTimeout(300); - - finalEdgeCount = await topoViewerPage.getEdgeCount(); - expect(finalEdgeCount).toBe(initialEdgeCount - 1); - }); - - test("deletes multiple selected edges", async ({ page, topoViewerPage }) => { - // This test needs a topology with multiple edges - spine-leaf has 6 edges - await topoViewerPage.gotoFile("spine-leaf.clab.yml"); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - expect(initialEdgeCount).toBeGreaterThanOrEqual(2); - - const edgeIds = await topoViewerPage.getEdgeIds(); - - // Select first edge - await topoViewerPage.selectEdge(edgeIds[0]); - await page.waitForTimeout(100); - - // Shift+click to select second edge (React Flow uses Shift for multi-select) - const midpoint = await getEdgeMidpoint(page, edgeIds[1]); - - expect(midpoint).not.toBeNull(); - await shiftClick(page, midpoint!.x, midpoint!.y); - await page.waitForTimeout(200); - - // Verify both are selected - const selectedIds = await topoViewerPage.getSelectedEdgeIds(); - expect(selectedIds.length).toBe(2); - - // Delete - await page.keyboard.press("Delete"); - await page.waitForTimeout(300); - - // Both edges should be deleted - const finalEdgeCount = await topoViewerPage.getEdgeCount(); - expect(finalEdgeCount).toBe(initialEdgeCount - 2); - }); - - test("can undo and redo edge deletion", async ({ page, topoViewerPage }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - const edgeIds = await topoViewerPage.getEdgeIds(); - const deletedEdgeId = edgeIds[0]; - - // Delete an edge - await topoViewerPage.selectEdge(deletedEdgeId); - await page.waitForTimeout(200); - await page.keyboard.press("Delete"); - await page.waitForTimeout(300); - - // Verify deletion - let currentEdgeCount = await topoViewerPage.getEdgeCount(); - expect(currentEdgeCount).toBe(initialEdgeCount - 1); - - // Undo - await topoViewerPage.undo(); - await page.waitForTimeout(300); - - // Edge should be restored - currentEdgeCount = await topoViewerPage.getEdgeCount(); - expect(currentEdgeCount).toBe(initialEdgeCount); - - // The deleted edge should be back - let currentEdgeIds = await topoViewerPage.getEdgeIds(); - expect(currentEdgeIds).toContain(deletedEdgeId); - - // Redo - await topoViewerPage.redo(); - await page.waitForTimeout(300); - - // Edge should be deleted again - currentEdgeCount = await topoViewerPage.getEdgeCount(); - expect(currentEdgeCount).toBe(initialEdgeCount - 1); - - currentEdgeIds = await topoViewerPage.getEdgeIds(); - expect(currentEdgeIds).not.toContain(deletedEdgeId); - }); - - test("does not delete edge when canvas is locked or in view mode", async ({ - page, - topoViewerPage - }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - const edgeIds = await topoViewerPage.getEdgeIds(); - - // Test locked state - await topoViewerPage.lock(); - await topoViewerPage.selectEdge(edgeIds[0]); - await page.waitForTimeout(200); - await page.keyboard.press("Delete"); - await page.waitForTimeout(300); - - let finalEdgeCount = await topoViewerPage.getEdgeCount(); - expect(finalEdgeCount).toBe(initialEdgeCount); - - // Unlock for view mode test - await topoViewerPage.unlock(); - - // Test view mode - await topoViewerPage.setViewMode(); - await topoViewerPage.selectEdge(edgeIds[0]); - await page.waitForTimeout(200); - await page.keyboard.press("Delete"); - await page.waitForTimeout(300); - - finalEdgeCount = await topoViewerPage.getEdgeCount(); - expect(finalEdgeCount).toBe(initialEdgeCount); - }); - - test("deleting edge does not delete connected nodes", async ({ page, topoViewerPage }) => { - const initialNodeCount = await topoViewerPage.getNodeCount(); - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - const edgeIds = await topoViewerPage.getEdgeIds(); - - // Delete an edge - await topoViewerPage.selectEdge(edgeIds[0]); - await page.waitForTimeout(200); - await page.keyboard.press("Delete"); - await page.waitForTimeout(300); - - // Edge count should decrease - const finalEdgeCount = await topoViewerPage.getEdgeCount(); - expect(finalEdgeCount).toBe(initialEdgeCount - 1); - - // Node count should remain the same - const finalNodeCount = await topoViewerPage.getNodeCount(); - expect(finalNodeCount).toBe(initialNodeCount); - }); - - test("pressing Delete with no selection does nothing", async ({ page, topoViewerPage }) => { - const initialEdgeCount = await topoViewerPage.getEdgeCount(); - - // Clear any selection - await topoViewerPage.clearSelection(); - await page.waitForTimeout(100); - - // Press Delete with nothing selected - await page.keyboard.press("Delete"); - await page.waitForTimeout(300); - - // Nothing should change - const finalEdgeCount = await topoViewerPage.getEdgeCount(); - expect(finalEdgeCount).toBe(initialEdgeCount); - }); -}); - -/** - * File Persistence Tests for Edge Deletion - * - * These tests verify that edge deletion properly updates the .clab.yml file - */ -test.describe("Edge Deletion - File Persistence", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(SPINE_LEAF_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - test("deleted edges are removed from YAML file (single and multiple)", async ({ - page, - topoViewerPage - }) => { - // Get initial YAML - const initialYaml = await topoViewerPage.getYamlFromFile(SPINE_LEAF_FILE); - const initialEndpointsCount = (initialYaml.match(/endpoints:/g) ?? []).length; - expect(initialEndpointsCount).toBeGreaterThanOrEqual(2); - - // Get edge IDs - const edgeIds = await topoViewerPage.getEdgeIds(); - expect(edgeIds.length).toBeGreaterThanOrEqual(2); - - // Delete first edge - await topoViewerPage.selectEdge(edgeIds[0]); - await page.waitForTimeout(200); - await page.keyboard.press("Delete"); - await page.waitForTimeout(1000); - - // Verify single deletion - let updatedYaml = await topoViewerPage.getYamlFromFile(SPINE_LEAF_FILE); - let updatedEndpointsCount = (updatedYaml.match(/endpoints:/g) ?? []).length; - expect(updatedEndpointsCount).toBe(initialEndpointsCount - 1); - - // Select and delete second edge using multi-select - const remainingEdgeIds = await topoViewerPage.getEdgeIds(); - await topoViewerPage.selectEdge(remainingEdgeIds[0]); - await page.waitForTimeout(100); - - // Shift+click to select another edge if available (React Flow uses Shift for multi-select) - if (remainingEdgeIds.length >= 2) { - const midpoint = await getEdgeMidpoint(page, remainingEdgeIds[1]); - - if (midpoint) { - await shiftClick(page, midpoint.x, midpoint.y); - await page.waitForTimeout(200); - } - } - - // Delete selected edges - await page.keyboard.press("Delete"); - await page.waitForTimeout(1000); - - // Verify multiple deletion - updatedYaml = await topoViewerPage.getYamlFromFile(SPINE_LEAF_FILE); - updatedEndpointsCount = (updatedYaml.match(/endpoints:/g) ?? []).length; - expect(updatedEndpointsCount).toBeLessThan(initialEndpointsCount - 1); - }); -}); diff --git a/test/e2e/specs/edge-selection.spec.ts b/test/e2e/specs/edge-selection.spec.ts deleted file mode 100644 index 019e8a559..000000000 --- a/test/e2e/specs/edge-selection.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { test, expect } from "../fixtures/topoviewer"; -import { shiftClick, getEdgeMidpoint } from "../helpers/react-flow-helpers"; - -test.describe("Edge Selection", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile("simple.clab.yml"); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - test("selects single edge on click and multiple with Shift+Click", async ({ - page, - topoViewerPage - }) => { - const edgeIds = await topoViewerPage.getEdgeIds(); - expect(edgeIds.length).toBeGreaterThan(0); - - // Test single selection - const edgeId = edgeIds[0]; - await topoViewerPage.selectEdge(edgeId); - - let selectedIds = await topoViewerPage.getSelectedEdgeIds(); - expect(selectedIds).toContain(edgeId); - - // Test multi-selection with Shift+Click (if multiple edges exist) - // React Flow uses Shift for multi-select - if (edgeIds.length >= 2) { - // Get second edge midpoint for Shift+Click - const midpoint = await getEdgeMidpoint(page, edgeIds[1]); - - expect(midpoint).not.toBeNull(); - - await shiftClick(page, midpoint!.x, midpoint!.y); - await page.waitForTimeout(200); - - selectedIds = await topoViewerPage.getSelectedEdgeIds(); - expect(selectedIds.length).toBe(2); - expect(selectedIds).toContain(edgeIds[0]); - expect(selectedIds).toContain(edgeIds[1]); - } - }); - - test("clears edge selection with Escape key", async ({ topoViewerPage }) => { - const edgeIds = await topoViewerPage.getEdgeIds(); - expect(edgeIds.length).toBeGreaterThan(0); - - // Select an edge - await topoViewerPage.selectEdge(edgeIds[0]); - let selectedIds = await topoViewerPage.getSelectedEdgeIds(); - expect(selectedIds.length).toBeGreaterThan(0); - - // Use clearSelection which presses Escape and also clears React Flow selection - await topoViewerPage.clearSelection(); - - selectedIds = await topoViewerPage.getSelectedEdgeIds(); - expect(selectedIds.length).toBe(0); - }); - - test("selecting node does not select edges and vice versa", async ({ topoViewerPage }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - const edgeIds = await topoViewerPage.getEdgeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - expect(edgeIds.length).toBeGreaterThan(0); - - // Test: selecting node does not select edges - await topoViewerPage.selectNode(nodeIds[0]); - - let selectedNodeIds = await topoViewerPage.getSelectedNodeIds(); - let selectedEdgeIds = await topoViewerPage.getSelectedEdgeIds(); - - expect(selectedNodeIds.length).toBe(1); - expect(selectedEdgeIds.length).toBe(0); - - // Clear selection - await topoViewerPage.clearSelection(); - - // Test: selecting edge does not select nodes - await topoViewerPage.selectEdge(edgeIds[0]); - - selectedNodeIds = await topoViewerPage.getSelectedNodeIds(); - selectedEdgeIds = await topoViewerPage.getSelectedEdgeIds(); - - expect(selectedEdgeIds.length).toBeGreaterThan(0); - expect(selectedNodeIds.length).toBe(0); - }); -}); diff --git a/test/e2e/specs/endpoint-label-offset.spec.ts b/test/e2e/specs/endpoint-label-offset.spec.ts deleted file mode 100644 index 44df5c08c..000000000 --- a/test/e2e/specs/endpoint-label-offset.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { test, expect } from "../fixtures/topoviewer"; -import { rightClick, getEdgeMidpoint } from "../helpers/react-flow-helpers"; - -const SIMPLE_FILE = "simple.clab.yml"; - -const SEL_LINK_LABELS_BTN = '[data-testid="navbar-link-labels"]'; -const SEL_CONTEXT_MENU = '[data-testid="context-menu"]'; -const SEL_EDIT_EDGE_ITEM = '[data-testid="context-menu-item-edit-edge"]'; -const SEL_ENDPOINT_OFFSET = "#link-endpoint-offset"; -const ATTR_ARIA_VALUE_NOW = "aria-valuenow"; - -/** - * Endpoint Label Offset E2E Tests (MUI version) - * - * The MUI navbar link labels menu now only controls label mode. - * Endpoint offset is set per-link in the Link Editor. - */ -test.describe("Endpoint Label Offset", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - test("link labels menu has Show All, On Select, and Hide options", async ({ page }) => { - await page.locator(SEL_LINK_LABELS_BTN).click(); - await page.waitForTimeout(200); - - await expect(page.locator('[data-testid="navbar-link-label-show-all"]')).toBeVisible(); - await expect(page.locator('[data-testid="navbar-link-label-on-select"]')).toBeVisible(); - await expect(page.locator('[data-testid="navbar-link-label-hide"]')).toBeVisible(); - await expect(page.locator('[data-testid="navbar-link-label-grafana"]')).toHaveCount(0); - }); - - test("per-link endpoint offset override persists after apply", async ({ - page, - topoViewerPage - }) => { - const edgeIds = await topoViewerPage.getEdgeIds(); - expect(edgeIds.length).toBeGreaterThan(0); - const edgeId = edgeIds[0]; - const edgeData = (await topoViewerPage.getEdgesData()).find((edge) => edge.id === edgeId); - expect(edgeData).toBeDefined(); - - // Open context menu on edge and click Edit - const midpoint = await getEdgeMidpoint(page, edgeId); - expect(midpoint).not.toBeNull(); - await rightClick(page, midpoint!.x, midpoint!.y); - await expect(page.locator(SEL_CONTEXT_MENU)).toBeVisible(); - await page.locator(SEL_EDIT_EDGE_ITEM).click(); - await expect(page.locator(SEL_CONTEXT_MENU)).not.toBeVisible(); - - // Slider is an MUI Slider (role="slider") - const slider = page.locator(SEL_ENDPOINT_OFFSET).getByRole("slider"); - await expect(slider).toBeVisible({ timeout: 3000 }); - - const readState = async (): Promise<{ enabled: boolean; offset?: number } | null> => { - const annotations = await topoViewerPage.getAnnotationsFromFile(SIMPLE_FILE); - const entry = annotations.edgeAnnotations?.find((e: any) => { - if (e.id === edgeId) return true; - return ( - e.source === edgeData!.source && - e.target === edgeData!.target && - (e.sourceEndpoint ?? "") === (edgeData!.sourceEndpoint ?? "") && - (e.targetEndpoint ?? "") === (edgeData!.targetEndpoint ?? "") - ); - }); - if (!entry) return null; - return { - enabled: entry.endpointLabelOffsetEnabled ?? false, - offset: entry.endpointLabelOffset - }; - }; - - const initialState = await readState(); - - const initialValue = Number(await slider.getAttribute(ATTR_ARIA_VALUE_NOW)); - expect(Number.isFinite(initialValue)).toBe(true); - - // Nudge the slider via keyboard to avoid brittle pointer math. - await slider.focus(); - for (let i = 0; i < 5; i++) await page.keyboard.press("ArrowRight"); - await page.waitForTimeout(200); - - const newValue = Number(await slider.getAttribute(ATTR_ARIA_VALUE_NOW)); - expect(newValue).toBeGreaterThanOrEqual(initialValue); - - // Persist via editor apply. - await page.locator('[data-testid="panel-apply-btn"]').click(); - await page.waitForTimeout(300); - - // Verify persisted - await expect.poll(readState, { timeout: 5000 }).toEqual({ enabled: true, offset: newValue }); - - // Reload and verify the per-link override remains. - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - const midpointAfterReload = await getEdgeMidpoint(page, edgeId); - expect(midpointAfterReload).not.toBeNull(); - await rightClick(page, midpointAfterReload!.x, midpointAfterReload!.y); - await expect(page.locator(SEL_CONTEXT_MENU)).toBeVisible(); - await page.locator(SEL_EDIT_EDGE_ITEM).click(); - await expect(page.locator(SEL_CONTEXT_MENU)).not.toBeVisible(); - - const sliderAfterReload = page.locator(SEL_ENDPOINT_OFFSET).getByRole("slider"); - await expect(sliderAfterReload).toBeVisible({ timeout: 3000 }); - const reloadedValue = Number(await sliderAfterReload.getAttribute(ATTR_ARIA_VALUE_NOW)); - expect(reloadedValue).toBe(newValue); - expect(initialState).not.toEqual({ enabled: true, offset: newValue }); - }); - - test("loads global endpoint label offset from annotations and restores on reload", async ({ - page, - topoViewerPage - }) => { - const TARGET_OFFSET = 40; - - // Write viewer settings directly to the annotations file (global setting). - const annotations = await topoViewerPage.getAnnotationsFromFile(SIMPLE_FILE); - await topoViewerPage.writeAnnotationsFile(SIMPLE_FILE, { - ...annotations, - viewerSettings: { - ...annotations.viewerSettings, - endpointLabelOffsetEnabled: true, - endpointLabelOffset: TARGET_OFFSET - } - }); - - // Reload to ensure settings are applied from disk. - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - const edgeIds = await topoViewerPage.getEdgeIds(); - expect(edgeIds.length).toBeGreaterThan(0); - - const openLinkEditorForEdge = async (edgeId: string) => { - const midpoint = await getEdgeMidpoint(page, edgeId); - expect(midpoint).not.toBeNull(); - await rightClick(page, midpoint!.x, midpoint!.y); - await expect(page.locator(SEL_CONTEXT_MENU)).toBeVisible(); - await page.locator(SEL_EDIT_EDGE_ITEM).click(); - await expect(page.locator(SEL_CONTEXT_MENU)).not.toBeVisible(); - }; - - await openLinkEditorForEdge(edgeIds[0]); - - const slider = page.locator(SEL_ENDPOINT_OFFSET).getByRole("slider"); - await expect(slider).toBeVisible({ timeout: 3000 }); - const value = Number(await slider.getAttribute(ATTR_ARIA_VALUE_NOW)); - expect(value).toBe(TARGET_OFFSET); - - // Close editor and reload again to ensure it restores. - await page.keyboard.press("Escape"); - await page.waitForTimeout(200); - - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - await openLinkEditorForEdge(edgeIds[0]); - const valueAfterReload = Number(await slider.getAttribute(ATTR_ARIA_VALUE_NOW)); - expect(valueAfterReload).toBe(TARGET_OFFSET); - }); -}); diff --git a/test/e2e/specs/field-deletion.spec.ts b/test/e2e/specs/field-deletion.spec.ts deleted file mode 100644 index 5ffa1e6b8..000000000 --- a/test/e2e/specs/field-deletion.spec.ts +++ /dev/null @@ -1,400 +0,0 @@ -import type { Page } from "@playwright/test"; - -import { test, expect } from "../fixtures/topoviewer"; - -// Test selectors - ContextPanel-based -const SEL_APPLY_BTN = '[data-testid="panel-apply-btn"]'; -const SEL_CONTEXT_PANEL = '[data-testid="context-panel"]'; - -// Tab identifiers -const TAB = { - CONFIG: "config", - RUNTIME: "runtime", - NETWORK: "network" -} as const; - -type TabName = (typeof TAB)[keyof typeof TAB]; - -const TEST_TOPOLOGY = "simple.clab.yml"; - -/** - * Click a node to open the editor in the ContextPanel - */ -async function clickNode(page: Page, nodeId: string): Promise { - const nodeHandle = page.locator(`[data-id="${nodeId}"]`); - await nodeHandle.scrollIntoViewIfNeeded(); - await expect(nodeHandle).toBeVisible({ timeout: 3000 }); - const box = await nodeHandle.boundingBox(); - if (!box) throw new Error(`Node ${nodeId} has no bounding box`); - await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); - await page.waitForTimeout(300); -} - -async function openNodeEditor(page: Page, nodeId: string): Promise { - await clickNode(page, nodeId); - await expect(page.locator('[data-testid="panel-tab-basic"]')).toBeVisible({ timeout: 3000 }); -} - -async function navigateToTab(page: Page, tabName: TabName): Promise { - const tab = page.locator(`[data-testid="panel-tab-${tabName}"]`); - await expect(tab).toBeVisible({ timeout: 2000 }); - await tab.click(); - await page.waitForTimeout(200); -} - -async function clickAllRemoveButtons(page: Page): Promise { - const panel = page.locator(SEL_CONTEXT_PANEL); - await expect(panel).toBeVisible({ timeout: 3000 }); - - const removeButtons = panel.locator('button[aria-label="Remove"]'); - const count = await removeButtons.count(); - for (let i = count - 1; i >= 0; i--) { - await removeButtons.nth(i).click(); - await page.waitForTimeout(100); - } -} - -/** - * Field Deletion E2E Tests (MUI ContextPanel version) - * - * Tests that clearing fields properly removes them from YAML. - */ -test.describe("Field Deletion from YAML", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - }); - - test("clearing string field removes it from YAML", async ({ page, topoViewerPage }) => { - const yamlWithUser = `name: simple -topology: - nodes: - srl1: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - user: testuser - srl2: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - links: - - endpoints: ["srl1:e1-1", "srl2:e1-1"] -`; - await topoViewerPage.writeYamlFile(TEST_TOPOLOGY, yamlWithUser); - await topoViewerPage.gotoFile(TEST_TOPOLOGY); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - await topoViewerPage.fit(); - - let yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - expect(yamlContent).toContain("user: testuser"); - - await openNodeEditor(page, "srl1"); - await navigateToTab(page, TAB.RUNTIME); - - const userField = page.locator("#node-user"); - await userField.clear(); - await userField.blur(); - await page.waitForTimeout(200); - - await page.locator(SEL_APPLY_BTN).click(); - await page.waitForTimeout(500); - - yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - const srl1Section = yamlContent.split("srl2:")[0]; - expect(srl1Section).not.toContain("user:"); - }); - - test("clearing mgmt-ipv4 removes it from YAML", async ({ page, topoViewerPage }) => { - const yamlWithMgmt = `name: simple -topology: - nodes: - srl1: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - mgmt-ipv4: 172.20.20.10 - srl2: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - links: - - endpoints: ["srl1:e1-1", "srl2:e1-1"] -`; - await topoViewerPage.writeYamlFile(TEST_TOPOLOGY, yamlWithMgmt); - await topoViewerPage.gotoFile(TEST_TOPOLOGY); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - await topoViewerPage.fit(); - - let yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - expect(yamlContent).toContain("mgmt-ipv4: 172.20.20.10"); - - await openNodeEditor(page, "srl1"); - await navigateToTab(page, TAB.NETWORK); - - const mgmtField = page.locator("#node-mgmt-ipv4"); - await mgmtField.clear(); - await mgmtField.blur(); - await page.waitForTimeout(200); - - await page.locator(SEL_APPLY_BTN).click(); - await page.waitForTimeout(500); - - yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - const srl1Section = yamlContent.split("srl2:")[0]; - expect(srl1Section).not.toContain("mgmt-ipv4:"); - }); - - test("deleting labels removes them from YAML", async ({ page, topoViewerPage }) => { - const yamlWithLabels = `name: simple -topology: - nodes: - srl1: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - labels: - env: production - team: network - srl2: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - links: - - endpoints: ["srl1:e1-1", "srl2:e1-1"] -`; - await topoViewerPage.writeYamlFile(TEST_TOPOLOGY, yamlWithLabels); - await topoViewerPage.gotoFile(TEST_TOPOLOGY); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - await topoViewerPage.fit(); - - let yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - expect(yamlContent).toContain("labels:"); - expect(yamlContent).toContain("env: production"); - - await openNodeEditor(page, "srl1"); - await navigateToTab(page, TAB.CONFIG); - - await clickAllRemoveButtons(page); - - await page.locator(SEL_APPLY_BTN).click(); - await page.waitForTimeout(700); - - yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - const srl1Section = yamlContent.split("srl2:")[0]; - expect(srl1Section).not.toContain("labels:"); - expect(srl1Section).not.toContain("env:"); - expect(srl1Section).not.toContain("team:"); - }); - - test("deleting env variables removes them from YAML", async ({ page, topoViewerPage }) => { - const yamlWithEnv = `name: simple -topology: - nodes: - srl1: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - env: - FOO: bar - BAZ: qux - srl2: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - links: - - endpoints: ["srl1:e1-1", "srl2:e1-1"] -`; - await topoViewerPage.writeYamlFile(TEST_TOPOLOGY, yamlWithEnv); - await topoViewerPage.gotoFile(TEST_TOPOLOGY); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - await topoViewerPage.fit(); - - let yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - expect(yamlContent).toContain("env:"); - expect(yamlContent).toContain("FOO: bar"); - - await openNodeEditor(page, "srl1"); - await navigateToTab(page, TAB.CONFIG); - - await clickAllRemoveButtons(page); - - await page.locator(SEL_APPLY_BTN).click(); - await page.waitForTimeout(700); - - yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - const srl1Section = yamlContent.split("srl2:")[0]; - expect(srl1Section).not.toContain("env:"); - expect(srl1Section).not.toContain("FOO:"); - expect(srl1Section).not.toContain("BAZ:"); - }); - - test("UI updates immediately after Apply without requiring reload", async ({ - page, - topoViewerPage - }) => { - // Regression: deleted dynamic-list entries should not reappear after Apply. - const yamlWithEnv = `name: simple -topology: - nodes: - srl1: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - env: - TEST_VAR: test_value - srl2: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - links: - - endpoints: ["srl1:e1-1", "srl2:e1-1"] -`; - - await topoViewerPage.writeYamlFile(TEST_TOPOLOGY, yamlWithEnv); - await topoViewerPage.gotoFile(TEST_TOPOLOGY); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - await topoViewerPage.fit(); - - await openNodeEditor(page, "srl1"); - await navigateToTab(page, TAB.CONFIG); - - const panel = page.locator(SEL_CONTEXT_PANEL); - await expect(panel).toBeVisible({ timeout: 3000 }); - - const removeButtons = panel.locator('button[aria-label="Remove"]'); - await expect(removeButtons).toHaveCount(1); - await removeButtons.first().click(); - await page.waitForTimeout(100); - await expect(removeButtons).toHaveCount(0); - - await page.locator(SEL_APPLY_BTN).click(); - await page.waitForTimeout(500); - - // Key regression check: Apply should not resurrect deleted rows. - await expect(removeButtons).toHaveCount(0); - - const yaml = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - const srl1Section = yaml.split("srl2:")[0]; - expect(srl1Section).not.toContain("env:"); - expect(srl1Section).not.toContain("TEST_VAR"); - }); - - test("deleting binds removes them from YAML", async ({ page, topoViewerPage }) => { - const yamlWithBinds = `name: simple -topology: - nodes: - srl1: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - binds: - - ./foo:/bar - - ./a:/b:ro - srl2: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - links: - - endpoints: ["srl1:e1-1", "srl2:e1-1"] -`; - await topoViewerPage.writeYamlFile(TEST_TOPOLOGY, yamlWithBinds); - await topoViewerPage.gotoFile(TEST_TOPOLOGY); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - await topoViewerPage.fit(); - - let yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - expect(yamlContent).toContain("binds:"); - expect(yamlContent).toContain("./foo:/bar"); - - await openNodeEditor(page, "srl1"); - await navigateToTab(page, TAB.CONFIG); - - await clickAllRemoveButtons(page); - - await page.locator(SEL_APPLY_BTN).click(); - await page.waitForTimeout(700); - - yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - const srl1Section = yamlContent.split("srl2:")[0]; - expect(srl1Section).not.toContain("binds:"); - expect(srl1Section).not.toContain("./foo:/bar"); - }); - - test("unchecking auto-remove checkbox removes it from YAML", async ({ page, topoViewerPage }) => { - const yamlWithAutoRemove = `name: simple -topology: - nodes: - srl1: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - auto-remove: true - srl2: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - links: - - endpoints: ["srl1:e1-1", "srl2:e1-1"] -`; - await topoViewerPage.writeYamlFile(TEST_TOPOLOGY, yamlWithAutoRemove); - await topoViewerPage.gotoFile(TEST_TOPOLOGY); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - await topoViewerPage.fit(); - - let yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - expect(yamlContent).toContain("auto-remove: true"); - - await openNodeEditor(page, "srl1"); - await navigateToTab(page, TAB.RUNTIME); - - // Uncheck and apply - await page.locator("#node-auto-remove").setChecked(false); - await page.waitForTimeout(200); - await page.locator(SEL_APPLY_BTN).click(); - await page.waitForTimeout(700); - - yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - const srl1Section = yamlContent.split("srl2:")[0]; - expect(srl1Section).not.toContain("auto-remove:"); - }); - - test("clearing startup-delay number field removes it from YAML", async ({ page, topoViewerPage }) => { - const yamlWithStartupDelay = `name: simple -topology: - nodes: - srl1: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - startup-delay: 15 - srl2: - kind: nokia_srlinux - image: ghcr.io/nokia/srlinux:latest - links: - - endpoints: ["srl1:e1-1", "srl2:e1-1"] -`; - await topoViewerPage.writeYamlFile(TEST_TOPOLOGY, yamlWithStartupDelay); - await topoViewerPage.gotoFile(TEST_TOPOLOGY); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - await topoViewerPage.fit(); - - let yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - expect(yamlContent).toContain("startup-delay: 15"); - - await openNodeEditor(page, "srl1"); - await navigateToTab(page, TAB.RUNTIME); - - const delayField = page.locator("#node-startup-delay"); - await delayField.clear(); - await delayField.blur(); - await page.waitForTimeout(200); - - await page.locator(SEL_APPLY_BTN).click(); - await page.waitForTimeout(700); - - yamlContent = await topoViewerPage.getYamlFromFile(TEST_TOPOLOGY); - const srl1Section = yamlContent.split("srl2:")[0]; - expect(srl1Section).not.toContain("startup-delay:"); - }); -}); diff --git a/test/e2e/specs/file-io-persistence.spec.ts b/test/e2e/specs/file-io-persistence.spec.ts deleted file mode 100644 index d30e62d61..000000000 --- a/test/e2e/specs/file-io-persistence.spec.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { test, expect } from "../fixtures/topoviewer"; -import { drag } from "../helpers/react-flow-helpers"; - -// Test file names -const SPINE_LEAF_FILE = "spine-leaf.clab.yml"; -const DATACENTER_FILE = "datacenter.clab.yml"; -const SIMPLE_FILE = "simple.clab.yml"; -const NETWORK_FILE = "network.clab.yml"; - -// File modification tests must run serially to avoid conflicts -// Use test.describe.serial to run all tests in this file sequentially -test.describe.serial("File I/O Persistence", () => { - test.describe("Node Position Persistence", () => { - test("moving node updates annotations file with new position", async ({ - page, - topoViewerPage - }) => { - // Reset files to ensure clean state - await topoViewerPage.resetFiles(); - - // Load file-based topology - await topoViewerPage.gotoFile(SPINE_LEAF_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Get initial annotations from file - const initialAnnotations = await topoViewerPage.getAnnotationsFromFile(SPINE_LEAF_FILE); - const spine1Initial = initialAnnotations.nodeAnnotations?.find((n) => n.id === "spine1"); - expect(spine1Initial).toBeDefined(); - expect(spine1Initial?.position).toBeDefined(); - - const initialX = spine1Initial!.position!.x; - const initialY = spine1Initial!.position!.y; - - // Get node bounding box for dragging - const nodeBox = await topoViewerPage.getNodeBoundingBox("spine1"); - expect(nodeBox).not.toBeNull(); - - const startX = nodeBox!.x + nodeBox!.width / 2; - const startY = nodeBox!.y + nodeBox!.height / 2; - - // Drag the node by 80px (larger distance for more reliable detection) - const dragDistance = 80; - await drag( - page, - { x: startX, y: startY }, - { x: startX + dragDistance, y: startY + dragDistance }, - { steps: 15 } - ); - - // Wait longer for drag end event and save to complete - await page.waitForTimeout(1000); - - // Read annotations from file again - const updatedAnnotations = await topoViewerPage.getAnnotationsFromFile(SPINE_LEAF_FILE); - const spine1Updated = updatedAnnotations.nodeAnnotations?.find((n) => n.id === "spine1"); - expect(spine1Updated).toBeDefined(); - expect(spine1Updated?.position).toBeDefined(); - - // Position should have changed significantly (at least 20px difference) - const deltaX = Math.abs(spine1Updated!.position!.x - initialX); - const deltaY = Math.abs(spine1Updated!.position!.y - initialY); - - // At least one axis should have moved significantly - expect(deltaX + deltaY).toBeGreaterThan(20); - }); - - test("moving multiple nodes updates all positions in file", async ({ - page, - topoViewerPage - }) => { - await topoViewerPage.gotoFile(SPINE_LEAF_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Get initial positions from React Flow - const spine1InitialCy = await topoViewerPage.getNodePosition("spine1"); - const spine2InitialCy = await topoViewerPage.getNodePosition("spine2"); - - // Drag spine1 - const box1 = await topoViewerPage.getNodeBoundingBox("spine1"); - expect(box1).not.toBeNull(); - await drag( - page, - { x: box1!.x + box1!.width / 2, y: box1!.y + box1!.height / 2 }, - { x: box1!.x + box1!.width / 2 + 30, y: box1!.y + box1!.height / 2 }, - { steps: 5 } - ); - await page.waitForTimeout(500); - - // Verify spine1 moved in React Flow state - const spine1AfterDrag1 = await topoViewerPage.getNodePosition("spine1"); - expect(spine1AfterDrag1.x).not.toBe(spine1InitialCy.x); - - // Drag spine2 - const box2 = await topoViewerPage.getNodeBoundingBox("spine2"); - expect(box2).not.toBeNull(); - await drag( - page, - { x: box2!.x + box2!.width / 2, y: box2!.y + box2!.height / 2 }, - { x: box2!.x + box2!.width / 2 - 30, y: box2!.y + box2!.height / 2 }, - { steps: 5 } - ); - await page.waitForTimeout(500); - - // Verify spine2 moved in React Flow state - const spine2AfterDrag2 = await topoViewerPage.getNodePosition("spine2"); - expect(spine2AfterDrag2.x).not.toBe(spine2InitialCy.x); - - // Wait for file saves to complete - await page.waitForTimeout(1000); - - // Read updated annotations from file - const updatedAnnotations = await topoViewerPage.getAnnotationsFromFile(SPINE_LEAF_FILE); - const spine1Updated = updatedAnnotations.nodeAnnotations?.find((n) => n.id === "spine1"); - const spine2Updated = updatedAnnotations.nodeAnnotations?.find((n) => n.id === "spine2"); - - // Both positions in file should match React Flow positions (with some tolerance) - expect(Math.abs(spine1Updated!.position!.x - spine1AfterDrag1.x)).toBeLessThan(5); - expect(Math.abs(spine2Updated!.position!.x - spine2AfterDrag2.x)).toBeLessThan(5); - }); - }); - - test.describe("Node Deletion Persistence", () => { - test("deleting node removes it from YAML file", async ({ page, topoViewerPage }) => { - // Reset files to ensure clean state - await topoViewerPage.resetFiles(); - - await topoViewerPage.gotoFile(SPINE_LEAF_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Get initial YAML - const initialYaml = await topoViewerPage.getYamlFromFile(SPINE_LEAF_FILE); - expect(initialYaml).toContain("client1"); - expect(initialYaml).toContain("client2"); - - // Get initial node count - const initialNodeCount = await topoViewerPage.getNodeCount(); - - // Select and delete client2 - await topoViewerPage.selectNode("client2"); - await page.keyboard.press("Delete"); - await page.waitForTimeout(500); - - // Verify node was removed from UI - const newNodeCount = await topoViewerPage.getNodeCount(); - expect(newNodeCount).toBe(initialNodeCount - 1); - - // Read YAML from file - const updatedYaml = await topoViewerPage.getYamlFromFile(SPINE_LEAF_FILE); - - // client2 should be removed from YAML - expect(updatedYaml).toContain("client1"); - expect(updatedYaml).not.toContain("client2:"); - }); - - test("deleting node removes it from annotations file", async ({ page, topoViewerPage }) => { - await topoViewerPage.gotoFile(SPINE_LEAF_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Get initial annotations - const initialAnnotations = await topoViewerPage.getAnnotationsFromFile(SPINE_LEAF_FILE); - const client1Exists = initialAnnotations.nodeAnnotations?.some((n) => n.id === "client1"); - expect(client1Exists).toBe(true); - - // Delete client1 - await topoViewerPage.selectNode("client1"); - await page.keyboard.press("Delete"); - await page.waitForTimeout(500); - - // Read annotations from file - const updatedAnnotations = await topoViewerPage.getAnnotationsFromFile(SPINE_LEAF_FILE); - const client1StillExists = updatedAnnotations.nodeAnnotations?.some( - (n) => n.id === "client1" - ); - - // client1 should be removed from annotations - expect(client1StillExists).toBe(false); - }); - - test("deleting node also removes connected links from YAML", async ({ - page, - topoViewerPage - }) => { - await topoViewerPage.gotoFile(SPINE_LEAF_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Get initial YAML - const initialYaml = await topoViewerPage.getYamlFromFile(SPINE_LEAF_FILE); - - // Ensure leaf1 has links in the fixture (otherwise this test is meaningless) - expect(initialYaml).toContain("leaf1:e1-49"); - expect(initialYaml).toContain("leaf1:e1-50"); - expect(initialYaml).toContain("leaf1:e1-1"); - - // Delete leaf1 - await topoViewerPage.selectNode("leaf1"); - await page.keyboard.press("Delete"); - await page.waitForTimeout(500); - - // Read updated YAML - const updatedYaml = await topoViewerPage.getYamlFromFile(SPINE_LEAF_FILE); - - // leaf1 node definition should be gone - expect(updatedYaml).not.toContain("leaf1:"); - expect(updatedYaml).not.toContain("leaf1:e1-49"); - expect(updatedYaml).not.toContain("leaf1:e1-50"); - expect(updatedYaml).not.toContain("leaf1:e1-1"); - - // Sanity: other parts of the topology should remain - expect(updatedYaml).toContain("leaf2:"); - }); - }); - - test.describe("Annotations Persistence", () => { - test("datacenter topology preserves groups, text, and shapes", async ({ topoViewerPage }) => { - await topoViewerPage.gotoFile(DATACENTER_FILE); - await topoViewerPage.waitForCanvasReady(); - - // Read annotations from file - const annotations = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - - // Should have groups - expect(annotations.groupStyleAnnotations?.length).toBeGreaterThan(0); - const groupNames = annotations.groupStyleAnnotations?.map((g) => g.name); - expect(groupNames).toContain("Border"); - expect(groupNames).toContain("Spine"); - - // Should have text annotations - expect(annotations.freeTextAnnotations?.length).toBeGreaterThan(0); - const textLabels = annotations.freeTextAnnotations?.map((t) => t.text); - expect(textLabels).toContain("Data Center West"); - expect(textLabels).toContain("Border Layer"); - - // Should have shape annotations - expect(annotations.freeShapeAnnotations?.length).toBeGreaterThan(0); - - // Should have node annotations with group membership - const nodesWithGroups = annotations.nodeAnnotations?.filter( - (n) => n.group !== undefined && n.group.length > 0 - ); - expect(nodesWithGroups?.length).toBeGreaterThan(0); - }); - - test("network topology preserves network node annotations", async ({ topoViewerPage }) => { - await topoViewerPage.gotoFile(NETWORK_FILE); - await topoViewerPage.waitForCanvasReady(); - - // Read annotations from file - const annotations = await topoViewerPage.getAnnotationsFromFile(NETWORK_FILE); - - // Should have network node annotations - expect(annotations.networkNodeAnnotations?.length).toBeGreaterThan(0); - - // Check for different network types - const types = annotations.networkNodeAnnotations?.map((n) => n.type); - expect(types).toContain("host"); - expect(types).toContain("bridge"); - }); - - test("moving node in datacenter preserves other annotations", async ({ - page, - topoViewerPage - }) => { - await topoViewerPage.gotoFile(DATACENTER_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Get initial annotations - const initialAnnotations = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const initialGroupCount = initialAnnotations.groupStyleAnnotations?.length ?? 0; - const initialTextCount = initialAnnotations.freeTextAnnotations?.length ?? 0; - const initialShapeCount = initialAnnotations.freeShapeAnnotations?.length ?? 0; - - // Move a node - const box = await topoViewerPage.getNodeBoundingBox("spine1"); - expect(box).not.toBeNull(); - - await drag( - page, - { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }, - { x: box!.x + box!.width / 2 + 20, y: box!.y + box!.height / 2 + 20 }, - { steps: 5 } - ); - await page.waitForTimeout(500); - - // Read updated annotations - const updatedAnnotations = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - - // All other annotations should be preserved - expect(updatedAnnotations.groupStyleAnnotations?.length).toBe(initialGroupCount); - expect(updatedAnnotations.freeTextAnnotations?.length).toBe(initialTextCount); - expect(updatedAnnotations.freeShapeAnnotations?.length).toBe(initialShapeCount); - }); - }); - - test.describe("File Loading", () => { - test("lists available topology files", async ({ topoViewerPage }) => { - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - - const files = await topoViewerPage.listTopologyFiles(); - - expect(files.length).toBeGreaterThan(0); - - const filenames = files.map((f) => f.filename); - expect(filenames).toContain(SIMPLE_FILE); - expect(filenames).toContain(SPINE_LEAF_FILE); - expect(filenames).toContain(DATACENTER_FILE); - }); - - test("tracks which files have annotations", async ({ topoViewerPage }) => { - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - - const files = await topoViewerPage.listTopologyFiles(); - - // spine-leaf should have annotations - const spineLeaf = files.find((f) => f.filename === SPINE_LEAF_FILE); - expect(spineLeaf?.hasAnnotations).toBe(true); - - // datacenter should have annotations - const datacenter = files.find((f) => f.filename === DATACENTER_FILE); - expect(datacenter?.hasAnnotations).toBe(true); - }); - - test("loading file updates current file path", async ({ topoViewerPage }) => { - await topoViewerPage.gotoFile(SPINE_LEAF_FILE); - await topoViewerPage.waitForCanvasReady(); - - const currentFile = await topoViewerPage.getCurrentFile(); - expect(currentFile).toBe(SPINE_LEAF_FILE); - }); - - test("switching between files works correctly", async ({ topoViewerPage }) => { - // Reset files to ensure clean state - await topoViewerPage.resetFiles(); - - // Load first file - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - - const simpleNodeCount = await topoViewerPage.getNodeCount(); - expect(simpleNodeCount).toBe(2); // simple has 2 nodes (srl1, srl2) - - // Load second file (need to navigate fresh) - await topoViewerPage.gotoFile(SPINE_LEAF_FILE); - await topoViewerPage.waitForCanvasReady(); - - const spineLeafNodeCount = await topoViewerPage.getNodeCount(); - expect(spineLeafNodeCount).toBe(6); // spine-leaf has 6 nodes - - const currentFile = await topoViewerPage.getCurrentFile(); - expect(currentFile).toBe(SPINE_LEAF_FILE); - }); - }); - - test.describe("Empty Topology", () => { - test("simple topology without annotations creates annotations on move", async ({ - page, - topoViewerPage - }) => { - await topoViewerPage.gotoFile(SIMPLE_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Initially simple.clab.yml has no annotations file - // Get node and move it - const box = await topoViewerPage.getNodeBoundingBox("srl1"); - expect(box).not.toBeNull(); - - await drag( - page, - { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }, - { x: box!.x + box!.width / 2 + 50, y: box!.y + box!.height / 2 + 50 }, - { steps: 5 } - ); - await page.waitForTimeout(500); - - // Annotations file should now exist with positions - const annotations = await topoViewerPage.getAnnotationsFromFile(SIMPLE_FILE); - expect(annotations.nodeAnnotations?.length).toBeGreaterThan(0); - - const srl1Annotation = annotations.nodeAnnotations?.find((n) => n.id === "srl1"); - expect(srl1Annotation).toBeDefined(); - expect(srl1Annotation?.position).toBeDefined(); - }); - }); -}); diff --git a/test/e2e/specs/find-node.spec.ts b/test/e2e/specs/find-node.spec.ts deleted file mode 100644 index 10eb4b84b..000000000 --- a/test/e2e/specs/find-node.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { test, expect } from "../fixtures/topoviewer"; - -// Test selectors for the new MUI Popover-based find node -const SEL_FIND_NODE_BTN = '[data-testid="navbar-find-node"]'; -const SEL_FIND_NODE_POPOVER = '[data-testid="find-node-popover"]'; -const SEL_FIND_NODE_INPUT = '[data-testid="find-node-input"]'; -const SEL_FIND_NODE_SEARCH_BTN = '[data-testid="find-node-search-btn"]'; -const SEL_FIND_NODE_MATCH_COUNT = '[data-testid="find-node-match-count"]'; - -const PLACEHOLDER_TEXT = "Search for nodes..."; - -/** - * Find Node Popover E2E Tests (MUI Popover version) - * - * In the new MUI design, find node is a Popover that opens below the navbar - * button, containing a FindNodeSearchWidget with input, search button, and - * match count display. - */ -test.describe("Find Node Popover", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.gotoFile("simple.clab.yml"); - await topoViewerPage.waitForCanvasReady(); - }); - - test("opens find node popover via navbar button", async ({ page }) => { - const findNodeBtn = page.locator(SEL_FIND_NODE_BTN); - await expect(findNodeBtn).toBeVisible(); - await findNodeBtn.click(); - await page.waitForTimeout(300); - - const popover = page.locator(SEL_FIND_NODE_POPOVER); - await expect(popover).toBeVisible(); - }); - - test("find node popover has input field with placeholder", async ({ page }) => { - await page.locator(SEL_FIND_NODE_BTN).click(); - await page.waitForTimeout(300); - - const input = page.locator(SEL_FIND_NODE_INPUT); - await expect(input).toBeVisible(); - - // MUI TextField wraps input — check placeholder on nested input - const nativeInput = input.locator("input"); - await expect(nativeInput).toHaveAttribute("placeholder", PLACEHOLDER_TEXT); - }); - - test("find node popover has search button", async ({ page }) => { - await page.locator(SEL_FIND_NODE_BTN).click(); - await page.waitForTimeout(300); - - const searchBtn = page.locator(SEL_FIND_NODE_SEARCH_BTN); - await expect(searchBtn).toBeVisible(); - await expect(searchBtn.locator("svg")).toBeVisible(); - }); - - test("search finds matching nodes", async ({ page, topoViewerPage }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - - await page.locator(SEL_FIND_NODE_BTN).click(); - await page.waitForTimeout(300); - - const input = page.locator(SEL_FIND_NODE_INPUT).locator("input"); - const searchTerm = nodeIds[0].substring(0, 3); - await input.fill(searchTerm); - - await page.locator(SEL_FIND_NODE_SEARCH_BTN).click(); - await page.waitForTimeout(300); - - const matchCount = page.locator(SEL_FIND_NODE_MATCH_COUNT); - await expect(matchCount).toBeVisible(); - const text = await matchCount.textContent(); - expect(text).toMatch(/Found \d+ node/); - }); - - test("search with Enter key works", async ({ page, topoViewerPage }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - - await page.locator(SEL_FIND_NODE_BTN).click(); - await page.waitForTimeout(300); - - const input = page.locator(SEL_FIND_NODE_INPUT).locator("input"); - await input.fill(nodeIds[0].substring(0, 3)); - await input.press("Enter"); - await page.waitForTimeout(300); - - const matchCount = page.locator(SEL_FIND_NODE_MATCH_COUNT); - await expect(matchCount).toBeVisible(); - }); - - test('search with no results shows "No nodes found"', async ({ page }) => { - await page.locator(SEL_FIND_NODE_BTN).click(); - await page.waitForTimeout(300); - - const input = page.locator(SEL_FIND_NODE_INPUT).locator("input"); - await input.fill("xyznonexistent123"); - - await page.locator(SEL_FIND_NODE_SEARCH_BTN).click(); - await page.waitForTimeout(300); - - const matchCount = page.locator(SEL_FIND_NODE_MATCH_COUNT); - await expect(matchCount).toBeVisible(); - await expect(matchCount).toHaveText("No nodes found"); - }); - - test("wildcard search finds all nodes", async ({ page, topoViewerPage }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - - await page.locator(SEL_FIND_NODE_BTN).click(); - await page.waitForTimeout(300); - - const input = page.locator(SEL_FIND_NODE_INPUT).locator("input"); - await input.fill("*"); - - await page.locator(SEL_FIND_NODE_SEARCH_BTN).click(); - await page.waitForTimeout(300); - - const matchCount = page.locator(SEL_FIND_NODE_MATCH_COUNT); - await expect(matchCount).toBeVisible(); - const text = await matchCount.textContent(); - expect(text).toMatch(/Found \d+ node/); - }); - - test("prefix search with + works", async ({ page, topoViewerPage }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - - await page.locator(SEL_FIND_NODE_BTN).click(); - await page.waitForTimeout(300); - - const input = page.locator(SEL_FIND_NODE_INPUT).locator("input"); - const prefix = nodeIds[0].substring(0, 2); - await input.fill(`+${prefix}`); - - await page.locator(SEL_FIND_NODE_SEARCH_BTN).click(); - await page.waitForTimeout(300); - - const matchCount = page.locator(SEL_FIND_NODE_MATCH_COUNT); - await expect(matchCount).toBeVisible(); - const text = await matchCount.textContent(); - expect(text).toMatch(/Found \d+ node/); - }); - - test("search fits viewport to matching nodes", async ({ page, topoViewerPage }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - - // Put the viewport into an obviously non-fit state. - await topoViewerPage.setZoom(0.4); - await topoViewerPage.setPan(800, 600); - const before = { zoom: await topoViewerPage.getZoom(), pan: await topoViewerPage.getPan() }; - - await page.locator(SEL_FIND_NODE_BTN).click(); - await page.waitForTimeout(300); - - const input = page.locator(SEL_FIND_NODE_INPUT).locator("input"); - await input.fill(nodeIds[0]); - - await page.locator(SEL_FIND_NODE_SEARCH_BTN).click(); - - // fitBounds uses a 300ms duration; allow it to animate and settle. - await expect - .poll(async () => { - const zoom = await topoViewerPage.getZoom(); - const pan = await topoViewerPage.getPan(); - const zoomChanged = Math.abs(zoom - before.zoom) > 0.05; - const panChanged = Math.abs(pan.x - before.pan.x) > 5 || Math.abs(pan.y - before.pan.y) > 5; - return zoomChanged || panChanged; - }, { timeout: 5000 }) - .toBe(true); - }); - - test("search for specific node shows exact count", async ({ page, topoViewerPage }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - - await topoViewerPage.clearSelection(); - - await page.locator(SEL_FIND_NODE_BTN).click(); - await page.waitForTimeout(300); - - const input = page.locator(SEL_FIND_NODE_INPUT).locator("input"); - await input.fill(nodeIds[0]); - - await page.locator(SEL_FIND_NODE_SEARCH_BTN).click(); - await page.waitForTimeout(500); - - const matchCount = page.locator(SEL_FIND_NODE_MATCH_COUNT); - await expect(matchCount).toBeVisible(); - await expect(matchCount).toHaveText("Found 1 node"); - }); - - test("popover closes with Escape key", async ({ page }) => { - await page.locator(SEL_FIND_NODE_BTN).click(); - await page.waitForTimeout(300); - - const popover = page.locator(SEL_FIND_NODE_POPOVER); - await expect(popover).toBeVisible(); - - await page.keyboard.press("Escape"); - await page.waitForTimeout(300); - - await expect(popover).not.toBeVisible(); - }); - - test("input is auto-focused when popover opens", async ({ page }) => { - await page.locator(SEL_FIND_NODE_BTN).click(); - await page.waitForTimeout(300); - - const nativeInput = page.locator(SEL_FIND_NODE_INPUT).locator("input"); - await expect(nativeInput).toBeFocused(); - }); - - test("empty search does not show match count", async ({ page }) => { - await page.locator(SEL_FIND_NODE_BTN).click(); - await page.waitForTimeout(300); - - const input = page.locator(SEL_FIND_NODE_INPUT).locator("input"); - await input.fill(""); - - await page.locator(SEL_FIND_NODE_SEARCH_BTN).click(); - await page.waitForTimeout(300); - - const matchCount = page.locator(SEL_FIND_NODE_MATCH_COUNT); - await expect(matchCount).not.toBeVisible(); - }); -}); diff --git a/test/e2e/specs/free-shape.spec.ts b/test/e2e/specs/free-shape.spec.ts deleted file mode 100644 index 1ad8f7588..000000000 --- a/test/e2e/specs/free-shape.spec.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { test, expect } from "../fixtures/topoviewer"; -import { rightClick } from "../helpers/react-flow-helpers"; - -const EMPTY_FILE = "empty.clab.yml"; - -const SEL_CONTEXT_MENU = '[data-testid="context-menu"]'; -const SEL_ADD_SHAPE_ITEM = '[data-testid="context-menu-item-add-shape"]'; -const SEL_RECTANGLE_ITEM = '[data-testid="context-menu-item-add-shape-rectangle"]'; -const SEL_CIRCLE_ITEM = '[data-testid="context-menu-item-add-shape-circle"]'; -const SEL_LINE_ITEM = '[data-testid="context-menu-item-add-shape-line"]'; - -async function addShapeViaContextMenu( - page: Parameters[0], - topoViewerPage: { getCanvas: () => ReturnType[0]["locator"]> }, - menuItemSelector: string, - offset: { x: number; y: number } = { x: 150, y: 150 } -): Promise { - const canvasBox = await topoViewerPage.getCanvas().boundingBox(); - if (!canvasBox) throw new Error("Canvas not found"); - await rightClick(page, canvasBox.x + offset.x, canvasBox.y + offset.y); - const contextMenu = page.locator(SEL_CONTEXT_MENU); - await expect(contextMenu).toBeVisible(); - const addShapeItem = page.locator(SEL_ADD_SHAPE_ITEM); - await expect(addShapeItem).toBeVisible(); - await addShapeItem.hover(); - await expect(page.locator(menuItemSelector)).toBeVisible(); - await page.locator(menuItemSelector).click(); - await expect(contextMenu).not.toBeVisible(); - await page.waitForTimeout(200); -} - -async function dismissEditorIfAny(page: Parameters[0]): Promise { - // Some shape creations may open an editor in the context panel; Escape should close it safely. - await page.keyboard.press("Escape"); - await page.waitForTimeout(200); -} - -async function getFreeShapeCount(topoViewerPage: any): Promise { - const ann = await topoViewerPage.getAnnotationsFromFile(EMPTY_FILE); - return ann.freeShapeAnnotations?.length ?? 0; -} - -/** - * Free Shape Annotations E2E Tests (MUI version) - * - * Tests creating shape annotations via the context menu. - */ -test.describe("Free Shape Annotations", () => { - test("can create rectangle and persist to annotations file", async ({ page, topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(EMPTY_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - const before = await topoViewerPage.getAnnotationsFromFile(EMPTY_FILE); - const beforeCount = before.freeShapeAnnotations?.length ?? 0; - - await addShapeViaContextMenu(page, topoViewerPage, SEL_RECTANGLE_ITEM); - - await dismissEditorIfAny(page); - - await expect - .poll(async () => { - const after = await topoViewerPage.getAnnotationsFromFile(EMPTY_FILE); - return after.freeShapeAnnotations?.length ?? 0; - }, { timeout: 5000 }) - .toBe(beforeCount + 1); - }); - - test("can create circle via context menu", async ({ page, topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(EMPTY_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - const before = await topoViewerPage.getAnnotationsFromFile(EMPTY_FILE); - const beforeCount = before.freeShapeAnnotations?.length ?? 0; - - await addShapeViaContextMenu(page, topoViewerPage, SEL_CIRCLE_ITEM, { x: 200, y: 200 }); - - await dismissEditorIfAny(page); - - await expect - .poll(async () => { - const after = await topoViewerPage.getAnnotationsFromFile(EMPTY_FILE); - return after.freeShapeAnnotations?.length ?? 0; - }, { timeout: 5000 }) - .toBe(beforeCount + 1); - }); - - test("undo removes created shape", async ({ page, topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(EMPTY_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - const before = await topoViewerPage.getAnnotationsFromFile(EMPTY_FILE); - const beforeCount = before.freeShapeAnnotations?.length ?? 0; - - await addShapeViaContextMenu(page, topoViewerPage, SEL_RECTANGLE_ITEM, { x: 250, y: 250 }); - await dismissEditorIfAny(page); - - // Verify shape was created - await expect - .poll(async () => { - return await getFreeShapeCount(topoViewerPage); - }, { timeout: 5000 }) - .toBe(beforeCount + 1); - - // Undo - await topoViewerPage.undo(); - await page.waitForTimeout(300); - - // Verify shape was removed - await expect - .poll(async () => await getFreeShapeCount(topoViewerPage), { timeout: 5000 }) - .toBe(beforeCount); - }); - - test("can undo and redo rectangle creation", async ({ page, topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(EMPTY_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - const beforeCount = await getFreeShapeCount(topoViewerPage); - - await addShapeViaContextMenu(page, topoViewerPage, SEL_RECTANGLE_ITEM, { x: 150, y: 150 }); - await dismissEditorIfAny(page); - - await expect.poll(async () => await getFreeShapeCount(topoViewerPage), { timeout: 5000 }).toBe( - beforeCount + 1 - ); - - await topoViewerPage.undo(); - await expect.poll(async () => await getFreeShapeCount(topoViewerPage), { timeout: 5000 }).toBe( - beforeCount - ); - - await topoViewerPage.redo(); - await expect.poll(async () => await getFreeShapeCount(topoViewerPage), { timeout: 5000 }).toBe( - beforeCount + 1 - ); - }); - - test("can undo and redo circle creation", async ({ page, topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(EMPTY_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - const beforeCount = await getFreeShapeCount(topoViewerPage); - - await addShapeViaContextMenu(page, topoViewerPage, SEL_CIRCLE_ITEM, { x: 200, y: 200 }); - await dismissEditorIfAny(page); - - await expect.poll(async () => await getFreeShapeCount(topoViewerPage), { timeout: 5000 }).toBe( - beforeCount + 1 - ); - - await topoViewerPage.undo(); - await expect.poll(async () => await getFreeShapeCount(topoViewerPage), { timeout: 5000 }).toBe( - beforeCount - ); - - await topoViewerPage.redo(); - await expect.poll(async () => await getFreeShapeCount(topoViewerPage), { timeout: 5000 }).toBe( - beforeCount + 1 - ); - }); - - test("can undo and redo line creation", async ({ page, topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(EMPTY_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - const beforeCount = await getFreeShapeCount(topoViewerPage); - - await addShapeViaContextMenu(page, topoViewerPage, SEL_LINE_ITEM, { x: 220, y: 140 }); - await dismissEditorIfAny(page); - - await expect.poll(async () => await getFreeShapeCount(topoViewerPage), { timeout: 5000 }).toBe( - beforeCount + 1 - ); - - await topoViewerPage.undo(); - await expect.poll(async () => await getFreeShapeCount(topoViewerPage), { timeout: 5000 }).toBe( - beforeCount - ); - - await topoViewerPage.redo(); - await expect.poll(async () => await getFreeShapeCount(topoViewerPage), { timeout: 5000 }).toBe( - beforeCount + 1 - ); - }); - - test("can undo and redo rectangle position change", async ({ page, topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(EMPTY_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Create exactly one rectangle. - await addShapeViaContextMenu(page, topoViewerPage, SEL_RECTANGLE_ITEM, { x: 120, y: 120 }); - await dismissEditorIfAny(page); - await expect.poll(async () => await getFreeShapeCount(topoViewerPage), { timeout: 5000 }).toBe(1); - - const before = await topoViewerPage.getAnnotationsFromFile(EMPTY_FILE); - const originalPos = before.freeShapeAnnotations?.[0]?.position; - expect(originalPos).toBeDefined(); - - const shapeNode = page.locator(".react-flow__node.react-flow__node-free-shape-node").first(); - await expect(shapeNode).toBeVisible({ timeout: 5000 }); - const box = await shapeNode.boundingBox(); - expect(box).not.toBeNull(); - - // Drag the shape. - await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); - await page.mouse.down(); - await page.mouse.move(box!.x + box!.width / 2 + 120, box!.y + box!.height / 2 + 60, { - steps: 8 - }); - await page.mouse.up(); - - await expect - .poll( - async () => { - const after = await topoViewerPage.getAnnotationsFromFile(EMPTY_FILE); - const pos = after.freeShapeAnnotations?.[0]?.position; - if (!pos || !originalPos) return false; - return pos.x !== originalPos.x || pos.y !== originalPos.y; - }, - { timeout: 5000 } - ) - .toBe(true); - - const afterDrag = await topoViewerPage.getAnnotationsFromFile(EMPTY_FILE); - const draggedPos = afterDrag.freeShapeAnnotations?.[0]?.position; - expect(draggedPos).toBeDefined(); - - // Undo drag, expect near original (snapping tolerance). - await topoViewerPage.undo(); - await expect - .poll( - async () => { - const after = await topoViewerPage.getAnnotationsFromFile(EMPTY_FILE); - const pos = after.freeShapeAnnotations?.[0]?.position; - if (!pos || !originalPos) return false; - return Math.abs(pos.x - originalPos.x) < 20 && Math.abs(pos.y - originalPos.y) < 20; - }, - { timeout: 5000 } - ) - .toBe(true); - - // Redo drag, expect near dragged. - await topoViewerPage.redo(); - await expect - .poll( - async () => { - const after = await topoViewerPage.getAnnotationsFromFile(EMPTY_FILE); - const pos = after.freeShapeAnnotations?.[0]?.position; - if (!pos || !draggedPos) return false; - return Math.abs(pos.x - draggedPos.x) < 20 && Math.abs(pos.y - draggedPos.y) < 20; - }, - { timeout: 5000 } - ) - .toBe(true); - }); -}); diff --git a/test/e2e/specs/free-text-drag.spec.ts b/test/e2e/specs/free-text-drag.spec.ts deleted file mode 100644 index ad0a30c5f..000000000 --- a/test/e2e/specs/free-text-drag.spec.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { expect, test } from "../fixtures/topoviewer"; - -const DATACENTER_FILE = "datacenter.clab.yml"; -const GIF_MARKDOWN = - "![gif](https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExaDVlNGdiODA5ZXhmcHp5ZDI1ZGo4bHc1ZHAyeTB0ZW03YzdmbHIzOCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/TcdpZwYDPlWXC/giphy.gif)"; - -function positionChanged( - before: { x: number; y: number } | undefined, - after: { x: number; y: number } | undefined -): boolean { - if (!before || !after) return false; - return before.x !== after.x || before.y !== after.y; -} - -test.describe("Free Text Dragging", () => { - test("clicking free text opens the editor", async ({ page, topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(DATACENTER_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - await page.waitForTimeout(500); - await topoViewerPage.fit(); - await page.waitForTimeout(300); - - const before = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const text = before.freeTextAnnotations?.[0]; - expect(text).toBeDefined(); - - const textElement = page.locator(`[data-id="${text!.id}"] .free-text-content`).first(); - await expect(textElement).toBeVisible(); - await textElement.click(); - - const panel = page.locator('[data-testid="context-panel"]'); - await expect(panel).toBeVisible(); - await expect(panel.getByText("Edit Text", { exact: true })).toBeVisible(); - }); - - test("can drag a free text annotation", async ({ page, topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(DATACENTER_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - await page.waitForTimeout(500); - await topoViewerPage.fit(); - await page.waitForTimeout(300); - - const before = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const text = before.freeTextAnnotations?.[0]; - expect(text).toBeDefined(); - - const originalPosition = text?.position; - expect(originalPosition).toBeDefined(); - - // Reproduce user flow: click text first (opens editor), then drag it. - const textElement = page.locator(`[data-id="${text!.id}"] .free-text-content`).first(); - await expect(textElement).toBeVisible(); - await textElement.click(); - await page.waitForTimeout(100); - - await topoViewerPage.dragNode(text!.id, { x: 120, y: 80 }); - - await expect - .poll( - async () => { - const after = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const updated = after.freeTextAnnotations?.find((entry) => entry.id === text!.id); - return positionChanged(originalPosition, updated?.position); - }, - { timeout: 5000 } - ) - .toBe(true); - }); - - test("can drag free text when markdown renders a link", async ({ page, topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(DATACENTER_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - await page.waitForTimeout(500); - await topoViewerPage.fit(); - await page.waitForTimeout(300); - - const before = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const text = before.freeTextAnnotations?.[0]; - expect(text).toBeDefined(); - - const updated = { - ...before, - freeTextAnnotations: (before.freeTextAnnotations ?? []).map((entry) => - entry.id === text!.id ? { ...entry, text: "[Data Center](https://example.com)" } : entry - ) - }; - await topoViewerPage.writeAnnotationsFile(DATACENTER_FILE, updated); - await page.waitForTimeout(500); - - const textElement = page.locator(`[data-id="${text!.id}"] .free-text-content`).first(); - await expect(textElement.locator("a")).toBeVisible({ timeout: 5000 }); - - const originalPosition = text?.position; - expect(originalPosition).toBeDefined(); - - const box = await textElement.boundingBox(); - expect(box).not.toBeNull(); - - await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); - await page.mouse.down(); - await page.mouse.move(box!.x + box!.width / 2 + 120, box!.y + box!.height / 2 + 80, { - steps: 8 - }); - await page.mouse.up(); - - await expect - .poll( - async () => { - const after = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const moved = after.freeTextAnnotations?.find((entry) => entry.id === text!.id); - return positionChanged(originalPosition, moved?.position); - }, - { timeout: 5000 } - ) - .toBe(true); - }); - - test("can drag free text when pointer starts on markdown gif image", async ({ - page, - topoViewerPage - }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(DATACENTER_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - await page.waitForTimeout(500); - await topoViewerPage.fit(); - await page.waitForTimeout(300); - - const before = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const text = before.freeTextAnnotations?.[0]; - expect(text).toBeDefined(); - - const updated = { - ...before, - freeTextAnnotations: (before.freeTextAnnotations ?? []).map((entry) => - entry.id === text!.id - ? { - ...entry, - text: GIF_MARKDOWN, - width: entry.width ?? 96, - height: undefined - } - : entry - ) - }; - await topoViewerPage.writeAnnotationsFile(DATACENTER_FILE, updated); - await page.waitForTimeout(500); - - const originalPosition = text?.position; - expect(originalPosition).toBeDefined(); - - const gifImage = page.locator(`[data-id="${text!.id}"] .free-text-content img`).first(); - await expect(gifImage).toBeVisible({ timeout: 5000 }); - - const box = await gifImage.boundingBox(); - expect(box).not.toBeNull(); - - await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); - await page.mouse.down(); - await page.mouse.move(box!.x + box!.width / 2 + 120, box!.y + box!.height / 2 + 80, { - steps: 8 - }); - await page.mouse.up(); - - await expect - .poll( - async () => { - const after = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const moved = after.freeTextAnnotations?.find((entry) => entry.id === text!.id); - return positionChanged(originalPosition, moved?.position); - }, - { timeout: 5000 } - ) - .toBe(true); - }); - - test("can rotate free text when markdown gif image is selected", async ({ - page, - topoViewerPage - }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(DATACENTER_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - await page.waitForTimeout(500); - await topoViewerPage.fit(); - await page.waitForTimeout(300); - - const pageErrors: string[] = []; - const handlePageError = (error: Error) => { - pageErrors.push(error.message); - }; - page.on("pageerror", handlePageError); - - const before = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const text = before.freeTextAnnotations?.[0]; - expect(text).toBeDefined(); - - const initialRotation = text?.rotation ?? 0; - const updated = { - ...before, - freeTextAnnotations: (before.freeTextAnnotations ?? []).map((entry) => - entry.id === text!.id - ? { - ...entry, - text: GIF_MARKDOWN, - width: entry.width ?? 120, - height: entry.height ?? 74, - rotation: entry.rotation ?? initialRotation - } - : entry - ) - }; - await topoViewerPage.writeAnnotationsFile(DATACENTER_FILE, updated); - await page.waitForTimeout(500); - - const textElement = page.locator(`[data-id="${text!.id}"] .free-text-content`).first(); - await expect(textElement.locator("img")).toBeVisible({ timeout: 5000 }); - await textElement.click(); - - const rotationHandle = page - .locator(`[data-id="${text!.id}"] [title="Drag to rotate (Shift for 15° snap)"]`) - .first(); - await expect(rotationHandle).toBeVisible({ timeout: 5000 }); - - const handleBox = await rotationHandle.boundingBox(); - expect(handleBox).not.toBeNull(); - - const startX = handleBox!.x + handleBox!.width / 2; - const startY = handleBox!.y + handleBox!.height / 2; - await page.mouse.move(startX, startY); - await page.mouse.down(); - await page.mouse.move(startX + 80, startY + 60, { steps: 12 }); - await page.mouse.up(); - await page.waitForTimeout(250); - - await expect - .poll( - async () => { - const after = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const rotated = after.freeTextAnnotations?.find((entry) => entry.id === text!.id); - return (rotated?.rotation ?? 0) !== initialRotation; - }, - { timeout: 5000 } - ) - .toBe(true); - - await expect(page.locator(".react-flow")).toBeVisible(); - const hasReactDepthError = pageErrors.some( - (message) => - message.includes("Maximum update depth exceeded") || - message.includes("Minified React error #185") - ); - expect(hasReactDepthError).toBe(false); - page.off("pageerror", handlePageError); - }); - - test("legacy gif markdown free text is migrated with explicit height on load", async ({ - topoViewerPage - }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(DATACENTER_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - const before = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const text = before.freeTextAnnotations?.[0]; - expect(text).toBeDefined(); - - const legacyLike = { - ...before, - freeTextAnnotations: (before.freeTextAnnotations ?? []).map((entry) => - entry.id === text!.id - ? { - ...entry, - text: GIF_MARKDOWN, - width: 96, - height: undefined - } - : entry - ) - }; - await topoViewerPage.writeAnnotationsFile(DATACENTER_FILE, legacyLike); - - // Reload to trigger legacy migration path in the host. - await topoViewerPage.gotoFile(DATACENTER_FILE); - await topoViewerPage.waitForCanvasReady(); - - await expect - .poll( - async () => { - const after = await topoViewerPage.getAnnotationsFromFile(DATACENTER_FILE); - const migrated = after.freeTextAnnotations?.find((entry) => entry.id === text!.id); - return typeof migrated?.height === "number" && Number.isFinite(migrated.height); - }, - { timeout: 5000 } - ) - .toBe(true); - }); -}); diff --git a/test/e2e/specs/full-workflow.spec.ts b/test/e2e/specs/full-workflow.spec.ts deleted file mode 100644 index 66cc4ef5e..000000000 --- a/test/e2e/specs/full-workflow.spec.ts +++ /dev/null @@ -1,492 +0,0 @@ -import type { Page } from "@playwright/test"; - -import { test, expect } from "../fixtures/topoviewer"; -import { shiftClick } from "../helpers/react-flow-helpers"; - -const TOPOLOGY_FILE = "empty.clab.yml"; -const KIND = "nokia_srlinux"; - -// Selectors -const SEL_APPLY_BTN = '[data-testid="panel-apply-btn"]'; -const SEL_PANEL_TOGGLE_BTN = '[data-testid="panel-toggle-btn"]'; -const SEL_PANEL_TAB_BASIC = '[data-testid="panel-tab-basic"]'; -const SEL_NODE_NAME = "#node-name"; - -const CORE_ROUTER = "core-router"; -const CORE_ROUTER_YAML_KEY = `${CORE_ROUTER}:`; - -async function clickNode(page: Page, nodeId: string): Promise { - const nodeHandle = page.locator(`[data-id="${nodeId}"]`); - await nodeHandle.scrollIntoViewIfNeeded(); - await expect(nodeHandle).toBeVisible({ timeout: 3000 }); - const box = await nodeHandle.boundingBox(); - if (!box) throw new Error(`Node ${nodeId} has no bounding box`); - await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); - await page.waitForTimeout(300); -} - -async function returnToPalette(page: Page): Promise { - const search = page.getByPlaceholder("Search nodes..."); - if (await search.isVisible().catch(() => false)) return; - - const toggle = page.locator(SEL_PANEL_TOGGLE_BTN); - await expect(toggle).toBeVisible({ timeout: 3000 }); - await toggle.click(); - await page.waitForTimeout(200); - await toggle.click(); - await expect(search).toBeVisible({ timeout: 5000 }); -} - -/** - * Full Workflow E2E Test (MUI version) - * - * Tests a complete multi-step workflow: create nodes, link them, - * edit properties, verify YAML, undo/redo. - */ -test.describe("Full Workflow", () => { - test("create nodes, link, edit, and verify YAML", async ({ page, topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(TOPOLOGY_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Step 1: Create nodes - await topoViewerPage.createNode("router1", { x: 200, y: 100 }, KIND); - await topoViewerPage.createNode("router2", { x: 400, y: 100 }, KIND); - await page.waitForTimeout(300); - - await expect.poll(() => topoViewerPage.getNodeCount()).toBe(2); - - // Step 2: Create link - await topoViewerPage.createLink("router1", "router2", "e1-1", "e1-1"); - await page.waitForTimeout(300); - - await expect.poll(() => topoViewerPage.getEdgeCount()).toBe(1); - - // Verify YAML has both nodes and link - let yaml = await topoViewerPage.getYamlFromFile(TOPOLOGY_FILE); - expect(yaml).toContain("router1:"); - expect(yaml).toContain("router2:"); - expect(yaml).toContain("endpoints:"); - - // Step 3: Edit node kind via editor - await clickNode(page, "router1"); - await expect(page.locator(SEL_PANEL_TAB_BASIC)).toBeVisible({ timeout: 3000 }); - - const kindField = page.locator("#node-kind"); - await kindField.clear(); - await kindField.fill("linux"); - await kindField.blur(); - await page.waitForTimeout(200); - - await page.locator(SEL_APPLY_BTN).click(); - await page.waitForTimeout(500); - - yaml = await topoViewerPage.getYamlFromFile(TOPOLOGY_FILE); - expect(yaml).toContain("kind: linux"); - - // Step 4: Undo the kind change - await returnToPalette(page); - await topoViewerPage.undo(); - await page.waitForTimeout(500); - - yaml = await topoViewerPage.getYamlFromFile(TOPOLOGY_FILE); - expect(yaml).toContain(`kind: ${KIND}`); - - // Step 5: Redo the kind change - await topoViewerPage.redo(); - await page.waitForTimeout(500); - - yaml = await topoViewerPage.getYamlFromFile(TOPOLOGY_FILE); - expect(yaml).toContain("kind: linux"); - }); - - test("node rename updates YAML and graph", async ({ page, topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(TOPOLOGY_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - await topoViewerPage.createNode("router1", { x: 200, y: 100 }, KIND); - await page.waitForTimeout(300); - - // Open editor and rename - await clickNode(page, "router1"); - await expect(page.locator(SEL_PANEL_TAB_BASIC)).toBeVisible({ timeout: 3000 }); - - const nameField = page.locator(SEL_NODE_NAME); - await nameField.clear(); - await nameField.fill(CORE_ROUTER); - await nameField.blur(); - await page.waitForTimeout(200); - - await page.locator(SEL_APPLY_BTN).click(); - await page.waitForTimeout(500); - - // Verify YAML has new name - const yaml = await topoViewerPage.getYamlFromFile(TOPOLOGY_FILE); - expect(yaml).toContain(CORE_ROUTER_YAML_KEY); - expect(yaml).not.toContain("router1:"); - - // Verify graph has new name - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds).toContain(CORE_ROUTER); - expect(nodeIds).not.toContain("router1"); - }); -}); - -/** - * Integration-style suite migrated from deprecated specs. - * Uses serial execution and larger timeouts, since it exercises many interacting features. - */ -test.describe.serial("Full Workflow E2E Test (Integration)", () => { - test.setTimeout(180000); - - const NODE_POSITIONS = { - router1: { x: 200, y: 100 }, - router2: { x: 400, y: 100 }, - router3: { x: 400, y: 300 }, - router4: { x: 200, y: 300 } - }; - - async function setup(topoViewerPage: any) { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(TOPOLOGY_FILE); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - } - - async function createInitialNodes(page: Page, topoViewerPage: any) { - for (const [nodeId, pos] of Object.entries(NODE_POSITIONS)) { - await topoViewerPage.createNode(nodeId, pos, KIND); - } - await page.waitForTimeout(500); - await expect.poll(() => topoViewerPage.getNodeCount(), { timeout: 10000 }).toBe(4); - } - - async function createInitialLinks(page: Page, topoViewerPage: any) { - await topoViewerPage.createLink("router1", "router2", "eth1", "eth1"); - await topoViewerPage.createLink("router2", "router3", "eth2", "eth1"); - await topoViewerPage.createLink("router3", "router4", "eth2", "eth1"); - await topoViewerPage.createLink("router4", "router1", "eth2", "eth2"); - await page.waitForTimeout(500); - await expect.poll(() => topoViewerPage.getEdgeCount(), { timeout: 10000 }).toBe(4); - } - - test("1. Create nodes and verify YAML persistence", async ({ page, topoViewerPage }) => { - await setup(topoViewerPage); - expect(await topoViewerPage.getNodeCount()).toBe(0); - - await createInitialNodes(page, topoViewerPage); - - const yaml = await topoViewerPage.getYamlFromFile(TOPOLOGY_FILE); - for (const nodeId of Object.keys(NODE_POSITIONS)) { - expect(yaml).toContain(`${nodeId}:`); - } - - const annotations = await topoViewerPage.getAnnotationsFromFile(TOPOLOGY_FILE); - expect(annotations.nodeAnnotations?.length).toBe(4); - }); - - test("2. Create links between nodes", async ({ page, topoViewerPage }) => { - await setup(topoViewerPage); - await createInitialNodes(page, topoViewerPage); - - expect(await topoViewerPage.getEdgeCount()).toBe(0); - await createInitialLinks(page, topoViewerPage); - - const yaml = await topoViewerPage.getYamlFromFile(TOPOLOGY_FILE); - expect(yaml).toContain("links:"); - expect(yaml).toContain("router1:eth1"); - expect(yaml).toContain("router2:eth1"); - }); - - test("3. Rename node via editor", async ({ page, topoViewerPage }) => { - await setup(topoViewerPage); - await createInitialNodes(page, topoViewerPage); - await createInitialLinks(page, topoViewerPage); - await topoViewerPage.fit(); - await page.waitForTimeout(500); - - await clickNode(page, "router1"); - await expect(page.getByText("Node Editor", { exact: true })).toBeVisible({ timeout: 5000 }); - - const nameField = page.locator(SEL_NODE_NAME); - await nameField.clear(); - await nameField.fill(CORE_ROUTER); - await nameField.blur(); - await page.waitForTimeout(200); - - await page.locator(SEL_APPLY_BTN).click(); - await expect.poll(() => topoViewerPage.getYamlFromFile(TOPOLOGY_FILE), { timeout: 10000 }).toContain( - CORE_ROUTER_YAML_KEY - ); - - await returnToPalette(page); - - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds).toContain(CORE_ROUTER); - expect(nodeIds).not.toContain("router1"); - }); - - test("4. Create group from selected nodes", async ({ page, topoViewerPage }) => { - await setup(topoViewerPage); - await createInitialNodes(page, topoViewerPage); - await createInitialLinks(page, topoViewerPage); - await topoViewerPage.fit(); - - const initialGroupCount = await topoViewerPage.getGroupCount(); - - await topoViewerPage.selectNode("router2"); - await page.waitForTimeout(200); - const router3Box = await topoViewerPage.getNodeBoundingBox("router3"); - expect(router3Box).not.toBeNull(); - await shiftClick(page, router3Box!.x + router3Box!.width / 2, router3Box!.y + router3Box!.height / 2); - await page.waitForTimeout(200); - - await topoViewerPage.createGroup(); - await expect.poll(() => topoViewerPage.getGroupCount(), { timeout: 10000 }).toBe(initialGroupCount + 1); - - // A single undo must fully remove the group (not require two undos). - await topoViewerPage.undo(); - await expect.poll(() => topoViewerPage.getGroupCount(), { timeout: 10000 }).toBe(initialGroupCount); - - // A single redo must fully restore the group. - await topoViewerPage.redo(); - await expect.poll(() => topoViewerPage.getGroupCount(), { timeout: 10000 }).toBe(initialGroupCount + 1); - - }); - - test("5-6. Complex undo/redo with interleaved operations", async ({ page, topoViewerPage }) => { - await setup(topoViewerPage); - await createInitialNodes(page, topoViewerPage); - await createInitialLinks(page, topoViewerPage); - - await topoViewerPage.createNode("router5", { x: 300, y: 200 }, KIND); - await expect.poll(() => topoViewerPage.getNodeCount(), { timeout: 10000 }).toBe(5); - - await topoViewerPage.createLink("router4", "router5", "eth3", "eth1"); - await expect.poll(() => topoViewerPage.getEdgeCount(), { timeout: 10000 }).toBe(5); - - // Undo link, create different link. - await topoViewerPage.undo(); - await expect.poll(() => topoViewerPage.getEdgeCount(), { timeout: 10000 }).toBe(4); - - await topoViewerPage.createLink("router3", "router5", "eth3", "eth2"); - await expect.poll(() => topoViewerPage.getEdgeCount(), { timeout: 10000 }).toBe(5); - - // Redo should have no effect because redo stack is cleared by the new link creation. - await topoViewerPage.redo(); - await expect.poll(() => topoViewerPage.getEdgeCount(), { timeout: 5000 }).toBe(5); - - await topoViewerPage.deleteNode("router5"); - await expect.poll(() => topoViewerPage.getNodeCount(), { timeout: 10000 }).toBe(4); - await expect.poll(() => topoViewerPage.getEdgeCount(), { timeout: 10000 }).toBe(4); - - await topoViewerPage.undo(); - await expect.poll(() => topoViewerPage.getNodeCount(), { timeout: 10000 }).toBe(5); - - await topoViewerPage.redo(); - await expect.poll(() => topoViewerPage.getNodeCount(), { timeout: 10000 }).toBe(4); - }); - - test("7. Undo/redo node drag", async ({ page, topoViewerPage }) => { - await setup(topoViewerPage); - await createInitialNodes(page, topoViewerPage); - await topoViewerPage.fit(); - await page.waitForTimeout(500); - - const initialPos = await topoViewerPage.getNodePosition("router3"); - await topoViewerPage.dragNode("router3", { x: 120, y: 0 }); - await page.waitForTimeout(400); - - const movedPos = await topoViewerPage.getNodePosition("router3"); - expect(movedPos.x).toBeGreaterThan(initialPos.x); - - await topoViewerPage.undo(); - await page.waitForTimeout(300); - const restoredPos = await topoViewerPage.getNodePosition("router3"); - expect(restoredPos.x).toBeCloseTo(initialPos.x, 0); - - await topoViewerPage.redo(); - await page.waitForTimeout(300); - const redoPos = await topoViewerPage.getNodePosition("router3"); - expect(redoPos.x).toBeGreaterThan(initialPos.x); - }); - - test("8. Copy/paste multiple nodes with batched undo", async ({ page, topoViewerPage }) => { - await setup(topoViewerPage); - await createInitialNodes(page, topoViewerPage); - await createInitialLinks(page, topoViewerPage); - await topoViewerPage.fit(); - - await page.keyboard.press("Escape"); - await page.waitForTimeout(200); - - const nodeCountBefore = await topoViewerPage.getNodeCount(); - const edgeCountBefore = await topoViewerPage.getEdgeCount(); - const nodeIdsBefore = await topoViewerPage.getNodeIds(); - - await topoViewerPage.selectNode("router2"); - await page.waitForTimeout(100); - const router3Box = await topoViewerPage.getNodeBoundingBox("router3"); - expect(router3Box).not.toBeNull(); - await shiftClick(page, router3Box!.x + router3Box!.width / 2, router3Box!.y + router3Box!.height / 2); - await page.waitForTimeout(200); - - await topoViewerPage.copy(); - await topoViewerPage.paste(); - await page.waitForTimeout(1200); - - const nodeCountAfter = await topoViewerPage.getNodeCount(); - const edgeCountAfter = await topoViewerPage.getEdgeCount(); - const nodeIdsAfter = await topoViewerPage.getNodeIds(); - const pastedNodeIds = nodeIdsAfter.filter((id: string) => !nodeIdsBefore.includes(id)); - - expect(nodeCountAfter - nodeCountBefore).toBe(2); - expect(edgeCountAfter - edgeCountBefore).toBe(1); - - // Undo until pasted nodes are removed. - let currentNodeCount = await topoViewerPage.getNodeCount(); - let steps = 0; - while (currentNodeCount > nodeCountBefore && steps < 6) { - await topoViewerPage.undo(); - await page.waitForTimeout(500); - currentNodeCount = await topoViewerPage.getNodeCount(); - steps++; - } - - expect(await topoViewerPage.getEdgeCount()).toBe(edgeCountBefore); - expect(currentNodeCount).toBe(nodeCountBefore); - - const nodeIdsAfterUndo = await topoViewerPage.getNodeIds(); - for (const pastedId of pastedNodeIds) { - expect(nodeIdsAfterUndo).not.toContain(pastedId); - } - }); - - test("9. Copy/paste group with batched undo", async ({ page, topoViewerPage }) => { - await setup(topoViewerPage); - await createInitialNodes(page, topoViewerPage); - await createInitialLinks(page, topoViewerPage); - await topoViewerPage.fit(); - - // Create a group with router2 + router3. - await topoViewerPage.selectNode("router2"); - await page.waitForTimeout(100); - const router3Box = await topoViewerPage.getNodeBoundingBox("router3"); - expect(router3Box).not.toBeNull(); - await shiftClick(page, router3Box!.x + router3Box!.width / 2, router3Box!.y + router3Box!.height / 2); - await page.waitForTimeout(200); - - await topoViewerPage.createGroup(); - await page.waitForTimeout(700); - - const groupIds = await topoViewerPage.getGroupIds(); - expect(groupIds.length).toBeGreaterThan(0); - - const groupCountBefore = await topoViewerPage.getGroupCount(); - const nodeCountBefore = await topoViewerPage.getNodeCount(); - const nodeIdsBefore = await topoViewerPage.getNodeIds(); - - await topoViewerPage.selectGroup(groupIds[0]); - await page.waitForTimeout(200); - await topoViewerPage.copy(); - await topoViewerPage.paste(); - await page.waitForTimeout(1200); - - const nodeIdsAfter = await topoViewerPage.getNodeIds(); - const newNodeIds = nodeIdsAfter.filter((id: string) => !nodeIdsBefore.includes(id)); - expect(newNodeIds.length).toBeGreaterThan(0); - - // Undo until pasted group and nodes are removed. - let currentGroupCount = await topoViewerPage.getGroupCount(); - let currentNodeCount = await topoViewerPage.getNodeCount(); - let steps = 0; - while ((currentGroupCount > groupCountBefore || currentNodeCount > nodeCountBefore) && steps < 10) { - await topoViewerPage.undo(); - await page.waitForTimeout(500); - currentGroupCount = await topoViewerPage.getGroupCount(); - currentNodeCount = await topoViewerPage.getNodeCount(); - steps++; - } - - expect(currentGroupCount).toBe(groupCountBefore); - expect(currentNodeCount).toBe(nodeCountBefore); - }); - - test("10. Persistence verification after reload", async ({ page, topoViewerPage }) => { - await setup(topoViewerPage); - await createInitialNodes(page, topoViewerPage); - await createInitialLinks(page, topoViewerPage); - - // Rename router1 - await topoViewerPage.fit(); - await page.waitForTimeout(500); - await clickNode(page, "router1"); - await expect(page.getByText("Node Editor", { exact: true })).toBeVisible({ timeout: 5000 }); - await page.locator(SEL_NODE_NAME).fill(CORE_ROUTER); - await page.locator(SEL_APPLY_BTN).click(); - await returnToPalette(page); - await page.waitForTimeout(500); - - // Reload file - await topoViewerPage.gotoFile(TOPOLOGY_FILE); - await topoViewerPage.waitForCanvasReady(); - await page.waitForTimeout(700); - - const nodeIds = await topoViewerPage.getNodeIds(); - for (const nodeId of [CORE_ROUTER, "router2", "router3", "router4"]) { - expect(nodeIds).toContain(nodeId); - } - expect(await topoViewerPage.getEdgeCount()).toBeGreaterThanOrEqual(4); - - const yaml = await topoViewerPage.getYamlFromFile(TOPOLOGY_FILE); - expect(yaml).toContain(CORE_ROUTER_YAML_KEY); - }); - - test("11. Nested groups", async ({ page, topoViewerPage }) => { - await setup(topoViewerPage); - await createInitialNodes(page, topoViewerPage); - await topoViewerPage.fit(); - await page.waitForTimeout(500); - - const groupCountBefore = await topoViewerPage.getGroupCount(); - - // Outer group: router2, router3, router4 - await topoViewerPage.selectNode("router2"); - await page.waitForTimeout(100); - const router3Box = await topoViewerPage.getNodeBoundingBox("router3"); - const router4Box = await topoViewerPage.getNodeBoundingBox("router4"); - expect(router3Box).not.toBeNull(); - expect(router4Box).not.toBeNull(); - await shiftClick(page, router3Box!.x + router3Box!.width / 2, router3Box!.y + router3Box!.height / 2); - await shiftClick(page, router4Box!.x + router4Box!.width / 2, router4Box!.y + router4Box!.height / 2); - await page.waitForTimeout(200); - - await topoViewerPage.createGroup(); - await expect.poll(() => topoViewerPage.getGroupCount(), { timeout: 10000 }).toBe(groupCountBefore + 1); - const outerGroupIds = await topoViewerPage.getGroupIds(); - const outerGroupId = outerGroupIds[outerGroupIds.length - 1]; - - // Inner group: router3 - await page.keyboard.press("Escape"); - await page.waitForTimeout(200); - await topoViewerPage.selectNode("router3"); - await page.waitForTimeout(200); - await topoViewerPage.createGroup(); - - await expect.poll(() => topoViewerPage.getGroupCount(), { timeout: 10000 }).toBe(groupCountBefore + 2); - const innerGroupIds = await topoViewerPage.getGroupIds(); - const innerGroupId = innerGroupIds.find((id: string) => id !== outerGroupId); - expect(innerGroupId).toBeDefined(); - - const annotations = await topoViewerPage.getAnnotationsFromFile(TOPOLOGY_FILE); - const innerGroup = annotations.groupStyleAnnotations?.find((g: any) => g.id === innerGroupId); - expect(innerGroup).toBeDefined(); - expect(innerGroup?.parentId).toBe(outerGroupId); - }); -}); diff --git a/test/e2e/specs/geo-map.spec.ts b/test/e2e/specs/geo-map.spec.ts deleted file mode 100644 index d8ae6301a..000000000 --- a/test/e2e/specs/geo-map.spec.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { test, expect } from "../fixtures/topoviewer"; - -/** - * GeoMap E2E Tests - * - * Tests the GeoMap (geographic layout) functionality including: - * - Switching to geo layout - * - Node geo coordinate assignment - * - Dragging nodes in geo mode updates their lat/lng - */ -const TEST_FILE = "simple.clab.yml"; -const GEO_LAYOUT = "geo"; -const GEO_MAP_CANVAS_SELECTOR = "#react-topoviewer-geo-map canvas"; - -test.describe("GeoMap Layout", () => { - const getNodeGeoFromStore = async (page: any, nodeId: string) => { - return page.evaluate((id: string) => { - const dev = (window as any).__DEV__; - const rf = dev?.rfInstance; - if (rf === undefined || rf === null) return null; - const node = (rf.getNodes?.() ?? []).find((n: any) => n.id === id); - const geo = node?.data?.geoCoordinates; - if (typeof geo !== "object" || geo === null) return null; - const lat = Reflect.get(geo, "lat"); - const lng = Reflect.get(geo, "lng"); - if (typeof lat !== "number" || typeof lng !== "number") return null; - return { lat, lng }; - }, nodeId); - }; - - const getNodeGeoFromFile = async (topoViewerPage: any, nodeId: string) => { - const annotations = await topoViewerPage.getAnnotationsFromFile(TEST_FILE); - const nodeAnnotation = annotations.nodeAnnotations?.find( - (ann: { id: string }) => ann.id === nodeId - ) as { geoCoordinates?: { lat: number; lng: number } } | undefined; - return nodeAnnotation?.geoCoordinates ?? null; - }; - - const expectGeoClose = ( - actual: { lat: number; lng: number } | null, - expected: { lat: number; lng: number } | null - ) => { - expect(actual).not.toBeNull(); - expect(expected).not.toBeNull(); - expect(actual!.lat).toBeCloseTo(expected!.lat, 5); - expect(actual!.lng).toBeCloseTo(expected!.lng, 5); - }; - - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile(TEST_FILE); - await topoViewerPage.waitForCanvasReady(); - }); - - test("geo layout enables map mode and assigns geo coordinates", async ({ - page, - topoViewerPage - }) => { - // Enable geo layout - await page.evaluate((layout) => { - const dev = (window as any).__DEV__; - if (typeof dev?.setLayout === "function") { - dev.setLayout(layout); - } else { - throw new Error("setLayout not available"); - } - }, GEO_LAYOUT); - - await page.waitForSelector(GEO_MAP_CANVAS_SELECTOR); - - // Geo layout should be active - const isGeoLayout = await page.evaluate(() => { - return (window as any).__DEV__?.isGeoLayout?.() ?? false; - }); - expect(isGeoLayout).toBe(true); - - // Verify nodes have geo coordinates assigned (wait for async assignment) - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThan(0); - - // Wait for geo coordinates to be assigned (async after map loads) - await expect - .poll(() => getNodeGeoFromStore(page, nodeIds[0]), { - timeout: 5000, - message: "geo coordinates should be assigned" - }) - .not.toBeNull(); - }); - - test("dragging node in geo layout updates geo coordinates only", async ({ - page, - topoViewerPage - }) => { - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - // Use specific known node ID (simple.clab.yml has srl1 and srl2) - const testNodeId = "srl2"; - - // Get original annotation position BEFORE enabling geo layout - // This is the preset position that should NOT change when dragging in geo mode - const originalAnnotations = await topoViewerPage.getAnnotationsFromFile(TEST_FILE); - const originalNodeAnnotation = originalAnnotations.nodeAnnotations?.find( - (ann: { id: string }) => ann.id === testNodeId - ) as - | { - id: string; - position?: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - } - | undefined; - const originalPosition = originalNodeAnnotation?.position; - console.log(`[DEBUG] Original annotation position: ${JSON.stringify(originalPosition)}`); - - // Enable geo layout - await page.evaluate((layout) => { - const dev = (window as any).__DEV__; - dev?.setLayout?.(layout); - }, GEO_LAYOUT); - - await page.waitForSelector(GEO_MAP_CANVAS_SELECTOR); - - // Capture initial node position from React Flow - const initialPosition = await topoViewerPage.getNodePosition(testNodeId); - console.log(`[DEBUG] Initial position: (${initialPosition.x}, ${initialPosition.y})`); - - // Capture console errors during drag - const consoleErrors: string[] = []; - page.on("console", (msg) => { - if (msg.type() === "error") { - consoleErrors.push(msg.text()); - } - }); - - // Perform the drag - this would trigger the bug before the fix - await topoViewerPage.dragNode(testNodeId, { x: 150, y: 75 }); - - // Wait for drag to complete and persistence to happen (debounce + file write) - await page.waitForTimeout(1500); - - // Check for errors - specifically the "Cannot read properties of undefined (reading 'group')" error - const relevantErrors = consoleErrors.filter( - (err) => - err.includes("Cannot read properties of undefined (reading 'group')") || - err.includes("isNode") - ); - expect(relevantErrors).toHaveLength(0); - - // Position should have changed (node was dragged) - const updatedPosition = await topoViewerPage.getNodePosition(testNodeId); - console.log(`[DEBUG] Updated position: (${updatedPosition.x}, ${updatedPosition.y})`); - expect(updatedPosition.x).not.toBeCloseTo(initialPosition.x, 0); - - // Verify geo coordinates were added to annotations - const annotations = await topoViewerPage.getAnnotationsFromFile(TEST_FILE); - - expect(annotations.nodeAnnotations).toBeDefined(); - expect(annotations.nodeAnnotations!.length).toBeGreaterThan(0); - - // Find the annotation for the dragged node - const nodeAnnotation = annotations.nodeAnnotations!.find( - (ann: { id: string }) => ann.id === testNodeId - ) as - | { - id: string; - position?: { x: number; y: number }; - geoCoordinates?: { lat: number; lng: number }; - } - | undefined; - - console.log(`[DEBUG] Annotation for ${testNodeId}: ${JSON.stringify(nodeAnnotation)}`); - - expect(nodeAnnotation).toBeDefined(); - expect(nodeAnnotation!.geoCoordinates).toBeDefined(); - - // CRITICAL: Verify that the preset x/y position did NOT change - // In GeoMap mode, only geo coordinates should be updated, not the preset position - // If there was no original position, there should still be no position after drag - console.log(`[DEBUG] Final annotation position: ${JSON.stringify(nodeAnnotation!.position)}`); - if (originalPosition) { - // Original had position - it should remain unchanged - expect(nodeAnnotation!.position).toBeDefined(); - expect(nodeAnnotation!.position!.x).toBe(originalPosition.x); - expect(nodeAnnotation!.position!.y).toBe(originalPosition.y); - } else { - // Original had no position - should still have no position (not added by geo drag) - expect(nodeAnnotation!.position).toBeUndefined(); - } - }); - - test("clicking map background deselects selected nodes and edges", async ({ - page, - topoViewerPage - }) => { - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - await page.evaluate((layout) => { - const dev = (window as any).__DEV__; - dev?.setLayout?.(layout); - }, GEO_LAYOUT); - - await page.waitForSelector(GEO_MAP_CANVAS_SELECTOR); - - await topoViewerPage.selectNode("srl1"); - await expect - .poll(async () => (await topoViewerPage.getSelectedNodeIds()).includes("srl1"), { - timeout: 3000, - message: "node should be selected before map click" - }) - .toBe(true); - - await page.click(GEO_MAP_CANVAS_SELECTOR, { position: { x: 10, y: 10 } }); - await expect - .poll(async () => topoViewerPage.getSelectedNodeIds(), { - timeout: 3000, - message: "map click should clear selected nodes" - }) - .toEqual([]); - - const edgeIds = await topoViewerPage.getEdgeIds(); - expect(edgeIds.length).toBeGreaterThan(0); - const edgeId = edgeIds[0]; - await topoViewerPage.selectEdge(edgeId); - await expect - .poll(async () => (await topoViewerPage.getSelectedEdgeIds()).includes(edgeId), { - timeout: 3000, - message: "edge should be selected before map click" - }) - .toBe(true); - - await page.click(GEO_MAP_CANVAS_SELECTOR, { position: { x: 20, y: 20 } }); - await expect - .poll(async () => topoViewerPage.getSelectedEdgeIds(), { - timeout: 3000, - message: "map click should clear selected edges" - }) - .toEqual([]); - }); - - test("undo/redo restores geo coordinates in store and annotations", async ({ - page, - topoViewerPage - }) => { - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - - const testNodeId = "srl2"; - - await page.evaluate((layout) => { - const dev = (window as any).__DEV__; - dev?.setLayout?.(layout); - }, GEO_LAYOUT); - - await page.waitForSelector(GEO_MAP_CANVAS_SELECTOR); - - await expect - .poll( - async () => { - return getNodeGeoFromStore(page, testNodeId); - }, - { timeout: 5000, message: "initial geo coordinates should be assigned" } - ) - .not.toBeNull(); - - const initialGeoStore = await getNodeGeoFromStore(page, testNodeId); - const initialGeoFile = await getNodeGeoFromFile(topoViewerPage, testNodeId); - expectGeoClose(initialGeoFile, initialGeoStore); - - await topoViewerPage.dragNode(testNodeId, { x: 160, y: 90 }); - await page.waitForTimeout(1500); - - const movedGeoStore = await getNodeGeoFromStore(page, testNodeId); - const movedGeoFile = await getNodeGeoFromFile(topoViewerPage, testNodeId); - expect(movedGeoStore).not.toBeNull(); - expect(movedGeoFile).not.toBeNull(); - expect(movedGeoStore!.lat).not.toBeCloseTo(initialGeoStore!.lat, 5); - expect(movedGeoStore!.lng).not.toBeCloseTo(initialGeoStore!.lng, 5); - expectGeoClose(movedGeoFile, movedGeoStore); - - await expect.poll(() => topoViewerPage.canUndo(), { timeout: 3000 }).toBe(true); - - await topoViewerPage.undo(); - await expect.poll(() => topoViewerPage.canRedo(), { timeout: 3000 }).toBe(true); - - const undoGeoStore = await getNodeGeoFromStore(page, testNodeId); - const undoGeoFile = await getNodeGeoFromFile(topoViewerPage, testNodeId); - expectGeoClose(undoGeoStore, initialGeoStore); - expectGeoClose(undoGeoFile, initialGeoStore); - - await topoViewerPage.redo(); - await page.waitForTimeout(800); - - const redoGeoStore = await getNodeGeoFromStore(page, testNodeId); - const redoGeoFile = await getNodeGeoFromFile(topoViewerPage, testNodeId); - expectGeoClose(redoGeoStore, movedGeoStore); - expectGeoClose(redoGeoFile, movedGeoStore); - }); - - test("disabling geo layout restores normal view", async ({ page, topoViewerPage }) => { - // Enable geo layout - await page.evaluate((layout) => { - const dev = (window as any).__DEV__; - dev?.setLayout?.(layout); - }, GEO_LAYOUT); - - await page.waitForTimeout(200); - - // Verify geo layout is active - let isGeoLayout = await page.evaluate(() => { - return (window as any).__DEV__?.isGeoLayout?.() ?? false; - }); - expect(isGeoLayout).toBe(true); - - // Switch back to preset layout - await page.evaluate(() => { - const dev = (window as any).__DEV__; - dev?.setLayout?.("preset"); - }); - - await page.waitForTimeout(1000); - - // Verify geo layout is not active after switching back - isGeoLayout = await page.evaluate(() => { - return (window as any).__DEV__?.isGeoLayout?.() ?? false; - }); - expect(isGeoLayout).toBe(false); - - // Canvas should still be functional - const nodeCount = await topoViewerPage.getNodeCount(); - expect(nodeCount).toBeGreaterThan(0); - }); -}); diff --git a/test/e2e/specs/group-operations.spec.ts b/test/e2e/specs/group-operations.spec.ts deleted file mode 100644 index b619d761f..000000000 --- a/test/e2e/specs/group-operations.spec.ts +++ /dev/null @@ -1,519 +0,0 @@ -import type { Page } from "@playwright/test"; - -import { test, expect } from "../fixtures/topoviewer"; -import { shiftClick, rightClick } from "../helpers/react-flow-helpers"; - -// Test file names -const TOPOLOGY_FILE = "empty.clab.yml"; -const SPINE_LEAF_FILE = "spine-leaf.clab.yml"; -const DATACENTER_FILE = "datacenter.clab.yml"; - -const SEL_PANEL_APPLY_BTN = '[data-testid="panel-apply-btn"]'; -const SEL_PANEL_TOGGLE_BTN = '[data-testid="panel-toggle-btn"]'; -const SEL_CONTEXT_PANEL = '[data-testid="context-panel"]'; - -type NodeBox = { x: number; y: number; width: number; height: number }; - -type GroupSelectionApi = { - clearSelection: () => Promise; - selectNode: (nodeId: string) => Promise; - getNodeBoundingBox: (nodeId: string) => Promise; -}; - -type GroupCreationApi = GroupSelectionApi & { - createGroup: () => Promise; - getGroupIds: () => Promise; - getGroupCount: () => Promise; - getSelectedNodeIds: () => Promise; -}; - -type AnnotationsApi = { - getAnnotationsFromFile: (filename: string) => Promise<{ - nodeAnnotations?: Array<{ id: string; groupId?: string; group?: string; level?: string }>; - freeTextAnnotations?: Array<{ id: string; text: string; groupId?: string }>; - freeShapeAnnotations?: Array<{ id: string; shapeType: string; groupId?: string }>; - groupStyleAnnotations?: Array<{ - id: string; - name: string; - parentId?: string; - position?: { x: number; y: number }; - zIndex?: number; - level?: string; - }>; - }>; -}; - -type TopoViewerPage = GroupCreationApi & - AnnotationsApi & { - resetFiles: () => Promise; - gotoFile: (filename: string) => Promise; - waitForCanvasReady: () => Promise; - setEditMode: () => Promise; - unlock: () => Promise; - createNode: ( - nodeId: string, - position: { x: number; y: number }, - kind?: string - ) => Promise; - fit: () => Promise; - undo: () => Promise; - writeAnnotationsFile: (filename: string, content: object) => Promise; - getNodeIds: () => Promise; - }; - -async function selectNodes(page: Page, topoViewerPage: GroupSelectionApi, nodeIds: string[]) { - await topoViewerPage.clearSelection(); - // Use React Flow selection directly for stability: group creation uses rf node.selected. - await page.evaluate((ids) => { - const dev = (window as any).__DEV__; - dev?.selectNodesForClipboard?.(ids); - // Keep TopoViewerContext in sync when available (single-select only). - if (typeof dev?.selectNode === "function") dev.selectNode(ids[0] ?? null); - }, nodeIds); - - await page.waitForTimeout(200); -} - -async function createGroupFromNodes( - page: Page, - topoViewerPage: GroupCreationApi, - nodeIds: string[] -): Promise { - const groupIdsBefore = await topoViewerPage.getGroupIds(); - await selectNodes(page, topoViewerPage, nodeIds); - - await topoViewerPage.createGroup(); - - await expect - .poll(() => topoViewerPage.getGroupCount(), { timeout: 5000 }) - .toBe(groupIdsBefore.length + 1); - - const groupIdsAfter = await topoViewerPage.getGroupIds(); - const newGroupId = groupIdsAfter.find((id) => !groupIdsBefore.includes(id)); - expect(newGroupId).toBeDefined(); - return newGroupId!; -} - -async function openGroupContextMenu(page: Page, groupId: string) { - const groupNode = page.locator(`[data-testid="group-node-${groupId}"]`); - await groupNode.waitFor({ state: "visible", timeout: 5000 }); - const box = await groupNode.boundingBox(); - expect(box).not.toBeNull(); - const clickX = box!.x + Math.min(10, box!.width / 4); - const clickY = box!.y + box!.height / 2; - await rightClick(page, clickX, clickY); -} - -async function applyAndBack(page: Page) { - const apply = page.locator(SEL_PANEL_APPLY_BTN); - const hasUnsavedChanges = await apply.isVisible().catch(() => false); - if (hasUnsavedChanges) { - // In dev mode, a floating dev toggle can intercept pointer clicks on footer buttons. - // Keyboard activation avoids that flake without weakening assertions. - await apply.focus(); - await page.keyboard.press("Enter"); - await page.waitForTimeout(300); - } - - // Return to palette by toggling panel closed/open. - const toggle = page.locator(SEL_PANEL_TOGGLE_BTN); - await expect(toggle).toBeVisible({ timeout: 3000 }); - await toggle.click(); - await page.waitForTimeout(200); - await toggle.click(); - await expect(page.getByPlaceholder("Search nodes...")).toBeVisible({ timeout: 5000 }); -} - -function findById(items: T[], id: string): T | undefined { - return items.find((item) => item.id === id); -} - -type GroupPromotionSnapshot = { - innerExists: boolean; - childParent: string | null; - nodeCGroup: string | null; - nodeDGroup: string | null; - textGroup: string | null; - shapeGroup: string | null; -}; - -async function getGroupPromotionSnapshot( - api: TopoViewerPage, - ids: { - innerGroupId: string; - childGroupId: string; - textAnnotationId: string; - shapeAnnotationId: string; - } -): Promise { - const annotations = await api.getAnnotationsFromFile(TOPOLOGY_FILE); - const groups = annotations.groupStyleAnnotations ?? []; - const nodes = annotations.nodeAnnotations ?? []; - const texts = annotations.freeTextAnnotations ?? []; - const shapes = annotations.freeShapeAnnotations ?? []; - - return { - innerExists: groups.some((group) => group.id === ids.innerGroupId), - childParent: findById(groups, ids.childGroupId)?.parentId ?? null, - nodeCGroup: findById(nodes, "node-c")?.groupId ?? null, - nodeDGroup: findById(nodes, "node-d")?.groupId ?? null, - textGroup: findById(texts, ids.textAnnotationId)?.groupId ?? null, - shapeGroup: findById(shapes, ids.shapeAnnotationId)?.groupId ?? null - }; -} - -test.describe("Group Operations", () => { - test.beforeEach(async ({ topoViewerPage }) => { - await topoViewerPage.resetFiles(); - await topoViewerPage.gotoFile("simple.clab.yml"); - await topoViewerPage.waitForCanvasReady(); - await topoViewerPage.setEditMode(); - await topoViewerPage.unlock(); - }); - - test("gets group IDs", async ({ topoViewerPage }) => { - const groupIds = await topoViewerPage.getGroupIds(); - const groupCount = await topoViewerPage.getGroupCount(); - expect(groupIds.length).toBe(groupCount); - }); - - test("creates group via Ctrl+G with selected nodes", async ({ page, topoViewerPage }) => { - const initialGroupCount = await topoViewerPage.getGroupCount(); - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThanOrEqual(2); - - await topoViewerPage.selectNode(nodeIds[0]); - - const secondNodeBox = await topoViewerPage.getNodeBoundingBox(nodeIds[1]); - expect(secondNodeBox).not.toBeNull(); - await shiftClick( - page, - secondNodeBox!.x + secondNodeBox!.width / 2, - secondNodeBox!.y + secondNodeBox!.height / 2 - ); - await page.waitForTimeout(200); - - expect((await topoViewerPage.getSelectedNodeIds()).length).toBe(2); - - await topoViewerPage.createGroup(); - await expect.poll(() => topoViewerPage.getGroupCount(), { timeout: 5000 }).toBe( - initialGroupCount + 1 - ); - }); - - test("group persists after all members are deleted", async ({ page, topoViewerPage }) => { - const nodeIds = await topoViewerPage.getNodeIds(); - expect(nodeIds.length).toBeGreaterThanOrEqual(2); - - const initialGroupCount = await topoViewerPage.getGroupCount(); - const node1 = nodeIds[0]; - const node2 = nodeIds[1]; - - await topoViewerPage.selectNode(node1); - const secondNodeBox = await topoViewerPage.getNodeBoundingBox(node2); - expect(secondNodeBox).not.toBeNull(); - await shiftClick( - page, - secondNodeBox!.x + secondNodeBox!.width / 2, - secondNodeBox!.y + secondNodeBox!.height / 2 - ); - await page.waitForTimeout(200); - - await topoViewerPage.createGroup(); - await expect.poll(() => topoViewerPage.getGroupCount(), { timeout: 5000 }).toBe( - initialGroupCount + 1 - ); - - // Delete both members. - await topoViewerPage.selectNode(node1); - await page.keyboard.press("Delete"); - await page.waitForTimeout(300); - - await topoViewerPage.selectNode(node2); - await page.keyboard.press("Delete"); - await page.waitForTimeout(300); - - // Group should persist even after all members are deleted. - await expect.poll(() => topoViewerPage.getGroupCount(), { timeout: 5000 }).toBe( - initialGroupCount + 1 - ); - }); -}); - -test.describe("Group Operations - Membership promotions", () => { - test.beforeEach(async ({ topoViewerPage }) => { - const api = topoViewerPage as TopoViewerPage; - await api.resetFiles(); - await api.gotoFile(TOPOLOGY_FILE); - await api.waitForCanvasReady(); - await api.setEditMode(); - await api.unlock(); - }); - - test("deleting a nested group promotes members, child groups, and annotations", async ({ - page, - topoViewerPage - }) => { - const api = topoViewerPage as TopoViewerPage; - const nodes = [ - { id: "node-a", position: { x: 100, y: 100 } }, - { id: "node-b", position: { x: 500, y: 400 } }, - { id: "node-c", position: { x: 200, y: 200 } }, - { id: "node-d", position: { x: 420, y: 320 } } - ]; - - for (const node of nodes) { - await api.createNode(node.id, node.position); - } - await api.fit(); - - const outerGroupId = await createGroupFromNodes(page, api, ["node-a", "node-b"]); - const innerGroupId = await createGroupFromNodes(page, api, ["node-c", "node-d"]); - - await expect - .poll( - async () => { - const annotations = await api.getAnnotationsFromFile(TOPOLOGY_FILE); - return annotations.groupStyleAnnotations?.find((g) => g.id === innerGroupId)?.parentId ?? null; - }, - { timeout: 5000 } - ) - .toBe(outerGroupId); - - const annotationsBeforeWrite = await api.getAnnotationsFromFile(TOPOLOGY_FILE); - const innerGroup = annotationsBeforeWrite.groupStyleAnnotations?.find((g) => g.id === innerGroupId); - expect(innerGroup).toBeDefined(); - - const nodeAnnotations = [...(annotationsBeforeWrite.nodeAnnotations ?? [])]; - const ensureNodeMembership = (nodeId: string) => { - const existing = nodeAnnotations.find((node) => node.id === nodeId); - if (existing) { - existing.groupId = innerGroupId; - existing.group = innerGroup!.name; - existing.level = innerGroup!.level; - return; - } - nodeAnnotations.push({ id: nodeId, groupId: innerGroupId, group: innerGroup!.name, level: innerGroup!.level }); - }; - ensureNodeMembership("node-c"); - ensureNodeMembership("node-d"); - - const childGroupId = `group-${Date.now()}`; - const childGroup = { - ...innerGroup!, - id: childGroupId, - name: `${innerGroup!.name}-child`, - parentId: innerGroupId, - position: { ...innerGroup!.position }, - width: 120, - height: 80, - zIndex: (innerGroup!.zIndex ?? 0) + 1 - }; - - const textAnnotationId = `text-${Date.now()}`; - const shapeAnnotationId = `shape-${Date.now()}`; - const updatedAnnotations = { - ...annotationsBeforeWrite, - nodeAnnotations, - groupStyleAnnotations: [...(annotationsBeforeWrite.groupStyleAnnotations ?? []), childGroup], - freeTextAnnotations: [ - ...(annotationsBeforeWrite.freeTextAnnotations ?? []), - { id: textAnnotationId, text: "Inner note", position: { x: 260, y: 180 }, groupId: innerGroupId } - ], - freeShapeAnnotations: [ - ...(annotationsBeforeWrite.freeShapeAnnotations ?? []), - { id: shapeAnnotationId, shapeType: "rectangle", position: { x: 260, y: 200 }, width: 80, height: 50, groupId: innerGroupId } - ] - }; - - await api.writeAnnotationsFile(TOPOLOGY_FILE, updatedAnnotations); - await api.gotoFile(TOPOLOGY_FILE); - await api.waitForCanvasReady(); - await api.setEditMode(); - await api.unlock(); - await api.fit(); - - await expect - .poll(() => api.getGroupIds(), { timeout: 5000 }) - .toEqual(expect.arrayContaining([outerGroupId, innerGroupId, childGroupId])); - - await expect - .poll( - async () => { - const annotations = await api.getAnnotationsFromFile(TOPOLOGY_FILE); - return annotations.groupStyleAnnotations?.find((g) => g.id === childGroupId)?.parentId ?? null; - }, - { timeout: 5000 } - ) - .toBe(innerGroupId); - - await openGroupContextMenu(page, innerGroupId); - await page.locator('[data-testid="context-menu-item-delete-group"]').click(); - - await expect - .poll( - () => - getGroupPromotionSnapshot(api, { - innerGroupId, - childGroupId, - textAnnotationId, - shapeAnnotationId - }), - { timeout: 5000 } - ) - .toEqual({ - innerExists: false, - childParent: outerGroupId, - nodeCGroup: outerGroupId, - nodeDGroup: outerGroupId, - textGroup: outerGroupId, - shapeGroup: outerGroupId - }); - - await api.undo(); - - await expect - .poll( - () => - getGroupPromotionSnapshot(api, { - innerGroupId, - childGroupId, - textAnnotationId, - shapeAnnotationId - }), - { timeout: 5000 } - ) - .toEqual({ - innerExists: true, - childParent: innerGroupId, - nodeCGroup: innerGroupId, - nodeDGroup: innerGroupId, - textGroup: innerGroupId, - shapeGroup: innerGroupId - }); - }); - - test("allows duplicate group names with distinct ids", async ({ page, topoViewerPage }) => { - const api = topoViewerPage as TopoViewerPage; - const nodes = [ - { id: "dup-a", position: { x: 100, y: 100 } }, - { id: "dup-b", position: { x: 180, y: 100 } }, - { id: "dup-c", position: { x: 500, y: 400 } }, - { id: "dup-d", position: { x: 580, y: 400 } } - ]; - - for (const node of nodes) { - await api.createNode(node.id, node.position); - } - await api.fit(); - - const firstGroupId = await createGroupFromNodes(page, api, ["dup-a", "dup-b"]); - const secondGroupId = await createGroupFromNodes(page, api, ["dup-c", "dup-d"]); - - const annotations = await api.getAnnotationsFromFile(TOPOLOGY_FILE); - const firstGroup = annotations.groupStyleAnnotations?.find((g) => g.id === firstGroupId); - expect(firstGroup).toBeDefined(); - - await openGroupContextMenu(page, secondGroupId); - await page.locator('[data-testid="context-menu-item-edit-group"]').click(); - - await expect(page.getByText("Edit Group", { exact: true })).toBeVisible({ timeout: 5000 }); - // Group editor uses a Typography label, not a native