diff --git a/README.md b/README.md index ad1e0e3e1..7a4e0d5b4 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,8 @@ A Visual Studio Code extension that integrates [containerlab](https://containerl ## Requirements -- **containerlab** must be installed and accessible in your system `PATH`. The extension will offer to install it if not found. +- **containerlab** must be installed. The extension will offer to install it if not found. +- You must be in the `clab_admins` and `docker` group. Podman is not supported for runtime features. - (Optional) **Edgeshark** for packet capture features - can be installed directly from the extension using the "Install Edgeshark" command. @@ -83,13 +84,13 @@ Configure the extension behavior through VS Code settings (`containerlab.*`): | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `sudoEnabledByDefault` | boolean | `false` | Prepend `sudo` to containerlab commands | -| `runtime` | string | `docker` | Container runtime (`docker`, `podman`, `ignite`) | -| `refreshInterval` | number | `5000` | Auto-refresh interval in milliseconds | +| `binaryPath` | string | `""` | Custom path to containerlab binary (leave empty to resolve from PATH) | | `showWelcomePage` | boolean | `true` | Show welcome page on activation | -| `skipInstallationCheck` | boolean | `false` | Skip containerlab install check/prompt; extension stays inactive if containerlab is missing | +| `skipUpdateCheck` | boolean | `false` | Skip extension update check | | `skipCleanupWarning` | boolean | `false` | Skip warning popups for cleanup commands | +The Containerlab Explorer listens to the containerlab event stream, so running labs update live without manual refresh intervals. + ### 🎯 Command Options | Setting | Type | Default | Description | @@ -124,12 +125,11 @@ Configure the extension behavior through VS Code settings (`containerlab.*`): | `capture.preferredAction` | string | `Wireshark VNC` | Preferred capture method (`Edgeshark`, `Wireshark VNC`) | | `capture.wireshark.dockerImage` | string | `ghcr.io/kaelemc/`
`wireshark-vnc-docker:latest` | Docker image for Wireshark VNC | | `capture.wireshark.pullPolicy` | string | `always` | Image pull policy (`always`, `missing`, `never`) | -| `capture.wireshark.extraDockerArgs` | string | `-e HTTP_PROXY=""`
`-e http_proxy=""` | Extra docker arguments | | `capture.wireshark.theme` | string | `Follow VS Code theme` | Wireshark theme | | `capture.wireshark.stayOpenInBackground` | boolean | `true` | Keep sessions alive in background | -| `edgeshark.extraEnvironmentVars` | string | `HTTP_PROXY=,`
`http_proxy=` | Environment variables for Edgeshark | -| `remote.hostname` | string | `""` | Hostname/IP for Edgeshark packet capture | -| `remote.packetflixPort` | number | `5001` | Port for Packetflix endpoint (Edgeshark) | +| `capture.edgeshark.extraEnvironmentVars` | string | `HTTP_PROXY=,`
`http_proxy=` | Environment variables for Edgeshark | +| `capture.remoteHostname` | string | `""` | Hostname/IP for Edgeshark packet capture | +| `capture.packetflixPort` | number | `5001` | Port for Packetflix endpoint (Edgeshark) | ### 🌐 Lab Sharing @@ -147,6 +147,10 @@ Configure the extension behavior through VS Code settings (`containerlab.*`): "nokia_srlinux": "sr_cli", "arista_ceos": "Cli" }, + "containerlab.node.sshUserMapping": { + "nokia_srlinux": "admin", + "cisco_xrd": "clab" + }, "containerlab.editor.customNodes": [ { "name": "SRLinux Latest", @@ -166,8 +170,8 @@ When deploying labs, you can monitor the detailed progress in the Output window: 2. Select "Containerlab" from the dropdown menu 3. Watch the deployment logs in real-time -## Auto-refresh Behavior -- The Containerlab Explorer automatically refreshes based on the `containerlab.refreshInterval` setting +## Live Updates +- The Containerlab Explorer streams containerlab events, so running labs refresh immediately without polling - Labs are consistently sorted: - Deployed labs appear before undeployed labs - Within each group (deployed/undeployed), labs are sorted by their absolute path diff --git a/esbuild.config.js b/esbuild.config.js index a733023c8..e66d33a68 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -32,6 +32,20 @@ async function build() { // Note: CSS and JS files are now bundled by webpack // No need to copy them separately from html-static + // Plugin to stub native .node files - ssh2 has JS fallbacks + const nativeNodeModulesPlugin = { + name: 'native-node-modules', + setup(build) { + build.onResolve({ filter: /\.node$/ }, () => ({ + path: 'noop', + namespace: 'native-node-empty', + })); + build.onLoad({ filter: /.*/, namespace: 'native-node-empty' }, () => ({ + contents: 'module.exports = {};', + })); + }, + }; + // Build the extension await esbuild.build({ entryPoints: ['src/extension.ts'], @@ -40,7 +54,8 @@ async function build() { format: 'cjs', external: ['vscode'], outfile: 'dist/extension.js', - sourcemap: true + sourcemap: true, + plugins: [nativeNodeModulesPlugin], }); console.log('Build complete! HTML templates copied to dist/'); diff --git a/eslint.config.mjs b/eslint.config.mjs index 59a212460..668608f09 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,7 +14,8 @@ export default [ 'node_modules/**', '.vscode-test.mjs', // VS Code test harness 'legacy-backup/**', // Legacy backup files - 'labs/**' // containerlab lab files + 'labs/**', // containerlab lab files + "src/utils/consts.ts" ] }, @@ -65,8 +66,8 @@ export default [ 'sonarjs/no-alphabetical-sort': 'off', 'aggregate-complexity/aggregate-complexity': ['error', { max: 15 }] - } + }, } - + ]; diff --git a/package-lock.json b/package-lock.json index 3df1f0f7a..acb7cef95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@types/cytoscape": "^3.31.0", "@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-edgehandles": "^4.0.5", + "@types/dockerode": "^3.3.47", "@types/dompurify": "^3.2.0", "@types/leaflet": "^1.9.21", "@types/markdown-it": "^14.1.2", @@ -39,6 +40,7 @@ "cytoscape-leaf": "^1.2.4", "cytoscape-popper": "^4.0.1", "cytoscape-svg": "^0.4.0", + "dockerode": "^4.0.9", "dompurify": "^3.3.0", "esbuild": "^0.27.0", "esbuild-loader": "^4.4.0", @@ -62,6 +64,7 @@ "tippy.js": "^6.3.7", "typescript": "^5.9.3", "typescript-eslint": "^8.47.0", + "uplot": "^1.6.32", "webpack-cli": "^6.0.1", "yaml": "^2.8.1" }, @@ -333,6 +336,13 @@ "node": ">=6.9.0" } }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1039,6 +1049,58 @@ "node": ">=6" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.1.tgz", + "integrity": "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1219,6 +1281,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@jscpd/core": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@jscpd/core/-/core-4.0.1.tgz", @@ -1332,6 +1405,80 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@secretlint/config-creator": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", @@ -1964,6 +2111,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/docker-modem": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", + "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "3.3.47", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.47.tgz", + "integrity": "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" + } + }, "node_modules/@types/dompurify": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz", @@ -2113,6 +2283,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -3029,6 +3226,16 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assert-never": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", @@ -3177,8 +3384,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/baseline-browser-mapping": { "version": "2.8.29", @@ -3190,6 +3396,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -3222,7 +3438,6 @@ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -3341,7 +3556,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -3372,6 +3586,16 @@ "license": "MIT", "peer": true }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", @@ -3607,8 +3831,7 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/chrome-trace-event": { "version": "1.0.4", @@ -3843,6 +4066,21 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4319,6 +4557,55 @@ "node": ">=0.3.1" } }, + "node_modules/docker-modem": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", + "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.9.tgz", + "integrity": "sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.6", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/doctypes": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", @@ -5882,8 +6169,7 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "11.3.2", @@ -6474,8 +6760,7 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/ignore": { "version": "7.0.5", @@ -6552,8 +6837,7 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", @@ -7913,6 +8197,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -8028,6 +8319,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -8290,8 +8588,7 @@ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/mocha": { "version": "11.7.5", @@ -8534,6 +8831,14 @@ "dev": true, "license": "ISC" }, + "node_modules/nan": { + "version": "2.23.1", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz", + "integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -9632,6 +9937,31 @@ "react-is": "^16.13.1" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/pug": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.3.tgz", @@ -9948,7 +10278,6 @@ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -10846,6 +11175,13 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "dev": true, + "license": "ISC" + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -10853,6 +11189,24 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -10873,7 +11227,6 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -11226,7 +11579,6 @@ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -11240,7 +11592,6 @@ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -11457,6 +11808,13 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11725,6 +12083,13 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "dev": true, + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 52fe7136e..0f0a383ea 100644 --- a/package.json +++ b/package.json @@ -234,6 +234,14 @@ "command": "containerlab.node.stop", "title": "Stop" }, + { + "command": "containerlab.node.pause", + "title": "Pause" + }, + { + "command": "containerlab.node.unpause", + "title": "Unpause" + }, { "command": "containerlab.node.save", "title": "Save config" @@ -830,6 +838,16 @@ "when": "viewItem == containerlabContainer", "group": "nodeNavigation@3" }, + { + "command": "containerlab.node.pause", + "when": "viewItem == containerlabContainer", + "group": "nodeNavigation@4" + }, + { + "command": "containerlab.node.unpause", + "when": "viewItem == containerlabContainer", + "group": "nodeNavigation@5" + }, { "command": "containerlab.node.save", "when": "viewItem == containerlabContainer", @@ -1126,225 +1144,251 @@ "command": "containerlab.lab.graph.topoViewer" } ], - "configuration": { - "title": "Containerlab", - "properties": { - "containerlab.sudoEnabledByDefault": { - "type": "boolean", - "default": false, - "description": "Whether to prepend 'sudo' to all containerlab commands by default." - }, - "containerlab.refreshInterval": { - "type": "number", - "default": 5000, - "description": "Refresh interval (in milliseconds) for the Containerlab Explorer." - }, - "containerlab.node.execCommandMapping": { - "type": "object", - "additionalProperties": { - "type": "string" + "configuration": [ + { + "title": "General", + "order": 1, + "properties": { + "containerlab.showWelcomePage": { + "type": "boolean", + "default": true, + "description": "Show the welcome page when the extension activates." }, - "default": {}, - "markdownDescription": "Change the default exec action for node when using the 'attach' command. Enter in the mapping between the kind and command.\n\nFor example: `{\"nokia_srlinux\": \"sr_cli\"}` means that `docker exec -it sr_cli` will be executed if `` is the `nokia_srlinux` kind." - }, - "containerlab.node.sshUserMapping": { - "type": "object", - "additionalProperties": { - "type": "string" + "containerlab.skipUpdateCheck": { + "type": "boolean", + "default": false, + "markdownDescription": "Skip checking for containerlab updates during activation." }, - "default": {}, - "markdownDescription": "Custom SSH users for different node kinds. Enter the mapping between the kind and SSH username.\n\nFor example: `{\"nokia_srlinux\": \"clab\"}` means that `ssh clab@` will be used if `` is the `nokia_srlinux` kind." - }, - "containerlab.remote.hostname": { - "type": "string", - "default": "", - "markdownDescription": "Hostname or IP address for **Edgeshark packet capture** connections. Can be either DNS resolvable hostname, or an IPv4/6 address. This setting tells Edgeshark where to connect for capturing packets.\n\n**Note:** A configured hostname for *this session of VS Code* takes precedence. (Command palette: **Containerlab: Configure session hostname**)" - }, - "containerlab.remote.packetflixPort": { - "type": "number", - "default": 5001, - "markdownDescription": "Port for the **Packetflix WebSocket endpoint** used by Edgeshark packet capture. This is where Edgeshark connects to stream captured packets." - }, - "containerlab.drawioDefaultTheme": { - "type": "string", - "enum": [ - "nokia_modern", - "nokia", - "grafana" - ], - "default": "nokia_modern", - "description": "Default theme to use when generating DrawIO graphs." - }, - "containerlab.runtime": { - "type": "string", - "enum": [ - "docker", - "podman", - "ignite" - ], - "default": "docker", - "description": "Set container runtime used by containerlab." - }, - "containerlab.skipCleanupWarning": { - "type": "boolean", - "default": false, - "description": "If true, skip the warning popup for cleanup commands (redeploy/destroy cleanup)." - }, - "containerlab.deploy.extraArgs": { - "type": "string", - "default": "", - "description": "Additional command-line options appended to all 'containerlab deploy' and 'containerlab redeploy' commands." - }, - "containerlab.destroy.extraArgs": { - "type": "string", - "default": "", - "description": "Additional command-line options appended to all 'containerlab destroy' commands." - }, - "containerlab.showWelcomePage": { - "type": "boolean", - "default": true, - "description": "Show the welcome page when the extension activates." - }, - "containerlab.skipInstallationCheck": { - "type": "boolean", - "default": false, - "markdownDescription": "Skip checking for the containerlab binary during activation to silence the install prompt. When enabled, the extension will not activate on systems without containerlab on PATH." - }, - "containerlab.node.telnetPort": { - "type": "number", - "default": 5000, - "description": "Port to connect when telnetting to the node with 'docker exec -it telnet 127.0.0.1 '" - }, - "containerlab.extras.fcli.extraDockerArgs": { - "type": "string", - "default": "", - "description": "Additional docker (or podman) arguments to append to the fcli command" - }, - "containerlab.capture.preferredAction": { - "type": "string", - "default": "Wireshark VNC", - "enum": [ - "Edgeshark", - "Wireshark VNC" - ], - "description": "The preferred capture method when using the capture interface quick action on the interface tree item" - }, - "containerlab.capture.wireshark.dockerImage": { - "type": "string", - "default": "ghcr.io/kaelemc/wireshark-vnc-docker:latest", - "description": "The docker image to use for Wireshark/Edgeshark VNC capture. Requires full image name + tag" - }, - "containerlab.capture.wireshark.pullPolicy": { - "type": "string", - "default": "always", - "enum": [ - "always", - "missing", - "never" - ], - "description": "The pull policy of the Wireshark docker image" - }, - "containerlab.capture.wireshark.extraDockerArgs": { - "type": "string", - "default": "-e HTTP_PROXY=\"\" -e http_proxy=\"\"", - "description": "Extra arguments to pass to the run command for the wireshark VNC container. Useful for things like bind mounts etc." - }, - "containerlab.capture.wireshark.theme": { - "type": "string", - "default": "Follow VS Code theme", - "enum": [ - "Follow VS Code theme", - "Dark", - "Light" - ], - "description": "The theme, or colour scheme of the wireshark application." - }, - "containerlab.capture.wireshark.stayOpenInBackground": { - "type": "boolean", - "default": "true", - "description": "Keep Wireshark VNC sessions alive, even when the capture tab is not active. Enabling this will consume more memory on both the client and remote containerlab host system." - }, - "containerlab.edgeshark.extraEnvironmentVars": { - "type": "string", - "default": "HTTP_PROXY=, http_proxy=", - "description": "Comma-separated environment variables to inject into edgeshark containers (e.g., 'HTTP_PROXY=, http_proxy=, NO_PROXY=localhost'). Each variable will be added to the environment section of both gostwire and packetflix services." - }, - "containerlab.gotty.port": { - "type": "number", - "default": 8080, - "description": "Port for GoTTY web terminal." - }, - "containerlab.editor.customNodes": { - "type": "array", - "default": [ - { - "name": "SRLinux Latest", - "kind": "nokia_srlinux", - "type": "ixrd1", - "image": "ghcr.io/nokia/srlinux:latest", - "icon": "router", - "baseName": "srl", - "interfacePattern": "e1-{n}", - "setDefault": true - }, - { - "name": "Network Multitool", - "kind": "linux", - "image": "ghcr.io/srl-labs/network-multitool:latest", - "icon": "client", - "baseName": "client", - "interfacePattern": "eth{n}", - "setDefault": false - } - ], - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "type": { - "type": "string" - }, - "image": { - "type": "string" - }, - "icon": { - "type": "string" - }, - "baseName": { - "type": "string" - }, - "interfacePattern": { - "type": "string" + "containerlab.binaryPath": { + "type": "string", + "default": "", + "markdownDescription": "The absolute file path to the Containerlab binary." + }, + "containerlab.skipCleanupWarning": { + "type": "boolean", + "default": false, + "description": "If true, skip the warning popup for cleanup commands (redeploy/destroy cleanup)." + }, + "containerlab.deploy.extraArgs": { + "type": "string", + "default": "", + "description": "Additional command-line options appended to all 'containerlab deploy' and 'containerlab redeploy' commands." + }, + "containerlab.destroy.extraArgs": { + "type": "string", + "default": "", + "description": "Additional command-line options appended to all 'containerlab destroy' commands." + }, + "containerlab.drawioDefaultTheme": { + "type": "string", + "enum": [ + "nokia_modern", + "nokia", + "grafana" + ], + "default": "nokia_modern", + "description": "Default theme to use when generating DrawIO graphs." + }, + "containerlab.gotty.port": { + "type": "number", + "default": 8080, + "description": "Port for GoTTY web terminal." + }, + "containerlab.runtime": { + "type": "string", + "default": "docker", + "description": "Container runtime to use (docker, podman)." + }, + "containerlab.refreshMode": { + "type": "string", + "enum": [ + "events", + "polling" + ], + "default": "events", + "markdownDescription": "How to refresh lab data. `events` uses real-time containerlab events. `polling` uses periodic `containerlab inspect` calls." + }, + "containerlab.pollInterval": { + "type": "number", + "default": 5000, + "minimum": 1000, + "markdownDescription": "Polling interval in milliseconds when using `polling` refresh mode." + }, + "containerlab.enableInterfaceStats": { + "type": "boolean", + "default": true, + "markdownDescription": "Enable interface statistics (RX/TX bytes, packets, etc.) in the tree view. Disable to reduce resource usage." + } + } + }, + { + "title": "TopoViewer", + "order": 2, + "properties": { + "containerlab.editor.customNodes": { + "type": "array", + "default": [ + { + "name": "SRLinux Latest", + "kind": "nokia_srlinux", + "type": "ixrd1", + "image": "ghcr.io/nokia/srlinux:latest", + "icon": "router", + "baseName": "srl", + "interfacePattern": "e1-{n}", + "setDefault": true }, - "setDefault": { - "type": "boolean" + { + "name": "Network Multitool", + "kind": "linux", + "image": "ghcr.io/srl-labs/network-multitool:latest", + "icon": "client", + "baseName": "client", + "interfacePattern": "eth{n}", + "setDefault": false } + ], + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "type": { + "type": "string" + }, + "image": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "baseName": { + "type": "string" + }, + "interfacePattern": { + "type": "string" + }, + "setDefault": { + "type": "boolean" + } + }, + "required": [ + "name", + "kind" + ], + "additionalProperties": true + }, + "markdownDescription": "Custom node templates available in the TopoViewer add-node menu. Can store full node configurations including startup-config, binds, env vars, etc." + }, + "containerlab.editor.updateLinkEndpointsOnKindChange": { + "type": "boolean", + "default": true, + "markdownDescription": "When enabled, changing a node's kind updates connected link endpoints to match the new kind's interface pattern." + }, + "containerlab.editor.lockLabByDefault": { + "type": "boolean", + "default": true, + "markdownDescription": "Lock the lab canvas by default to prevent accidental modifications. Disable to start new sessions unlocked." + } + } + }, + { + "title": "Node actions", + "order": 2, + "properties": { + "containerlab.node.execCommandMapping": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {}, + "markdownDescription": "Change the default exec action for node when using the 'attach' command. Enter in the mapping between the kind and command.\n\nFor example: `{\"nokia_srlinux\": \"sr_cli\"}` means that `docker exec -it sr_cli` will be executed if `` is the `nokia_srlinux` kind." + }, + "containerlab.node.sshUserMapping": { + "type": "object", + "additionalProperties": { + "type": "string" }, - "required": [ - "name", - "kind" + "default": {}, + "markdownDescription": "Custom SSH users for different node kinds. Enter the mapping between the kind and SSH username.\n\nFor example: `{\"nokia_srlinux\": \"clab\"}` means that `ssh clab@` will be used if `` is the `nokia_srlinux` kind." + }, + "containerlab.node.telnetPort": { + "type": "number", + "default": 5000, + "description": "Port to connect when telnetting to the node with 'docker exec -it telnet 127.0.0.1 '" + }, + "containerlab.extras.fcli.extraDockerArgs": { + "type": "string", + "default": "", + "description": "Additional docker (or podman) arguments to append to the fcli command" + } + } + }, + { + "title": "Packet Capture", + "order": 4, + "properties": { + "containerlab.capture.remoteHostname": { + "type": "string", + "default": "", + "markdownDescription": "Hostname or IP address for **Edgeshark packet capture** connections. Can be either DNS resolvable hostname, or an IPv4/6 address. This setting tells Edgeshark where to connect for capturing packets.\n\n**Note:** A configured hostname for *this session of VS Code* takes precedence. (Command palette: **Containerlab: Configure session hostname**)" + }, + "containerlab.capture.packetflixPort": { + "type": "number", + "default": 5001, + "markdownDescription": "Port for the **Packetflix WebSocket endpoint** used by Edgeshark packet capture. This is where Edgeshark connects to stream captured packets." + }, + "containerlab.capture.preferredAction": { + "type": "string", + "default": "Wireshark VNC", + "enum": [ + "Edgeshark", + "Wireshark VNC" ], - "additionalProperties": true + "description": "The preferred capture method when using the capture interface quick action on the interface tree item" }, - "markdownDescription": "Custom node templates available in the TopoViewer add-node menu. Can store full node configurations including startup-config, binds, env vars, etc." - }, - "containerlab.editor.updateLinkEndpointsOnKindChange": { - "type": "boolean", - "default": true, - "markdownDescription": "When enabled, changing a node's kind updates connected link endpoints to match the new kind's interface pattern." - }, - "containerlab.editor.lockLabByDefault": { - "type": "boolean", - "default": true, - "markdownDescription": "Lock the lab canvas by default to prevent accidental modifications. Disable to start new sessions unlocked." + "containerlab.capture.wireshark.dockerImage": { + "type": "string", + "default": "ghcr.io/kaelemc/wireshark-vnc-docker:latest", + "description": "The docker image to use for Wireshark/Edgeshark VNC capture. Requires full image name + tag" + }, + "containerlab.capture.wireshark.pullPolicy": { + "type": "string", + "default": "always", + "enum": [ + "always", + "missing", + "never" + ], + "description": "The pull policy of the Wireshark docker image" + }, + "containerlab.capture.wireshark.theme": { + "type": "string", + "default": "Follow VS Code theme", + "enum": [ + "Follow VS Code theme", + "Dark", + "Light" + ], + "description": "The theme, or colour scheme of the wireshark application." + }, + "containerlab.capture.wireshark.stayOpenInBackground": { + "type": "boolean", + "default": "true", + "description": "Keep Wireshark VNC sessions alive, even when the capture tab is not active. Enabling this will consume more memory on both the client and remote containerlab host system." + }, + "containerlab.capture.edgeshark.extraEnvironmentVars": { + "type": "string", + "default": "HTTP_PROXY=, http_proxy=", + "description": "Comma-separated environment variables to inject into edgeshark containers (e.g., 'HTTP_PROXY=, http_proxy=, NO_PROXY=localhost'). Each variable will be added to the environment section of both gostwire and packetflix services." + } } } - } + ] }, "scripts": { "compile": "tsc -p .", @@ -1371,6 +1415,7 @@ "@types/cytoscape": "^3.31.0", "@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-edgehandles": "^4.0.5", + "@types/dockerode": "^3.3.47", "@types/dompurify": "^3.2.0", "@types/leaflet": "^1.9.21", "@types/markdown-it": "^14.1.2", @@ -1395,6 +1440,7 @@ "cytoscape-leaf": "^1.2.4", "cytoscape-popper": "^4.0.1", "cytoscape-svg": "^0.4.0", + "dockerode": "^4.0.9", "dompurify": "^3.3.0", "esbuild": "^0.27.0", "esbuild-loader": "^4.4.0", @@ -1418,6 +1464,7 @@ "tippy.js": "^6.3.7", "typescript": "^5.9.3", "typescript-eslint": "^8.47.0", + "uplot": "^1.6.32", "webpack-cli": "^6.0.1", "yaml": "^2.8.1" } diff --git a/src/commands/attachShell.ts b/src/commands/attachShell.ts deleted file mode 100644 index ca95bce30..000000000 --- a/src/commands/attachShell.ts +++ /dev/null @@ -1 +0,0 @@ -export { attachShell } from './nodeExec'; diff --git a/src/commands/capture.ts b/src/commands/capture.ts index a2fc90b01..7753cb1ae 100644 --- a/src/commands/capture.ts +++ b/src/commands/capture.ts @@ -1,11 +1,11 @@ import * as vscode from "vscode" -import * as os from "os"; -import { outputChannel } from "../extension"; -import * as utils from "../helpers/utils"; +import { outputChannel, dockerClient, username } from "../extension"; +import * as utils from "../utils/index"; import { ClabInterfaceTreeNode } from "../treeView/common"; -import { installEdgeshark } from "./edgeshark"; +import { genPacketflixURI } from "../utils/packetflix"; +import { DEFAULT_WIRESHARK_VNC_DOCKER_IMAGE, DEFAULT_WIRESHARK_VNC_DOCKER_PULL_POLICY, ImagePullPolicy, WIRESHARK_VNC_CTR_NAME_PREFIX } from "../utils/consts"; -let sessionHostname: string = ""; +export { getHostname, setSessionHostname } from "../utils/packetflix"; /** * Begin packet capture on an interface. @@ -35,159 +35,39 @@ export async function captureInterface( } -// Build the packetflix:ws: URI -async function handleMultiSelection( - selected: ClabInterfaceTreeNode[], +async function buildPacketflixUri( + node: ClabInterfaceTreeNode, + allSelectedNodes?: ClabInterfaceTreeNode[], forVNC?: boolean ): Promise<[string, string] | undefined> { - // Check if they are from the same container - const uniqueContainers = new Set(selected.map(i => i.parentName)); - if (uniqueContainers.size > 1) { - // from different containers => spawn multiple capture sessions individually - outputChannel.debug("Edgeshark multi selection => multiple containers => launching individually"); - for (const nd of selected) { - if (forVNC) { - await captureEdgesharkVNC(nd); // re-call for single in VNC mode - } else { - await captureInterfaceWithPacketflix(nd); // re-call for single in external mode - } - } - return undefined; - } - - // All from same container => build multi-interface edgeshark link - return await captureMultipleEdgeshark(selected); -} - -async function genPacketflixURI(node: ClabInterfaceTreeNode, - allSelectedNodes?: ClabInterfaceTreeNode[], // [CHANGED] - forVNC?: boolean -) { if (!node) { - return vscode.window.showErrorMessage("No interface to capture found."); - } - outputChannel.debug(`captureInterfaceWithPacketflix() called for node=${node.parentName} if=${node.name}`); - - // Ensure Edgeshark is running/available - const edgesharkReady = await ensureEdgesharkAvailable(); - if (!edgesharkReady) { - return; + vscode.window.showErrorMessage("No interface to capture found."); + return undefined; } - // If user multi‐selected items, we capture them all. const selected = allSelectedNodes && allSelectedNodes.length > 0 ? allSelectedNodes : [node]; - // If multiple selected if (selected.length > 1) { - return await handleMultiSelection(selected, forVNC); - } - - // [ORIGINAL SINGLE-INTERFACE EDGESHARK LOGIC] - outputChannel.debug(`captureInterfaceWithPacketflix() single mode for node=${node.parentName}/${node.name}`); - - // For VNC capture, use 127.0.0.1 which will be adjusted later - const hostname = forVNC ? "127.0.0.1" : await getHostname(); - if (!hostname) { - return vscode.window.showErrorMessage( - "No known hostname/IP address to connect to for packet capture." - ); - } - - // If it's an IPv6 literal, bracket it. e.g. ::1 => [::1] - const bracketed = hostname.includes(":") ? `[${hostname}]` : hostname; - - const config = vscode.workspace.getConfiguration("containerlab"); - const packetflixPort = config.get("remote.packetflixPort", 5001); - - const containerStr = encodeURIComponent(`{"network-interfaces":["${node.name}"],"name":"${node.parentName}","type":"docker"}`) - - const uri = `packetflix:ws://${bracketed}:${packetflixPort}/capture?container=${containerStr}&nif=${node.name}` - - vscode.window.showInformationMessage( - `Starting edgeshark capture on ${node.parentName}/${node.name}...` - ); - - outputChannel.debug(`single-edgeShark => ${uri.toString()}`); - - return [uri, bracketed] -} - -// Ensure Edgeshark API is up; optionally prompt to start it -async function ensureEdgesharkAvailable(): Promise { - // - make a simple API call to get version of packetflix - let edgesharkOk = false; - try { - const res = await fetch("http://127.0.0.1:5001/version"); - edgesharkOk = res.ok; - } catch { - // Port is probably closed, edgeshark not running - } - if (edgesharkOk) return true; - - const selectedOpt = await vscode.window.showInformationMessage( - "Capture: Edgeshark is not running. Would you like to start it?", - { modal: false }, - "Yes" - ); - if (selectedOpt === "Yes") { - await installEdgeshark(); - - const maxRetries = 30; - const delayMs = 1000; - for (let i = 0; i < maxRetries; i++) { - try { - const res = await fetch("http://127.0.0.1:5001/version"); - if (res.ok) { - return true; + const uniqueContainers = new Set(selected.map(i => i.parentName)); + if (uniqueContainers.size > 1) { + outputChannel.debug("Edgeshark multi selection => multiple containers => launching individually"); + for (const nd of selected) { + if (forVNC) { + await captureEdgesharkVNC(nd); + } else { + await captureInterfaceWithPacketflix(nd); } - } catch { - // wait and retry } - await new Promise(resolve => setTimeout(resolve, delayMs)); + return undefined; } - - vscode.window.showErrorMessage("Edgeshark did not start in time. Please try again."); - return false; } - return false; -} -// Capture multiple interfaces with Edgeshark -async function captureMultipleEdgeshark(nodes: ClabInterfaceTreeNode[]): Promise<[string, string]> { - const base = nodes[0]; - const ifNames = nodes.map(n => n.name); - outputChannel.debug(`multi-interface edgeshark for container=${base.parentName} ifaces=[${ifNames.join(", ")}]`); - - // We optionally store "netns" in node if needed. - const netnsVal = (base as any).netns || 4026532270; // example if you track netns - const containerObj = { - netns: netnsVal, - "network-interfaces": ifNames, - name: base.parentName, - type: "docker", - prefix: "" - }; - - const containerStr = encodeURIComponent(JSON.stringify(containerObj)); - const nifParam = encodeURIComponent(ifNames.join("/")); - - const hostname = await getHostname(); - const bracketed = hostname.includes(":") ? `[${hostname}]` : hostname; - const config = vscode.workspace.getConfiguration("containerlab"); - const packetflixPort = config.get("remote.packetflixPort", 5001); - - const packetflixUri = `packetflix:ws://${bracketed}:${packetflixPort}/capture?container=${containerStr}&nif=${nifParam}`; - - vscode.window.showInformationMessage( - `Starting multi-interface edgeshark on ${base.parentName} for: ${ifNames.join(", ")}` - ); - outputChannel.debug(`multi-edgeShark => ${packetflixUri}`); - - return [packetflixUri, bracketed] + return await genPacketflixURI(selected, forVNC); } + /** * Start capture on an interface using edgeshark/packetflix. * This method builds a 'packetflix:' URI that calls edgeshark. @@ -197,7 +77,7 @@ export async function captureInterfaceWithPacketflix( allSelectedNodes?: ClabInterfaceTreeNode[] // [CHANGED] ) { - const packetflixUri = await genPacketflixURI(node, allSelectedNodes) + const packetflixUri = await buildPacketflixUri(node, allSelectedNodes) if (!packetflixUri) { return } @@ -223,16 +103,42 @@ function isDarkModeEnabled(themeSetting?: string): boolean { async function getEdgesharkNetwork(): Promise { try { - const psOut = await utils.runWithSudo(`docker ps --filter "name=edgeshark" --format "{{.Names}}"`, 'List edgeshark containers', outputChannel, 'docker', true) as string; - const firstName = (psOut || '').split(/\r?\n/).find(Boolean)?.trim() || ''; - if (firstName) { - const netsOut = await utils.runWithSudo(`docker inspect ${firstName} --format '{{range .NetworkSettings.Networks}}{{.NetworkID}} {{end}}'`, 'Inspect edgeshark networks', outputChannel, 'docker', true) as string; - const networkId = (netsOut || '').trim().split(/\s+/)[0] || ''; - if (networkId) { - const nameOut = await utils.runWithSudo(`docker network inspect ${networkId} --format '{{.Name}}'`, 'Inspect network name', outputChannel, 'docker', true) as string; - const netName = (nameOut || '').trim(); - if (netName) return `--network ${netName}`; - } + if (!dockerClient) { + outputChannel.debug("getEdgesharkNetwork() failed: docker client unavailable.") + return ""; + } + // List containers using edgeshark as name filter + const containers = await dockerClient.listContainers({ + filters: { name: ['edgeshark'] } + }); + + if (containers.length === 0) { + return ""; + } + + // get info of the 0th ctr + const container = dockerClient.getContainer(containers[0].Id); + const containerInfo = await container.inspect(); + + const networks = containerInfo.NetworkSettings.Networks || {}; + const networkIds = Object.values(networks).map((net: any) => net.NetworkID).filter(Boolean); + + if (networkIds.length === 0) { + return ""; + } + + const networkId = networkIds[0]; + if (!networkId) { + return ""; + } + + // Get network name from network ID + const network = dockerClient.getNetwork(networkId); + const networkInfo = await network.inspect(); + const netName = networkInfo.Name; + + if (netName) { + return `--network ${netName}`; } } catch { // ignore @@ -242,14 +148,15 @@ async function getEdgesharkNetwork(): Promise { async function getVolumeMount(nodeName: string): Promise { try { - const out = await utils.runWithSudo( - `docker inspect ${nodeName} --format '{{index .Config.Labels "clab-node-lab-dir"}}'`, - 'Inspect lab dir label', - outputChannel, - 'docker', - true - ) as string; - const labDir = (out || '').trim(); + if (!dockerClient) { + outputChannel.debug("getVolumeMount() failed: docker client unavailable.") + return ""; + } + + const container = dockerClient.getContainer(nodeName); + const containerInfo = await container.inspect(); + const labDir = containerInfo.Config.Labels?.['clab-node-lab-dir']; + if (labDir && labDir !== '') { const pathParts = labDir.split('/') pathParts.pop() @@ -273,38 +180,102 @@ function adjustPacketflixHost(uri: string, edgesharkNetwork: string): string { return uri } +const VOLUME_MOUNT_REGEX = /-v\s+"?([^"]+)"?/; + +function buildVolumeBinds(volumeMount?: string): string[] { + if (!volumeMount) { + return []; + } + const match = VOLUME_MOUNT_REGEX.exec(volumeMount); + return match ? [match[1]] : []; +} + +function buildWiresharkEnvVars(packetflixLink: string, themeSetting?: string): string[] { + const env = [`PACKETFLIX_LINK=${packetflixLink}`]; + if (isDarkModeEnabled(themeSetting)) { + env.push('DARK_MODE=1'); + } + return env; +} + +type WiresharkContainerOptions = { + dockerImage: string; + dockerPullPolicy: ImagePullPolicy.Always | ImagePullPolicy.Missing | ImagePullPolicy.Never; + edgesharkNetwork: string; + volumeMount?: string; + packetflixUri: string; + themeSetting?: string; + ctrName: string; + port: number; +}; + +async function startWiresharkContainer(options: WiresharkContainerOptions): Promise { + if (!dockerClient) { + outputChannel.debug("captureEdgesharkVNC() failed: docker client unavailable.") + vscode.window.showErrorMessage("Unable to start capture: Docker client unavailable") + return undefined; + } + + try { + await utils.checkAndPullDockerImage(options.dockerImage, options.dockerPullPolicy); + + const networkName = options.edgesharkNetwork.replace('--network ', '').trim(); + const volumeBinds = buildVolumeBinds(options.volumeMount); + const env = buildWiresharkEnvVars(options.packetflixUri, options.themeSetting); + + const container = await dockerClient.createContainer({ + Image: options.dockerImage, + name: options.ctrName, + Env: env, + HostConfig: { + AutoRemove: true, + PortBindings: { + '5800/tcp': [{ HostIp: '127.0.0.1', HostPort: options.port.toString() }] + }, + NetworkMode: networkName || 'bridge', + Binds: volumeBinds.length > 0 ? volumeBinds : undefined + } + }); + await container.start(); + outputChannel.info(`Started Wireshark VNC container: ${container.id}`); + return container.id; + } catch (err: any) { + vscode.window.showErrorMessage(`Starting Wireshark: ${err.message || String(err)}`); + return undefined; + } +} + // Capture using Edgeshark + Wireshark via VNC in a webview -export async function captureEdgesharkVNC( - node: ClabInterfaceTreeNode, - allSelectedNodes?: ClabInterfaceTreeNode[] // [CHANGED] -) { +export async function captureEdgesharkVNC(node: ClabInterfaceTreeNode, allSelectedNodes?: ClabInterfaceTreeNode[]) { - const packetflixUri = await genPacketflixURI(node, allSelectedNodes, true) + // Handle settings + const wsConfig = vscode.workspace.getConfiguration("containerlab"); + const dockerImage = wsConfig.get("capture.wireshark.dockerImage", DEFAULT_WIRESHARK_VNC_DOCKER_IMAGE); + const dockerPullPolicy = wsConfig.get("capture.wireshark.pullPolicy", DEFAULT_WIRESHARK_VNC_DOCKER_PULL_POLICY); + const wiresharkThemeSetting = wsConfig.get("capture.wireshark.theme"); + const keepOpenInBackground = wsConfig.get("capture.wireshark.stayOpenInBackground"); + + const packetflixUri = await buildPacketflixUri(node, allSelectedNodes, true) if (!packetflixUri) { return } - - const wsConfig = vscode.workspace.getConfiguration("containerlab") - const dockerImage = wsConfig.get("capture.wireshark.dockerImage", "ghcr.io/kaelemc/wireshark-vnc-docker:latest") - const dockerPullPolicy = wsConfig.get("capture.wireshark.pullPolicy", "always") - const extraDockerArgs = wsConfig.get("capture.wireshark.extraDockerArgs") - const wiresharkThemeSetting = wsConfig.get("capture.wireshark.theme") - const keepOpenInBackground = wsConfig.get("capture.wireshark.stayOpenInBackground") - - const darkModeSetting = isDarkModeEnabled(wiresharkThemeSetting) ? "-e DARK_MODE=1" : "" const edgesharkNetwork = await getEdgesharkNetwork() const volumeMount = await getVolumeMount(node.parentName) const modifiedPacketflixUri = adjustPacketflixHost(packetflixUri[0], edgesharkNetwork) const port = await utils.getFreePort() - const ctrName = utils.sanitize(`clab_vsc_ws-${node.parentName}_${node.name}-${Date.now()}`) - let containerId = ''; - try { - const command = `docker run -d --rm --pull ${dockerPullPolicy} -p 127.0.0.1:${port}:5800 ${edgesharkNetwork} ${volumeMount} ${darkModeSetting} -e PACKETFLIX_LINK="${modifiedPacketflixUri}" ${extraDockerArgs || ''} --name ${ctrName} ${dockerImage}`; - const out = await utils.runWithSudo(command, 'Start Wireshark VNC', outputChannel, 'docker', true, true) as string; - containerId = (out || '').trim().split(/\s+/)[0] || ''; - } catch (err: any) { - vscode.window.showErrorMessage(`Starting Wireshark: ${err.message || String(err)}`); + const ctrName = utils.sanitize(`${WIRESHARK_VNC_CTR_NAME_PREFIX}-${username}-${node.parentName}_${node.name}-${Date.now()}`) + const containerId = await startWiresharkContainer({ + dockerImage, + dockerPullPolicy, + edgesharkNetwork, + volumeMount, + packetflixUri: modifiedPacketflixUri, + themeSetting: wiresharkThemeSetting, + ctrName, + port + }); + if (!containerId) { return; } @@ -322,8 +293,22 @@ export async function captureEdgesharkVNC( } ); - panel.onDidDispose(() => { - void utils.runWithSudo(`docker rm -f ${containerId}`, 'Remove Wireshark container', outputChannel, 'docker').catch(() => undefined); + panel.onDidDispose(async () => { + try { + if (!dockerClient) { + outputChannel.debug("captureEdgesharkVNC() VNC webview dispose failed: docker client unavailable.") + return; + } + if (!containerId) { + outputChannel.debug("captureEdgesharkVNC() VNC webview dispose failed: nil container ID.") + return; + } + const container = dockerClient.getContainer(containerId); + await container.stop(); + outputChannel.info(`Stopped Wireshark VNC container: ${containerId}`); + } catch { + // ignore + } }) const iframeUrl = externalUri; @@ -555,179 +540,68 @@ async function runVncReadinessLoop( return } - await tryPostMessage(panel, { type: 'vnc-progress', attempt: 0, maxAttempts }) + await utils.tryPostMessage(panel, { type: 'vnc-progress', attempt: 0, maxAttempts }) for (let attempt = 1; attempt <= maxAttempts; attempt++) { if (isDisposed() || token.cancelled) { return } - const ready = await isHttpEndpointReady(localUrl) + const ready = await utils.isHttpEndpointReady(localUrl) if (isDisposed() || token.cancelled) { return } if (ready) { - await tryPostMessage(panel, { type: 'vnc-ready', url: iframeUrl }) + await utils.tryPostMessage(panel, { type: 'vnc-ready', url: iframeUrl }) return } - await tryPostMessage(panel, { type: 'vnc-progress', attempt, maxAttempts }) - await delay(delayMs) + await utils.tryPostMessage(panel, { type: 'vnc-progress', attempt, maxAttempts }) + await utils.delay(delayMs) } if (!isDisposed() && !token.cancelled) { - await tryPostMessage(panel, { type: 'vnc-timeout', url: iframeUrl }) + await utils.tryPostMessage(panel, { type: 'vnc-timeout', url: iframeUrl }) } } -async function tryPostMessage(panel: vscode.WebviewPanel, message: unknown): Promise { - try { - await panel.webview.postMessage(message) - } catch { - // The panel might already be disposed; ignore errors - } -} - -async function isHttpEndpointReady(url: string, timeoutMs = 4000): Promise { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), timeoutMs) - - try { - const response = await fetch(url, { method: 'GET', signal: controller.signal }) - return response.ok - } catch { - return false - } finally { - clearTimeout(timeout) - } -} - -function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} - - export async function killAllWiresharkVNCCtrs() { - const dockerImage = vscode.workspace.getConfiguration("containerlab").get("capture.wireshark.dockerImage", "ghcr.io/kaelemc/wireshark-vnc-docker:latest") + const dockerImage = vscode.workspace.getConfiguration("containerlab").get("capture.wireshark.dockerImage", DEFAULT_WIRESHARK_VNC_DOCKER_IMAGE) try { - const idsOut = await utils.runWithSudo( - `docker ps --filter "name=clab_vsc_ws-" --filter "ancestor=${dockerImage}" --format "{{.ID}}"`, - 'List Wireshark VNC containers', - outputChannel, - 'docker', - true - ) as string; - const ids = (idsOut || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean); - if (ids.length > 0) { - await utils.runWithSudo(`docker rm -f ${ids.join(' ')}`, 'Remove Wireshark VNC containers', outputChannel, 'docker'); + if (!dockerClient) { + outputChannel.debug("killAllWiresharkVNCCtrs() failed: docker client unavailable.") } - } catch (err: any) { - vscode.window.showErrorMessage(`Killing Wireshark container: ${err.message || String(err)}`); - } -} - -/** - * If a user calls the "Set session hostname" command, we store it in-memory here, - * overriding the auto-detected or config-based hostname until the user closes VS Code. - */ -export async function setSessionHostname(): Promise { - const opts: vscode.InputBoxOptions = { - title: `Configure hostname for Containerlab remote (this session only)`, - placeHolder: `IPv4, IPv6 or DNS resolvable hostname of the system where containerlab is running`, - prompt: "This will persist for only this session of VS Code.", - validateInput: (input: string): string | undefined => { - if (input.trim().length === 0) { - return "Input should not be empty"; - } - return undefined; - } - }; - const val = await vscode.window.showInputBox(opts); - if (!val) { - return false; - } - sessionHostname = val.trim(); - vscode.window.showInformationMessage(`Session hostname is set to: ${sessionHostname}`); - return true; -} - -function resolveOrbstackIPv4(): string | undefined { - try { - const nets = os.networkInterfaces(); - const eth0 = nets["eth0"] ?? []; - const v4 = (eth0 as any[]).find( - (n: any) => (n.family === "IPv4" || n.family === 4) && !n.internal - ); - return v4?.address as string | undefined; - } catch (e: any) { - outputChannel.debug(`(Orbstack) Error retrieving IPv4: ${e.message || e.toString()}`); - return undefined; - } -} + const ctrNamePrefix = `${WIRESHARK_VNC_CTR_NAME_PREFIX}-${username}`; -/** - * Determine the hostname (or IP) to use for packet capture based on environment: - * - * - If a global setting "containerlab.remote.hostname" is set, that value is used. - * - If in a WSL environment (or SSH in WSL), always return "localhost". - * - If in an Orbstack environment (regardless of SSH), always use the IPv4 address from "ip -4 add show eth0". - * - If in an SSH remote session (and not Orbstack), use the remote IP from SSH_CONNECTION. - * - Otherwise, if a session hostname was set, use it. - * - Otherwise, default to "localhost". - */ -export async function getHostname(): Promise { - // 1. Global configuration takes highest priority. - const cfgHost = vscode.workspace - .getConfiguration("containerlab") - .get("remote.hostname", ""); - if (cfgHost) { - outputChannel.debug( - `Using containerlab.remote.hostname from settings: ${cfgHost}` - ); - return cfgHost; - } - - // 2. If in a WSL environment, always use "localhost". - if (vscode.env.remoteName === "wsl") { - outputChannel.debug("Detected WSL environment; using 'localhost'"); - return "localhost"; - } - - // 3. If in an Orbstack environment (whether SSH or not), always use IPv4. - if (utils.isOrbstack()) { - const v4 = resolveOrbstackIPv4(); - if (v4) { - outputChannel.debug(`(Orbstack) Using IPv4 from networkInterfaces: ${v4}`); - return v4; - } - outputChannel.debug("(Orbstack) Could not determine IPv4 from networkInterfaces"); - } - - // 4. If in an SSH remote session (and not Orbstack), use the remote IP from SSH_CONNECTION. - if (vscode.env.remoteName === "ssh-remote") { - const sshConnection = process.env.SSH_CONNECTION; - outputChannel.debug(`(SSH non-Orb) SSH_CONNECTION: ${sshConnection}`); - if (sshConnection) { - const parts = sshConnection.split(" "); - if (parts.length >= 3) { - const remoteIp = parts[2]; - outputChannel.debug( - `(SSH non-Orb) Using remote IP from SSH_CONNECTION: ${remoteIp}` - ); - return remoteIp; + // List containers which have that name + use the configured image + const containers = await dockerClient.listContainers({ + filters: { + name: [ctrNamePrefix], + ancestor: [dockerImage] } + }); + + if (containers.length > 0) { + // equivalent of docker rm -f for each container + await Promise.all( + containers.map(async (containerInfo: any) => { + try { + const container = dockerClient.getContainer(containerInfo.Id); + await container.remove( + { + force: true + } + ); + outputChannel.info(`Removed Wireshark VNC container: ${containerInfo.Id}`); + } catch (err) { + outputChannel.warn(`Failed to remove container ${containerInfo.Id}: ${err}`); + } + }) + ); } + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to remove Wireshark VNC containers: ${err.message}`); } - - // 5. If a session hostname was manually set, use it. - if (sessionHostname) { - outputChannel.debug(`Using sessionHostname: ${sessionHostname}`); - return sessionHostname; - } - - // 6. Fallback: default to "localhost". - outputChannel.debug("No suitable hostname found; defaulting to 'localhost'"); - return "localhost"; } diff --git a/src/commands/clabCommand.ts b/src/commands/clabCommand.ts index edd668fe4..25e46b9c7 100644 --- a/src/commands/clabCommand.ts +++ b/src/commands/clabCommand.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import * as cmd from './command'; import { ClabLabTreeNode } from "../treeView/common"; +import { containerlabBinaryPath } from "../extension"; /** * A helper class to build a 'containerlab' command (with optional sudo, etc.) * and run it either in the Output channel or in a Terminal. @@ -19,16 +20,17 @@ export class ClabCommand extends cmd.Command { onSuccess?: () => Promise, onFailure?: cmd.CommandFailureHandler ) { + const binaryPath = containerlabBinaryPath || "containerlab"; let options: cmd.CmdOptions; if (useTerminal) { options = { - command: "containerlab", + command: binaryPath, useSpinner: false, terminalName: terminalName || "Containerlab", }; } else { options = { - command: "containerlab", + command: binaryPath, useSpinner: true, spinnerMsg: spinnerMsg || { progressMsg: `Running ${action}...`, diff --git a/src/commands/cloneRepo.ts b/src/commands/cloneRepo.ts index 5f3515741..450157e1f 100644 --- a/src/commands/cloneRepo.ts +++ b/src/commands/cloneRepo.ts @@ -1,9 +1,9 @@ import * as vscode from "vscode"; -import { runWithSudo } from "../helpers/utils"; import * as path from "path"; import * as os from "os"; import * as fs from "fs"; import { outputChannel } from "../extension"; +import { runCommand } from "../utils/utils"; export async function cloneRepoFromUrl(repoUrl?: string) { if (!repoUrl) { @@ -30,8 +30,14 @@ export async function cloneRepoFromUrl(repoUrl?: string) { outputChannel.info(`git clone ${repoUrl} ${dest}`); try { - const out = await runWithSudo(`git clone ${repoUrl} "${dest}"`, 'Git clone', outputChannel, 'generic', true, true) as string; - if (out) outputChannel.info(out); + const command = `git clone ${repoUrl} "${dest}"`; + await runCommand( + command, + 'Clone repository', + outputChannel, + false, + false + ); vscode.window.showInformationMessage(`Repository cloned to ${dest}`); vscode.commands.executeCommand('containerlab.refresh'); } catch (error: any) { diff --git a/src/commands/command.ts b/src/commands/command.ts index 79d12cb0f..a2727bf04 100644 --- a/src/commands/command.ts +++ b/src/commands/command.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import * as utils from '../helpers/utils'; +import * as utils from '../utils/index'; import { spawn } from 'child_process'; import { outputChannel } from '../extension'; import * as fs from 'fs'; @@ -8,22 +8,29 @@ import * as path from 'path'; /** * Run a shell command in a named VS Code terminal. - * If that terminal already exists, we send a Ctrl+C first. + * @param command The command to execute + * @param terminalName The name for the terminal + * @param reuseOnly If true, just focus existing terminal without sending command again. + * If false (default), reuses existing terminal with Ctrl+C and resends command. */ -export function execCommandInTerminal(command: string, terminalName: string) { - let terminal: vscode.Terminal | undefined; +export function execCommandInTerminal(command: string, terminalName: string, reuseOnly: boolean = false) { for (const term of vscode.window.terminals) { if (term.name === terminalName) { - terminal = term; - // Send Ctrl+C & enter to stop any previous command + if (reuseOnly) { + // Terminal already exists - just focus it + term.show(); + return; + } + // Send Ctrl+C & enter to stop any previous command, then resend term.sendText("\x03\r"); - break; + term.sendText(command); + term.show(); + return; } } - if (!terminal) { - terminal = vscode.window.createTerminal({ name: terminalName }); - } + // Terminal doesn't exist - create new one + const terminal = vscode.window.createTerminal({ name: terminalName }); terminal.sendText(command); terminal.show(); } @@ -121,7 +128,6 @@ export type CommandFailureHandler = (error: unknown) => Promise; export class Command { protected command: string; protected useSpinner: boolean; - protected useSudo: boolean; protected spinnerMsg?: SpinnerMsg; protected terminalName?: string; protected onSuccessCallback?: () => Promise; @@ -132,13 +138,11 @@ export class Command { this.useSpinner = options.useSpinner || false; this.spinnerMsg = options.spinnerMsg; this.terminalName = options.terminalName; - this.useSudo = utils.getConfig('sudoEnabledByDefault'); } protected execute(args?: string[]): Promise { let cmd: string[] = []; - if (this.useSudo) { cmd.push("sudo"); } cmd.push(this.command); if (args) { cmd.push(...args); } @@ -230,7 +234,7 @@ export class Command { await vscode.commands.executeCommand("containerlab.refresh"); } catch (err: any) { - const command = this.useSudo ? cmd[2] : cmd[1]; + const command = cmd[1]; const failMsg = this.spinnerMsg?.failMsg ? `${this.spinnerMsg.failMsg}. Err: ${err}` : `${utils.titleCase(command)} failed: ${err.message}`; const viewOutputBtn = await vscode.window.showErrorMessage(failMsg, "View logs"); if (viewOutputBtn === "View logs") { outputChannel.show(); } diff --git a/src/commands/copy.ts b/src/commands/copy.ts index caeb0f62c..08f570580 100644 --- a/src/commands/copy.ts +++ b/src/commands/copy.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import * as utils from "../helpers/utils"; +import * as utils from "../utils/utils"; import { ClabContainerTreeNode, ClabInterfaceTreeNode, ClabLabTreeNode } from "../treeView/common"; const ERR_NO_LAB_NODE = 'No lab node selected.'; diff --git a/src/commands/dockerCommand.ts b/src/commands/dockerCommand.ts deleted file mode 100644 index a26a9b353..000000000 --- a/src/commands/dockerCommand.ts +++ /dev/null @@ -1,29 +0,0 @@ -import * as cmd from './command'; -import * as vscode from "vscode"; - -/** - * A helper class to build a 'docker' command (with optional sudo, etc.) - * and run it either in the Output channel or in a Terminal. - */ -export class DockerCommand extends cmd.Command { - private action: string; - - constructor(action: string, spinnerMsg: cmd.SpinnerMsg) { - const config = vscode.workspace.getConfiguration("containerlab"); - const runtime = config.get("runtime", "docker"); - - const options: cmd.SpinnerOptions = { - command: runtime, - spinnerMsg: spinnerMsg - } - super(options); - - this.action = action; - } - - public run(containerID: string) { - // Build the command - const cmd = [this.action, containerID]; - this.execute(cmd); - } -} \ No newline at end of file diff --git a/src/commands/fcli.ts b/src/commands/fcli.ts index 47be55933..5643f29f0 100644 --- a/src/commands/fcli.ts +++ b/src/commands/fcli.ts @@ -3,7 +3,6 @@ import * as fs from "fs"; import * as YAML from "yaml"; import { execCommandInTerminal } from "./command"; import { ClabLabTreeNode } from "../treeView/common"; -import { getSudo } from "../helpers/utils"; function buildNetworkFromYaml(topoPath: string): string { try { @@ -37,7 +36,7 @@ function runFcli(node: ClabLabTreeNode, cmd: string) { const network = buildNetworkFromYaml(topo); - const command = `${getSudo()}${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}`; + 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}`; execCommandInTerminal(command, `fcli - ${node.label}`); } diff --git a/src/commands/gottyShare.ts b/src/commands/gottyShare.ts index 0ce3c74d5..e1fd8cd32 100644 --- a/src/commands/gottyShare.ts +++ b/src/commands/gottyShare.ts @@ -1,8 +1,8 @@ import * as vscode from "vscode"; import { ClabLabTreeNode } from "../treeView/common"; -import { outputChannel, gottySessions, runningLabsProvider, refreshGottySessions } from "../extension"; +import { outputChannel, gottySessions, runningLabsProvider, refreshGottySessions, containerlabBinaryPath } from "../extension"; import { getHostname } from "./capture"; -import { runWithSudo } from "../helpers/utils"; +import { runCommand } from "../utils/utils"; async function parseGottyLink(output: string): Promise { try { @@ -56,7 +56,14 @@ async function gottyStart(action: "attach" | "reattach", node: ClabLabTreeNode) } try { const port = vscode.workspace.getConfiguration('containerlab').get('gotty.port', 8080); - const out = await runWithSudo(`containerlab tools gotty ${action} -l ${node.name} --port ${port}`, `GoTTY ${action}`, outputChannel, 'containerlab', true, true) as string; + const command = `${containerlabBinaryPath} tools gotty ${action} -l ${node.name} --port ${port}`; + const out = await runCommand( + command, + `GoTTY ${action}`, + outputChannel, + true, + true + ) as string; const link = await parseGottyLink(out || ''); if (link) { gottySessions.set(node.name, link); @@ -90,7 +97,14 @@ export async function gottyDetach(node: ClabLabTreeNode) { return; } try { - await runWithSudo(`containerlab tools gotty detach -l ${node.name}`, 'GoTTY detach', outputChannel, 'containerlab'); + const command = `${containerlabBinaryPath} tools gotty detach -l ${node.name}`; + await runCommand( + command, + 'GoTTY detach', + outputChannel, + false, + false + ); gottySessions.delete(node.name); vscode.window.showInformationMessage('GoTTY session detached'); } catch (err: any) { diff --git a/src/commands/graph.ts b/src/commands/graph.ts index b1cc6ac12..755a49e82 100644 --- a/src/commands/graph.ts +++ b/src/commands/graph.ts @@ -5,7 +5,7 @@ import { ClabCommand } from "./clabCommand"; import { ClabLabTreeNode } from "../treeView/common"; import { TopoViewer } from "../topoViewer"; -import { getSelectedLabNode } from "../helpers/utils"; +import { getSelectedLabNode } from "../utils/utils"; /** diff --git a/src/commands/impairments.ts b/src/commands/impairments.ts index 20b9efd28..c6fd0254d 100644 --- a/src/commands/impairments.ts +++ b/src/commands/impairments.ts @@ -1,8 +1,8 @@ // ./src/commands/impairments.ts import * as vscode from "vscode"; -import * as utils from "../helpers/utils"; import { ClabInterfaceTreeNode } from "../treeView/common"; import { execCommandInOutput } from "./command"; +import { containerlabBinaryPath } from "../extension"; // Common validation messages and patterns const ERR_EMPTY = 'Input should not be empty'; @@ -23,7 +23,7 @@ async function setImpairment(node: ClabInterfaceTreeNode, impairment?: string, v } const impairmentFlag = impairment ? `--${impairment}` : undefined; if (impairment && !value) { return; } - const cmd = `${utils.getSudo()}containerlab tools netem set --node ${node.parentName} --interface ${node.name} ${impairmentFlag} ${value}`; + const cmd = `${containerlabBinaryPath} tools netem set --node ${node.parentName} --interface ${node.name} ${impairmentFlag} ${value}`; const msg = `set ${impairment} to ${value} for ${node.name} on ${node.parentName}.`; vscode.window.showInformationMessage(`Attempting to ${msg}`); execCommandInOutput(cmd, false, diff --git a/src/commands/index.ts b/src/commands/index.ts index 4da1a4547..444c02974 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -4,9 +4,8 @@ export * from "./destroy"; export * from "./redeploy"; export * from "./save"; export * from "./openLabFile"; -export * from "./startNode"; -export * from "./stopNode"; -export * from "./attachShell"; +export * from "./nodeActions"; +export * from "./nodeExec"; export * from "./ssh"; export * from "./nodeImpairments"; export * from "./showLogs"; @@ -19,7 +18,6 @@ export * from "./capture"; export * from "./impairments"; export * from "./edgeshark"; export * from "./openBrowser"; -export * from "./telnet"; export * from "./favorite"; export * from "./deleteLab"; export * from "./cloneRepo"; diff --git a/src/commands/inspect.ts b/src/commands/inspect.ts index f6d6354a1..6a09684af 100644 --- a/src/commands/inspect.ts +++ b/src/commands/inspect.ts @@ -1,12 +1,8 @@ import * as vscode from "vscode"; -import { promisify } from "util"; -import { exec } from "child_process"; import { getInspectHtml } from "../webview/inspectHtml"; import { ClabLabTreeNode } from "../treeView/common"; -import { getSudo } from "../helpers/utils"; import { outputChannel } from "../extension"; // Import outputChannel for logging - -const execAsync = promisify(exec); +import * as inspector from "../treeView/inspector"; // Store the current panel and context for refresh functionality let currentPanel: vscode.WebviewPanel | undefined; @@ -43,26 +39,11 @@ function normalizeInspectOutput(parsedData: any): any[] { export async function inspectAllLabs(context: vscode.ExtensionContext) { try { - const config = vscode.workspace.getConfiguration("containerlab"); - const runtime = config.get("runtime", "docker"); - const sudoPrefix = getSudo(); - const command = `${sudoPrefix}containerlab inspect -r ${runtime} --all --details --format json`; - outputChannel.appendLine(`[Inspect Command]: Running: ${command}`); - - const { stdout, stderr } = await execAsync(command, { timeout: 15000 }); // Added timeout - - if (stderr) { - outputChannel.appendLine(`[Inspect Command]: stderr from inspect --all: ${stderr}`); - } - if (!stdout) { - outputChannel.appendLine(`[Inspect Command]: No stdout from inspect --all.`); - showInspectWebview([], "Inspect - All Labs", context.extensionUri); // Show empty view - return; - } + outputChannel.appendLine(`[Inspect Command]: Refreshing via containerlab events cache`); - const parsed = JSON.parse(stdout); + await inspector.update(); + const parsed = inspector.rawInspectData; - // Normalize the data (handles both old and new formats) const normalizedContainers = normalizeInspectOutput(parsed); // Store context for refresh @@ -74,8 +55,8 @@ export async function inspectAllLabs(context: vscode.ExtensionContext) { showInspectWebview(normalizedContainers, "Inspect - All Labs", context.extensionUri); } catch (err: any) { - outputChannel.appendLine(`[Inspect Command]: Failed to run containerlab inspect --all: ${err.message || err}`); - vscode.window.showErrorMessage(`Failed to run containerlab inspect --all: ${err.message || err}`); + outputChannel.appendLine(`[Inspect Command]: Failed to refresh inspect data: ${err.message || err}`); + vscode.window.showErrorMessage(`Failed to refresh inspect data: ${err.message || err}`); // Optionally show an empty webview on error // showInspectWebview([], "Inspect - All Labs (Error)", context.extensionUri); } @@ -88,33 +69,26 @@ export async function inspectOneLab(node: ClabLabTreeNode, context: vscode.Exten } try { - const config = vscode.workspace.getConfiguration("containerlab"); - const runtime = config.get("runtime", "docker"); - const sudoPrefix = getSudo(); - // Ensure lab path is quoted correctly for the shell - const labPathEscaped = `"${node.labPath.absolute.replace(/"/g, '\\"')}"`; - const command = `${sudoPrefix}containerlab inspect -r ${runtime} -t ${labPathEscaped} --details --format json`; - outputChannel.appendLine(`[Inspect Command]: Running: ${command}`); - - const { stdout, stderr } = await execAsync(command, { timeout: 15000 }); // Added timeout - - if (stderr) { - outputChannel.appendLine(`[Inspect Command]: stderr from inspect -t: ${stderr}`); - } - if (!stdout) { - outputChannel.appendLine(`[Inspect Command]: No stdout from inspect -t.`); - showInspectWebview([], `Inspect - ${node.label}`, context.extensionUri); // Show empty view - return; - } + outputChannel.appendLine(`[Inspect Command]: Refreshing lab ${node.label} via events cache`); - const parsed = JSON.parse(stdout); + await inspector.update(); - // Normalize the data (handles both old and new formats for single lab) - // The normalization function should correctly handle the case where 'parsed' - // might be {"lab_name": [...]} or potentially still {"containers": [...]}. - const normalizedContainers = normalizeInspectOutput(parsed); + const parsed = inspector.rawInspectData || {}; + const filtered: Record = {}; + + for (const [labName, containers] of Object.entries(parsed)) { + if (!Array.isArray(containers)) { + continue; + } + const topoFile = (containers as any)['topo-file']; + if ((node.name && labName === node.name) || topoFile === node.labPath.absolute) { + filtered[labName] = containers; + break; + } + } + + const normalizedContainers = normalizeInspectOutput(Object.keys(filtered).length ? filtered : []); - // Store context for refresh currentContext = { type: 'single', node: node, @@ -124,10 +98,8 @@ export async function inspectOneLab(node: ClabLabTreeNode, context: vscode.Exten showInspectWebview(normalizedContainers, `Inspect - ${node.label}`, context.extensionUri); } catch (err: any) { - outputChannel.appendLine(`[Inspect Command]: Failed to inspect lab ${node.label}: ${err.message || err}`); - vscode.window.showErrorMessage(`Failed to inspect lab ${node.label}: ${err.message || err}`); - // Optionally show an empty webview on error - // showInspectWebview([], `Inspect - ${node.label} (Error)`, context.extensionUri); + outputChannel.appendLine(`[Inspect Command]: Failed to refresh lab ${node.label}: ${err.message || err}`); + vscode.window.showErrorMessage(`Failed to refresh lab ${node.label}: ${err.message || err}`); } } diff --git a/src/commands/nodeActions.ts b/src/commands/nodeActions.ts index 24dec78fc..53b74d2d1 100644 --- a/src/commands/nodeActions.ts +++ b/src/commands/nodeActions.ts @@ -1,9 +1,9 @@ import * as vscode from "vscode"; -import { SpinnerMsg } from "./command"; -import { DockerCommand } from "./dockerCommand"; import { ClabContainerTreeNode } from "../treeView/common"; +import * as utils from '../utils'; -async function runNodeAction(action: "start" | "stop", node: ClabContainerTreeNode): Promise { +async function runNodeAction(action: utils.ContainerAction, node: ClabContainerTreeNode +): Promise { if (!node) { vscode.window.showErrorMessage("No container node selected."); return; @@ -15,23 +15,21 @@ async function runNodeAction(action: "start" | "stop", node: ClabContainerTreeNo return; } - const verb = action === "start" ? "Starting" : "Stopping"; - const past = action === "start" ? "started" : "stopped"; - - const spinnerMessages: SpinnerMsg = { - progressMsg: `${verb} node ${containerId}...`, - successMsg: `Node '${containerId}' ${past} successfully`, - failMsg: `Could not ${action} node '${containerId}'`, - }; - - const cmd = new DockerCommand(action, spinnerMessages); - cmd.run(containerId); + await utils.runContainerAction(containerId, action); } export async function startNode(node: ClabContainerTreeNode): Promise { - await runNodeAction("start", node); + await runNodeAction(utils.ContainerAction.Start, node); } export async function stopNode(node: ClabContainerTreeNode): Promise { - await runNodeAction("stop", node); + await runNodeAction(utils.ContainerAction.Stop, node); +} + +export async function pauseNode(node: ClabContainerTreeNode): Promise { + await runNodeAction(utils.ContainerAction.Pause, node); +} + +export async function unpauseNode(node: ClabContainerTreeNode): Promise { + await runNodeAction(utils.ContainerAction.Unpause, node); } diff --git a/src/commands/nodeExec.ts b/src/commands/nodeExec.ts index 1c6cea5d3..3a7a66152 100644 --- a/src/commands/nodeExec.ts +++ b/src/commands/nodeExec.ts @@ -1,8 +1,8 @@ import * as vscode from "vscode"; -import * as utils from "../helpers/utils"; import { execCommandInTerminal } from "./command"; import { execCmdMapping } from "../extension"; import { ClabContainerTreeNode } from "../treeView/common"; +import { DEFAULT_ATTACH_SHELL_CMD, DEFAULT_ATTACH_TELNET_PORT } from "../utils"; interface NodeContext { containerId: string; @@ -33,7 +33,7 @@ export function attachShell(node: ClabContainerTreeNode | undefined): void { const ctx = getNodeContext(node); if (!ctx) return; - let execCmd = (execCmdMapping as any)[ctx.containerKind] || "sh"; + let execCmd = (execCmdMapping as any)[ctx.containerKind] || DEFAULT_ATTACH_SHELL_CMD; const config = vscode.workspace.getConfiguration("containerlab"); const userExecMapping = config.get("node.execCommandMapping") as { [key: string]: string }; const runtime = config.get("runtime", "docker"); @@ -41,8 +41,9 @@ export function attachShell(node: ClabContainerTreeNode | undefined): void { execCmd = userExecMapping[ctx.containerKind] || execCmd; execCommandInTerminal( - `${utils.getSudo()}${runtime} exec -it ${ctx.containerId} ${execCmd}`, - `Shell - ${ctx.container}` + `${runtime} exec -it ${ctx.containerId} ${execCmd}`, + `Shell - ${ctx.container}`, + true // If terminal exists, just focus it ); } @@ -50,10 +51,11 @@ export function telnetToNode(node: ClabContainerTreeNode | undefined): void { const ctx = getNodeContext(node); if (!ctx) return; const config = vscode.workspace.getConfiguration("containerlab"); - const port = (config.get("node.telnetPort") as number) || 5000; + const port = (config.get("node.telnetPort") as number) || DEFAULT_ATTACH_TELNET_PORT; const runtime = config.get("runtime", "docker"); execCommandInTerminal( - `${utils.getSudo()}${runtime} exec -it ${ctx.containerId} telnet 127.0.0.1 ${port}`, - `Telnet - ${ctx.container}` + `${runtime} exec -it ${ctx.containerId} telnet 127.0.0.1 ${port}`, + `Telnet - ${ctx.container}`, + true // If terminal exists, just focus it ); } diff --git a/src/commands/nodeImpairments.ts b/src/commands/nodeImpairments.ts index 5b45cc9e9..e9642724d 100644 --- a/src/commands/nodeImpairments.ts +++ b/src/commands/nodeImpairments.ts @@ -1,8 +1,8 @@ import * as vscode from "vscode"; import { ClabContainerTreeNode } from "../treeView/common"; import { getNodeImpairmentsHtml } from "../webview/nodeImpairmentsHtml"; -import { runWithSudo } from "../helpers/utils"; -import { outputChannel } from "../extension"; +import { outputChannel, containerlabBinaryPath } from "../extension"; +import { runCommand } from "../utils/utils"; type NetemFields = { delay: string; @@ -74,21 +74,20 @@ function ensureDefaults(map: Record, node: ClabContainerTre async function refreshNetemSettings(node: ClabContainerTreeNode): Promise> { const config = vscode.workspace.getConfiguration("containerlab"); const runtime = config.get("runtime", "docker"); - const showCmd = `containerlab tools -r ${runtime} netem show -n ${node.name} --format json`; + const showCmd = `${containerlabBinaryPath} tools -r ${runtime} netem show -n ${node.name} --format json`; let netemMap: Record = {}; try { - const stdoutResult = await runWithSudo( + const stdout = await runCommand( showCmd, - `Retrieving netem settings for ${node.name}`, + 'Refresh netem settings', outputChannel, - "containerlab", - true - ); - if (!stdoutResult) { + true, + false + ) as string; + if (!stdout) { throw new Error("No output from netem show command"); } - const stdout = stdoutResult as string; const rawData = JSON.parse(stdout); const interfacesData = rawData[node.name] || []; interfacesData.forEach((item: any) => { @@ -143,13 +142,14 @@ async function applyNetem( for (const [intfName, fields] of Object.entries(netemData)) { const netemArgs = buildNetemArgs(fields as Record); if (netemArgs.length > 0) { - const cmd = `containerlab tools netem set -n ${node.name} -i ${intfName} ${netemArgs.join(" ")} > /dev/null 2>&1`; + const cmd = `${containerlabBinaryPath} tools netem set -n ${node.name} -i ${intfName} ${netemArgs.join(" ")} > /dev/null 2>&1`; ops.push( - runWithSudo( + runCommand( cmd, - `Applying netem on ${node.name}/${intfName}`, + `Apply netem to ${intfName}`, outputChannel, - "containerlab" + false, + false ) ); } @@ -179,13 +179,14 @@ async function clearNetem( continue; } const cmd = - `containerlab tools netem set -n ${node.name} -i ${norm} --delay 0s --jitter 0s --loss 0 --rate 0 --corruption 0.0000000000000001 > /dev/null 2>&1`; + `${containerlabBinaryPath} tools netem set -n ${node.name} -i ${norm} --delay 0s --jitter 0s --loss 0 --rate 0 --corruption 0.0000000000000001 > /dev/null 2>&1`; ops.push( - runWithSudo( + runCommand( cmd, - `Clearing netem on ${node.name}/${norm}`, + `Clear netem for ${norm}`, outputChannel, - "containerlab" + false, + false ) ); } diff --git a/src/commands/openBrowser.ts b/src/commands/openBrowser.ts index b36aca574..e0b86de4d 100644 --- a/src/commands/openBrowser.ts +++ b/src/commands/openBrowser.ts @@ -1,11 +1,6 @@ import * as vscode from "vscode"; -import { promisify } from "util"; -import { exec } from "child_process"; -import { outputChannel } from "../extension"; +import { outputChannel, dockerClient } from "../extension"; import { ClabContainerTreeNode } from "../treeView/common"; -import { getSudo } from "../helpers/utils"; - -const execAsync = promisify(exec); interface PortMapping { containerPort: string; @@ -19,134 +14,143 @@ interface PortMapping { * If multiple ports are exposed, presents a quick pick to select which one. */ export async function openBrowser(node: ClabContainerTreeNode) { - if (!node) { - vscode.window.showErrorMessage("No container node selected."); + const containerId = resolveContainerId(node); + if (!containerId) { return; } - const containerId = node.cID; - if (!containerId) { - vscode.window.showErrorMessage("No container ID found."); + const portMappings = await getExposedPorts(containerId); + if (!portMappings || portMappings.length === 0) { + vscode.window.showInformationMessage(`No exposed ports found for container ${node.name}.`); return; } - try { - // Get the exposed ports for this container - const portMappings = await getExposedPorts(containerId); + const mapping = await pickPortMapping(portMappings); + if (!mapping) { + return; + } - if (!portMappings || portMappings.length === 0) { - vscode.window.showInformationMessage(`No exposed ports found for container ${node.name}.`); - return; - } + openPortInBrowser(mapping, node.name); +} - // If only one port is exposed, open it directly - if (portMappings.length === 1) { - openPortInBrowser(portMappings[0], node.name); - return; - } +function resolveContainerId(node?: ClabContainerTreeNode): string | undefined { + if (!node) { + vscode.window.showErrorMessage("No container node selected."); + return undefined; + } - // If multiple ports are exposed, show a quick pick - const quickPickItems = portMappings.map(mapping => ({ - label: `${mapping.hostPort}:${mapping.containerPort}/${mapping.protocol}`, - description: mapping.description || "", - detail: `Open in browser`, - mapping: mapping - })); + if (!node.cID) { + vscode.window.showErrorMessage("No container ID found."); + return undefined; + } - const selected = await vscode.window.showQuickPick(quickPickItems, { - placeHolder: "Select a port to open in browser" - }); + return node.cID; +} - if (selected) { - openPortInBrowser(selected.mapping, node.name); - } - } catch (error: any) { - vscode.window.showErrorMessage(`Error getting port mappings: ${error.message}`); - outputChannel.error(`openPort() => ${error.message}`); +async function pickPortMapping(portMappings: PortMapping[]): Promise { + if (portMappings.length === 1) { + return portMappings[0]; } + + const quickPickItems = portMappings.map(mapping => ({ + label: `${mapping.hostPort}:${mapping.containerPort}/${mapping.protocol}`, + description: mapping.description || "", + detail: `Open in browser`, + mapping + })); + + const selected = await vscode.window.showQuickPick(quickPickItems, { + placeHolder: "Select a port to open in browser" + }); + + return selected?.mapping; } /** - * Get the exposed ports for a container using docker/podman port command + * Get the exposed ports for a container using Dockerode */ async function getExposedPorts(containerId: string): Promise { - try { - // Use runtime from user configuration - const config = vscode.workspace.getConfiguration("containerlab"); - const runtime = config.get("runtime", "docker"); - - // Use the 'port' command which gives cleaner output format - const command = `${getSudo()}${runtime} port ${containerId}`; - - const { stdout, stderr } = await execAsync(command); + if (!dockerClient) { + outputChannel.error('Docker client not initialized'); + return []; + } - if (stderr) { - outputChannel.warn(`stderr from port mapping command: ${stderr}`); - } + try { + const container = dockerClient.getContainer(containerId); + const containerInfo = await container.inspect(); + const ports = containerInfo.NetworkSettings.Ports || {}; - // Store unique port mappings by hostPort to avoid duplicates - const portMap = new Map(); + const mappings = collectPortMappings(ports); - if (!stdout.trim()) { + if (mappings.length === 0) { outputChannel.info(`No exposed ports found for container ${containerId}`); - return []; - } - - // Output can vary by Docker version, but generally looks like: - // 8080/tcp -> 0.0.0.0:30008 - // or - // 80/tcp -> 0.0.0.0:8080 - // or sometimes just - // 80/tcp -> :8080 - const portLines = stdout.trim().split('\n'); - - for (const line of portLines) { - - // Match container port and protocol - let containerPort = ''; - let protocol = ''; - let hostPort = ''; - - // Look for format like "80/tcp -> 0.0.0.0:8080" or "80/tcp -> :8080" - const parts = line.trim().split(/\s+/); - const first = parts[0] || ''; - const last = parts[parts.length - 1] || ''; - const portProto = /^(\d+)\/(\w+)$/; - const hostPortRegex = /:(\d+)$/; - const ppMatch = portProto.exec(first); - const hpMatch = hostPortRegex.exec(last); - const match = ppMatch && hpMatch ? [first, ppMatch[1], ppMatch[2], hpMatch[1]] as unknown as RegExpExecArray : null; - - if (match) { - containerPort = match[1]; - protocol = match[2]; - hostPort = match[3]; - - // Get a description for this port - const description = getPortDescription(containerPort); - - // Use hostPort as the key to avoid duplicates - if (!portMap.has(hostPort)) { - portMap.set(hostPort, { - containerPort, - hostPort, - protocol, - description - }); - } - } else { - outputChannel.warn(`Failed to parse port mapping from: ${line}`); - } } - // Convert the map values to an array - return Array.from(portMap.values()); + return mappings; } catch (error: any) { outputChannel.error(`Error getting port mappings: ${error.message}`); return []; } } +type DockerPortBinding = { HostIp?: string; HostPort?: string }; +type DockerPortBindings = Record; + +function collectPortMappings(ports: DockerPortBindings): PortMapping[] { + const portMap = new Map(); + + for (const [portProto, bindings] of Object.entries(ports)) { + addBindingsForPort(portMap, portProto, bindings); + } + + return Array.from(portMap.values()); +} + +function addBindingsForPort( + portMap: Map, + portProto: string, + bindings?: DockerPortBinding[] +) { + if (!bindings || bindings.length === 0) { + return; + } + + const parsed = parseContainerPort(portProto); + if (!parsed) { + return; + } + + for (const binding of bindings) { + addBinding(portMap, binding.HostPort, parsed.containerPort, parsed.protocol); + } +} + +function parseContainerPort(portProto: string): { containerPort: string; protocol: string } | undefined { + const match = /^(\d+)\/(\w+)$/.exec(portProto); + if (!match) { + return undefined; + } + return { containerPort: match[1], protocol: match[2] }; +} + +function addBinding( + portMap: Map, + hostPort: string | undefined, + containerPort: string, + protocol: string +) { + if (!hostPort || portMap.has(hostPort)) { + return; + } + + portMap.set(hostPort, { + containerPort, + hostPort, + protocol, + description: getPortDescription(containerPort) + }); +} + /** * Open a specific port in the default browser */ diff --git a/src/commands/runClabAction.ts b/src/commands/runClabAction.ts index 95a087fbb..725ee7ca8 100644 --- a/src/commands/runClabAction.ts +++ b/src/commands/runClabAction.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode"; import { ClabCommand } from "./clabCommand"; import { ClabLabTreeNode } from "../treeView/common"; -import { getSelectedLabNode } from "../helpers/utils"; +import { getSelectedLabNode } from "../utils/utils"; import { notifyCurrentTopoViewerOfCommandFailure, notifyCurrentTopoViewerOfCommandSuccess } from "./graph"; export async function runClabAction(action: "deploy" | "redeploy" | "destroy", node?: ClabLabTreeNode, cleanup = false): Promise { diff --git a/src/commands/showLogs.ts b/src/commands/showLogs.ts index 371425d4e..204ee2537 100644 --- a/src/commands/showLogs.ts +++ b/src/commands/showLogs.ts @@ -1,7 +1,6 @@ import * as vscode from "vscode"; import { execCommandInTerminal } from "./command"; import { ClabContainerTreeNode } from "../treeView/common"; -import { getSudo } from "../helpers/utils"; export function showLogs(node: ClabContainerTreeNode) { if (!node) { @@ -20,7 +19,7 @@ export function showLogs(node: ClabContainerTreeNode) { const config = vscode.workspace.getConfiguration("containerlab"); const runtime = config.get("runtime", "docker"); execCommandInTerminal( - `${getSudo()}${runtime} logs -f ${containerId}`, + `${runtime} logs -f ${containerId}`, `Logs - ${container}` ); } \ No newline at end of file diff --git a/src/commands/ssh.ts b/src/commands/ssh.ts index 49fd2b95f..ad11c273e 100644 --- a/src/commands/ssh.ts +++ b/src/commands/ssh.ts @@ -29,7 +29,7 @@ export function sshToNode(node: ClabContainerTreeNode | undefined): void { const container = node.name || node.cID || "Container"; - execCommandInTerminal(`ssh ${sshUser}@${sshTarget}`, `SSH - ${container}`); + execCommandInTerminal(`ssh ${sshUser}@${sshTarget}`, `SSH - ${container}`, true); } export function sshToLab(node: ClabLabTreeNode | undefined): void { diff --git a/src/commands/sshxShare.ts b/src/commands/sshxShare.ts index 004c904f5..4fad0edac 100644 --- a/src/commands/sshxShare.ts +++ b/src/commands/sshxShare.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode"; import { ClabLabTreeNode } from "../treeView/common"; -import { outputChannel, sshxSessions, runningLabsProvider, refreshSshxSessions } from "../extension"; -import { runWithSudo } from "../helpers/utils"; +import { outputChannel, sshxSessions, runningLabsProvider, refreshSshxSessions, containerlabBinaryPath } from "../extension"; +import { runCommand } from "../utils/utils"; function parseLink(output: string): string | undefined { const re = /(https?:\/\/\S+)/; @@ -15,7 +15,13 @@ async function sshxStart(action: "attach" | "reattach", node: ClabLabTreeNode) { return; } try { - const out = await runWithSudo(`containerlab tools sshx ${action} -l ${node.name}`, `SSHX ${action}`, outputChannel, 'containerlab', true, true) as string; + const out = await runCommand( + `${containerlabBinaryPath} tools sshx ${action} -l ${node.name}`, + `SSHX ${action}`, + outputChannel, + true, + true + ) as string; const link = parseLink(out || ''); if (link) { sshxSessions.set(node.name, link); @@ -49,7 +55,13 @@ export async function sshxDetach(node: ClabLabTreeNode) { return; } try { - await runWithSudo(`containerlab tools sshx detach -l ${node.name}`, 'SSHX detach', outputChannel); + await runCommand( + `${containerlabBinaryPath} tools sshx detach -l ${node.name}`, + 'SSHX detach', + outputChannel, + false, + false + ); sshxSessions.delete(node.name); vscode.window.showInformationMessage('SSHX session detached'); } catch (err: any) { diff --git a/src/commands/startNode.ts b/src/commands/startNode.ts deleted file mode 100644 index 759b2fc3e..000000000 --- a/src/commands/startNode.ts +++ /dev/null @@ -1 +0,0 @@ -export { startNode } from "./nodeActions"; diff --git a/src/commands/stopNode.ts b/src/commands/stopNode.ts deleted file mode 100644 index 888753d38..000000000 --- a/src/commands/stopNode.ts +++ /dev/null @@ -1 +0,0 @@ -export { stopNode } from "./nodeActions"; diff --git a/src/commands/telnet.ts b/src/commands/telnet.ts deleted file mode 100644 index 7e81f3d0d..000000000 --- a/src/commands/telnet.ts +++ /dev/null @@ -1 +0,0 @@ -export { telnetToNode } from './nodeExec'; diff --git a/src/extension.ts b/src/extension.ts index 4286d8641..3f4620899 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,9 +1,12 @@ import * as vscode from 'vscode'; import * as cmd from './commands/index'; -import * as utils from './helpers/utils'; +import * as utils from './utils/index'; import * as ins from "./treeView/inspector" import * as c from './treeView/common'; import * as path from 'path'; +import * as fs from 'fs'; +import { execSync } from 'child_process'; +import Docker from 'dockerode'; import { TopoViewerEditor } from './topoViewer/providers/topoViewerEditorWebUiFacade'; import { setCurrentTopoViewer } from './commands/graph'; @@ -14,6 +17,9 @@ import { LocalLabTreeDataProvider } from './treeView/localLabsProvider'; import { RunningLabTreeDataProvider } from './treeView/runningLabsProvider'; import { HelpFeedbackProvider } from './treeView/helpFeedbackProvider'; import { registerClabImageCompletion } from './yaml/imageCompletion'; +import { onDataChanged as onEventsDataChanged, onContainerStateChanged } from "./services/containerlabEvents"; +import { onDataChanged as onFallbackDataChanged, stopPolling as stopFallbackPolling } from "./services/containerlabInspectFallback"; +import { isPollingMode } from "./treeView/inspector"; /** Our global output channel */ export let outputChannel: vscode.LogOutputChannel; @@ -30,12 +36,11 @@ export let runningLabsProvider: RunningLabTreeDataProvider; export let helpFeedbackProvider: HelpFeedbackProvider; export let sshxSessions: Map = new Map(); export let gottySessions: Map = new Map(); -export const DOCKER_IMAGES_STATE_KEY = 'dockerImages'; export const extensionVersion = vscode.extensions.getExtension('srl-labs.vscode-containerlab')?.packageJSON.version; -let refreshInterval: number; -let refreshTaskID: ReturnType | undefined; +export let containerlabBinaryPath: string = 'containerlab'; +export let dockerClient: Docker; function registerUnsupportedViews(context: vscode.ExtensionContext) { let warningShown = false; @@ -102,12 +107,12 @@ function extractLabName(session: any, prefix: string): string | undefined { export async function refreshSshxSessions() { try { - const out = await utils.runWithSudo( - 'containerlab tools sshx list -f json', + const out = await utils.runCommand( + `${containerlabBinaryPath} tools sshx list -f json`, 'List SSHX sessions', outputChannel, - 'containerlab', - true + true, + false ) as string; sshxSessions.clear(); if (out) { @@ -129,12 +134,12 @@ export async function refreshSshxSessions() { export async function refreshGottySessions() { try { - const out = await utils.runWithSudo( - 'containerlab tools gotty list -f json', + const out = await utils.runCommand( + `${containerlabBinaryPath} tools gotty list -f json`, 'List GoTTY sessions', outputChannel, - 'containerlab', - true + true, + false ) as string; gottySessions.clear(); if (out) { @@ -160,30 +165,6 @@ export async function refreshGottySessions() { } } -/** - * Refreshes the cached list of local Docker images and stores them in extension global state. - * The list is a unique, sorted array of strings in the form "repository:tag". - */ -export async function refreshDockerImages(context?: vscode.ExtensionContext): Promise { - // Fail silently if docker is not available or any error occurs. - const ctx = context ?? extensionContext; - if (!ctx) return; - try { - const { exec } = await import('child_process'); - const { promisify } = await import('util'); - const execAsync = promisify(exec); - const { stdout } = await execAsync('docker images --format "{{.Repository}}:{{.Tag}}"'); - const images = (stdout || '') - .split(/\r?\n/) - .map(s => s.trim()) - .filter(s => s && !s.endsWith(':') && !s.startsWith('')); - const unique = Array.from(new Set(images)).sort((a, b) => a.localeCompare(b)); - await ctx.globalState.update(DOCKER_IMAGES_STATE_KEY, unique); - } catch { - // On failure, do not prompt or log; leave cache as-is. - return; - } -} import * as execCmdJson from '../resources/exec_cmd.json'; import * as sshUserJson from '../resources/ssh_users.json'; @@ -327,30 +308,6 @@ function onDidChangeConfiguration(e: vscode.ConfigurationChangeEvent) { } } -function refreshTask() { - ins.update().then(() => { - localLabsProvider?.refresh(); - runningLabsProvider?.softRefresh(); - }); -} - -// Function to start the refresh interval -function startRefreshInterval() { - if (!refreshTaskID) { - console.debug("Starting refresh task") - refreshTaskID = setInterval(refreshTask, refreshInterval); - } -} - -// Function to stop the refresh interval -function stopRefreshInterval() { - if (refreshTaskID) { - console.debug("Stopping refresh task") - clearInterval(refreshTaskID); - refreshTaskID = undefined; - } -} - function registerCommands(context: vscode.ExtensionContext) { const commands: Array<[string, any]> = [ ['containerlab.lab.openFile', cmd.openLabFile], @@ -385,6 +342,8 @@ function registerCommands(context: vscode.ExtensionContext) { ['containerlab.lab.graph.topoViewerReload', cmd.graphTopoviewerReload], ['containerlab.node.start', cmd.startNode], ['containerlab.node.stop', cmd.stopNode], + ['containerlab.node.pause', cmd.pauseNode], + ['containerlab.node.unpause', cmd.unpauseNode], ['containerlab.node.save', cmd.saveNode], ['containerlab.node.attachShell', cmd.attachShell], ['containerlab.node.ssh', cmd.sshToNode], @@ -440,6 +399,93 @@ function registerCommands(context: vscode.ExtensionContext) { context.subscriptions.push(vscode.commands.registerCommand('containerlab.treeView.localLabs.clearFilter', clearLocalLabsFilterCommand)); } +function registerRealtimeUpdates(context: vscode.ExtensionContext) { + // Common handler for data changes (used by both events and fallback) + const handleDataChanged = () => { + ins.refreshFromEventStream(); + if (runningLabsProvider) { + void runningLabsProvider.softRefresh().catch(err => { + console.error("[containerlab extension]: realtime refresh failed", err); + }); + } + }; + + // Register BOTH listeners - isPollingMode() will dynamically check which one applies + // This handles the case where events fail and we fall back to polling mid-session + + // Events listener (only fires if events mode is active) + const disposeEventsRealtime = onEventsDataChanged(() => { + if (!isPollingMode()) { + handleDataChanged(); + } + }); + context.subscriptions.push({ dispose: disposeEventsRealtime }); + + // Fallback polling listener (only fires if polling mode is active) + const disposeFallbackRealtime = onFallbackDataChanged(() => { + if (isPollingMode()) { + handleDataChanged(); + } + }); + context.subscriptions.push({ dispose: disposeFallbackRealtime }); + + // Register listener for container state changes (only relevant in events mode) + const disposeStateChange = onContainerStateChanged((containerShortId, newState) => { + if (!isPollingMode() && runningLabsProvider) { + void runningLabsProvider.refreshContainer(containerShortId, newState).catch(err => { + outputChannel.debug(`Failed to refresh container ${containerShortId}: ${err instanceof Error ? err.message : String(err)}`); + }); + } + }); + context.subscriptions.push({ dispose: disposeStateChange }); + + // Stop fallback polling on deactivate + context.subscriptions.push({ + dispose: () => { + stopFallbackPolling(); + } + }); + + ins.refreshFromEventStream(); +} + +function setClabBinPath(): boolean { + const configPath = vscode.workspace.getConfiguration('containerlab').get('binaryPath', ''); + + // if empty fall back to resolving from PATH + if (!configPath || configPath.trim() === '') { + try { + // eslint-disable-next-line sonarjs/no-os-command-from-path + const stdout = execSync('which containerlab', { encoding: 'utf-8' }); + const resolvedPath = stdout.trim(); + if (resolvedPath) { + containerlabBinaryPath = resolvedPath; + outputChannel.info(`Resolved containerlab binary from sys PATH as: ${resolvedPath}`); + return true; + } + } catch (err) { + outputChannel.warn(`Could not resolve containerlab bin path from sys PATH: ${err}`); + } + containerlabBinaryPath = 'containerlab'; + return true; + } + + try { + // Check if file exists and is executable + fs.accessSync(configPath, fs.constants.X_OK); + containerlabBinaryPath = configPath; + outputChannel.info(`Using user configured containerlab binary: ${configPath}`); + return true; + } catch (err) { + // Path is invalid or not executable - try to resolve from PATH as fallback + outputChannel.error(`Invalid containerlab.binaryPath "${configPath}": ${err}`); + vscode.window.showErrorMessage( + `Configured containerlab binary path "${configPath}" is invalid or not executable.` + ); + } + return false; +} + /** * Called when VSCode activates your extension. */ @@ -447,8 +493,8 @@ export async function activate(context: vscode.ExtensionContext) { // Create and register the output channel outputChannel = vscode.window.createOutputChannel('Containerlab', { log: true }); context.subscriptions.push(outputChannel); - - outputChannel.info(process.platform); + outputChannel.info('Registered output channel sucessfully.'); + outputChannel.info(`Detected platform: ${process.platform}`); const config = vscode.workspace.getConfiguration('containerlab'); const isSupportedPlatform = process.platform === "linux" || vscode.env.remoteName === "wsl"; @@ -460,24 +506,81 @@ export async function activate(context: vscode.ExtensionContext) { return; } - outputChannel.info('Containerlab extension activated.'); + if (!setClabBinPath()) { + // don't activate + outputChannel.error(`Error setting containerlab binary. Exiting activation.`); + return; + } - // 1) Ensure containerlab is installed (or skip based on user setting) - const skipInstallationCheck = config.get('skipInstallationCheck', false); - const clabInstalled = skipInstallationCheck - ? await utils.isClabInstalled(outputChannel) - : await utils.ensureClabInstalled(outputChannel); - if (!clabInstalled) { - if (skipInstallationCheck) { - outputChannel.info('containerlab not detected; skipping activation because installation checks are disabled.'); + // Ensure clab is installed if the binpath was unable to be set. + if (containerlabBinaryPath === 'containerlab') { + const installChoice = await vscode.window.showWarningMessage( + 'Containerlab is not installed. Would you like to install it?', + 'Install', + 'Cancel' + ); + if (installChoice === 'Install') { + utils.installContainerlab(); + vscode.window.showInformationMessage( + 'Please complete the installation in the terminal, then reload the window.', + 'Reload Window' + ).then(choice => { + if (choice === 'Reload Window') { + vscode.commands.executeCommand('workbench.action.reloadWindow'); + } + }); } return; } - // 2) If installed, check for updates - utils.checkAndUpdateClabIfNeeded(outputChannel, context).catch(err => { - outputChannel.error(`Update check error: ${err.message}`); - }); + outputChannel.info('Containerlab extension activated.'); + + outputChannel.debug(`Starting user permissions check`); + // 1) Check if user has required permissions + const userInfo = utils.getUserInfo(); + username = userInfo.username; + if (!userInfo.hasPermission) { + outputChannel.error(`User '${userInfo.username}' (id:${userInfo.uid}) has insufficient permissions`); + + vscode.window.showErrorMessage( + `Extension activation failed. Insufficient permissions.\nEnsure ${userInfo.username} is in the 'clab_admins' and 'docker' groups.` + ) + return; + } + outputChannel.debug(`Permission check success for user '${userInfo.username}' (id:${userInfo.uid})`); + + // 2) Check for updates + const skipUpdateCheck = config.get('skipUpdateCheck', false); + if (!skipUpdateCheck) { + utils.checkAndUpdateClabIfNeeded(outputChannel, context).catch(err => { + outputChannel.error(`Update check error: ${err.message}`); + }); + } + + /** + * CONNECT TO DOCKER SOCKET VIA DOCKERODE + */ + try { + dockerClient = new Docker({ socketPath: '/var/run/docker.sock' }); + // verify we are connected + await dockerClient.ping(); + outputChannel.info('Successfully connected to Docker socket'); + } catch (err: any) { + outputChannel.error(`Failed to connect to Docker socket: ${err.message}`); + vscode.window.showErrorMessage( + `Failed to connect to Docker. Ensure Docker is running and you have proper permissions.` + ); + return; + } + + /** + * At this stage we should have successfully connected to the docker socket. + * now we can: + * - Initially load docker images cache + * - Start the docker images listener + */ + utils.refreshDockerImages(); + utils.startDockerImageEventMonitor(context); // Show welcome page const welcomePage = new WelcomePage(context); @@ -514,8 +617,7 @@ export async function activate(context: vscode.ExtensionContext) { canSelectMany: false }); - // get the username - username = utils.getUsername(); + registerRealtimeUpdates(context); // Determine if local capture is allowed. const isLocalCaptureAllowed = @@ -532,30 +634,6 @@ export async function activate(context: vscode.ExtensionContext) { // Register commands registerCommands(context); context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(onDidChangeConfiguration)); - - // Auto-refresh the TreeView based on user setting - refreshInterval = config.get('refreshInterval', 5000); - - // Only refresh when window is focused to prevent queue buildup when tabbed out - context.subscriptions.push( - vscode.window.onDidChangeWindowState(e => { - if (e.focused) { - // Window gained focus - refresh immediately, then start interval - refreshTask(); - startRefreshInterval(); - } else { - // Window lost focus - stop the interval to prevent queue buildup - stopRefreshInterval(); - } - }) - ); - - // Start the interval if window is already focused - if (vscode.window.state.focused) { - startRefreshInterval(); - } - - context.subscriptions.push({ dispose: () => stopRefreshInterval() }); } export function deactivate() { diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts deleted file mode 100644 index f7ad4eea2..000000000 --- a/src/helpers/utils.ts +++ /dev/null @@ -1,550 +0,0 @@ -import * as vscode from "vscode"; -import * as path from "path"; -import * as fs from "fs"; -import * as os from "os"; -import { exec } from "child_process"; -import * as net from "net"; -import { promisify } from "util"; -import { ClabLabTreeNode } from "../treeView/common"; - -const execAsync = promisify(exec); - -export function stripAnsi(input: string): string { - const esc = String.fromCharCode(27); - const escapeSeq = new RegExp( - esc + String.fromCharCode(91) + "[0-?]*[ -/]*[@-~]", - "g", - ); - const controlSeq = new RegExp(`${esc}[@-Z\\-_]`, "g"); - return input.replace(escapeSeq, "").replace(controlSeq, ""); -} - -export function stripFileName(p: string): string { - return p.substring(0, p.lastIndexOf("/")); -} - -export function getRelativeFolderPath(targetPath: string): string { - const workspacePath = vscode.workspace.workspaceFolders - ? vscode.workspace.workspaceFolders[0].uri.path - : ""; - return path.relative(workspacePath, targetPath); -} - -export function getRelLabFolderPath(labPath: string): string { - return stripFileName(getRelativeFolderPath(labPath)); -} - -/** - * Normalize a lab path by: - * 1) Handling empty input - * 2) Normalizing slashes - * 3) Expanding ~ if present - * 4) Handling relative paths - * 5) Using realpathSync if exists - */ -export function normalizeLabPath(labPath: string, singleFolderBase?: string): string { - if (!labPath) { - return labPath; - } - labPath = path.normalize(labPath); - - if (labPath.startsWith('~')) { - const homedir = os.homedir(); - const sub = labPath.replace(/^~[/\\]?/, ""); - labPath = path.normalize(path.join(homedir, sub)); - } - - let candidatePaths: string[] = []; - if (!path.isAbsolute(labPath)) { - if (singleFolderBase) { - candidatePaths.push(path.resolve(singleFolderBase, labPath)); - } - candidatePaths.push(path.resolve(process.cwd(), labPath)); - } else { - candidatePaths.push(labPath); - } - - for (const candidate of candidatePaths) { - if (fs.existsSync(candidate)) { - try { - return fs.realpathSync(candidate); - } catch { - return candidate; - } - } - } - return candidatePaths[0]; -} - -export function titleCase(str: string) { - return str[0].toLocaleUpperCase() + str.slice(1); -} - -/** - * If sudo is enabled in config, return 'sudo ', else ''. - */ -export function getSudo() { - const sudo = vscode.workspace.getConfiguration("containerlab") - .get("sudoEnabledByDefault", false) - ? "sudo " - : ""; - return sudo; -} - -/** - * Detect OrbStack by checking the kernel version from `uname -r` for "orbstack". - * (No longer relying on `/.orbstack` existence.) - */ -export function isOrbstack(): boolean { - try { - const kernel = os.release().toLowerCase(); - // If "orbstack" is in the kernel, assume OrbStack environment - return kernel.includes("orbstack"); - } catch { - return false; - } -} - -export function getUsername(): string { - let username = ""; - try { - username = os.userInfo().username; - } catch { - throw new Error( - "Could not determine user. Failed to execute command: whoami", - ); - } - return username; -} - - -export async function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, '127.0.0.1'); - server.on('listening', () => { - const address = server.address(); - server.close(); - if (typeof address === 'object' && address?.port) { - resolve(address.port); - } else { - reject(new Error('Could not get free port')); - } - }); - server.on('error', reject); - }); -} - -// Get the config, set the default to undefined as all defaults **SHOULD** be set in package.json -export function getConfig(relCfgPath: string): any { - return vscode.workspace.getConfiguration("containerlab").get(relCfgPath, undefined); -} - -// ---------------------------------------------------------- -// Containerlab helper functions -// ---------------------------------------------------------- - -/** - * Log info messages to the output channel. - */ -function log(message: string, channel: vscode.LogOutputChannel) { - channel.info(message); -} - -/** - * Replaces any " with \", so that we can safely wrap the entire string in quotes. - */ -function escapeDoubleQuotes(input: string): string { - return input.replace(/"/g, '\\"'); -} - -async function runAndLog( - cmd: string, - description: string, - outputChannel: vscode.LogOutputChannel, - returnOutput: boolean, - includeStderr: boolean -): Promise { - const { stdout: cmdOut, stderr: cmdErr } = await execAsync(cmd); - if (cmdOut) outputChannel.info(cmdOut); - if (cmdErr) outputChannel.warn(`[${description} stderr]: ${cmdErr}`); - const combined = includeStderr && returnOutput - ? [cmdOut, cmdErr].filter(Boolean).join("\n") - : cmdOut; - return returnOutput ? combined : undefined; -} - -async function tryRunAsGroupMember( - command: string, - description: string, - outputChannel: vscode.LogOutputChannel, - returnOutput: boolean, - includeStderr: boolean, - groupsToCheck: string[] -): Promise { - try { - const { stdout } = await execAsync("id -nG"); - const groups = stdout.split(/\s+/); - for (const grp of groupsToCheck) { - if (groups.includes(grp)) { - log(`User is in "${grp}". Running without sudo: ${command}`, outputChannel); - const result = await runAndLog( - command, - description, - outputChannel, - returnOutput, - includeStderr - ); - return returnOutput ? (result as string) : ""; - } - } - } catch (err) { - log(`Failed to check user groups: ${err}`, outputChannel); - } - return undefined; -} - -async function hasPasswordlessSudo(checkType: 'generic' | 'containerlab' | 'docker'): Promise { - let checkCommand: string; - if (checkType === 'containerlab') { - checkCommand = "sudo -n containerlab version >/dev/null 2>&1 && echo true || echo false"; - } else if (checkType === 'docker') { - checkCommand = "sudo -n docker ps >/dev/null 2>&1 && echo true || echo false"; - } else { - checkCommand = "sudo -n true"; - } - try { - await execAsync(checkCommand); - return true; - } catch { - return false; - } -} - -async function runWithPasswordless( - command: string, - description: string, - outputChannel: vscode.LogOutputChannel, - returnOutput: boolean, - includeStderr: boolean -): Promise { - log(`Passwordless sudo available. Trying with -E: ${command}`, outputChannel); - const escapedCommand = escapeDoubleQuotes(command); - const cmdToRun = `sudo -E bash -c "${escapedCommand}"`; - try { - return await runAndLog(cmdToRun, description, outputChannel, returnOutput, includeStderr); - } catch (err) { - throw new Error(`Command failed: ${cmdToRun}\n${(err as Error).message}`); - } -} - -async function runWithPasswordPrompt( - command: string, - description: string, - outputChannel: vscode.LogOutputChannel, - returnOutput: boolean, - includeStderr: boolean -): Promise { - log( - `Passwordless sudo not available for "${description}". Prompting for password.`, - outputChannel - ); - const shouldProceed = await vscode.window.showWarningMessage( - `The command "${description}" requires sudo privileges. Proceed?`, - { modal: true }, - 'Yes' - ); - if (shouldProceed !== 'Yes') { - throw new Error(`User cancelled sudo password prompt for: ${description}`); - } - - const password = await vscode.window.showInputBox({ - prompt: `Enter sudo password for: ${description}`, - password: true, - ignoreFocusOut: true - }); - if (!password) { - throw new Error(`No sudo password provided for: ${description}`); - } - - log(`Executing command with sudo and provided password: ${command}`, outputChannel); - const escapedCommand = escapeDoubleQuotes(command); - const cmdToRun = `echo '${password}' | sudo -S -E bash -c "${escapedCommand}"`; - try { - return await runAndLog(cmdToRun, description, outputChannel, returnOutput, includeStderr); - } catch (err) { - throw new Error( - `Command failed: runWithSudo [non-passwordless]\n${(err as Error).message}` - ); - } -} - -/** - * Runs a command, checking for these possibilities in order: - * 1) If sudo is not forced and the user belongs to an allowed group - * ("clab_admins"/"docker" for containerlab or "docker" for docker), run it directly. - * 2) If passwordless sudo is available, run with "sudo -E". - * 3) Otherwise, prompt the user for their sudo password and run with it. - * - * If `returnOutput` is true, the function returns the command’s stdout as a string. - */ -export async function runWithSudo( - command: string, - description: string, - outputChannel: vscode.LogOutputChannel, - checkType: 'generic' | 'containerlab' | 'docker' = 'containerlab', - returnOutput: boolean = false, - includeStderr: boolean = false -): Promise { - // Get forced sudo setting from user configuration. - // If the user has enabled "always use sudo" then getSudo() will return a non-empty string. - const forcedSudo = getSudo(); - - if (forcedSudo === "") { - if (checkType === 'containerlab') { - const direct = await tryRunAsGroupMember( - command, - description, - outputChannel, - returnOutput, - includeStderr, - ['clab_admins', 'docker'] - ); - if (typeof direct !== 'undefined') { - return direct; - } - } else if (checkType === 'docker') { - const direct = await tryRunAsGroupMember( - command, - description, - outputChannel, - returnOutput, - includeStderr, - ['docker'] - ); - if (typeof direct !== 'undefined') { - return direct; - } - } - } - - if (await hasPasswordlessSudo(checkType)) { - return runWithPasswordless( - command, - description, - outputChannel, - returnOutput, - includeStderr - ); - } - - return runWithPasswordPrompt( - command, - description, - outputChannel, - returnOutput, - includeStderr - ); -} - -/** - * Installs containerlab using the official installer script, via sudo. - */ -export async function installContainerlab(outputChannel: vscode.LogOutputChannel): Promise { - log(`Installing containerlab...`, outputChannel); - const installerCmd = `curl -sL https://containerlab.dev/setup | bash -s "all"`; - await runWithSudo(installerCmd, 'Installing containerlab', outputChannel, 'generic'); -} - -/** - * Returns true if containerlab is already present on PATH. - */ -export async function isClabInstalled(outputChannel: vscode.LogOutputChannel): Promise { - log(`Checking "which containerlab" to verify installation...`, outputChannel); - try { - const { stdout } = await execAsync('which containerlab'); - const installed = Boolean(stdout && stdout.trim().length > 0); - if (!installed) { - log('containerlab not found on PATH.', outputChannel); - } - return installed; - } catch (err: any) { - log(`Error while checking for containerlab: ${err?.message ?? err}`, outputChannel); - return false; - } -} - -/** - * Ensures containerlab is installed by running "which containerlab". - * If not found, offers to install it. - */ -export async function ensureClabInstalled(outputChannel: vscode.LogOutputChannel): Promise { - const clabInstalled = await isClabInstalled(outputChannel); - if (clabInstalled) { - log(`containerlab is already installed.`, outputChannel); - return true; - } - - log(`containerlab is not installed. Prompting user for installation.`, outputChannel); - const installAction = 'Install containerlab'; - const cancelAction = 'No'; - const chosen = await vscode.window.showWarningMessage( - 'Containerlab is not installed. Would you like to install it now?', - installAction, - cancelAction - ); - if (chosen !== installAction) { - log('User declined containerlab installation.', outputChannel); - return false; - } - try { - await installContainerlab(outputChannel); - // Verify the installation once more. - if (await isClabInstalled(outputChannel)) { - vscode.window.showInformationMessage('Containerlab installed successfully!'); - log(`containerlab installed successfully.`, outputChannel); - return true; - } - throw new Error('containerlab installation failed; command not found after installation.'); - } catch (installErr: any) { - vscode.window.showErrorMessage(`Failed to install containerlab:\n${installErr.message}`); - log(`Failed to install containerlab: ${installErr}`, outputChannel); - return false; - } -} - -/** - * Checks if containerlab is up to date, and if not, prompts the user to update it. - * This version uses runWithSudo to execute the version check only once. - */ -export async function checkAndUpdateClabIfNeeded( - outputChannel: vscode.LogOutputChannel, - context: vscode.ExtensionContext -): Promise { - try { - log('Running "containerlab version check".', outputChannel); - // Run the version check via runWithSudo and capture output. - const versionOutputRaw = await runWithSudo( - 'containerlab version check', - 'containerlab version check', - outputChannel, - 'containerlab', - true - ); - const versionOutput = (versionOutputRaw || "").trim(); - if (!versionOutput) { - throw new Error('No output from containerlab version check command.'); - } - - if (versionOutput.includes("Version check timed out")) { - log("Version check timed out. Skipping update check.", outputChannel); - return; - } - - // Register update command if there's a new version - if (versionOutput.includes("newer containerlab version") || versionOutput.includes("version:")) { - // Register command for performing the update - const updateCommandId = 'containerlab.updateClab'; - context.subscriptions.push( - vscode.commands.registerCommand(updateCommandId, async () => { - try { - await runWithSudo('containerlab version upgrade', 'Upgrading containerlab', outputChannel, 'generic'); - vscode.window.showInformationMessage('Containerlab updated successfully!'); - log('Containerlab updated successfully.', outputChannel); - } catch (err: any) { - vscode.window.showErrorMessage(`Update failed: ${err.message}`); - } - }) - ); - - // Show non-modal notification with options - vscode.window.showInformationMessage( - versionOutput, - 'Update Now', - 'View Release Notes', - 'Dismiss' - ).then(selection => { - if (selection === 'Update Now') { - vscode.commands.executeCommand(updateCommandId); - } else if (selection === 'View Release Notes') { - const urlRegex = /(https?:\/\/\S+)/; - const m = urlRegex.exec(versionOutput); - if (m) { - vscode.env.openExternal(vscode.Uri.parse(m[1])); - } else { - vscode.window.showInformationMessage("No release notes URL found."); - } - } - // For 'Dismiss' we do nothing - }); - } else { - log("Containerlab is up to date.", outputChannel); - } - } catch (err: any) { - log(`containerlab version check failed: ${err.message}`, outputChannel); - vscode.window.showErrorMessage( - 'Unable to detect containerlab version. Please check your installation.' - ); - } -} - -// ---------------------------------------------------------- -// Command helper functions -// ---------------------------------------------------------- - -export async function getSelectedLabNode(node?: ClabLabTreeNode): Promise { - if (node) { - return node; - } - - // Try to get from tree selection - const { localTreeView, runningTreeView } = await import("../extension"); - - // Try running tree first - if (runningTreeView && runningTreeView.selection.length > 0) { - const selected = runningTreeView.selection[0]; - if (selected instanceof ClabLabTreeNode) { - return selected; - } - } - - // Then try local tree - if (localTreeView && localTreeView.selection.length > 0) { - const selected = localTreeView.selection[0]; - if (selected instanceof ClabLabTreeNode) { - return selected; - } - } - - return undefined; -} - -// Sanitizes a string to a Docker-safe container name. -// Rules: only [A-Za-z0-9_.-], must start with alnum, no trailing '.'/'-'. -export function sanitize( - raw: string, - { maxLen = 128, lower = false }: { maxLen?: number; lower?: boolean } = {}, -): string { - if (!raw) return "container"; - - // Replace all disallowed characters (including "/") with "-" - let out = raw.replace(/[^A-Za-z0-9_.-]+/g, "-"); - - // Remove leading or trailing separators - while (out.startsWith("-") || out.startsWith(".")) out = out.substring(1); - while (out.endsWith("-") || out.endsWith(".")) out = out.slice(0, -1); - - // Ensure the name starts with an alphanumeric character - if (!/^[A-Za-z0-9]/.test(out)) { - out = `c-${out}`; - } - - // Enforce maximum length and trim any trailing separators again - if (out.length > maxLen) { - out = out.slice(0, maxLen); - while (out.endsWith("-") || out.endsWith(".")) out = out.slice(0, -1); - } - - if (!out) out = "container"; - return lower ? out.toLowerCase() : out; -} diff --git a/src/services/containerlabEvents.ts b/src/services/containerlabEvents.ts new file mode 100644 index 000000000..2551d39d2 --- /dev/null +++ b/src/services/containerlabEvents.ts @@ -0,0 +1,1138 @@ +import { spawn, ChildProcess } from "child_process"; +import * as readline from "readline"; +import * as vscode from "vscode"; +import type { ClabDetailedJSON } from "../treeView/common"; +import type { ClabInterfaceSnapshot, ClabInterfaceSnapshotEntry } from "../types/containerlab"; +import { containerlabBinaryPath } from "../extension"; + +interface ContainerlabEvent { + timestamp?: string; + type: string; + action: string; + actor_id: string; + actor_name?: string; + actor_full_id?: string; + attributes?: Record; +} + +interface ContainerRecord { + labName: string; + topoFile?: string; + data: ClabDetailedJSON; +} + +interface InterfaceRecord { + ifname: string; + type: string; + state: string; + alias?: string; + mac?: string; + mtu?: number; + ifindex?: number; + rxBps?: number; + rxPps?: number; + rxBytes?: number; + rxPackets?: number; + txBps?: number; + txPps?: number; + txBytes?: number; + txPackets?: number; + statsIntervalSeconds?: number; +} + +const INTERFACE_KEYS: (keyof InterfaceRecord)[] = [ + "ifname", + "type", + "state", + "alias", + "mac", + "mtu", + "ifindex", + "rxBps", + "rxPps", + "rxBytes", + "rxPackets", + "txBps", + "txPps", + "txBytes", + "txPackets", + "statsIntervalSeconds", +]; + +type MutableInterfaceRecord = InterfaceRecord & { [key: string]: unknown }; +type MutableSnapshotEntry = ClabInterfaceSnapshotEntry & { [key: string]: unknown }; + +const STRING_ATTRIBUTE_MAPPINGS: Array<[keyof InterfaceRecord, string]> = [ + ["type", "type"], + ["state", "state"], + ["alias", "alias"], + ["mac", "mac"], +]; + +const NUMERIC_ATTRIBUTE_MAPPINGS: Array<[keyof InterfaceRecord, string]> = [ + ["mtu", "mtu"], + ["ifindex", "index"], + ["rxBps", "rx_bps"], + ["txBps", "tx_bps"], + ["rxPps", "rx_pps"], + ["txPps", "tx_pps"], + ["rxBytes", "rx_bytes"], + ["txBytes", "tx_bytes"], + ["rxPackets", "rx_packets"], + ["txPackets", "tx_packets"], + ["statsIntervalSeconds", "interval_seconds"], +]; + +const SNAPSHOT_FIELD_MAPPINGS: Array<[keyof ClabInterfaceSnapshotEntry, keyof InterfaceRecord]> = [ + ["rxBps", "rxBps"], + ["rxPps", "rxPps"], + ["rxBytes", "rxBytes"], + ["rxPackets", "rxPackets"], + ["txBps", "txBps"], + ["txPps", "txPps"], + ["txBytes", "txBytes"], + ["txPackets", "txPackets"], + ["statsIntervalSeconds", "statsIntervalSeconds"], +]; + +function parseNumericAttribute(value: unknown): number | undefined { + if (value === undefined || value === null || value === "") { + return undefined; + } + + const numeric = typeof value === "number" ? value : Number(value); + return Number.isFinite(numeric) ? numeric : undefined; +} + +function interfaceRecordsEqual(a: InterfaceRecord | undefined, b: InterfaceRecord): boolean { + if (!a) { + return false; + } + + return INTERFACE_KEYS.every(key => a[key] === b[key]); +} + +function assignStringAttributes( + record: MutableInterfaceRecord, + attributes: Record, + mappings: Array<[keyof InterfaceRecord, string]> +): void { + for (const [targetKey, attributeKey] of mappings) { + const value = attributes[attributeKey]; + if (typeof value === "string") { + record[targetKey as string] = value; + } + } +} + +function assignNumericAttributes( + record: MutableInterfaceRecord, + attributes: Record, + mappings: Array<[keyof InterfaceRecord, string]> +): void { + for (const [targetKey, attributeKey] of mappings) { + const parsed = parseNumericAttribute(attributes[attributeKey]); + if (parsed !== undefined) { + record[targetKey as string] = parsed; + } + } +} + +function buildUpdatedInterfaceRecord( + ifaceName: string, + attributes: Record, + existing: InterfaceRecord | undefined +): InterfaceRecord { + const base: MutableInterfaceRecord = existing + ? { ...existing } + : { + ifname: ifaceName, + type: "", + state: "", + }; + + base.ifname = ifaceName; + + assignStringAttributes(base, attributes, STRING_ATTRIBUTE_MAPPINGS); + assignNumericAttributes(base, attributes, NUMERIC_ATTRIBUTE_MAPPINGS); + + if (typeof base.type !== "string" || !base.type) { + base.type = ""; + } + if (typeof base.state !== "string" || !base.state) { + base.state = ""; + } + + return base as InterfaceRecord; +} + +function assignSnapshotFields(entry: MutableSnapshotEntry, iface: InterfaceRecord): void { + for (const [entryKey, ifaceKey] of SNAPSHOT_FIELD_MAPPINGS) { + const value = iface[ifaceKey]; + if (value !== undefined) { + entry[entryKey as string] = value as number; + } + } +} + +function toInterfaceSnapshotEntry(iface: InterfaceRecord): ClabInterfaceSnapshotEntry { + const entry: MutableSnapshotEntry = { + name: iface.ifname, + type: iface.type || "", + state: iface.state || "", + alias: iface.alias || "", + mac: iface.mac || "", + mtu: iface.mtu ?? 0, + ifindex: iface.ifindex ?? 0, + }; + + assignSnapshotFields(entry, iface); + + return entry as ClabInterfaceSnapshotEntry; +} + +interface NodeSnapshot { + ipv4?: string; + ipv4Prefix?: number; + ipv6?: string; + ipv6Prefix?: number; + startedAt?: number; +} + +interface LabRecord { + topoFile?: string; + containers: Map; +} + +const INITIAL_IDLE_TIMEOUT_MS = 250; +const INITIAL_FALLBACK_TIMEOUT_MS = 500; + +let currentRuntime: string | undefined; +let child: ChildProcess | null = null; +let stdoutInterface: readline.Interface | null = null; +let initialLoadComplete = false; +let initialLoadPromise: Promise | null = null; +let resolveInitialLoad: (() => void) | null = null; +let idleTimer: ReturnType | null = null; +let fallbackTimer: ReturnType | null = null; + +/* eslint-disable-next-line no-unused-vars */ +type RejectInitialLoad = (error: Error) => void; +let rejectInitialLoad: RejectInitialLoad | null = null; + +const containersById = new Map(); +const labsByName = new Map(); +const interfacesByContainer = new Map>(); +const interfaceVersions = new Map(); +const nodeSnapshots = new Map(); +type DataListener = () => void; +/* eslint-disable-next-line no-unused-vars */ +type ContainerStateChangedListener = (containerShortId: string, newState: string) => void; +const dataListeners = new Set(); +const containerStateChangedListeners = new Set(); +let dataChangedTimer: ReturnType | null = null; +const DATA_NOTIFY_DELAY_MS = 50; + +function scheduleDataChanged(): void { + if (dataListeners.size === 0) { + return; + } + if (dataChangedTimer) { + return; + } + dataChangedTimer = setTimeout(() => { + dataChangedTimer = null; + for (const listener of Array.from(dataListeners)) { + try { + listener(); + } catch (err) { + console.error(`[containerlabEvents]: Failed to notify listener: ${err instanceof Error ? err.message : String(err)}`); + } + } + }, DATA_NOTIFY_DELAY_MS); +} + +function notifyContainerStateChanged(containerShortId: string, newState: string): void { + if (containerStateChangedListeners.size === 0) { + return; + } + for (const listener of Array.from(containerStateChangedListeners)) { + try { + listener(containerShortId, newState); + } catch (err) { + console.error(`[containerlabEvents]: Failed to notify state change listener: ${err instanceof Error ? err.message : String(err)}`); + } + } +} + +function scheduleInitialResolution(): void { + if (initialLoadComplete) { + return; + } + + if (idleTimer) { + clearTimeout(idleTimer); + } + + idleTimer = setTimeout(() => finalizeInitialLoad(), INITIAL_IDLE_TIMEOUT_MS); +} + +function finalizeInitialLoad(error?: Error): void { + if (initialLoadComplete) { + return; + } + + initialLoadComplete = true; + + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + if (fallbackTimer) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } + + if (error) { + if (rejectInitialLoad) { + rejectInitialLoad(error); + } + return; + } + + if (resolveInitialLoad) { + resolveInitialLoad(); + } +} + +function stopProcess(): void { + if (stdoutInterface) { + stdoutInterface.removeAllListeners(); + stdoutInterface.close(); + stdoutInterface = null; + } + + if (child) { + child.removeAllListeners(); + try { + child.kill(); + } catch { + // ignore errors during shutdown + } + child = null; + } + + initialLoadComplete = false; + initialLoadPromise = null; + resolveInitialLoad = null; + rejectInitialLoad = null; + + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + if (fallbackTimer) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } +} + +function parseCidr(value?: string): { address?: string; prefixLength?: number } { + if (!value || typeof value !== "string") { + return {}; + } + const parts = value.split("/"); + if (parts.length === 2) { + const prefix = Number(parts[1]); + return { + address: parts[0], + prefixLength: Number.isFinite(prefix) ? prefix : undefined, + }; + } + return { address: value }; +} + +function resolveLabName(attributes: Record): string { + return attributes.containerlab || attributes.lab || "unknown"; +} + +function resolveContainerIds(event: ContainerlabEvent, attributes: Record): { id: string; shortId: string } { + const fullId = attributes.id || event.actor_full_id || ""; + const shortFromEvent = event.actor_id || ""; + const shortId = shortFromEvent || (fullId ? fullId.slice(0, 12) : ""); + const id = fullId || shortId; + return { id, shortId }; +} + +function resolveNames(event: ContainerlabEvent, attributes: Record): { name: string; nodeName: string } { + const name = attributes.name || attributes["clab-node-longname"] || event.actor_name || ""; + const nodeName = attributes["clab-node-name"] || name; + return { name, nodeName }; +} + +function resolveImage(attributes: Record): string { + return attributes.image || attributes["org.opencontainers.image.ref.name"] || ""; +} + +function buildLabels( + attributes: Record, + labName: string, + name: string, + nodeName: string, + topoFile?: string, +): ClabDetailedJSON["Labels"] { + const labels: ClabDetailedJSON["Labels"] = { + "clab-node-kind": attributes["clab-node-kind"] || "", + "clab-node-lab-dir": attributes["clab-node-lab-dir"] || "", + "clab-node-longname": attributes["clab-node-longname"] || name, + "clab-node-name": nodeName, + "clab-owner": attributes["clab-owner"] || "", + "clab-topo-file": topoFile || "", + containerlab: labName, + }; + + if (attributes["clab-node-type"]) { + labels["clab-node-type"] = attributes["clab-node-type"]; + } + if (attributes["clab-node-group"]) { + labels["clab-node-group"] = attributes["clab-node-group"]; + } + return labels; +} + +function buildNetworkSettings(attributes: Record): ClabDetailedJSON["NetworkSettings"] { + const ipv4 = parseCidr(attributes.mgmt_ipv4); + const ipv6 = parseCidr(attributes.mgmt_ipv6); + return { + IPv4addr: ipv4.address, + IPv4pLen: ipv4.prefixLength, + IPv6addr: ipv6.address, + IPv6pLen: ipv6.prefixLength, + }; +} + +function resolveNetworkName(attributes: Record): string | undefined { + return attributes.network || attributes["clab-mgmt-net-bridge"]; +} + +function toOptionalNumber(value: unknown): number | undefined { + if (value === undefined || value === null) { + return undefined; + } + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : undefined; +} + +function toClabDetailed(event: ContainerlabEvent): ContainerRecord | undefined { + const attributes = event.attributes ?? {}; + + const labName = resolveLabName(attributes); + const topoFile: string | undefined = attributes["clab-topo-file"]; + const { id, shortId } = resolveContainerIds(event, attributes); + const { name, nodeName } = resolveNames(event, attributes); + const image = resolveImage(attributes); + const state = attributes.state || deriveStateFromAction(event.action); + const status = attributes.status ?? ""; + const labels = buildLabels(attributes, labName, name, nodeName, topoFile); + const networkSettings = buildNetworkSettings(attributes); + const networkName = resolveNetworkName(attributes); + + const detailed: ClabDetailedJSON = { + Names: name ? [name] : [], + ID: id, + ShortID: shortId, + Image: image, + State: state, + Status: status, + Labels: labels, + NetworkSettings: networkSettings, + Mounts: [], + Ports: [], + Pid: toOptionalNumber(attributes.pid), + NetworkName: networkName, + }; + + return { labName, topoFile, data: detailed }; +} + +function deriveStateFromAction(action: string): string { + switch (action) { + case "die": + case "kill": + case "destroy": + case "stop": + return "exited"; + case "pause": + return "paused"; + case "unpause": + return "running"; + case "start": + case "restart": + case "running": + return "running"; + case "create": + case "create: container": + return "created"; + default: + return action; + } +} + +function isExecAction(action: string | undefined): boolean { + if (!action) { + return false; + } + return action.startsWith("exec"); +} + +function shouldRemoveContainer(action: string): boolean { + // Keep containers in the tree when they stop or exit so users can still + // interact with them until they are actually removed. + return action === "destroy"; +} + +function mergeContainerRecord( + existing: ContainerRecord | undefined, + incoming: ContainerRecord, + action: string, +): ContainerRecord { + if (!existing) { + return incoming; + } + + return { + labName: resolveLabNameForMerge(existing, incoming), + topoFile: resolveTopoFileForMerge(existing, incoming), + data: mergeContainerData(existing, incoming, action), + }; +} + +function resolveLabNameForMerge(existing: ContainerRecord, incoming: ContainerRecord): string { + if ((!incoming.labName || incoming.labName === "unknown") && existing.labName) { + return existing.labName; + } + return incoming.labName; +} + +function resolveTopoFileForMerge(existing: ContainerRecord, incoming: ContainerRecord): string | undefined { + return incoming.topoFile || existing.topoFile; +} + +function mergeContainerData( + existing: ContainerRecord, + incoming: ContainerRecord, + action: string, +): ClabDetailedJSON { + const previousData = existing.data; + const nextData = incoming.data; + + const mergedNetwork = mergeNetworkSettings(previousData.NetworkSettings, nextData.NetworkSettings); + + const merged: ClabDetailedJSON = { + ...nextData, + Labels: { ...previousData.Labels, ...nextData.Labels }, + NetworkSettings: mergedNetwork, + Status: resolveStatusValue(nextData.Status, previousData.Status, action), + Image: pickNonEmpty(nextData.Image, previousData.Image), + State: resolveStateValue(nextData.State, previousData.State, action), + }; + + if (nextData.StartedAt !== undefined || previousData.StartedAt !== undefined) { + merged.StartedAt = nextData.StartedAt ?? previousData.StartedAt; + } + + if (!merged.NetworkName && previousData.NetworkName) { + merged.NetworkName = previousData.NetworkName; + } + + return merged; +} + +function mergeNetworkSettings( + previous: ClabDetailedJSON["NetworkSettings"], + next: ClabDetailedJSON["NetworkSettings"], +): ClabDetailedJSON["NetworkSettings"] { + const merged = { ...next }; + + if (!merged.IPv4addr && previous.IPv4addr) { + merged.IPv4addr = previous.IPv4addr; + merged.IPv4pLen = previous.IPv4pLen; + } + if (!merged.IPv6addr && previous.IPv6addr) { + merged.IPv6addr = previous.IPv6addr; + merged.IPv6pLen = previous.IPv6pLen; + } + + return merged; +} + +function resolveStatusValue(current: string, fallback: string | undefined, action: string): string { + if (shouldResetLifecycleStatus(action)) { + return current || ""; + } + return pickNonEmpty(current, fallback); +} + +function shouldResetLifecycleStatus(action: string): boolean { + switch (action) { + case "create": + case "start": + case "running": + case "restart": + case "pause": + case "unpause": + case "stop": + case "kill": + case "die": + return true; + default: + return false; + } +} + +function pickNonEmpty(current: string, fallback?: string): string { + if (current && current.trim().length > 0) { + return current; + } + return fallback ?? current; +} + +function resolveStateValue(current: string, fallback: string | undefined, action: string): string { + if ((!current || current === action) && fallback) { + return fallback; + } + return current; +} + +function updateLabMappings(previous: ContainerRecord | undefined, next: ContainerRecord): void { + if (previous && previous.labName !== next.labName) { + const previousLab = labsByName.get(previous.labName); + if (previousLab) { + previousLab.containers.delete(next.data.ShortID); + if (previousLab.containers.size === 0) { + labsByName.delete(previous.labName); + } + } + } + + let lab = labsByName.get(next.labName); + if (!lab) { + lab = { topoFile: next.topoFile, containers: new Map() }; + labsByName.set(next.labName, lab); + } + if (next.topoFile) { + lab.topoFile = next.topoFile; + } + lab.containers.set(next.data.ShortID, next.data); +} + +function makeNodeSnapshotKey(record: ContainerRecord): string | undefined { + const labels = record.data.Labels; + const nodeName = labels["clab-node-name"] || labels["clab-node-longname"] || record.data.Names[0]; + if (!nodeName) { + return undefined; + } + const lab = record.labName || labels.containerlab || "unknown"; + return `${lab}::${nodeName}`.toLowerCase(); +} + +function applyNodeSnapshot(record: ContainerRecord): ContainerRecord { + const key = makeNodeSnapshotKey(record); + if (!key) { + return record; + } + const snapshot = nodeSnapshots.get(key); + if (!snapshot) { + return record; + } + + const settings = record.data.NetworkSettings; + if (!settings.IPv4addr && snapshot.ipv4) { + settings.IPv4addr = snapshot.ipv4; + settings.IPv4pLen = snapshot.ipv4Prefix; + } + if (!settings.IPv6addr && snapshot.ipv6) { + settings.IPv6addr = snapshot.ipv6; + settings.IPv6pLen = snapshot.ipv6Prefix; + } + + if (record.data.State === "running") { + record.data.StartedAt = snapshot.startedAt; + if (!hasNonEmptyStatus(record.data.Status)) { + record.data.Status = "Running"; + } + } else { + record.data.StartedAt = undefined; + if (!hasNonEmptyStatus(record.data.Status)) { + record.data.Status = formatStateLabel(record.data.State); + } + } + + return record; +} + +function estimateStartedAtFromStatus(status: string | undefined, eventTimestamp?: number): number | undefined { + if (!status) { + return undefined; + } + + const trimmed = status.trim(); + if (!trimmed.toLowerCase().startsWith("up ")) { + return undefined; + } + + let withoutSuffix = trimmed; + const lastOpen = trimmed.lastIndexOf("("); + const hasClosing = trimmed.endsWith(")"); + if (lastOpen !== -1 && hasClosing) { + withoutSuffix = trimmed.slice(0, lastOpen).trimEnd(); + } + + const durationText = withoutSuffix.slice(2).trim(); + + const tokens = durationText.split(" ").filter(Boolean); + let totalSeconds = 0; + let matched = false; + + let index = 0; + while (index < tokens.length - 1) { + const value = Number(tokens[index]); + if (!Number.isFinite(value)) { + index += 1; + continue; + } + const unitToken = tokens[index + 1]; + if (!unitToken) { + break; + } + const unitSeconds = toDurationSeconds(unitToken); + if (unitSeconds === 0) { + index += 1; + continue; + } + totalSeconds += value * unitSeconds; + matched = true; + index += 2; + } + + if (!matched || totalSeconds <= 0) { + return undefined; + } + + const reference = eventTimestamp ?? Date.now(); + const estimated = reference - (totalSeconds * 1000); + return estimated > 0 ? estimated : 0; +} + +function toDurationSeconds(unit: string): number { + let normalized = unit.toLowerCase().replace(/[^a-z]/g, ""); + if (normalized.endsWith("s")) { + normalized = normalized.slice(0, -1); + } + if (normalized === "mins") { + normalized = "min"; + } + if (normalized === "hrs") { + normalized = "hour"; + } + switch (normalized) { + case "second": + return 1; + case "minute": + case "min": + return 60; + case "hour": + return 3600; + case "day": + return 86400; + default: + return 0; + } +} + +function updateNodeSnapshot(record: ContainerRecord, eventTimestamp?: number, action?: string): void { + const key = makeNodeSnapshotKey(record); + if (!key) { + return; + } + + const settings = record.data.NetworkSettings; + const snapshot = nodeSnapshots.get(key) ?? {}; + + if (settings.IPv4addr) { + snapshot.ipv4 = settings.IPv4addr; + snapshot.ipv4Prefix = settings.IPv4pLen; + } + + if (settings.IPv6addr) { + snapshot.ipv6 = settings.IPv6addr; + snapshot.ipv6Prefix = settings.IPv6pLen; + } + + if (record.data.State === "running") { + const estimatedStart = estimateStartedAtFromStatus(record.data.Status, eventTimestamp); + if (estimatedStart !== undefined) { + snapshot.startedAt = estimatedStart; + } else if (shouldResetLifecycleStatus(action ?? "") || snapshot.startedAt === undefined) { + snapshot.startedAt = resolveStartTimestamp(eventTimestamp, snapshot.startedAt); + } + } else { + snapshot.startedAt = undefined; + } + + nodeSnapshots.set(key, snapshot); +} + +function clearNodeSnapshot(record: ContainerRecord): void { + const key = makeNodeSnapshotKey(record); + if (!key) { + return; + } + nodeSnapshots.delete(key); +} + +function parseEventTimestamp(timestamp?: string): number | undefined { + if (!timestamp) { + return undefined; + } + const parsed = Date.parse(timestamp); + return Number.isNaN(parsed) ? undefined : parsed; +} + +function resolveStartTimestamp(eventTimestamp?: number, current?: number): number { + if (typeof eventTimestamp === "number" && !Number.isNaN(eventTimestamp)) { + return eventTimestamp; + } + if (typeof current === "number" && !Number.isNaN(current)) { + return current; + } + return Date.now(); +} + +function hasNonEmptyStatus(value: string | undefined): boolean { + return !!(value && value.trim().length > 0); +} + +function formatStateLabel(state: string | undefined): string { + if (!state) { + return "Unknown"; + } + const normalized = state.replace(/[_-]+/g, " "); + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +function shouldMarkInterfacesDown(state: string | undefined): boolean { + if (!state) { + return true; + } + + const normalized = state.toLowerCase(); + return normalized !== "running" && normalized !== "paused"; +} + +function applyContainerEvent(event: ContainerlabEvent): void { + const action = event.action || ""; + + if (isExecAction(action)) { + return; + } + + if (shouldRemoveContainer(action)) { + removeContainer(event.actor_id); + scheduleInitialResolution(); + return; + } + + const record = toClabDetailed(event); + if (!record) { + return; + } + + const eventTimestamp = parseEventTimestamp(event.timestamp); + const existing = containersById.get(record.data.ShortID); + const oldState = existing?.data.State; + const mergedRecord = mergeContainerRecord(existing, record, action); + updateNodeSnapshot(mergedRecord, eventTimestamp, action); + const enrichedRecord = applyNodeSnapshot(mergedRecord); + const newState = enrichedRecord.data.State; + + containersById.set(enrichedRecord.data.ShortID, enrichedRecord); + updateLabMappings(existing, enrichedRecord); + + if (oldState && oldState !== newState) { + notifyContainerStateChanged(enrichedRecord.data.ShortID, newState); + } + + if (shouldMarkInterfacesDown(enrichedRecord.data.State)) { + if (markInterfacesDown(enrichedRecord.data.ShortID)) { + scheduleInitialResolution(); + scheduleDataChanged(); + return; + } + } + + scheduleInitialResolution(); + scheduleDataChanged(); +} + +function removeContainer(containerShortId: string): void { + const record = containersById.get(containerShortId); + if (!record) { + return; + } + + const lab = labsByName.get(record.labName); + if (lab) { + lab.containers.delete(containerShortId); + if (lab.containers.size === 0) { + labsByName.delete(record.labName); + } + } + + clearNodeSnapshot(record); + containersById.delete(containerShortId); + interfacesByContainer.delete(containerShortId); + interfaceVersions.delete(containerShortId); + scheduleDataChanged(); +} + +function applyInterfaceEvent(event: ContainerlabEvent): void { + const attributes = event.attributes ?? {}; + const containerId = event.actor_id; + if (!containerId) { + return; + } + + const ifaceName = typeof attributes.ifname === "string" ? attributes.ifname : undefined; + if (!ifaceName) { + return; + } + + if (event.action === "delete") { + if (removeInterfaceRecord(containerId, ifaceName)) { + bumpInterfaceVersion(containerId); + scheduleInitialResolution(); + scheduleDataChanged(); + } + return; + } + + if (ifaceName.startsWith("clab-")) { + removeInterfaceRecord(containerId, ifaceName); + return; + } + + let ifaceMap = interfacesByContainer.get(containerId); + if (!ifaceMap) { + ifaceMap = new Map(); + interfacesByContainer.set(containerId, ifaceMap); + } + const existing = ifaceMap.get(ifaceName); + const updated = buildUpdatedInterfaceRecord(ifaceName, attributes, existing); + + const changed = !interfaceRecordsEqual(existing, updated); + if (!changed) { + return; + } + + ifaceMap.set(ifaceName, updated); + + bumpInterfaceVersion(containerId); + scheduleInitialResolution(); + scheduleDataChanged(); +} + +function removeInterfaceRecord(containerId: string, ifaceName: string): boolean { + const ifaceMap = interfacesByContainer.get(containerId); + if (!ifaceMap) { + return false; + } + + const removed = ifaceMap.delete(ifaceName); + if (ifaceMap.size === 0) { + interfacesByContainer.delete(containerId); + } + return removed; +} + +function bumpInterfaceVersion(containerId: string): void { + const next = (interfaceVersions.get(containerId) ?? 0) + 1; + interfaceVersions.set(containerId, next); +} + +function markInterfacesDown(containerId: string): boolean { + const ifaceMap = interfacesByContainer.get(containerId); + if (!ifaceMap || ifaceMap.size === 0) { + return false; + } + + // When a container goes down, clear all interfaces + interfacesByContainer.delete(containerId); + bumpInterfaceVersion(containerId); + + return true; +} + +function handleEventLine(line: string): void { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + + try { + const event = JSON.parse(trimmed) as ContainerlabEvent; + if (event.type === "container") { + applyContainerEvent(event); + } else if (event.type === "interface") { + applyInterfaceEvent(event); + } + } catch (err) { + console.error(`[containerlabEvents]: Failed to parse event line: ${err instanceof Error ? err.message : String(err)}`); + } +} + +function startProcess(runtime: string): void { + currentRuntime = runtime; + initialLoadComplete = false; + + initialLoadPromise = new Promise((resolve, reject) => { + resolveInitialLoad = resolve; + rejectInitialLoad = reject; + }); + + const config = vscode.workspace.getConfiguration("containerlab"); + const enableInterfaceStats = config.get("enableInterfaceStats", true); + + const containerlabBinary = containerlabBinaryPath + const baseArgs = ["events", "--format", "json", "--initial-state"]; + + // Only add --interface-stats if enabled in settings + if (enableInterfaceStats) { + baseArgs.push("--interface-stats"); + } + + if (runtime) { + baseArgs.splice(1, 0, "-r", runtime); + } + + const spawned = spawn(containerlabBinary, baseArgs, { stdio: ["ignore", "pipe", "pipe"] }); + child = spawned; + + if (!spawned.stdout) { + finalizeInitialLoad(new Error("Failed to start containerlab events process")); + return; + } + + stdoutInterface = readline.createInterface({ input: spawned.stdout }); + stdoutInterface.on("line", handleEventLine); + + spawned.stderr?.on("data", chunk => { + console.warn(`[containerlabEvents]: stderr: ${chunk}`); + }); + + spawned.on("error", err => { + finalizeInitialLoad(err instanceof Error ? err : new Error(String(err))); + stopProcess(); + }); + + spawned.on("exit", (code, signal) => { + if (!initialLoadComplete) { + const message = `containerlab events exited prematurely (code=${code}, signal=${signal ?? ""})`; + finalizeInitialLoad(new Error(message)); + } + stopProcess(); + }); + + if (fallbackTimer) { + clearTimeout(fallbackTimer); + } + fallbackTimer = setTimeout(() => finalizeInitialLoad(), INITIAL_FALLBACK_TIMEOUT_MS); +} + +export async function ensureEventStream(runtime: string): Promise { + if (child && currentRuntime === runtime) { + if (initialLoadComplete) { + return; + } + if (initialLoadPromise) { + return initialLoadPromise; + } + } + + stopProcess(); + startProcess(runtime); + + if (!initialLoadPromise) { + throw new Error("Failed to initialize containerlab events stream"); + } + return initialLoadPromise; +} + +export function getGroupedContainers(): Record { + const result: Record = {}; + + for (const [labName, lab] of labsByName.entries()) { + const containers = Array.from(lab.containers.values()).map(container => ({ + ...container, + Names: [...container.Names], + Labels: { ...container.Labels }, + NetworkSettings: { ...container.NetworkSettings }, + Mounts: container.Mounts?.map(mount => ({ ...mount })) ?? [], + Ports: container.Ports?.map(port => ({ ...port })) ?? [], + })); + + const arrayWithMeta = containers as unknown as ClabDetailedJSON[] & { [key: string]: unknown }; + if (lab.topoFile) { + arrayWithMeta["topo-file"] = lab.topoFile; + } + result[labName] = arrayWithMeta; + } + + return result; +} + +export function getInterfaceSnapshot(containerShortId: string, containerName: string): ClabInterfaceSnapshot[] { + const ifaceMap = interfacesByContainer.get(containerShortId); + if (!ifaceMap || ifaceMap.size === 0) { + return []; + } + + const interfaces = Array.from(ifaceMap.values()).map(toInterfaceSnapshotEntry); + + interfaces.sort((a, b) => a.name.localeCompare(b.name)); + + return [ + { + name: containerName, + interfaces, + }, + ]; +} + +export function getInterfaceVersion(containerShortId: string): number { + return interfaceVersions.get(containerShortId) ?? 0; +} + +export function resetForTests(): void { + stopProcess(); + containersById.clear(); + labsByName.clear(); + interfacesByContainer.clear(); + interfaceVersions.clear(); + nodeSnapshots.clear(); + scheduleDataChanged(); +} + +export function onDataChanged(listener: DataListener): () => void { + dataListeners.add(listener); + return () => { + dataListeners.delete(listener); + }; +} + +export function onContainerStateChanged(listener: ContainerStateChangedListener): () => void { + containerStateChangedListeners.add(listener); + return () => { + containerStateChangedListeners.delete(listener); + }; +} diff --git a/src/services/containerlabInspectFallback.ts b/src/services/containerlabInspectFallback.ts new file mode 100644 index 000000000..7a6d365de --- /dev/null +++ b/src/services/containerlabInspectFallback.ts @@ -0,0 +1,345 @@ +/** + * Fallback data provider using `containerlab inspect` polling. + * + * This module provides an alternative to the event-based system for fetching + * container and lab data. It polls containerlab inspect at configurable intervals. + * + * This is a temporary fallback that can be easily removed once the event system + * is stable and widely available. + */ + +import { promisify } from "util"; +import { exec } from "child_process"; +import { execFileSync } from "child_process"; +import * as vscode from "vscode"; +import { containerlabBinaryPath } from "../extension"; +import type { ClabDetailedJSON } from "../treeView/common"; +import type { ClabInterfaceSnapshot, ClabInterfaceSnapshotEntry } from "../types/containerlab"; + +const execAsync = promisify(exec); + +// Internal state +let rawInspectData: Record | undefined; +let pollingInterval: ReturnType | null = null; +let isPolling = false; + +// Listeners for data changes +type DataListener = () => void; +const dataListeners = new Set(); +let dataChangedTimer: ReturnType | null = null; +const DATA_NOTIFY_DELAY_MS = 50; + +// Default polling interval (ms) +const DEFAULT_POLL_INTERVAL_MS = 5000; + +function scheduleDataChanged(): void { + if (dataListeners.size === 0) { + return; + } + if (dataChangedTimer) { + return; + } + dataChangedTimer = setTimeout(() => { + dataChangedTimer = null; + for (const listener of Array.from(dataListeners)) { + try { + listener(); + } catch (err) { + console.error(`[containerlabInspectFallback]: Failed to notify listener: ${err instanceof Error ? err.message : String(err)}`); + } + } + }, DATA_NOTIFY_DELAY_MS); +} + +/** + * Fetch lab data using containerlab inspect command + */ +async function fetchInspectData(runtime: string): Promise | undefined> { + const cmd = `${containerlabBinaryPath} inspect -r ${runtime} --all --details --format json 2>/dev/null`; + + try { + const { stdout } = await execAsync(cmd); + if (!stdout) { + return undefined; + } + return JSON.parse(stdout) as Record; + } catch (err) { + console.error(`[containerlabInspectFallback]: Failed to run inspect: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } +} + +/** + * Check if containers in a lab have changed + */ +function hasLabChanged( + oldContainers: ClabDetailedJSON[], + newContainers: ClabDetailedJSON[] +): boolean { + if (oldContainers.length !== newContainers.length) { + return true; + } + + for (const newContainer of newContainers) { + const oldContainer = oldContainers.find(c => c.ShortID === newContainer.ShortID); + if (!oldContainer || oldContainer.State !== newContainer.State) { + return true; + } + } + + return false; +} + +/** + * Compare two data sets to detect changes + */ +function hasDataChanged( + oldData: Record | undefined, + newData: Record | undefined +): boolean { + if (!oldData && !newData) { + return false; + } + if (!oldData || !newData) { + return true; + } + + const oldLabs = Object.keys(oldData); + const newLabs = Object.keys(newData); + + if (oldLabs.length !== newLabs.length) { + return true; + } + + for (const labName of newLabs) { + if (!oldData[labName]) { + return true; + } + + if (hasLabChanged(oldData[labName], newData[labName])) { + return true; + } + } + + return false; +} + +/** + * Poll for updates + */ +async function pollOnce(runtime: string): Promise { + const newData = await fetchInspectData(runtime); + + if (hasDataChanged(rawInspectData, newData)) { + rawInspectData = newData; + scheduleDataChanged(); + } +} + +/** + * Start polling for lab data + */ +export function startPolling(runtime: string, intervalMs: number = DEFAULT_POLL_INTERVAL_MS): void { + if (isPolling) { + return; + } + + isPolling = true; + console.log(`[containerlabInspectFallback]: Starting polling with ${intervalMs}ms interval`); + + // Initial fetch + void pollOnce(runtime); + + // Set up polling interval + pollingInterval = setInterval(() => { + void pollOnce(runtime); + }, intervalMs); +} + +/** + * Stop polling + */ +export function stopPolling(): void { + if (pollingInterval) { + clearInterval(pollingInterval); + pollingInterval = null; + } + isPolling = false; + console.log("[containerlabInspectFallback]: Stopped polling"); +} + +/** + * Get the current grouped containers data + */ +export function getGroupedContainers(): Record { + return rawInspectData ?? {}; +} + +/** + * Force an immediate update + */ +export async function forceUpdate(runtime: string): Promise { + const newData = await fetchInspectData(runtime); + rawInspectData = newData; + scheduleDataChanged(); +} + +/** + * Ensure the fallback is running (similar API to ensureEventStream) + */ +export async function ensureFallback(runtime: string): Promise { + const config = vscode.workspace.getConfiguration("containerlab"); + const pollInterval = config.get("pollInterval", DEFAULT_POLL_INTERVAL_MS); + + if (!isPolling) { + startPolling(runtime, pollInterval); + } + + // Do an initial fetch and wait for it + await forceUpdate(runtime); +} + +/** + * Register a listener for data changes + */ +export function onDataChanged(listener: DataListener): () => void { + dataListeners.add(listener); + return () => { + dataListeners.delete(listener); + }; +} + +/** + * Reset for testing + */ +export function resetForTests(): void { + stopPolling(); + rawInspectData = undefined; + dataListeners.clear(); + interfaceCache.clear(); + if (dataChangedTimer) { + clearTimeout(dataChangedTimer); + dataChangedTimer = null; + } +} + +// Interface cache: key is `labPath::containerName`, value is cached interfaces +const interfaceCache = new Map(); +const INTERFACE_CACHE_TTL_MS = 5000; + +/** + * Raw interface data from containerlab inspect interfaces + */ +interface ClabInspectInterfaceJSON { + name: string; + interfaces: Array<{ + name: string; + type: string; + state: string; + alias: string; + mac: string; + mtu: number; + ifindex: number; + }>; +} + +/** + * Find the lab path for a container by looking through rawInspectData + */ +function findLabPathForContainer(containerName: string): string | undefined { + if (!rawInspectData) { + return undefined; + } + + for (const labContainers of Object.values(rawInspectData)) { + const labArray = labContainers as ClabDetailedJSON[] & { "topo-file"?: string }; + const container = labArray.find(c => c.Names?.[0] === containerName); + if (container) { + // The topo-file is stored as a property on the array + return labArray["topo-file"] || container.Labels?.["clab-topo-file"]; + } + } + return undefined; +} + +/** + * Fetch interface data for a container using containerlab inspect interfaces + */ +function fetchInterfacesSync(labPath: string, containerName: string): ClabInspectInterfaceJSON[] { + try { + const clabStdout = execFileSync( + containerlabBinaryPath, + ["inspect", "interfaces", "-t", labPath, "-f", "json", "-n", containerName], + { stdio: ["pipe", "pipe", "ignore"], timeout: 10000 } + ).toString(); + return JSON.parse(clabStdout) as ClabInspectInterfaceJSON[]; + } catch (err) { + console.error(`[containerlabInspectFallback]: Failed to fetch interfaces for ${containerName}: ${err instanceof Error ? err.message : String(err)}`); + return []; + } +} + +/** + * Convert raw interface data to snapshot format + */ +function toInterfaceSnapshot(raw: ClabInspectInterfaceJSON[]): ClabInterfaceSnapshot[] { + if (!raw || raw.length === 0) { + return []; + } + + return raw.map(item => ({ + name: item.name, + interfaces: (item.interfaces || []).map(iface => ({ + name: iface.name, + type: iface.type || "", + state: iface.state || "", + alias: iface.alias || "", + mac: iface.mac || "", + mtu: iface.mtu || 0, + ifindex: iface.ifindex || 0, + } as ClabInterfaceSnapshotEntry)), + })); +} + +/** + * Get interface snapshot for a container (fallback implementation) + */ +export function getInterfaceSnapshot(_containerShortId: string, containerName: string): ClabInterfaceSnapshot[] { + // Find the lab path for this container + const labPath = findLabPathForContainer(containerName); + if (!labPath) { + console.warn(`[containerlabInspectFallback]: Could not find lab path for container ${containerName}`); + return []; + } + + const cacheKey = `${labPath}::${containerName}`; + const cached = interfaceCache.get(cacheKey); + + // Return cached data if still valid + if (cached && Date.now() - cached.timestamp < INTERFACE_CACHE_TTL_MS) { + return cached.interfaces; + } + + // Fetch fresh data + const rawInterfaces = fetchInterfacesSync(labPath, containerName); + const interfaces = toInterfaceSnapshot(rawInterfaces); + + // Update cache + interfaceCache.set(cacheKey, { + timestamp: Date.now(), + interfaces, + }); + + return interfaces; +} + +/** + * Get interface version (always 0 for fallback since we don't track versions) + */ +// eslint-disable-next-line no-unused-vars +export function getInterfaceVersion(_containerShortId: string): number { + return 0; +} diff --git a/src/topoViewer/core/managerRegistry.ts b/src/topoViewer/core/managerRegistry.ts index 7013753eb..59b806635 100644 --- a/src/topoViewer/core/managerRegistry.ts +++ b/src/topoViewer/core/managerRegistry.ts @@ -4,11 +4,13 @@ import type { ManagerGroupStyle } from '../webview-ui/managerGroupStyle'; import { ManagerLayoutAlgo } from '../webview-ui/managerLayoutAlgo'; import { ManagerZoomToFit } from '../webview-ui/managerZoomToFit'; import { ManagerLabelEndpoint } from '../webview-ui/managerLabelEndpoint'; +import { ManagerDummyLinks } from '../webview-ui/managerDummyLinks'; // Singleton instances for managers that don't require external dependencies export const layoutAlgoManager = new ManagerLayoutAlgo(); export const zoomToFitManager = new ManagerZoomToFit(); export const labelEndpointManager = new ManagerLabelEndpoint(); +export const dummyLinksManager = new ManagerDummyLinks(); // Lazy singletons for managers that require initialization parameters let groupManager: ManagerGroupManagement | null = null; diff --git a/src/topoViewer/core/topoViewerAdaptorClab.ts b/src/topoViewer/core/topoViewerAdaptorClab.ts index 4a1d30d3c..1961a79eb 100644 --- a/src/topoViewer/core/topoViewerAdaptorClab.ts +++ b/src/topoViewer/core/topoViewerAdaptorClab.ts @@ -1373,6 +1373,31 @@ export class TopoViewerAdaptorClab { return label.label ?? ''; } + public computeEdgeClassFromStates( + topology: NonNullable, + sourceNode: string, + targetNode: string, + sourceState?: string, + targetState?: string + ): string { + const sourceNodeData = topology.nodes?.[sourceNode]; + const targetNodeData = topology.nodes?.[targetNode]; + const sourceIsSpecial = this.isSpecialNode(sourceNodeData, sourceNode); + const targetIsSpecial = this.isSpecialNode(targetNodeData, targetNode); + if (sourceIsSpecial || targetIsSpecial) { + return this.edgeClassForSpecial( + sourceIsSpecial, + targetIsSpecial, + { state: sourceState }, + { state: targetState } + ); + } + if (sourceState && targetState) { + return sourceState === 'up' && targetState === 'up' ? 'link-up' : 'link-down'; + } + return ''; + } + private computeEdgeClass( sourceNode: string, targetNode: string, @@ -1608,6 +1633,8 @@ export class TopoViewerAdaptorClab { sourceIfaceData, targetIfaceData, extValidationErrors, + sourceNodeId: sourceNode, + targetNodeId: targetNode, }); return { @@ -1661,6 +1688,8 @@ export class TopoViewerAdaptorClab { sourceIfaceData: any; targetIfaceData: any; extValidationErrors: string[]; + sourceNodeId: string; + targetNodeId: string; }): any { const { linkObj, @@ -1673,6 +1702,8 @@ export class TopoViewerAdaptorClab { sourceIfaceData, targetIfaceData, extValidationErrors, + sourceNodeId, + targetNodeId, } = params; const yamlFormat = typeof linkObj?.type === 'string' && linkObj.type ? 'extended' : 'short'; @@ -1689,7 +1720,14 @@ export class TopoViewerAdaptorClab { const extInfo = this.createExtInfo({ linkObj, endA, endB }); - return { ...clabInfo, ...extInfo, yamlFormat, extValidationErrors: extErrors }; + return { + ...clabInfo, + ...extInfo, + yamlFormat, + extValidationErrors: extErrors, + yamlSourceNodeId: sourceNodeId, + yamlTargetNodeId: targetNodeId, + }; } private createClabInfo(params: { @@ -1723,7 +1761,10 @@ export class TopoViewerAdaptorClab { type: tgtType = '', } = targetIfaceData; - return { + const sourceStats = this.extractEdgeInterfaceStats(sourceIfaceData); + const targetStats = this.extractEdgeInterfaceStats(targetIfaceData); + + const info: Record = { clabServerUsername: 'asad', clabSourceLongName: sourceContainerName, clabTargetLongName: targetContainerName, @@ -1738,6 +1779,48 @@ export class TopoViewerAdaptorClab { clabSourceType: srcType, clabTargetType: tgtType, }; + + if (sourceStats) { + info.clabSourceStats = sourceStats; + } + if (targetStats) { + info.clabTargetStats = targetStats; + } + + return info; + } + + private extractEdgeInterfaceStats(ifaceData: any): Record | undefined { + if (!ifaceData || typeof ifaceData !== 'object') { + return undefined; + } + + const sourceStats = (ifaceData as { stats?: Record }).stats || ifaceData; + if (!sourceStats || typeof sourceStats !== 'object') { + return undefined; + } + + const keys: Array> = [ + 'rxBps', + 'rxPps', + 'rxBytes', + 'rxPackets', + 'txBps', + 'txPps', + 'txBytes', + 'txPackets', + 'statsIntervalSeconds', + ]; + + const stats: Record = {}; + for (const key of keys) { + const value = (sourceStats as Record)[key as string]; + if (typeof value === 'number' && Number.isFinite(value)) { + stats[key as string] = value; + } + } + + return Object.keys(stats).length > 0 ? stats : undefined; } private createExtInfo(params: { linkObj: any; endA: any; endB: any }): any { diff --git a/src/topoViewer/providers/topoViewerEditorWebUiFacade.ts b/src/topoViewer/providers/topoViewerEditorWebUiFacade.ts index e5733997f..7ebd0d76e 100644 --- a/src/topoViewer/providers/topoViewerEditorWebUiFacade.ts +++ b/src/topoViewer/providers/topoViewerEditorWebUiFacade.ts @@ -10,10 +10,12 @@ import { log } from '../logging/logger'; import { generateWebviewHtml, EditorTemplateParams, ViewerTemplateParams, TemplateMode } from '../htmlTemplateUtils'; import { TopoViewerAdaptorClab } from '../core/topoViewerAdaptorClab'; +import { ClabTopology, CyElement } from '../types/topoViewerType'; import { resolveNodeConfig } from '../core/nodeConfig'; -import { ClabLabTreeNode, ClabContainerTreeNode } from "../../treeView/common"; +import { ClabLabTreeNode, ClabContainerTreeNode, ClabInterfaceTreeNode } from "../../treeView/common"; import * as inspector from "../../treeView/inspector"; -import { runningLabsProvider, refreshDockerImages } from "../../extension"; +import { runningLabsProvider } from "../../extension"; +import * as utils from "../../utils/index"; import { validateYamlContent } from '../utilities/yamlValidator'; import { saveViewport } from '../utilities/saveViewport'; @@ -21,6 +23,8 @@ import { annotationsManager } from '../utilities/annotationsManager'; import { perfMark, perfMeasure, perfSummary } from '../utilities/performanceMonitor'; import { sleep } from '../utilities/asyncUtils'; import { DEFAULT_INTERFACE_PATTERNS } from '../constants/interfacePatterns'; +import type { ClabInterfaceStats } from '../../types/containerlab'; +import { findInterfaceNode } from '../utilities/treeUtils'; // Common configuration section key used throughout this module const CONFIG_SECTION = 'containerlab.editor'; @@ -64,6 +68,14 @@ export class TopoViewerEditor { public deploymentState: 'deployed' | 'undeployed' | 'unknown' = 'unknown'; private isSwitchingMode: boolean = false; // Flag to prevent concurrent mode switches private isSplitViewOpen: boolean = false; // Track if YAML split view is open + private dockerImagesSubscription: vscode.Disposable | undefined; + private viewModeCache: + | { + elements: CyElement[]; + parsedTopology?: ClabTopology; + yamlMtimeMs?: number; + } + | undefined; /* eslint-disable no-unused-vars */ private readonly generalEndpointHandlers: Record< @@ -74,39 +86,46 @@ export class TopoViewerEditor { _panel: vscode.WebviewPanel ) => Promise<{ result: unknown; error: string | null }> > = { - 'topo-viewport-save': this.handleViewportSaveEndpoint.bind(this), - 'lab-settings-get': this.handleLabSettingsGetEndpoint.bind(this), - 'lab-settings-update': this.handleLabSettingsUpdateEndpoint.bind(this), - 'topo-editor-get-node-config': this.handleGetNodeConfigEndpoint.bind(this), - 'show-error-message': this.handleShowErrorMessageEndpoint.bind(this), - 'topo-editor-viewport-save': this.handleViewportSaveEditEndpoint.bind(this), - 'topo-editor-viewport-save-suppress-notification': - this.handleViewportSaveSuppressNotificationEndpoint.bind(this), - 'topo-editor-undo': this.handleUndoEndpoint.bind(this), - 'topo-editor-show-vscode-message': this.handleShowVscodeMessageEndpoint.bind(this), - 'topo-switch-mode': this.handleSwitchModeEndpoint.bind(this), - 'open-external': this.handleOpenExternalEndpoint.bind(this), - 'topo-editor-load-annotations': this.handleLoadAnnotationsEndpoint.bind(this), - 'topo-editor-save-annotations': this.handleSaveAnnotationsEndpoint.bind(this), - 'topo-editor-load-viewer-settings': this.handleLoadViewerSettingsEndpoint.bind(this), - 'topo-editor-save-viewer-settings': this.handleSaveViewerSettingsEndpoint.bind(this), - 'topo-editor-save-custom-node': this.handleSaveCustomNodeEndpoint.bind(this), - 'topo-editor-delete-custom-node': this.handleDeleteCustomNodeEndpoint.bind(this), - 'topo-editor-set-default-custom-node': this.handleSetDefaultCustomNodeEndpoint.bind(this), - 'refresh-docker-images': this.handleRefreshDockerImagesEndpoint.bind(this), - 'topo-editor-upload-icon': this.handleUploadIconEndpoint.bind(this), - 'topo-editor-delete-icon': this.handleDeleteIconEndpoint.bind(this), - showError: this.handleShowErrorEndpoint.bind(this), - 'topo-toggle-split-view': this.handleToggleSplitViewEndpoint.bind(this), - copyElements: this.handleCopyElementsEndpoint.bind(this), - getCopiedElements: this.handleGetCopiedElementsEndpoint.bind(this), - 'topo-debug-log': this.handleDebugLogEndpoint.bind(this), - 'topo-editor-open-link': this.handleOpenExternalLinkEndpoint.bind(this) - }; + 'topo-viewport-save': this.handleViewportSaveEndpoint.bind(this), + 'lab-settings-get': this.handleLabSettingsGetEndpoint.bind(this), + 'lab-settings-update': this.handleLabSettingsUpdateEndpoint.bind(this), + 'topo-editor-get-node-config': this.handleGetNodeConfigEndpoint.bind(this), + 'show-error-message': this.handleShowErrorMessageEndpoint.bind(this), + 'topo-editor-viewport-save': this.handleViewportSaveEditEndpoint.bind(this), + 'topo-editor-viewport-save-suppress-notification': + this.handleViewportSaveSuppressNotificationEndpoint.bind(this), + 'topo-editor-undo': this.handleUndoEndpoint.bind(this), + 'topo-editor-show-vscode-message': this.handleShowVscodeMessageEndpoint.bind(this), + 'topo-switch-mode': this.handleSwitchModeEndpoint.bind(this), + 'open-external': this.handleOpenExternalEndpoint.bind(this), + 'topo-editor-load-annotations': this.handleLoadAnnotationsEndpoint.bind(this), + 'topo-editor-save-annotations': this.handleSaveAnnotationsEndpoint.bind(this), + 'topo-editor-load-viewer-settings': this.handleLoadViewerSettingsEndpoint.bind(this), + 'topo-editor-save-viewer-settings': this.handleSaveViewerSettingsEndpoint.bind(this), + 'topo-editor-save-custom-node': this.handleSaveCustomNodeEndpoint.bind(this), + 'topo-editor-delete-custom-node': this.handleDeleteCustomNodeEndpoint.bind(this), + 'topo-editor-set-default-custom-node': this.handleSetDefaultCustomNodeEndpoint.bind(this), + 'refresh-docker-images': this.handleRefreshDockerImagesEndpoint.bind(this), + 'topo-editor-upload-icon': this.handleUploadIconEndpoint.bind(this), + 'topo-editor-delete-icon': this.handleDeleteIconEndpoint.bind(this), + showError: this.handleShowErrorEndpoint.bind(this), + 'performance-metrics': this.handlePerformanceMetricsEndpoint.bind(this), + 'topo-toggle-split-view': this.handleToggleSplitViewEndpoint.bind(this), + copyElements: this.handleCopyElementsEndpoint.bind(this), + getCopiedElements: this.handleGetCopiedElementsEndpoint.bind(this), + 'topo-debug-log': this.handleDebugLogEndpoint.bind(this), + 'topo-editor-open-link': this.handleOpenExternalLinkEndpoint.bind(this) + }; constructor(context: vscode.ExtensionContext) { this.context = context; this.adaptor = new TopoViewerAdaptorClab(); + this.dockerImagesSubscription = utils.onDockerImagesUpdated(images => { + if (this.currentPanel) { + this.currentPanel.webview.postMessage({ type: 'docker-images-updated', dockerImages: images }); + } + }); + context.subscriptions.push(this.dockerImagesSubscription); } private logDebug(message: string): void { @@ -138,27 +157,26 @@ topology: private async getContainerNode(nodeName: string): Promise { const labs = await runningLabsProvider?.discoverInspectLabs(); - if (!labs) { + if (!labs || !this.lastYamlFilePath) { return undefined; } - let distributedSrosFallback: ClabContainerTreeNode | undefined; - - for (const lab of Object.values(labs)) { - const containers = lab.containers ?? []; - const directMatch = containers.find( - (c) => c.name === nodeName || c.name_short === nodeName || (c.label as string) === nodeName - ); - if (directMatch) { - return directMatch; - } + // Only search in the current lab + const currentLab = Object.values(labs).find(lab => lab.labPath.absolute === this.lastYamlFilePath); + if (!currentLab) { + return undefined; + } - if (!distributedSrosFallback) { - distributedSrosFallback = this.resolveDistributedSrosContainer(containers, nodeName); - } + const containers = currentLab.containers ?? []; + const directMatch = containers.find( + (c) => c.name === nodeName || c.name_short === nodeName || (c.label as string) === nodeName + ); + if (directMatch) { + return directMatch; } - return distributedSrosFallback; + // Check for distributed SROS container + return this.resolveDistributedSrosContainer(containers, nodeName); } private resolveDistributedSrosContainer( @@ -530,6 +548,12 @@ topology: this.lastYamlFilePath ); + if (this.isViewMode) { + await this.updateViewModeCache(yamlContent, cytoTopology); + } else { + this.viewModeCache = undefined; + } + const writeOk = await this.writeTopologyFiles( folderName, cytoTopology, @@ -640,6 +664,30 @@ topology: return yamlContent; } + private async updateViewModeCache(yamlContent: string, elements: CyElement[]): Promise { + let parsedTopology: ClabTopology | undefined; + try { + parsedTopology = YAML.parse(yamlContent) as ClabTopology; + } catch (err) { + log.debug(`Failed to cache parsed topology: ${err}`); + } + + const yamlMtimeMs = await this.getYamlMtimeMs(); + this.viewModeCache = { elements, parsedTopology, yamlMtimeMs }; + } + + private async getYamlMtimeMs(): Promise { + if (!this.lastYamlFilePath) { + return undefined; + } + try { + const stats = await fs.promises.stat(this.lastYamlFilePath); + return stats.mtimeMs; + } catch { + return undefined; + } + } + private shouldSkipUpdate(yamlContent: string, isInitialLoad: boolean): boolean { if (isInitialLoad || this.isViewMode) { return false; @@ -734,7 +782,7 @@ topology: } private async getEditorTemplateParams(): Promise> { - await refreshDockerImages(this.context); + await utils.refreshDockerImages(); const config = vscode.workspace.getConfiguration(CONFIG_SECTION); const lockLabByDefault = config.get('lockLabByDefault', true); const legacyIfacePatternMapping = this.getLegacyInterfacePatternMapping(config); @@ -755,7 +803,7 @@ topology: ); const { defaultNode, defaultKind, defaultType } = this.getDefaultCustomNode(customNodes); const imageMapping = this.buildImageMapping(customNodes); - const dockerImages = (this.context.globalState.get('dockerImages') || []) as string[]; + const dockerImages = utils.getDockerImages(); const customIcons = await this.loadCustomIcons(); return { imageMapping, @@ -1155,6 +1203,7 @@ topology: private registerPanelListeners(panel: vscode.WebviewPanel, context: vscode.ExtensionContext): void { panel.onDidDispose(() => { this.currentPanel = undefined; + this.viewModeCache = undefined; this.disposeFileHandlers(); }, null, context.subscriptions); @@ -1212,7 +1261,7 @@ topology: const baseName = path.basename(this.lastYamlFilePath); const labNameFromFile = baseName.replace(/\.clab\.(yml|yaml)$/i, '').replace(/\.(yml|yaml)$/i, ''); const defaultContent = `name: ${labNameFromFile}\n\n` + -`topology:\n nodes:\n srl1:\n kind: nokia_srlinux\n type: ixr-d2l\n image: ghcr.io/nokia/srlinux:latest\n\n srl2:\n kind: nokia_srlinux\n type: ixr-d2l\n image: ghcr.io/nokia/srlinux:latest\n\n links:\n # inter-switch link\n - endpoints: [ srl1:e1-1, srl2:e1-1 ]\n - endpoints: [ srl1:e1-2, srl2:e1-2 ]\n`; + `topology:\n nodes:\n srl1:\n kind: nokia_srlinux\n type: ixr-d2l\n image: ghcr.io/nokia/srlinux:latest\n\n srl2:\n kind: nokia_srlinux\n type: ixr-d2l\n image: ghcr.io/nokia/srlinux:latest\n\n links:\n # inter-switch link\n - endpoints: [ srl1:e1-1, srl2:e1-1 ]\n - endpoints: [ srl1:e1-2, srl2:e1-2 ]\n`; this.isInternalUpdate = true; await fs.promises.writeFile(this.lastYamlFilePath, defaultContent, 'utf8'); await sleep(50); @@ -1505,6 +1554,64 @@ topology: return { result: { success: true }, error: null }; } + private async handlePerformanceMetricsEndpoint( + payload: string | undefined, + payloadObj: any, + _panel: vscode.WebviewPanel + ): Promise<{ result: unknown; error: string | null }> { + try { + const metricsPayload = this.normalizeMetricsPayload(payload, payloadObj); + const metrics = metricsPayload?.metrics; + if (!metrics || typeof metrics !== 'object') { + const warning = 'Received performance-metrics call without metrics payload'; + log.warn(warning); + return { result: { success: false, warning }, error: null }; + } + + const numericEntries = Object.entries(metrics) + .map(([name, value]) => [name, typeof value === 'number' ? value : Number(value)] as [string, number]) + .filter(([, value]) => Number.isFinite(value)); + + if (!numericEntries.length) { + const warning = 'Performance metrics payload contained no numeric values'; + log.warn(warning); + return { result: { success: false, warning }, error: null }; + } + + const total = numericEntries.reduce((sum, [, value]) => sum + value, 0); + log.info( + `TopoViewer performance metrics (${numericEntries.length} entries, total ${total.toFixed(2)}ms):` + ); + const sortedEntries = [...numericEntries].sort((a, b) => b[1] - a[1]); + sortedEntries.slice(0, 8).forEach(([name, value]) => { + log.info(` ${name}: ${value.toFixed(2)}ms`); + }); + + return { result: { success: true }, error: null }; + } catch (err) { + const error = `Failed to record performance metrics: ${err instanceof Error ? err.message : String(err)}`; + log.error(error); + return { result: null, error }; + } + } + + private normalizeMetricsPayload( + payload: string | undefined, + payloadObj: any + ): any { + if (payloadObj && typeof payloadObj === 'object') { + return payloadObj; + } + if (typeof payload === 'string' && payload.trim()) { + try { + return JSON.parse(payload); + } catch (err) { + log.warn(`Failed to parse performance metrics payload: ${err instanceof Error ? err.message : String(err)}`); + } + } + return undefined; + } + private async handleViewportSaveEditEndpoint( payload: string | undefined, _payloadObj: any, @@ -1742,15 +1849,16 @@ topology: const annotations = await annotationsManager.loadAnnotations(this.lastYamlFilePath); const result = { annotations: annotations.freeTextAnnotations || [], + freeShapeAnnotations: annotations.freeShapeAnnotations || [], groupStyles: annotations.groupStyleAnnotations || [] }; log.info( - `Loaded ${annotations.freeTextAnnotations?.length || 0} annotations and ${annotations.groupStyleAnnotations?.length || 0} group styles` + `Loaded ${annotations.freeTextAnnotations?.length || 0} text annotations, ${annotations.freeShapeAnnotations?.length || 0} shape annotations, and ${annotations.groupStyleAnnotations?.length || 0} group styles` ); return { result, error: null }; } catch (err) { log.error(`Error loading annotations: ${JSON.stringify(err, null, 2)}`); - return { result: { annotations: [], groupStyles: [] }, error: null }; + return { result: { annotations: [], freeShapeAnnotations: [], groupStyles: [] }, error: null }; } } @@ -1762,16 +1870,19 @@ topology: try { const data = payloadObj; const existing = await annotationsManager.loadAnnotations(this.lastYamlFilePath); + // Preserve existing values when not provided in the payload + // This allows individual managers (freeText, freeShapes, groupStyle) to save independently await annotationsManager.saveAnnotations(this.lastYamlFilePath, { - freeTextAnnotations: data.annotations, - groupStyleAnnotations: data.groupStyles, + freeTextAnnotations: data.annotations !== undefined ? data.annotations : existing.freeTextAnnotations, + freeShapeAnnotations: data.freeShapeAnnotations !== undefined ? data.freeShapeAnnotations : existing.freeShapeAnnotations, + groupStyleAnnotations: data.groupStyles !== undefined ? data.groupStyles : existing.groupStyleAnnotations, cloudNodeAnnotations: existing.cloudNodeAnnotations, nodeAnnotations: existing.nodeAnnotations, // Preserve viewer settings to avoid accidental loss when other managers save viewerSettings: (existing as any).viewerSettings }); log.info( - `Saved ${data.annotations?.length || 0} annotations and ${data.groupStyles?.length || 0} group styles` + `Saved ${data.annotations?.length || 0} text annotations, ${data.freeShapeAnnotations?.length || 0} shape annotations, and ${data.groupStyles?.length || 0} group styles` ); return { result: { success: true }, error: null }; } catch (err) { @@ -1996,8 +2107,8 @@ topology: _panel: vscode.WebviewPanel ): Promise<{ result: unknown; error: string | null }> { try { - await refreshDockerImages(this.context); - const dockerImages = (this.context.globalState.get('dockerImages') || []) as string[]; + await utils.refreshDockerImages(); + const dockerImages = utils.getDockerImages(); log.info(`Docker images refreshed, found ${dockerImages.length} images`); return { result: { success: true, dockerImages }, error: null }; } catch (err) { @@ -2529,30 +2640,225 @@ topology: return; } - const yamlContent = await this.getYamlContentViewMode(); - const elements = await this.adaptor.clabYamlToCytoscapeElements( - yamlContent, - labs, - this.lastYamlFilePath - ); + await this.ensureViewModeCache(labs); - const edgeUpdates = elements.filter(el => el.group === 'edges'); - if (!edgeUpdates.length) { - this.logDebug('refreshLinkStates: no edge updates to send'); - return; - } + const edgeUpdates = this.buildEdgeUpdatesFromCache(labs); + if (!edgeUpdates.length) { + this.logDebug('refreshLinkStates: no edge updates to send'); + return; + } - this.logDebug(`refreshLinkStates: posting ${edgeUpdates.length} edge updates to webview`); - this.currentPanel.webview.postMessage({ - type: 'updateTopology', - data: edgeUpdates, - }); + this.logDebug(`refreshLinkStates: posting ${edgeUpdates.length} edge updates to webview`); + this.currentPanel.webview.postMessage({ + type: 'updateTopology', + data: edgeUpdates, + }); } catch (err) { const message = err instanceof Error ? err.message : String(err); log.warn(`Failed to refresh link states from inspect data: ${message}`); } } + private async ensureViewModeCache( + labs: Record | undefined + ): Promise { + if (!this.isViewMode) { + return; + } + + const yamlMtimeMs = await this.getYamlMtimeMs(); + const cache = this.viewModeCache; + const needsReload = + !cache || + cache.elements.length === 0 || + (yamlMtimeMs !== undefined && cache.yamlMtimeMs !== yamlMtimeMs); + + if (!needsReload) { + return; + } + + const yamlContent = await this.getYamlContentViewMode(); + const elements = await this.adaptor.clabYamlToCytoscapeElements( + yamlContent, + labs, + this.lastYamlFilePath + ); + await this.updateViewModeCache(yamlContent, elements); + } + + private buildEdgeUpdatesFromCache(labs: Record): CyElement[] { + const cache = this.viewModeCache; + if (!cache || cache.elements.length === 0) { + return []; + } + + const updates: CyElement[] = []; + const topology = cache.parsedTopology?.topology; + + for (const el of cache.elements) { + if (el.group !== 'edges') { + continue; + } + const updated = this.refreshEdgeWithLatestData(el, labs, topology); + if (updated) { + updates.push(updated); + } + } + + return updates; + } + + private refreshEdgeWithLatestData( + edge: CyElement, + labs: Record, + topology?: ClabTopology['topology'] + ): CyElement | null { + if (edge.group !== 'edges') { + return null; + } + + const data = { ...edge.data }; + const extraData = { ...(data.extraData || {}) }; + + const sourceIfaceName = this.normalizeInterfaceName(extraData.clabSourcePort, data.sourceEndpoint); + const targetIfaceName = this.normalizeInterfaceName(extraData.clabTargetPort, data.targetEndpoint); + + const sourceIface = findInterfaceNode( + labs, + extraData.clabSourceLongName ?? '', + sourceIfaceName, + this.currentLabName + ); + const targetIface = findInterfaceNode( + labs, + extraData.clabTargetLongName ?? '', + targetIfaceName, + this.currentLabName + ); + + const sourceState = this.applyInterfaceDetails(extraData, 'Source', sourceIface); + const targetState = this.applyInterfaceDetails(extraData, 'Target', targetIface); + + data.extraData = extraData; + + const sourceNodeForClass = this.pickNodeId(extraData.yamlSourceNodeId, data.source); + const targetNodeForClass = this.pickNodeId(extraData.yamlTargetNodeId, data.target); + + const stateClass = + topology && sourceNodeForClass && targetNodeForClass + ? this.adaptor.computeEdgeClassFromStates( + topology, + sourceNodeForClass, + targetNodeForClass, + sourceState, + targetState + ) + : undefined; + + const mergedClasses = this.mergeLinkStateClasses(edge.classes, stateClass); + + edge.data = data; + if (mergedClasses !== undefined) { + edge.classes = mergedClasses; + } + + return edge; + } + + private applyInterfaceDetails( + extraData: Record, + prefix: 'Source' | 'Target', + iface: ClabInterfaceTreeNode | undefined + ): string | undefined { + const stateKey = prefix === 'Source' ? 'clabSourceInterfaceState' : 'clabTargetInterfaceState'; + const macKey = prefix === 'Source' ? 'clabSourceMacAddress' : 'clabTargetMacAddress'; + const mtuKey = prefix === 'Source' ? 'clabSourceMtu' : 'clabTargetMtu'; + const typeKey = prefix === 'Source' ? 'clabSourceType' : 'clabTargetType'; + const statsKey = prefix === 'Source' ? 'clabSourceStats' : 'clabTargetStats'; + + if (!iface) { + delete extraData[statsKey]; + return typeof extraData[stateKey] === 'string' ? extraData[stateKey] : undefined; + } + + extraData[stateKey] = iface.state || ''; + extraData[macKey] = iface.mac ?? ''; + extraData[mtuKey] = iface.mtu ?? ''; + extraData[typeKey] = iface.type ?? ''; + + const stats = this.extractInterfaceStatsForEdge(iface.stats); + if (stats) { + extraData[statsKey] = stats; + } else { + delete extraData[statsKey]; + } + + return iface.state; + } + + private extractInterfaceStatsForEdge(stats?: ClabInterfaceStats): Record | undefined { + if (!stats) { + return undefined; + } + + const result: Record = {}; + const keys: Array = [ + 'rxBps', + 'rxPps', + 'rxBytes', + 'rxPackets', + 'txBps', + 'txPps', + 'txBytes', + 'txPackets', + 'statsIntervalSeconds', + ]; + + for (const key of keys) { + const value = stats[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + result[key] = value; + } + } + + return Object.keys(result).length > 0 ? result : undefined; + } + + private normalizeInterfaceName(value: unknown, fallback: unknown): string { + if (typeof value === 'string' && value.trim()) { + return value; + } + if (typeof fallback === 'string' && fallback.trim()) { + return fallback; + } + return ''; + } + + private pickNodeId(primary: unknown, fallback: unknown): string { + if (typeof primary === 'string' && primary.trim()) { + return primary; + } + if (typeof fallback === 'string' && fallback.trim()) { + return fallback; + } + return ''; + } + + private mergeLinkStateClasses(existing: string | undefined, stateClass: string | undefined): string | undefined { + if (!stateClass) { + return existing; + } + + const tokens = (existing ?? '') + .split(/\s+/) + .filter(Boolean) + .filter(token => token !== 'link-up' && token !== 'link-down'); + + tokens.unshift(stateClass); + + return tokens.join(' '); + } + /** * Check if a mode switch operation is currently in progress diff --git a/src/topoViewer/state.ts b/src/topoViewer/state.ts index 9735a0351..ff431a6c1 100644 --- a/src/topoViewer/state.ts +++ b/src/topoViewer/state.ts @@ -9,6 +9,7 @@ export const topoViewerState: TopoViewerState = { selectedEdge: null, linkLabelMode: DEFAULT_LINK_LABEL_MODE, nodeContainerStatusVisibility: false, + dummyLinksVisible: true, labName: '', prefixName: 'clab', multiLayerViewPortState: false, @@ -28,6 +29,7 @@ export function resetState(): void { topoViewerState.selectedEdge = null; topoViewerState.linkLabelMode = DEFAULT_LINK_LABEL_MODE; topoViewerState.nodeContainerStatusVisibility = false; + topoViewerState.dummyLinksVisible = true; topoViewerState.multiLayerViewPortState = false; topoViewerState.isGeoMapInitialized = false; topoViewerState.isPanel01Cy = false; diff --git a/src/topoViewer/templates/main.html b/src/topoViewer/templates/main.html index 76d1efa51..bad4bfe2a 100644 --- a/src/topoViewer/templates/main.html +++ b/src/topoViewer/templates/main.html @@ -38,6 +38,7 @@ {{PANEL_LINK_EDITOR}} {{PANEL_BULK_LINK}} {{PANEL_FREE_TEXT}} + {{PANEL_FREE_SHAPES}} {{WIRESHARK_MODAL}} {{UNIFIED_FLOATING_PANEL}} {{CONFIRM_DIALOG}} diff --git a/src/topoViewer/templates/partials/draggable-panels.html b/src/topoViewer/templates/partials/draggable-panels.html index 9e6ec5b92..3f746876f 100644 --- a/src/topoViewer/templates/partials/draggable-panels.html +++ b/src/topoViewer/templates/partials/draggable-panels.html @@ -1,333 +1,167 @@ diff --git a/src/topoViewer/templates/partials/navbar-buttons.html b/src/topoViewer/templates/partials/navbar-buttons.html index 0a28ef35a..f1862c429 100644 --- a/src/topoViewer/templates/partials/navbar-buttons.html +++ b/src/topoViewer/templates/partials/navbar-buttons.html @@ -118,6 +118,19 @@ No Labels +
+ diff --git a/src/topoViewer/templates/partials/panel-bulk-link.html b/src/topoViewer/templates/partials/panel-bulk-link.html index d9e63ae89..0758d9775 100644 --- a/src/topoViewer/templates/partials/panel-bulk-link.html +++ b/src/topoViewer/templates/partials/panel-bulk-link.html @@ -4,7 +4,12 @@ aria-labelledby="bulk-link-panel-heading" style="display: none" > - +

diff --git a/src/topoViewer/templates/partials/panel-free-shapes.html b/src/topoViewer/templates/partials/panel-free-shapes.html new file mode 100644 index 000000000..361e2540b --- /dev/null +++ b/src/topoViewer/templates/partials/panel-free-shapes.html @@ -0,0 +1,228 @@ + +

diff --git a/src/topoViewer/templates/partials/panel-free-text.html b/src/topoViewer/templates/partials/panel-free-text.html index c2cb42b7f..5a0f14918 100644 --- a/src/topoViewer/templates/partials/panel-free-text.html +++ b/src/topoViewer/templates/partials/panel-free-text.html @@ -1,98 +1,171 @@ - -