├── .prettierignore ├── src ├── topoViewer │ ├── webview │ │ ├── types │ │ │ ├── cytoscape-cola.d.ts │ │ │ ├── cytoscape-leaf.d.ts │ │ │ ├── cytoscape-svg.d.ts │ │ │ ├── cytoscape-grid-guide.d.ts │ │ │ ├── index.d.ts │ │ │ ├── topoViewerState.ts │ │ │ ├── linkLabelMode.ts │ │ │ └── globals.d.ts │ │ ├── features │ │ │ ├── groups │ │ │ │ └── LabelPositions.ts │ │ │ ├── canvas │ │ │ │ ├── ZoomToFitManager.ts │ │ │ │ ├── EventHandlers.ts │ │ │ │ ├── CytoscapeFactory.ts │ │ │ │ ├── DummyLinksManager.ts │ │ │ │ └── GeoUtils.ts │ │ │ ├── nodes │ │ │ │ └── NodeUtils.ts │ │ │ └── node-editor │ │ │ │ └── ChangeTrackingManager.ts │ │ ├── assets │ │ │ ├── images │ │ │ │ ├── wireshark_bold.svg │ │ │ │ └── containerlab.svg │ │ │ └── templates │ │ │ │ ├── main.html │ │ │ │ └── partials │ │ │ │ ├── viewport-drawer-capture-sceenshoot.html │ │ │ │ ├── viewport-drawer-topology-overview.html │ │ │ │ ├── panel-node.html │ │ │ │ └── scripts.html │ │ ├── core │ │ │ ├── managerRegistry.ts │ │ │ └── nodeConfig.ts │ │ ├── app │ │ │ ├── state.ts │ │ │ └── entry.ts │ │ └── ui │ │ │ ├── IdUtils.ts │ │ │ └── IconDropdownRenderer.ts │ ├── index.ts │ ├── shared │ │ ├── constants │ │ │ └── interfacePatterns.ts │ │ ├── utilities │ │ │ ├── AsyncUtils.ts │ │ │ ├── PerformanceMonitor.ts │ │ │ └── LinkTypes.ts │ │ └── types │ │ │ └── topoViewerType.ts │ └── extension │ │ └── services │ │ ├── TreeUtils.ts │ │ ├── DeploymentStateChecker.ts │ │ ├── LabLifecycleService.ts │ │ ├── YamlValidator.ts │ │ └── YamlSettingsManager.ts ├── utils │ ├── clab.ts │ ├── async.ts │ ├── index.ts │ ├── consts.ts │ ├── webview.ts │ └── docker │ │ └── images.ts ├── commands │ ├── openLink.ts │ ├── destroy.ts │ ├── redeploy.ts │ ├── clonePopularRepo.ts │ ├── openLabFile.ts │ ├── deployPopular.ts │ ├── openFolderInNewWindow.ts │ ├── showLogs.ts │ ├── favorite.ts │ ├── index.ts │ ├── addToWorkspace.ts │ ├── nodeActions.ts │ ├── deleteLab.ts │ ├── deploy.ts │ ├── ssh.ts │ ├── runClabAction.ts │ ├── save.ts │ ├── cloneRepo.ts │ ├── edgeshark.ts │ ├── nodeExec.ts │ ├── fcli.ts │ ├── sshxShare.ts │ └── clabCommand.ts ├── types │ └── containerlab.ts ├── treeView │ └── helpFeedbackProvider.ts └── helpers │ ├── filterUtils.ts │ └── popularLabs.ts ├── test ├── helpers │ ├── extension-stub.ts │ ├── extensionLogger-stub.ts │ ├── command-stub.ts │ ├── graph-stub.ts │ ├── command-class-stub.ts │ ├── clabCommand-stub.ts │ ├── utils-stub.ts │ └── vscode-stub.ts ├── tsconfig.json ├── README.md └── unit │ ├── topoViewer │ ├── cytoscapeInstanceFactory.test.ts │ ├── topoViewerAdaptorClab.bridge.test.ts │ ├── managerSvgGenerator.test.ts │ ├── managerGroupManagement.rememberStyle.test.ts │ ├── annotationsManager.noTouch.test.ts │ ├── managerFreeText.rememberStyle.test.ts │ ├── managerCytoscapeBaseStyles.test.ts │ ├── managerCopyPaste.duplicateGroupLabel.test.ts │ ├── managerRegistry.test.ts │ ├── groupManagerBindings.test.ts │ ├── managerGroupManagement.allowDuplicateLabel.test.ts │ ├── saveViewport.cloudNodeGroup.test.ts │ ├── logging │ │ └── logger.webview.test.ts │ └── buildCytoscapeElements.test.ts │ ├── utils │ └── clabSchema.test.ts │ ├── commands │ ├── openFolderInNewWindow.test.ts │ ├── openLabFile.test.ts │ ├── addToWorkspace.test.ts │ └── deploy.test.ts │ ├── extension │ └── refreshSshxSessions.test.ts │ └── treeView │ └── common.test.ts ├── resources ├── exec_cmd.json ├── screenshot.png ├── containerlab.png ├── icons │ ├── partial.svg │ ├── running.svg │ ├── stopped.svg │ ├── undeployed.svg │ ├── wireshark_dark.svg │ ├── wireshark_light.svg │ ├── ethernet-port-dark.svg │ ├── ethernet-port-green.svg │ ├── ethernet-port-light.svg │ └── ethernet-port-red.svg ├── ssh_users.json ├── containerlab-icon-theme.json └── containerlab.svg ├── postcss.config.js ├── .mocharc.json ├── .prettierrc.json ├── .vscode └── launch.json ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── pr.yml ├── .vscodeignore ├── tsconfig.json ├── .gitignore ├── webpack.config.js ├── esbuild.config.js └── eslint.config.mjs /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | *.vsix 5 | mochawesome-report 6 | -------------------------------------------------------------------------------- /src/topoViewer/webview/types/cytoscape-cola.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cytoscape-cola'; 2 | -------------------------------------------------------------------------------- /src/topoViewer/webview/types/cytoscape-leaf.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cytoscape-leaf'; 2 | -------------------------------------------------------------------------------- /src/topoViewer/webview/types/cytoscape-svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cytoscape-svg'; 2 | 3 | -------------------------------------------------------------------------------- /test/helpers/extension-stub.ts: -------------------------------------------------------------------------------- 1 | export const execCmdMapping = { nokia_srlinux: 'sr_cli' }; 2 | -------------------------------------------------------------------------------- /src/topoViewer/webview/types/cytoscape-grid-guide.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cytoscape-grid-guide'; 2 | -------------------------------------------------------------------------------- /resources/exec_cmd.json: -------------------------------------------------------------------------------- 1 | { 2 | "nokia_srlinux": "sr_cli", 3 | "cisco_xrd": "/pkg/bin/xr_cli.sh" 4 | } -------------------------------------------------------------------------------- /resources/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/srl-labs/vscode-containerlab/HEAD/resources/screenshot.png -------------------------------------------------------------------------------- /src/topoViewer/index.ts: -------------------------------------------------------------------------------- 1 | export { TopoViewerEditor as TopoViewer } from './extension/services/EditorProvider'; 2 | -------------------------------------------------------------------------------- /resources/containerlab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/srl-labs/vscode-containerlab/HEAD/resources/containerlab.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /test/helpers/extensionLogger-stub.ts: -------------------------------------------------------------------------------- 1 | export const log = { 2 | info() {}, 3 | debug() {}, 4 | warn() {}, 5 | error() {}, 6 | }; 7 | -------------------------------------------------------------------------------- /src/utils/clab.ts: -------------------------------------------------------------------------------- 1 | export function isClabYamlFile(file: string): boolean { 2 | return file.endsWith('.clab.yml') || file.endsWith('.clab.yaml'); 3 | } 4 | -------------------------------------------------------------------------------- /src/commands/openLink.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | export function openLink(url: string): void { 4 | vscode.env.openExternal(vscode.Uri.parse(url)); 5 | } 6 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "reporter": "mochawesome", 3 | "reporter-option": [ 4 | "reportDir=mochawesome-report", 5 | "reportFilename=report", 6 | "quiet=true" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/async.ts: -------------------------------------------------------------------------------- 1 | // wrapper to sleep() for async functions 2 | export async function delay(ms: number): Promise { 3 | return new Promise(resolve => setTimeout(resolve, ms)) 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "singleQuote": false, 6 | "trailingComma": "none", 7 | "htmlWhitespaceSensitivity": "ignore" 8 | } 9 | -------------------------------------------------------------------------------- /resources/icons/partial.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/running.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/stopped.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/undeployed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/ssh_users.json: -------------------------------------------------------------------------------- 1 | { 2 | "nokia_srlinux": "admin", 3 | "nokia_sros": "admin", 4 | "cisco_xrd": "clab", 5 | "cisco_xr9vk": "clab", 6 | "arista_ceos": "admin", 7 | "juniper_crpd": "root" 8 | } -------------------------------------------------------------------------------- /test/helpers/command-stub.ts: -------------------------------------------------------------------------------- 1 | export const calls: { command: string; terminalName: string }[] = []; 2 | export function execCommandInTerminal(command: string, terminalName: string) { 3 | calls.push({ command, terminalName }); 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './async'; 2 | export * from './consts'; 3 | export * from './docker/docker'; 4 | export * from './docker/images'; 5 | export * from './packetflix'; 6 | export * from './webview'; 7 | export * from './utils'; 8 | export * from './clab'; 9 | -------------------------------------------------------------------------------- /src/topoViewer/webview/features/groups/LabelPositions.ts: -------------------------------------------------------------------------------- 1 | export const GROUP_LABEL_POSITIONS = [ 2 | 'top-center', 3 | 'top-left', 4 | 'top-right', 5 | 'bottom-center', 6 | 'bottom-left', 7 | 'bottom-right' 8 | ] as const; 9 | 10 | export type GroupLabelPosition = typeof GROUP_LABEL_POSITIONS[number]; 11 | 12 | -------------------------------------------------------------------------------- /resources/containerlab-icon-theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "iconDefinitions": { 3 | "clab": { "iconPath": "./containerlab.svg" } 4 | }, 5 | "fileExtensions": { 6 | "clab.yml": "clab", 7 | "clab.yaml": "clab" 8 | }, 9 | "fileNames": { 10 | ".clab.yml": "clab", 11 | ".clab.yaml": "clab" 12 | }, 13 | "folderNames": {}, 14 | "languageIds": {} 15 | } 16 | -------------------------------------------------------------------------------- /src/commands/destroy.ts: -------------------------------------------------------------------------------- 1 | import { ClabLabTreeNode } from "../treeView/common"; 2 | import { runClabAction } from "./runClabAction"; 3 | 4 | export async function destroy(node?: ClabLabTreeNode) { 5 | await runClabAction("destroy", node); 6 | } 7 | 8 | export async function destroyCleanup(node?: ClabLabTreeNode) { 9 | await runClabAction("destroy", node, true); 10 | } 11 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "..", 5 | "outDir": "../out/test", 6 | "resolveJsonModule": true, 7 | "typeRoots": ["../src/topoViewer/webview/types", "../node_modules/@types"] 8 | }, 9 | "include": ["**/*.ts", "../src/topoViewer/webview/types/**/*.d.ts"], 10 | "exclude": [] 11 | } 12 | -------------------------------------------------------------------------------- /src/commands/redeploy.ts: -------------------------------------------------------------------------------- 1 | import { ClabLabTreeNode } from "../treeView/common"; 2 | import { runClabAction } from "./runClabAction"; 3 | 4 | export async function redeploy(node?: ClabLabTreeNode) { 5 | await runClabAction("redeploy", node); 6 | } 7 | 8 | export async function redeployCleanup(node?: ClabLabTreeNode) { 9 | await runClabAction("redeploy", node, true); 10 | } 11 | -------------------------------------------------------------------------------- /src/commands/clonePopularRepo.ts: -------------------------------------------------------------------------------- 1 | import { cloneRepoFromUrl } from './cloneRepo'; 2 | import { pickPopularRepo } from '../helpers/popularLabs'; 3 | 4 | export async function clonePopularRepo() { 5 | const pick = await pickPopularRepo('Clone popular lab', 'Select a repository to clone'); 6 | if (!pick) { 7 | return; 8 | } 9 | await cloneRepoFromUrl((pick as any).repo); 10 | } 11 | -------------------------------------------------------------------------------- /src/topoViewer/shared/constants/interfacePatterns.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_INTERFACE_PATTERNS: Record = { 2 | nokia_srlinux: 'e1-{n}', 3 | nokia_srsim: '1/1/c{n}/1', 4 | nokia_sros: '1/1/{n}', 5 | cisco_xrd: 'Gi0-0-0-{n}', 6 | cisco_xrv: 'Gi0/0/0/{n}', 7 | cisco_xrv9k: 'Gi0/0/0/{n}', 8 | cisco_csr1000v: 'Gi{n}', 9 | cisco_c8000v: 'Gi{n}', 10 | cisco_cat9kv: 'Gi1/0/{n}', 11 | cisco_iol: 'e0/{n}', 12 | }; 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Run Extension", 5 | "type": "extensionHost", 6 | "request": "launch", 7 | "runtimeExecutable": "${execPath}", 8 | "args": [ 9 | "--extensionDevelopmentPath=${workspaceFolder}" 10 | ], 11 | "outFiles": [ 12 | "${workspaceFolder}/dist/**/*.js" 13 | ], 14 | "preLaunchTask": "npm: package", 15 | "cwd": "${workspaceFolder}" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /test/helpers/graph-stub.ts: -------------------------------------------------------------------------------- 1 | export async function notifyCurrentTopoViewerOfCommandSuccess( 2 | _commandType: 'deploy' | 'destroy' | 'redeploy' 3 | ) { 4 | if (_commandType) { 5 | // no-op stub 6 | } 7 | // no-op stub 8 | } 9 | 10 | export async function notifyCurrentTopoViewerOfCommandFailure( 11 | _commandType: 'deploy' | 'destroy' | 'redeploy', 12 | _error?: Error 13 | ) { 14 | // no-op stub - reference params to satisfy linter 15 | if (_commandType && _error) { 16 | // no-op 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/topoViewer/shared/utilities/AsyncUtils.ts: -------------------------------------------------------------------------------- 1 | export function sleep(ms: number): Promise { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | } 4 | 5 | export function debounce( 6 | fn: (...args: TArgs) => unknown, 7 | wait: number 8 | ): (...args: TArgs) => void { 9 | let timeout: ReturnType | null = null; 10 | return (...params: TArgs) => { 11 | if (timeout) clearTimeout(timeout); 12 | timeout = setTimeout(() => fn(...params), wait); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/topoViewer/webview/types/index.d.ts: -------------------------------------------------------------------------------- 1 | // Type declarations for modules without TypeScript definitions 2 | 3 | declare module 'cytoscape-expand-collapse'; 4 | declare module 'cytoscape-svg'; 5 | declare module 'cytoscape-node-edge-html-label'; 6 | declare global { 7 | interface Window { 8 | topoViewerMode?: 'editor' | 'viewer' | string; 9 | updateTopoGridTheme?: (theme: 'light' | 'dark') => void; 10 | // Optional debug logger injected by the webview host 11 | writeTopoDebugLog?: (message: string) => void; 12 | } 13 | } 14 | export {}; 15 | -------------------------------------------------------------------------------- /src/commands/openLabFile.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ClabLabTreeNode } from "../treeView/common"; 3 | 4 | export function openLabFile(node: ClabLabTreeNode) { 5 | if (!node) { 6 | vscode.window.showErrorMessage('No lab node selected.'); 7 | return; 8 | } 9 | 10 | const labPath = node.labPath.absolute; 11 | if (!labPath) { 12 | vscode.window.showErrorMessage('No labPath found.'); 13 | return; 14 | } 15 | 16 | const uri = vscode.Uri.file(labPath); 17 | vscode.commands.executeCommand('vscode.open', uri); 18 | } 19 | -------------------------------------------------------------------------------- /src/commands/deployPopular.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { deploy } from './deploy'; 3 | import { pickPopularRepo } from '../helpers/popularLabs'; 4 | import { ClabLabTreeNode } from '../treeView/common'; 5 | 6 | export async function deployPopularLab() { 7 | const pick = await pickPopularRepo('Deploy popular lab', 'Select a repository to deploy'); 8 | if (!pick) { 9 | return; 10 | } 11 | const node = new ClabLabTreeNode('', vscode.TreeItemCollapsibleState.None, { 12 | absolute: (pick as any).repo, 13 | relative: '', 14 | }); 15 | deploy(node); 16 | } 17 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Unit tests 2 | 3 | This folder holds the Mocha test suite for the Containerlab extension. The tests use simple stubs under `test/helpers` to emulate VS Code APIs so they can run outside of VS Code. 4 | 5 | ## Running the tests 6 | 7 | 1. Install all dependencies with `npm install`. 8 | 2. Compile the test sources via `npm run test:compile`. 9 | 3. Execute `npm test` to run the suite and generate an HTML report in `mochawesome-report`. 10 | 11 | The provided scripts automatically compile the extension sources and output the transpiled test files to `out/test` before running Mocha. 12 | -------------------------------------------------------------------------------- /test/unit/topoViewer/cytoscapeInstanceFactory.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, node */ 2 | /* global describe, it, global */ 3 | import { expect } from 'chai'; 4 | describe('cytoscapeInstanceFactory', () => { 5 | it('creates cytoscape instance with custom options', async () => { 6 | (global as any).window = {} as unknown; 7 | const { createConfiguredCytoscape } = await import('../../../src/topoViewer/webview/features/canvas/CytoscapeFactory'); 8 | const cy = createConfiguredCytoscape(undefined as any, { headless: true, wheelSensitivity: 2 }); 9 | expect((cy as any).options().wheelSensitivity).to.equal(2); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/utils/consts.ts: -------------------------------------------------------------------------------- 1 | export const WIRESHARK_VNC_CTR_NAME_PREFIX="clab_vsc_ws" 2 | export const DEFAULT_WIRESHARK_VNC_DOCKER_PULL_POLICY=ImagePullPolicy.Always 3 | export const DEFAULT_WIRESHARK_VNC_DOCKER_IMAGE="ghcr.io/kaelemc/wireshark-vnc-docker:latest" 4 | export const DEFAULT_ATTACH_SHELL_CMD="sh" 5 | export const DEFAULT_ATTACH_TELNET_PORT=5000 6 | 7 | 8 | export const enum ImagePullPolicy { 9 | Never = 'never', 10 | Missing = 'missing', 11 | Always = 'always', 12 | } 13 | 14 | export const enum ContainerAction { 15 | Start = 'start', 16 | Stop = 'stop', 17 | Pause = 'pause', 18 | Unpause = 'unpause', 19 | } 20 | -------------------------------------------------------------------------------- /test/helpers/command-class-stub.ts: -------------------------------------------------------------------------------- 1 | export const instances: any[] = []; 2 | export type CmdOptions = { command: string; useSpinner?: boolean; terminalName?: string; spinnerMsg?: any }; 3 | export class Command { 4 | public options: CmdOptions; 5 | public executedArgs: string[] | undefined; 6 | constructor(options: CmdOptions) { 7 | this.options = { 8 | command: options.command, 9 | useSpinner: options.useSpinner || false, 10 | terminalName: options.terminalName || 'term', 11 | spinnerMsg: options.spinnerMsg 12 | }; 13 | instances.push(this); 14 | } 15 | execute(args?: string[]): Promise { 16 | this.executedArgs = args; 17 | return Promise.resolve(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/helpers/clabCommand-stub.ts: -------------------------------------------------------------------------------- 1 | export const instances: any[] = []; 2 | 3 | export class ClabCommand { 4 | public action: string; 5 | public node: any; 6 | public spinnerMessages: any; 7 | public runArgs: any[] | undefined; 8 | 9 | constructor(action: string, node: any, spinnerMessages?: any) { 10 | this.action = action; 11 | this.node = node; 12 | this.spinnerMessages = spinnerMessages || { 13 | progressMsg: action === 'deploy' ? 'Deploying Lab... ' : `${action}ing Lab... `, 14 | successMsg: action === 'deploy' ? 'Lab deployed successfully!' : `Lab ${action}ed successfully!` 15 | }; 16 | instances.push(this); 17 | } 18 | 19 | run(args?: string[]) { 20 | this.runArgs = args; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/topoViewer/webview/types/topoViewerState.ts: -------------------------------------------------------------------------------- 1 | import type cytoscape from 'cytoscape'; 2 | import type { LinkLabelMode } from './linkLabelMode'; 3 | 4 | export interface TopoViewerState { 5 | cy: cytoscape.Core | null; 6 | selectedNode: string | null; 7 | selectedEdge: string | null; 8 | linkLabelMode: LinkLabelMode; 9 | nodeContainerStatusVisibility: boolean; 10 | dummyLinksVisible: boolean; 11 | labName: string; 12 | prefixName: string; 13 | multiLayerViewPortState: boolean; 14 | isGeoMapInitialized: boolean; 15 | isPanel01Cy: boolean; 16 | nodeClicked: boolean; 17 | edgeClicked: boolean; 18 | deploymentType: string; 19 | cytoscapeLeafletMap: any; 20 | cytoscapeLeafletLeaf: any; 21 | editorEngine?: any; 22 | } 23 | -------------------------------------------------------------------------------- /src/commands/openFolderInNewWindow.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as path from "path"; 3 | import { ClabLabTreeNode } from "../treeView/common"; 4 | 5 | export async function openFolderInNewWindow(node: ClabLabTreeNode) { 6 | if (!node.labPath.absolute) { 7 | vscode.window.showErrorMessage("No lab path found for this lab."); 8 | return; 9 | } 10 | 11 | // The folder that contains the .clab.(yml|yaml) 12 | const folderPath = path.dirname(node.labPath.absolute); 13 | const uri = vscode.Uri.file(folderPath); 14 | 15 | // Force opening that folder in a brand-new window 16 | await vscode.commands.executeCommand("vscode.openFolder", uri, { 17 | forceNewWindow: true 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/webview.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode" 2 | 3 | export async function tryPostMessage(panel: vscode.WebviewPanel, message: unknown): Promise { 4 | try { 5 | await panel.webview.postMessage(message) 6 | } catch { 7 | // The panel might already be disposed; ignore errors 8 | } 9 | } 10 | 11 | export async function isHttpEndpointReady(url: string, timeoutMs = 4000): Promise { 12 | const controller = new AbortController() 13 | const timeout = setTimeout(() => controller.abort(), timeoutMs) 14 | 15 | try { 16 | const response = await fetch(url, { method: 'GET', signal: controller.signal }) 17 | return response.ok 18 | } catch { 19 | return false 20 | } finally { 21 | clearTimeout(timeout) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/topoViewer/webview/features/canvas/ZoomToFitManager.ts: -------------------------------------------------------------------------------- 1 | import cytoscape from 'cytoscape'; 2 | import { log } from '../../platform/logging/logger'; 3 | import topoViewerState from '../../app/state'; 4 | 5 | /** 6 | * Provides functionality to zoom and fit the Cytoscape viewport across modes. 7 | */ 8 | export class ZoomToFitManager { 9 | public viewportButtonsZoomToFit(cy: cytoscape.Core): void { 10 | const initialZoom = cy.zoom(); 11 | log.info(`Initial zoom level is "${initialZoom}".`); 12 | 13 | cy.fit(); 14 | const currentZoom = cy.zoom(); 15 | log.info(`And now the zoom level is "${currentZoom}".`); 16 | 17 | const layoutMgr = topoViewerState.editorEngine?.layoutAlgoManager || (window as any).layoutManager; 18 | layoutMgr?.cytoscapeLeafletLeaf?.fit(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/commands/showLogs.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { execCommandInTerminal } from "./command"; 3 | import { ClabContainerTreeNode } from "../treeView/common"; 4 | 5 | export function showLogs(node: ClabContainerTreeNode) { 6 | if (!node) { 7 | vscode.window.showErrorMessage('No container node selected.'); 8 | return; 9 | } 10 | const containerId = node.cID; 11 | 12 | if (!containerId) { 13 | vscode.window.showErrorMessage('No containerID for logs.'); 14 | return; 15 | } 16 | 17 | const container = node.name || containerId 18 | 19 | const config = vscode.workspace.getConfiguration("containerlab"); 20 | const runtime = config.get("runtime", "docker"); 21 | execCommandInTerminal( 22 | `${runtime} logs -f ${containerId}`, 23 | `Logs - ${container}` 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | # Development infrastructure 2 | .vscode/** 3 | .github/** 4 | .gitignore 5 | .eslintrc.json 6 | .eslintignore 7 | .yarnrc 8 | tsconfig.json 9 | eslint.config.mjs 10 | webpack.config.js 11 | Makefile 12 | vsc-extension-quickstart.md 13 | 14 | # Source files - only bundle compiled output 15 | src/** 16 | 17 | # Include static HTML assets needed at runtime 18 | !src/webview/*.css 19 | 20 | # If you still need specific source files at runtime, selectively include them with: 21 | node_modules/** 22 | out/** 23 | 24 | # Build artifacts 25 | **/*.map 26 | **/*.ts 27 | *.vsix 28 | 29 | # Test files 30 | test.clab.yml 31 | **/*.test.js 32 | **/__tests__/** 33 | **/__mocks__/** 34 | 35 | #clab files 36 | clab-*/ 37 | *.clab.yml* 38 | labs/ 39 | 40 | #others 41 | .claude/** 42 | legacy-backup/** 43 | AGENTS.md 44 | CLAUDE.md 45 | mochawesome-report/** 46 | patch.sh 47 | report/ -------------------------------------------------------------------------------- /src/types/containerlab.ts: -------------------------------------------------------------------------------- 1 | export interface ClabInterfaceSnapshotEntry { 2 | name: string; 3 | type: string; 4 | state: string; 5 | alias: string; 6 | mac: string; 7 | mtu: number; 8 | ifindex: number; 9 | rxBps?: number; 10 | rxPps?: number; 11 | rxBytes?: number; 12 | rxPackets?: number; 13 | txBps?: number; 14 | txPps?: number; 15 | txBytes?: number; 16 | txPackets?: number; 17 | statsIntervalSeconds?: number; 18 | } 19 | 20 | export type ClabInterfaceStats = Pick< 21 | ClabInterfaceSnapshotEntry, 22 | | 'rxBps' 23 | | 'rxPps' 24 | | 'rxBytes' 25 | | 'rxPackets' 26 | | 'txBps' 27 | | 'txPps' 28 | | 'txBytes' 29 | | 'txPackets' 30 | | 'statsIntervalSeconds' 31 | >; 32 | 33 | export interface ClabInterfaceSnapshot { 34 | name: string; 35 | interfaces: ClabInterfaceSnapshotEntry[]; 36 | } 37 | 38 | -------------------------------------------------------------------------------- /src/commands/favorite.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { ClabLabTreeNode } from '../treeView/common'; 3 | import { favoriteLabs, extensionContext } from '../extension'; 4 | 5 | export async function toggleFavorite(node: ClabLabTreeNode) { 6 | if (!node?.labPath?.absolute) { 7 | return; 8 | } 9 | const absPath = node.labPath.absolute; 10 | if (favoriteLabs.has(absPath)) { 11 | favoriteLabs.delete(absPath); 12 | await extensionContext.globalState.update('favoriteLabs', Array.from(favoriteLabs)); 13 | vscode.window.showInformationMessage('Removed favorite lab'); 14 | } else { 15 | favoriteLabs.add(absPath); 16 | await extensionContext.globalState.update('favoriteLabs', Array.from(favoriteLabs)); 17 | vscode.window.showInformationMessage('Marked lab as favorite'); 18 | } 19 | vscode.commands.executeCommand('containerlab.refresh'); 20 | } 21 | -------------------------------------------------------------------------------- /test/unit/topoViewer/topoViewerAdaptorClab.bridge.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global describe, it */ 3 | import { expect } from 'chai'; 4 | import { TopoViewerAdaptorClab } from '../../../src/topoViewer/extension/services/TopologyAdapter'; 5 | 6 | describe('TopoViewerAdaptorClab bridge nodes', () => { 7 | it('assigns bridge topoViewerRole for bridge kinds', async () => { 8 | const adaptor = new TopoViewerAdaptorClab(); 9 | const yaml = ` 10 | name: test 11 | topology: 12 | nodes: 13 | br1: 14 | kind: bridge 15 | ovs1: 16 | kind: ovs-bridge 17 | `; 18 | const elements = await adaptor.clabYamlToCytoscapeElementsEditor(yaml); 19 | const br = elements.find(el => el.data?.id === 'br1'); 20 | const ovs = elements.find(el => el.data?.id === 'ovs1'); 21 | expect(br?.data?.topoViewerRole).to.equal('bridge'); 22 | expect(ovs?.data?.topoViewerRole).to.equal('bridge'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "esModuleInterop": true, 5 | "lib": ["ES2024", "dom"], 6 | "module": "commonjs", 7 | "noFallthroughCasesInSwitch": true, 8 | "noImplicitAny": true, 9 | "noImplicitReturns": true, 10 | "noImplicitThis": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "outDir": "out", 14 | "resolveJsonModule": true, 15 | "rootDir": ".", 16 | "skipLibCheck": true, 17 | "sourceMap": true, 18 | "strict": true, 19 | "strictFunctionTypes": true, 20 | "strictNullChecks": true, 21 | "target": "ES2024", 22 | "typeRoots": [ 23 | "src/topoViewer/common/types", 24 | "./node_modules/@types" 25 | ], 26 | "types": ["mocha", "node"] 27 | }, 28 | "exclude": [ 29 | "node_modules", 30 | ".vscode-test", 31 | "test/**/*", 32 | "legacy-backup/**/*", 33 | "labs/**/*" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /test/helpers/utils-stub.ts: -------------------------------------------------------------------------------- 1 | export const calls: string[] = []; 2 | let output = ''; 3 | 4 | export function setOutput(out: string) { 5 | output = out; 6 | } 7 | 8 | export async function runCommand(command: string, ..._args: any[]): Promise { 9 | calls.push(command); 10 | if (_args.length > 0) { 11 | // no-op to consume args for linter 12 | } 13 | return output; 14 | } 15 | 16 | export function getUserInfo(): { 17 | hasPermission: boolean; 18 | isRoot: boolean; 19 | userGroups: string[]; 20 | username: string; 21 | uid: number; 22 | } { 23 | // In tests, always return that permissions are granted 24 | return { 25 | hasPermission: true, 26 | isRoot: false, 27 | userGroups: ['clab_admins', 'docker'], 28 | username: 'testuser', 29 | uid: 1000 30 | }; 31 | } 32 | 33 | export async function getSelectedLabNode(node?: any): Promise { 34 | // In tests, always return the node that was passed in 35 | return node; 36 | } 37 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./command"; 2 | export * from "./deploy"; 3 | export * from "./destroy"; 4 | export * from "./redeploy"; 5 | export * from "./save"; 6 | export * from "./openLabFile"; 7 | export * from "./nodeActions"; 8 | export * from "./nodeExec"; 9 | export * from "./ssh"; 10 | export * from "./nodeImpairments"; 11 | export * from "./showLogs"; 12 | export * from "./graph"; 13 | export * from "./copy"; 14 | export * from "./addToWorkspace"; 15 | export * from "./openFolderInNewWindow"; 16 | export * from "./inspect"; 17 | export * from "./capture"; 18 | export * from "./impairments"; 19 | export * from "./edgeshark"; 20 | export * from "./openBrowser"; 21 | export * from "./favorite"; 22 | export * from "./deleteLab"; 23 | export * from "./cloneRepo"; 24 | export * from "./deployPopular"; 25 | export * from "./clonePopularRepo"; 26 | export * from "./openLink"; 27 | export * from "./sshxShare"; 28 | export * from "./gottyShare"; 29 | export * from "./fcli"; 30 | -------------------------------------------------------------------------------- /test/unit/utils/clabSchema.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global describe, it, __dirname */ 3 | import { expect } from 'chai'; 4 | import Ajv from 'ajv'; 5 | import addFormats from 'ajv-formats'; 6 | import fs from 'fs'; 7 | import path from 'path'; 8 | 9 | describe('clab.schema.json', () => { 10 | it('compiles with custom markdownDescription keyword', () => { 11 | // Go up from out/test/test/unit/utils to project root, then into schema/ 12 | const schemaPath = path.join(__dirname, '..', '..', '..', '..', '..', 'schema', 'clab.schema.json'); 13 | const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8')); 14 | const ajv = new Ajv({ strict: false, allErrors: true, verbose: true }); 15 | addFormats(ajv); 16 | ajv.addKeyword({ 17 | keyword: 'markdownDescription', 18 | schemaType: 'string', 19 | compile: () => () => true, 20 | }); 21 | const validate = ajv.compile(schema); 22 | expect(validate).to.be.a('function'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | topoViewerData 6 | .vscode/* 7 | !.vscode/ 8 | !.vscode/launch.json 9 | 10 | package.json 11 | .DS_Store 12 | **/.DS_Store 13 | *.vsix 14 | mochawesome-report/ 15 | coverage/ 16 | .nyc_output/ 17 | clab-*/ 18 | *.clab.yml* 19 | *.clab.yaml* 20 | CLAUDE.md 21 | AGENTS.md 22 | patch.sh 23 | labs/** 24 | license.txt 25 | 26 | # Claude Flow generated files 27 | .claude/settings.local.json 28 | .mcp.json 29 | claude-flow.config.json 30 | .swarm/ 31 | .hive-mind/ 32 | memory/claude-flow-data.json 33 | memory/sessions/* 34 | !memory/sessions/README.md 35 | memory/agents/* 36 | !memory/agents/README.md 37 | coordination/memory_bank/* 38 | coordination/subtasks/* 39 | coordination/orchestration/* 40 | *.db 41 | *.db-journal 42 | *.db-wal 43 | *.sqlite 44 | *.sqlite-journal 45 | *.sqlite-wal 46 | claude-flow 47 | claude-flow.bat 48 | claude-flow.ps1 49 | hive-mind-prompt-*.txt 50 | memory/** 51 | .claude/** 52 | .claude-flow/** 53 | patch.sh 54 | report/ 55 | -------------------------------------------------------------------------------- /src/commands/addToWorkspace.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as path from "path"; 3 | import { ClabLabTreeNode } from "../treeView/common"; 4 | 5 | export async function addLabFolderToWorkspace(node: ClabLabTreeNode): Promise { 6 | if (!node.labPath.absolute) { 7 | vscode.window.showErrorMessage("No lab path found for this lab"); 8 | return; 9 | } 10 | 11 | // Get the folder that contains the .clab.yaml 12 | const folderPath = path.dirname(node.labPath.absolute); 13 | 14 | // Add it to the current workspace 15 | const existingCount = vscode.workspace.workspaceFolders 16 | ? vscode.workspace.workspaceFolders.length 17 | : 0; 18 | 19 | vscode.workspace.updateWorkspaceFolders( 20 | existingCount, 21 | null, 22 | { 23 | uri: vscode.Uri.file(folderPath), 24 | name: node.label // or any other display name 25 | } 26 | ); 27 | 28 | vscode.window.showInformationMessage( 29 | `Added "${node.name}" to your workspace.` 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/topoViewer/webview/types/linkLabelMode.ts: -------------------------------------------------------------------------------- 1 | export type LinkLabelMode = 'on-select' | 'show-all' | 'hide'; 2 | 3 | export function normalizeLinkLabelMode(value: string): LinkLabelMode { 4 | const normalized = (value || '').toLowerCase(); 5 | switch (normalized) { 6 | case 'show-all': 7 | case 'hide': 8 | case 'on-select': 9 | return normalized as LinkLabelMode; 10 | case 'show': 11 | case 'show-labels': 12 | case 'show_labels': 13 | case 'showlabels': 14 | case 'show labels': 15 | return 'show-all'; 16 | case 'none': 17 | case 'no-labels': 18 | case 'no_labels': 19 | case 'nolabels': 20 | case 'no labels': 21 | return 'hide'; 22 | default: 23 | return 'on-select'; 24 | } 25 | } 26 | 27 | export function linkLabelModeLabel(mode: LinkLabelMode): string { 28 | switch (mode) { 29 | case 'show-all': 30 | return 'Show Labels'; 31 | case 'hide': 32 | return 'No Labels'; 33 | case 'on-select': 34 | default: 35 | return 'Show Link Labels on Select'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/topoViewer/webview/features/canvas/EventHandlers.ts: -------------------------------------------------------------------------------- 1 | import type cytoscape from 'cytoscape'; 2 | 3 | /** 4 | * Utility wrapper to register common Cytoscape event handlers for clicks 5 | * on the canvas, nodes and edges. This helps reduce duplicated wiring of 6 | * event listeners between the TopoViewer view and editor implementations. 7 | */ 8 | export interface CyEventHandlerOptions { 9 | cy: cytoscape.Core; 10 | onCanvasClick?: (event: cytoscape.EventObject) => void; 11 | onNodeClick?: (event: cytoscape.EventObject) => void | Promise; 12 | onEdgeClick?: (event: cytoscape.EventObject) => void | Promise; 13 | } 14 | 15 | export function registerCyEventHandlers(options: CyEventHandlerOptions): void { 16 | const { cy, onCanvasClick, onNodeClick, onEdgeClick } = options; 17 | 18 | if (onCanvasClick) { 19 | cy.on('click', (event) => { 20 | if (event.target === cy) { 21 | onCanvasClick(event); 22 | } 23 | }); 24 | } 25 | 26 | if (onNodeClick) { 27 | cy.on('click', 'node', onNodeClick); 28 | } 29 | 30 | if (onEdgeClick) { 31 | cy.on('click', 'edge', onEdgeClick); 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /src/topoViewer/webview/assets/images/wireshark_bold.svg: -------------------------------------------------------------------------------- 1 | 3 | 6 | -------------------------------------------------------------------------------- /resources/icons/wireshark_dark.svg: -------------------------------------------------------------------------------- 1 | 3 | 6 | -------------------------------------------------------------------------------- /resources/icons/wireshark_light.svg: -------------------------------------------------------------------------------- 1 | 3 | 6 | -------------------------------------------------------------------------------- /src/commands/nodeActions.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ClabContainerTreeNode } from "../treeView/common"; 3 | import * as utils from '../utils'; 4 | 5 | async function runNodeAction(action: utils.ContainerAction, node: ClabContainerTreeNode 6 | ): Promise { 7 | if (!node) { 8 | vscode.window.showErrorMessage("No container node selected."); 9 | return; 10 | } 11 | 12 | const containerId = node.cID; 13 | if (!containerId) { 14 | vscode.window.showErrorMessage("No containerId found."); 15 | return; 16 | } 17 | 18 | await utils.runContainerAction(containerId, action); 19 | } 20 | 21 | export async function startNode(node: ClabContainerTreeNode): Promise { 22 | await runNodeAction(utils.ContainerAction.Start, node); 23 | } 24 | 25 | export async function stopNode(node: ClabContainerTreeNode): Promise { 26 | await runNodeAction(utils.ContainerAction.Stop, node); 27 | } 28 | 29 | export async function pauseNode(node: ClabContainerTreeNode): Promise { 30 | await runNodeAction(utils.ContainerAction.Pause, node); 31 | } 32 | 33 | export async function unpauseNode(node: ClabContainerTreeNode): Promise { 34 | await runNodeAction(utils.ContainerAction.Unpause, node); 35 | } 36 | -------------------------------------------------------------------------------- /src/commands/deleteLab.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import { ClabLabTreeNode } from '../treeView/common'; 5 | import { favoriteLabs, extensionContext } from '../extension'; 6 | 7 | export async function deleteLab(node: ClabLabTreeNode) { 8 | const filePath = node?.labPath?.absolute; 9 | if (!filePath) { 10 | vscode.window.showErrorMessage('No lab file found.'); 11 | return; 12 | } 13 | 14 | const confirm = await vscode.window.showWarningMessage( 15 | `Delete lab "${path.basename(filePath)}"? This action cannot be undone.`, 16 | { modal: true }, 17 | 'Delete' 18 | ); 19 | if (confirm !== 'Delete') { 20 | return; 21 | } 22 | 23 | try { 24 | await fs.promises.unlink(filePath); 25 | favoriteLabs.delete(filePath); 26 | if (extensionContext) { 27 | await extensionContext.globalState.update('favoriteLabs', Array.from(favoriteLabs)); 28 | } 29 | vscode.window.showInformationMessage(`Deleted lab file ${node.label}`); 30 | vscode.commands.executeCommand('containerlab.refresh'); 31 | } catch (err) { 32 | const msg = err instanceof Error ? err.message : String(err); 33 | vscode.window.showErrorMessage(`Failed to delete lab: ${msg}`); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/topoViewer/webview/core/managerRegistry.ts: -------------------------------------------------------------------------------- 1 | import type cytoscape from 'cytoscape'; 2 | import { GroupManager } from '../features/groups/GroupManager'; 3 | import type { GroupStyleManager } from '../features/groups/GroupStyleManager'; 4 | import { LayoutManager } from '../features/canvas/LayoutManager'; 5 | import { ZoomToFitManager } from '../features/canvas/ZoomToFitManager'; 6 | import { LinkLabelManager } from '../features/canvas/LinkLabelManager'; 7 | import { DummyLinksManager } from '../features/canvas/DummyLinksManager'; 8 | 9 | // Singleton instances for managers that don't require external dependencies 10 | export const layoutAlgoManager = new LayoutManager(); 11 | export const zoomToFitManager = new ZoomToFitManager(); 12 | export const labelEndpointManager = new LinkLabelManager(); 13 | export const dummyLinksManager = new DummyLinksManager(); 14 | 15 | // Lazy singletons for managers that require initialization parameters 16 | let groupManager: GroupManager | null = null; 17 | 18 | export function getGroupManager( 19 | cy: cytoscape.Core, 20 | groupStyleManager: GroupStyleManager, 21 | mode: 'edit' | 'view' 22 | ): GroupManager { 23 | if (!groupManager) { 24 | groupManager = new GroupManager(cy, groupStyleManager, mode); 25 | } 26 | return groupManager; 27 | } 28 | 29 | -------------------------------------------------------------------------------- /test/unit/topoViewer/managerSvgGenerator.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global describe, it */ 3 | import { expect } from 'chai'; 4 | import { generateEncodedSVG, NodeType } from '../../../src/topoViewer/webview/features/canvas/SvgGenerator'; 5 | 6 | describe('generateEncodedSVG', () => { 7 | const nodeTypes: NodeType[] = [ 8 | 'pe', 9 | 'dcgw', 10 | 'leaf', 11 | 'switch', 12 | 'spine', 13 | 'super-spine', 14 | 'server', 15 | 'pon', 16 | 'controller', 17 | 'rgw', 18 | 'ue', 19 | 'cloud', 20 | 'client', 21 | 'bridge' 22 | ]; 23 | 24 | it('returns encoded data URI containing color for all node types', () => { 25 | for (const type of nodeTypes) { 26 | const color = '#123456'; 27 | const uri = generateEncodedSVG(type, color); 28 | expect(uri.startsWith('data:image/svg+xml;utf8,')).to.be.true; 29 | const decoded = decodeURIComponent(uri.replace('data:image/svg+xml;utf8,', '')); 30 | expect(decoded).to.contain(color); 31 | } 32 | }); 33 | 34 | it('falls back to pe icon for unknown node type', () => { 35 | const color = '#abcdef'; 36 | const unknown = generateEncodedSVG('unknown' as NodeType, color); 37 | const pe = generateEncodedSVG('pe', color); 38 | expect(unknown).to.equal(pe); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/topoViewer/extension/services/TreeUtils.ts: -------------------------------------------------------------------------------- 1 | import { ClabLabTreeNode, ClabContainerTreeNode, ClabInterfaceTreeNode } from '../../../treeView/common'; 2 | 3 | export function findContainerNode( 4 | labs: Record | undefined, 5 | name: string, 6 | clabName?: string 7 | ): ClabContainerTreeNode | undefined { 8 | if (!labs) { 9 | return undefined; 10 | } 11 | const labValues = clabName 12 | ? Object.values(labs).filter(l => l.name === clabName) 13 | : Object.values(labs); 14 | for (const lab of labValues) { 15 | const container = lab.containers?.find( 16 | (c: ClabContainerTreeNode) => 17 | c.name === name || c.name_short === name || c.label === name 18 | ); 19 | if (container) { 20 | return container; 21 | } 22 | } 23 | return undefined; 24 | } 25 | 26 | export function findInterfaceNode( 27 | labs: Record | undefined, 28 | nodeName: string, 29 | intf: string, 30 | clabName?: string 31 | ): ClabInterfaceTreeNode | undefined { 32 | const container = findContainerNode(labs, nodeName, clabName); 33 | if (!container) { 34 | return undefined; 35 | } 36 | return container.interfaces.find( 37 | (i: ClabInterfaceTreeNode) => 38 | i.name === intf || i.alias === intf || i.label === intf 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/topoViewer/webview/features/nodes/NodeUtils.ts: -------------------------------------------------------------------------------- 1 | export function updateNodePosition(node: any, nodeJson: any, isGeoActive: boolean): void { 2 | let posX = node.position().x; 3 | let posY = node.position().y; 4 | if (isGeoActive) { 5 | const origX = node.data('_origPosX'); 6 | const origY = node.data('_origPosY'); 7 | if (origX !== undefined && origY !== undefined) { 8 | posX = origX; 9 | posY = origY; 10 | } 11 | } 12 | nodeJson.position = { x: posX, y: posY }; 13 | } 14 | 15 | export function handleGeoData(node: any, nodeJson: any, isGeoActive: boolean, layoutMgr?: any): void { 16 | const lat = node.data('lat'); 17 | const lng = node.data('lng'); 18 | if (lat !== undefined && lng !== undefined) { 19 | nodeJson.data = nodeJson.data || {}; 20 | nodeJson.data.geoLayoutActive = !!isGeoActive; 21 | nodeJson.data.lat = lat.toString(); 22 | nodeJson.data.lng = lng.toString(); 23 | return; 24 | } 25 | if (isGeoActive && layoutMgr?.cytoscapeLeafletMap) { 26 | nodeJson.data = nodeJson.data || {}; 27 | nodeJson.data.geoLayoutActive = true; 28 | const latlng = layoutMgr.cytoscapeLeafletMap.containerPointToLatLng({ 29 | x: node.position().x, 30 | y: node.position().y 31 | }); 32 | nodeJson.data.lat = latlng.lat.toString(); 33 | nodeJson.data.lng = latlng.lng.toString(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /resources/icons/ethernet-port-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /resources/icons/ethernet-port-green.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /resources/icons/ethernet-port-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /resources/icons/ethernet-port-red.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/topoViewer/webview/core/nodeConfig.ts: -------------------------------------------------------------------------------- 1 | import { ClabTopology, ClabNode } from '../../shared/types/topoViewerType'; 2 | 3 | /** 4 | * Applies Containerlab inheritance rules to compute the effective 5 | * configuration for a node. The precedence order is: 6 | * node -> group -> kind -> defaults. 7 | */ 8 | 9 | function getSection( 10 | source: Record | undefined, 11 | key: string | undefined 12 | ): ClabNode { 13 | return key && source?.[key] ? source[key] : {}; 14 | } 15 | 16 | function resolveKindName( 17 | node: ClabNode, 18 | groupCfg: ClabNode, 19 | defaults: ClabNode 20 | ): string | undefined { 21 | return node.kind ?? groupCfg.kind ?? defaults.kind; 22 | } 23 | 24 | function mergeNodeLabels( 25 | ...labels: (Record | undefined)[] 26 | ): Record { 27 | return Object.assign({}, ...labels.filter(Boolean)); 28 | } 29 | 30 | export function resolveNodeConfig( 31 | parsed: ClabTopology, 32 | node: ClabNode 33 | ): ClabNode { 34 | const { defaults = {}, groups, kinds } = parsed.topology ?? {}; 35 | 36 | const groupCfg = getSection(groups, node.group); 37 | const kindName = resolveKindName(node, groupCfg, defaults); 38 | const kindCfg = getSection(kinds, kindName); 39 | 40 | return { 41 | ...defaults, 42 | ...kindCfg, 43 | ...groupCfg, 44 | ...node, 45 | kind: kindName, 46 | labels: mergeNodeLabels( 47 | defaults.labels, 48 | kindCfg.labels, 49 | groupCfg.labels, 50 | node.labels, 51 | ), 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/topoViewer/webview/app/state.ts: -------------------------------------------------------------------------------- 1 | import type { TopoViewerState } from '../types/topoViewerState'; 2 | import type { LinkLabelMode } from '../types/linkLabelMode'; 3 | 4 | const DEFAULT_LINK_LABEL_MODE: LinkLabelMode = 'show-all'; 5 | 6 | export const topoViewerState: TopoViewerState = { 7 | cy: null, 8 | selectedNode: null, 9 | selectedEdge: null, 10 | linkLabelMode: DEFAULT_LINK_LABEL_MODE, 11 | nodeContainerStatusVisibility: false, 12 | dummyLinksVisible: true, 13 | labName: '', 14 | prefixName: 'clab', 15 | multiLayerViewPortState: false, 16 | isGeoMapInitialized: false, 17 | isPanel01Cy: false, 18 | nodeClicked: false, 19 | edgeClicked: false, 20 | deploymentType: '', 21 | cytoscapeLeafletMap: null, 22 | cytoscapeLeafletLeaf: null, 23 | editorEngine: null, 24 | }; 25 | 26 | export function resetState(): void { 27 | topoViewerState.cy = null; 28 | topoViewerState.selectedNode = null; 29 | topoViewerState.selectedEdge = null; 30 | topoViewerState.linkLabelMode = DEFAULT_LINK_LABEL_MODE; 31 | topoViewerState.nodeContainerStatusVisibility = false; 32 | topoViewerState.dummyLinksVisible = true; 33 | topoViewerState.multiLayerViewPortState = false; 34 | topoViewerState.isGeoMapInitialized = false; 35 | topoViewerState.isPanel01Cy = false; 36 | topoViewerState.nodeClicked = false; 37 | topoViewerState.edgeClicked = false; 38 | topoViewerState.deploymentType = ''; 39 | topoViewerState.cytoscapeLeafletMap = null; 40 | topoViewerState.cytoscapeLeafletLeaf = null; 41 | topoViewerState.editorEngine = null; 42 | } 43 | 44 | export default topoViewerState; 45 | -------------------------------------------------------------------------------- /src/treeView/helpFeedbackProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | interface HelpLink { 4 | label: string; 5 | url: string; 6 | } 7 | 8 | export class HelpFeedbackProvider implements vscode.TreeDataProvider { 9 | private links: HelpLink[] = [ 10 | { label: 'Containerlab Documentation', url: 'https://containerlab.dev/' }, 11 | { label: 'VS Code Extension Documentation', url: 'https://containerlab.dev/manual/vsc-extension/' }, 12 | { label: 'Browse Labs on GitHub (srl-labs)', url: 'https://github.com/srl-labs/' }, 13 | { label: 'Find more labs tagged with "clab-topo"', url: 'https://github.com/search?q=topic%3Aclab-topo++fork%3Atrue&type=repositories' }, 14 | { label: 'Join our Discord server', url: 'https://discord.gg/vAyddtaEV9' }, 15 | { label: 'Download cshargextcap Wireshark plugin', url: 'https://github.com/siemens/cshargextcap/releases/latest' } 16 | ]; 17 | 18 | private _onDidChangeTreeData = new vscode.EventEmitter(); 19 | readonly onDidChangeTreeData = this._onDidChangeTreeData.event; 20 | 21 | getTreeItem(element: vscode.TreeItem): vscode.TreeItem { 22 | return element; 23 | } 24 | 25 | getChildren(): vscode.ProviderResult { 26 | return this.links.map(link => { 27 | const item = new vscode.TreeItem(link.label, vscode.TreeItemCollapsibleState.None); 28 | item.command = { 29 | command: 'containerlab.openLink', 30 | title: 'Open Link', 31 | arguments: [link.url] 32 | }; 33 | item.iconPath = new vscode.ThemeIcon('link-external'); 34 | return item; 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/topoViewer/webview/assets/templates/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{pageTitle}} 7 | 8 | 9 | 10 | 11 | {{CSS_EXTEND}} 12 | 13 | 14 | 15 | {{NAVBAR}} 16 | 17 |
18 | 19 | 20 |
21 | 22 |
26 | 27 | 28 | {{VIEWPORT_DRAWER_LAYOUT}} 29 | {{VIEWPORT_DRAWER_TOPOLOGY_OVERVIEW}} 30 | {{VIEWPORT_DRAWER_CAPTURE_SCEENSHOOT}} 31 | {{PANEL_NODE}} 32 | {{PANEL_NODE_EDITOR_PARENT}} 33 | {{PANEL_NODE_EDITOR}} 34 | {{PANEL_NETWORK_EDITOR}} 35 | {{PANEL_LAB_SETTINGS}} 36 | {{PANEL_TOPOVIEWER_ABOUT}} 37 | {{PANEL_LINK}} 38 | {{PANEL_LINK_EDITOR}} 39 | {{PANEL_BULK_LINK}} 40 | {{PANEL_FREE_TEXT}} 41 | {{PANEL_FREE_SHAPES}} 42 | {{WIRESHARK_MODAL}} 43 | {{UNIFIED_FLOATING_PANEL}} 44 | {{CONFIRM_DIALOG}} 45 | {{SHORTCUTS_MODAL}} 46 | {{SCRIPTS}} 47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /test/unit/topoViewer/managerGroupManagement.rememberStyle.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import cytoscape from 'cytoscape'; 5 | import { GroupManager } from '../../../src/topoViewer/webview/features/groups/GroupManager'; 6 | import { GroupStyleManager } from '../../../src/topoViewer/webview/features/groups/GroupStyleManager'; 7 | 8 | // ensure window is available for global assignments 9 | (globalThis as any).window = globalThis; 10 | 11 | describe('ManagerGroupManagement remember style', () => { 12 | it('applies last used style to new groups', () => { 13 | const cy = cytoscape({ headless: true }); 14 | const messageSender = { sendMessageToVscodeEndpointPost: async () => ({}) } as any; 15 | const styleManager = new GroupStyleManager(cy, messageSender); 16 | const mgr = new GroupManager(cy, styleManager, 'edit'); 17 | 18 | const firstId = mgr.createNewParent(); 19 | styleManager.updateGroupStyle(firstId, { 20 | id: firstId, 21 | backgroundColor: '#ff0000', 22 | backgroundOpacity: 50, 23 | borderColor: '#00ff00', 24 | borderWidth: 1, 25 | borderStyle: 'dashed', 26 | borderRadius: 5, 27 | color: '#123456', 28 | labelPosition: 'bottom-left' 29 | }); 30 | 31 | const secondId = mgr.createNewParent(); 32 | const secondStyle = styleManager.getStyle(secondId); 33 | expect(secondStyle).to.include({ 34 | backgroundColor: '#ff0000', 35 | backgroundOpacity: 50, 36 | borderColor: '#00ff00', 37 | borderWidth: 1, 38 | borderStyle: 'dashed', 39 | borderRadius: 5, 40 | color: '#123456', 41 | labelPosition: 'bottom-left' 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/commands/deploy.ts: -------------------------------------------------------------------------------- 1 | import { ClabLabTreeNode } from "../treeView/common"; 2 | import * as vscode from "vscode"; 3 | import { deployPopularLab } from "./deployPopular"; 4 | import { runClabAction } from "./runClabAction"; 5 | 6 | export async function deploy(node?: ClabLabTreeNode) { 7 | await runClabAction("deploy", node); 8 | } 9 | 10 | export async function deployCleanup(node?: ClabLabTreeNode) { 11 | await runClabAction("deploy", node, true); 12 | } 13 | 14 | export async function deploySpecificFile() { 15 | const mode = await vscode.window.showQuickPick( 16 | ["Select local file", "Enter Git/HTTP URL", "Choose from popular labs"], 17 | { title: "Deploy from" } 18 | ); 19 | 20 | if (!mode) { 21 | return; 22 | } 23 | 24 | let labRef: string | undefined; 25 | 26 | if (mode === "Select local file") { 27 | const opts: vscode.OpenDialogOptions = { 28 | title: "Select containerlab topology file", 29 | filters: { 30 | yaml: ["yaml", "yml"], 31 | }, 32 | }; 33 | 34 | const uri = await vscode.window.showOpenDialog(opts); 35 | if (!uri || !uri.length) { 36 | return; 37 | } 38 | labRef = uri[0].fsPath; 39 | } else if (mode === "Enter Git/HTTP URL") { 40 | labRef = await vscode.window.showInputBox({ 41 | title: "Git/HTTP URL", 42 | placeHolder: "https://github.com/user/repo or https://example.com/lab.yml", 43 | prompt: "Provide a repository or file URL", 44 | }); 45 | if (!labRef) { 46 | return; 47 | } 48 | } else { 49 | await deployPopularLab(); 50 | return; 51 | } 52 | 53 | const tempNode = new ClabLabTreeNode( 54 | "", 55 | vscode.TreeItemCollapsibleState.None, 56 | { absolute: labRef, relative: "" } 57 | ); 58 | deploy(tempNode); 59 | } 60 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | 4 | const editorConfig = { 5 | mode: 'production', // or 'development' for debugging 6 | target: 'web', 7 | cache: { 8 | type: 'filesystem' 9 | }, 10 | entry: './src/topoViewer/webview/app/entry.ts', 11 | output: { 12 | path: path.resolve(__dirname, 'dist'), 13 | filename: 'topologyEditorWebviewController.js', 14 | libraryTarget: 'module' 15 | }, 16 | experiments: { 17 | outputModule: true 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.ts$/, 23 | loader: 'esbuild-loader', 24 | options: { 25 | loader: 'ts', 26 | target: 'es2017' 27 | }, 28 | exclude: /node_modules/ 29 | }, 30 | { 31 | test: /\.css$/, 32 | use: [ 33 | MiniCssExtractPlugin.loader, 34 | 'css-loader', 35 | 'postcss-loader' 36 | ] 37 | }, 38 | { 39 | test: /\.(woff2?|eot|ttf|otf|svg)$/i, 40 | type: 'asset/resource', 41 | generator: { 42 | filename: 'webfonts/[name][ext][query]' 43 | } 44 | } 45 | ] 46 | }, 47 | plugins: [ 48 | new MiniCssExtractPlugin({ 49 | filename: 'topoViewerEditorStyles.css' 50 | }) 51 | ], 52 | resolve: { 53 | extensions: ['.ts', '.js'] 54 | }, 55 | externals: { 56 | vscode: 'commonjs vscode' 57 | }, 58 | // Disable asset size warnings to keep the build output clean. The 59 | // bundled webview code is quite large but the size is acceptable for the 60 | // extension, so we suppress webpack's performance hints. 61 | performance: { 62 | hints: false 63 | } 64 | }; 65 | 66 | module.exports = [editorConfig]; 67 | -------------------------------------------------------------------------------- /src/commands/ssh.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { execCommandInTerminal } from "./command"; 3 | import { ClabContainerTreeNode, ClabLabTreeNode } from "../treeView/common"; 4 | import { sshUserMapping } from "../extension"; 5 | 6 | export function sshToNode(node: ClabContainerTreeNode | undefined): void { 7 | if (!node) { 8 | vscode.window.showErrorMessage('No container node selected.'); 9 | return; 10 | } 11 | 12 | let sshTarget: string | undefined; 13 | 14 | if (node.name) { sshTarget = node.name; } 15 | else if (node.v6Address) { sshTarget = node.v6Address; } 16 | else if (node.v4Address) { sshTarget = node.v4Address; } 17 | else if (node.cID) { sshTarget = node.cID; } 18 | else { 19 | vscode.window.showErrorMessage("No target to connect to container"); 20 | return; 21 | } 22 | 23 | // Get the SSH user mapping from user settings 24 | const config = vscode.workspace.getConfiguration("containerlab"); 25 | const userSshMapping = config.get("node.sshUserMapping") as { [key: string]: string }; 26 | 27 | // Use user setting first, then default mapping, then fallback to "admin" 28 | const sshUser = userSshMapping?.[node.kind] || (sshUserMapping as any)[node.kind] || "admin"; 29 | 30 | const container = node.name || node.cID || "Container"; 31 | 32 | execCommandInTerminal(`ssh ${sshUser}@${sshTarget}`, `SSH - ${container}`, true); 33 | } 34 | 35 | export function sshToLab(node: ClabLabTreeNode | undefined): void { 36 | if (!node) { 37 | vscode.window.showErrorMessage('No lab node selected.'); 38 | return; 39 | } 40 | 41 | if (!node.containers) { 42 | vscode.window.showErrorMessage("No child containers to connect to"); 43 | return; 44 | } 45 | 46 | node.containers.forEach((c) => { 47 | sshToNode(c); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /src/topoViewer/webview/assets/templates/partials/viewport-drawer-capture-sceenshoot.html: -------------------------------------------------------------------------------- 1 | 2 | 52 | -------------------------------------------------------------------------------- /test/unit/topoViewer/annotationsManager.noTouch.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global describe, it, after, __dirname */ 3 | import { expect } from 'chai'; 4 | import fs from 'fs'; 5 | import os from 'os'; 6 | import path from 'path'; 7 | import Module from 'module'; 8 | 9 | const originalResolve = (Module as any)._resolveFilename; 10 | (Module as any)._resolveFilename = function(request: string, parent: any, isMain: boolean, options: any) { 11 | if (request === 'vscode') { 12 | return path.join(__dirname, '..', '..', 'helpers', 'vscode-stub.js'); 13 | } 14 | if (request.endsWith('logging/logger')) { 15 | return path.join(__dirname, '..', '..', 'helpers', 'extensionLogger-stub.js'); 16 | } 17 | return originalResolve.call(this, request, parent, isMain, options); 18 | }; 19 | 20 | import { annotationsManager } from '../../../src/topoViewer/extension/services/AnnotationsFile'; 21 | 22 | describe('AnnotationsManager saveAnnotations', () => { 23 | after(() => { 24 | (Module as any)._resolveFilename = originalResolve; 25 | }); 26 | 27 | it('does not rewrite file when annotations unchanged', async () => { 28 | const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ann-')); 29 | const yamlPath = path.join(tmpDir, 'test.clab.yaml'); 30 | fs.writeFileSync(yamlPath, 'name: test\n'); 31 | const annPath = yamlPath + '.annotations.json'; 32 | const data = { 33 | freeTextAnnotations: [], 34 | groupStyleAnnotations: [], 35 | cloudNodeAnnotations: [], 36 | nodeAnnotations: [{ id: 'n1', position: { x: 0, y: 0 } }] 37 | }; 38 | fs.writeFileSync(annPath, JSON.stringify(data, null, 2)); 39 | const before = fs.statSync(annPath).mtimeMs; 40 | 41 | await annotationsManager.saveAnnotations(yamlPath, data); 42 | 43 | const after = fs.statSync(annPath).mtimeMs; 44 | expect(after).to.equal(before); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/topoViewer/webview/app/entry.ts: -------------------------------------------------------------------------------- 1 | // Mode is now set from the HTML template via window.topoViewerMode 2 | 3 | import { initializeGlobalHandlers } from '../ui/UiHandlers'; 4 | import { windowManager, WindowManager, ManagedWindow } from '../platform/windowing/WindowManager'; 5 | import { panelManager, PanelManager, defaultPanelConfigs, initializeDefaultPanels } from '../platform/windowing/PanelManager'; 6 | 7 | import('./TopologyWebviewController').then(() => { 8 | initializeGlobalHandlers(); 9 | }); 10 | 11 | // Expose window manager globally for use in HTML templates 12 | declare global { 13 | interface Window { 14 | windowManager: typeof windowManager; 15 | WindowManager: typeof WindowManager; 16 | ManagedWindow: typeof ManagedWindow; 17 | panelManager: typeof panelManager; 18 | PanelManager: typeof PanelManager; 19 | defaultPanelConfigs: typeof defaultPanelConfigs; 20 | initializeDefaultPanels: typeof initializeDefaultPanels; 21 | } 22 | } 23 | window.windowManager = windowManager; 24 | window.WindowManager = WindowManager; 25 | window.ManagedWindow = ManagedWindow; 26 | window.panelManager = panelManager; 27 | window.PanelManager = PanelManager; 28 | window.defaultPanelConfigs = defaultPanelConfigs; 29 | window.initializeDefaultPanels = initializeDefaultPanels; 30 | 31 | export { default as TopologyWebviewController } from './TopologyWebviewController'; 32 | export * from '../platform/messaging/VscodeMessaging'; 33 | export { LayoutManager } from '../features/canvas/LayoutManager'; 34 | export { GroupManager } from '../features/groups/GroupManager'; 35 | export * from '../features/canvas/SvgGenerator'; 36 | export * from '../ui/UiHandlers'; 37 | export { windowManager, WindowManager, ManagedWindow } from '../platform/windowing/WindowManager'; 38 | export { panelManager, PanelManager, defaultPanelConfigs, initializeDefaultPanels } from '../platform/windowing/PanelManager'; 39 | -------------------------------------------------------------------------------- /src/commands/runClabAction.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ClabCommand } from "./clabCommand"; 3 | import { ClabLabTreeNode } from "../treeView/common"; 4 | import { getSelectedLabNode } from "../utils/utils"; 5 | import { notifyCurrentTopoViewerOfCommandFailure, notifyCurrentTopoViewerOfCommandSuccess } from "./graph"; 6 | 7 | export async function runClabAction(action: "deploy" | "redeploy" | "destroy", node?: ClabLabTreeNode, cleanup = false): Promise { 8 | node = await getSelectedLabNode(node); 9 | if (!node) { 10 | return; 11 | } 12 | 13 | const execute = async () => { 14 | const cmd = new ClabCommand( 15 | action, 16 | node as ClabLabTreeNode, 17 | undefined, 18 | undefined, 19 | undefined, 20 | async () => { 21 | await notifyCurrentTopoViewerOfCommandSuccess(action); 22 | }, 23 | async (error) => { 24 | await notifyCurrentTopoViewerOfCommandFailure(action, error); 25 | } 26 | ); 27 | if (cleanup) { 28 | await cmd.run(["-c"]); 29 | } else { 30 | await cmd.run(); 31 | } 32 | }; 33 | 34 | if (cleanup) { 35 | const config = vscode.workspace.getConfiguration("containerlab"); 36 | const skipWarning = config.get("skipCleanupWarning", false); 37 | if (!skipWarning) { 38 | const selection = await vscode.window.showWarningMessage( 39 | `WARNING: ${action.charAt(0).toUpperCase() + action.slice(1)} (cleanup) will remove all configuration artifacts.. Are you sure you want to proceed?`, 40 | { modal: true }, 41 | "Yes", 42 | "Don't warn me again" 43 | ); 44 | if (!selection) { 45 | return; 46 | } 47 | if (selection === "Don't warn me again") { 48 | await config.update("skipCleanupWarning", true, vscode.ConfigurationTarget.Global); 49 | } 50 | } 51 | } 52 | 53 | await execute(); 54 | } 55 | -------------------------------------------------------------------------------- /src/commands/save.ts: -------------------------------------------------------------------------------- 1 | // src/commands/save.ts 2 | import * as vscode from "vscode"; 3 | import { ClabCommand } from "./clabCommand"; 4 | import { ClabLabTreeNode, ClabContainerTreeNode } from "../treeView/common"; 5 | import * as path from "path"; 6 | 7 | /** 8 | * Save the entire lab configuration. 9 | * Executes: containerlab -t save 10 | */ 11 | export async function saveLab(node: ClabLabTreeNode) { 12 | if (!node) { 13 | vscode.window.showErrorMessage("No lab node selected."); 14 | return; 15 | } 16 | const labPath = node.labPath && node.labPath.absolute; 17 | if (!labPath) { 18 | vscode.window.showErrorMessage("No labPath found for the lab."); 19 | return; 20 | } 21 | 22 | // Create a ClabCommand for "save" using the lab node. 23 | const saveCmd = new ClabCommand("save", node); 24 | // ClabCommand automatically appends "-t ". 25 | saveCmd.run(); 26 | } 27 | 28 | /** 29 | * Save the configuration for a specific container node. 30 | * Executes: containerlab -t save --node-filter 31 | */ 32 | export async function saveNode(node: ClabContainerTreeNode) { 33 | if (!node) { 34 | vscode.window.showErrorMessage("No container node selected."); 35 | return; 36 | } 37 | 38 | if (!node.labPath || !node.labPath.absolute) { 39 | vscode.window.showErrorMessage("Error: Could not determine lab path for this node."); 40 | return; 41 | } 42 | 43 | // Use the short node name if available to support custom prefixes 44 | const shortNodeName = node.name_short ?? node.name.replace(/^clab-[^-]+-/, ''); 45 | 46 | const tempLabNode = new ClabLabTreeNode( 47 | path.basename(node.labPath.absolute), 48 | vscode.TreeItemCollapsibleState.None, 49 | node.labPath, 50 | undefined, 51 | undefined, 52 | undefined, 53 | "containerlabLabDeployed" 54 | ); 55 | 56 | const saveCmd = new ClabCommand("save", tempLabNode); 57 | // Use --node-filter instead of -n and use the short name 58 | saveCmd.run(["--node-filter", shortNodeName]); 59 | } 60 | -------------------------------------------------------------------------------- /test/unit/topoViewer/managerFreeText.rememberStyle.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import cytoscape from 'cytoscape'; 5 | import { FreeTextManager } from '../../../src/topoViewer/webview/features/annotations/FreeTextManager'; 6 | 7 | // ensure window is available 8 | (globalThis as any).window = globalThis; 9 | 10 | describe('ManagerFreeText remember style', () => { 11 | it('applies last used style to new free text', async () => { 12 | const cy = cytoscape({ headless: true }); 13 | const messageSender = { sendMessageToVscodeEndpointPost: async () => ({}) } as any; 14 | const mgr = new FreeTextManager(cy, messageSender); 15 | 16 | mgr.addFreeTextAnnotation({ 17 | id: 'freeText_1', 18 | text: 'First', 19 | position: { x: 0, y: 0 }, 20 | fontSize: 18, 21 | fontColor: '#ff0000', 22 | backgroundColor: '#00ff00', 23 | fontWeight: 'bold', 24 | fontStyle: 'italic', 25 | textDecoration: 'underline', 26 | fontFamily: 'serif' 27 | }); 28 | 29 | const mgrAny = mgr as any; 30 | const modal = mgrAny.modalController as any; 31 | let passedAnnotation: any; 32 | modal.promptForTextWithFormatting = async (_title: string, annotation: any) => { 33 | passedAnnotation = annotation; 34 | return { ...annotation, text: 'Second' }; 35 | }; 36 | 37 | await mgrAny.addFreeTextAtPosition({ x: 10, y: 20 }); 38 | 39 | expect(passedAnnotation).to.include({ 40 | fontSize: 18, 41 | fontColor: '#ff0000', 42 | backgroundColor: '#00ff00', 43 | fontWeight: 'bold', 44 | fontStyle: 'italic', 45 | textDecoration: 'underline', 46 | fontFamily: 'serif' 47 | }); 48 | 49 | const annotations = mgr.getAnnotations(); 50 | const second = annotations.find(a => a.id !== 'freeText_1'); 51 | expect(second).to.include({ 52 | fontSize: 18, 53 | fontColor: '#ff0000', 54 | backgroundColor: '#00ff00', 55 | fontWeight: 'bold', 56 | fontStyle: 'italic', 57 | textDecoration: 'underline', 58 | fontFamily: 'serif' 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/commands/cloneRepo.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as path from "path"; 3 | import * as os from "os"; 4 | import * as fs from "fs"; 5 | import { outputChannel } from "../extension"; 6 | import { runCommand } from "../utils/utils"; 7 | 8 | export async function cloneRepoFromUrl(repoUrl?: string) { 9 | if (!repoUrl) { 10 | repoUrl = await vscode.window.showInputBox({ 11 | title: "Git repository URL", 12 | placeHolder: "https://github.com/user/repo.git", 13 | prompt: "Enter the repository to clone" 14 | }); 15 | if (!repoUrl) { 16 | return; 17 | } 18 | } 19 | 20 | const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; 21 | const destBase = workspaceRoot ?? path.join(os.homedir(), ".clab"); 22 | 23 | if (!fs.existsSync(destBase)) { 24 | fs.mkdirSync(destBase, { recursive: true }); 25 | } 26 | 27 | const repoName = path.basename(repoUrl.replace(/\.git$/, "")); 28 | const dest = path.join(destBase, repoName); 29 | 30 | outputChannel.info(`git clone ${repoUrl} ${dest}`); 31 | 32 | try { 33 | const command = `git clone ${repoUrl} "${dest}"`; 34 | await runCommand( 35 | command, 36 | 'Clone repository', 37 | outputChannel, 38 | false, 39 | false 40 | ); 41 | vscode.window.showInformationMessage(`Repository cloned to ${dest}`); 42 | vscode.commands.executeCommand('containerlab.refresh'); 43 | } catch (error: any) { 44 | vscode.window.showErrorMessage(`Git clone failed: ${error.message || String(error)}`); 45 | outputChannel.error(`git clone failed: ${error.message || String(error)}`); 46 | } 47 | } 48 | 49 | export async function cloneRepo() { 50 | const choice = await vscode.window.showQuickPick( 51 | [ 52 | { label: 'Clone via Git URL', action: 'url' }, 53 | { label: 'Clone popular lab', action: 'popular' }, 54 | ], 55 | { title: 'Clone repository' } 56 | ); 57 | 58 | if (!choice) { 59 | return; 60 | } 61 | 62 | if (choice.action === 'url') { 63 | await cloneRepoFromUrl(); 64 | } else if (choice.action === 'popular') { 65 | const mod = await import('./clonePopularRepo'); 66 | await mod.clonePopularRepo(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/unit/topoViewer/managerCytoscapeBaseStyles.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global describe, it, before, beforeEach, after, global */ 3 | import { expect } from 'chai'; 4 | import { getCytoscapeStyles, extractNodeIcons } from '../../../src/topoViewer/webview/features/canvas/BaseStyles'; 5 | 6 | describe('managerCytoscapeBaseStyles', () => { 7 | let originalWindow: any; 8 | let originalDocument: any; 9 | 10 | before(() => { 11 | // Save original values 12 | originalWindow = (global as any).window; 13 | originalDocument = (global as any).document; 14 | }); 15 | 16 | beforeEach(() => { 17 | // Set up mocks before each test 18 | (global as any).window = { 19 | getComputedStyle: () => ({ 20 | getPropertyValue: (prop: string) => { 21 | const values: Record = { 22 | '--vscode-focusBorder': '#007acc', 23 | '--vscode-list-focusBackground': '#073655', 24 | }; 25 | return values[prop] || ''; 26 | }, 27 | }), 28 | }; 29 | 30 | (global as any).document = { 31 | documentElement: {}, 32 | }; 33 | }); 34 | 35 | after(() => { 36 | // Restore original values 37 | (global as any).window = originalWindow; 38 | (global as any).document = originalDocument; 39 | }); 40 | 41 | it('applies group styling based on theme', () => { 42 | const light = getCytoscapeStyles('light'); 43 | const dark = getCytoscapeStyles('dark'); 44 | const lightGroup = light.find((s: any) => s.selector === 'node[topoViewerRole="group"]'); 45 | const darkGroup = dark.find((s: any) => s.selector === 'node[topoViewerRole="group"]'); 46 | expect(lightGroup?.style['background-color']).to.equal('#a6a6a6'); 47 | expect(lightGroup?.style['background-opacity']).to.equal('0.4'); 48 | expect(darkGroup?.style['background-color']).to.equal('#d9d9d9'); 49 | expect(darkGroup?.style['background-opacity']).to.equal('0.2'); 50 | }); 51 | 52 | it('extracts node icons excluding special roles', () => { 53 | const icons = extractNodeIcons(); 54 | expect(icons).to.include('router'); 55 | expect(icons).to.include('pe'); 56 | expect(icons).to.not.include('group'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/unit/topoViewer/managerCopyPaste.duplicateGroupLabel.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import cytoscape from 'cytoscape'; 5 | import { CopyPasteManager } from '../../../src/topoViewer/webview/features/nodes/CopyPasteManager'; 6 | import { GroupStyleManager } from '../../../src/topoViewer/webview/features/groups/GroupStyleManager'; 7 | import { FreeTextManager } from '../../../src/topoViewer/webview/features/annotations/FreeTextManager'; 8 | 9 | (globalThis as any).window = globalThis; 10 | 11 | describe('CopyPasteManager duplicate groups', () => { 12 | it('keeps label and generates sequential ids on paste', () => { 13 | const cy = cytoscape({ headless: true, elements: [ 14 | { data: { id: 'test:1', name: 'test', label: 'test', topoViewerRole: 'group', extraData: { 15 | clabServerUsername: '', weight: '', name: '', topoViewerGroup: 'test', topoViewerGroupLevel: '1' 16 | } } } 17 | ]}); 18 | const messageSender = { sendMessageToVscodeEndpointPost: async () => ({}) } as any; 19 | const groupStyle = new GroupStyleManager(cy, messageSender); 20 | const freeText = { addFreeTextAnnotation: () => {}, getAnnotations: () => [] } as unknown as FreeTextManager; 21 | const mgr = new CopyPasteManager(cy, messageSender, groupStyle, freeText); 22 | 23 | const copyData = { 24 | elements: [ 25 | { group: 'nodes', data: { id: 'test:1', name: 'test', label: 'test', topoViewerRole: 'group', extraData: { 26 | clabServerUsername: '', weight: '', name: '', topoViewerGroup: 'test', topoViewerGroupLevel: '1' 27 | } }, position: { x: 0, y: 0 } } 28 | ], 29 | annotations: { groupStyleAnnotations: [], freeTextAnnotations: [], cloudNodeAnnotations: [], nodeAnnotations: [] }, 30 | originalCenter: { x: 0, y: 0 } 31 | }; 32 | 33 | mgr.performPaste(copyData); 34 | 35 | const ids = cy.nodes().map(n => n.id()); 36 | expect(ids).to.include('test:1'); 37 | expect(ids).to.include('test:2'); 38 | const newGroup = cy.getElementById('test:2'); 39 | expect(newGroup.data('name')).to.equal('test'); 40 | expect(newGroup.data('label')).to.equal('test'); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | # Only run on the main repository, not on forks 11 | if: github.repository == 'srl-labs/vscode-containerlab' 12 | permissions: 13 | contents: write 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | with: 19 | ref: ${{ github.event.release.target_commitish }} 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: '20' 25 | cache: 'npm' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Validate version 31 | id: version 32 | run: | 33 | # Get version from package.json 34 | PACKAGE_VERSION=$(node -p "require('./package.json').version") 35 | echo "package=$PACKAGE_VERSION" >> $GITHUB_OUTPUT 36 | 37 | # Get version from release tag 38 | TAG_NAME="${{ github.event.release.tag_name }}" 39 | TAG_VERSION="${TAG_NAME#v}" 40 | echo "tag=$TAG_VERSION" >> $GITHUB_OUTPUT 41 | 42 | # Validate they match 43 | if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then 44 | echo "❌ Version mismatch!" 45 | echo "Package.json version: $PACKAGE_VERSION" 46 | echo "Release tag version: $TAG_VERSION" 47 | echo "" 48 | echo "Please ensure package.json version matches the release tag." 49 | exit 1 50 | fi 51 | 52 | echo "✅ Version validation passed: $PACKAGE_VERSION" 53 | 54 | - name: Run linter 55 | run: npm run lint 56 | 57 | - name: Run tests 58 | run: npm test 59 | 60 | - name: Package extension 61 | run: npm run package 62 | 63 | - name: Upload VSIX to release 64 | uses: softprops/action-gh-release@v2 65 | with: 66 | files: | 67 | vscode-containerlab-${{ steps.version.outputs.package }}.vsix 68 | fail_on_unmatched_files: true -------------------------------------------------------------------------------- /test/unit/topoViewer/managerRegistry.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import cytoscape from 'cytoscape'; 5 | 6 | import { 7 | layoutAlgoManager, 8 | zoomToFitManager, 9 | labelEndpointManager, 10 | getGroupManager, 11 | } from '../../../src/topoViewer/webview/core/managerRegistry'; 12 | import { 13 | layoutAlgoManager as layoutAlgoManager2, 14 | zoomToFitManager as zoomToFitManager2, 15 | labelEndpointManager as labelEndpointManager2, 16 | } from '../../../src/topoViewer/webview/core/managerRegistry'; 17 | import { LayoutManager } from '../../../src/topoViewer/webview/features/canvas/LayoutManager'; 18 | import { GroupManager } from '../../../src/topoViewer/webview/features/groups/GroupManager'; 19 | import { ZoomToFitManager } from '../../../src/topoViewer/webview/features/canvas/ZoomToFitManager'; 20 | import { LinkLabelManager } from '../../../src/topoViewer/webview/features/canvas/LinkLabelManager'; 21 | import { GroupStyleManager } from '../../../src/topoViewer/webview/features/groups/GroupStyleManager'; 22 | 23 | // Ensure window is defined for modules that expect it 24 | (globalThis as any).window = globalThis; 25 | 26 | describe('manager registry', () => { 27 | it('exports singleton layout manager', () => { 28 | expect(layoutAlgoManager).to.equal(layoutAlgoManager2); 29 | expect(layoutAlgoManager).to.be.instanceOf(LayoutManager); 30 | }); 31 | 32 | it('provides singleton group manager', () => { 33 | const cy = cytoscape({ headless: true }); 34 | const sender = { sendMessageToVscodeEndpointPost: async () => ({}) } as any; 35 | const gsm = new GroupStyleManager(cy, sender); 36 | const gm1 = getGroupManager(cy, gsm, 'view'); 37 | const gm2 = getGroupManager(cy, gsm, 'edit'); 38 | expect(gm1).to.equal(gm2); 39 | expect(gm1).to.be.instanceOf(GroupManager); 40 | }); 41 | 42 | it('exports singleton auxiliary managers', () => { 43 | expect(zoomToFitManager).to.equal(zoomToFitManager2); 44 | expect(zoomToFitManager).to.be.instanceOf(ZoomToFitManager); 45 | 46 | expect(labelEndpointManager).to.equal(labelEndpointManager2); 47 | expect(labelEndpointManager).to.be.instanceOf(LinkLabelManager); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /esbuild.config.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | const fs = require('fs-extra'); 3 | const path = require('path'); 4 | 5 | async function build() { 6 | // Copy HTML template files to dist 7 | const templateDestDir = path.join(__dirname, 'dist'); 8 | 9 | // Copy main template 10 | await fs.copy( 11 | path.join(__dirname, 'src/topoViewer/webview/assets/templates/main.html'), 12 | path.join(templateDestDir, 'main.html') 13 | ); 14 | 15 | // Copy shared partials 16 | const sharedPartialsDir = path.join(__dirname, 'src/topoViewer/webview/assets/templates/partials'); 17 | if (fs.existsSync(sharedPartialsDir)) { 18 | await fs.copy(sharedPartialsDir, path.join(templateDestDir, 'partials')); 19 | } 20 | 21 | // Editor partials are now merged with shared partials 22 | 23 | // Copy images 24 | const commonImagesDir = path.join(__dirname, 'src/topoViewer/webview/assets/images'); 25 | const imagesDestDir = path.join(__dirname, 'dist/images'); 26 | 27 | if (fs.existsSync(commonImagesDir)) { 28 | await fs.copy(commonImagesDir, imagesDestDir); 29 | console.log('Common images copied to dist/images'); 30 | } 31 | 32 | // Note: CSS and JS files are now bundled by webpack 33 | // No need to copy them separately from html-static 34 | 35 | // Plugin to stub native .node files - ssh2 has JS fallbacks 36 | const nativeNodeModulesPlugin = { 37 | name: 'native-node-modules', 38 | setup(build) { 39 | build.onResolve({ filter: /\.node$/ }, () => ({ 40 | path: 'noop', 41 | namespace: 'native-node-empty', 42 | })); 43 | build.onLoad({ filter: /.*/, namespace: 'native-node-empty' }, () => ({ 44 | contents: 'module.exports = {};', 45 | })); 46 | }, 47 | }; 48 | 49 | // Build the extension 50 | await esbuild.build({ 51 | entryPoints: ['src/extension.ts'], 52 | bundle: true, 53 | platform: 'node', 54 | format: 'cjs', 55 | external: ['vscode'], 56 | outfile: 'dist/extension.js', 57 | sourcemap: true, 58 | plugins: [nativeNodeModulesPlugin], 59 | }); 60 | 61 | console.log('Build complete! HTML templates copied to dist/'); 62 | } 63 | 64 | build().catch((err) => { 65 | console.error('Build failed:', err); 66 | process.exit(1); 67 | }); -------------------------------------------------------------------------------- /src/commands/edgeshark.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { execCommandInTerminal } from './command'; 3 | 4 | export function getEdgesharkInstallCmd(): string { 5 | const config = vscode.workspace.getConfiguration('containerlab'); 6 | const extraEnvVars = config.get('edgeshark.extraEnvironmentVars', ''); 7 | 8 | if (extraEnvVars) { 9 | // Parse the environment variables from the setting 10 | const envLines = extraEnvVars.split(',').map(env => env.trim()).filter(env => env); 11 | if (envLines.length > 0) { 12 | // Create a temporary file approach with proper YAML injection 13 | const envSection = envLines.map(env => ` - ${env}`).join('\\n'); 14 | 15 | // Download, modify, and run the compose file using a secure temp file 16 | return `tmpFile="$(mktemp -t edgeshark-compose.XXXXXX)" && \ 17 | curl -sL https://github.com/siemens/edgeshark/raw/main/deployments/wget/docker-compose.yaml -o "$tmpFile" && \ 18 | sed -i '/gostwire:/,/^ [^ ]/ { /pull_policy:.*always/a\\ environment:\\n${envSection} 19 | }' "$tmpFile" && \ 20 | sed -i '/edgeshark:/,/^ [^ ]/ { /pull_policy:.*always/a\\ environment:\\n${envSection} 21 | }' "$tmpFile" && \ 22 | DOCKER_DEFAULT_PLATFORM= docker compose -f "$tmpFile" up -d && \ 23 | rm -f "$tmpFile"`; 24 | } 25 | } 26 | 27 | // Default command without modifications 28 | return `curl -sL \ 29 | https://github.com/siemens/edgeshark/raw/main/deployments/wget/docker-compose.yaml \ 30 | | DOCKER_DEFAULT_PLATFORM= docker compose -f - up -d`; 31 | } 32 | 33 | export function getEdgesharkUninstallCmd(): string { 34 | return `curl -sL \ 35 | https://github.com/siemens/edgeshark/raw/main/deployments/wget/docker-compose.yaml \ 36 | | DOCKER_DEFAULT_PLATFORM= docker compose -f - down`; 37 | } 38 | 39 | export const EDGESHARK_INSTALL_CMD = getEdgesharkInstallCmd(); 40 | export const EDGESHARK_UNINSTALL_CMD = getEdgesharkUninstallCmd(); 41 | 42 | export async function installEdgeshark() { 43 | execCommandInTerminal(getEdgesharkInstallCmd(), "Edgeshark Installation"); 44 | } 45 | 46 | export async function uninstallEdgeshark() { 47 | execCommandInTerminal(getEdgesharkUninstallCmd(), "Edgeshark Uninstallation"); 48 | } 49 | -------------------------------------------------------------------------------- /src/helpers/filterUtils.ts: -------------------------------------------------------------------------------- 1 | export class FilterUtils { 2 | /** 3 | * Creates a filter function that supports both regex and string matching 4 | * @param filterText The filter text (can be regex or plain string) 5 | * @returns A function that tests if a value matches the filter 6 | */ 7 | static createFilter(filterText: string) { 8 | if (!filterText) { 9 | return () => true; 10 | } 11 | 12 | const regex = this.tryCreateRegExp(filterText); 13 | if (regex) { 14 | return (value: string) => regex.test(value); 15 | } 16 | 17 | // Invalid regex -> fall back simple string 18 | const searchText = filterText.toLowerCase(); 19 | return (value: string) => value.toLowerCase().includes(searchText); 20 | } 21 | 22 | static tryCreateRegExp(filterText: string, flags = 'i'): RegExp | null { 23 | if (!filterText) { 24 | return null; 25 | } 26 | 27 | const processedPattern = this.convertUserFriendlyPattern(filterText); 28 | 29 | try { 30 | return new RegExp(processedPattern, flags); 31 | } catch { 32 | return null; 33 | } 34 | } 35 | 36 | /** 37 | * Convert user-friendly wildcard patterns to regex 38 | */ 39 | private static convertUserFriendlyPattern(pattern: string): string { 40 | if (this.looksLikeRegex(pattern)) { 41 | return pattern; 42 | } 43 | const hasWildcards = /[*?#]/.test(pattern); 44 | 45 | let converted = pattern 46 | .replace(/\*/g, '.*') // * becomes .* 47 | .replace(/\?/g, '.') // ? becomes . 48 | .replace(/#/g, '\\d+'); // # becomes \d+ (for numbers) 49 | 50 | if (hasWildcards) { 51 | converted = `^${converted}$`; 52 | } 53 | 54 | return converted; 55 | } 56 | 57 | private static looksLikeRegex(pattern: string): boolean { 58 | return pattern.includes('\\') || 59 | pattern.includes('[') || 60 | pattern.includes('(') || 61 | pattern.includes('|') || 62 | pattern.includes('^') || 63 | pattern.includes('$') || 64 | pattern.includes('.*') || 65 | pattern.includes('.+'); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /test/unit/topoViewer/groupManagerBindings.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import cytoscape from 'cytoscape'; 5 | import { GroupManager } from '../../../src/topoViewer/webview/features/groups/GroupManager'; 6 | import { GroupStyleManager } from '../../../src/topoViewer/webview/features/groups/GroupStyleManager'; 7 | 8 | // ensure window is available for global assignments 9 | (globalThis as any).window = globalThis; 10 | 11 | describe('group manager global bindings', () => { 12 | it('exposes nodeParentPropertiesUpdate on window and updates label class', async () => { 13 | const cy = cytoscape({ headless: true, elements: [ 14 | { data: { id: 'group1:1', name: 'group1', topoViewerRole: 'group', extraData: { 15 | clabServerUsername: '', weight: '', name: '', topoViewerGroup: 'group1', topoViewerGroupLevel: '1' 16 | } } } 17 | ] }); 18 | 19 | const messageSender = { sendMessageToVscodeEndpointPost: async () => ({}) } as any; 20 | const gsm = new GroupStyleManager(cy, messageSender); 21 | const mgr = new GroupManager(cy, gsm, 'edit'); 22 | (window as any).nodeParentPropertiesUpdate = mgr.nodeParentPropertiesUpdate.bind(mgr); 23 | 24 | const elements: Record = { 25 | 'panel-node-editor-parent-graph-group-id': { textContent: 'group1:1' }, 26 | 'panel-node-editor-parent-graph-group': { value: 'group1' }, 27 | 'panel-node-editor-parent-graph-level': { value: '1' }, 28 | 'panel-node-editor-parent-label-dropdown-button-text': { textContent: 'top-center' }, 29 | 'panel-node-editor-parent-bg-color': { value: '#d9d9d9' }, 30 | 'panel-node-editor-parent-border-color': { value: '#DDDDDD' }, 31 | 'panel-node-editor-parent-border-width': { value: '0.5' }, 32 | 'panel-node-editor-parent-text-color': { value: '#EBECF0' } 33 | }; 34 | (globalThis as any).document = { getElementById: (id: string) => elements[id] } as any; 35 | (globalThis as any).acquireVsCodeApi = () => ({ window: { showWarningMessage: () => {} } }); 36 | (globalThis as any).sendMessageToVscodeEndpointPost = async () => {}; 37 | 38 | expect(typeof (window as any).nodeParentPropertiesUpdate).to.equal('function'); 39 | await (window as any).nodeParentPropertiesUpdate!(); 40 | expect(cy.getElementById('group1:1').hasClass('top-center')).to.be.true; 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/topoViewer/extension/services/DeploymentStateChecker.ts: -------------------------------------------------------------------------------- 1 | import { log } from '../../webview/platform/logging/logger'; 2 | import * as inspector from "../../../treeView/inspector"; 3 | 4 | export type DeploymentState = 'deployed' | 'undeployed' | 'unknown'; 5 | 6 | /** 7 | * Checks deployment state of containerlab labs by querying inspect data. 8 | */ 9 | export class DeploymentStateChecker { 10 | /** 11 | * Check if a lab is deployed by querying containerlab 12 | */ 13 | async checkDeploymentState( 14 | labName: string, 15 | topoFilePath: string | undefined, 16 | updateLabName?: (newLabName: string) => void 17 | ): Promise { 18 | try { 19 | await inspector.update(); 20 | if (!inspector.rawInspectData) return 'unknown'; 21 | if (this.labExistsByName(labName)) return 'deployed'; 22 | if (topoFilePath) { 23 | const matchedLabName = this.findLabByTopoFile(topoFilePath); 24 | if (matchedLabName) { 25 | if (updateLabName && matchedLabName !== labName) { 26 | log.info(`Updating lab name from '${labName}' to '${matchedLabName}' based on topo-file match`); 27 | updateLabName(matchedLabName); 28 | } 29 | return 'deployed'; 30 | } 31 | } 32 | return 'undeployed'; 33 | } catch (err) { 34 | log.warn(`Failed to check deployment state: ${err}`); 35 | return 'unknown'; 36 | } 37 | } 38 | 39 | /** 40 | * Check if a lab with the given name exists in inspect data 41 | */ 42 | private labExistsByName(labName: string): boolean { 43 | return labName in (inspector.rawInspectData as any); 44 | } 45 | 46 | /** 47 | * Find a lab by its topo-file path and return the lab name if found 48 | */ 49 | private findLabByTopoFile(topoFilePath: string): string | null { 50 | const normalizedYamlPath = topoFilePath.replace(/\\/g, '/'); 51 | for (const [deployedLabName, labData] of Object.entries(inspector.rawInspectData as any)) { 52 | const topo = (labData as any)['topo-file']; 53 | if (!topo) continue; 54 | const normalizedTopoFile = (topo as string).replace(/\\/g, '/'); 55 | if (normalizedTopoFile === normalizedYamlPath) { 56 | return deployedLabName; 57 | } 58 | } 59 | return null; 60 | } 61 | } 62 | 63 | // Export a singleton instance 64 | export const deploymentStateChecker = new DeploymentStateChecker(); 65 | -------------------------------------------------------------------------------- /src/commands/nodeExec.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { execCommandInTerminal } from "./command"; 3 | import { execCmdMapping } from "../extension"; 4 | import { ClabContainerTreeNode } from "../treeView/common"; 5 | import { DEFAULT_ATTACH_SHELL_CMD, DEFAULT_ATTACH_TELNET_PORT } from "../utils"; 6 | 7 | interface NodeContext { 8 | containerId: string; 9 | containerKind: string; 10 | container: string; 11 | } 12 | 13 | function getNodeContext(node: ClabContainerTreeNode | undefined): NodeContext | undefined { 14 | if (!node) { 15 | vscode.window.showErrorMessage("No container node selected."); 16 | return undefined; 17 | } 18 | const containerId = node.cID; 19 | const containerKind = node.kind; 20 | if (!containerId) { 21 | vscode.window.showErrorMessage("No containerId for shell attach."); 22 | return undefined; 23 | } 24 | if (!containerKind) { 25 | vscode.window.showErrorMessage("No container kind for shell attach."); 26 | return undefined; 27 | } 28 | const container = node.name || containerId; 29 | return { containerId, containerKind, container }; 30 | } 31 | 32 | export function attachShell(node: ClabContainerTreeNode | undefined): void { 33 | const ctx = getNodeContext(node); 34 | if (!ctx) return; 35 | 36 | let execCmd = (execCmdMapping as any)[ctx.containerKind] || DEFAULT_ATTACH_SHELL_CMD; 37 | const config = vscode.workspace.getConfiguration("containerlab"); 38 | const userExecMapping = config.get("node.execCommandMapping") as { [key: string]: string }; 39 | const runtime = config.get("runtime", "docker"); 40 | 41 | execCmd = userExecMapping[ctx.containerKind] || execCmd; 42 | 43 | execCommandInTerminal( 44 | `${runtime} exec -it ${ctx.containerId} ${execCmd}`, 45 | `Shell - ${ctx.container}`, 46 | true // If terminal exists, just focus it 47 | ); 48 | } 49 | 50 | export function telnetToNode(node: ClabContainerTreeNode | undefined): void { 51 | const ctx = getNodeContext(node); 52 | if (!ctx) return; 53 | const config = vscode.workspace.getConfiguration("containerlab"); 54 | const port = (config.get("node.telnetPort") as number) || DEFAULT_ATTACH_TELNET_PORT; 55 | const runtime = config.get("runtime", "docker"); 56 | execCommandInTerminal( 57 | `${runtime} exec -it ${ctx.containerId} telnet 127.0.0.1 ${port}`, 58 | `Telnet - ${ctx.container}`, 59 | true // If terminal exists, just focus it 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /test/unit/topoViewer/managerGroupManagement.allowDuplicateLabel.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { describe, it } from 'mocha'; 3 | import { expect } from 'chai'; 4 | import cytoscape from 'cytoscape'; 5 | import { GroupManager } from '../../../src/topoViewer/webview/features/groups/GroupManager'; 6 | import { GroupStyleManager } from '../../../src/topoViewer/webview/features/groups/GroupStyleManager'; 7 | 8 | (globalThis as any).window = globalThis; 9 | 10 | describe('ManagerGroupManagement duplicate group labels', () => { 11 | it('allows creating groups with identical labels by generating unique ids', async () => { 12 | const cy = cytoscape({ headless: true, elements: [ 13 | { data: { id: 'group1:1', name: 'group1', topoViewerRole: 'group', extraData: { 14 | clabServerUsername: '', weight: '', name: '', topoViewerGroup: 'group1', topoViewerGroupLevel: '1' 15 | } } }, 16 | { data: { id: 'group2:1', name: 'group2', topoViewerRole: 'group', extraData: { 17 | clabServerUsername: '', weight: '', name: '', topoViewerGroup: 'group2', topoViewerGroupLevel: '1' 18 | } } } 19 | ]}); 20 | const messageSender = { sendMessageToVscodeEndpointPost: async () => ({}) } as any; 21 | const styleManager = new GroupStyleManager(cy, messageSender); 22 | const mgr = new GroupManager(cy, styleManager, 'edit'); 23 | 24 | const elements: Record = { 25 | 'panel-node-editor-parent-graph-group-id': { textContent: 'group2:1' }, 26 | 'panel-node-editor-parent-graph-group': { value: 'group1' }, 27 | 'panel-node-editor-parent-graph-level': { value: '1' }, 28 | 'panel-node-editor-parent-label-dropdown-button-text': { textContent: 'top-center' }, 29 | 'panel-node-editor-parent-bg-color': { value: '#d9d9d9' }, 30 | 'panel-node-editor-parent-border-color': { value: '#DDDDDD' }, 31 | 'panel-node-editor-parent-border-width': { value: '0.5' }, 32 | 'panel-node-editor-parent-text-color': { value: '#EBECF0' } 33 | }; 34 | (globalThis as any).document = { getElementById: (id: string) => elements[id] } as any; 35 | (globalThis as any).sendMessageToVscodeEndpointPost = async () => {}; 36 | 37 | await mgr.nodeParentPropertiesUpdate(); 38 | 39 | const ids = cy.nodes().map(n => n.id()); 40 | expect(ids).to.include('group1:1'); 41 | expect(ids).to.include('group1:2'); 42 | const group1Nodes = cy.nodes().filter(n => n.data('name') === 'group1'); 43 | expect(group1Nodes.length).to.equal(2); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/commands/fcli.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as fs from "fs"; 3 | import * as YAML from "yaml"; 4 | import { execCommandInTerminal } from "./command"; 5 | import { ClabLabTreeNode } from "../treeView/common"; 6 | 7 | function buildNetworkFromYaml(topoPath: string): string { 8 | try { 9 | const content = fs.readFileSync(topoPath, "utf8"); 10 | const doc = YAML.parse(content); 11 | const net = doc?.mgmt?.network; 12 | if (typeof net === 'string' && net.trim().length > 0) { 13 | return net.trim(); 14 | } 15 | } catch { 16 | // ignore errors 17 | } 18 | return "clab"; 19 | } 20 | 21 | function runFcli(node: ClabLabTreeNode, cmd: string) { 22 | if (!node) { 23 | vscode.window.showErrorMessage('No lab node selected.'); 24 | return; 25 | } 26 | 27 | const topo = node.labPath?.absolute; 28 | if (!topo) { 29 | vscode.window.showErrorMessage('No topology path found.'); 30 | return; 31 | } 32 | 33 | const config = vscode.workspace.getConfiguration("containerlab"); 34 | const runtime = config.get("runtime", "docker"); 35 | const extraArgs = config.get("extras.fcli.extraDockerArgs", "") 36 | 37 | const network = buildNetworkFromYaml(topo); 38 | 39 | const command = `${runtime} run --pull always -it --network ${network} --rm -v /etc/hosts:/etc/hosts:ro -v "${topo}":/topo.yml ${extraArgs} ghcr.io/srl-labs/nornir-srl:latest -t /topo.yml ${cmd}`; 40 | 41 | execCommandInTerminal(command, `fcli - ${node.label}`); 42 | } 43 | 44 | export const fcliBgpPeers = (node: ClabLabTreeNode) => runFcli(node, "bgp-peers"); 45 | export const fcliBgpRib = (node: ClabLabTreeNode) => runFcli(node, "bgp-rib"); 46 | export const fcliIpv4Rib = (node: ClabLabTreeNode) => runFcli(node, "ipv4-rib"); 47 | export const fcliLldp = (node: ClabLabTreeNode) => runFcli(node, "lldp"); 48 | export const fcliMac = (node: ClabLabTreeNode) => runFcli(node, "mac"); 49 | export const fcliNi = (node: ClabLabTreeNode) => runFcli(node, "ni"); 50 | export const fcliSubif = (node: ClabLabTreeNode) => runFcli(node, "subif"); 51 | export const fcliSysInfo = (node: ClabLabTreeNode) => runFcli(node, "sys-info"); 52 | 53 | export async function fcliCustom(node: ClabLabTreeNode) { 54 | const val = await vscode.window.showInputBox({ 55 | title: 'Custom fcli command', 56 | placeHolder: 'Enter command, e.g. bgp-peers', 57 | }); 58 | if (!val || val.trim().length === 0) { 59 | return; 60 | } 61 | runFcli(node, val.trim()); 62 | } 63 | -------------------------------------------------------------------------------- /test/unit/topoViewer/saveViewport.cloudNodeGroup.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global describe, it, after, afterEach, __dirname */ 3 | import { expect } from 'chai'; 4 | import sinon from 'sinon'; 5 | import fs from 'fs'; 6 | import os from 'os'; 7 | import path from 'path'; 8 | import Module from 'module'; 9 | 10 | const originalResolve = (Module as any)._resolveFilename; 11 | (Module as any)._resolveFilename = function(request: string, parent: any, isMain: boolean, options: any) { 12 | if (request === 'vscode') { 13 | return path.join(__dirname, '..', '..', 'helpers', 'vscode-stub.js'); 14 | } 15 | if (request.endsWith('logging/logger')) { 16 | return path.join(__dirname, '..', '..', 'helpers', 'extensionLogger-stub.js'); 17 | } 18 | return originalResolve.call(this, request, parent, isMain, options); 19 | }; 20 | 21 | import { saveViewport } from '../../../src/topoViewer/extension/services/SaveViewport'; 22 | import { annotationsManager } from '../../../src/topoViewer/extension/services/AnnotationsFile'; 23 | 24 | describe('saveViewport cloud node group handling', () => { 25 | after(() => { 26 | (Module as any)._resolveFilename = originalResolve; 27 | }); 28 | 29 | afterEach(() => { 30 | sinon.restore(); 31 | }); 32 | 33 | it('saves group and level for cloud nodes', async () => { 34 | const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'saveViewport-')); 35 | const yamlPath = path.join(tmpDir, 'test.clab.yaml'); 36 | fs.writeFileSync(yamlPath, 'name: test\ntopology:\n nodes: {}\n'); 37 | 38 | sinon.stub(annotationsManager, 'loadAnnotations').resolves({ 39 | freeTextAnnotations: [], 40 | groupStyleAnnotations: [], 41 | cloudNodeAnnotations: [], 42 | nodeAnnotations: [] 43 | }); 44 | const saveStub = sinon.stub(annotationsManager, 'saveAnnotations').resolves(); 45 | 46 | const payload = JSON.stringify([ 47 | { 48 | group: 'nodes', 49 | data: { 50 | id: 'host:eth1', 51 | topoViewerRole: 'cloud', 52 | name: 'host:eth1', 53 | extraData: { kind: 'host' } 54 | }, 55 | position: { x: 10, y: 20 }, 56 | parent: 'grp:1' 57 | } 58 | ]); 59 | 60 | await saveViewport({ mode: 'view', yamlFilePath: yamlPath, payload }); 61 | 62 | expect(saveStub.calledOnce).to.be.true; 63 | const annotations = saveStub.firstCall.args[1] as any; 64 | expect(annotations.cloudNodeAnnotations).to.have.lengthOf(1); 65 | expect(annotations.cloudNodeAnnotations[0]).to.include({ id: 'host:eth1', group: 'grp', level: '1' }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/unit/topoViewer/logging/logger.webview.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, node */ 2 | /* global __dirname, global */ 3 | import { expect } from 'chai'; 4 | import sinon from 'sinon'; 5 | import path from 'path'; 6 | import { describe, it, afterEach } from 'mocha'; 7 | import fs from 'fs'; 8 | import vm from 'vm'; 9 | 10 | describe('logger (webview)', () => { 11 | const loggerPath = path.join( 12 | __dirname, 13 | '..', 14 | '..', 15 | '..', 16 | '..', 17 | 'src', 18 | 'topoViewer', 19 | 'webview', 20 | 'platform', 21 | 'logging', 22 | 'logger' 23 | ); 24 | const originalWindow = (global as any).window; 25 | 26 | afterEach(() => { 27 | delete require.cache[require.resolve(loggerPath)]; 28 | sinon.restore(); 29 | (global as any).window = originalWindow; 30 | }); 31 | 32 | it('posts messages to VS Code extension host', () => { 33 | const postMessage = sinon.spy(); 34 | const resolved = `${loggerPath}.js`; 35 | const code = fs.readFileSync(resolved, 'utf8'); 36 | const shared: any = {}; 37 | const sandbox: any = { 38 | exports: shared, 39 | module: { exports: shared }, 40 | require, 41 | __filename: resolved, 42 | __dirname: path.dirname(resolved), 43 | window: { vscode: { postMessage } } 44 | }; 45 | vm.runInNewContext(code, sandbox, { filename: resolved }); 46 | 47 | const { log } = sandbox.exports as typeof import('../../../../src/topoViewer/webview/platform/logging/logger'); 48 | log.error('boom'); 49 | 50 | expect(postMessage.calledOnce).to.be.true; 51 | const arg = postMessage.firstCall.args[0]; 52 | expect(arg.command).to.equal('topoViewerLog'); 53 | expect(arg.level).to.equal('error'); 54 | expect(arg.message).to.equal('boom'); 55 | expect(arg.fileLine).to.be.a('string'); 56 | }); 57 | 58 | it('stringifies objects before sending', () => { 59 | const postMessage = sinon.spy(); 60 | const resolved = `${loggerPath}.js`; 61 | const code = fs.readFileSync(resolved, 'utf8'); 62 | const shared: any = {}; 63 | const sandbox: any = { 64 | exports: shared, 65 | module: { exports: shared }, 66 | require, 67 | __filename: resolved, 68 | __dirname: path.dirname(resolved), 69 | window: { vscode: { postMessage } } 70 | }; 71 | vm.runInNewContext(code, sandbox, { filename: resolved }); 72 | 73 | const { log } = sandbox.exports as typeof import('../../../../src/topoViewer/webview/platform/logging/logger'); 74 | log.info({ a: 1 }); 75 | 76 | const arg = postMessage.firstCall.args[0]; 77 | expect(arg.message).to.equal('{"a":1}'); 78 | expect(arg.level).to.equal('info'); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/unit/commands/openFolderInNewWindow.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global describe, it, after, beforeEach, afterEach, __dirname */ 3 | /** 4 | * Tests for the `openFolderInNewWindow` command. 5 | * 6 | * The suite checks that the selected lab folder is opened in a new 7 | * VS Code window using the stubbed `vscode` API. Additional cases 8 | * cover how the command surfaces errors when required arguments are 9 | * missing. 10 | */ 11 | // The command simply invokes `vscode.openFolder` on the chosen folder path 12 | import { expect } from 'chai'; 13 | import sinon from 'sinon'; 14 | import Module from 'module'; 15 | import path from 'path'; 16 | 17 | // Replace the vscode module with our stub before importing the command 18 | const originalResolve = (Module as any)._resolveFilename; 19 | (Module as any)._resolveFilename = function (request: string, parent: any, isMain: boolean, options: any) { 20 | if (request === 'vscode') { 21 | return path.join(__dirname, '..', '..', 'helpers', 'vscode-stub.js'); 22 | } 23 | return originalResolve.call(this, request, parent, isMain, options); 24 | }; 25 | 26 | import { openFolderInNewWindow } from '../../../src/commands/openFolderInNewWindow'; 27 | const vscodeStub = require('../../helpers/vscode-stub'); 28 | 29 | describe('openFolderInNewWindow command', () => { 30 | after(() => { 31 | (Module as any)._resolveFilename = originalResolve; 32 | }); 33 | 34 | beforeEach(() => { 35 | vscodeStub.window.lastErrorMessage = ''; 36 | vscodeStub.commands.executed = []; 37 | sinon.spy(vscodeStub.commands, 'executeCommand'); 38 | sinon.spy(vscodeStub.window, 'showErrorMessage'); 39 | }); 40 | 41 | afterEach(() => { 42 | sinon.restore(); 43 | }); 44 | 45 | // Should open the lab folder in a separate VS Code window. 46 | it('opens the folder in a new window', async () => { 47 | const node = { labPath: { absolute: '/home/user/lab.yml' } } as any; 48 | await openFolderInNewWindow(node); 49 | 50 | const spy = (vscodeStub.commands.executeCommand as sinon.SinonSpy); 51 | expect(spy.calledOnce).to.be.true; 52 | expect(spy.firstCall.args[0]).to.equal('vscode.openFolder'); 53 | expect(spy.firstCall.args[1].fsPath).to.equal('/home/user'); 54 | expect(spy.firstCall.args[2]).to.deep.equal({ forceNewWindow: true }); 55 | }); 56 | 57 | // Should surface an error message when the labPath is not provided. 58 | it('shows an error when labPath is missing', async () => { 59 | await openFolderInNewWindow({ labPath: { absolute: '' } } as any); 60 | const spy = (vscodeStub.window.showErrorMessage as sinon.SinonSpy); 61 | expect(spy.calledOnceWith('No lab path found for this lab.')).to.be.true; 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/unit/commands/openLabFile.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global describe, it, after, beforeEach, afterEach, __dirname */ 3 | /** 4 | * Tests for the `openLabFile` command. 5 | * 6 | * The suite ensures that the correct topology file is opened via the 7 | * stubbed `vscode` API and that helpful errors are displayed when 8 | * required arguments are missing. 9 | */ 10 | // These tests run against a stubbed version of the VS Code API 11 | import { expect } from 'chai'; 12 | import sinon from 'sinon'; 13 | import Module from 'module'; 14 | import path from 'path'; 15 | 16 | // Replace the vscode module with our stub before importing the command 17 | const originalResolve = (Module as any)._resolveFilename; 18 | (Module as any)._resolveFilename = function (request: string, parent: any, isMain: boolean, options: any) { 19 | if (request === 'vscode') { 20 | return path.join(__dirname, '..', '..', 'helpers', 'vscode-stub.js'); 21 | } 22 | return originalResolve.call(this, request, parent, isMain, options); 23 | }; 24 | 25 | import { openLabFile } from '../../../src/commands/openLabFile'; 26 | const vscodeStub = require('../../helpers/vscode-stub'); 27 | 28 | describe('openLabFile command', () => { 29 | after(() => { 30 | (Module as any)._resolveFilename = originalResolve; 31 | }); 32 | 33 | beforeEach(() => { 34 | vscodeStub.window.lastErrorMessage = ''; 35 | vscodeStub.commands.executed = []; 36 | sinon.spy(vscodeStub.commands, 'executeCommand'); 37 | sinon.spy(vscodeStub.window, 'showErrorMessage'); 38 | }); 39 | 40 | afterEach(() => { 41 | sinon.restore(); 42 | }); 43 | 44 | // Opens the topology file in the current VS Code window. 45 | it('opens the lab file with vscode.open', () => { 46 | const node = { labPath: { absolute: '/home/user/lab.yml' } } as any; 47 | openLabFile(node); 48 | 49 | const spy = (vscodeStub.commands.executeCommand as sinon.SinonSpy); 50 | expect(spy.calledOnce).to.be.true; 51 | expect(spy.firstCall.args[0]).to.equal('vscode.open'); 52 | expect(spy.firstCall.args[1].fsPath).to.equal('/home/user/lab.yml'); 53 | }); 54 | 55 | // Should show an error message when no node is provided. 56 | it('shows an error when node is undefined', () => { 57 | openLabFile(undefined as any); 58 | const spy = (vscodeStub.window.showErrorMessage as sinon.SinonSpy); 59 | expect(spy.calledOnceWith('No lab node selected.')).to.be.true; 60 | }); 61 | 62 | // Should show an error if the selected node has no labPath. 63 | it('shows an error when labPath is missing', () => { 64 | openLabFile({ labPath: { absolute: '' } } as any); 65 | const spy = (vscodeStub.window.showErrorMessage as sinon.SinonSpy); 66 | expect(spy.calledOnceWith('No labPath found.')).to.be.true; 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/topoViewer/shared/types/topoViewerType.ts: -------------------------------------------------------------------------------- 1 | // file: src/types/topoViewerType.ts 2 | 3 | 4 | /** 5 | * Represents a Containerlab node definition as specified in the YAML configuration. 6 | */ 7 | export interface ClabNode { 8 | [key: string]: any; 9 | kind?: string; 10 | image?: string; 11 | type?: string; 12 | group?: string; 13 | labels?: Record; 14 | } 15 | 16 | /** 17 | * Represents a Containerlab link definition as specified in the YAML configuration. 18 | */ 19 | export interface ClabLinkEndpointMap { 20 | node: string; 21 | interface?: string; 22 | mac?: string; 23 | } 24 | 25 | export interface ClabLink { 26 | // Short format 27 | endpoints?: (string | ClabLinkEndpointMap)[]; 28 | // Extended single-endpoint 29 | endpoint?: ClabLinkEndpointMap; 30 | // Extended common 31 | type?: 'veth' | 'host' | 'mgmt-net' | 'macvlan' | 'dummy' | 'vxlan' | 'vxlan-stitch' | string; 32 | mtu?: number | string; 33 | vars?: any; 34 | labels?: any; 35 | // Per-type fields 36 | 'host-interface'?: string; 37 | mode?: string; 38 | remote?: string; 39 | vni?: number | string; 40 | 'dst-port'?: number | string; 41 | 'src-port'?: number | string; 42 | } 43 | 44 | /** 45 | * Represents the main Containerlab topology structure as defined in the YAML configuration. 46 | */ 47 | export interface ClabTopology { 48 | name?: string 49 | prefix?: string; 50 | topology?: { 51 | defaults?: ClabNode; 52 | kinds?: Record; 53 | groups?: Record; 54 | nodes?: Record; 55 | links?: ClabLink[]; 56 | }; 57 | } 58 | 59 | /** 60 | * Represents a single Cytoscape element, either a node or an edge. 61 | */ 62 | export interface CyElement { 63 | group: 'nodes' | 'edges'; 64 | data: Record; 65 | position?: { x: number; y: number }; 66 | removed?: boolean; 67 | selected?: boolean; 68 | selectable?: boolean; 69 | locked?: boolean; 70 | grabbed?: boolean; 71 | grabbable?: boolean; 72 | classes?: string; 73 | } 74 | 75 | /** 76 | * Represents the overall Cytoscape topology as an array of elements. 77 | */ 78 | export type CytoTopology = CyElement[]; 79 | 80 | /** 81 | * Represents the structure of the environment.json configuration file. 82 | */ 83 | export interface EnvironmentJson { 84 | workingDirectory: string; 85 | clabPrefix: string; 86 | clabName: string; 87 | clabServerAddress: string; 88 | clabAllowedHostname: string; 89 | clabAllowedHostname01: string; 90 | clabServerPort: string; 91 | deploymentType: string; 92 | topoviewerVersion: string; 93 | topviewerPresetLayout: string 94 | envCyTopoJsonBytes: CytoTopology | ''; 95 | } 96 | 97 | -------------------------------------------------------------------------------- /src/topoViewer/webview/features/node-editor/ChangeTrackingManager.ts: -------------------------------------------------------------------------------- 1 | // ChangeTrackingManager.ts - Handles change tracking for the Apply button 2 | 3 | import { ID_PANEL_EDITOR_APPLY, CLASS_HAS_CHANGES } from "./NodeEditorConstants"; 4 | 5 | export class ChangeTrackingManager { 6 | private panel: HTMLElement | null = null; 7 | private initialValues: string | null = null; 8 | 9 | public setPanel(panel: HTMLElement | null): void { 10 | this.panel = panel; 11 | } 12 | 13 | /** 14 | * Captures a serialized snapshot of all form inputs in the node editor panel. 15 | */ 16 | public captureValues(): string { 17 | if (!this.panel) return ""; 18 | const inputs = this.panel.querySelectorAll("input, select, textarea"); 19 | const values: Record = {}; 20 | inputs.forEach((el, idx) => { 21 | const input = el as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; 22 | const key = input.id || input.name || `input-${idx}`; 23 | if (input.type === "checkbox") { 24 | values[key] = String((input as HTMLInputElement).checked); 25 | } else { 26 | values[key] = input.value || ""; 27 | } 28 | }); 29 | return JSON.stringify(values); 30 | } 31 | 32 | /** 33 | * Checks if there are unsaved changes in the node editor. 34 | */ 35 | public hasChanges(): boolean { 36 | if (!this.initialValues) return false; 37 | const current = this.captureValues(); 38 | return this.initialValues !== current; 39 | } 40 | 41 | /** 42 | * Updates the node editor Apply button visual state. 43 | */ 44 | public updateApplyButtonState(): void { 45 | const applyBtn = document.getElementById(ID_PANEL_EDITOR_APPLY); 46 | if (!applyBtn) return; 47 | const hasChanges = this.hasChanges(); 48 | applyBtn.classList.toggle(CLASS_HAS_CHANGES, hasChanges); 49 | } 50 | 51 | /** 52 | * Resets initial values after applying changes. 53 | */ 54 | public resetInitialValues(): void { 55 | this.initialValues = this.captureValues(); 56 | this.updateApplyButtonState(); 57 | } 58 | 59 | /** 60 | * Sets up change tracking on all form inputs in the node editor. 61 | */ 62 | public setupChangeTracking(): void { 63 | if (!this.panel) return; 64 | const inputs = this.panel.querySelectorAll("input, select, textarea"); 65 | inputs.forEach((el) => { 66 | el.addEventListener("input", () => this.updateApplyButtonState()); 67 | el.addEventListener("change", () => this.updateApplyButtonState()); 68 | }); 69 | } 70 | 71 | /** 72 | * Initialize change tracking after panel opens. 73 | * Should be called after a delay to ensure all fields are populated. 74 | */ 75 | public initializeTracking(): void { 76 | this.initialValues = this.captureValues(); 77 | this.updateApplyButtonState(); 78 | this.setupChangeTracking(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/topoViewer/webview/assets/templates/partials/viewport-drawer-topology-overview.html: -------------------------------------------------------------------------------- 1 | 2 | 73 | -------------------------------------------------------------------------------- /src/commands/sshxShare.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ClabLabTreeNode } from "../treeView/common"; 3 | import { outputChannel, sshxSessions, runningLabsProvider, refreshSshxSessions, containerlabBinaryPath } from "../extension"; 4 | import { runCommand } from "../utils/utils"; 5 | 6 | function parseLink(output: string): string | undefined { 7 | const re = /(https?:\/\/\S+)/; 8 | const m = re.exec(output); 9 | return m ? m[1] : undefined; 10 | } 11 | 12 | async function sshxStart(action: "attach" | "reattach", node: ClabLabTreeNode) { 13 | if (!node || !node.name) { 14 | vscode.window.showErrorMessage(`No lab selected for SSHX ${action}.`); 15 | return; 16 | } 17 | try { 18 | const out = await runCommand( 19 | `${containerlabBinaryPath} tools sshx ${action} -l ${node.name}`, 20 | `SSHX ${action}`, 21 | outputChannel, 22 | true, 23 | true 24 | ) as string; 25 | const link = parseLink(out || ''); 26 | if (link) { 27 | sshxSessions.set(node.name, link); 28 | await vscode.env.clipboard.writeText(link); 29 | const choice = await vscode.window.showInformationMessage('SSHX link copied to clipboard.', 'Open Link'); 30 | if (choice === 'Open Link') { 31 | vscode.env.openExternal(vscode.Uri.parse(link)); 32 | } 33 | } else { 34 | const msg = action === 'attach' ? 'SSHX session started but no link found.' : 'SSHX session reattached'; 35 | vscode.window.showInformationMessage(msg); 36 | } 37 | } catch (err: any) { 38 | vscode.window.showErrorMessage(`Failed to ${action} SSHX: ${err.message || err}`); 39 | } 40 | await refreshSshxSessions(); 41 | if (action === 'attach') { 42 | runningLabsProvider.softRefresh(); 43 | } else { 44 | runningLabsProvider.refresh(); 45 | } 46 | } 47 | 48 | export async function sshxAttach(node: ClabLabTreeNode) { 49 | await sshxStart('attach', node); 50 | } 51 | 52 | export async function sshxDetach(node: ClabLabTreeNode) { 53 | if (!node || !node.name) { 54 | vscode.window.showErrorMessage("No lab selected for SSHX detach."); 55 | return; 56 | } 57 | try { 58 | await runCommand( 59 | `${containerlabBinaryPath} tools sshx detach -l ${node.name}`, 60 | 'SSHX detach', 61 | outputChannel, 62 | false, 63 | false 64 | ); 65 | sshxSessions.delete(node.name); 66 | vscode.window.showInformationMessage('SSHX session detached'); 67 | } catch (err: any) { 68 | vscode.window.showErrorMessage(`Failed to detach SSHX: ${err.message || err}`); 69 | } 70 | await refreshSshxSessions(); 71 | runningLabsProvider.refresh(); 72 | } 73 | 74 | export async function sshxReattach(node: ClabLabTreeNode) { 75 | await sshxStart('reattach', node); 76 | } 77 | 78 | export function sshxCopyLink(link: string) { 79 | vscode.env.clipboard.writeText(link); 80 | vscode.window.showInformationMessage('SSHX link copied to clipboard'); 81 | } 82 | -------------------------------------------------------------------------------- /src/topoViewer/webview/ui/IdUtils.ts: -------------------------------------------------------------------------------- 1 | import { isSpecialEndpoint } from '../../shared/utilities/LinkTypes'; 2 | 3 | export function generateDummyId(baseName: string, usedIds: Set): string { 4 | const re = /^(dummy)(\d*)$/; 5 | const match = re.exec(baseName); 6 | const base = match?.[1] || 'dummy'; 7 | let num = parseInt(match?.[2] || '1') || 1; 8 | while (usedIds.has(`${base}${num}`)) num++; 9 | return `${base}${num}`; 10 | } 11 | 12 | export function generateAdapterNodeId(baseName: string, usedIds: Set): string { 13 | const [nodeType, adapter] = baseName.split(':'); 14 | const adapterRe = /^([a-zA-Z]+)(\d+)$/; 15 | const adapterMatch = adapterRe.exec(adapter); 16 | if (adapterMatch) { 17 | const adapterBase = adapterMatch[1]; 18 | let adapterNum = parseInt(adapterMatch[2]); 19 | let name = baseName; 20 | while (usedIds.has(name)) { 21 | adapterNum++; 22 | name = `${nodeType}:${adapterBase}${adapterNum}`; 23 | } 24 | return name; 25 | } 26 | let name = baseName; 27 | let counter = 1; 28 | while (usedIds.has(name)) { 29 | name = `${nodeType}:${adapter}${counter}`; 30 | counter++; 31 | } 32 | return name; 33 | } 34 | 35 | export function generateSpecialNodeId(baseName: string, usedIds: Set): string { 36 | let name = baseName; 37 | while (usedIds.has(name)) { 38 | let i = name.length - 1; 39 | while (i >= 0 && name[i] >= '0' && name[i] <= '9') i--; 40 | const base = name.slice(0, i + 1) || name; 41 | const digits = name.slice(i + 1); 42 | let num = digits ? parseInt(digits, 10) : 0; 43 | num += 1; 44 | name = `${base}${num}`; 45 | } 46 | return name; 47 | } 48 | 49 | export function generateRegularNodeId(baseName: string, usedIds: Set, isGroup: boolean): string { 50 | if (isGroup) { 51 | let num = 1; 52 | let id = `${baseName}:${num}`; 53 | while (usedIds.has(id)) { 54 | num++; 55 | id = `${baseName}:${num}`; 56 | } 57 | return id; 58 | } 59 | 60 | let i = baseName.length - 1; 61 | while (i >= 0 && baseName[i] >= '0' && baseName[i] <= '9') i--; 62 | const hasNumber = i < baseName.length - 1; 63 | const base = hasNumber ? baseName.slice(0, i + 1) : baseName; 64 | let num: number; 65 | if (hasNumber) { 66 | num = parseInt(baseName.slice(i + 1), 10); 67 | } else { 68 | num = 1; 69 | } 70 | while (usedIds.has(`${base}${num}`)) num++; 71 | return `${base}${num}`; 72 | } 73 | 74 | export function getUniqueId(baseName: string, usedIds: Set, isGroup: boolean): string { 75 | if (isSpecialEndpoint(baseName)) { 76 | if (baseName.startsWith('dummy')) { 77 | return generateDummyId(baseName, usedIds); 78 | } 79 | if (baseName.includes(':')) { 80 | return generateAdapterNodeId(baseName, usedIds); 81 | } 82 | return generateSpecialNodeId(baseName, usedIds); 83 | } 84 | return generateRegularNodeId(baseName, usedIds, isGroup); 85 | } 86 | 87 | -------------------------------------------------------------------------------- /src/topoViewer/webview/features/canvas/CytoscapeFactory.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Shared Cytoscape instance factory that registers common extensions and 3 | * applies default styling and behavior in one place. 4 | */ 5 | import cytoscape, { Core, CytoscapeOptions } from 'cytoscape'; 6 | 7 | let extensionsRegistered = false; 8 | let extensionPromises: Record> = {}; 9 | 10 | // Lazy load extensions only when needed 11 | function registerCoreExtensions(): void { 12 | if (extensionsRegistered) { 13 | return; 14 | } 15 | 16 | // Only register grid guide initially (needed for grid display) 17 | try { 18 | const gridGuide = require('cytoscape-grid-guide'); 19 | cytoscape.use(gridGuide); 20 | } catch { /* ignore */ } 21 | 22 | extensionsRegistered = true; 23 | } 24 | 25 | // Lazy load other extensions asynchronously 26 | export async function loadExtension(name: 'edgehandles' | 'cola' | 'cxtmenu' | 'svg' | 'leaflet'): Promise { 27 | if (!extensionPromises[name]) { 28 | extensionPromises[name] = (async () => { 29 | try { 30 | switch(name) { 31 | case 'edgehandles': { 32 | const edgehandles = await import('cytoscape-edgehandles'); 33 | cytoscape.use(edgehandles.default); 34 | break; 35 | } 36 | case 'cola': { 37 | const cola = await import('cytoscape-cola'); 38 | cytoscape.use(cola.default); 39 | break; 40 | } 41 | case 'cxtmenu': { 42 | const cxtmenu = await import('cytoscape-cxtmenu'); 43 | cytoscape.use(cxtmenu.default); 44 | break; 45 | } 46 | case 'svg': { 47 | const cytoscapeSvg = await import('cytoscape-svg'); 48 | cytoscape.use(cytoscapeSvg.default); 49 | break; 50 | } 51 | case 'leaflet': { 52 | const leaflet = await import('cytoscape-leaf'); 53 | cytoscape.use(leaflet.default); 54 | break; 55 | } 56 | } 57 | } catch { /* ignore */ } 58 | })(); 59 | } 60 | return extensionPromises[name]; 61 | } 62 | 63 | const defaultOptions: CytoscapeOptions = { 64 | elements: [], 65 | style: [ 66 | { 67 | selector: 'node', 68 | style: { 69 | 'background-color': '#3498db', 70 | label: 'data(label)', 71 | }, 72 | }, 73 | ], 74 | boxSelectionEnabled: true, 75 | selectionType: 'additive', 76 | wheelSensitivity: 0, 77 | // Performance optimizations 78 | textureOnViewport: true, 79 | hideEdgesOnViewport: false, 80 | hideLabelsOnViewport: false, 81 | pixelRatio: 'auto', 82 | motionBlur: false, 83 | motionBlurOpacity: 0.2, 84 | }; 85 | 86 | export function createConfiguredCytoscape(container: HTMLElement | undefined, options: CytoscapeOptions = {}): Core { 87 | registerCoreExtensions(); 88 | return cytoscape({ 89 | container, 90 | ...defaultOptions, 91 | ...options, 92 | }); 93 | } 94 | 95 | export type { Core as CytoscapeCore }; 96 | -------------------------------------------------------------------------------- /src/topoViewer/webview/assets/templates/partials/panel-node.html: -------------------------------------------------------------------------------- 1 | 2 | 78 | -------------------------------------------------------------------------------- /src/helpers/popularLabs.ts: -------------------------------------------------------------------------------- 1 | import * as https from 'https'; 2 | import * as vscode from 'vscode'; 3 | 4 | export interface PopularRepo { 5 | name: string; 6 | html_url: string; 7 | description: string; 8 | stargazers_count: number; 9 | } 10 | 11 | export const fallbackRepos: PopularRepo[] = [ 12 | { 13 | name: 'srl-telemetry-lab', 14 | html_url: 'https://github.com/srl-labs/srl-telemetry-lab', 15 | description: 'A lab demonstrating the telemetry stack with SR Linux.', 16 | stargazers_count: 85, 17 | }, 18 | { 19 | name: 'netbox-nrx-clab', 20 | html_url: 'https://github.com/srl-labs/netbox-nrx-clab', 21 | description: 'NetBox NRX Containerlab integration, enabling network automation use cases.', 22 | stargazers_count: 65, 23 | }, 24 | { 25 | name: 'sros-anysec-macsec-lab', 26 | html_url: 'https://github.com/srl-labs/sros-anysec-macsec-lab', 27 | description: 'SR OS Anysec & MACsec lab with containerlab.', 28 | stargazers_count: 42, 29 | }, 30 | { 31 | name: 'intent-based-ansible-lab', 32 | html_url: 'https://github.com/srl-labs/intent-based-ansible-lab', 33 | description: 'Intent-based networking lab with Ansible and SR Linux.', 34 | stargazers_count: 38, 35 | }, 36 | { 37 | name: 'multivendor-evpn-lab', 38 | html_url: 'https://github.com/srl-labs/multivendor-evpn-lab', 39 | description: 'Multivendor EVPN lab with Nokia, Arista, and Cisco network operating systems.', 40 | stargazers_count: 78, 41 | }, 42 | ]; 43 | 44 | export function fetchPopularRepos(): Promise { 45 | const url = 46 | 'https://api.github.com/search/repositories?q=topic:clab-topo+org:srl-labs+fork:true&sort=stars&order=desc'; 47 | 48 | return new Promise((resolve, reject) => { 49 | const req = https.get( 50 | url, 51 | { 52 | headers: { 53 | 'User-Agent': 'VSCode-Containerlab-Extension', 54 | Accept: 'application/vnd.github.v3+json', 55 | }, 56 | }, 57 | (res) => { 58 | let data = ''; 59 | res.on('data', (chunk) => { 60 | data += chunk; 61 | }); 62 | res.on('end', () => { 63 | try { 64 | const parsed = JSON.parse(data); 65 | resolve(parsed.items || []); 66 | } catch (e) { 67 | reject(e); 68 | } 69 | }); 70 | }, 71 | ); 72 | 73 | req.on('error', (err) => { 74 | reject(err); 75 | }); 76 | 77 | req.end(); 78 | }); 79 | } 80 | 81 | async function getRepos(): Promise { 82 | try { 83 | return await fetchPopularRepos(); 84 | } catch { 85 | return fallbackRepos; 86 | } 87 | } 88 | 89 | export async function pickPopularRepo(title: string, placeHolder: string) { 90 | const repos = await getRepos(); 91 | const items = repos.map((r) => ({ 92 | label: r.name, 93 | description: r.description, 94 | detail: `⭐ ${r.stargazers_count}`, 95 | repo: r.html_url, 96 | })); 97 | return vscode.window.showQuickPick(items, { title, placeHolder }); 98 | } 99 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // eslint.config.mjs – works with ESLint 9+ 2 | import eslint from '@eslint/js'; 3 | import tseslint from 'typescript-eslint'; 4 | import sonarjs from 'eslint-plugin-sonarjs'; 5 | import aggregateComplexity from './eslint-plugin-aggregate-complexity.mjs'; 6 | 7 | export default [ 8 | /* ─── files & globs ESLint must ignore ─────────────────────────── */ 9 | { 10 | ignores: [ 11 | '**/*.js', // ← ignore *all* JavaScript bundles 12 | 'out/**', 13 | 'dist/**', 14 | 'node_modules/**', 15 | '.vscode-test.mjs', // VS Code test harness 16 | 'legacy-backup/**', // Legacy backup files 17 | 'labs/**', // containerlab lab files 18 | "src/utils/consts.ts" 19 | ] 20 | }, 21 | 22 | /* ---------- every other JS/JSON file ---------- */ 23 | eslint.configs.recommended, // same as "eslint:recommended" 24 | 25 | /* ---------- TypeScript (syntax + type-aware) ---------- */ 26 | { 27 | files: ['**/*.ts', '**/*.tsx'], 28 | languageOptions: { 29 | parser: tseslint.parser, 30 | parserOptions: { 31 | project: ['./tsconfig.json', './test/tsconfig.json'], 32 | ecmaVersion: 'latest', 33 | sourceType: 'module' 34 | }, 35 | globals: { 36 | console: 'readonly', 37 | process: 'readonly', 38 | Buffer: 'readonly', 39 | require: 'readonly', 40 | setTimeout: 'readonly', 41 | clearTimeout: 'readonly', 42 | setInterval: 'readonly', 43 | clearInterval: 'readonly', 44 | window: 'readonly', 45 | document: 'readonly', 46 | fetch: 'readonly' 47 | } 48 | }, 49 | plugins: { '@typescript-eslint': tseslint.plugin, sonarjs, 'aggregate-complexity': aggregateComplexity }, 50 | // merge the two rule-sets 51 | rules: { 52 | ...tseslint.configs.recommended.rules, 53 | ...tseslint.configs.recommendedTypeChecked.rules, 54 | ...sonarjs.configs.recommended.rules, 55 | // Use TypeScript's noUnused* diagnostics instead of duplicating in ESLint 56 | 'no-unused-vars': 'off', 57 | '@typescript-eslint/no-unused-vars': 'off', 58 | // disallow any trailing whitespace 59 | 60 | 'no-trailing-spaces': ['error', { 61 | skipBlankLines: false, // also flag lines that are purely whitespace 62 | ignoreComments: false // also flag whitespace at end of comments 63 | }], 64 | 'complexity': ['error', { max: 15 }], 65 | 'sonarjs/cognitive-complexity': ['error', 15], 66 | 'sonarjs/no-identical-functions': 'error', 67 | 'sonarjs/no-duplicate-string': 'error', 68 | 'sonarjs/no-hardcoded-ip': 'off', 69 | 'sonarjs/no-alphabetical-sort': 'off', 70 | 'aggregate-complexity/aggregate-complexity': ['error', { max: 15 }] 71 | 72 | }, 73 | }, 74 | 75 | /* ---------- topoViewer: max-lines limit ---------- */ 76 | { 77 | files: ['src/topoViewer/**/*.ts', 'src/topoViewer/**/*.tsx'], 78 | rules: { 79 | 'max-lines': ['error', { max: 1000, skipBlankLines: true, skipComments: true }] 80 | } 81 | } 82 | ]; 83 | -------------------------------------------------------------------------------- /test/unit/commands/addToWorkspace.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global describe, it, after, beforeEach, afterEach, __dirname */ 3 | /** 4 | * Tests for the `addLabFolderToWorkspace` command. 5 | * 6 | * The suite checks that a chosen lab folder is added to the VS Code 7 | * workspace using a stubbed `vscode` API from `test/helpers`. It also 8 | * verifies that appropriate errors are returned when the path is 9 | * missing or invalid. 10 | */ 11 | // These tests simulate adding a folder to the workspace without launching VS Code 12 | import { expect } from 'chai'; 13 | import sinon from 'sinon'; 14 | import Module from 'module'; 15 | import path from 'path'; 16 | 17 | // Replace the vscode module with our stub before importing the command 18 | const originalResolve = (Module as any)._resolveFilename; 19 | (Module as any)._resolveFilename = function (request: string, parent: any, isMain: boolean, options: any) { 20 | if (request === 'vscode') { 21 | return path.join(__dirname, '..', '..', 'helpers', 'vscode-stub.js'); 22 | } 23 | return originalResolve.call(this, request, parent, isMain, options); 24 | }; 25 | 26 | import { addLabFolderToWorkspace } from '../../../src/commands/addToWorkspace'; 27 | const vscodeStub = require('../../helpers/vscode-stub'); 28 | 29 | describe('addLabFolderToWorkspace command', () => { 30 | after(() => { 31 | (Module as any)._resolveFilename = originalResolve; 32 | }); 33 | 34 | beforeEach(() => { 35 | vscodeStub.window.lastInfoMessage = ''; 36 | vscodeStub.workspace.workspaceFolders = []; 37 | sinon.spy(vscodeStub.workspace, 'updateWorkspaceFolders'); 38 | sinon.spy(vscodeStub.window, 'showInformationMessage'); 39 | sinon.spy(vscodeStub.window, 'showErrorMessage'); 40 | }); 41 | 42 | afterEach(() => { 43 | sinon.restore(); 44 | }); 45 | 46 | // Adds a new folder entry to the current workspace. 47 | it('adds the folder to the workspace', async () => { 48 | const node = { 49 | labPath: { absolute: '/home/user/path/to/lab.clab.yaml' }, 50 | label: 'lab1', 51 | name: 'lab1', 52 | } as any; 53 | await addLabFolderToWorkspace(node); 54 | 55 | const addSpy = (vscodeStub.workspace.updateWorkspaceFolders as sinon.SinonSpy); 56 | const msgSpy = (vscodeStub.window.showInformationMessage as sinon.SinonSpy); 57 | expect(addSpy.calledOnce).to.be.true; 58 | expect(addSpy.firstCall.args[2].uri.fsPath).to.equal('/home/user/path/to'); 59 | expect(addSpy.firstCall.args[2].name).to.equal('lab1'); 60 | expect(msgSpy.calledOnceWith('Added "lab1" to your workspace.')).to.be.true; 61 | }); 62 | 63 | // Should return an error when the labPath field is empty. 64 | it('shows an error when labPath is missing', async () => { 65 | const result = await addLabFolderToWorkspace({ labPath: { absolute: '' } } as any); 66 | expect(result).to.be.undefined; 67 | const errSpy = vscodeStub.window.showErrorMessage as sinon.SinonSpy; 68 | expect(errSpy.calledOnceWith('No lab path found for this lab')).to.be.true; 69 | const addSpy = vscodeStub.workspace.updateWorkspaceFolders as sinon.SinonSpy; 70 | expect(addSpy.notCalled).to.be.true; 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /test/unit/extension/refreshSshxSessions.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global describe, it, before, after, beforeEach, afterEach, __dirname */ 3 | import { expect } from 'chai'; 4 | import sinon from 'sinon'; 5 | import Module from 'module'; 6 | import path from 'path'; 7 | 8 | describe('refreshSshxSessions', () => { 9 | const originalResolve = (Module as any)._resolveFilename; 10 | let refreshSshxSessions: () => Promise; 11 | let sshxSessions: Map; 12 | let utilsStub: any; 13 | let vscodeStub: any; 14 | let extension: any; 15 | 16 | // Helper to clear module cache for all vscode-containerlab modules 17 | function clearModuleCache() { 18 | Object.keys(require.cache).forEach(key => { 19 | if (key.includes('vscode-containerlab') && !key.includes('node_modules')) { 20 | delete require.cache[key]; 21 | } 22 | }); 23 | } 24 | 25 | before(() => { 26 | // Clear any previously cached modules 27 | clearModuleCache(); 28 | 29 | // Set up module resolution intercepts 30 | (Module as any)._resolveFilename = function(request: string, parent: any, isMain: boolean, options: any) { 31 | if (request === 'vscode') { 32 | return path.join(__dirname, '..', '..', 'helpers', 'vscode-stub.js'); 33 | } 34 | if (request.includes('utils') && !request.includes('stub')) { 35 | return path.join(__dirname, '..', '..', 'helpers', 'utils-stub.js'); 36 | } 37 | return originalResolve.call(this, request, parent, isMain, options); 38 | }; 39 | 40 | // Now require the modules fresh 41 | vscodeStub = require('../../helpers/vscode-stub'); 42 | utilsStub = require('../../helpers/utils-stub'); 43 | extension = require('../../../src/extension'); 44 | refreshSshxSessions = extension.refreshSshxSessions; 45 | sshxSessions = extension.sshxSessions; 46 | }); 47 | 48 | after(() => { 49 | (Module as any)._resolveFilename = originalResolve; 50 | clearModuleCache(); 51 | }); 52 | 53 | beforeEach(() => { 54 | utilsStub.calls.length = 0; 55 | utilsStub.setOutput(''); 56 | sshxSessions.clear(); 57 | (extension as any).outputChannel = vscodeStub.window.createOutputChannel('test', { log: true }); 58 | }); 59 | 60 | afterEach(() => { 61 | sinon.restore(); 62 | }); 63 | 64 | it('parses sessions from container name when network lacks prefix', async () => { 65 | const sample = JSON.stringify([ 66 | { 67 | "name": "clab-atest-sshx", 68 | "network": "clab", 69 | "state": "running", 70 | "ipv4_address": "172.20.20.4", 71 | "link": "https://sshx.io/s/QfCkbDXUnk#FINGn1xZar19RC", 72 | "owner": "tester" 73 | }, 74 | { 75 | "name": "sshx-clab", 76 | "network": "clab", 77 | "state": "exited", 78 | "ipv4_address": "", 79 | "link": "N/A", 80 | "owner": "tester" 81 | } 82 | ]); 83 | utilsStub.setOutput(sample); 84 | await refreshSshxSessions(); 85 | expect(utilsStub.calls[0]).to.contain('containerlab tools sshx list -f json'); 86 | expect(sshxSessions.size).to.equal(1); 87 | expect(sshxSessions.get('atest')).to.equal('https://sshx.io/s/QfCkbDXUnk#FINGn1xZar19RC'); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/topoViewer/shared/utilities/PerformanceMonitor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Performance monitoring utility for TopoViewer 3 | * Tracks load times and provides metrics for optimization 4 | */ 5 | 6 | import { log } from '../../webview/platform/logging/logger'; 7 | 8 | // Use Date.now() for Node.js compatibility, performance is browser-only 9 | // eslint-disable-next-line no-undef 10 | const getTime = typeof performance !== 'undefined' ? () => performance.now() : () => Date.now(); 11 | 12 | export class PerformanceMonitor { 13 | private static marks: Map = new Map(); 14 | private static measures: Map = new Map(); 15 | 16 | /** 17 | * Start timing a specific operation 18 | */ 19 | static mark(name: string): void { 20 | this.marks.set(name, getTime()); 21 | log.debug(`Performance mark: ${name}`); 22 | } 23 | 24 | /** 25 | * Measure time between two marks or from a mark to now 26 | */ 27 | static measure(name: string, startMark: string, endMark?: string): number { 28 | const startTime = this.marks.get(startMark); 29 | if (!startTime) { 30 | log.warn(`Start mark not found: ${startMark}`); 31 | return 0; 32 | } 33 | 34 | const endTime = endMark ? this.marks.get(endMark) : getTime(); 35 | if (endMark && !endTime) { 36 | log.warn(`End mark not found: ${endMark}`); 37 | return 0; 38 | } 39 | 40 | const duration = (endTime || getTime()) - startTime; 41 | this.measures.set(name, duration); 42 | 43 | // Log performance metrics 44 | if (duration > 1000) { 45 | log.warn(`Slow operation - ${name}: ${duration.toFixed(2)}ms`); 46 | } else if (duration > 100) { 47 | log.info(`Performance - ${name}: ${duration.toFixed(2)}ms`); 48 | } else { 49 | log.debug(`Performance - ${name}: ${duration.toFixed(2)}ms`); 50 | } 51 | 52 | return duration; 53 | } 54 | 55 | /** 56 | * Get all recorded measures 57 | */ 58 | static getMeasures(): Record { 59 | const result: Record = {}; 60 | this.measures.forEach((value, key) => { 61 | result[key] = value; 62 | }); 63 | return result; 64 | } 65 | 66 | /** 67 | * Clear all marks and measures 68 | */ 69 | static clear(): void { 70 | this.marks.clear(); 71 | this.measures.clear(); 72 | } 73 | 74 | /** 75 | * Log a performance summary 76 | */ 77 | static logSummary(): void { 78 | const measures = this.getMeasures(); 79 | const total = Object.values(measures).reduce((sum, val) => sum + val, 0); 80 | 81 | log.info('=== Performance Summary ==='); 82 | Object.entries(measures) 83 | .sort((a, b) => b[1] - a[1]) 84 | .forEach(([name, duration]) => { 85 | const percentage = ((duration / total) * 100).toFixed(1); 86 | log.info(` ${name}: ${duration.toFixed(2)}ms (${percentage}%)`); 87 | }); 88 | log.info(`Total: ${total.toFixed(2)}ms`); 89 | log.info('========================'); 90 | } 91 | } 92 | 93 | // Export convenience functions 94 | export const perfMark = (name: string) => PerformanceMonitor.mark(name); 95 | export const perfMeasure = (name: string, startMark: string, endMark?: string) => 96 | PerformanceMonitor.measure(name, startMark, endMark); 97 | export const perfSummary = () => PerformanceMonitor.logSummary(); 98 | -------------------------------------------------------------------------------- /test/unit/treeView/common.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global describe, it, after, __dirname */ 3 | /** 4 | * Unit tests for helper classes used by the tree view. 5 | * 6 | * These tests focus on the {@link ClabContainerTreeNode} getters that 7 | * return IPv4 and IPv6 addresses without their CIDR masks. The getters 8 | * also normalise a value of `"N/A"` to an empty string. By exercising all 9 | * branches we can ensure that the UI correctly displays node addresses. 10 | * 11 | * The suite stubs the `vscode` module so it can run in a plain Node 12 | * environment without the VS Code API available. 13 | */ 14 | import { expect } from 'chai'; 15 | import Module from 'module'; 16 | import path from 'path'; 17 | 18 | // The source files depend on the VS Code API. To run the tests without the 19 | // actual editor environment we replace Node's module resolution logic and point 20 | // any import of `vscode` to a lightweight stub. 21 | const originalResolve = (Module as any)._resolveFilename; 22 | (Module as any)._resolveFilename = function (request: string, parent: any, isMain: boolean, options: any) { 23 | if (request === 'vscode') { 24 | return path.join(__dirname, '..', '..', 'helpers', 'vscode-stub.js'); 25 | } 26 | return originalResolve.call(this, request, parent, isMain, options); 27 | }; 28 | 29 | import { ClabContainerTreeNode } from '../../../src/treeView/common'; 30 | import { TreeItemCollapsibleState } from '../../helpers/vscode-stub'; 31 | 32 | describe('ClabContainerTreeNode getters', () => { 33 | after(() => { 34 | // Restore the original module resolver so subsequent tests use the 35 | // standard behaviour. 36 | (Module as any)._resolveFilename = originalResolve; 37 | }); 38 | 39 | // When a CIDR is present the getter should strip it and return only 40 | // the IPv4 address. 41 | it('returns IPv4 address without mask', () => { 42 | const node = new ClabContainerTreeNode( 43 | 'test', 44 | TreeItemCollapsibleState.None, 45 | 'node1', 46 | 'node1', 47 | 'id', 48 | 'running', 49 | 'kind', 50 | 'image', 51 | [], 52 | { absolute: '/abs/path', relative: 'path' }, 53 | '10.0.0.1/24', 54 | undefined, 55 | undefined, 56 | undefined, 57 | undefined, 58 | ); 59 | expect(node.IPv4Address).to.equal('10.0.0.1'); 60 | }); 61 | 62 | // The getter returns an empty string if the address is reported as 'N/A'. 63 | it('returns empty string when IPv4 is N/A', () => { 64 | const node = new ClabContainerTreeNode( 65 | 'test', 66 | TreeItemCollapsibleState.None, 67 | 'node1', 68 | 'node1', 69 | 'id', 70 | 'running', 71 | 'kind', 72 | 'image', 73 | [], 74 | { absolute: '/abs/path', relative: 'path' }, 75 | 'N/A', 76 | ); 77 | expect(node.IPv4Address).to.equal(''); 78 | }); 79 | 80 | // The same stripping logic applies to IPv6 addresses as well. 81 | it('returns IPv6 address without mask', () => { 82 | const node = new ClabContainerTreeNode( 83 | 'test', 84 | TreeItemCollapsibleState.None, 85 | 'node1', 86 | 'node1', 87 | 'id', 88 | 'running', 89 | 'kind', 90 | 'image', 91 | [], 92 | { absolute: '/abs/path', relative: 'path' }, 93 | undefined, 94 | '2001:db8::1/64', 95 | ); 96 | expect(node.IPv6Address).to.equal('2001:db8::1'); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /resources/containerlab.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/topoViewer/shared/utilities/LinkTypes.ts: -------------------------------------------------------------------------------- 1 | // Common link/node type constants and helpers used across TopoViewer 2 | 3 | export const STR_HOST = 'host' as const; 4 | export const STR_MGMT_NET = 'mgmt-net' as const; 5 | export const PREFIX_MACVLAN = 'macvlan:' as const; 6 | export const PREFIX_VXLAN = 'vxlan:' as const; 7 | export const PREFIX_VXLAN_STITCH = 'vxlan-stitch:' as const; 8 | export const PREFIX_DUMMY = 'dummy' as const; 9 | export const PREFIX_BRIDGE = 'bridge:' as const; 10 | export const PREFIX_OVS_BRIDGE = 'ovs-bridge:' as const; 11 | 12 | export const TYPE_DUMMY = 'dummy' as const; 13 | 14 | export const SINGLE_ENDPOINT_TYPES = new Set([ 15 | STR_HOST, 16 | STR_MGMT_NET, 17 | 'macvlan', 18 | TYPE_DUMMY, 19 | 'vxlan', 20 | 'vxlan-stitch' 21 | ]); 22 | 23 | export const VX_TYPES = new Set(['vxlan', 'vxlan-stitch']); 24 | export const HOSTY_TYPES = new Set([STR_HOST, STR_MGMT_NET, 'macvlan']); 25 | 26 | /** 27 | * Determines if a node ID represents a special endpoint. 28 | * @param nodeId - The node ID to check. 29 | * @returns True if the node is a special endpoint (host, mgmt-net, macvlan, vxlan, vxlan-stitch, dummy, bridge, ovs-bridge). 30 | */ 31 | export function isSpecialEndpointId(nodeId: string): boolean { 32 | return ( 33 | nodeId.startsWith(`${STR_HOST}:`) || 34 | nodeId.startsWith(`${STR_MGMT_NET}:`) || 35 | nodeId.startsWith(PREFIX_MACVLAN) || 36 | nodeId.startsWith(PREFIX_VXLAN) || 37 | nodeId.startsWith(PREFIX_VXLAN_STITCH) || 38 | nodeId.startsWith('dummy') || 39 | nodeId.startsWith(PREFIX_BRIDGE) || 40 | nodeId.startsWith(PREFIX_OVS_BRIDGE) 41 | ); 42 | } 43 | 44 | /** Alias for isSpecialEndpointId for backwards compatibility */ 45 | export const isSpecialEndpoint = isSpecialEndpointId; 46 | 47 | /** 48 | * Determines if a node ID represents a special endpoint or bridge node. 49 | * This is a more comprehensive check that includes both special endpoints and bridge nodes. 50 | * @param nodeId - The node ID to check. 51 | * @param cy - Optional Cytoscape instance to check node data for bridge types. 52 | * @returns True if the node is a special endpoint or bridge node. 53 | */ 54 | 55 | export function isSpecialNodeOrBridge(nodeId: string, cy?: any): boolean { 56 | // First check if it's a special endpoint 57 | if (isSpecialEndpointId(nodeId)) { 58 | return true; 59 | } 60 | 61 | // If we have a Cytoscape instance, also check if it's a bridge node by examining node data 62 | if (cy) { 63 | const node = cy.getElementById(nodeId); 64 | if (node.length > 0) { 65 | const kind = node.data('extraData')?.kind; 66 | return kind === 'bridge' || kind === 'ovs-bridge'; 67 | } 68 | } 69 | 70 | return false; 71 | } 72 | 73 | export function splitEndpointLike(endpoint: string | { node: string; interface?: string }): { node: string; iface: string } { 74 | if (typeof endpoint === 'string') { 75 | if ( 76 | endpoint.startsWith(PREFIX_MACVLAN) || 77 | endpoint.startsWith(PREFIX_DUMMY) || 78 | endpoint.startsWith(PREFIX_VXLAN) || 79 | endpoint.startsWith(PREFIX_VXLAN_STITCH) 80 | ) { 81 | return { node: endpoint, iface: '' }; 82 | } 83 | const parts = endpoint.split(':'); 84 | if (parts.length === 2) return { node: parts[0], iface: parts[1] }; 85 | return { node: endpoint, iface: '' }; 86 | } 87 | if (endpoint && typeof endpoint === 'object') { 88 | return { node: endpoint.node, iface: endpoint.interface ?? '' }; 89 | } 90 | return { node: '', iface: '' }; 91 | } 92 | 93 | -------------------------------------------------------------------------------- /test/unit/topoViewer/buildCytoscapeElements.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global describe, it, after, afterEach, __dirname */ 3 | import { expect } from 'chai'; 4 | import sinon from 'sinon'; 5 | import Module from 'module'; 6 | import path from 'path'; 7 | 8 | const originalResolve = (Module as any)._resolveFilename; 9 | (Module as any)._resolveFilename = function(request: string, parent: any, isMain: boolean, options: any) { 10 | if (request === 'vscode') { 11 | return path.join(__dirname, '..', '..', 'helpers', 'vscode-stub.js'); 12 | } 13 | if (request.endsWith('logging/logger')) { 14 | return path.join(__dirname, '..', '..', 'helpers', 'extensionLogger-stub.js'); 15 | } 16 | return originalResolve.call(this, request, parent, isMain, options); 17 | }; 18 | 19 | import { TopoViewerAdaptorClab } from '../../../src/topoViewer/extension/services/TopologyAdapter'; 20 | import * as treeUtils from '../../../src/topoViewer/extension/services/TreeUtils'; 21 | 22 | describe('buildCytoscapeElements delegation', () => { 23 | after(() => { 24 | (Module as any)._resolveFilename = originalResolve; 25 | }); 26 | 27 | afterEach(() => { 28 | sinon.restore(); 29 | }); 30 | 31 | it('delegates for both converter methods', async () => { 32 | const adaptor = new TopoViewerAdaptorClab(); 33 | const spy = sinon.spy(adaptor as any, 'buildCytoscapeElements'); 34 | sinon.stub(treeUtils, 'findContainerNode').returns({ 35 | IPv4Address: '10.0.0.1', 36 | IPv6Address: '::1', 37 | state: 'running', 38 | interfaces: [] 39 | } as any); 40 | 41 | const yaml = `\nname: demo\ntopology:\n nodes:\n node1: {}\n links:\n - endpoints: ['node1:eth0','node1:eth1']\n`; 42 | 43 | const withMgmt = await adaptor.clabYamlToCytoscapeElements(yaml, {}); 44 | const withoutMgmt = await adaptor.clabYamlToCytoscapeElementsEditor(yaml); 45 | 46 | expect(spy.calledTwice).to.be.true; 47 | const nodeWith = withMgmt.find((e: any) => e.group === 'nodes'); 48 | const nodeWithout = withoutMgmt.find((e: any) => e.group === 'nodes'); 49 | expect(nodeWith?.data.extraData.mgmtIpv4Address).to.equal('10.0.0.1'); 50 | expect(nodeWithout?.data.extraData.mgmtIpv4Address).to.equal(''); 51 | }); 52 | 53 | it('does not mark links as down when interface state is unknown', async () => { 54 | const adaptor = new TopoViewerAdaptorClab(); 55 | 56 | const yaml = `\nname: demo\ntopology:\n nodes:\n node1: {}\n links:\n - endpoints: ['node1:eth0','node1:eth1']\n`; 57 | 58 | const elements = await adaptor.clabYamlToCytoscapeElementsEditor(yaml); 59 | const edge = elements.find((e: any) => e.group === 'edges'); 60 | expect(edge?.classes).to.equal(''); 61 | }); 62 | 63 | it('assigns unique ids to multiple dummy networks', async () => { 64 | const adaptor = new TopoViewerAdaptorClab(); 65 | const yaml = `\nname: demo\ntopology:\n nodes:\n node1: {}\n links:\n - type: dummy\n endpoint: node1:eth0\n - type: dummy\n endpoint: node1:eth1\n`; 66 | const elements = await adaptor.clabYamlToCytoscapeElementsEditor(yaml); 67 | const dummyNodes = elements.filter((e: any) => e.group === 'nodes' && e.data.id.startsWith('dummy')); 68 | expect(dummyNodes).to.have.length(2); 69 | const names = dummyNodes.map((node: any) => node.data.name); 70 | expect(names.every((name: string) => name.startsWith('dummy'))).to.be.true; 71 | expect(new Set(names).size).to.equal(names.length); 72 | expect(dummyNodes[0].data.id).to.not.equal(dummyNodes[1].data.id); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/topoViewer/webview/assets/images/containerlab.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/unit/commands/deploy.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* global describe, it, before, after, beforeEach, afterEach, __dirname */ 3 | /** 4 | * Tests for the `deploy` command. 5 | * 6 | * The suite verifies that a {@link ClabCommand} instance receives the 7 | * correct arguments when deploying a topology. By stubbing the `vscode` 8 | * module and the command implementation we can exercise the logic in a 9 | * plain Node environment without invoking containerlab. 10 | */ 11 | import { expect } from 'chai'; 12 | import sinon from 'sinon'; 13 | import Module from 'module'; 14 | import path from 'path'; 15 | 16 | const originalResolve = (Module as any)._resolveFilename; 17 | 18 | // Helper to clear module cache for all vscode-containerlab modules 19 | function clearModuleCache() { 20 | Object.keys(require.cache).forEach(key => { 21 | if (key.includes('vscode-containerlab') && !key.includes('node_modules')) { 22 | delete require.cache[key]; 23 | } 24 | }); 25 | } 26 | 27 | // Helper to resolve stub paths for module interception 28 | function getStubPath(request: string): string | null { 29 | if (request === 'vscode') { 30 | return path.join(__dirname, '..', '..', 'helpers', 'vscode-stub.js'); 31 | } 32 | if (request.includes('clabCommand') && !request.includes('stub')) { 33 | return path.join(__dirname, '..', '..', 'helpers', 'clabCommand-stub.js'); 34 | } 35 | if (request.includes('utils') && !request.includes('stub')) { 36 | return path.join(__dirname, '..', '..', 'helpers', 'utils-stub.js'); 37 | } 38 | if ((request === './graph' || request.endsWith('/graph')) && !request.includes('stub')) { 39 | return path.join(__dirname, '..', '..', 'helpers', 'graph-stub.js'); 40 | } 41 | return null; 42 | } 43 | 44 | describe('deploy command', () => { 45 | let deploy: Function; 46 | let clabStub: any; 47 | 48 | before(() => { 49 | clearModuleCache(); 50 | 51 | (Module as any)._resolveFilename = function (request: string, parent: any, isMain: boolean, options: any) { 52 | const stubPath = getStubPath(request); 53 | if (stubPath) { 54 | return stubPath; 55 | } 56 | return originalResolve.call(this, request, parent, isMain, options); 57 | }; 58 | 59 | clabStub = require('../../helpers/clabCommand-stub'); 60 | const deployModule = require('../../../src/commands/deploy'); 61 | deploy = deployModule.deploy; 62 | }); 63 | 64 | after(() => { 65 | (Module as any)._resolveFilename = originalResolve; 66 | clearModuleCache(); 67 | }); 68 | 69 | beforeEach(() => { 70 | clabStub.instances.length = 0; 71 | sinon.spy(clabStub.ClabCommand.prototype, 'run'); 72 | }); 73 | 74 | afterEach(() => { 75 | sinon.restore(); 76 | }); 77 | 78 | // Should instantiate ClabCommand with the selected node and execute it. 79 | it('creates ClabCommand and runs it', async () => { 80 | const node = { labPath: { absolute: '/home/user/lab.yml' } } as any; 81 | await deploy(node); 82 | 83 | expect(clabStub.instances.length).to.equal(1); 84 | const instance = clabStub.instances[0]; 85 | expect(instance.action).to.equal('deploy'); 86 | expect(instance.node).to.equal(node); 87 | expect(instance.spinnerMessages.progressMsg).to.equal('Deploying Lab... '); 88 | expect(instance.spinnerMessages.successMsg).to.equal('Lab deployed successfully!'); 89 | 90 | const spy = clabStub.ClabCommand.prototype.run as sinon.SinonSpy; 91 | expect(spy.calledOnceWithExactly()).to.be.true; 92 | expect(instance.runArgs).to.be.undefined; 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/topoViewer/webview/types/globals.d.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutManager as LayoutManagerClass } from '../features/canvas/LayoutManager'; 2 | import type { GroupManager } from '../features/groups/GroupManager'; 3 | import type TopologyWebviewController from '../app/TopologyWebviewController'; 4 | 5 | declare global { 6 | interface LayoutManager extends LayoutManagerClass {} 7 | 8 | interface GlobalState { 9 | layoutManager?: LayoutManager; 10 | groupManager?: GroupManager; 11 | // layout control functions 12 | layoutAlgoChange?: (..._args: any[]) => void; 13 | viewportButtonsLayoutAlgo?: (..._args: any[]) => void; 14 | viewportDrawerLayoutGeoMap?: (..._args: any[]) => void; 15 | viewportDrawerDisableGeoMap?: (..._args: any[]) => void; 16 | viewportDrawerLayoutForceDirected?: (..._args: any[]) => void; 17 | viewportDrawerLayoutForceDirectedRadial?: (..._args: any[]) => void; 18 | viewportDrawerLayoutVertical?: (..._args: any[]) => void; 19 | viewportDrawerLayoutHorizontal?: (..._args: any[]) => void; 20 | viewportDrawerPreset?: (..._args: any[]) => void; 21 | viewportButtonsGeoMapPan?: (..._args: any[]) => void; 22 | viewportButtonsGeoMapEdit?: (..._args: any[]) => void; 23 | viewportButtonsTopologyOverview?: (..._args: any[]) => void; 24 | viewportButtonsZoomToFit?: () => void; 25 | viewportSetLinkLabelMode?: (mode: string) => void; 26 | viewportToggleLinkLabelMenu?: (event?: MouseEvent) => void; 27 | viewportSelectLinkLabelMode?: (mode: string) => void; 28 | viewportCloseLinkLabelMenu?: () => void; 29 | viewportButtonsCaptureViewportAsSvg?: () => void; 30 | showPanelAbout?: () => void; 31 | // group manager bindings 32 | orphaningNode?: (..._args: any[]) => void; 33 | createNewParent?: (..._args: any[]) => void; 34 | panelNodeEditorParentToggleDropdown?: (..._args: any[]) => void; 35 | nodeParentPropertiesUpdate?: (..._args: any[]) => void; 36 | nodeParentPropertiesUpdateClose?: (..._args: any[]) => void; 37 | nodeParentRemoval?: (..._args: any[]) => void; 38 | viewportButtonsAddGroup?: (..._args: any[]) => void; 39 | showPanelGroupEditor?: (..._args: any[]) => void; 40 | // library globals 41 | cytoscape?: typeof import('cytoscape'); 42 | L?: any; 43 | tippy?: any; 44 | cytoscapePopper?: any; 45 | // environment globals 46 | isVscodeDeployment?: boolean; 47 | loadCytoStyle?: (cy: any, theme?: 'light' | 'dark', options?: { preserveExisting?: boolean }) => void; 48 | jsonFileUrlDataCytoMarshall?: string; 49 | jsonFileUrlDataEnvironment?: string; 50 | schemaUrl?: string; 51 | ifacePatternMapping?: Record; 52 | imageMapping?: Record; 53 | updateLinkEndpointsOnKindChange?: boolean; 54 | lockLabByDefault?: boolean; 55 | defaultKind?: string; 56 | defaultType?: string; 57 | customNodes?: Array<{ 58 | name: string; 59 | kind: string; 60 | type?: string; 61 | image?: string; 62 | baseName?: string; 63 | icon?: string; 64 | interfacePattern?: string; 65 | setDefault?: boolean; 66 | }>; 67 | defaultNode?: string; 68 | topologyDefaults?: Record; 69 | topologyKinds?: Record; 70 | topologyGroups?: Record; 71 | customIcons?: Record; 72 | vscode?: { postMessage(data: unknown): void }; 73 | topologyWebviewController?: TopologyWebviewController; 74 | } 75 | 76 | // Augment the Window interface 77 | interface Window extends GlobalState {} 78 | } 79 | 80 | export {}; 81 | -------------------------------------------------------------------------------- /src/topoViewer/webview/assets/templates/partials/scripts.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 10 | 11 | 12 | 15 | 16 | 17 | 20 | 21 | 22 | 25 | 26 | 27 | 30 | 31 | 32 | 35 | 36 | 37 | 40 | 41 | 42 | 45 | 48 | 51 | 54 | 57 | 60 | 63 | 66 | 69 | 72 | 75 | 78 | 81 | 82 | 92 | 93 | 108 | 109 | 110 | {{DRAGGABLE_PANELS}} 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /src/utils/docker/images.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { dockerClient, outputChannel } from "../../extension"; 3 | 4 | let dockerImagesCache: string[] = []; 5 | 6 | const dockerImagesEmitter = new vscode.EventEmitter(); 7 | 8 | export const onDockerImagesUpdated = dockerImagesEmitter.event; 9 | 10 | export function getDockerImages(): string[] { 11 | return [...dockerImagesCache]; 12 | } 13 | 14 | // Internal func to fetch all docker images 15 | async function fetchDockerImages(): Promise { 16 | if (!dockerClient) { 17 | outputChannel.debug("getDockerImages() failed: docker client unavailable.") 18 | return []; 19 | } 20 | 21 | const images = await dockerClient.listImages(); 22 | type TagEntry = { tag: string; created: number }; 23 | const entries: TagEntry[] = []; 24 | const seen = new Set(); 25 | 26 | for (const img of images) { 27 | const repoTags = Array.isArray(img.RepoTags) ? img.RepoTags : []; 28 | for (const tag of repoTags) { 29 | const isValid = tag && !tag.endsWith(":") && !tag.startsWith(""); 30 | if (isValid && !seen.has(tag)) { 31 | seen.add(tag); 32 | entries.push({ tag, created: typeof img.Created === "number" ? img.Created : 0 }); 33 | } 34 | } 35 | } 36 | 37 | entries.sort((a, b) => b.created - a.created || a.tag.localeCompare(b.tag)); 38 | return entries.map(entry => entry.tag); 39 | } 40 | 41 | function updateDockerImagesCache(images: string[]) { 42 | const changed = 43 | images.length !== dockerImagesCache.length || 44 | images.some((img, idx) => dockerImagesCache[idx] !== img); 45 | if (!changed) { 46 | return; 47 | } 48 | dockerImagesCache = images; 49 | // fire an event to whom is listenting that the cache updated. 50 | dockerImagesEmitter.fire([...dockerImagesCache]); 51 | } 52 | 53 | export async function refreshDockerImages() { 54 | outputChannel.debug("Refreshing docker image cache.") 55 | try { 56 | const images = await fetchDockerImages(); 57 | updateDockerImagesCache(images); 58 | outputChannel.debug("SUCCESS! Refreshed docker image cache.") 59 | } catch { 60 | // Leave existing cache untouched. 61 | } 62 | } 63 | 64 | // Create disposable handle to let the image monitor get cleaned up by VSC. 65 | let monitorHandle: vscode.Disposable | undefined; 66 | 67 | export function startDockerImageEventMonitor(context: vscode.ExtensionContext) { 68 | if (monitorHandle || !dockerClient) { 69 | return; 70 | } 71 | 72 | // Start a 'docker events' but only for image events. 73 | dockerClient.getEvents({ filters:{ type: ["image"] }}).then(stream => { 74 | const onData = () => { 75 | // upon any event, the cache should be updated. 76 | refreshDockerImages(); 77 | }; 78 | 79 | const onError = (err: Error) => { 80 | outputChannel.error(`Docker images event stream error: ${err.message}`); 81 | }; 82 | 83 | stream.on("data", onData); 84 | stream.on("error", onError); 85 | 86 | // Ensure we check if the monitor handle needs to dispose us. 87 | monitorHandle = { 88 | dispose: () => { 89 | stream.off("data", onData); 90 | stream.off("error", onError); 91 | stream.removeAllListeners(); 92 | monitorHandle = undefined; 93 | } 94 | }; 95 | context.subscriptions.push(monitorHandle); 96 | 97 | }) 98 | .catch((err: any) => { 99 | outputChannel.warn(`Unable to subscribe to Docker image events: ${err?.message || err}`); 100 | }); 101 | } 102 | -------------------------------------------------------------------------------- /src/topoViewer/webview/features/canvas/DummyLinksManager.ts: -------------------------------------------------------------------------------- 1 | // file: DummyLinks.ts 2 | 3 | import cytoscape from 'cytoscape'; 4 | import { log } from '../../platform/logging/logger'; 5 | import topoViewerState from '../../app/state'; 6 | import { PREFIX_DUMMY } from '../../../shared/utilities/LinkTypes'; 7 | 8 | const DUMMY_LINK_HIDDEN_CLASS = 'dummy-link-hidden'; 9 | const DUMMY_TOGGLE_BUTTON_ID = 'viewport-dummy-links-toggle'; 10 | 11 | /** 12 | * Manages dummy link visibility in the topology viewer. 13 | * Dummy links connect nodes to synthetic "dummy" endpoints and can be hidden 14 | * without affecting the underlying YAML topology data. 15 | */ 16 | export class DummyLinksManager { 17 | private cy: cytoscape.Core | null = null; 18 | private visible: boolean = topoViewerState.dummyLinksVisible; 19 | 20 | /** 21 | * Bind the manager to a Cytoscape instance. 22 | */ 23 | public initialize(cy: cytoscape.Core): void { 24 | if (this.cy === cy) { 25 | this.applyVisibility(); 26 | this.syncButton(); 27 | return; 28 | } 29 | 30 | this.cy = cy; 31 | this.visible = topoViewerState.dummyLinksVisible; 32 | this.applyVisibility(); 33 | this.syncButton(); 34 | log.debug(`Dummy links manager initialized, visible: ${this.visible}`); 35 | } 36 | 37 | /** 38 | * Toggle dummy link visibility. 39 | */ 40 | public toggle(): void { 41 | this.setVisibility(!this.visible); 42 | } 43 | 44 | /** 45 | * Set dummy link visibility. 46 | */ 47 | public setVisibility(visible: boolean): void { 48 | if (visible === this.visible && topoViewerState.dummyLinksVisible === visible) { 49 | return; 50 | } 51 | 52 | this.visible = visible; 53 | topoViewerState.dummyLinksVisible = visible; 54 | 55 | log.info(`Dummy links visibility set to: ${visible}`); 56 | if (this.cy) { 57 | this.applyVisibility(); 58 | } 59 | this.syncButton(); 60 | } 61 | 62 | /** 63 | * Get current visibility state. 64 | */ 65 | public isVisible(): boolean { 66 | return this.visible; 67 | } 68 | 69 | /** 70 | * Re-apply visibility after Cytoscape elements are refreshed. 71 | */ 72 | public refreshAfterUpdate(): void { 73 | if (!this.cy) { 74 | return; 75 | } 76 | this.applyVisibility(); 77 | this.syncButton(); 78 | } 79 | 80 | private applyVisibility(): void { 81 | const cy = this.cy; 82 | if (!cy) { 83 | return; 84 | } 85 | 86 | // Find all edges connected to dummy nodes 87 | const dummyEdges = cy.edges().filter(edge => { 88 | const sourceId = edge.source().id(); 89 | const targetId = edge.target().id(); 90 | return sourceId.startsWith(PREFIX_DUMMY) || targetId.startsWith(PREFIX_DUMMY); 91 | }); 92 | 93 | // Find all dummy nodes 94 | const dummyNodes = cy.nodes().filter(node => { 95 | return node.id().startsWith(PREFIX_DUMMY); 96 | }); 97 | 98 | if (this.visible) { 99 | // Show dummy links and nodes 100 | dummyEdges.removeClass(DUMMY_LINK_HIDDEN_CLASS); 101 | dummyNodes.removeClass(DUMMY_LINK_HIDDEN_CLASS); 102 | } else { 103 | // Hide dummy links and nodes 104 | dummyEdges.addClass(DUMMY_LINK_HIDDEN_CLASS); 105 | dummyNodes.addClass(DUMMY_LINK_HIDDEN_CLASS); 106 | } 107 | } 108 | 109 | private syncButton(): void { 110 | const button = document.getElementById(DUMMY_TOGGLE_BUTTON_ID) as HTMLButtonElement | null; 111 | if (button) { 112 | button.dataset.visible = this.visible ? 'true' : 'false'; 113 | button.dataset.selected = this.visible ? 'true' : 'false'; 114 | button.setAttribute('aria-checked', this.visible ? 'true' : 'false'); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/topoViewer/extension/services/LabLifecycleService.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { log } from '../../webview/platform/logging/logger'; 3 | 4 | /** 5 | * Result type for endpoint handlers 6 | */ 7 | export interface EndpointResult { 8 | result: unknown; 9 | error: string | null; 10 | } 11 | 12 | /** 13 | * Action configuration for lab lifecycle commands 14 | */ 15 | interface LabAction { 16 | command: string; 17 | resultMsg: string; 18 | errorMsg: string; 19 | noLabPath: string; 20 | } 21 | 22 | /** 23 | * Map of available lab lifecycle actions 24 | */ 25 | const LAB_ACTIONS: Record = { 26 | deployLab: { 27 | command: 'containerlab.lab.deploy', 28 | resultMsg: 'Lab deployment initiated', 29 | errorMsg: 'Error deploying lab', 30 | noLabPath: 'No lab path provided for deployment', 31 | }, 32 | destroyLab: { 33 | command: 'containerlab.lab.destroy', 34 | resultMsg: 'Lab destruction initiated', 35 | errorMsg: 'Error destroying lab', 36 | noLabPath: 'No lab path provided for destruction', 37 | }, 38 | deployLabCleanup: { 39 | command: 'containerlab.lab.deploy.cleanup', 40 | resultMsg: 'Lab deployment with cleanup initiated', 41 | errorMsg: 'Error deploying lab with cleanup', 42 | noLabPath: 'No lab path provided for deployment with cleanup', 43 | }, 44 | destroyLabCleanup: { 45 | command: 'containerlab.lab.destroy.cleanup', 46 | resultMsg: 'Lab destruction with cleanup initiated', 47 | errorMsg: 'Error destroying lab with cleanup', 48 | noLabPath: 'No lab path provided for destruction with cleanup', 49 | }, 50 | redeployLab: { 51 | command: 'containerlab.lab.redeploy', 52 | resultMsg: 'Lab redeploy initiated', 53 | errorMsg: 'Error redeploying lab', 54 | noLabPath: 'No lab path provided for redeploy', 55 | }, 56 | redeployLabCleanup: { 57 | command: 'containerlab.lab.redeploy.cleanup', 58 | resultMsg: 'Lab redeploy with cleanup initiated', 59 | errorMsg: 'Error redeploying lab with cleanup', 60 | noLabPath: 'No lab path provided for redeploy with cleanup', 61 | }, 62 | }; 63 | 64 | /** 65 | * Service for handling lab lifecycle operations (deploy, destroy, redeploy). 66 | * Executes containerlab commands via VS Code command palette. 67 | */ 68 | export class LabLifecycleService { 69 | /** 70 | * Handles lab lifecycle endpoint requests. 71 | * @param endpointName The action to perform (deployLab, destroyLab, etc.) 72 | * @param labPath The path to the lab topology file 73 | */ 74 | async handleLabLifecycleEndpoint( 75 | endpointName: string, 76 | labPath: string 77 | ): Promise { 78 | const action = LAB_ACTIONS[endpointName]; 79 | if (!action) { 80 | const error = `Unknown endpoint "${endpointName}".`; 81 | log.error(error); 82 | return { result: null, error }; 83 | } 84 | 85 | if (!labPath) { 86 | return { result: null, error: action.noLabPath }; 87 | } 88 | 89 | try { 90 | const { ClabLabTreeNode } = await import('../../../treeView/common'); 91 | const tempNode = new ClabLabTreeNode( 92 | '', 93 | vscode.TreeItemCollapsibleState.None, 94 | { absolute: labPath, relative: '' } 95 | ); 96 | vscode.commands.executeCommand(action.command, tempNode); 97 | return { result: `${action.resultMsg} for ${labPath}`, error: null }; 98 | } catch (innerError) { 99 | const error = `${action.errorMsg}: ${innerError}`; 100 | log.error(`${action.errorMsg}: ${JSON.stringify(innerError, null, 2)}`); 101 | return { result: null, error }; 102 | } 103 | } 104 | } 105 | 106 | // Export a singleton instance 107 | export const labLifecycleService = new LabLifecycleService(); 108 | -------------------------------------------------------------------------------- /src/topoViewer/webview/ui/IconDropdownRenderer.ts: -------------------------------------------------------------------------------- 1 | import { getIconDataUriForRole } from '../features/canvas/BaseStyles'; 2 | 3 | const ATTR_ARIA_DISABLED = 'aria-disabled'; 4 | const CLASS_DIMMED = 'opacity-60'; 5 | const CLASS_NO_POINTER = 'pointer-events-none'; 6 | 7 | type NodeIconDeleteHandler = (iconName: string) => void | Promise; 8 | 9 | export interface NodeIconOptionRendererOptions { 10 | onDelete?: NodeIconDeleteHandler; 11 | } 12 | 13 | function isCustomIcon(role: string): boolean { 14 | const customIcons = (window as any)?.customIcons; 15 | return Boolean(customIcons && typeof customIcons === 'object' && customIcons[role]); 16 | } 17 | 18 | export function createNodeIconOptionElement( 19 | role: string, 20 | options?: NodeIconOptionRendererOptions 21 | ): HTMLElement { 22 | const wrapper = document.createElement('div'); 23 | wrapper.className = 'flex items-center justify-between gap-3 py-1 w-full'; 24 | wrapper.style.display = 'flex'; 25 | wrapper.style.alignItems = 'center'; 26 | wrapper.style.justifyContent = 'space-between'; 27 | wrapper.style.gap = '0.75rem'; 28 | wrapper.style.padding = '0.25rem 0'; 29 | wrapper.style.width = '100%'; 30 | 31 | const infoContainer = document.createElement('div'); 32 | infoContainer.className = 'flex items-center gap-3'; 33 | infoContainer.style.display = 'flex'; 34 | infoContainer.style.alignItems = 'center'; 35 | infoContainer.style.gap = '0.75rem'; 36 | 37 | const iconUri = getIconDataUriForRole(role); 38 | const icon = document.createElement('img'); 39 | icon.alt = `${role} icon`; 40 | icon.width = 40; 41 | icon.height = 40; 42 | icon.style.width = '40px'; 43 | icon.style.height = '40px'; 44 | icon.style.objectFit = 'contain'; 45 | icon.style.borderRadius = '4px'; 46 | if (iconUri) { 47 | icon.src = iconUri; 48 | } 49 | 50 | const label = document.createElement('span'); 51 | label.textContent = role; 52 | label.className = 'text-base capitalize flex-1'; 53 | label.style.flex = '1'; 54 | 55 | infoContainer.appendChild(icon); 56 | infoContainer.appendChild(label); 57 | wrapper.appendChild(infoContainer); 58 | 59 | const shouldShowDeleteButton = Boolean(isCustomIcon(role) && options?.onDelete); 60 | if (shouldShowDeleteButton) { 61 | const deleteButton = document.createElement('span'); 62 | deleteButton.setAttribute('role', 'button'); 63 | deleteButton.tabIndex = 0; 64 | deleteButton.className = 'btn-icon-hover-clean flex items-center gap-1 text-sm'; 65 | deleteButton.style.color = 'var(--vscode-errorForeground, #f14c4c)'; 66 | deleteButton.style.cursor = 'pointer'; 67 | deleteButton.title = `Delete custom icon "${role}"`; 68 | deleteButton.innerHTML = ''; 69 | 70 | const executeDelete = (): void => { 71 | if (!options?.onDelete || deleteButton.getAttribute(ATTR_ARIA_DISABLED) === 'true') { 72 | return; 73 | } 74 | deleteButton.setAttribute(ATTR_ARIA_DISABLED, 'true'); 75 | deleteButton.classList.add(CLASS_DIMMED, CLASS_NO_POINTER); 76 | const maybePromise = options.onDelete(role); 77 | Promise.resolve(maybePromise) 78 | .catch(() => undefined) 79 | .finally(() => { 80 | deleteButton.setAttribute(ATTR_ARIA_DISABLED, 'false'); 81 | deleteButton.classList.remove(CLASS_DIMMED, CLASS_NO_POINTER); 82 | }); 83 | }; 84 | 85 | deleteButton.addEventListener('click', event => { 86 | event.preventDefault(); 87 | event.stopPropagation(); 88 | executeDelete(); 89 | }); 90 | 91 | deleteButton.addEventListener('keydown', event => { 92 | if (event.key !== 'Enter' && event.key !== ' ') { 93 | return; 94 | } 95 | event.preventDefault(); 96 | event.stopPropagation(); 97 | executeDelete(); 98 | }); 99 | 100 | wrapper.appendChild(deleteButton); 101 | } 102 | 103 | return wrapper; 104 | } 105 | -------------------------------------------------------------------------------- /src/commands/clabCommand.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as cmd from './command'; 3 | import { ClabLabTreeNode } from "../treeView/common"; 4 | import { containerlabBinaryPath } from "../extension"; 5 | /** 6 | * A helper class to build a 'containerlab' command (with optional sudo, etc.) 7 | * and run it either in the Output channel or in a Terminal. 8 | */ 9 | export class ClabCommand extends cmd.Command { 10 | private node?: ClabLabTreeNode; 11 | private action: string; 12 | private runtime: string; 13 | 14 | constructor( 15 | action: string, 16 | node: ClabLabTreeNode, 17 | spinnerMsg?: cmd.SpinnerMsg, 18 | useTerminal?: boolean, 19 | terminalName?: string, 20 | onSuccess?: () => Promise, 21 | onFailure?: cmd.CommandFailureHandler 22 | ) { 23 | const binaryPath = containerlabBinaryPath || "containerlab"; 24 | let options: cmd.CmdOptions; 25 | if (useTerminal) { 26 | options = { 27 | command: binaryPath, 28 | useSpinner: false, 29 | terminalName: terminalName || "Containerlab", 30 | }; 31 | } else { 32 | options = { 33 | command: binaryPath, 34 | useSpinner: true, 35 | spinnerMsg: spinnerMsg || { 36 | progressMsg: `Running ${action}...`, 37 | successMsg: `${action} completed successfully` 38 | }, 39 | }; 40 | } 41 | super(options); 42 | 43 | // Read the runtime from configuration. 44 | const config = vscode.workspace.getConfiguration("containerlab"); 45 | this.runtime = config.get("runtime", "docker"); 46 | 47 | this.action = action; 48 | this.node = node instanceof ClabLabTreeNode ? node : undefined; 49 | if (onSuccess) { 50 | this.onSuccessCallback = onSuccess; 51 | } 52 | if (onFailure) { 53 | this.onFailureCallback = onFailure; 54 | } 55 | } 56 | 57 | public async run(flags?: string[]): Promise { 58 | // Try node.details -> fallback to active editor 59 | let labPath: string; 60 | if (!this.node) { 61 | const editor = vscode.window.activeTextEditor; 62 | if (!editor) { 63 | vscode.window.showErrorMessage( 64 | 'No lab node or topology file selected' 65 | ); 66 | return; 67 | } 68 | labPath = editor.document.uri.fsPath; 69 | } 70 | else { 71 | labPath = this.node.labPath.absolute 72 | } 73 | 74 | if (!labPath) { 75 | vscode.window.showErrorMessage( 76 | `No labPath found for command "${this.action}".` 77 | ); 78 | return; 79 | } 80 | 81 | // Build the command 82 | const config = vscode.workspace.getConfiguration("containerlab"); 83 | let extraFlags: string[] = []; 84 | if (this.action === "deploy" || this.action === "redeploy") { 85 | const extra = config.get("deploy.extraArgs", ""); 86 | if (extra) { 87 | extraFlags = extra.split(/\s+/).filter(f => f); 88 | } 89 | } else if (this.action === "destroy") { 90 | const extra = config.get("destroy.extraArgs", ""); 91 | if (extra) { 92 | extraFlags = extra.split(/\s+/).filter(f => f); 93 | } 94 | } 95 | 96 | const allFlags = [...extraFlags]; 97 | if (flags) { 98 | allFlags.push(...flags); 99 | } 100 | 101 | const cmdArgs = [this.action, "-r", this.runtime, ...allFlags, "-t", labPath]; 102 | 103 | // Return the promise from .execute() so we can await 104 | return this.execute(cmdArgs); 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /src/topoViewer/extension/services/YamlValidator.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as YAML from 'yaml'; 3 | 4 | import { log } from '../../webview/platform/logging/logger'; 5 | 6 | export async function validateYamlContent(_context: vscode.ExtensionContext, yamlContent: string): Promise { 7 | try { 8 | const yamlObj = YAML.parse(yamlContent); 9 | 10 | if (!isObject(yamlObj)) { 11 | return fail('Invalid YAML: Content must be an object'); 12 | } 13 | 14 | if (!validateName(yamlObj)) { 15 | return false; 16 | } 17 | 18 | if (!validateTopology(yamlObj)) { 19 | return false; 20 | } 21 | 22 | checkLinkReferencesIfNeeded(yamlObj); 23 | return true; 24 | } catch (err) { 25 | handleError(err); 26 | return false; 27 | } 28 | } 29 | 30 | function isObject(value: unknown): value is Record { 31 | return value !== null && typeof value === 'object' && !Array.isArray(value); 32 | } 33 | 34 | function fail(message: string): false { 35 | vscode.window.showErrorMessage(message); 36 | log.error(message); 37 | return false; 38 | } 39 | 40 | function validateName(yamlObj: any): boolean { 41 | if (typeof yamlObj.name !== 'string' || yamlObj.name.trim() === '') { 42 | return fail('Invalid Containerlab YAML: "name" field is required and cannot be empty'); 43 | } 44 | return true; 45 | } 46 | 47 | function validateTopology(yamlObj: any): boolean { 48 | const topology = yamlObj.topology; 49 | if (topology === undefined) { 50 | return true; 51 | } 52 | if (!isObject(topology)) { 53 | return fail('Invalid Containerlab YAML: "topology" must be an object if present'); 54 | } 55 | if (topology.nodes !== undefined && !isObject(topology.nodes)) { 56 | return fail('Invalid Containerlab YAML: "topology.nodes" must be an object if present'); 57 | } 58 | if (topology.links !== undefined && !Array.isArray(topology.links)) { 59 | return fail('Invalid Containerlab YAML: "topology.links" must be an array if present'); 60 | } 61 | if (topology.nodes) { 62 | for (const [nodeName, nodeData] of Object.entries(topology.nodes)) { 63 | if (nodeData !== null && typeof nodeData !== 'object') { 64 | log.warn(`Node "${nodeName}" should be an object, but found ${typeof nodeData}. Treating as minimal node.`); 65 | } 66 | } 67 | } 68 | return true; 69 | } 70 | 71 | function checkLinkReferencesIfNeeded(yamlObj: any): void { 72 | const nodes = yamlObj.topology?.nodes; 73 | const links = yamlObj.topology?.links; 74 | if (isObject(nodes) && Array.isArray(links) && Object.keys(nodes).length > 0 && links.length > 0) { 75 | const linkError = checkLinkReferences(yamlObj); 76 | if (linkError) { 77 | log.warn(`Link reference warning: ${linkError}`); 78 | } 79 | } 80 | } 81 | 82 | function handleError(err: unknown): void { 83 | if (err instanceof YAML.YAMLParseError) { 84 | const message = `YAML syntax error: ${err.message}`; 85 | vscode.window.showErrorMessage(message); 86 | log.error(message); 87 | } else { 88 | const message = `Error validating YAML: ${err}`; 89 | vscode.window.showErrorMessage(message); 90 | log.error(message); 91 | } 92 | } 93 | 94 | function checkLinkReferences(yamlObj: any): string | null { 95 | const nodes = new Set(Object.keys(yamlObj?.topology?.nodes ?? {})); 96 | const links = yamlObj?.topology?.links; 97 | if (!Array.isArray(links)) return null; 98 | 99 | const invalidNodes = new Set(); 100 | for (const link of links) { 101 | const endpoints = Array.isArray(link?.endpoints) ? link.endpoints : []; 102 | for (const ep of endpoints) { 103 | if (typeof ep !== 'string') continue; 104 | const nodeName = ep.split(':')[0]; 105 | if (nodeName && !nodes.has(nodeName)) invalidNodes.add(nodeName); 106 | } 107 | } 108 | 109 | return invalidNodes.size 110 | ? `Undefined node reference(s): ${Array.from(invalidNodes).join(', ')}` 111 | : null; 112 | } 113 | -------------------------------------------------------------------------------- /test/helpers/vscode-stub.ts: -------------------------------------------------------------------------------- 1 | export const window = { 2 | lastErrorMessage: '', 3 | lastInfoMessage: '', 4 | createOutputChannel(_name: string, options?: { log: boolean } | string) { 5 | const isLogChannel = typeof options === 'object' && options?.log; 6 | return { 7 | appendLine() {}, 8 | show() {}, 9 | // LogOutputChannel methods (when { log: true } is passed) 10 | ...(isLogChannel && { 11 | info() {}, 12 | debug() {}, 13 | warn() {}, 14 | error() {}, 15 | trace() {}, 16 | }), 17 | }; 18 | }, 19 | showErrorMessage(message: string) { 20 | this.lastErrorMessage = message; 21 | }, 22 | showInformationMessage(message: string) { 23 | this.lastInfoMessage = message; 24 | }, 25 | }; 26 | 27 | export const commands = { 28 | executed: [] as { command: string; args: any[] }[], 29 | executeCommand(command: string, ...args: any[]) { 30 | this.executed.push({ command, args }); 31 | return Promise.resolve(); 32 | }, 33 | }; 34 | 35 | export const workspace = { 36 | workspaceFolders: [] as { uri: { fsPath: string }; name?: string }[], 37 | getConfiguration() { 38 | return { 39 | get: (_: string, defaultValue?: T): T | undefined => defaultValue, 40 | }; 41 | }, 42 | updateWorkspaceFolders( 43 | index: number, 44 | deleteCount: number | null, 45 | ...folders: { uri: { fsPath: string }; name?: string }[] 46 | ) { 47 | const del = deleteCount ?? 0; 48 | this.workspaceFolders.splice(index, del, ...folders); 49 | }, 50 | onDidSaveTextDocument(cb: any) { 51 | if (typeof cb === 'function') { 52 | // no-op 53 | } 54 | return { dispose() {} }; 55 | }, 56 | fs: { 57 | readFile: async () => new TextEncoder().encode('{}'), 58 | }, 59 | }; 60 | 61 | export const Uri = { 62 | file(p: string) { 63 | return { fsPath: p }; 64 | }, 65 | joinPath(...parts: any[]) { 66 | return { fsPath: parts.map(p => (typeof p === 'string' ? p : p.fsPath)).join('/') }; 67 | }, 68 | }; 69 | 70 | export class TreeItem { 71 | public iconPath: any; 72 | public label?: string; 73 | public collapsibleState?: number; 74 | constructor(label?: string, collapsibleState?: number) { 75 | this.label = label; 76 | this.collapsibleState = collapsibleState; 77 | } 78 | } 79 | 80 | export const TreeItemCollapsibleState = { 81 | None: 0, 82 | Collapsed: 1, 83 | Expanded: 2, 84 | } as const; 85 | 86 | export class ThemeColor { 87 | public id: string; 88 | constructor(id: string) { 89 | this.id = id; 90 | } 91 | } 92 | 93 | export class ThemeIcon { 94 | static readonly File = 'file'; 95 | public id: string; 96 | public color?: ThemeColor; 97 | constructor(id: string, color?: ThemeColor) { 98 | this.id = id; 99 | this.color = color; 100 | } 101 | } 102 | 103 | export const ViewColumn = { 104 | One: 1, 105 | }; 106 | 107 | export const env = { 108 | clipboard: { 109 | lastText: '', 110 | writeText(text: string) { 111 | this.lastText = text; 112 | return Promise.resolve(); 113 | }, 114 | }, 115 | }; 116 | 117 | export const extensions = { 118 | getExtension(_extensionId: string) { 119 | if (_extensionId) { 120 | // no-op 121 | } 122 | return { 123 | packageJSON: { 124 | version: '0.0.0-test', 125 | }, 126 | }; 127 | }, 128 | }; 129 | 130 | export class EventEmitter { 131 | private listeners: Function[] = []; 132 | 133 | get event(): Function { 134 | return (callback: Function) => { 135 | this.listeners.push(callback); 136 | return { 137 | dispose: () => { 138 | const idx = this.listeners.indexOf(callback); 139 | if (idx >= 0) { 140 | this.listeners.splice(idx, 1); 141 | } 142 | }, 143 | }; 144 | }; 145 | } 146 | 147 | fire(data: T): void { 148 | for (const listener of this.listeners) { 149 | listener(data); 150 | } 151 | } 152 | 153 | dispose(): void { 154 | this.listeners = []; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR Checks 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | 7 | jobs: 8 | build-and-test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 22 19 | cache: "npm" 20 | 21 | - name: Install dependencies 22 | run: npm install 23 | 24 | - name: Create package and run lint 25 | run: npm run package 26 | 27 | - name: Run tests 28 | run: npm test 29 | 30 | - name: Upload test reports 31 | uses: actions/upload-artifact@v4 32 | if: always() # Upload test reports even if tests failed 33 | with: 34 | name: test-reports-${{ github.run_number }} 35 | path: | 36 | mochawesome-report/ 37 | out/test/ 38 | retention-days: 30 39 | 40 | - name: Upload compiled output 41 | uses: actions/upload-artifact@v4 42 | if: always() 43 | with: 44 | name: compiled-output-${{ github.run_number }} 45 | path: | 46 | out/ 47 | dist/ 48 | retention-days: 7 49 | 50 | - name: Upload packaged extension 51 | uses: actions/upload-artifact@v4 52 | if: always() 53 | with: 54 | name: vscode-extension-${{ github.run_number }} 55 | path: "*.vsix" 56 | retention-days: 30 57 | 58 | - name: Upload build logs 59 | uses: actions/upload-artifact@v4 60 | if: failure() # Only upload logs if something failed 61 | with: 62 | name: build-logs-${{ github.run_number }} 63 | path: | 64 | npm-debug.log* 65 | yarn-error.log* 66 | .npm/_logs/ 67 | retention-days: 7 68 | 69 | # Optional: Add a job to test the packaged extension 70 | package-validation: 71 | runs-on: ubuntu-latest 72 | needs: build-and-test 73 | if: always() 74 | 75 | steps: 76 | - name: Checkout repository 77 | uses: actions/checkout@v4 78 | 79 | - name: Setup Node.js 80 | uses: actions/setup-node@v4 81 | with: 82 | node-version: 22 83 | cache: "npm" 84 | 85 | - name: Install dependencies 86 | run: npm install 87 | 88 | - name: Download packaged extension 89 | uses: actions/download-artifact@v4 90 | with: 91 | name: vscode-extension-${{ github.run_number }} 92 | path: ./package/ 93 | 94 | - name: Validate package 95 | run: | 96 | echo "Checking if VSIX package was created..." 97 | if ls ./package/*.vsix 1> /dev/null 2>&1; then 98 | echo "✅ VSIX package found:" 99 | ls -la ./package/*.vsix 100 | 101 | # Install vsce to inspect the package 102 | npm install -g @vscode/vsce 103 | 104 | echo "📦 Package contents:" 105 | vsce ls ./package/*.vsix 106 | 107 | echo "✅ Package validation completed successfully" 108 | else 109 | echo "❌ No VSIX package found" 110 | exit 1 111 | fi 112 | 113 | # Summary job that depends on all other jobs 114 | pr-check-summary: 115 | runs-on: ubuntu-latest 116 | needs: [build-and-test, package-validation] 117 | if: always() 118 | 119 | steps: 120 | - name: Check results 121 | run: | 122 | echo "Build and Test: ${{ needs.build-and-test.result }}" 123 | echo "Package Validation: ${{ needs.package-validation.result }}" 124 | 125 | if [[ "${{ needs.build-and-test.result }}" == "success" && "${{ needs.package-validation.result }}" == "success" ]]; then 126 | echo "✅ All checks passed!" 127 | else 128 | echo "❌ Some checks failed. Please review the artifacts and logs." 129 | exit 1 130 | fi 131 | -------------------------------------------------------------------------------- /src/topoViewer/extension/services/YamlSettingsManager.ts: -------------------------------------------------------------------------------- 1 | import * as YAML from 'yaml'; 2 | 3 | /** 4 | * Interface for lab settings that can be updated 5 | */ 6 | export interface LabSettings { 7 | name?: string; 8 | prefix?: string | null; 9 | mgmt?: Record | null; 10 | } 11 | 12 | /** 13 | * Result of applying settings to a YAML document 14 | */ 15 | export interface ApplySettingsResult { 16 | hadPrefix: boolean; 17 | hadMgmt: boolean; 18 | } 19 | 20 | /** 21 | * Manages YAML settings operations for containerlab topology files. 22 | * Provides utilities for updating lab settings like name, prefix, and mgmt. 23 | */ 24 | export class YamlSettingsManager { 25 | /** 26 | * Applies existing settings to a YAML document. 27 | * Updates name, prefix, and mgmt fields if they exist in both the document and settings. 28 | */ 29 | applyExistingSettings(doc: YAML.Document, settings: LabSettings): ApplySettingsResult { 30 | if (settings.name !== undefined && settings.name !== '') { 31 | doc.set('name', settings.name); 32 | } 33 | const hadPrefix = doc.has('prefix'); 34 | const hadMgmt = doc.has('mgmt'); 35 | if (settings.prefix !== undefined && hadPrefix) { 36 | if (settings.prefix === null) { 37 | doc.delete('prefix'); 38 | } else { 39 | doc.set('prefix', settings.prefix); 40 | } 41 | } 42 | if (settings.mgmt !== undefined && hadMgmt) { 43 | if (settings.mgmt === null || (typeof settings.mgmt === 'object' && Object.keys(settings.mgmt).length === 0)) { 44 | doc.delete('mgmt'); 45 | } else { 46 | doc.set('mgmt', settings.mgmt); 47 | } 48 | } 49 | return { hadPrefix, hadMgmt }; 50 | } 51 | 52 | /** 53 | * Inserts missing settings into the YAML content. 54 | * Called after applyExistingSettings to add new prefix/mgmt fields. 55 | */ 56 | insertMissingSettings( 57 | updatedYaml: string, 58 | settings: LabSettings, 59 | hadPrefix: boolean, 60 | hadMgmt: boolean 61 | ): string { 62 | updatedYaml = this.maybeInsertPrefix(updatedYaml, settings, hadPrefix); 63 | updatedYaml = this.maybeInsertMgmt(updatedYaml, settings, hadMgmt); 64 | return updatedYaml; 65 | } 66 | 67 | /** 68 | * Inserts prefix field after the name field if it doesn't exist. 69 | */ 70 | private maybeInsertPrefix(updatedYaml: string, settings: LabSettings, hadPrefix: boolean): string { 71 | if (settings.prefix === undefined || settings.prefix === null || hadPrefix) { 72 | return updatedYaml; 73 | } 74 | const lines = updatedYaml.split('\n'); 75 | const nameIndex = lines.findIndex(line => line.trim().startsWith('name:')); 76 | if (nameIndex === -1) { 77 | return updatedYaml; 78 | } 79 | const prefixValue = settings.prefix === '' ? '""' : settings.prefix; 80 | lines.splice(nameIndex + 1, 0, `prefix: ${prefixValue}`); 81 | return lines.join('\n'); 82 | } 83 | 84 | /** 85 | * Inserts mgmt section after prefix (or name) if it doesn't exist. 86 | */ 87 | private maybeInsertMgmt(updatedYaml: string, settings: LabSettings, hadMgmt: boolean): string { 88 | if (settings.mgmt === undefined || hadMgmt || !settings.mgmt || Object.keys(settings.mgmt).length === 0) { 89 | return updatedYaml; 90 | } 91 | const lines = updatedYaml.split('\n'); 92 | let insertIndex = lines.findIndex(line => line.trim().startsWith('prefix:')); 93 | if (insertIndex === -1) { 94 | insertIndex = lines.findIndex(line => line.trim().startsWith('name:')); 95 | } 96 | if (insertIndex === -1) { 97 | return updatedYaml; 98 | } 99 | const mgmtYaml = YAML.stringify({ mgmt: settings.mgmt }); 100 | const mgmtLines = mgmtYaml.split('\n').filter(line => line.trim()); 101 | const nextLine = lines[insertIndex + 1]; 102 | if (nextLine && nextLine.trim() !== '') { 103 | lines.splice(insertIndex + 1, 0, '', ...mgmtLines); 104 | } else { 105 | lines.splice(insertIndex + 1, 0, ...mgmtLines); 106 | } 107 | return lines.join('\n'); 108 | } 109 | } 110 | 111 | // Export a singleton instance 112 | export const yamlSettingsManager = new YamlSettingsManager(); 113 | -------------------------------------------------------------------------------- /src/topoViewer/webview/features/canvas/GeoUtils.ts: -------------------------------------------------------------------------------- 1 | export interface LatLngDataItem { 2 | data: { 3 | id?: string; 4 | lat?: string; 5 | lng?: string; 6 | [key: string]: any; 7 | }; 8 | } 9 | 10 | function addIfValidNumber(target: number[], maybe?: string) { 11 | if (!maybe) return; 12 | const s = maybe.trim(); 13 | if (!s) return; 14 | const n = parseFloat(s); 15 | if (!isNaN(n)) target.push(n); 16 | } 17 | 18 | function computeAverage(values: number[], fallback: number): { avg: number; usedDefault: boolean } { 19 | if (values.length === 0) return { avg: fallback, usedDefault: true }; 20 | const avg = values.reduce((a, b) => a + b, 0) / values.length; 21 | return { avg, usedDefault: false }; 22 | } 23 | 24 | function normalizeCoord( 25 | value: string | undefined, 26 | average: number, 27 | defaultAverage: number, 28 | counter: { value: number }, 29 | usedDefault: boolean, 30 | ): string { 31 | const normalized = value && value.trim() !== '' ? parseFloat(value) : NaN; 32 | if (!isNaN(normalized)) { 33 | return normalized.toFixed(15); 34 | } 35 | const deterministicOffset = (counter.value++ % 9) * 0.1; 36 | const base = usedDefault ? defaultAverage : (average + deterministicOffset); 37 | return base.toFixed(15); 38 | } 39 | 40 | export function assignMissingLatLngToElements(dataArray: T[]): T[] { 41 | const DEFAULT_AVERAGE_LAT = 48.684826888402256; 42 | const DEFAULT_AVERAGE_LNG = 9.007895390625677; 43 | const existingLats: number[] = []; 44 | const existingLngs: number[] = []; 45 | 46 | dataArray.forEach(({ data }) => { 47 | addIfValidNumber(existingLats, data.lat); 48 | addIfValidNumber(existingLngs, data.lng); 49 | }); 50 | 51 | const { avg: averageLat, usedDefault: usedDefaultLat } = computeAverage(existingLats, DEFAULT_AVERAGE_LAT); 52 | const { avg: averageLng, usedDefault: usedDefaultLng } = computeAverage(existingLngs, DEFAULT_AVERAGE_LNG); 53 | 54 | const counter = { value: 0 }; 55 | dataArray.forEach(item => { 56 | const { data } = item; 57 | data.lat = normalizeCoord(data.lat, averageLat, DEFAULT_AVERAGE_LAT, counter, usedDefaultLat); 58 | data.lng = normalizeCoord(data.lng, averageLng, DEFAULT_AVERAGE_LNG, counter, usedDefaultLng); 59 | }); 60 | 61 | return dataArray; 62 | } 63 | 64 | export function assignMissingLatLngToCy(cy: any): void { 65 | if (!cy) return; 66 | const stats = computeLatLngStats(cy); 67 | cy.nodes().forEach((node: any) => applyLatLng(node, stats)); 68 | } 69 | 70 | function computeLatLngStats(cy: any) { 71 | const DEFAULT_AVERAGE_LAT = 48.684826888402256; 72 | const DEFAULT_AVERAGE_LNG = 9.007895390625677; 73 | 74 | const lats: number[] = []; 75 | const lngs: number[] = []; 76 | cy.nodes().forEach((node: any) => { 77 | const lat = parseFloat(node.data('lat')); 78 | if (!isNaN(lat)) lats.push(lat); 79 | const lng = parseFloat(node.data('lng')); 80 | if (!isNaN(lng)) lngs.push(lng); 81 | }); 82 | 83 | const avgLat = lats.length > 0 ? lats.reduce((a, b) => a + b, 0) / lats.length : DEFAULT_AVERAGE_LAT; 84 | const avgLng = lngs.length > 0 ? lngs.reduce((a, b) => a + b, 0) / lngs.length : DEFAULT_AVERAGE_LNG; 85 | return { 86 | avgLat, 87 | avgLng, 88 | useDefaultLat: lats.length === 0, 89 | useDefaultLng: lngs.length === 0, 90 | DEFAULT_AVERAGE_LAT, 91 | DEFAULT_AVERAGE_LNG 92 | }; 93 | } 94 | 95 | function applyLatLng(node: any, stats: ReturnType) { 96 | const { avgLat, avgLng, useDefaultLat, useDefaultLng, DEFAULT_AVERAGE_LAT, DEFAULT_AVERAGE_LNG } = stats; 97 | 98 | let lat = parseFloat(node.data('lat')); 99 | if (!node.data('lat') || isNaN(lat)) { 100 | const idx = node.id().length % 5; 101 | const offset = (idx - 2) * 0.05; 102 | lat = (useDefaultLat ? DEFAULT_AVERAGE_LAT : avgLat) + offset; 103 | } 104 | 105 | let lng = parseFloat(node.data('lng')); 106 | if (!node.data('lng') || isNaN(lng)) { 107 | const idx = (node.id().charCodeAt(0) || 0) % 7; 108 | const offset = (idx - 3) * 0.05; 109 | lng = (useDefaultLng ? DEFAULT_AVERAGE_LNG : avgLng) + offset; 110 | } 111 | 112 | node.data('lat', lat.toFixed(15)); 113 | node.data('lng', lng.toFixed(15)); 114 | } 115 | --------------------------------------------------------------------------------