diff --git a/package.json b/package.json index d168a60..c10e908 100644 --- a/package.json +++ b/package.json @@ -329,22 +329,22 @@ { "command": "containerlab.inspectOneLab", "when": "viewItem =~ /containerlabLab/", - "group": "navigation@9" + "group": "navigation@7" }, { "command": "containerlab.lab.graph", "when": "viewItem =~ /containerlabLab/", - "group": "navigation@8" + "group": "graph@0" }, { "command": "containerlab.lab.graph.drawio", "when": "viewItem =~ /containerlabLab/", - "group": "navigation@9" + "group": "graph@1" }, { "command": "containerlab.lab.graph.drawio.interactive", "when": "viewItem =~ /containerlabLab/", - "group": "navigation@10" + "group": "graph@2" }, { "command": "containerlab.node.start", diff --git a/src/clabTreeDataProvider.ts b/src/clabTreeDataProvider.ts new file mode 100644 index 0000000..53529e5 --- /dev/null +++ b/src/clabTreeDataProvider.ts @@ -0,0 +1,427 @@ +import * as vscode from "vscode" +import * as utils from "./utils" +import { promisify } from "util"; +import path = require("path"); + +const execAsync = promisify(require('child_process').exec); + +// Enum to store types of icons. +enum StateIcons { + RUNNING = "icons/running.svg", + STOPPED = "icons/stopped.svg", + PARTIAL = "icons/partial.svg", + UNDEPLOYED = "icons/undeployed.svg" +} + +/** + * A tree node for labs + */ +export class ClabLabTreeNode extends vscode.TreeItem { + constructor( + public readonly label: string, + collapsibleState: vscode.TreeItemCollapsibleState, + public readonly labPath: LabPath, + public readonly name?: string, + public readonly owner?: string, + public readonly containers?: ClabContainerTreeNode[], + contextValue?: string, + ) { + super(label, collapsibleState); + this.contextValue = contextValue; + } +} + +/** + * Interface which stores relative and absolute lab path. + */ +export interface LabPath { + absolute: string, + relative: string +} + +/** + * Tree node for containers (children of ClabLabTreeNode) + */ +export class ClabContainerTreeNode extends vscode.TreeItem { + constructor( + label: string, + collapsibleState: vscode.TreeItemCollapsibleState, + public readonly name: string, + public readonly cID: string, + public readonly state: string, + public readonly kind: string, + public readonly image: string, + public readonly v4Address?: string, + public readonly v6Address?: string, + contextValue?: string, + ) { + super(label, collapsibleState); + this.contextValue = contextValue; + } + + // Get the IPv4 address without CIDR mask + public get IPv4Address() { + if (!(this.v4Address === "N/A")) { + return this.v4Address?.split('/')[0]; + } else { + return ""; + } + } + + // Get the IPv6 address without CIDR mask + public get IPv6Address() { + if (!(this.v6Address === "N/A")) { + return this.v6Address?.split('/')[0]; + } else { + return ""; + } + } +} + +/** + * Interface which stores fields we expect from + * clab inspect data (in JSON format). + */ +interface ClabJSON { + container_id: string, + image: string, + ipv4_address: string, + ipv6_address: string, + kind: string, + lab_name: string, + labPath: string, + name: string, + owner: string, + state: string, +} + +export class ClabTreeDataProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + constructor(private context: vscode.ExtensionContext) { } + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: ClabLabTreeNode | ClabContainerTreeNode): vscode.TreeItem { + return element; + } + + /** + * Return tree children. If called with ClabLabTreeNode as args it will return the ClabLabTreeNode's + * array of containers. + * + * @param element A ClabLabTreeNode or ClabContainerTreeNode of which you want the children for + * @returns An array of ClabLabTreeNodes or ClabContainerTreeNodes + */ + async getChildren(element?: ClabLabTreeNode): Promise { + // Discover labs to populate tree + if (!element) { return this.discoverLabs(); } + // Find containers belonging to a lab + if (element instanceof ClabLabTreeNode) { return element.containers; } + + // Container tree nodes have no children (yet). + return []; + } + + /** + * Discovers all running labs on the system and all lab files in the local dir/subdirs and sort them. + * + * @returns A sorted array of all discovered labs (both locally and running - sourced from clab inspect -a) + */ + private async discoverLabs(): Promise { + + console.log("[discovery]:\tDiscovering labs"); + + // discover labs + const localLabs = await this.discoverLocalLabs(); + const globalLabs = await this.discoverInspectLabs(); + + + if (!localLabs && !globalLabs) { + console.error("[discovery]:\tNo labs found"); + return [new ClabLabTreeNode("No labs found. Add a lab with the '+' icon.", vscode.TreeItemCollapsibleState.None, { absolute: "", relative: "" })]; + } + else if (!globalLabs) { + console.error("[discovery]:\tNo inspected labs found"); + return Object.values(localLabs!); + } + else if (!localLabs) { + console.error("[discovery]:\tNo local labs found"); + return Object.values(globalLabs); + } + + const labs: Record = { ...globalLabs }; + + // add the local labs, if they aren't already discovered. + for (const labPath in localLabs) { + if (!labs.hasOwnProperty(labPath)) { + labs[labPath] = localLabs[labPath]; + } + } + + // Convert to an array then sort + const sortedLabs = Object.values(labs).sort( + // deployed labs go first, then compare the absolute path to the lab topology as this should be unique. + (a, b) => { + if (a.contextValue === "containerlabLabDeployed" && b.contextValue === "containerlabLabUndeployed") { + return -1; // a goes first + } + if (a.contextValue === "containerlabLabUndeployed" && b.contextValue === "containerlabLabDeployed") { + return 1; // b goes first + } + return a.labPath.absolute.localeCompare(b.labPath.absolute); + } + ); + + console.log(`[discovery]:\tDiscovered ${sortedLabs.length} labs.`) + + return sortedLabs; + } + + /** + * Finds all labs in local subdirectories using glob patterns of: + * - *.clab.yaml + * - *.clab.yml + * + * @returns A record. Aboslute labPath is the key, and value is a ClabLabTreeNode object. + */ + private async discoverLocalLabs(): Promise | undefined> { + console.log("[discovery]:\tDiscovering local labs..."); + + const clabGlobPatterns = ['**/*.clab.yml', '**/*.clab.yaml']; + const ignorePattern = '**/node_modules/**'; + + let uris: vscode.Uri[] = []; + + // search the workspace with both glob patterns + for (const pattern of clabGlobPatterns) { + const found = await vscode.workspace.findFiles(pattern, ignorePattern); + uris.push(...found); + } + + if (!uris.length) { return undefined; } + + let labs: Record = {}; + + uris.map( + (uri) => { + if (!labs[uri.fsPath]) { + // create a node, omitting the name, owners and 'child' containers + const lab = new ClabLabTreeNode( + path.basename(uri.fsPath), + vscode.TreeItemCollapsibleState.None, + { + relative: uri.fsPath, + absolute: utils.normalizeLabPath(uri.fsPath) + }, + undefined, + undefined, + undefined, + "containerlabLabUndeployed" + ) + lab.description = utils.getRelLabFolderPath(uri.fsPath); + // set the icon + const icon = this.getResourceUri(StateIcons.UNDEPLOYED); + lab.iconPath = { light: icon, dark: icon }; + + labs[uri.fsPath] = lab; + } + } + ) + + return labs; + } + + /** + * Performs a clab inspect -a --format JSON, parses the JSON and returns the object. + * + * @returns An object comprised of the parsed JSON from clab inspect + */ + private async getInspectData(): Promise { + const cmd = `${utils.getSudo()}containerlab inspect --all --format json`; + + let clabStdout; + let clabStderr; + try { + const { stdout, stderr } = await execAsync(cmd); + clabStdout = stdout; + clabStderr = stderr; + } catch (err) { + throw new Error(`Could not run ${cmd}.\n${err}`); + } + + if (clabStderr) { console.error(`[stderr]: ${clabStderr}`.replace("\n", "")); } + + // if no containers, then there should be no stdout + if (!clabStdout) { return undefined; } + + const inspectObject = JSON.parse(clabStdout); + + // console.log(inspectObject); + + return inspectObject; + } + + /** + * Discover labs from the clab inspect data - from getInspectData() + * and populate the lab with it's children (containers). + * + * @returns Record comprised of labPath as the key and ClabLabTreeNode as value. + */ + private async discoverInspectLabs(): Promise | undefined> { + console.log("[discovery]:\tDiscovering labs via inspect..."); + + const inspectData = await this.getInspectData(); + + if (!inspectData) { return undefined; } + + let labs: Record = {}; + + // 'containers' is the name of the array in the clab inspect JSON + // which holds all the running container data/ + inspectData.containers.map( + (container: ClabJSON) => { + if (!labs.hasOwnProperty(container.labPath)) { + const label = `${container.lab_name} (${container.owner})`; + + const labPathObj: LabPath = { + absolute: container.labPath, + relative: utils.getRelLabFolderPath(container.labPath) + } + + // get all containers that belong to this lab. + const discoveredContainers: ClabContainerTreeNode[] = this.discoverContainers(inspectData, container.labPath); + + + /** + * To determine the icon, we use a counter. + * + * When we see a discovered container as running then increment the counter. + * + * If the counter and array length of discovered containers is equal, then we know + * that all containers are running. + * + * If this is not the case, then we know to use the partial icon. + * + * If the counter is zero, then the lab is not running -- but could be deployed. + */ + let counter = 0; + + // increment counter if container is running + for (const c of discoveredContainers) { + if (c.state === "running") { counter++; } + } + + let icon: string; + + // determine what icon to use + if (!counter) { icon = StateIcons.STOPPED; } + else if (counter == discoveredContainers.length) { icon = StateIcons.RUNNING; } + else { icon = StateIcons.PARTIAL; } + + // create the node + const lab: ClabLabTreeNode = new ClabLabTreeNode( + label, + vscode.TreeItemCollapsibleState.Collapsed, + labPathObj, + container.lab_name, + container.owner, + discoveredContainers, + "containerlabLabDeployed" + ) + // setting the description (text next to label) to relFolderPath. + lab.description = labPathObj.relative; + + const iconUri = this.getResourceUri(icon); + lab.iconPath = { light: iconUri, dark: iconUri }; + + labs[container.labPath] = lab; + } + } + ) + + return labs; + } + + /** + * Discovers containers that are related to a lab. + * + * @param inspectData JSON object of data from 'clab inspect -a --format json' + * @param labPath The absolute path to the lab topology file. Used to identify what lab a container belongs to. + * @returns An array of ClabContainerTreeNodes. + */ + private discoverContainers(inspectData: any, labPath: string): ClabContainerTreeNode[] { + console.log(`[discovery]:\tDiscovering containers for ${labPath}...`); + + // filter the data to only relevant containers + const filtered = inspectData.containers.filter((container: ClabJSON) => container.labPath === labPath); + + let containers: ClabContainerTreeNode[] = []; + + filtered.map( + (container: ClabJSON) => { + + let tooltip = [ + `Container: ${container.name}`, + `ID: ${container.container_id}`, + `State: ${container.state}`, + `Kind: ${container.kind}`, + `Image: ${container.image}` + ] + + if (!(container.ipv4_address === "N/A")) { + const v4Addr = container.ipv4_address.split('/')[0]; + tooltip.push(`IPv4: ${v4Addr}`); + } + + if (!(container.ipv6_address === "N/A")) { + const v6Addr = container.ipv6_address.split('/')[0]; + tooltip.push(`IPv6: ${v6Addr}`); + } + + let icon: string; + // for some reason switch statement isn't working correctly here. + if (container.state === "running") { icon = StateIcons.RUNNING; } + else { icon = StateIcons.STOPPED; } + + // create the node + const node = new ClabContainerTreeNode( + container.name, + vscode.TreeItemCollapsibleState.None, + container.name, + container.container_id, + container.state, + container.kind, + container.image, + container.ipv4_address, + container.ipv6_address, + "containerlabContainer" + ) + node.description = utils.titleCase(container.state); + node.tooltip = tooltip.join("\n"); + // convert to a extension resource Uri + const iconPath = this.getResourceUri(icon) + node.iconPath = { light: iconPath, dark: iconPath }; + + containers.push(node); + } + ) + + return containers; + } + + /** + * Convert the filepath of something in the ./resources dir + * to an extension context Uri. + * + * @param resource The relative path of something in the resources dir. For example: an icon would be icons/icon.svg + * @returns A vscode.Uri of the path to the file in extension context. + */ + private getResourceUri(resource: string) { + return vscode.Uri.file(this.context.asAbsolutePath(path.join("resources", resource))); + } + +} \ No newline at end of file diff --git a/src/commands/addToWorkspace.ts b/src/commands/addToWorkspace.ts index 424ee85..ecd2e38 100644 --- a/src/commands/addToWorkspace.ts +++ b/src/commands/addToWorkspace.ts @@ -1,15 +1,14 @@ import * as vscode from "vscode"; import * as path from "path"; -import { ContainerlabNode } from "../containerlabTreeDataProvider"; +import { ClabLabTreeNode } from "../clabTreeDataProvider"; -export async function addLabFolderToWorkspace(node: ContainerlabNode) { - if (!node?.details?.labPath) { - vscode.window.showErrorMessage("No lab path found for this lab."); - return; +export async function addLabFolderToWorkspace(node: ClabLabTreeNode) { + if (!node.labPath.absolute) { + return new Error("No lab path found for this lab") } // Get the folder that contains the .clab.yaml - const folderPath = path.dirname(node.details.labPath); + const folderPath = path.dirname(node.labPath.absolute); // Add it to the current workspace const existingCount = vscode.workspace.workspaceFolders @@ -26,6 +25,6 @@ export async function addLabFolderToWorkspace(node: ContainerlabNode) { ); vscode.window.showInformationMessage( - `Added "${node.label}" to your workspace.` + `Added "${node.name}" to your workspace.` ); } diff --git a/src/commands/attachShell.ts b/src/commands/attachShell.ts index fbd1768..b02f7cf 100644 --- a/src/commands/attachShell.ts +++ b/src/commands/attachShell.ts @@ -1,20 +1,16 @@ import * as vscode from "vscode"; +import * as utils from "../utils" import { execCommandInTerminal } from "./command"; -import { ContainerlabNode } from "../containerlabTreeDataProvider"; import { execCmdMapping } from "../extension"; +import { ClabContainerTreeNode } from "../clabTreeDataProvider"; -export function attachShell(node: ContainerlabNode) { +export function attachShell(node: ClabContainerTreeNode) { if (!node) { - vscode.window.showErrorMessage('No container node selected.'); - return; + return new Error("No container node selected.") } - const nodeDetails = node.details; - - if(!nodeDetails) { return vscode.window.showErrorMessage("Couldn't fetch node details");} - - const containerId = nodeDetails.containerId; - const containerKind = nodeDetails.kind; + const containerId = node.cID; + const containerKind = node.kind; const containerLabel = node.label || "Container"; if (!containerId) { return vscode.window.showErrorMessage('No containerId for shell attach.');} @@ -22,16 +18,13 @@ export function attachShell(node: ContainerlabNode) { let execCmd = execCmdMapping[containerKind] || "sh"; - // Use the sudoEnabledByDefault setting const config = vscode.workspace.getConfiguration("containerlab"); - const useSudo = config.get("sudoEnabledByDefault", true); - const userExecMapping = config.get("node.execCommandMapping") as { [key: string]: string }; execCmd = userExecMapping[containerKind] || execCmd; execCommandInTerminal( - `${useSudo ? "sudo " : ""}docker exec -it ${containerId} ${execCmd}`, + `${utils.getSudo()}docker exec -it ${containerId} ${execCmd}`, `Shell - ${containerLabel}` ); } \ No newline at end of file diff --git a/src/commands/clabCommand.ts b/src/commands/clabCommand.ts index 18fd8f7..698a3da 100644 --- a/src/commands/clabCommand.ts +++ b/src/commands/clabCommand.ts @@ -1,18 +1,18 @@ import * as vscode from "vscode"; import * as cmd from './command'; -import { ContainerlabNode } from '../containerlabTreeDataProvider'; +import { ClabLabTreeNode } from "../clabTreeDataProvider"; /** * A helper class to build a 'containerlab' command (with optional sudo, etc.) * and run it either in the Output channel or in a Terminal. */ export class ClabCommand extends cmd.Command { - private node: ContainerlabNode; + private node?: ClabLabTreeNode; private action: string; constructor( action: string, - node: ContainerlabNode, + node: ClabLabTreeNode, spinnerMsg?: cmd.SpinnerMsg, useTerminal?: boolean, terminalName?: string @@ -26,13 +26,14 @@ export class ClabCommand extends cmd.Command { super(options); this.action = action; - this.node = node; + this.node = node instanceof ClabLabTreeNode ? node : undefined; } public async run(flags?: string[]): Promise { // Try node.details -> fallback to active editor - let labPath = this.node?.details?.labPath; - if (!labPath) { + let labPath: string; + console.log(this.node); + if (!this.node) { const editor = vscode.window.activeTextEditor; if (!editor) { vscode.window.showErrorMessage( @@ -42,6 +43,9 @@ export class ClabCommand extends cmd.Command { } labPath = editor.document.uri.fsPath; } + else { + labPath = this.node.labPath.absolute + } if (!labPath) { vscode.window.showErrorMessage( diff --git a/src/commands/command.ts b/src/commands/command.ts index 9a1cb0c..ba41220 100644 --- a/src/commands/command.ts +++ b/src/commands/command.ts @@ -122,7 +122,6 @@ export class Command { }, async (progress, token) => { return new Promise((resolve, reject) => { - console.log(`xxx: ${cmd[1]}`); const child = spawn(cmd[0], cmd.slice(1)); // If user clicks Cancel, kill the child process diff --git a/src/commands/copy.ts b/src/commands/copy.ts index 9e47187..cd4949c 100644 --- a/src/commands/copy.ts +++ b/src/commands/copy.ts @@ -1,35 +1,35 @@ import * as vscode from "vscode"; import * as utils from "../utils"; -import { ContainerlabNode } from "../containerlabTreeDataProvider"; +import { ClabContainerTreeNode, ClabLabTreeNode } from "../clabTreeDataProvider"; -export function copyLabPath(node: ContainerlabNode) { +export function copyLabPath(node: ClabLabTreeNode) { if (!node) { vscode.window.showErrorMessage('No lab node selected.'); return; } - const labPath = node.details?.labPath; + const labPath = node.labPath.absolute; if (!labPath) { vscode.window.showErrorMessage('No labPath found.'); return; } - const labName = node.details?.labName || utils.getRelativeFolderPath(labPath); + const labName = node.labPath.absolute || utils.getRelativeFolderPath(labPath); vscode.env.clipboard.writeText(labPath).then(() => { vscode.window.showInformationMessage(`Copied file path of ${labName} to clipboard.`); }); } -export function copyContainerIPv4Address(node: ContainerlabNode) { +export function copyContainerIPv4Address(node: ClabContainerTreeNode) { if (!node) { vscode.window.showErrorMessage('No lab node selected.'); return; } - const containerName = node.details?.hostname || ""; + const containerName = node.name || ""; - const data = node.details?.v4Addr; + const data = node.IPv4Address; if (!data) { vscode.window.showErrorMessage(`${containerName}: Could not fetch IPv4 address.`); return; @@ -41,15 +41,15 @@ export function copyContainerIPv4Address(node: ContainerlabNode) { }); } -export function copyContainerIPv6Address(node: ContainerlabNode) { +export function copyContainerIPv6Address(node: ClabContainerTreeNode) { if (!node) { vscode.window.showErrorMessage('No lab node selected.'); return; } - const containerName = node.details?.hostname || ""; + const containerName = node.name || ""; - const data = node.details?.v6Addr; + const data = node.IPv6Address; if (!data) { vscode.window.showErrorMessage(`${containerName}: Could not fetch IPv6 address.`); return; @@ -61,13 +61,13 @@ export function copyContainerIPv6Address(node: ContainerlabNode) { }); } -export function copyContainerName(node: ContainerlabNode) { +export function copyContainerName(node: ClabContainerTreeNode) { if (!node) { vscode.window.showErrorMessage('No lab node selected.'); return; } - const containerName = node.details?.hostname; + const containerName = node.name || ""; if (!containerName) { vscode.window.showErrorMessage(`${containerName}: Could not fetch container hostname.`); @@ -80,15 +80,15 @@ export function copyContainerName(node: ContainerlabNode) { }); } -export function copyContainerID(node: ContainerlabNode) { +export function copyContainerID(node: ClabContainerTreeNode) { if (!node) { vscode.window.showErrorMessage('No lab node selected.'); return; } - const containerName = node.details?.hostname || ""; + const containerName = node.name || ""; - const data = node.details?.containerId; + const data = node.cID; if (!data) { vscode.window.showErrorMessage(`${containerName}: Could not fetch container ID.`); return; @@ -100,15 +100,15 @@ export function copyContainerID(node: ContainerlabNode) { }); } -export function copyContainerKind(node: ContainerlabNode) { +export function copyContainerKind(node: ClabContainerTreeNode) { if (!node) { vscode.window.showErrorMessage('No lab node selected.'); return; } - const containerName = node.details?.hostname || ""; + const containerName = node.name || ""; - const data = node.details?.kind; + const data = node.kind; if (!data) { vscode.window.showErrorMessage(`${containerName}: Could not fetch kind.`); return; @@ -120,15 +120,15 @@ export function copyContainerKind(node: ContainerlabNode) { }); } -export function copyContainerImage(node: ContainerlabNode) { +export function copyContainerImage(node: ClabContainerTreeNode) { if (!node) { vscode.window.showErrorMessage('No lab node selected.'); return; } - const containerName = node.details?.hostname || ""; + const containerName = node.name || ""; - const data = node.details?.image; + const data = node.image; if (!data) { vscode.window.showErrorMessage(`${containerName}: Could not fetch image.`); return; diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index b4a1c39..7422393 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -1,9 +1,9 @@ -import { ContainerlabNode } from "../containerlabTreeDataProvider"; +import { ClabLabTreeNode } from "../clabTreeDataProvider"; import { ClabCommand } from "./clabCommand"; import { SpinnerMsg } from "./command"; import * as vscode from "vscode"; -export function deploy(node: ContainerlabNode) { +export function deploy(node: ClabLabTreeNode) { const spinnerMessages: SpinnerMsg = { progressMsg: "Deploying Lab...", successMsg: "Lab deployed successfully!" @@ -12,7 +12,7 @@ export function deploy(node: ContainerlabNode) { deployCmd.run(); } -export function deployCleanup(node: ContainerlabNode) { +export function deployCleanup(node: ClabLabTreeNode) { const spinnerMessages: SpinnerMsg = { progressMsg: "Deploying Lab (cleanup)...", successMsg: "Lab deployed (cleanup) successfully!" @@ -35,7 +35,7 @@ export function deploySpecificFile() { return; } const picked = uri[0].fsPath; - const tempNode = new ContainerlabNode("", vscode.TreeItemCollapsibleState.None, { labPath: picked }, ""); + const tempNode = new ClabLabTreeNode("", vscode.TreeItemCollapsibleState.None, {absolute: picked, relative: ""}); deploy(tempNode); }); } diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index 475aa76..e36ae6a 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -1,8 +1,8 @@ -import { ContainerlabNode } from "../containerlabTreeDataProvider"; +import { ClabLabTreeNode } from "../clabTreeDataProvider"; import { ClabCommand } from "./clabCommand"; import { SpinnerMsg } from "./command"; -export function destroy(node: ContainerlabNode) { +export function destroy(node: ClabLabTreeNode) { const spinnerMessages: SpinnerMsg = { progressMsg: "Destroying Lab...", successMsg: "Lab destroyed successfully!" @@ -11,7 +11,7 @@ export function destroy(node: ContainerlabNode) { destroyCmd.run(); } -export function destroyCleanup(node: ContainerlabNode) { +export function destroyCleanup(node: ClabLabTreeNode) { const spinnerMessages: SpinnerMsg = { progressMsg: "Destroying Lab (cleanup)...", successMsg: "Lab destroyed (cleanup) successfully!" diff --git a/src/commands/graph.ts b/src/commands/graph.ts index 1f7e625..2585b26 100644 --- a/src/commands/graph.ts +++ b/src/commands/graph.ts @@ -1,14 +1,13 @@ import * as vscode from "vscode"; -import * as path from "path"; import * as fs from "fs"; -import { ContainerlabNode } from "../containerlabTreeDataProvider"; import { ClabCommand } from "./clabCommand"; import { SpinnerMsg } from "./command"; +import { ClabLabTreeNode } from "../clabTreeDataProvider"; /** * Graph Lab (Web) => run in Terminal (no spinner). */ -export function graphNextUI(node: ContainerlabNode) { +export function graphNextUI(node: ClabLabTreeNode) { const graphCmd = new ClabCommand("graph", node, undefined, true, "Graph - Web"); graphCmd.run(); @@ -17,7 +16,7 @@ export function graphNextUI(node: ContainerlabNode) { /** * Graph Lab (draw.io) => use spinner, then open .drawio file in hediet.vscode-drawio */ -export async function graphDrawIO(node: ContainerlabNode) { +export async function graphDrawIO(node: ClabLabTreeNode) { const spinnerMessages: SpinnerMsg = { progressMsg: "Generating DrawIO graph...", successMsg: "DrawIO Graph Completed!", @@ -27,11 +26,11 @@ export async function graphDrawIO(node: ContainerlabNode) { const graphCmd = new ClabCommand("graph", node, spinnerMessages); // Figure out the .drawio filename - if (!node.details?.labPath) { + if (!node.labPath.absolute) { vscode.window.showErrorMessage("No lab path found. Cannot open .drawio file."); return; } - const labPath = node.details.labPath; + const labPath = node.labPath.absolute; const drawioPath = labPath.replace(/\.(ya?ml)$/i, ".drawio"); const drawioUri = vscode.Uri.file(drawioPath); @@ -40,8 +39,8 @@ export async function graphDrawIO(node: ContainerlabNode) { () => { // Verify the file exists if (!fs.existsSync(drawioPath)) { - vscode.window.showErrorMessage( - `Containerlab generated no .drawio file: ${drawioPath}` + return vscode.window.showErrorMessage( + `Containerlab failed to generate .drawio file for lab: ${node.name}.` ); } @@ -53,7 +52,7 @@ export async function graphDrawIO(node: ContainerlabNode) { /** * Graph Lab (draw.io, Interactive) => always run in Terminal */ -export function graphDrawIOInteractive(node: ContainerlabNode) { +export function graphDrawIOInteractive(node: ClabLabTreeNode) { const graphCmd = new ClabCommand("graph", node, undefined, true, "Graph - drawio Interactive"); graphCmd.run(["--drawio", "--drawio-args", `"-I"`]); diff --git a/src/commands/inspect.ts b/src/commands/inspect.ts index 528821a..c52a217 100644 --- a/src/commands/inspect.ts +++ b/src/commands/inspect.ts @@ -1,8 +1,8 @@ import * as vscode from "vscode"; import { promisify } from "util"; import { exec } from "child_process"; -import { ContainerlabNode } from "../containerlabTreeDataProvider"; import { getInspectHtml } from "../webview/inspectHtml"; +import { ClabLabTreeNode } from "../clabTreeDataProvider"; const execAsync = promisify(exec); @@ -17,14 +17,14 @@ export async function inspectAllLabs(context: vscode.ExtensionContext) { } } -export async function inspectOneLab(node: ContainerlabNode, context: vscode.ExtensionContext) { - if (!node?.details?.labPath) { +export async function inspectOneLab(node: ClabLabTreeNode, context: vscode.ExtensionContext) { + if (!node.labPath.absolute) { vscode.window.showErrorMessage("No lab path found for this lab."); return; } try { - const { stdout } = await execAsync(`sudo containerlab inspect -t "${node.details.labPath}" --format json`); + const { stdout } = await execAsync(`sudo containerlab inspect -t "${node.labPath.absolute}" --format json`); const parsed = JSON.parse(stdout); showInspectWebview(parsed.containers || [], `Inspect - ${node.label}`, context.extensionUri); diff --git a/src/commands/openFolderInNewWindow.ts b/src/commands/openFolderInNewWindow.ts index afcabcb..8284f01 100644 --- a/src/commands/openFolderInNewWindow.ts +++ b/src/commands/openFolderInNewWindow.ts @@ -1,15 +1,15 @@ import * as vscode from "vscode"; import * as path from "path"; -import { ContainerlabNode } from "../containerlabTreeDataProvider"; +import { ClabLabTreeNode } from "../clabTreeDataProvider"; -export async function openFolderInNewWindow(node: ContainerlabNode) { - if (!node.details?.labPath) { +export async function openFolderInNewWindow(node: ClabLabTreeNode) { + if (!node.labPath.absolute) { vscode.window.showErrorMessage("No lab path found for this lab."); return; } // The folder that contains the .clab.(yml|yaml) - const folderPath = path.dirname(node.details.labPath); + const folderPath = path.dirname(node.labPath.absolute); const uri = vscode.Uri.file(folderPath); // Force opening that folder in a brand-new window diff --git a/src/commands/openLabFile.ts b/src/commands/openLabFile.ts index 010de2f..ef0afc1 100644 --- a/src/commands/openLabFile.ts +++ b/src/commands/openLabFile.ts @@ -1,13 +1,13 @@ import * as vscode from "vscode"; -import { ContainerlabNode } from "../containerlabTreeDataProvider"; +import { ClabLabTreeNode } from "../clabTreeDataProvider"; -export function openLabFile(node: ContainerlabNode) { +export function openLabFile(node: ClabLabTreeNode) { if (!node) { vscode.window.showErrorMessage('No lab node selected.'); return; } - const labPath = node.details?.labPath; + const labPath = node.labPath.absolute; if (!labPath) { vscode.window.showErrorMessage('No labPath found.'); return; diff --git a/src/commands/redeploy.ts b/src/commands/redeploy.ts index dc4b205..31fd14a 100644 --- a/src/commands/redeploy.ts +++ b/src/commands/redeploy.ts @@ -1,8 +1,8 @@ -import { ContainerlabNode } from "../containerlabTreeDataProvider"; +import { ClabLabTreeNode } from "../clabTreeDataProvider"; import { ClabCommand } from "./clabCommand"; import { SpinnerMsg } from "./command"; -export function redeploy(node: ContainerlabNode) { +export function redeploy(node: ClabLabTreeNode) { const spinnerMessages: SpinnerMsg = { progressMsg: "Redeploying Lab...", successMsg: "Lab redeployed successfully!" @@ -11,7 +11,7 @@ export function redeploy(node: ContainerlabNode) { redeployCmd.run(); } -export function redeployCleanup(node: ContainerlabNode) { +export function redeployCleanup(node: ClabLabTreeNode) { const spinnerMessages: SpinnerMsg = { progressMsg: "Redeploying Lab (cleanup)...", successMsg: "Lab redeployed (cleanup) successfully!" diff --git a/src/commands/showLogs.ts b/src/commands/showLogs.ts index a9f1f48..dbfae07 100644 --- a/src/commands/showLogs.ts +++ b/src/commands/showLogs.ts @@ -1,26 +1,23 @@ import * as vscode from "vscode"; import { execCommandInTerminal } from "./command"; -import { ContainerlabNode } from "../containerlabTreeDataProvider"; +import { ClabContainerTreeNode } from "../clabTreeDataProvider"; +import { getSudo } from "../utils"; -export function showLogs(node: ContainerlabNode) { +export function showLogs(node: ClabContainerTreeNode) { if (!node) { vscode.window.showErrorMessage('No container node selected.'); return; } - const containerId = node.details?.containerId; + const containerId = node.cID; const containerLabel = node.label || "Container"; if (!containerId) { - vscode.window.showErrorMessage('No containerId for logs.'); + vscode.window.showErrorMessage('No containerID for logs.'); return; } - - // Use the sudoEnabledByDefault setting - const config = vscode.workspace.getConfiguration("containerlab"); - const useSudo = config.get("sudoEnabledByDefault", true); - + execCommandInTerminal( - `${useSudo ? "sudo " : ""}docker logs -f ${containerId}`, + `${getSudo()}docker logs -f ${containerId}`, `Logs - ${containerLabel}` ); } \ No newline at end of file diff --git a/src/commands/ssh.ts b/src/commands/ssh.ts index 8c4019a..60f1525 100644 --- a/src/commands/ssh.ts +++ b/src/commands/ssh.ts @@ -1,8 +1,8 @@ import * as vscode from "vscode"; import { execCommandInTerminal } from "./command"; -import { ContainerlabNode } from "../containerlabTreeDataProvider"; +import { ClabContainerTreeNode } from "../clabTreeDataProvider"; -export function sshToNode(node: ContainerlabNode) { +export function sshToNode(node: ClabContainerTreeNode) { if (!node) { vscode.window.showErrorMessage('No container node selected.'); return; @@ -10,11 +10,11 @@ export function sshToNode(node: ContainerlabNode) { let sshTarget: string | undefined; - if(node.details?.hostname) {sshTarget = node.details?.hostname;} - else if(node.details?.v6Addr) {sshTarget = node.details?.hostname;} - else if(node.details?.v4Addr) {sshTarget = node.details?.v4Addr;} - else if(node.details?.containerId) {sshTarget = node.details?.containerId;} - else {return vscode.window.showErrorMessage("No target to connect to container");} + if(node.name) {sshTarget = node.name} + else if(node.v6Address) {sshTarget = node.v6Address;} + else if(node.v4Address) {sshTarget = node.v4Address} + else if(node.cID) {sshTarget = node.cID} + else { return vscode.window.showErrorMessage("No target to connect to container"); } // Pull the default SSH user from settings const config = vscode.workspace.getConfiguration("containerlab"); diff --git a/src/commands/startNode.ts b/src/commands/startNode.ts index 1e69744..f462d2a 100644 --- a/src/commands/startNode.ts +++ b/src/commands/startNode.ts @@ -1,16 +1,16 @@ import * as vscode from "vscode"; -import { ContainerlabNode } from "../containerlabTreeDataProvider"; import { SpinnerMsg } from "./command"; import { DockerCommand } from "./dockerCommand"; +import { ClabContainerTreeNode } from "../clabTreeDataProvider"; -export async function startNode(node: ContainerlabNode) { +export async function startNode(node: ClabContainerTreeNode) { if (!node) { vscode.window.showErrorMessage("No container node selected."); return; } - const containerId = node.details?.containerId; + const containerId = node.cID; if (!containerId) { vscode.window.showErrorMessage("No containerId found."); return; diff --git a/src/commands/stopNode.ts b/src/commands/stopNode.ts index 770772d..2f3f88e 100644 --- a/src/commands/stopNode.ts +++ b/src/commands/stopNode.ts @@ -1,16 +1,16 @@ import * as vscode from "vscode"; -import { ContainerlabNode } from "../containerlabTreeDataProvider"; import { SpinnerMsg } from "./command"; import { DockerCommand } from "./dockerCommand"; +import { ClabContainerTreeNode } from "../clabTreeDataProvider"; -export async function stopNode(node: ContainerlabNode) { +export async function stopNode(node: ClabContainerTreeNode) { if (!node) { vscode.window.showErrorMessage("No container node selected."); return; } - const containerId = node.details?.containerId; + const containerId = node.cID; if (!containerId) { vscode.window.showErrorMessage("No containerId found."); return; diff --git a/src/containerlabTreeDataProvider.ts b/src/containerlabTreeDataProvider.ts deleted file mode 100644 index bad91ed..0000000 --- a/src/containerlabTreeDataProvider.ts +++ /dev/null @@ -1,293 +0,0 @@ -import * as vscode from 'vscode'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import * as path from 'path'; -import * as utils from './utils'; - -const execAsync = promisify(exec); - -export class ContainerlabNode extends vscode.TreeItem { - constructor( - public readonly label: string, - public readonly collapsibleState: vscode.TreeItemCollapsibleState, - public readonly details?: ClabNodeDetails, - contextValue?: string - ) { - super(label, collapsibleState); - this.contextValue = contextValue; - } -} - -interface LabInfo { - labPath: string; - localExists: boolean; - containers: any[]; - labName?: string; - owner?: string; -} - -export interface ClabNodeDetails { - state?: any; - hostname?: string; - containerId?: string; - v4Addr?: string; - v6Addr?: string; - labName?: string; - labPath?: string; - localExists?: boolean; - containers?: any[]; - owner?: string; - image?: string; - kind?: string; -} - -export class ContainerlabTreeDataProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData = new vscode.EventEmitter(); - public readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - - constructor(private context: vscode.ExtensionContext) { } - - getTreeItem(element: ContainerlabNode): vscode.TreeItem { - return element; - } - - async getChildren(element?: ContainerlabNode): Promise { - if (!element) { - return this.getAllLabs(); - } - else { - const info = element.details as LabInfo; - if (info && info.containers.length > 0) { - return this.getContainerNodes(info.containers); - } - return []; - } - } - - public refresh(): void { - this._onDidChangeTreeData.fire(); - } - - private async getAllLabs(): Promise { - const localFiles = await this.findLocalClabFiles(); - const labData = await this.inspectContainerlab(); - - const allPaths = new Set([...Object.keys(labData), ...localFiles]); - if (allPaths.size === 0) { - return [ - new ContainerlabNode('No local .clab files or labs found', vscode.TreeItemCollapsibleState.None) - ]; - } - - const nodes: ContainerlabNode[] = []; - for (const labPath of allPaths) { - const info = labData[labPath] || { labPath, containers: [], labName: undefined, owner: undefined }; - const localExists = localFiles.includes(labPath); - info.localExists = localExists; - - let finalLabel = info.labName; - if (!finalLabel) { - if (localExists) { - finalLabel = path.basename(labPath); - } else { - finalLabel = labPath; - } - } - - if (info.owner) { - finalLabel += ` (${info.owner})`; - } - - let contextVal: string; - let iconFilename: string; - if (info.containers.length === 0) { - // Undeployed - contextVal = "containerlabLabUndeployed"; - iconFilename = "undeployed.svg"; // pick your grey or other color - } else { - // Deployed - contextVal = "containerlabLabDeployed"; - const states = info.containers.map(c => c.state); - const allRunning = states.every(s => s === 'running'); - const noneRunning = states.every(s => s !== 'running'); - if (allRunning) { - iconFilename = "running.svg"; // green circle - } else if (noneRunning) { - iconFilename = "stopped.svg"; // red circle - } else { - iconFilename = "partial.svg"; // yellow circle - } - } - - const collapsible = (info.containers.length > 0) - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.None; - - const node = new ContainerlabNode( - finalLabel, - collapsible, - { - labPath: labPath, - localExists: localExists, - containers: info.containers, - labName: info.labName, - owner: info.owner - }, - contextVal - ); - const iconFile = this.context.asAbsolutePath( - path.join('resources', 'icons', iconFilename) - ); - const iconUri = vscode.Uri.file(iconFile); - node.iconPath = { light: iconUri, dark: iconUri }; - node.description = utils.getRelLabFolderPath(labPath); - nodes.push(node); - } - - // 1) Labs with contextValue === "containerlabLabDeployed" come first - // 2) Then labs with contextValue === "containerlabLabUndeployed" - // 3) Within each group, sort by labPath - nodes.sort((a, b) => { - // First compare contextValue - if (a.contextValue === "containerlabLabDeployed" && b.contextValue === "containerlabLabUndeployed") { - return -1; // a goes first - } - if (a.contextValue === "containerlabLabUndeployed" && b.contextValue === "containerlabLabDeployed") { - return 1; // b goes first - } - // If both have the same contextValue, labPath - return a.details!.labPath!.localeCompare(b.details!.labPath!); - }); - - return nodes; - } - - private getContainerNodes(containers: any[]): ContainerlabNode[] { - const containerNodes = containers.map((ctr: any) => { - let v4Addr, v6Addr; - - let tooltip = [ - `Container: ${ctr.name}`, - `ID: ${ctr.container_id}`, - `State: ${ctr.state}`, - `Kind: ${ctr.kind}`, - `Image: ${ctr.image}` - ] - - if (ctr.ipv4_address) { - v4Addr = ctr.ipv4_address.split('/')[0]; - tooltip.push(`IPv4: ${v4Addr}`); - } - if (ctr.ipv6_address) { - v6Addr = ctr.ipv6_address.split('/')[0]; - tooltip.push(`IPv6: ${v6Addr}`); - } - - const label = `${ctr.name} (${ctr.state})`; - - const node = new ContainerlabNode( - label, - vscode.TreeItemCollapsibleState.None, - { - hostname: ctr.name, - containerId: ctr.container_id, - state: ctr.state, - v4Addr: v4Addr, - v6Addr: v6Addr, - kind: ctr.kind, - image: ctr.image - }, - "containerlabContainer", - ); - node.tooltip = tooltip.join("\n"); - - let iconFilename: string; - if (ctr.state === 'running') { - iconFilename = 'running.svg'; - } else { - iconFilename = 'stopped.svg'; - } - const iconFile = this.context.asAbsolutePath( - path.join('resources', 'icons', iconFilename) - ); - const iconUri = vscode.Uri.file(iconFile); - node.iconPath = { light: iconUri, dark: iconUri }; - - return node; - }); - - // Sort containers by label - containerNodes.sort((a, b) => a.label.localeCompare(b.label)); - return containerNodes; - } - - private async findLocalClabFiles(): Promise { - if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) {return [];} - - const patterns = ['**/*.clab.yml', '**/*.clab.yaml']; - const exclude = '**/node_modules/**'; - - let uris: vscode.Uri[] = []; - for (const pat of patterns) { - const found = await vscode.workspace.findFiles(pat, exclude); - uris.push(...found); - } - - const set = new Set(); - for (const uri of uris) { - set.add(uri.fsPath); - } - return [...set]; - } - - private async inspectContainerlab(): Promise> { - let stdout: string; - try { - const { stdout: out } = await execAsync('sudo containerlab inspect --all --format json'); - stdout = out; - } catch (err) { - console.debug(`Error running containerlab inspect: ${err}`); - return {}; - } - - let parsed: any; - try { - parsed = JSON.parse(stdout); - } catch (err) { - console.debug(`Error parsing containerlab JSON: ${err}`); - parsed = { containers: [] }; - } - - const arr = parsed.containers || []; - const map: Record = {}; - - // Single folder base - let singleFolderBase: string | undefined; - const wsf = vscode.workspace.workspaceFolders; - if (wsf && wsf.length === 1) { - singleFolderBase = wsf[0].uri.fsPath; - } - - for (const c of arr) { - let p = c.labPath || ''; - const original = p; - p = utils.normalizeLabPath(p, singleFolderBase); - console.debug( - `Container: ${c.name}, original path: ${original}, normalized: ${p}` - ); - - if (!map[p]) { - map[p] = { - labPath: p, - localExists: false, - containers: [], - labName: c.lab_name, - owner: c.owner - }; - } - map[p].containers.push(c); - } - - return map; - } -} diff --git a/src/extension.ts b/src/extension.ts index c1fb07f..7983e40 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,4 @@ import * as vscode from 'vscode'; -import { ContainerlabTreeDataProvider } from './containerlabTreeDataProvider'; import { promisify } from 'util'; import { exec } from 'child_process'; import { @@ -31,6 +30,7 @@ import { copyContainerImage, copyContainerKind } from './commands/index'; +import { ClabTreeDataProvider } from './clabTreeDataProvider'; export let outputChannel: vscode.OutputChannel; const execAsync = promisify(exec); @@ -61,7 +61,8 @@ export async function activate(context: vscode.ExtensionContext) { versionOutput = ''; } - const provider = new ContainerlabTreeDataProvider(context); + // const provider = new ContainerlabTreeDataProvider(context); + const provider = new ClabTreeDataProvider(context); vscode.window.registerTreeDataProvider('containerlabExplorer', provider); context.subscriptions.push(vscode.commands.registerCommand('containerlab.refresh', () => { diff --git a/src/utils.ts b/src/utils.ts index 42d4eca..68ce056 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -32,7 +32,7 @@ export function getRelLabFolderPath(labPath: string): string { */ export function normalizeLabPath(labPath: string, singleFolderBase?: string): string { if (!labPath) { - console.debug(`normalizeLabPath: received empty labPath`); + // console.debug(`normalizeLabPath: received empty labPath`); return labPath; } @@ -40,7 +40,7 @@ export function normalizeLabPath(labPath: string, singleFolderBase?: string): st labPath = path.normalize(labPath); if (path.isAbsolute(labPath)) { - console.debug(`normalizeLabPath => absolute: ${originalInput} => ${labPath}`); + // console.debug(`normalizeLabPath => absolute: ${originalInput} => ${labPath}`); return labPath; } @@ -48,9 +48,7 @@ export function normalizeLabPath(labPath: string, singleFolderBase?: string): st const homedir = os.homedir(); const sub = labPath.replace(/^~[\/\\]?/, ''); const expanded = path.normalize(path.join(homedir, sub)); - console.debug( - `normalizeLabPath => tilde expansion: ${originalInput} => ${expanded}` - ); + // console.debug(`normalizeLabPath => tilde expansion: ${originalInput} => ${expanded}`); return expanded; } @@ -62,16 +60,34 @@ export function normalizeLabPath(labPath: string, singleFolderBase?: string): st candidatePaths.push(path.normalize(path.resolve(process.cwd(), labPath))); for (const candidate of candidatePaths) { - console.debug(`normalizeLabPath => checking if path exists: ${candidate}`); + // console.debug(`normalizeLabPath => checking if path exists: ${candidate}`); if (fs.existsSync(candidate)) { - console.debug(`normalizeLabPath => found existing path: ${candidate}`); + // console.debug(`normalizeLabPath => found existing path: ${candidate}`); return candidate; } } const chosen = candidatePaths[0]; - console.debug( - `normalizeLabPath => no candidate path found on disk, fallback to: ${chosen}` - ); + // console.debug(`normalizeLabPath => no candidate path found on disk, fallback to: ${chosen}`); return chosen; +} + +/* + Capitalise the first letter of a string +*/ +export function titleCase(str: string) { + return str[0].toLocaleUpperCase() + str.slice(1); +} + +/** + * Getter which checks the extension config on whether to use sudo or not. + * If sudo is enabled, the sudo string will have a space at the end. + * + * @returns A string which is either "sudo " or blank ("") + */ +export function getSudo() { + const sudo = vscode.workspace.getConfiguration("containerlab").get("sudoEnabledByDefault", true) ? "sudo " : ""; + // console.trace(); + console.log(`[getSudo]:\tReturning: "${sudo}"`); + return sudo; } \ No newline at end of file