From f73fd89fb603f3586690c916e181c2d47ae1fe05 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Sun, 19 Oct 2025 16:33:00 +0200 Subject: [PATCH 01/55] initial try --- src/commands/inspect.ts | 78 +-- src/services/containerlabEvents.ts | 907 ++++++++++++++++++++++++++++ src/treeView/common.ts | 2 + src/treeView/inspector.ts | 49 +- src/treeView/runningLabsProvider.ts | 165 +++-- src/types/containerlab.ts | 15 + 6 files changed, 1069 insertions(+), 147 deletions(-) create mode 100644 src/services/containerlabEvents.ts create mode 100644 src/types/containerlab.ts 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/services/containerlabEvents.ts b/src/services/containerlabEvents.ts new file mode 100644 index 000000000..e5d029dc3 --- /dev/null +++ b/src/services/containerlabEvents.ts @@ -0,0 +1,907 @@ +import { spawn, ChildProcess } from "child_process"; +import * as fs from "fs"; +import * as readline from "readline"; +import * as utils from "../helpers/utils"; +import type { ClabDetailedJSON } from "../treeView/common"; +import type { ClabInterfaceSnapshot } from "../types/containerlab"; + +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; +} + +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(); + +function findContainerlabBinary(): string { + const candidateBins = [ + "/usr/bin/containerlab", + "/usr/local/bin/containerlab", + "/bin/containerlab", + ]; + + for (const candidate of candidateBins) { + try { + if (fs.existsSync(candidate)) { + return candidate; + } + } catch { + // ignore filesystem errors and continue searching + } + } + return "containerlab"; +} + +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 "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 { + switch (action) { + case "kill": + case "die": + case "destroy": + return true; + default: + return false; + } +} + +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": + 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 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 mergedRecord = mergeContainerRecord(existing, record, action); + updateNodeSnapshot(mergedRecord, eventTimestamp, action); + const enrichedRecord = applyNodeSnapshot(mergedRecord); + containersById.set(enrichedRecord.data.ShortID, enrichedRecord); + updateLabMappings(existing, enrichedRecord); + + scheduleInitialResolution(); +} + +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); +} + +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(); + } + return; + } + + if (ifaceName.startsWith("clab-")) { + removeInterfaceRecord(containerId, ifaceName); + return; + } + + const iface: InterfaceRecord = { + ifname: ifaceName, + type: attributes.type, + state: attributes.state, + alias: attributes.alias, + mac: attributes.mac, + mtu: attributes.mtu !== undefined ? Number(attributes.mtu) : undefined, + ifindex: attributes.index !== undefined ? Number(attributes.index) : undefined, + }; + + let ifaceMap = interfacesByContainer.get(containerId); + if (!ifaceMap) { + ifaceMap = new Map(); + interfacesByContainer.set(containerId, ifaceMap); + } + ifaceMap.set(iface.ifname, iface); + + bumpInterfaceVersion(containerId); + scheduleInitialResolution(); +} + +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 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 sudo = utils.getSudo().trim(); + const containerlabBinary = findContainerlabBinary(); + const baseArgs = ["events", "--format", "json", "--initial-state"]; + if (runtime) { + baseArgs.splice(1, 0, "-r", runtime); + } + + let spawned: ChildProcess; + if (sudo) { + const sudoParts = sudo.split(/\s+/).filter(Boolean); + const sudoCmd = sudoParts.shift() || "sudo"; + spawned = spawn(sudoCmd, [...sudoParts, containerlabBinary, ...baseArgs], { stdio: ["ignore", "pipe", "pipe"] }); + } else { + 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(iface => ({ + name: iface.ifname, + type: iface.type || "", + state: iface.state || "", + alias: iface.alias || "", + mac: iface.mac || "", + mtu: iface.mtu ?? 0, + ifindex: iface.ifindex ?? 0, + })); + + 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(); +} diff --git a/src/treeView/common.ts b/src/treeView/common.ts index 8467b4283..8cedb562e 100644 --- a/src/treeView/common.ts +++ b/src/treeView/common.ts @@ -248,6 +248,7 @@ export interface ClabDetailedJSON { Image: string; State: string; Status: string; + StartedAt?: number; Labels: { 'clab-node-kind': string; 'clab-node-lab-dir': string; @@ -298,4 +299,5 @@ export interface ClabJSON { node_type?: string; // Node type (e.g. ixrd3, srlinux, etc.) node_group?: string; // Node group network_name?: string; // Management network name + startedAt?: number; } diff --git a/src/treeView/inspector.ts b/src/treeView/inspector.ts index 40ab88b02..8121a1b87 100644 --- a/src/treeView/inspector.ts +++ b/src/treeView/inspector.ts @@ -1,40 +1,29 @@ import * as vscode from "vscode"; -import * as utils from "../helpers/utils"; import * as c from "./common"; +import { ensureEventStream, getGroupedContainers, getInterfaceSnapshot, getInterfaceVersion as getInterfaceVersionImpl } from "../services/containerlabEvents"; +import type { ClabInterfaceSnapshot } from "../types/containerlab"; -import { promisify } from "util"; -import { exec } from "child_process"; +export let rawInspectData: Record | undefined; -const execAsync = promisify(exec); +export async function update(): Promise { + const config = vscode.workspace.getConfiguration("containerlab"); + const runtime = config.get("runtime", "docker"); -export let rawInspectData: any; -export let transformedInspectData: c.ClabJSON; + console.log("[inspector]:\tUpdating inspect data via events stream"); + const start = Date.now(); -const config = vscode.workspace.getConfiguration("containerlab"); -const runtime = config.get("runtime", "docker"); + await ensureEventStream(runtime); + rawInspectData = getGroupedContainers(); -export async function update() { + const duration = (Date.now() - start) / 1000; + const labsCount = rawInspectData ? Object.keys(rawInspectData).length : 0; + console.log(`[inspector]:\tUpdated inspect data for ${labsCount} labs in ${duration.toFixed(3)} seconds.`); +} - console.log("[inspector]:\tUpdating inspect data"); - const t_start = Date.now() +export function getInterfacesSnapshot(containerShortId: string, containerName: string): ClabInterfaceSnapshot[] { + return getInterfaceSnapshot(containerShortId, containerName); +} - const cmd = `${utils.getSudo()}containerlab inspect -r ${runtime} --all --details --format json 2>/dev/null`; - - let clabStdout; - try { - const { stdout } = await execAsync(cmd); - clabStdout = stdout; - } catch (err) { - throw new Error(`Could not run ${cmd}.\n${err}`); - } - - if (!clabStdout) { - return undefined; - } - - rawInspectData = JSON.parse(clabStdout); - - const duration = (Date.now() - t_start) / 1000; - - console.log(`[inspector]:\tParsed inspect data. Took ${duration} seconds.`); +export function getInterfaceVersion(containerShortId: string): number { + return getInterfaceVersionImpl(containerShortId); } \ No newline at end of file diff --git a/src/treeView/runningLabsProvider.ts b/src/treeView/runningLabsProvider.ts index bbaec3ae9..30b4f54bd 100644 --- a/src/treeView/runningLabsProvider.ts +++ b/src/treeView/runningLabsProvider.ts @@ -4,30 +4,11 @@ import * as c from "./common"; import * as ins from "./inspector" import { FilterUtils } from "../helpers/filterUtils"; -import { execFileSync } from "child_process"; -import * as fs from "fs"; import path = require("path"); import { hideNonOwnedLabsState, runningTreeView, username, favoriteLabs, sshxSessions, refreshSshxSessions, gottySessions, refreshGottySessions } from "../extension"; import { getCurrentTopoViewer } from "../commands/graph"; -/** - * Interface corresponding to fields in the - * the JSON output of 'clab ins interfaces' - */ -interface ClabInsIntfJSON { - name: string, - interfaces: [ - { - name: string, - type: string, - state: string, - alias: string, - mac: string, - mtu: number, - ifindex: number, - } - ] -} +import type { ClabInterfaceSnapshot } from "../types/containerlab"; type RunningTreeNode = c.ClabLabTreeNode | c.ClabContainerTreeNode | c.ClabInterfaceTreeNode; @@ -51,7 +32,7 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider = new Map(); @@ -662,6 +643,8 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider { + const status = this.computeContainerStatus(container); + // Construct IPv4 and IPv6 addresses with prefix length let ipv4Address = "N/A"; if (container.NetworkSettings.IPv4addr && container.NetworkSettings.IPv4pLen !== undefined) { @@ -692,10 +675,11 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider 0) { + return `Up ${hoursPart} ${this.formatQuantity(remainingMinutes, "minute")}`; + } + return `Up ${hoursPart}`; + } + + const totalDays = Math.floor(totalHours / 24); + const remainingHours = totalHours % 24; + const daysPart = this.formatQuantity(totalDays, "day"); + if (remainingHours > 0) { + return `Up ${daysPart} ${this.formatQuantity(remainingHours, "hour")}`; + } + return `Up ${daysPart}`; + } + + private formatQuantity(value: number, unit: string): string { + const quantity = Math.max(1, Math.floor(value)); + const suffix = quantity === 1 ? unit : `${unit}s`; + return `${quantity} ${suffix}`; + } + private createContainerHash(containers: c.ClabJSON[] | undefined): string { if (!containers || containers.length === 0) { return 'empty'; @@ -718,7 +774,8 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider (a.name ?? '').localeCompare(b.name ?? '')); const collapsible = interfaces.length > 0 @@ -1080,53 +1140,30 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider { - try { return fs.existsSync(p); } catch { return false; } - }) || 'containerlab'; - } + const snapshot = ins.getInterfacesSnapshot(cID, cName); + const interfaces = this.buildInterfaceNodes(snapshot, cName, cID); - private getInterfacesJSON(bin: string, absLabPath: string, cName: string): ClabInsIntfJSON[] { - const clabStdout = execFileSync( - bin, - ['inspect', 'interfaces', '-t', absLabPath, '-f', 'json', '-n', cName], - { stdio: ['pipe', 'pipe', 'ignore'], timeout: 10000 } - ).toString(); - return JSON.parse(clabStdout); + this.containerInterfacesCache.set(cacheKey, { + version: currentVersion, + timestamp: Date.now(), + interfaces, + }); + + return interfaces; } - private buildInterfaceNodes(clabInsJSON: ClabInsIntfJSON[], cName: string, cID: string): c.ClabInterfaceTreeNode[] { + private buildInterfaceNodes(clabInsJSON: ClabInterfaceSnapshot[], cName: string, cID: string): c.ClabInterfaceTreeNode[] { const interfaces: c.ClabInterfaceTreeNode[] = []; if (!(clabInsJSON && clabInsJSON.length > 0 && Array.isArray(clabInsJSON[0].interfaces))) { diff --git a/src/types/containerlab.ts b/src/types/containerlab.ts new file mode 100644 index 000000000..3b87c8db0 --- /dev/null +++ b/src/types/containerlab.ts @@ -0,0 +1,15 @@ +export interface ClabInterfaceSnapshotEntry { + name: string; + type: string; + state: string; + alias: string; + mac: string; + mtu: number; + ifindex: number; +} + +export interface ClabInterfaceSnapshot { + name: string; + interfaces: ClabInterfaceSnapshotEntry[]; +} + From 68feb4c0ff40b3363b3d854c195811f1228e6ec0 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Sun, 19 Oct 2025 16:54:37 +0200 Subject: [PATCH 02/55] make it faster --- src/extension.ts | 16 +++++++++++++ src/services/containerlabEvents.ts | 35 +++++++++++++++++++++++++++++ src/treeView/inspector.ts | 6 ++++- src/treeView/runningLabsProvider.ts | 6 +++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/extension.ts b/src/extension.ts index fe581dc59..ed5971a22 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -14,6 +14,7 @@ import { LocalLabTreeDataProvider } from './treeView/localLabsProvider'; import { RunningLabTreeDataProvider } from './treeView/runningLabsProvider'; import { HelpFeedbackProvider } from './treeView/helpFeedbackProvider'; import { registerClabImageCompletion } from './yaml/imageCompletion'; +import { onDataChanged } from "./services/containerlabEvents"; /** Our global output channel */ export let outputChannel: vscode.LogOutputChannel; @@ -395,6 +396,19 @@ function registerCommands(context: vscode.ExtensionContext) { context.subscriptions.push(vscode.commands.registerCommand('containerlab.treeView.localLabs.clearFilter', clearLocalLabsFilterCommand)); } +function registerRealtimeUpdates(context: vscode.ExtensionContext) { + const disposeRealtime = onDataChanged(() => { + ins.refreshFromEventStream(); + if (runningLabsProvider) { + void runningLabsProvider.softRefresh(undefined, { forceInterfaceRefresh: true }).catch(err => { + console.error("[containerlab extension]: realtime refresh failed", err); + }); + } + }); + context.subscriptions.push({ dispose: disposeRealtime }); + ins.refreshFromEventStream(); +} + /** * Called when VSCode activates your extension. */ @@ -479,6 +493,8 @@ export async function activate(context: vscode.ExtensionContext) { canSelectMany: false }); + registerRealtimeUpdates(context); + // get the username username = utils.getUsername(); diff --git a/src/services/containerlabEvents.ts b/src/services/containerlabEvents.ts index e5d029dc3..cfe27da16 100644 --- a/src/services/containerlabEvents.ts +++ b/src/services/containerlabEvents.ts @@ -65,6 +65,29 @@ const labsByName = new Map(); const interfacesByContainer = new Map>(); const interfaceVersions = new Map(); const nodeSnapshots = new Map(); +type DataListener = () => void; +const dataListeners = 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 findContainerlabBinary(): string { const candidateBins = [ @@ -664,6 +687,7 @@ function applyContainerEvent(event: ContainerlabEvent): void { updateLabMappings(existing, enrichedRecord); scheduleInitialResolution(); + scheduleDataChanged(); } function removeContainer(containerShortId: string): void { @@ -684,6 +708,7 @@ function removeContainer(containerShortId: string): void { containersById.delete(containerShortId); interfacesByContainer.delete(containerShortId); interfaceVersions.delete(containerShortId); + scheduleDataChanged(); } function applyInterfaceEvent(event: ContainerlabEvent): void { @@ -702,6 +727,7 @@ function applyInterfaceEvent(event: ContainerlabEvent): void { if (removeInterfaceRecord(containerId, ifaceName)) { bumpInterfaceVersion(containerId); scheduleInitialResolution(); + scheduleDataChanged(); } return; } @@ -730,6 +756,7 @@ function applyInterfaceEvent(event: ContainerlabEvent): void { bumpInterfaceVersion(containerId); scheduleInitialResolution(); + scheduleDataChanged(); } function removeInterfaceRecord(containerId: string, ifaceName: string): boolean { @@ -904,4 +931,12 @@ export function resetForTests(): void { interfacesByContainer.clear(); interfaceVersions.clear(); nodeSnapshots.clear(); + scheduleDataChanged(); +} + +export function onDataChanged(listener: DataListener): () => void { + dataListeners.add(listener); + return () => { + dataListeners.delete(listener); + }; } diff --git a/src/treeView/inspector.ts b/src/treeView/inspector.ts index 8121a1b87..fdb517701 100644 --- a/src/treeView/inspector.ts +++ b/src/treeView/inspector.ts @@ -26,4 +26,8 @@ export function getInterfacesSnapshot(containerShortId: string, containerName: s export function getInterfaceVersion(containerShortId: string): number { return getInterfaceVersionImpl(containerShortId); -} \ No newline at end of file +} + +export function refreshFromEventStream(): void { + rawInspectData = getGroupedContainers(); +} diff --git a/src/treeView/runningLabsProvider.ts b/src/treeView/runningLabsProvider.ts index 30b4f54bd..d100c179c 100644 --- a/src/treeView/runningLabsProvider.ts +++ b/src/treeView/runningLabsProvider.ts @@ -480,6 +480,11 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider Date: Sun, 19 Oct 2025 17:28:54 +0200 Subject: [PATCH 03/55] remove old polling --- README.md | 7 +- package.json | 7 +- src/extension.ts | 54 +-------- src/treeView/runningLabsProvider.ts | 165 +++------------------------- 4 files changed, 19 insertions(+), 214 deletions(-) diff --git a/README.md b/README.md index 74991932d..0f37302d2 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,11 @@ Configure the extension behavior through VS Code settings (`containerlab.*`): |---------|------|---------|-------------| | `sudoEnabledByDefault` | boolean | `false` | Prepend `sudo` to containerlab commands | | `runtime` | string | `docker` | Container runtime (`docker`, `podman`, `ignite`) | -| `refreshInterval` | number | `5000` | Auto-refresh interval in milliseconds | | `showWelcomePage` | boolean | `true` | Show welcome page on activation | | `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 | @@ -165,8 +166,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/package.json b/package.json index 105a00756..93c2dcbb5 100644 --- a/package.json +++ b/package.json @@ -1134,11 +1134,6 @@ "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": { @@ -1409,4 +1404,4 @@ "webpack-cli": "^6.0.1", "yaml": "^2.8.1" } -} \ No newline at end of file +} diff --git a/src/extension.ts b/src/extension.ts index ed5971a22..0645262ca 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -35,9 +35,6 @@ 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; - function extractLabName(session: any, prefix: string): string | undefined { if (typeof session.network === 'string' && session.network.startsWith('clab-')) { @@ -283,30 +280,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], @@ -400,7 +373,7 @@ function registerRealtimeUpdates(context: vscode.ExtensionContext) { const disposeRealtime = onDataChanged(() => { ins.refreshFromEventStream(); if (runningLabsProvider) { - void runningLabsProvider.softRefresh(undefined, { forceInterfaceRefresh: true }).catch(err => { + void runningLabsProvider.softRefresh().catch(err => { console.error("[containerlab extension]: realtime refresh failed", err); }); } @@ -513,31 +486,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 - const config = vscode.workspace.getConfiguration('containerlab'); - 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/treeView/runningLabsProvider.ts b/src/treeView/runningLabsProvider.ts index d100c179c..c7c8fd279 100644 --- a/src/treeView/runningLabsProvider.ts +++ b/src/treeView/runningLabsProvider.ts @@ -18,10 +18,6 @@ interface LabDiscoveryResult { containersToRefresh: Set; } -interface DiscoveryOptions { - forceInterfaceRefresh?: boolean; -} - export class RunningLabTreeDataProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData = new vscode.EventEmitter(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; @@ -29,46 +25,19 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider = new Map(); - - - private containerInterfacesCache: Map = new Map(); - - // Cache for labs: both local and inspect (running) labs. - private labsCache: { - inspect: { data: Record | undefined, timestamp: number, rawDataHash?: string } | null, - } = { inspect: null }; - - private refreshInterval: number = 5000; // Default to ~5 seconds - private cacheTTL: number = 30000; // Default to 30 seconds, will be overridden - private interfaceCacheTTL: number = 5000; // Tracks how long interface data stays fresh + private labsSnapshot: Record | undefined; private context: vscode.ExtensionContext; constructor(context: vscode.ExtensionContext) { this.context = context; - // Get the refresh interval from configuration - const config = vscode.workspace.getConfiguration('containerlab'); - this.refreshInterval = config.get('refreshInterval', 5000); - - const minCacheTtl = Math.max(this.refreshInterval * 3, 30000); - this.cacheTTL = minCacheTtl; - this.interfaceCacheTTL = Math.max(this.refreshInterval, 1000); - - this.startCacheJanitor(); } async refresh(element?: c.ClabLabTreeNode | c.ClabContainerTreeNode) { if (!element) { - // Full refresh - update inspect data and clear interface cache - this.containerInterfacesCache.clear(); - // Don't clear labs cache - let the hash comparison handle it - + // Full refresh - update inspect data from the event stream await ins.update(); - const discovery = await this.discoverLabs({ forceInterfaceRefresh: true }); + const discovery = await this.discoverLabs(); this.emitRefreshEvents(discovery); // Also refresh the topology viewer if it's open @@ -84,12 +53,11 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider this.interfaceCacheTTL) { - anyInterfaceExpired = true; - break; - } - } - - const labsExpired = !!(this.labsCache.inspect && (now - this.labsCache.inspect.timestamp >= this.cacheTTL)); - return anyInterfaceExpired || labsExpired; - } - getTreeItem(element: RunningTreeNode): vscode.TreeItem { return element; } @@ -237,14 +189,14 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider filter(String(it.label))); } - private async discoverLabs(options: DiscoveryOptions = {}): Promise { + private async discoverLabs(): Promise { console.log("[RunningLabTreeDataProvider]:\tDiscovering labs"); const previousCache = this.labNodeCache; const labsToRefresh: Set = new Set(); const containersToRefresh: Set = new Set(); - const globalLabs = await this.discoverInspectLabs(options); // Deployed labs from `clab inspect -a` + const globalLabs = await this.discoverInspectLabs(); // Deployed labs from `clab inspect -a` // --- Combine local and global labs --- // Initialize with global labs (deployed) @@ -765,52 +717,24 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider { - const normPath = container.absLabPath || utils.normalizeLabPath(container.labPath); - const name = container.name || ''; - const state = container.state || ''; - const status = container.status || ''; - const owner = container.owner || ''; - const id = container.container_id || ''; - const kind = container.kind || ''; - const nodeType = container.node_type || ''; - const nodeGroup = container.node_group || ''; - const startedAt = container.startedAt ?? 0; - return `${normPath}|${name}|${state}|${status}|${owner}|${id}|${kind}|${nodeType}|${nodeGroup}|${startedAt}`; - }); - - tokens.sort(); - return tokens.join('~'); - } - - public async discoverInspectLabs(options: DiscoveryOptions = {}): Promise | undefined> { + public async discoverInspectLabs(): Promise | undefined> { console.log("[RunningLabTreeDataProvider]:\tDiscovering labs via inspect..."); const inspectData = await this.getInspectData(); // This now properly handles both formats // --- Normalize inspectData into a flat list of containers --- const allContainers = this.normalizeInspectData(inspectData); - const currentDataHash = this.createContainerHash(allContainers); - - // Check if we have cached data and if the raw data hasn't changed - const cached = this.getCachedInspectIfFresh(currentDataHash, options.forceInterfaceRefresh === true); - if (cached) return cached; if (!inspectData || !allContainers) { this.updateBadge(0); - this.labsCache.inspect = { data: undefined, timestamp: Date.now(), rawDataHash: currentDataHash }; + this.labsSnapshot = undefined; return undefined; } // If after normalization, we have no containers, return undefined if (allContainers.length === 0) { this.updateBadge(0); - this.labsCache.inspect = { data: undefined, timestamp: Date.now(), rawDataHash: currentDataHash }; + this.labsSnapshot = undefined; return undefined; } @@ -821,24 +745,10 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider (a.name ?? '').localeCompare(b.name ?? '')); @@ -1144,29 +1053,11 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider { - const now = Date.now(); - let hasExpired = false; - - // Check for expired container interfaces - this.containerInterfacesCache.forEach((value, key) => { - if (now - value.timestamp >= this.interfaceCacheTTL) { - this.containerInterfacesCache.delete(key); - hasExpired = true; - } - }); - - if (this.labsCache.inspect && now - this.labsCache.inspect.timestamp >= this.cacheTTL) { - this.labsCache.inspect = null; - hasExpired = true; - } - - if (hasExpired) { - const options: DiscoveryOptions = { forceInterfaceRefresh: true }; - void this.softRefresh(undefined, options).catch(err => { - console.error("[RunningLabTreeDataProvider]:\tCache janitor refresh failed", err); - }); - } - }, janitorInterval); - } - // getResourceUri remains unchanged private getResourceUri(resource: string) { return vscode.Uri.file(this.context.asAbsolutePath(path.join("resources", resource))); From 48f74c458914f558cc3bd7cb8e7c79f57f957df9 Mon Sep 17 00:00:00 2001 From: flosch62 Date: Wed, 22 Oct 2025 12:59:29 +0200 Subject: [PATCH 04/55] poc --- src/services/containerlabEvents.ts | 192 ++++++++++++++++-- src/topoViewer/core/topoViewerAdaptorClab.ts | 47 ++++- .../templates/partials/panel-link.html | 5 + .../webview-ui/topologyWebviewController.ts | 162 +++++++++++++-- src/treeView/common.ts | 4 + src/treeView/runningLabsProvider.ts | 99 ++++++++- src/types/containerlab.ts | 22 ++ 7 files changed, 493 insertions(+), 38 deletions(-) diff --git a/src/services/containerlabEvents.ts b/src/services/containerlabEvents.ts index cfe27da16..4345b6759 100644 --- a/src/services/containerlabEvents.ts +++ b/src/services/containerlabEvents.ts @@ -3,7 +3,7 @@ import * as fs from "fs"; import * as readline from "readline"; import * as utils from "../helpers/utils"; import type { ClabDetailedJSON } from "../treeView/common"; -import type { ClabInterfaceSnapshot } from "../types/containerlab"; +import type { ClabInterfaceSnapshot, ClabInterfaceSnapshotEntry } from "../types/containerlab"; interface ContainerlabEvent { timestamp?: string; @@ -29,6 +29,166 @@ interface InterfaceRecord { 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 { @@ -737,22 +897,20 @@ function applyInterfaceEvent(event: ContainerlabEvent): void { return; } - const iface: InterfaceRecord = { - ifname: ifaceName, - type: attributes.type, - state: attributes.state, - alias: attributes.alias, - mac: attributes.mac, - mtu: attributes.mtu !== undefined ? Number(attributes.mtu) : undefined, - ifindex: attributes.index !== undefined ? Number(attributes.index) : undefined, - }; - let ifaceMap = interfacesByContainer.get(containerId); if (!ifaceMap) { ifaceMap = new Map(); interfacesByContainer.set(containerId, ifaceMap); } - ifaceMap.set(iface.ifname, iface); + 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(); @@ -900,15 +1058,7 @@ export function getInterfaceSnapshot(containerShortId: string, containerName: st return []; } - const interfaces = Array.from(ifaceMap.values()).map(iface => ({ - name: iface.ifname, - type: iface.type || "", - state: iface.state || "", - alias: iface.alias || "", - mac: iface.mac || "", - mtu: iface.mtu ?? 0, - ifindex: iface.ifindex ?? 0, - })); + const interfaces = Array.from(ifaceMap.values()).map(toInterfaceSnapshotEntry); interfaces.sort((a, b) => a.name.localeCompare(b.name)); diff --git a/src/topoViewer/core/topoViewerAdaptorClab.ts b/src/topoViewer/core/topoViewerAdaptorClab.ts index 6c9a11822..518dd9c5e 100644 --- a/src/topoViewer/core/topoViewerAdaptorClab.ts +++ b/src/topoViewer/core/topoViewerAdaptorClab.ts @@ -1709,7 +1709,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, @@ -1724,6 +1727,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/templates/partials/panel-link.html b/src/topoViewer/templates/partials/panel-link.html index 3a913bb86..173eb7bae 100644 --- a/src/topoViewer/templates/partials/panel-link.html +++ b/src/topoViewer/templates/partials/panel-link.html @@ -76,6 +76,11 @@ fields.appendChild(createField("MAC address", `${prefix}-mac-address`)); fields.appendChild(createField("MTU", `${prefix}-mtu`)); fields.appendChild(createField("Type", `${prefix}-type`)); + fields.appendChild(createField("RX Throughput", `${prefix}-rx-rate`)); + fields.appendChild(createField("TX Throughput", `${prefix}-tx-rate`)); + fields.appendChild(createField("RX Totals", `${prefix}-rx-total`)); + fields.appendChild(createField("TX Totals", `${prefix}-tx-total`)); + fields.appendChild(createField("Stats Interval", `${prefix}-stats-interval`)); } container.appendChild(fragment); }); diff --git a/src/topoViewer/webview-ui/topologyWebviewController.ts b/src/topoViewer/webview-ui/topologyWebviewController.ts index 7ca698125..197da3c0b 100644 --- a/src/topoViewer/webview-ui/topologyWebviewController.ts +++ b/src/topoViewer/webview-ui/topologyWebviewController.ts @@ -73,6 +73,18 @@ type EditorParamsPayload = { currentLabPath?: string; }; +type InterfaceStatsPayload = { + rxBps?: number; + rxPps?: number; + rxBytes?: number; + rxPackets?: number; + txBps?: number; + txPps?: number; + txBytes?: number; + txPackets?: number; + statsIntervalSeconds?: number; +}; + interface ModeSwitchPayload { mode: 'viewer' | 'editor' | string; deploymentState?: string; @@ -623,6 +635,9 @@ class TopologyWebviewController { existing.removeClass('link-down'); window.writeTopoDebugLog?.(`updateTopology: stripped link state classes from ${id}`); } + if (existing.isEdge()) { + this.refreshLinkPanelIfSelected(existing); + } } else { this.cy.add(el); requiresStyleReload = true; @@ -1602,6 +1617,21 @@ class TopologyWebviewController { this.updateLinkEndpointInfo(ele, extraData); } + private refreshLinkPanelIfSelected(edge: cytoscape.Singular): void { + if (!edge.isEdge()) { + return; + } + const selectedId = topoViewerState.selectedEdge; + if (!selectedId || edge.id() !== selectedId) { + return; + } + const panelLink = document.getElementById('panel-link') as HTMLElement | null; + if (!panelLink || panelLink.style.display === 'none') { + return; + } + this.populateLinkPanel(edge); + } + private updateLinkName(ele: cytoscape.Singular): void { const linkNameEl = document.getElementById('panel-link-name'); if (linkNameEl) { @@ -1610,21 +1640,125 @@ class TopologyWebviewController { } private updateLinkEndpointInfo(ele: cytoscape.Singular, extraData: any): void { - const entries: Array<[string, string | undefined]> = [ - ['panel-link-endpoint-a-name', `${ele.data('source')} :: ${ele.data('sourceEndpoint') || ''}`], - ['panel-link-endpoint-a-mac-address', extraData.clabSourceMacAddress || 'N/A'], - ['panel-link-endpoint-a-mtu', extraData.clabSourceMtu || 'N/A'], - ['panel-link-endpoint-a-type', extraData.clabSourceType || 'N/A'], - ['panel-link-endpoint-b-name', `${ele.data('target')} :: ${ele.data('targetEndpoint') || ''}`], - ['panel-link-endpoint-b-mac-address', extraData.clabTargetMacAddress || 'N/A'], - ['panel-link-endpoint-b-mtu', extraData.clabTargetMtu || 'N/A'], - ['panel-link-endpoint-b-type', extraData.clabTargetType || 'N/A'] - ]; - entries.forEach(([id, value]) => { - const el = document.getElementById(id); - if (el) { - el.textContent = value || ''; + this.setEndpointFields('a', { + name: `${ele.data('source')} :: ${ele.data('sourceEndpoint') || ''}`, + mac: extraData?.clabSourceMacAddress, + mtu: extraData?.clabSourceMtu, + type: extraData?.clabSourceType, + stats: extraData?.clabSourceStats as InterfaceStatsPayload | undefined, + }); + this.setEndpointFields('b', { + name: `${ele.data('target')} :: ${ele.data('targetEndpoint') || ''}`, + mac: extraData?.clabTargetMacAddress, + mtu: extraData?.clabTargetMtu, + type: extraData?.clabTargetType, + stats: extraData?.clabTargetStats as InterfaceStatsPayload | undefined, + }); + } + + private setEndpointFields( + letter: 'a' | 'b', + data: { name: string; mac?: string; mtu?: string | number; type?: string; stats?: InterfaceStatsPayload } + ): void { + const prefix = `panel-link-endpoint-${letter}`; + this.setLabelText(`${prefix}-name`, data.name, 'N/A'); + this.setLabelText(`${prefix}-mac-address`, data.mac, 'N/A'); + this.setLabelText(`${prefix}-mtu`, data.mtu, 'N/A'); + this.setLabelText(`${prefix}-type`, data.type, 'N/A'); + this.setLabelText(`${prefix}-rx-rate`, this.buildRateLine(data.stats, 'rx'), 'N/A'); + this.setLabelText(`${prefix}-tx-rate`, this.buildRateLine(data.stats, 'tx'), 'N/A'); + this.setLabelText(`${prefix}-rx-total`, this.buildCounterLine(data.stats, 'rx'), 'N/A'); + this.setLabelText(`${prefix}-tx-total`, this.buildCounterLine(data.stats, 'tx'), 'N/A'); + this.setLabelText(`${prefix}-stats-interval`, this.buildIntervalLine(data.stats), 'N/A'); + } + + private setLabelText(id: string, value: string | number | undefined, fallback: string): void { + const el = document.getElementById(id); + if (!el) { + return; + } + let text: string; + if (value === undefined) { + text = fallback; + } else if (typeof value === 'number') { + text = value.toLocaleString(); + } else if (value.trim() === '') { + text = fallback; + } else { + text = value; + } + el.textContent = text; + } + + private buildRateLine(stats: InterfaceStatsPayload | undefined, direction: 'rx' | 'tx'): string | undefined { + if (!stats) { + return undefined; + } + const bpsKey = direction === 'rx' ? 'rxBps' : 'txBps'; + const ppsKey = direction === 'rx' ? 'rxPps' : 'txPps'; + const bps = stats[bpsKey]; + const pps = stats[ppsKey]; + + if (typeof bps !== 'number' || !Number.isFinite(bps)) { + if (typeof pps !== 'number' || !Number.isFinite(pps)) { + return undefined; } + return `PPS ${this.formatWithPrecision(pps, 2)}`; + } + + const rateParts = [ + `${this.formatWithPrecision(bps, 0)} bps`, + `${this.formatWithPrecision(bps / 1_000, 2)} Kbps`, + `${this.formatWithPrecision(bps / 1_000_000, 2)} Mbps`, + `${this.formatWithPrecision(bps / 1_000_000_000, 2)} Gbps`, + ]; + + let line = rateParts.join(' / '); + if (typeof pps === 'number' && Number.isFinite(pps)) { + line += ` | PPS: ${this.formatWithPrecision(pps, 2)}`; + } + return line; + } + + private buildCounterLine(stats: InterfaceStatsPayload | undefined, direction: 'rx' | 'tx'): string | undefined { + if (!stats) { + return undefined; + } + const bytesKey = direction === 'rx' ? 'rxBytes' : 'txBytes'; + const packetsKey = direction === 'rx' ? 'rxPackets' : 'txPackets'; + const bytes = stats[bytesKey]; + const packets = stats[packetsKey]; + const segments: string[] = []; + + if (typeof bytes === 'number' && Number.isFinite(bytes)) { + segments.push(`${this.formatWithPrecision(bytes, 0)} bytes`); + } + if (typeof packets === 'number' && Number.isFinite(packets)) { + segments.push(`${this.formatWithPrecision(packets, 0)} packets`); + } + + if (segments.length === 0) { + return undefined; + } + + return segments.join(' / '); + } + + private buildIntervalLine(stats: InterfaceStatsPayload | undefined): string | undefined { + if (!stats) { + return undefined; + } + const interval = stats.statsIntervalSeconds; + if (typeof interval !== 'number' || !Number.isFinite(interval)) { + return undefined; + } + return `${this.formatWithPrecision(interval, 3)} s`; + } + + private formatWithPrecision(value: number, fractionDigits: number): string { + return value.toLocaleString(undefined, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, }); } diff --git a/src/treeView/common.ts b/src/treeView/common.ts index 8cedb562e..e1c6d13bd 100644 --- a/src/treeView/common.ts +++ b/src/treeView/common.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode" +import type { ClabInterfaceStats } from "../types/containerlab"; // LabPath interface export interface LabPath { @@ -166,6 +167,7 @@ export class ClabInterfaceTreeNode extends vscode.TreeItem { public readonly mtu: number; public readonly ifIndex: number; public state: string; // Added state tracking + public readonly stats?: ClabInterfaceStats; constructor( label: string, @@ -180,6 +182,7 @@ export class ClabInterfaceTreeNode extends vscode.TreeItem { ifIndex: number, state: string, contextValue?: string, + stats?: ClabInterfaceStats, ) { super(label, collapsibleState); this.parentName = parentName; @@ -192,6 +195,7 @@ export class ClabInterfaceTreeNode extends vscode.TreeItem { this.ifIndex = ifIndex; this.state = state; this.contextValue = contextValue; + this.stats = stats; } } diff --git a/src/treeView/runningLabsProvider.ts b/src/treeView/runningLabsProvider.ts index c7c8fd279..0b718b505 100644 --- a/src/treeView/runningLabsProvider.ts +++ b/src/treeView/runningLabsProvider.ts @@ -8,7 +8,7 @@ import path = require("path"); import { hideNonOwnedLabsState, runningTreeView, username, favoriteLabs, sshxSessions, refreshSshxSessions, gottySessions, refreshGottySessions } from "../extension"; import { getCurrentTopoViewer } from "../commands/graph"; -import type { ClabInterfaceSnapshot } from "../types/containerlab"; +import type { ClabInterfaceSnapshot, ClabInterfaceSnapshotEntry, ClabInterfaceStats } from "../types/containerlab"; type RunningTreeNode = c.ClabLabTreeNode | c.ClabContainerTreeNode | c.ClabInterfaceTreeNode; @@ -1107,6 +1107,8 @@ export class RunningLabTreeDataProvider implements vscode.TreeDataProvider { + if (typeof value === 'number' && Number.isFinite(value)) { + stats[key] = value; + } + }; + + assign('rxBps', intf.rxBps); + assign('rxPps', intf.rxPps); + assign('rxBytes', intf.rxBytes); + assign('rxPackets', intf.rxPackets); + assign('txBps', intf.txBps); + assign('txPps', intf.txPps); + assign('txBytes', intf.txBytes); + assign('txPackets', intf.txPackets); + assign('statsIntervalSeconds', intf.statsIntervalSeconds); + + return Object.keys(stats).length > 0 ? stats : undefined; + } + + private appendInterfaceStats(tooltipParts: string[], intf: ClabInterfaceSnapshotEntry): void { + const rxRateLine = this.buildRateLine("RX", intf.rxBps, intf.rxPps); + if (rxRateLine) { + tooltipParts.push(rxRateLine); + } + + const txRateLine = this.buildRateLine("TX", intf.txBps, intf.txPps); + if (txRateLine) { + tooltipParts.push(txRateLine); + } + + const rxCounterLine = this.buildCounterLine("RX", intf.rxBytes, intf.rxPackets); + if (rxCounterLine) { + tooltipParts.push(rxCounterLine); + } + + const txCounterLine = this.buildCounterLine("TX", intf.txBytes, intf.txPackets); + if (txCounterLine) { + tooltipParts.push(txCounterLine); + } + + if (typeof intf.statsIntervalSeconds === 'number' && Number.isFinite(intf.statsIntervalSeconds)) { + tooltipParts.push(`Stats Interval: ${this.formatWithPrecision(intf.statsIntervalSeconds, 3)} s`); + } + } + + private buildRateLine(direction: string, bps?: number, pps?: number): string | undefined { + if (typeof bps !== 'number' || !Number.isFinite(bps)) { + if (typeof pps !== 'number' || !Number.isFinite(pps)) { + return undefined; + } + return `${direction}: PPS ${this.formatWithPrecision(pps, 2)}`; + } + + const rateParts = [ + `${this.formatWithPrecision(bps, 0)} bps`, + `${this.formatWithPrecision(bps / 1_000, 2)} Kbps`, + `${this.formatWithPrecision(bps / 1_000_000, 2)} Mbps`, + `${this.formatWithPrecision(bps / 1_000_000_000, 2)} Gbps`, + ]; + + let line = `${direction}: ${rateParts.join(' / ')}`; + if (typeof pps === 'number' && Number.isFinite(pps)) { + line += ` | PPS: ${this.formatWithPrecision(pps, 2)}`; + } + return line; + } + + private buildCounterLine(direction: string, bytes?: number, packets?: number): string | undefined { + const segments: string[] = []; + if (typeof bytes === 'number' && Number.isFinite(bytes)) { + segments.push(`${this.formatWithPrecision(bytes, 0)} bytes`); + } + if (typeof packets === 'number' && Number.isFinite(packets)) { + segments.push(`${this.formatWithPrecision(packets, 0)} packets`); + } + if (segments.length === 0) { + return undefined; + } + return `${direction} Total: ${segments.join(' / ')}`; + } + + private formatWithPrecision(value: number, fractionDigits: number): string { + return value.toLocaleString(undefined, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }); + } + // getResourceUri remains unchanged private getResourceUri(resource: string) { return vscode.Uri.file(this.context.asAbsolutePath(path.join("resources", resource))); diff --git a/src/types/containerlab.ts b/src/types/containerlab.ts index 3b87c8db0..a6c11dcf3 100644 --- a/src/types/containerlab.ts +++ b/src/types/containerlab.ts @@ -6,8 +6,30 @@ export interface ClabInterfaceSnapshotEntry { mac: string; mtu: number; ifindex: number; + rxBps?: number; + rxPps?: number; + rxBytes?: number; + rxPackets?: number; + txBps?: number; + txPps?: number; + txBytes?: number; + txPackets?: number; + statsIntervalSeconds?: number; } +export type ClabInterfaceStats = Pick< + ClabInterfaceSnapshotEntry, + | 'rxBps' + | 'rxPps' + | 'rxBytes' + | 'rxPackets' + | 'txBps' + | 'txPps' + | 'txBytes' + | 'txPackets' + | 'statsIntervalSeconds' +>; + export interface ClabInterfaceSnapshot { name: string; interfaces: ClabInterfaceSnapshotEntry[]; From bd73581653e7dc22011867a08d20353f6be11145 Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Thu, 13 Nov 2025 21:04:39 +0800 Subject: [PATCH 05/55] add setting to allow for custom clab bin path --- package.json | 5 +++++ src/extension.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 93c2dcbb5..20b5b6a96 100644 --- a/package.json +++ b/package.json @@ -1332,6 +1332,11 @@ "type": "boolean", "default": true, "markdownDescription": "Lock the lab canvas by default to prevent accidental modifications. Disable to start new sessions unlocked." + }, + "containerlab.binaryPath": { + "type": "string", + "default": "", + "markdownDescription": "The absolute file path to the Containerlab binary." } } } diff --git a/src/extension.ts b/src/extension.ts index 0645262ca..e2736fdf3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,6 +4,8 @@ import * as utils from './helpers/utils'; 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 { TopoViewerEditor } from './topoViewer/providers/topoViewerEditorWebUiFacade'; import { setCurrentTopoViewer } from './commands/graph'; @@ -35,6 +37,8 @@ export const DOCKER_IMAGES_STATE_KEY = 'dockerImages'; export const extensionVersion = vscode.extensions.getExtension('srl-labs.vscode-containerlab')?.packageJSON.version; +export let containerlabBinaryPath: string = 'containerlab'; + function extractLabName(session: any, prefix: string): string | undefined { if (typeof session.network === 'string' && session.network.startsWith('clab-')) { @@ -56,7 +60,7 @@ function extractLabName(session: any, prefix: string): string | undefined { export async function refreshSshxSessions() { try { const out = await utils.runWithSudo( - 'containerlab tools sshx list -f json', + `${containerlabBinaryPath} tools sshx list -f json`, 'List SSHX sessions', outputChannel, 'containerlab', @@ -83,7 +87,7 @@ export async function refreshSshxSessions() { export async function refreshGottySessions() { try { const out = await utils.runWithSudo( - 'containerlab tools gotty list -f json', + `${containerlabBinaryPath} tools gotty list -f json`, 'List GoTTY sessions', outputChannel, 'containerlab', @@ -382,6 +386,42 @@ function registerRealtimeUpdates(context: vscode.ExtensionContext) { 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 { + 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'); + } + 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 setting: "${configPath}" is not a valid executable.`); + vscode.window.showErrorMessage( + `Configured containerlab binary path "${configPath}" is invalid or not executable.` + ); + } + return false; +} + /** * Called when VSCode activates your extension. */ @@ -392,6 +432,12 @@ export async function activate(context: vscode.ExtensionContext) { outputChannel.info(process.platform); + if (!setClabBinPath()) { + // dont activate + return; + } + + // Allow activation only on Linux or when connected via WSL. // Provide a more helpful message for macOS users with a workaround link. if (process.platform !== "linux" && vscode.env.remoteName !== "wsl") { From a115b4d8769d7d94505aae2c28a9de12cd0b4a80 Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Thu, 13 Nov 2025 21:18:56 +0800 Subject: [PATCH 06/55] migrate containerlab calls to custom bin path --- src/commands/clabCommand.ts | 5 +++-- src/commands/gottyShare.ts | 6 +++--- src/commands/impairments.ts | 3 ++- src/commands/nodeImpairments.ts | 8 ++++---- src/commands/sshxShare.ts | 6 +++--- src/extension.ts | 5 +++-- src/helpers/utils.ts | 9 +++++---- src/services/containerlabEvents.ts | 23 ++--------------------- 8 files changed, 25 insertions(+), 40 deletions(-) diff --git a/src/commands/clabCommand.ts b/src/commands/clabCommand.ts index edd668fe4..c39046c49 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. @@ -22,13 +23,13 @@ export class ClabCommand extends cmd.Command { let options: cmd.CmdOptions; if (useTerminal) { options = { - command: "containerlab", + command: containerlabBinaryPath, useSpinner: false, terminalName: terminalName || "Containerlab", }; } else { options = { - command: "containerlab", + command: containerlabBinaryPath, useSpinner: true, spinnerMsg: spinnerMsg || { progressMsg: `Running ${action}...`, diff --git a/src/commands/gottyShare.ts b/src/commands/gottyShare.ts index 0ce3c74d5..6c822e89e 100644 --- a/src/commands/gottyShare.ts +++ b/src/commands/gottyShare.ts @@ -1,6 +1,6 @@ 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"; @@ -56,7 +56,7 @@ 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 out = await runWithSudo(`${containerlabBinaryPath} tools gotty ${action} -l ${node.name} --port ${port}`, `GoTTY ${action}`, outputChannel, 'containerlab', true, true) as string; const link = await parseGottyLink(out || ''); if (link) { gottySessions.set(node.name, link); @@ -90,7 +90,7 @@ export async function gottyDetach(node: ClabLabTreeNode) { return; } try { - await runWithSudo(`containerlab tools gotty detach -l ${node.name}`, 'GoTTY detach', outputChannel, 'containerlab'); + await runWithSudo(`${containerlabBinaryPath} tools gotty detach -l ${node.name}`, 'GoTTY detach', outputChannel, 'containerlab'); gottySessions.delete(node.name); vscode.window.showInformationMessage('GoTTY session detached'); } catch (err: any) { diff --git a/src/commands/impairments.ts b/src/commands/impairments.ts index 20b9efd28..e35385002 100644 --- a/src/commands/impairments.ts +++ b/src/commands/impairments.ts @@ -3,6 +3,7 @@ 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 +24,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 = `${utils.getSudo()}${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/nodeImpairments.ts b/src/commands/nodeImpairments.ts index 5b45cc9e9..2f23d3d0f 100644 --- a/src/commands/nodeImpairments.ts +++ b/src/commands/nodeImpairments.ts @@ -2,7 +2,7 @@ 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"; type NetemFields = { delay: string; @@ -74,7 +74,7 @@ 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 { @@ -143,7 +143,7 @@ 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( cmd, @@ -179,7 +179,7 @@ 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( cmd, diff --git a/src/commands/sshxShare.ts b/src/commands/sshxShare.ts index 004c904f5..5d28c99cf 100644 --- a/src/commands/sshxShare.ts +++ b/src/commands/sshxShare.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; import { ClabLabTreeNode } from "../treeView/common"; -import { outputChannel, sshxSessions, runningLabsProvider, refreshSshxSessions } from "../extension"; +import { outputChannel, sshxSessions, runningLabsProvider, refreshSshxSessions, containerlabBinaryPath } from "../extension"; import { runWithSudo } from "../helpers/utils"; function parseLink(output: string): string | undefined { @@ -15,7 +15,7 @@ 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 runWithSudo(`${containerlabBinaryPath} tools sshx ${action} -l ${node.name}`, `SSHX ${action}`, outputChannel, 'containerlab', true, true) as string; const link = parseLink(out || ''); if (link) { sshxSessions.set(node.name, link); @@ -49,7 +49,7 @@ export async function sshxDetach(node: ClabLabTreeNode) { return; } try { - await runWithSudo(`containerlab tools sshx detach -l ${node.name}`, 'SSHX detach', outputChannel); + await runWithSudo(`${containerlabBinaryPath} tools sshx detach -l ${node.name}`, 'SSHX detach', outputChannel); sshxSessions.delete(node.name); vscode.window.showInformationMessage('SSHX session detached'); } catch (err: any) { diff --git a/src/extension.ts b/src/extension.ts index e2736fdf3..828af9183 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -392,6 +392,7 @@ function setClabBinPath(): boolean { // 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) { @@ -400,7 +401,7 @@ function setClabBinPath(): boolean { return true; } } catch (err) { - outputChannel.warn('Could not resolve containerlab bin path from sys PATH'); + outputChannel.warn(`Could not resolve containerlab bin path from sys PATH: ${err}`); } containerlabBinaryPath = 'containerlab'; return true; @@ -414,7 +415,7 @@ function setClabBinPath(): boolean { return true; } catch (err) { // Path is invalid or not executable - try to resolve from PATH as fallback - outputChannel.error(`Invalid containerlab.binaryPath setting: "${configPath}" is not a valid executable.`); + outputChannel.error(`Invalid containerlab.binaryPath "${configPath}": ${err}`); vscode.window.showErrorMessage( `Configured containerlab binary path "${configPath}" is invalid or not executable.` ); diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index d86efb2a1..016baf486 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -6,6 +6,7 @@ import { exec } from "child_process"; import * as net from "net"; import { promisify } from "util"; import { ClabLabTreeNode } from "../treeView/common"; +import { containerlabBinaryPath } from "../extension"; const execAsync = promisify(exec); @@ -207,7 +208,7 @@ async function tryRunAsGroupMember( 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"; + checkCommand = `sudo -n ${containerlabBinaryPath} 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 { @@ -410,10 +411,10 @@ export async function checkAndUpdateClabIfNeeded( context: vscode.ExtensionContext ): Promise { try { - log('Running "containerlab version check".', outputChannel); + log(`Running "${containerlabBinaryPath} version check".`, outputChannel); // Run the version check via runWithSudo and capture output. const versionOutputRaw = await runWithSudo( - 'containerlab version check', + `${containerlabBinaryPath} version check`, 'containerlab version check', outputChannel, 'containerlab', @@ -436,7 +437,7 @@ export async function checkAndUpdateClabIfNeeded( context.subscriptions.push( vscode.commands.registerCommand(updateCommandId, async () => { try { - await runWithSudo('containerlab version upgrade', 'Upgrading containerlab', outputChannel, 'generic'); + await runWithSudo(`${containerlabBinaryPath} version upgrade`, 'Upgrading containerlab', outputChannel, 'generic'); vscode.window.showInformationMessage('Containerlab updated successfully!'); log('Containerlab updated successfully.', outputChannel); } catch (err: any) { diff --git a/src/services/containerlabEvents.ts b/src/services/containerlabEvents.ts index 4345b6759..4a3909694 100644 --- a/src/services/containerlabEvents.ts +++ b/src/services/containerlabEvents.ts @@ -1,9 +1,9 @@ import { spawn, ChildProcess } from "child_process"; -import * as fs from "fs"; import * as readline from "readline"; import * as utils from "../helpers/utils"; import type { ClabDetailedJSON } from "../treeView/common"; import type { ClabInterfaceSnapshot, ClabInterfaceSnapshotEntry } from "../types/containerlab"; +import { containerlabBinaryPath } from "../extension"; interface ContainerlabEvent { timestamp?: string; @@ -249,25 +249,6 @@ function scheduleDataChanged(): void { }, DATA_NOTIFY_DELAY_MS); } -function findContainerlabBinary(): string { - const candidateBins = [ - "/usr/bin/containerlab", - "/usr/local/bin/containerlab", - "/bin/containerlab", - ]; - - for (const candidate of candidateBins) { - try { - if (fs.existsSync(candidate)) { - return candidate; - } - } catch { - // ignore filesystem errors and continue searching - } - } - return "containerlab"; -} - function scheduleInitialResolution(): void { if (initialLoadComplete) { return; @@ -963,7 +944,7 @@ function startProcess(runtime: string): void { }); const sudo = utils.getSudo().trim(); - const containerlabBinary = findContainerlabBinary(); + const containerlabBinary = containerlabBinaryPath const baseArgs = ["events", "--format", "json", "--initial-state"]; if (runtime) { baseArgs.splice(1, 0, "-r", runtime); From 10569c51e5ed57392436b60007cb95e006830a0b Mon Sep 17 00:00:00 2001 From: flosch62 Date: Thu, 13 Nov 2025 16:47:09 +0100 Subject: [PATCH 07/55] Guarded the command construction by deriving a binaryPath that falls back to 'containerlab' --- src/commands/clabCommand.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/clabCommand.ts b/src/commands/clabCommand.ts index c39046c49..25e46b9c7 100644 --- a/src/commands/clabCommand.ts +++ b/src/commands/clabCommand.ts @@ -20,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: containerlabBinaryPath, + command: binaryPath, useSpinner: false, terminalName: terminalName || "Containerlab", }; } else { options = { - command: containerlabBinaryPath, + command: binaryPath, useSpinner: true, spinnerMsg: spinnerMsg || { progressMsg: `Running ${action}...`, From 212e2e5fe5c999da720e147535dbf8887f085b34 Mon Sep 17 00:00:00 2001 From: flosch62 Date: Tue, 18 Nov 2025 10:08:34 +0100 Subject: [PATCH 08/55] update container removal logic and mark interfaces as down when stopped --- src/services/containerlabEvents.ts | 46 ++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/src/services/containerlabEvents.ts b/src/services/containerlabEvents.ts index 4a3909694..29b81df3d 100644 --- a/src/services/containerlabEvents.ts +++ b/src/services/containerlabEvents.ts @@ -466,14 +466,9 @@ function isExecAction(action: string | undefined): boolean { } function shouldRemoveContainer(action: string): boolean { - switch (action) { - case "kill": - case "die": - case "destroy": - return true; - default: - return false; - } + // 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( @@ -564,6 +559,9 @@ function shouldResetLifecycleStatus(action: string): boolean { case "start": case "running": case "restart": + case "stop": + case "kill": + case "die": return true; default: return false; @@ -827,6 +825,14 @@ function applyContainerEvent(event: ContainerlabEvent): void { containersById.set(enrichedRecord.data.ShortID, enrichedRecord); updateLabMappings(existing, enrichedRecord); + if (enrichedRecord.data.State !== "running") { + if (markInterfacesDown(enrichedRecord.data.ShortID)) { + scheduleInitialResolution(); + scheduleDataChanged(); + return; + } + } + scheduleInitialResolution(); scheduleDataChanged(); } @@ -916,6 +922,30 @@ function bumpInterfaceVersion(containerId: string): void { interfaceVersions.set(containerId, next); } +function markInterfacesDown(containerId: string): boolean { + const ifaceMap = interfacesByContainer.get(containerId); + if (!ifaceMap || ifaceMap.size === 0) { + return false; + } + + let changed = false; + + for (const [name, iface] of ifaceMap.entries()) { + if ((iface.state || "").toLowerCase() === "down") { + continue; + } + + ifaceMap.set(name, { ...iface, state: "down" }); + changed = true; + } + + if (changed) { + bumpInterfaceVersion(containerId); + } + + return changed; +} + function handleEventLine(line: string): void { const trimmed = line.trim(); if (!trimmed) { From 27a015bf515bfc12236e23e2bb7828633b41cefd Mon Sep 17 00:00:00 2001 From: flosch62 Date: Tue, 18 Nov 2025 10:33:28 +0100 Subject: [PATCH 09/55] pause state --- src/services/containerlabEvents.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/services/containerlabEvents.ts b/src/services/containerlabEvents.ts index 29b81df3d..37c8f2f1b 100644 --- a/src/services/containerlabEvents.ts +++ b/src/services/containerlabEvents.ts @@ -446,6 +446,10 @@ function deriveStateFromAction(action: string): string { case "destroy": case "stop": return "exited"; + case "pause": + return "paused"; + case "unpause": + return "running"; case "start": case "restart": case "running": @@ -559,6 +563,8 @@ function shouldResetLifecycleStatus(action: string): boolean { case "start": case "running": case "restart": + case "pause": + case "unpause": case "stop": case "kill": case "die": @@ -799,6 +805,15 @@ function formatStateLabel(state: string | undefined): string { 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 || ""; @@ -825,7 +840,7 @@ function applyContainerEvent(event: ContainerlabEvent): void { containersById.set(enrichedRecord.data.ShortID, enrichedRecord); updateLabMappings(existing, enrichedRecord); - if (enrichedRecord.data.State !== "running") { + if (shouldMarkInterfacesDown(enrichedRecord.data.State)) { if (markInterfacesDown(enrichedRecord.data.ShortID)) { scheduleInitialResolution(); scheduleDataChanged(); From ff7cbd5dd2d517c30bd86d0e4b599abeec4ba713 Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Sat, 29 Nov 2025 16:21:16 +0800 Subject: [PATCH 10/55] Remove sudoEnabledByDefault setting --- package.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/package.json b/package.json index 027bbd980..6e7948dd4 100644 --- a/package.json +++ b/package.json @@ -1129,11 +1129,6 @@ "configuration": { "title": "Containerlab", "properties": { - "containerlab.sudoEnabledByDefault": { - "type": "boolean", - "default": false, - "description": "Whether to prepend 'sudo' to all containerlab commands by default." - }, "containerlab.node.execCommandMapping": { "type": "object", "additionalProperties": { From 2bbc675bddd9428f69650eb72e43c302532d07dc Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Sat, 29 Nov 2025 16:50:34 +0800 Subject: [PATCH 11/55] Replace sudo function with simple group/perm check --- src/commands/capture.ts | 62 +++++-- src/commands/cloneRepo.ts | 12 +- src/commands/command.ts | 5 +- src/commands/fcli.ts | 3 +- src/commands/gottyShare.ts | 20 ++- src/commands/impairments.ts | 3 +- src/commands/nodeExec.ts | 5 +- src/commands/nodeImpairments.ts | 29 ++-- src/commands/openBrowser.ts | 3 +- src/commands/showLogs.ts | 3 +- src/commands/sshxShare.ts | 18 +- src/extension.ts | 32 +++- src/helpers/utils.ts | 266 +++++++++-------------------- src/services/containerlabEvents.ts | 11 +- test/helpers/utils-stub.ts | 19 ++- 15 files changed, 231 insertions(+), 260 deletions(-) diff --git a/src/commands/capture.ts b/src/commands/capture.ts index a2fc90b01..65041b5bc 100644 --- a/src/commands/capture.ts +++ b/src/commands/capture.ts @@ -223,13 +223,31 @@ 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 psOut = await utils.runCommand( + `docker ps --filter "name=edgeshark" --format "{{.Names}}"`, + 'Get edgeshark container names', + outputChannel, + true, + false + ) 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 netsOut = await utils.runCommand( + `docker inspect ${firstName} --format '{{range .NetworkSettings.Networks}}{{.NetworkID}} {{end}}'`, + 'Get edgeshark network ID', + outputChannel, + true, + false + ) 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 nameOut = await utils.runCommand( + `docker network inspect ${networkId} --format '{{.Name}}'`, + 'Get edgeshark network name', + outputChannel, + true, + false + ) as string; const netName = (nameOut || '').trim(); if (netName) return `--network ${netName}`; } @@ -242,12 +260,12 @@ async function getEdgesharkNetwork(): Promise { async function getVolumeMount(nodeName: string): Promise { try { - const out = await utils.runWithSudo( + const out = await utils.runCommand( `docker inspect ${nodeName} --format '{{index .Config.Labels "clab-node-lab-dir"}}'`, - 'Inspect lab dir label', + 'Get lab directory for volume mount', outputChannel, - 'docker', - true + true, + false ) as string; const labDir = (out || '').trim(); if (labDir && labDir !== '') { @@ -301,7 +319,13 @@ export async function captureEdgesharkVNC( 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; + const out = await utils.runCommand( + command, + 'Start Wireshark VNC container', + outputChannel, + true, + true + ) as string; containerId = (out || '').trim().split(/\s+/)[0] || ''; } catch (err: any) { vscode.window.showErrorMessage(`Starting Wireshark: ${err.message || String(err)}`); @@ -323,7 +347,13 @@ export async function captureEdgesharkVNC( ); panel.onDidDispose(() => { - void utils.runWithSudo(`docker rm -f ${containerId}`, 'Remove Wireshark container', outputChannel, 'docker').catch(() => undefined); + void utils.runCommand( + `docker rm -f ${containerId}`, + 'Remove Wireshark VNC container', + outputChannel, + false, + false + ).catch(() => undefined); }) const iframeUrl = externalUri; @@ -611,16 +641,22 @@ function delay(ms: number): Promise { export async function killAllWiresharkVNCCtrs() { const dockerImage = vscode.workspace.getConfiguration("containerlab").get("capture.wireshark.dockerImage", "ghcr.io/kaelemc/wireshark-vnc-docker:latest") try { - const idsOut = await utils.runWithSudo( + const idsOut = await utils.runCommand( `docker ps --filter "name=clab_vsc_ws-" --filter "ancestor=${dockerImage}" --format "{{.ID}}"`, 'List Wireshark VNC containers', outputChannel, - 'docker', - true + true, + false ) 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'); + await utils.runCommand( + `docker rm -f ${ids.join(' ')}`, + 'Kill all Wireshark VNC containers', + outputChannel, + false, + false + ); } } catch (err: any) { vscode.window.showErrorMessage(`Killing Wireshark container: ${err.message || String(err)}`); diff --git a/src/commands/cloneRepo.ts b/src/commands/cloneRepo.ts index 5f3515741..51b2454ac 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 "../helpers/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..5205f0596 100644 --- a/src/commands/command.ts +++ b/src/commands/command.ts @@ -121,7 +121,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 +131,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 +227,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/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 6c822e89e..60e7232bd 100644 --- a/src/commands/gottyShare.ts +++ b/src/commands/gottyShare.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import { ClabLabTreeNode } from "../treeView/common"; import { outputChannel, gottySessions, runningLabsProvider, refreshGottySessions, containerlabBinaryPath } from "../extension"; import { getHostname } from "./capture"; -import { runWithSudo } from "../helpers/utils"; +import { runCommand } from "../helpers/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(`${containerlabBinaryPath} 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(`${containerlabBinaryPath} 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/impairments.ts b/src/commands/impairments.ts index e35385002..c6fd0254d 100644 --- a/src/commands/impairments.ts +++ b/src/commands/impairments.ts @@ -1,6 +1,5 @@ // ./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"; @@ -24,7 +23,7 @@ async function setImpairment(node: ClabInterfaceTreeNode, impairment?: string, v } const impairmentFlag = impairment ? `--${impairment}` : undefined; if (impairment && !value) { return; } - const cmd = `${utils.getSudo()}${containerlabBinaryPath} 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/nodeExec.ts b/src/commands/nodeExec.ts index 1c6cea5d3..98901f5b9 100644 --- a/src/commands/nodeExec.ts +++ b/src/commands/nodeExec.ts @@ -1,5 +1,4 @@ import * as vscode from "vscode"; -import * as utils from "../helpers/utils"; import { execCommandInTerminal } from "./command"; import { execCmdMapping } from "../extension"; import { ClabContainerTreeNode } from "../treeView/common"; @@ -41,7 +40,7 @@ export function attachShell(node: ClabContainerTreeNode | undefined): void { execCmd = userExecMapping[ctx.containerKind] || execCmd; execCommandInTerminal( - `${utils.getSudo()}${runtime} exec -it ${ctx.containerId} ${execCmd}`, + `${runtime} exec -it ${ctx.containerId} ${execCmd}`, `Shell - ${ctx.container}` ); } @@ -53,7 +52,7 @@ export function telnetToNode(node: ClabContainerTreeNode | undefined): void { const port = (config.get("node.telnetPort") as number) || 5000; const runtime = config.get("runtime", "docker"); execCommandInTerminal( - `${utils.getSudo()}${runtime} exec -it ${ctx.containerId} telnet 127.0.0.1 ${port}`, + `${runtime} exec -it ${ctx.containerId} telnet 127.0.0.1 ${port}`, `Telnet - ${ctx.container}` ); } diff --git a/src/commands/nodeImpairments.ts b/src/commands/nodeImpairments.ts index 2f23d3d0f..009e3b88e 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, containerlabBinaryPath } from "../extension"; +import { runCommand } from "../helpers/utils"; type NetemFields = { delay: string; @@ -78,17 +78,16 @@ async function refreshNetemSettings(node: ClabContainerTreeNode): Promise = {}; 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) => { @@ -145,11 +144,12 @@ async function applyNetem( if (netemArgs.length > 0) { 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 ) ); } @@ -181,11 +181,12 @@ async function clearNetem( const cmd = `${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..7aa558f60 100644 --- a/src/commands/openBrowser.ts +++ b/src/commands/openBrowser.ts @@ -3,7 +3,6 @@ import { promisify } from "util"; import { exec } from "child_process"; import { outputChannel } from "../extension"; import { ClabContainerTreeNode } from "../treeView/common"; -import { getSudo } from "../helpers/utils"; const execAsync = promisify(exec); @@ -76,7 +75,7 @@ async function getExposedPorts(containerId: string): Promise { const runtime = config.get("runtime", "docker"); // Use the 'port' command which gives cleaner output format - const command = `${getSudo()}${runtime} port ${containerId}`; + const command = `${runtime} port ${containerId}`; const { stdout, stderr } = await execAsync(command); 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/sshxShare.ts b/src/commands/sshxShare.ts index 5d28c99cf..0a88d927e 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, containerlabBinaryPath } from "../extension"; -import { runWithSudo } from "../helpers/utils"; +import { runCommand } from "../helpers/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(`${containerlabBinaryPath} 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(`${containerlabBinaryPath} 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/extension.ts b/src/extension.ts index ef57c9092..96b51bc0c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -104,12 +104,12 @@ function extractLabName(session: any, prefix: string): string | undefined { export async function refreshSshxSessions() { try { - const out = await utils.runWithSudo( + 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) { @@ -131,12 +131,12 @@ export async function refreshSshxSessions() { export async function refreshGottySessions() { try { - const out = await utils.runWithSudo( + 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) { @@ -475,11 +475,12 @@ 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}`); if (!setClabBinPath()) { // dont activate + outputChannel.error(`Error setting containerlab binary. Exiting activation.`); return; } @@ -507,7 +508,20 @@ export async function activate(context: vscode.ExtensionContext) { return; } - // 2) If installed, check for updates + outputChannel.debug(`Starting user permissions check`); + // 2) Check if user has required permissions + const userInfo = utils.getUserInfo(); + 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})`); + + // 3) If installed, check for updates utils.checkAndUpdateClabIfNeeded(outputChannel, context).catch(err => { outputChannel.error(`Update check error: ${err.message}`); }); diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index 014ebfa69..1c940b536 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -2,11 +2,11 @@ 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 { exec, execSync } from "child_process"; import * as net from "net"; import { promisify } from "util"; import { ClabLabTreeNode } from "../treeView/common"; -import { containerlabBinaryPath } from "../extension"; +import { containerlabBinaryPath, outputChannel } from "../extension"; const execAsync = promisify(exec); @@ -81,15 +81,71 @@ 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; +// Get relevant user information we need to validate permissions. +export function getUserInfo(): { + hasPermission: boolean; + isRoot: boolean; + userGroups: string[]; + username: string; + uid: number; +} { + try { + // Check if running as root + // eslint-disable-next-line sonarjs/no-os-command-from-path + const uidOut = execSync('id -u', { encoding: 'utf-8' }); + const uid = parseInt(uidOut.trim(), 10); + const isRoot = uid === 0; + + // Get username + // eslint-disable-next-line sonarjs/no-os-command-from-path + const usernameOut = execSync('id -un', { encoding: 'utf-8' }); + const username = usernameOut.trim(); + + // Check group membership + // eslint-disable-next-line sonarjs/no-os-command-from-path + const groupsOut = execSync('id -nG', { encoding: 'utf-8' }); + const userGroups = groupsOut.trim().split(/\s+/); + + if (isRoot) { + return { + hasPermission: true, + isRoot: true, + userGroups, + username, + uid + }; + } + + const isMemberOfClabAdmins = userGroups.includes('clab_admins'); + const isMemberOfDocker = userGroups.includes('docker'); + + if (isMemberOfClabAdmins && isMemberOfDocker) { + return { + hasPermission: true, + isRoot: false, + userGroups, + username, + uid + }; + } else { + return { + hasPermission: false, + isRoot: false, + userGroups, + username, + uid + }; + } + } catch (err: any) { + outputChannel.error(`User info check failed: ${err}`) + return { + hasPermission: false, + isRoot: false, + userGroups: [], + username: '', + uid: -1 + }; + } } /** @@ -152,13 +208,6 @@ 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, @@ -175,186 +224,32 @@ async function runAndLog( 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 ${containerlabBinaryPath} 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. + * Runs a command and logs output to the channel. + * If `returnOutput` is true, the function returns the command's stdout as a string. */ -export async function runWithSudo( +export async function runCommand( 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 - ); + log(`Running: ${command}`, outputChannel); + try { + return await runAndLog(command, description, outputChannel, returnOutput, includeStderr); + } catch (err) { + throw new Error(`Command failed: ${command}\n${(err as Error).message}`); } - - return runWithPasswordPrompt( - command, - description, - outputChannel, - returnOutput, - includeStderr - ); } /** - * Installs containerlab using the official installer script, via sudo. + * Installs containerlab using the official installer script. */ 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'); + await runCommand(installerCmd, 'Installing containerlab', outputChannel); } /** @@ -416,7 +311,6 @@ export async function ensureClabInstalled(outputChannel: vscode.LogOutputChannel /** * 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, @@ -424,12 +318,10 @@ export async function checkAndUpdateClabIfNeeded( ): Promise { try { log(`Running "${containerlabBinaryPath} version check".`, outputChannel); - // Run the version check via runWithSudo and capture output. - const versionOutputRaw = await runWithSudo( + const versionOutputRaw = await runCommand( `${containerlabBinaryPath} version check`, 'containerlab version check', outputChannel, - 'containerlab', true ); const versionOutput = (versionOutputRaw || "").trim(); @@ -449,7 +341,7 @@ export async function checkAndUpdateClabIfNeeded( context.subscriptions.push( vscode.commands.registerCommand(updateCommandId, async () => { try { - await runWithSudo(`${containerlabBinaryPath} version upgrade`, 'Upgrading containerlab', outputChannel, 'generic'); + await runCommand(`${containerlabBinaryPath} version upgrade`, 'Upgrading containerlab', outputChannel); vscode.window.showInformationMessage('Containerlab updated successfully!'); log('Containerlab updated successfully.', outputChannel); } catch (err: any) { diff --git a/src/services/containerlabEvents.ts b/src/services/containerlabEvents.ts index 37c8f2f1b..5403ccb11 100644 --- a/src/services/containerlabEvents.ts +++ b/src/services/containerlabEvents.ts @@ -1,6 +1,5 @@ import { spawn, ChildProcess } from "child_process"; import * as readline from "readline"; -import * as utils from "../helpers/utils"; import type { ClabDetailedJSON } from "../treeView/common"; import type { ClabInterfaceSnapshot, ClabInterfaceSnapshotEntry } from "../types/containerlab"; import { containerlabBinaryPath } from "../extension"; @@ -988,21 +987,13 @@ function startProcess(runtime: string): void { rejectInitialLoad = reject; }); - const sudo = utils.getSudo().trim(); const containerlabBinary = containerlabBinaryPath const baseArgs = ["events", "--format", "json", "--initial-state"]; if (runtime) { baseArgs.splice(1, 0, "-r", runtime); } - let spawned: ChildProcess; - if (sudo) { - const sudoParts = sudo.split(/\s+/).filter(Boolean); - const sudoCmd = sudoParts.shift() || "sudo"; - spawned = spawn(sudoCmd, [...sudoParts, containerlabBinary, ...baseArgs], { stdio: ["ignore", "pipe", "pipe"] }); - } else { - spawned = spawn(containerlabBinary, baseArgs, { stdio: ["ignore", "pipe", "pipe"] }); - } + const spawned = spawn(containerlabBinary, baseArgs, { stdio: ["ignore", "pipe", "pipe"] }); child = spawned; if (!spawned.stdout) { diff --git a/test/helpers/utils-stub.ts b/test/helpers/utils-stub.ts index bdfb1a410..0ed615483 100644 --- a/test/helpers/utils-stub.ts +++ b/test/helpers/utils-stub.ts @@ -5,7 +5,7 @@ export function setOutput(out: string) { output = out; } -export async function runWithSudo(command: string, ..._args: any[]): Promise { +export async function runCommand(command: string, ..._args: any[]): Promise { calls.push(command); if (_args.length > 0) { // no-op to consume args for linter @@ -13,8 +13,21 @@ export async function runWithSudo(command: string, ..._args: any[]): Promise { From 2cdfcdc3f383ff7c2dc71cce25159343810afb2d Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Sat, 29 Nov 2025 17:07:27 +0800 Subject: [PATCH 12/55] Streamline clab install/update activation flow --- package.json | 4 +-- src/extension.ts | 52 ++++++++++++++++----------- src/helpers/utils.ts | 83 ++++++-------------------------------------- 3 files changed, 45 insertions(+), 94 deletions(-) diff --git a/package.json b/package.json index 6e7948dd4..50512ab35 100644 --- a/package.json +++ b/package.json @@ -1195,10 +1195,10 @@ "default": true, "description": "Show the welcome page when the extension activates." }, - "containerlab.skipInstallationCheck": { + "containerlab.skipUpdateCheck": { "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." + "markdownDescription": "Skip checking for containerlab updates during activation." }, "containerlab.node.telnetPort": { "type": "number", diff --git a/src/extension.ts b/src/extension.ts index 96b51bc0c..7ad674e42 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -478,12 +478,6 @@ export async function activate(context: vscode.ExtensionContext) { outputChannel.info('Registered output channel sucessfully.'); outputChannel.info(`Detected platform: ${process.platform}`); - if (!setClabBinPath()) { - // dont activate - outputChannel.error(`Error setting containerlab binary. Exiting activation.`); - return; - } - const config = vscode.workspace.getConfiguration('containerlab'); const isSupportedPlatform = process.platform === "linux" || vscode.env.remoteName === "wsl"; @@ -494,22 +488,37 @@ 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; } + outputChannel.info('Containerlab extension activated.'); + outputChannel.debug(`Starting user permissions check`); - // 2) Check if user has required permissions + // 1) Check if user has required permissions const userInfo = utils.getUserInfo(); if (!userInfo.hasPermission) { outputChannel.error(`User '${userInfo.username}' (id:${userInfo.uid}) has insufficient permissions`); @@ -521,10 +530,13 @@ export async function activate(context: vscode.ExtensionContext) { } outputChannel.debug(`Permission check success for user '${userInfo.username}' (id:${userInfo.uid})`); - // 3) If installed, check for updates - utils.checkAndUpdateClabIfNeeded(outputChannel, context).catch(err => { - outputChannel.error(`Update check error: ${err.message}`); - }); + // 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}`); + }); + } // Show welcome page const welcomePage = new WelcomePage(context); diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index 1c940b536..964277b23 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -243,81 +243,23 @@ export async function runCommand( } } -/** - * Installs containerlab using the official installer script. - */ -export async function installContainerlab(outputChannel: vscode.LogOutputChannel): Promise { - log(`Installing containerlab...`, outputChannel); - const installerCmd = `curl -sL https://containerlab.dev/setup | bash -s "all"`; - await runCommand(installerCmd, 'Installing containerlab', outputChannel); -} - -/** - * 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; - } +// Launch clab install script in a terminal. Let the shell handle sudo passwords. +export function installContainerlab(): void { + const terminal = vscode.window.createTerminal('Containerlab Installation'); + terminal.show(); + terminal.sendText('curl -sL https://containerlab.dev/setup | sudo bash -s "all"'); } /** * Checks if containerlab is up to date, and if not, prompts the user to update it. + * Handled in a terminal to let the shell handle sudo passwords */ export async function checkAndUpdateClabIfNeeded( outputChannel: vscode.LogOutputChannel, context: vscode.ExtensionContext ): Promise { try { - log(`Running "${containerlabBinaryPath} version check".`, outputChannel); const versionOutputRaw = await runCommand( `${containerlabBinaryPath} version check`, 'containerlab version check', @@ -339,14 +281,11 @@ export async function checkAndUpdateClabIfNeeded( // Register command for performing the update const updateCommandId = 'containerlab.updateClab'; context.subscriptions.push( - vscode.commands.registerCommand(updateCommandId, async () => { - try { - await runCommand(`${containerlabBinaryPath} version upgrade`, 'Upgrading containerlab', outputChannel); - vscode.window.showInformationMessage('Containerlab updated successfully!'); - log('Containerlab updated successfully.', outputChannel); - } catch (err: any) { - vscode.window.showErrorMessage(`Update failed: ${err.message}`); - } + vscode.commands.registerCommand(updateCommandId, () => { + const terminal = vscode.window.createTerminal('Containerlab Update'); + terminal.show(); + terminal.sendText(`${containerlabBinaryPath} version upgrade`); + vscode.window.showInformationMessage(`Containerlab update started in terminal 'Containerlab Update'. Please follow the prompts.`); }) ); From ad85e5f031c1db879fd817acf3ccad9c78d6c2e0 Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Sat, 29 Nov 2025 17:08:54 +0800 Subject: [PATCH 13/55] Add dockerode package --- package-lock.json | 342 +++++++++++++++++++++++++++++++++++++++++++--- package.json | 3 +- 2 files changed, 326 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3df1f0f7a..960d14398 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,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", @@ -333,6 +334,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 +1047,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 +1279,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 +1403,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", @@ -3029,6 +3174,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 +3332,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/baseline-browser-mapping": { "version": "2.8.29", @@ -3190,6 +3344,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 +3386,6 @@ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -3341,7 +3504,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -3372,6 +3534,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 +3779,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 +4014,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 +4505,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 +6117,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 +6708,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 +6785,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 +8145,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 +8267,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 +8536,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 +8779,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 +9885,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 +10226,6 @@ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -10846,6 +11123,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 +11137,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 +11175,6 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -11226,7 +11527,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 +11540,6 @@ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -11457,6 +11756,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", diff --git a/package.json b/package.json index 50512ab35..cb582a778 100644 --- a/package.json +++ b/package.json @@ -1414,6 +1414,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.47.0", "webpack-cli": "^6.0.1", - "yaml": "^2.8.1" + "yaml": "^2.8.1", + "dockerode": "^4.0.9" } } From 610348dd3fd7ebaa7e4d9fd8a233efa76468476a Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Sat, 29 Nov 2025 22:42:00 +0800 Subject: [PATCH 14/55] Migration to dockerode + refactoring for related docker parts. --- README.md | 4 +- eslint.config.mjs | 7 +- package-lock.json | 51 ++ package.json | 441 +++++++------- src/commands/attachShell.ts | 1 - src/commands/capture.ts | 572 +++++++----------- src/commands/cloneRepo.ts | 2 +- src/commands/command.ts | 2 +- src/commands/copy.ts | 2 +- src/commands/dockerCommand.ts | 29 - src/commands/gottyShare.ts | 2 +- src/commands/graph.ts | 2 +- src/commands/index.ts | 6 +- src/commands/nodeActions.ts | 30 +- src/commands/nodeExec.ts | 5 +- src/commands/nodeImpairments.ts | 2 +- src/commands/openBrowser.ts | 213 +++---- src/commands/runClabAction.ts | 2 +- src/commands/sshxShare.ts | 2 +- src/commands/startNode.ts | 1 - src/commands/stopNode.ts | 1 - src/commands/telnet.ts | 1 - src/extension.ts | 60 +- .../providers/topoViewerEditorWebUiFacade.ts | 18 +- src/treeView/localLabsProvider.ts | 2 +- src/treeView/runningLabsProvider.ts | 2 +- src/utils/async.ts | 4 + src/utils/clab.ts | 3 + src/utils/consts.ts | 19 + src/utils/docker/docker.ts | 165 +++++ src/utils/docker/images.ts | 101 ++++ src/utils/index.ts | 8 + src/utils/packetflix.ts | 225 +++++++ src/{helpers => utils}/utils.ts | 14 +- src/utils/webview.ts | 23 + src/yaml/imageCompletion.ts | 220 +++++-- 36 files changed, 1410 insertions(+), 832 deletions(-) delete mode 100644 src/commands/attachShell.ts delete mode 100644 src/commands/dockerCommand.ts delete mode 100644 src/commands/startNode.ts delete mode 100644 src/commands/stopNode.ts delete mode 100644 src/commands/telnet.ts create mode 100644 src/utils/async.ts create mode 100644 src/utils/clab.ts create mode 100644 src/utils/consts.ts create mode 100644 src/utils/docker/docker.ts create mode 100644 src/utils/docker/images.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/packetflix.ts rename src/{helpers => utils}/utils.ts (97%) create mode 100644 src/utils/webview.ts diff --git a/README.md b/README.md index abbfe0cd2..4497fc7a9 100644 --- a/README.md +++ b/README.md @@ -129,8 +129,8 @@ The Containerlab Explorer listens to the containerlab event stream, so running l | `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.remoteHostname` | string | `""` | Hostname/IP for Edgeshark packet capture | +| `capture.packetflixPort` | number | `5001` | Port for Packetflix endpoint (Edgeshark) | ### 🌐 Lab Sharing 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 960d14398..282140718 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", @@ -2109,6 +2110,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", @@ -2258,6 +2282,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", diff --git a/package.json b/package.json index cb582a778..867c491c9 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,220 +1144,226 @@ "command": "containerlab.lab.graph.topoViewer" } ], - "configuration": { - "title": "Containerlab", - "properties": { - "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.skipUpdateCheck": { - "type": "boolean", - "default": false, - "markdownDescription": "Skip checking for containerlab updates during activation." - }, - "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." + } + } + }, + { + "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" + }, + "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." }, - "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.binaryPath": { - "type": "string", - "default": "", - "markdownDescription": "The absolute file path to the Containerlab binary." + "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 .", @@ -1366,6 +1390,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", @@ -1390,6 +1415,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", @@ -1414,7 +1440,6 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.47.0", "webpack-cli": "^6.0.1", - "yaml": "^2.8.1", - "dockerode": "^4.0.9" + "yaml": "^2.8.1" } -} +} \ No newline at end of file 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 65041b5bc..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,34 +103,42 @@ function isDarkModeEnabled(themeSetting?: string): boolean { async function getEdgesharkNetwork(): Promise { try { - const psOut = await utils.runCommand( - `docker ps --filter "name=edgeshark" --format "{{.Names}}"`, - 'Get edgeshark container names', - outputChannel, - true, - false - ) as string; - const firstName = (psOut || '').split(/\r?\n/).find(Boolean)?.trim() || ''; - if (firstName) { - const netsOut = await utils.runCommand( - `docker inspect ${firstName} --format '{{range .NetworkSettings.Networks}}{{.NetworkID}} {{end}}'`, - 'Get edgeshark network ID', - outputChannel, - true, - false - ) as string; - const networkId = (netsOut || '').trim().split(/\s+/)[0] || ''; - if (networkId) { - const nameOut = await utils.runCommand( - `docker network inspect ${networkId} --format '{{.Name}}'`, - 'Get edgeshark network name', - outputChannel, - true, - false - ) 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 @@ -260,14 +148,15 @@ async function getEdgesharkNetwork(): Promise { async function getVolumeMount(nodeName: string): Promise { try { - const out = await utils.runCommand( - `docker inspect ${nodeName} --format '{{index .Config.Labels "clab-node-lab-dir"}}'`, - 'Get lab directory for volume mount', - outputChannel, - true, - false - ) 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() @@ -291,44 +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[]) { + + // 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 genPacketflixURI(node, allSelectedNodes, true) + 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.runCommand( - command, - 'Start Wireshark VNC container', - outputChannel, - 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; } @@ -346,14 +293,22 @@ export async function captureEdgesharkVNC( } ); - panel.onDidDispose(() => { - void utils.runCommand( - `docker rm -f ${containerId}`, - 'Remove Wireshark VNC container', - outputChannel, - false, - false - ).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; @@ -585,185 +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.runCommand( - `docker ps --filter "name=clab_vsc_ws-" --filter "ancestor=${dockerImage}" --format "{{.ID}}"`, - 'List Wireshark VNC containers', - outputChannel, - true, - false - ) as string; - const ids = (idsOut || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean); - if (ids.length > 0) { - await utils.runCommand( - `docker rm -f ${ids.join(' ')}`, - 'Kill all Wireshark VNC containers', - outputChannel, - false, - false - ); + 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; - } -} -/** - * 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; - } + const ctrNamePrefix = `${WIRESHARK_VNC_CTR_NAME_PREFIX}-${username}`; - // 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/cloneRepo.ts b/src/commands/cloneRepo.ts index 51b2454ac..450157e1f 100644 --- a/src/commands/cloneRepo.ts +++ b/src/commands/cloneRepo.ts @@ -3,7 +3,7 @@ import * as path from "path"; import * as os from "os"; import * as fs from "fs"; import { outputChannel } from "../extension"; -import { runCommand } from "../helpers/utils"; +import { runCommand } from "../utils/utils"; export async function cloneRepoFromUrl(repoUrl?: string) { if (!repoUrl) { diff --git a/src/commands/command.ts b/src/commands/command.ts index 5205f0596..036b6e381 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'; 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/gottyShare.ts b/src/commands/gottyShare.ts index 60e7232bd..e1fd8cd32 100644 --- a/src/commands/gottyShare.ts +++ b/src/commands/gottyShare.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import { ClabLabTreeNode } from "../treeView/common"; import { outputChannel, gottySessions, runningLabsProvider, refreshGottySessions, containerlabBinaryPath } from "../extension"; import { getHostname } from "./capture"; -import { runCommand } from "../helpers/utils"; +import { runCommand } from "../utils/utils"; async function parseGottyLink(output: string): Promise { try { 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/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/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 98901f5b9..6057252ad 100644 --- a/src/commands/nodeExec.ts +++ b/src/commands/nodeExec.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; 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; @@ -32,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"); @@ -49,7 +50,7 @@ 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( `${runtime} exec -it ${ctx.containerId} telnet 127.0.0.1 ${port}`, diff --git a/src/commands/nodeImpairments.ts b/src/commands/nodeImpairments.ts index 009e3b88e..e9642724d 100644 --- a/src/commands/nodeImpairments.ts +++ b/src/commands/nodeImpairments.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import { ClabContainerTreeNode } from "../treeView/common"; import { getNodeImpairmentsHtml } from "../webview/nodeImpairmentsHtml"; import { outputChannel, containerlabBinaryPath } from "../extension"; -import { runCommand } from "../helpers/utils"; +import { runCommand } from "../utils/utils"; type NetemFields = { delay: string; diff --git a/src/commands/openBrowser.ts b/src/commands/openBrowser.ts index 7aa558f60..e0b86de4d 100644 --- a/src/commands/openBrowser.ts +++ b/src/commands/openBrowser.ts @@ -1,11 +1,7 @@ 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"; -const execAsync = promisify(exec); - interface PortMapping { containerPort: string; hostPort: string; @@ -18,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 = `${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/sshxShare.ts b/src/commands/sshxShare.ts index 0a88d927e..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, containerlabBinaryPath } from "../extension"; -import { runCommand } from "../helpers/utils"; +import { runCommand } from "../utils/utils"; function parseLink(output: string): string | undefined { const re = /(https?:\/\/\S+)/; 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 7ad674e42..d7e9d2c5b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,11 +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'; @@ -33,11 +34,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; export let containerlabBinaryPath: string = 'containerlab'; +export let dockerClient: Docker; function registerUnsupportedViews(context: vscode.ExtensionContext) { let warningShown = false; @@ -162,30 +163,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'; @@ -363,6 +340,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], @@ -520,6 +499,7 @@ export async function activate(context: vscode.ExtensionContext) { 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`); @@ -538,6 +518,31 @@ export async function activate(context: vscode.ExtensionContext) { }); } + /** + * 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); await welcomePage.show(); @@ -575,9 +580,6 @@ export async function activate(context: vscode.ExtensionContext) { registerRealtimeUpdates(context); - // get the username - username = utils.getUsername(); - // Determine if local capture is allowed. const isLocalCaptureAllowed = vscode.env.remoteName !== "ssh-remote" && !utils.isOrbstack(); diff --git a/src/topoViewer/providers/topoViewerEditorWebUiFacade.ts b/src/topoViewer/providers/topoViewerEditorWebUiFacade.ts index e5733997f..882cb1058 100644 --- a/src/topoViewer/providers/topoViewerEditorWebUiFacade.ts +++ b/src/topoViewer/providers/topoViewerEditorWebUiFacade.ts @@ -13,7 +13,8 @@ import { TopoViewerAdaptorClab } from '../core/topoViewerAdaptorClab'; import { resolveNodeConfig } from '../core/nodeConfig'; import { ClabLabTreeNode, ClabContainerTreeNode } 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'; @@ -64,6 +65,7 @@ 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; /* eslint-disable no-unused-vars */ private readonly generalEndpointHandlers: Record< @@ -107,6 +109,12 @@ export class TopoViewerEditor { 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 { @@ -734,7 +742,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 +763,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, @@ -1996,8 +2004,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) { diff --git a/src/treeView/localLabsProvider.ts b/src/treeView/localLabsProvider.ts index 4b1da9c1b..525652714 100644 --- a/src/treeView/localLabsProvider.ts +++ b/src/treeView/localLabsProvider.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode" -import * as utils from "../helpers/utils" +import * as utils from "../utils/utils" import * as c from "./common"; import * as ins from "./inspector"; import { localTreeView, favoriteLabs } from "../extension"; diff --git a/src/treeView/runningLabsProvider.ts b/src/treeView/runningLabsProvider.ts index 0b718b505..600466851 100644 --- a/src/treeView/runningLabsProvider.ts +++ b/src/treeView/runningLabsProvider.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode" -import * as utils from "../helpers/utils" +import * as utils from "../utils/utils" import * as c from "./common"; import * as ins from "./inspector" import { FilterUtils } from "../helpers/filterUtils"; diff --git a/src/utils/async.ts b/src/utils/async.ts new file mode 100644 index 000000000..b456fefce --- /dev/null +++ b/src/utils/async.ts @@ -0,0 +1,4 @@ +// wrapper to sleep() for async functions +export async function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} \ No newline at end of file diff --git a/src/utils/clab.ts b/src/utils/clab.ts new file mode 100644 index 000000000..a6be31b56 --- /dev/null +++ b/src/utils/clab.ts @@ -0,0 +1,3 @@ +export function isClabYamlFile(file: string): boolean { + return file.endsWith('.clab.yml') || file.endsWith('.clab.yaml'); +} \ No newline at end of file diff --git a/src/utils/consts.ts b/src/utils/consts.ts new file mode 100644 index 000000000..6ae904308 --- /dev/null +++ b/src/utils/consts.ts @@ -0,0 +1,19 @@ +export const WIRESHARK_VNC_CTR_NAME_PREFIX="clab_vsc_ws" +export const DEFAULT_WIRESHARK_VNC_DOCKER_PULL_POLICY=ImagePullPolicy.Always +export const DEFAULT_WIRESHARK_VNC_DOCKER_IMAGE="ghcr.io/kaelemc/wireshark-vnc-docker:latest" +export const DEFAULT_ATTACH_SHELL_CMD="sh" +export const DEFAULT_ATTACH_TELNET_PORT=5000 + + +export const enum ImagePullPolicy { + Never = 'never', + Missing = 'missing', + Always = 'always', +} + +export const enum ContainerAction { + Start = 'start', + Stop = 'stop', + Pause = 'pause', + Unpause = 'unpause', +} diff --git a/src/utils/docker/docker.ts b/src/utils/docker/docker.ts new file mode 100644 index 000000000..bacadfaf7 --- /dev/null +++ b/src/utils/docker/docker.ts @@ -0,0 +1,165 @@ +import * as vscode from "vscode"; +import { dockerClient, outputChannel } from "../../extension"; +import { ContainerAction, ImagePullPolicy } from "../consts"; +import Dockerode from "dockerode"; + +// Internal helper to pull the docker image using dockerode client +async function pullDockerImage(image: string): Promise { + if (!dockerClient) { + outputChannel.debug("pullDockerImage() failed: docker client unavailable.") + return false; + } + + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Pulling Docker image ${image}`, + cancellable: false + }, + async () => { + outputChannel.info(`Pulling image ${image}`); + try { + const stream = await dockerClient.pull(image); + await new Promise((resolve, reject) => { + dockerClient.modem.followProgress( + stream, + err => { + if (err) { + reject(err); + } else { + resolve(); + } + } + ); + }); + + vscode.window.showInformationMessage(`Successfully pulled image '${image}'`); + outputChannel.info(`Successfully pulled image '${image}'`); + return true; + } catch (err: any) { + const message = err?.message || String(err); + outputChannel.error(`Failed to pull image '${image}': ${message}`); + vscode.window.showErrorMessage(`Failed to pull image '${image}': ${message}`); + return false; + } + } + ); +} + +// Checks if docker image is available locally, and handles the pull policy +export async function checkAndPullDockerImage(image: string, imagePullPolicy: ImagePullPolicy): Promise { + if (!dockerClient) { + outputChannel.debug("pullDockerImage() failed: docker client unavailable.") + return false; + } + outputChannel.debug(`Checking docker image '${image}'`) + + // Check if image exists locally + let imageExists = false; + try { + await dockerClient.getImage(image).inspect(); + imageExists = true; + outputChannel.debug(`Docker image '${image}' found locally`); + } catch { + outputChannel.debug(`Docker image '${image}' not found locally`); + } + + if(!imageExists) { + switch (imagePullPolicy) { + case ImagePullPolicy.Never: + outputChannel.debug(`Pull policy is 'never', skipping image pull for missing image ${image}`); + break; + + case ImagePullPolicy.Missing: + outputChannel.debug(`Pull policy is 'missing', Pulling missing image '${image}'`); + imageExists = await pullDockerImage(image); + break; + + case ImagePullPolicy.Always: + outputChannel.debug(`Pull policy is 'always', Pulling missing image '${image}'`); + imageExists = await pullDockerImage(image); + break; + + default: + break; + } + } else if(imageExists && imagePullPolicy == ImagePullPolicy.Always) { + outputChannel.debug(`Pull policy is 'always', Pulling available image '${image}'`); + imageExists = await pullDockerImage(image); + } + + return imageExists; +} + +// Basic wrapper to safely return the name of a container based on the Dockerode.Container obj +async function getContainerName(container: Dockerode.Container): Promise { + let ctrName: string; + try { + ctrName = (await container.inspect()).Name; + } catch { + ctrName = container.id; + } + + return ctrName; +} + +export async function runContainerAction(containerId: string, action: ContainerAction): Promise { + + if(!dockerClient) { + outputChannel.debug("runContainerAction() failed: docker client unavailable."); + return; + } + + if(!containerId) { + vscode.window.showErrorMessage(`Failed to ${action} container. Container ID nil.`); + return; + } + + const container = await dockerClient.getContainer(containerId); + if (!container) { + vscode.window.showErrorMessage(`Unable to ${action} container: Failed to get '${containerId}'`); + return; + } + + const ctrName = await getContainerName(container); + + try { + switch (action) { + case ContainerAction.Start: + await container.start(); + break; + case ContainerAction.Stop: + await container.stop(); + break; + case ContainerAction.Pause: + await container.pause(); + break; + case ContainerAction.Unpause: + await container.unpause(); + break; + } + const msg = `${action}: Success for '${ctrName}'` + outputChannel.info(msg); + vscode.window.showInformationMessage(msg); + } catch (err: any) { + const msg = `Failed to ${action} container ${ctrName}: ${err.message}` + outputChannel.error(msg); + vscode.window.showErrorMessage(msg); + } +} + +export async function startContainer(containerId: string): Promise { + return runContainerAction(containerId, ContainerAction.Start); +} + +export async function stopContainer(containerId: string): Promise { + return runContainerAction(containerId, ContainerAction.Stop); +} + +export async function pauseContainer(containerId: string): Promise { + return runContainerAction(containerId, ContainerAction.Pause); +} + +export async function unpauseContainer(containerId: string): Promise { + return runContainerAction(containerId, ContainerAction.Unpause); +} diff --git a/src/utils/docker/images.ts b/src/utils/docker/images.ts new file mode 100644 index 000000000..c96359e73 --- /dev/null +++ b/src/utils/docker/images.ts @@ -0,0 +1,101 @@ +import * as vscode from "vscode"; +import { dockerClient, outputChannel } from "../../extension"; + +let dockerImagesCache: string[] = []; + +const dockerImagesEmitter = new vscode.EventEmitter(); + +export const onDockerImagesUpdated = dockerImagesEmitter.event; + +export function getDockerImages(): string[] { + return [...dockerImagesCache]; +} + +// Internal func to fetch all docker images +async function fetchDockerImages(): Promise { + if (!dockerClient) { + outputChannel.debug("getDockerImages() failed: docker client unavailable.") + return []; + } + + const images = await dockerClient.listImages(); + type TagEntry = { tag: string; created: number }; + const entries: TagEntry[] = []; + const seen = new Set(); + + for (const img of images) { + const repoTags = Array.isArray(img.RepoTags) ? img.RepoTags : []; + for (const tag of repoTags) { + const isValid = tag && !tag.endsWith(":") && !tag.startsWith(""); + if (isValid && !seen.has(tag)) { + seen.add(tag); + entries.push({ tag, created: typeof img.Created === "number" ? img.Created : 0 }); + } + } + } + + entries.sort((a, b) => b.created - a.created || a.tag.localeCompare(b.tag)); + return entries.map(entry => entry.tag); +} + +function updateDockerImagesCache(images: string[]) { + const changed = + images.length !== dockerImagesCache.length || + images.some((img, idx) => dockerImagesCache[idx] !== img); + if (!changed) { + return; + } + dockerImagesCache = images; + // fire an event to whom is listenting that the cache updated. + dockerImagesEmitter.fire([...dockerImagesCache]); +} + +export async function refreshDockerImages() { + outputChannel.debug("Refreshing docker image cache.") + try { + const images = await fetchDockerImages(); + updateDockerImagesCache(images); + outputChannel.debug("SUCCESS! Refreshed docker image cache.") + } catch { + // Leave existing cache untouched. + } +} + +// Create disposable handle to let the image monitor get cleaned up by VSC. +let monitorHandle: vscode.Disposable | undefined; + +export function startDockerImageEventMonitor(context: vscode.ExtensionContext) { + if (monitorHandle || !dockerClient) { + return; + } + + // Start a 'docker events' but only for image events. + dockerClient.getEvents({ filters:{ type: ["image"] }}).then(stream => { + const onData = () => { + // upon any event, the cache should be updated. + refreshDockerImages(); + }; + + const onError = (err: Error) => { + outputChannel.error(`Docker images event stream error: ${err.message}`); + }; + + stream.on("data", onData); + stream.on("error", onError); + + // Ensure we check if the monitor handle needs to dispose us. + monitorHandle = { + dispose: () => { + stream.off("data", onData); + stream.off("error", onError); + stream.removeAllListeners(); + monitorHandle = undefined; + } + }; + context.subscriptions.push(monitorHandle); + + }) + .catch((err: any) => { + outputChannel.warn(`Unable to subscribe to Docker image events: ${err?.message || err}`); + }); +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 000000000..66647b968 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,8 @@ +export * from './async'; +export * from './consts'; +export * from './docker/docker'; +export * from './docker/images'; +export * from './packetflix'; +export * from './webview'; +export * from './utils'; +export * from './clab'; diff --git a/src/utils/packetflix.ts b/src/utils/packetflix.ts new file mode 100644 index 000000000..da19e513b --- /dev/null +++ b/src/utils/packetflix.ts @@ -0,0 +1,225 @@ +import * as vscode from "vscode"; +import * as os from "os"; +import * as utils from "./utils"; +import * as c from "../treeView/common"; +import { outputChannel } from "../extension"; +import { installEdgeshark } from "../commands/edgeshark"; + +let sessionHostname = ""; + +/** + * Generate a packetflix URI for one or more interfaces. Assumes all nodes belong + * to the same container when multiple nodes are provided. + */ +export async function genPacketflixURI( + selectedNodes: c.ClabInterfaceTreeNode[], + forVNC?: boolean +): Promise<[string, string] | undefined> { + if (!selectedNodes || selectedNodes.length === 0) { + vscode.window.showErrorMessage("No interface to capture found."); + return undefined; + } + + const edgesharkReady = await ensureEdgesharkAvailable(); + if (!edgesharkReady) { + return undefined; + } + + if (selectedNodes.length > 1) { + return await captureMultipleEdgeshark(selectedNodes); + } + + const node = selectedNodes[0]; + outputChannel.debug(`genPacketflixURI() single mode for node=${node.parentName}/${node.name}`); + + const hostname = forVNC ? "127.0.0.1" : await getHostname(); + if (!hostname) { + vscode.window.showErrorMessage( + "No known hostname/IP address to connect to for packet capture." + ); + return undefined; + } + + const bracketed = hostname.includes(":") ? `[${hostname}]` : hostname; + + const config = vscode.workspace.getConfiguration("containerlab"); + const packetflixPort = config.get("capture.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}`); + + return [uri, bracketed]; +} + +// Ensure Edgeshark API is up; optionally prompt to start it +async function ensureEdgesharkAvailable(): Promise { + 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; + } + } catch { + // wait and retry + } + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + + 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: c.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(", ")}]`); + + const netnsVal = (base as any).netns || 4026532270; + 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("capture.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]; +} + +/** + * 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; + } +} + +/** + * Determine the hostname (or IP) to use for packet capture based on environment. + */ +export async function getHostname(): Promise { + const cfgHost = vscode.workspace + .getConfiguration("containerlab") + .get("capture.remoteHostname", ""); + if (cfgHost) { + outputChannel.debug( + `Using containerlab.capture.remoteHostname from settings: ${cfgHost}` + ); + return cfgHost; + } + + if (vscode.env.remoteName === "wsl") { + outputChannel.debug("Detected WSL environment; using 'localhost'"); + return "localhost"; + } + + 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"); + } + + 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; + } + } + } + + if (sessionHostname) { + outputChannel.debug(`Using sessionHostname: ${sessionHostname}`); + return sessionHostname; + } + + outputChannel.debug("No suitable hostname found; defaulting to 'localhost'"); + return "localhost"; +} diff --git a/src/helpers/utils.ts b/src/utils/utils.ts similarity index 97% rename from src/helpers/utils.ts rename to src/utils/utils.ts index 964277b23..e0b7ad964 100644 --- a/src/helpers/utils.ts +++ b/src/utils/utils.ts @@ -162,19 +162,6 @@ export function isOrbstack(): boolean { } } -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(); @@ -264,6 +251,7 @@ export async function checkAndUpdateClabIfNeeded( `${containerlabBinaryPath} version check`, 'containerlab version check', outputChannel, + true, true ); const versionOutput = (versionOutputRaw || "").trim(); diff --git a/src/utils/webview.ts b/src/utils/webview.ts new file mode 100644 index 000000000..6e8007d71 --- /dev/null +++ b/src/utils/webview.ts @@ -0,0 +1,23 @@ +import * as vscode from "vscode" + +export async function tryPostMessage(panel: vscode.WebviewPanel, message: unknown): Promise { + try { + await panel.webview.postMessage(message) + } catch { + // The panel might already be disposed; ignore errors + } +} + +export 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) + } +} \ No newline at end of file diff --git a/src/yaml/imageCompletion.ts b/src/yaml/imageCompletion.ts index 7462e6899..d81a9e714 100644 --- a/src/yaml/imageCompletion.ts +++ b/src/yaml/imageCompletion.ts @@ -1,10 +1,5 @@ import * as vscode from 'vscode'; -import { DOCKER_IMAGES_STATE_KEY, refreshDockerImages } from '../extension'; - -function isClabYamlFile(document: vscode.TextDocument): boolean { - const file = document.uri.fsPath.toLowerCase(); - return file.endsWith('.clab.yml') || file.endsWith('.clab.yaml'); -} +import * as utils from '../utils/index'; function lineContainsInlineImage(line: string, cursor: number): boolean { const beforeCursor = line.slice(0, cursor); @@ -52,43 +47,150 @@ function isCompletingImageValue( return hasPreviousLineEndingWithImage(document, position.line, currIndent); } -async function getCachedImages(context: vscode.ExtensionContext): Promise { - const cached = context.globalState.get(DOCKER_IMAGES_STATE_KEY) || []; - if (cached.length === 0) { - await refreshDockerImages(context).catch(() => undefined); +function extractKeyFromLine(trimmedLine: string): string | undefined { + const colonIndex = trimmedLine.indexOf(':'); + if (colonIndex === -1) { + return undefined; + } + const key = trimmedLine.slice(0, colonIndex).trim(); + return key.length > 0 ? key : undefined; +} + +function getAncestorPath(document: vscode.TextDocument, lineIndex: number): string[] { + const path: string[] = []; + let currentIndent = getIndent(document.lineAt(lineIndex).text); + for (let i = lineIndex - 1; i >= 0; i--) { + const raw = document.lineAt(i).text; + const trimmed = raw.split('#')[0].trimEnd(); + if (!trimmed.trim()) { + continue; + } + const indent = getIndent(raw); + if (indent >= currentIndent) { + continue; + } + const key = extractKeyFromLine(trimmed); + if (key) { + path.unshift(key); + currentIndent = indent; + } else { + currentIndent = indent; + } + } + return path; +} + +function isNodesContext(path: string[]): boolean { + const nodesIndex = path.lastIndexOf('nodes'); + if (nodesIndex === -1 || nodesIndex >= path.length - 1) { + return false; + } + return nodesIndex === 0 || path[nodesIndex - 1] === 'topology'; +} + +function isDefaultsContext(path: string[]): boolean { + const defaultsIndex = path.lastIndexOf('defaults'); + if (defaultsIndex === -1) { + return false; + } + return ( + (defaultsIndex === 0 && path.length === 1) || + (defaultsIndex === 1 && path[0] === 'topology') + ); +} + +function isScopedContext(path: string[], section: string): boolean { + const sectionIndex = path.lastIndexOf(section); + if (sectionIndex === -1 || sectionIndex >= path.length - 1) { + return false; } - return context.globalState.get(DOCKER_IMAGES_STATE_KEY) || []; + return ( + (sectionIndex === 0 && path.length > 1) || + (sectionIndex === 1 && path[0] === 'topology') + ); +} + +function isAllowedImageSection(document: vscode.TextDocument, position: vscode.Position): boolean { + const path = getAncestorPath(document, position.line).map(key => key.toLowerCase()); + if (path.length === 0) { + return false; + } + + return ( + isNodesContext(path) || + isDefaultsContext(path) || + isScopedContext(path, 'kinds') || + isScopedContext(path, 'groups') + ); +} + +function shouldProvideImageCompletion(document: vscode.TextDocument, position: vscode.Position): boolean { + return ( + utils.isClabYamlFile(document.uri.fsPath) && + isCompletingImageValue(document, position) && + isAllowedImageSection(document, position) + ); +} + +function getInlineImageFilter(document: vscode.TextDocument, position: vscode.Position): string { + const line = document.lineAt(position.line).text; + const portion = line.slice(0, position.character); + const before = portion.split('#')[0]; + const idx = before.toLowerCase().lastIndexOf('image:'); + if (idx === -1) { + return ''; + } + const afterImage = before.slice(idx + 'image:'.length).trimStart(); + return afterImage.toLowerCase(); } /** * Registers YAML completion for the `image:` directive in clab.yml/clab.yaml files. - * Suggests local Docker images cached in the extension's global state. + * Suggests local Docker images cached in-memory for the current VS Code session. */ -export function registerClabImageCompletion(context: vscode.ExtensionContext) { +function buildCompletionItems(images: string[]): vscode.CompletionList | undefined { + if (images.length === 0) { + return undefined; + } + const items: vscode.CompletionItem[] = images.map((img, index) => { + const ci = new vscode.CompletionItem(img, vscode.CompletionItemKind.Value); + ci.insertText = img; + ci.detail = 'Docker image'; + ci.sortText = index.toString().padStart(6, '0'); + return ci; + }); + return new vscode.CompletionList(items, true); +} + +function filterImages(images: string[], filterText: string): string[] { + if (!filterText) { + return images; + } + const lowerFilter = filterText.toLowerCase(); + return images.filter(img => img.toLowerCase().includes(lowerFilter)); +} + +async function provideImageCompletions( + document: vscode.TextDocument, + position: vscode.Position +): Promise { + if (!shouldProvideImageCompletion(document, position)) { + return undefined; + } + + const images = await utils.getDockerImages(); + if (images.length === 0) { + return undefined; + } + + const filtered = filterImages(images, getInlineImageFilter(document, position)); + return buildCompletionItems(filtered); +} + +function registerCompletionProvider(context: vscode.ExtensionContext): void { const provider: vscode.CompletionItemProvider = { - async provideCompletionItems(document, position) { - try { - if (!isClabYamlFile(document) || !isCompletingImageValue(document, position)) { - return undefined; - } - - const images = await getCachedImages(context); - if (images.length === 0) { - return undefined; - } - - const items: vscode.CompletionItem[] = images.map((img) => { - const ci = new vscode.CompletionItem(img, vscode.CompletionItemKind.Value); - ci.insertText = img; - ci.detail = 'Local container image'; - ci.sortText = '1_' + img; // keep a stable, predictable order - return ci; - }); - - return new vscode.CompletionList(items, true); - } catch { - return undefined; - } + provideCompletionItems(document, position) { + return provideImageCompletions(document, position); } }; @@ -99,3 +201,49 @@ export function registerClabImageCompletion(context: vscode.ExtensionContext) { ); context.subscriptions.push(disposable); } + +function registerAutoTrigger(context: vscode.ExtensionContext): void { + const changeListener = vscode.workspace.onDidChangeTextDocument(event => { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document !== event.document) { + return; + } + if (event.contentChanges.length === 0) { + return; + } + + const position = editor.selection.active; + if (!shouldProvideImageCompletion(event.document, position)) { + return; + } + + const lastChange = event.contentChanges[event.contentChanges.length - 1]; + if (!lastChange || lastChange.text.length === 0) { + return; + } + + const inserted = lastChange.text; + const lastChar = inserted[inserted.length - 1]; + const lineText = event.document.lineAt(position.line).text; + const currentFilter = getInlineImageFilter(event.document, position); + const images = utils.getDockerImages(); + const isExactMatch = + images.length > 0 && + images.some(img => img.toLowerCase() === currentFilter && lineText.trimEnd().endsWith(img)); + + const shouldTrigger = + !isExactMatch && (/\S/.test(lastChar) || inserted.includes('\n')); + if (!shouldTrigger) { + return; + } + + void vscode.commands.executeCommand('editor.action.triggerSuggest'); + }); + + context.subscriptions.push(changeListener); +} + +export function registerClabImageCompletion(context: vscode.ExtensionContext) { + registerCompletionProvider(context); + registerAutoTrigger(context); +} From 8022793085895bdca1a2f6f75a2425ce7c084e10 Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Sat, 29 Nov 2025 22:49:22 +0800 Subject: [PATCH 15/55] Update TopoViewer for live handling of docker images update from event stream --- .../webview-ui/managerNodeEditor.ts | 13 ++++ .../webview-ui/topologyWebviewController.ts | 64 +++++++++++-------- 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/src/topoViewer/webview-ui/managerNodeEditor.ts b/src/topoViewer/webview-ui/managerNodeEditor.ts index 4c5150c9c..2360dbdda 100644 --- a/src/topoViewer/webview-ui/managerNodeEditor.ts +++ b/src/topoViewer/webview-ui/managerNodeEditor.ts @@ -436,6 +436,19 @@ export class ManagerNodeEditor { this.initializePanel(); } + public handleDockerImagesUpdated(images: string[]): void { + (window as any).dockerImages = images; + if (!this.panel || this.panel.style.display === 'none') { + return; + } + if (!this.currentNode) { + return; + } + const extraData = this.currentNode.data('extraData') || {}; + const actualInherited = this.computeActualInheritedProps(extraData); + this.setupImageFields(extraData, actualInherited); + } + /** * Parse docker images to extract base images and their versions */ diff --git a/src/topoViewer/webview-ui/topologyWebviewController.ts b/src/topoViewer/webview-ui/topologyWebviewController.ts index 1fa232229..038e27f8e 100644 --- a/src/topoViewer/webview-ui/topologyWebviewController.ts +++ b/src/topoViewer/webview-ui/topologyWebviewController.ts @@ -611,36 +611,46 @@ class TopologyWebviewController { if (!msg?.type) { return; } - const runHandler = (type: string, fn: () => void | Promise): void => { - Promise.resolve(fn()).catch((error) => { - log.error(`Error handling message "${type}": ${error instanceof Error ? error.message : String(error)}`); - }); - }; - switch (msg.type) { - case 'yaml-saved': - runHandler(msg.type, async () => { - await fetchAndLoadData(this.cy, this.messageSender, { incremental: true }); - }); - break; - case 'updateTopology': - runHandler(msg.type, () => { - this.updateTopology(msg.data); - }); - break; - case 'copiedElements': - runHandler(msg.type, () => { - this.handleCopiedElements(msg.data); - }); - break; - case 'topo-mode-changed': - runHandler(msg.type, () => this.handleModeSwitchMessage(msg.data as ModeSwitchPayload)); - break; - default: - break; - } + this.dispatchIncomingMessage(msg); }); } + private dispatchIncomingMessage(msg: any): void { + const runHandler = (type: string, fn: () => void | Promise): void => { + Promise.resolve(fn()).catch((error) => { + log.error(`Error handling message "${type}": ${error instanceof Error ? error.message : String(error)}`); + }); + }; + + switch (msg.type) { + case 'yaml-saved': + runHandler(msg.type, async () => { + await fetchAndLoadData(this.cy, this.messageSender, { incremental: true }); + }); + return; + case 'updateTopology': + runHandler(msg.type, () => this.updateTopology(msg.data)); + return; + case 'copiedElements': + runHandler(msg.type, () => this.handleCopiedElements(msg.data)); + return; + case 'topo-mode-changed': + runHandler(msg.type, () => this.handleModeSwitchMessage(msg.data as ModeSwitchPayload)); + return; + case 'docker-images-updated': + runHandler(msg.type, () => this.handleDockerImagesUpdatedMessage(msg.dockerImages as string[])); + return; + default: + return; + } + } + + private handleDockerImagesUpdatedMessage(images?: string[]): void { + const nextImages = Array.isArray(images) ? images : []; + this.assignWindowValue('dockerImages', nextImages, []); + this.nodeEditor?.handleDockerImagesUpdated(nextImages); + } + private updateTopology(data: any): void { try { const elements = data as any[]; From a9bd0b29e4c40f10112e0f306da2b101a3f9f780 Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Sun, 30 Nov 2025 01:13:38 +0800 Subject: [PATCH 16/55] Change panel style to feel more like a 'window'. Change buttons to Ok, and Apply --- .../templates/partials/draggable-panels.html | 75 +- .../templates/partials/panel-bulk-link.html | 7 +- .../partials/panel-lab-settings.html | 38 +- .../templates/partials/panel-link-editor.html | 13 +- .../templates/partials/panel-link.html | 7 +- .../partials/panel-network-editor.html | 18 +- .../partials/panel-node-editor-parent.html | 7 +- .../templates/partials/panel-node-editor.html | 19 +- .../templates/partials/panel-node.html | 7 +- .../partials/panel-topoviewer-about.html | 7 +- .../templates/partials/scripts.html | 18 +- .../webview-ui/managerNodeEditor.ts | 2225 ++++++++++------- .../webview-ui/managerViewportPanels.ts | 1343 ++++++---- src/topoViewer/webview-ui/tailwind.css | 229 +- 14 files changed, 2510 insertions(+), 1503 deletions(-) diff --git a/src/topoViewer/templates/partials/draggable-panels.html b/src/topoViewer/templates/partials/draggable-panels.html index 9e6ec5b92..37e7ba278 100644 --- a/src/topoViewer/templates/partials/draggable-panels.html +++ b/src/topoViewer/templates/partials/draggable-panels.html @@ -106,26 +106,17 @@ // expose for other scripts window.keepPanelWithinBounds = keepPanelWithinBounds; - // Create and insert drag handle for a panel + // Get the drag handle for a panel (title bar) function createDragHandle(panel) { - // Check if drag handle already exists - if (panel.querySelector(".panel-drag-handle")) { - return panel.querySelector(".panel-drag-handle"); + // All panels should have a title bar - use it as the drag handle + const titleBar = panel.querySelector(".panel-title-bar"); + if (titleBar) { + updateDragHandleAppearance(titleBar); + return titleBar; } - // Create the drag handle element - const dragHandle = document.createElement("div"); - dragHandle.className = - "panel-drag-handle w-full h-[6px] bg-[var(--vscode-panel-border)] rounded-t-md cursor-grab hover:bg-[var(--vscode-button-hoverBackground)] transition-colors duration-150"; - dragHandle.style.margin = "0 0 8px 0"; - - // Insert as the first child of the panel - panel.insertBefore(dragHandle, panel.firstChild); - - // Update appearance based on current lock state - updateDragHandleAppearance(dragHandle); - - return dragHandle; + console.warn(`Panel ${panel.id} does not have a title bar (.panel-title-bar)`); + return null; } // Initialize drag functionality for a panel @@ -164,8 +155,8 @@ // Mouse down on drag handle (start drag) dragHandle.addEventListener("mousedown", function (e) { - // Don't start dragging if unified floating panel is locked - if (isUnifiedPanelLocked()) { + // Don't start dragging if clicking on close button or if panel is locked + if (e.target.closest(".panel-close-btn") || isUnifiedPanelLocked()) { return; } @@ -192,18 +183,7 @@ e.stopPropagation(); }); - // Add visual feedback on hover - dragHandle.addEventListener("mouseenter", function () { - if (!isDragging && !isUnifiedPanelLocked()) { - dragHandle.style.backgroundColor = "var(--vscode-button-hoverBackground)"; - } - }); - - dragHandle.addEventListener("mouseleave", function () { - if (!isDragging) { - updateDragHandleAppearance(dragHandle); - } - }); + // No hover effects on title bar - keep fixed background color } // Global mouse move (drag) @@ -235,10 +215,9 @@ if (isDragging && currentPanel) { isDragging = false; - const dragHandle = currentPanel.querySelector(".panel-drag-handle"); + const dragHandle = currentPanel.querySelector(".panel-title-bar"); if (dragHandle) { dragHandle.style.cursor = "grab"; - dragHandle.style.backgroundColor = ""; } currentPanel.style.cursor = "default"; @@ -306,8 +285,29 @@ }); } + // Wire up close buttons for all panels + function setupPanelCloseButtons() { + document.querySelectorAll(".panel-close-btn").forEach((closeBtn) => { + // Skip if already set up + if (closeBtn.dataset.listenerAttached === "true") { + return; + } + closeBtn.dataset.listenerAttached = "true"; + + // Add click listener + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); // Prevent drag from triggering + const panel = closeBtn.closest(".draggable-panel"); + if (panel) { + panel.style.display = "none"; + } + }); + }); + } + // Initial setup initializeAllDraggablePanels(); + setupPanelCloseButtons(); // Listen for unified panel lock state changes // Check for lock state changes periodically (since localStorage doesn't emit events across scripts) @@ -321,8 +321,13 @@ }, 100); // Check every 100ms // Re-initialize when DOM changes (for dynamically added panels) - const mainObserver = new MutationObserver(() => { - initializeAllDraggablePanels(); + const mainObserver = new MutationObserver((mutations) => { + // Only reinitialize if actual elements were added, not just attributes changed + const hasAddedNodes = mutations.some((mutation) => mutation.addedNodes.length > 0); + if (hasAddedNodes) { + initializeAllDraggablePanels(); + setupPanelCloseButtons(); + } }); mainObserver.observe(document.body, { 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-lab-settings.html b/src/topoViewer/templates/partials/panel-lab-settings.html index ec196e1ff..e4c815758 100644 --- a/src/topoViewer/templates/partials/panel-lab-settings.html +++ b/src/topoViewer/templates/partials/panel-lab-settings.html @@ -4,7 +4,12 @@ id="panel-lab-settings" style="display: none" > -

Lab Settings

+
+ Lab Settings + +
@@ -442,36 +447,7 @@ } } - // Make panel draggable - let isDragging = false; - let startX, startY, initialLeft, initialTop; - - const heading = panel.querySelector(".panel-heading"); - heading.style.cursor = "move"; - - heading.addEventListener("mousedown", (e) => { - isDragging = true; - const rect = panel.getBoundingClientRect(); - startX = e.clientX; - startY = e.clientY; - initialLeft = rect.left; - initialTop = rect.top; - e.preventDefault(); - }); - - document.addEventListener("mousemove", (e) => { - if (!isDragging) return; - - const deltaX = e.clientX - startX; - const deltaY = e.clientY - startY; - - panel.style.left = initialLeft + deltaX + "px"; - panel.style.top = initialTop + deltaY + "px"; - }); - - document.addEventListener("mouseup", () => { - isDragging = false; - }); + // Panel drag handling is managed by draggable-panels.html script }); // Global function to show the lab settings panel diff --git a/src/topoViewer/templates/partials/panel-link-editor.html b/src/topoViewer/templates/partials/panel-link-editor.html index c78c15b69..28e96142e 100644 --- a/src/topoViewer/templates/partials/panel-link-editor.html +++ b/src/topoViewer/templates/partials/panel-link-editor.html @@ -4,7 +4,12 @@ id="panel-link-editor" style="display: none" > -

Link Editor

+
+ Link Editor + +
@@ -192,12 +197,12 @@ const footerFrag = footerTpl.content.cloneNode(true); const saveBtn = footerFrag.querySelector(".editor-save-btn"); - const closeBtn = footerFrag.querySelector(".editor-close-btn"); + const applyBtn = footerFrag.querySelector(".editor-apply-btn"); if (saveBtn) { saveBtn.id = "panel-link-editor-save-button"; } - if (closeBtn) { - closeBtn.id = "panel-link-editor-close-button"; + if (applyBtn) { + applyBtn.id = "panel-link-editor-apply-button"; } document.getElementById("panel-link-footer-placeholder")?.appendChild(footerFrag); } diff --git a/src/topoViewer/templates/partials/panel-link.html b/src/topoViewer/templates/partials/panel-link.html index 173eb7bae..db4af5c19 100644 --- a/src/topoViewer/templates/partials/panel-link.html +++ b/src/topoViewer/templates/partials/panel-link.html @@ -4,7 +4,12 @@ id="panel-link" style="display: none" > -

Link Properties

+
+ Link Properties + +
diff --git a/src/topoViewer/templates/partials/panel-network-editor.html b/src/topoViewer/templates/partials/panel-network-editor.html index 1ddead7a2..316d37a30 100644 --- a/src/topoViewer/templates/partials/panel-network-editor.html +++ b/src/topoViewer/templates/partials/panel-network-editor.html @@ -4,7 +4,17 @@ id="panel-network-editor" style="display: none" > -

Network Editor

+
+ Network Editor + +
@@ -178,12 +188,12 @@ const footerFrag = footerTpl.content.cloneNode(true); const saveBtn = footerFrag.querySelector(".editor-save-btn"); - const closeBtn = footerFrag.querySelector(".editor-close-btn"); + const applyBtn = footerFrag.querySelector(".editor-apply-btn"); if (saveBtn) { saveBtn.id = "panel-network-editor-save-button"; } - if (closeBtn) { - closeBtn.id = "panel-network-editor-close-button"; + if (applyBtn) { + applyBtn.id = "panel-network-editor-apply-button"; } document.getElementById("panel-network-footer-placeholder")?.appendChild(footerFrag); } diff --git a/src/topoViewer/templates/partials/panel-node-editor-parent.html b/src/topoViewer/templates/partials/panel-node-editor-parent.html index 38ea80c90..64058537d 100644 --- a/src/topoViewer/templates/partials/panel-node-editor-parent.html +++ b/src/topoViewer/templates/partials/panel-node-editor-parent.html @@ -5,7 +5,12 @@ aria-labelledby="group-panel-heading" style="display: none" > -

Group Editor

+
+ Group Editor + +
diff --git a/src/topoViewer/templates/partials/panel-node-editor.html b/src/topoViewer/templates/partials/panel-node-editor.html index b19ed2bb2..6251dad87 100644 --- a/src/topoViewer/templates/partials/panel-node-editor.html +++ b/src/topoViewer/templates/partials/panel-node-editor.html @@ -4,7 +4,12 @@ id="panel-node-editor" style="display: none" > -

Node Editor

+
+ Node Editor + +
@@ -996,13 +1001,13 @@

Health Check

const footerTpl = document.getElementById("editor-footer-template"); if (footerTpl) { const footerFrag = footerTpl.content.cloneNode(true); - const saveBtn = footerFrag.querySelector(".editor-save-btn"); - const closeBtn = footerFrag.querySelector(".editor-close-btn"); - if (saveBtn) { - saveBtn.id = "panel-node-editor-save"; + const applyBtn = footerFrag.querySelector(".editor-apply-btn"); + const okBtn = footerFrag.querySelector(".editor-save-btn"); + if (applyBtn) { + applyBtn.id = "panel-node-editor-apply"; } - if (closeBtn) { - closeBtn.id = "panel-node-editor-cancel"; + if (okBtn) { + okBtn.id = "panel-node-editor-save"; } document.getElementById("panel-node-footer-placeholder")?.appendChild(footerFrag); } diff --git a/src/topoViewer/templates/partials/panel-node.html b/src/topoViewer/templates/partials/panel-node.html index 963f30587..921d5d349 100644 --- a/src/topoViewer/templates/partials/panel-node.html +++ b/src/topoViewer/templates/partials/panel-node.html @@ -5,7 +5,12 @@ aria-labelledby="node-panel-heading" style="display: none" > -

Node Properties

+
+ Node Properties + +
diff --git a/src/topoViewer/templates/partials/panel-topoviewer-about.html b/src/topoViewer/templates/partials/panel-topoviewer-about.html index 2a92709f2..3840c8db6 100644 --- a/src/topoViewer/templates/partials/panel-topoviewer-about.html +++ b/src/topoViewer/templates/partials/panel-topoviewer-about.html @@ -5,7 +5,12 @@ aria-labelledby="about-heading" style="display: none" > -
About TopoViewer
+
+ About TopoViewer + +
diff --git a/src/topoViewer/templates/partials/scripts.html b/src/topoViewer/templates/partials/scripts.html index 7e52a821b..de86c2ac3 100644 --- a/src/topoViewer/templates/partials/scripts.html +++ b/src/topoViewer/templates/partials/scripts.html @@ -91,12 +91,18 @@ diff --git a/src/topoViewer/webview-ui/managerNodeEditor.ts b/src/topoViewer/webview-ui/managerNodeEditor.ts index 2360dbdda..7aa1f6653 100644 --- a/src/topoViewer/webview-ui/managerNodeEditor.ts +++ b/src/topoViewer/webview-ui/managerNodeEditor.ts @@ -1,263 +1,270 @@ // managerNodeEditor.ts -import cytoscape from 'cytoscape'; -import { log } from '../logging/logger'; -import { createFilterableDropdown } from './utilities/filterableDropdown'; -import { ManagerSaveTopo } from './managerSaveTopo'; -import { VscodeMessageSender } from './managerVscodeWebview'; -import { applyIconColorToNode, extractNodeIcons, getIconDataUriForRole } from './managerCytoscapeBaseStyles'; -import { createNodeIconOptionElement } from './utilities/iconDropdownRenderer'; -import { resolveNodeConfig } from '../core/nodeConfig'; -import type { ClabTopology } from '../types/topoViewerType'; -import { DEFAULT_INTERFACE_PATTERN } from './utilities/interfacePatternUtils'; +import cytoscape from "cytoscape"; +import { log } from "../logging/logger"; +import { createFilterableDropdown } from "./utilities/filterableDropdown"; +import { ManagerSaveTopo } from "./managerSaveTopo"; +import { VscodeMessageSender } from "./managerVscodeWebview"; +import { + applyIconColorToNode, + extractNodeIcons, + getIconDataUriForRole +} from "./managerCytoscapeBaseStyles"; +import { createNodeIconOptionElement } from "./utilities/iconDropdownRenderer"; +import { resolveNodeConfig } from "../core/nodeConfig"; +import type { ClabTopology } from "../types/topoViewerType"; +import { DEFAULT_INTERFACE_PATTERN } from "./utilities/interfacePatternUtils"; // Reuse common literal types to avoid duplicate strings -type ExecTarget = 'container' | 'host'; -type ExecPhase = 'on-enter' | 'on-exit'; -type RestartPolicy = 'no' | 'on-failure' | 'always' | 'unless-stopped'; -type ImagePullPolicy = 'IfNotPresent' | 'Never' | 'Always'; -type Runtime = 'docker' | 'podman' | 'ignite'; +type ExecTarget = "container" | "host"; +type ExecPhase = "on-enter" | "on-exit"; +type RestartPolicy = "no" | "on-failure" | "always" | "unless-stopped"; +type ImagePullPolicy = "IfNotPresent" | "Never" | "Always"; +type Runtime = "docker" | "podman" | "ignite"; // Common CSS classes and element IDs -const CLASS_HIDDEN = 'hidden' as const; -const CLASS_PANEL_TAB_BUTTON = 'panel-tab-button' as const; -const CLASS_TAB_CONTENT = 'tab-content' as const; -const CLASS_TAB_ACTIVE = 'tab-active' as const; -const CLASS_DYNAMIC_ENTRY = 'dynamic-entry' as const; -const CLASS_DYNAMIC_DELETE_BTN = 'dynamic-delete-btn' as const; -const CLASS_INPUT_FIELD = 'input-field' as const; +const CLASS_HIDDEN = "hidden" as const; +const CLASS_PANEL_TAB_BUTTON = "panel-tab-button" as const; +const CLASS_TAB_CONTENT = "tab-content" as const; +const CLASS_TAB_ACTIVE = "tab-active" as const; +const CLASS_DYNAMIC_ENTRY = "dynamic-entry" as const; +const CLASS_DYNAMIC_DELETE_BTN = "dynamic-delete-btn" as const; +const CLASS_INPUT_FIELD = "input-field" as const; const HTML_TRASH_ICON = '' as const; -const CLASS_COMPONENT_ENTRY = 'component-entry' as const; -const CLASS_COMPONENT_SFM_ENTRY = 'component-sfm-entry' as const; -const CLASS_COMPONENT_MDA_ENTRY = 'component-mda-entry' as const; -const CLASS_COMPONENT_XIOM_ENTRY = 'component-xiom-entry' as const; -const CLASS_COMPONENT_XIOM_MDA_ENTRY = 'component-xiom-mda-entry' as const; -const CLASS_INTEGRATED_MDA_ENTRY = 'integrated-mda-entry' as const; +const CLASS_COMPONENT_ENTRY = "component-entry" as const; +const CLASS_COMPONENT_SFM_ENTRY = "component-sfm-entry" as const; +const CLASS_COMPONENT_MDA_ENTRY = "component-mda-entry" as const; +const CLASS_COMPONENT_XIOM_ENTRY = "component-xiom-entry" as const; +const CLASS_COMPONENT_XIOM_MDA_ENTRY = "component-xiom-mda-entry" as const; +const CLASS_INTEGRATED_MDA_ENTRY = "integrated-mda-entry" as const; const SELECTOR_COMPONENT_BODY = '[data-role="component-body"]' as const; const SELECTOR_COMPONENT_CARET = '[data-role="component-caret"]' as const; -const SELECTOR_FORM_GROUP = '.form-group' as const; -const ICON_CHEVRON_RIGHT = 'fa-chevron-right' as const; -const ICON_CHEVRON_DOWN = 'fa-chevron-down' as const; -const ATTR_ARIA_HIDDEN = 'aria-hidden' as const; +const SELECTOR_FORM_GROUP = ".form-group" as const; +const ICON_CHEVRON_RIGHT = "fa-chevron-right" as const; +const ICON_CHEVRON_DOWN = "fa-chevron-down" as const; +const ATTR_ARIA_HIDDEN = "aria-hidden" as const; const SELECTOR_SFM_VALUE = '[data-role="sfm-value"]' as const; const SELECTOR_SFM_DROPDOWN = '[data-role="sfm-dropdown"]' as const; -const DEFAULT_ICON_COLOR = '#005aff' as const; +const DEFAULT_ICON_COLOR = "#005aff" as const; const DEFAULT_ICON_CORNER_RADIUS = 0; const NODE_ICON_BASE_SIZE = 14; const ICON_PREVIEW_DEFAULT_SIZE = 64; -const ID_PANEL_NODE_EDITOR = 'panel-node-editor' as const; -const ID_PANEL_EDITOR_CLOSE = 'panel-node-editor-close' as const; -const ID_PANEL_EDITOR_CANCEL = 'panel-node-editor-cancel' as const; -const ID_PANEL_EDITOR_SAVE = 'panel-node-editor-save' as const; -const ID_NODE_CERT_ISSUE = 'node-cert-issue' as const; -const ID_CERT_OPTIONS = 'cert-options' as const; -const ID_PANEL_NODE_EDITOR_HEADING = 'panel-node-editor-heading' as const; -const ID_PANEL_NODE_EDITOR_ID = 'panel-node-editor-id' as const; -const ID_TAB_COMPONENTS_BUTTON = 'tab-components-button' as const; -const ID_TAB_COMPONENTS_CONTENT = 'tab-components' as const; -const ID_NODE_COMPONENTS_CONTAINER = 'node-components-container' as const; -const ID_NODE_COMPONENTS_CPM_CONTAINER = 'node-components-cpm-container' as const; -const ID_NODE_COMPONENTS_CARD_CONTAINER = 'node-components-card-container' as const; -const ID_NODE_COMPONENTS_SFM_CONTAINER = 'node-components-sfm-container' as const; -const ID_NODE_COMPONENTS_ACTIONS_DISTRIBUTED = 'node-components-actions-distributed' as const; -const ID_NODE_COMPONENTS_ACTIONS_INTEGRATED = 'node-components-actions-integrated' as const; -const ID_NODE_COMPONENTS_INTEGRATED_SECTION = 'node-components-integrated-section' as const; -const ID_NODE_INTEGRATED_MDA_CONTAINER = 'node-integrated-mda-container' as const; -const ID_ADD_INTEGRATED_MDA_BUTTON = 'btn-add-integrated-mda' as const; -const ID_ADD_CPM_BUTTON = 'btn-add-cpm' as const; -const ID_ADD_CARD_BUTTON = 'btn-add-card' as const; -const SFM_ENTRY_ID_PREFIX = 'sfm-entry-' as const; +const ID_PANEL_NODE_EDITOR = "panel-node-editor" as const; +const ID_PANEL_EDITOR_CLOSE = "panel-node-editor-close" as const; +const ID_PANEL_EDITOR_APPLY = "panel-node-editor-apply" as const; +const ID_PANEL_EDITOR_SAVE = "panel-node-editor-save" as const; +const ID_NODE_CERT_ISSUE = "node-cert-issue" as const; +const ID_CERT_OPTIONS = "cert-options" as const; +const ID_PANEL_NODE_EDITOR_HEADING = "panel-node-editor-heading" as const; +const ID_PANEL_NODE_EDITOR_ID = "panel-node-editor-id" as const; +const ID_TAB_COMPONENTS_BUTTON = "tab-components-button" as const; +const ID_TAB_COMPONENTS_CONTENT = "tab-components" as const; +const ID_NODE_COMPONENTS_CONTAINER = "node-components-container" as const; +const ID_NODE_COMPONENTS_CPM_CONTAINER = "node-components-cpm-container" as const; +const ID_NODE_COMPONENTS_CARD_CONTAINER = "node-components-card-container" as const; +const ID_NODE_COMPONENTS_SFM_CONTAINER = "node-components-sfm-container" as const; +const ID_NODE_COMPONENTS_ACTIONS_DISTRIBUTED = "node-components-actions-distributed" as const; +const ID_NODE_COMPONENTS_ACTIONS_INTEGRATED = "node-components-actions-integrated" as const; +const ID_NODE_COMPONENTS_INTEGRATED_SECTION = "node-components-integrated-section" as const; +const ID_NODE_INTEGRATED_MDA_CONTAINER = "node-integrated-mda-container" as const; +const ID_ADD_INTEGRATED_MDA_BUTTON = "btn-add-integrated-mda" as const; +const ID_ADD_CPM_BUTTON = "btn-add-cpm" as const; +const ID_ADD_CARD_BUTTON = "btn-add-card" as const; +const SFM_ENTRY_ID_PREFIX = "sfm-entry-" as const; // Tab scroll controls -const ID_TAB_SCROLL_LEFT = 'node-editor-tab-scroll-left' as const; -const ID_TAB_SCROLL_RIGHT = 'node-editor-tab-scroll-right' as const; -const ID_TAB_VIEWPORT = 'node-editor-tab-viewport' as const; -const ID_TAB_STRIP = 'node-editor-tab-strip' as const; +const ID_TAB_SCROLL_LEFT = "node-editor-tab-scroll-left" as const; +const ID_TAB_SCROLL_RIGHT = "node-editor-tab-scroll-right" as const; +const ID_TAB_VIEWPORT = "node-editor-tab-viewport" as const; +const ID_TAB_STRIP = "node-editor-tab-strip" as const; // Frequently used Node Editor element IDs -const ID_NODE_KIND_DROPDOWN = 'node-kind-dropdown-container' as const; -const ID_NODE_KIND_FILTER_INPUT = 'node-kind-dropdown-container-filter-input' as const; -const ID_NODE_TYPE = 'node-type' as const; -const ID_NODE_TYPE_DROPDOWN = 'panel-node-type-dropdown-container' as const; -const ID_NODE_TYPE_FILTER_INPUT = 'panel-node-type-dropdown-container-filter-input' as const; -const ID_NODE_TYPE_WARNING = 'node-type-warning' as const; -const ID_NODE_VERSION_DROPDOWN = 'node-version-dropdown-container' as const; -const ID_NODE_VERSION_FILTER_INPUT = 'node-version-dropdown-container-filter-input' as const; -const ID_NODE_ICON_COLOR = 'node-icon-color' as const; -const ID_NODE_ICON_EDIT_BUTTON = 'node-icon-edit-button' as const; -const ID_NODE_ICON_ADD_BUTTON = 'node-icon-add-button' as const; -const ID_ICON_EDITOR_BACKDROP = 'node-icon-editor-backdrop' as const; -const ID_ICON_EDITOR_MODAL = 'node-icon-editor-modal' as const; -const ID_ICON_EDITOR_COLOR = 'node-icon-editor-color' as const; -const ID_ICON_EDITOR_HEX = 'node-icon-editor-hex' as const; -const ID_ICON_EDITOR_SHAPE = 'node-icon-editor-shape' as const; -const ID_ICON_EDITOR_PREVIEW = 'node-icon-editor-preview' as const; -const ID_ICON_EDITOR_SAVE = 'node-icon-editor-save' as const; -const ID_ICON_EDITOR_CANCEL = 'node-icon-editor-cancel' as const; -const ID_ICON_EDITOR_CLOSE = 'node-icon-editor-close' as const; -const ID_ICON_EDITOR_CORNER = 'node-icon-editor-corner' as const; -const ID_ICON_EDITOR_CORNER_VALUE = 'node-icon-editor-corner-value' as const; -const ID_NODE_RP_DROPDOWN = 'node-restart-policy-dropdown-container' as const; -const ID_NODE_RP_FILTER_INPUT = 'node-restart-policy-dropdown-container-filter-input' as const; -const ID_NODE_NM_DROPDOWN = 'node-network-mode-dropdown-container' as const; -const ID_NODE_NM_FILTER_INPUT = 'node-network-mode-dropdown-container-filter-input' as const; -const ID_NODE_CUSTOM_DEFAULT = 'node-custom-default' as const; -const ID_NODE_INTERFACE_PATTERN = 'node-interface-pattern' as const; - -const ID_NODE_IPP_DROPDOWN = 'node-image-pull-policy-dropdown-container' as const; -const ID_NODE_IPP_FILTER_INPUT = 'node-image-pull-policy-dropdown-container-filter-input' as const; -const ID_NODE_RUNTIME_DROPDOWN = 'node-runtime-dropdown-container' as const; -const ID_NODE_RUNTIME_FILTER_INPUT = 'node-runtime-dropdown-container-filter-input' as const; -const ID_NODE_IMAGE_DROPDOWN = 'node-image-dropdown-container' as const; -const ID_NODE_IMAGE_FILTER_INPUT = 'node-image-dropdown-container-filter-input' as const; -const ID_NODE_IMAGE_FALLBACK_INPUT = 'node-image-fallback-input' as const; -const ID_NODE_VERSION_FALLBACK_INPUT = 'node-version-fallback-input' as const; -const ID_PANEL_NODE_TOPOROLE_CONTAINER = 'panel-node-topoviewerrole-dropdown-container' as const; -const ID_PANEL_NODE_TOPOROLE_FILTER_INPUT = 'panel-node-topoviewerrole-dropdown-container-filter-input' as const; -const ID_NODE_CERT_KEYSIZE_DROPDOWN = 'node-cert-key-size-dropdown-container' as const; -const ID_NODE_CERT_VALIDITY = 'node-cert-validity' as const; -const ID_NODE_SANS_CONTAINER = 'node-sans-container' as const; -const ID_NODE_CERT_KEYSIZE_FILTER_INPUT = 'node-cert-key-size-dropdown-container-filter-input' as const; -const ID_NODE_NAME = 'node-name' as const; -const ID_NODE_CUSTOM_NAME = 'node-custom-name' as const; -const ID_NODE_CUSTOM_NAME_GROUP = 'node-custom-name-group' as const; -const ID_NODE_NAME_GROUP = 'node-name-group' as const; +const ID_NODE_KIND_DROPDOWN = "node-kind-dropdown-container" as const; +const ID_NODE_KIND_FILTER_INPUT = "node-kind-dropdown-container-filter-input" as const; +const ID_NODE_TYPE = "node-type" as const; +const ID_NODE_TYPE_DROPDOWN = "panel-node-type-dropdown-container" as const; +const ID_NODE_TYPE_FILTER_INPUT = "panel-node-type-dropdown-container-filter-input" as const; +const ID_NODE_TYPE_WARNING = "node-type-warning" as const; +const ID_NODE_VERSION_DROPDOWN = "node-version-dropdown-container" as const; +const ID_NODE_VERSION_FILTER_INPUT = "node-version-dropdown-container-filter-input" as const; +const ID_NODE_ICON_COLOR = "node-icon-color" as const; +const ID_NODE_ICON_EDIT_BUTTON = "node-icon-edit-button" as const; +const ID_NODE_ICON_ADD_BUTTON = "node-icon-add-button" as const; +const ID_ICON_EDITOR_BACKDROP = "node-icon-editor-backdrop" as const; +const ID_ICON_EDITOR_MODAL = "node-icon-editor-modal" as const; +const ID_ICON_EDITOR_COLOR = "node-icon-editor-color" as const; +const ID_ICON_EDITOR_HEX = "node-icon-editor-hex" as const; +const ID_ICON_EDITOR_SHAPE = "node-icon-editor-shape" as const; +const ID_ICON_EDITOR_PREVIEW = "node-icon-editor-preview" as const; +const ID_ICON_EDITOR_SAVE = "node-icon-editor-save" as const; +const ID_ICON_EDITOR_CANCEL = "node-icon-editor-cancel" as const; +const ID_ICON_EDITOR_CLOSE = "node-icon-editor-close" as const; +const ID_ICON_EDITOR_CORNER = "node-icon-editor-corner" as const; +const ID_ICON_EDITOR_CORNER_VALUE = "node-icon-editor-corner-value" as const; +const ID_NODE_RP_DROPDOWN = "node-restart-policy-dropdown-container" as const; +const ID_NODE_RP_FILTER_INPUT = "node-restart-policy-dropdown-container-filter-input" as const; +const ID_NODE_NM_DROPDOWN = "node-network-mode-dropdown-container" as const; +const ID_NODE_NM_FILTER_INPUT = "node-network-mode-dropdown-container-filter-input" as const; +const ID_NODE_CUSTOM_DEFAULT = "node-custom-default" as const; +const ID_NODE_INTERFACE_PATTERN = "node-interface-pattern" as const; + +const ID_NODE_IPP_DROPDOWN = "node-image-pull-policy-dropdown-container" as const; +const ID_NODE_IPP_FILTER_INPUT = "node-image-pull-policy-dropdown-container-filter-input" as const; +const ID_NODE_RUNTIME_DROPDOWN = "node-runtime-dropdown-container" as const; +const ID_NODE_RUNTIME_FILTER_INPUT = "node-runtime-dropdown-container-filter-input" as const; +const ID_NODE_IMAGE_DROPDOWN = "node-image-dropdown-container" as const; +const ID_NODE_IMAGE_FILTER_INPUT = "node-image-dropdown-container-filter-input" as const; +const ID_NODE_IMAGE_FALLBACK_INPUT = "node-image-fallback-input" as const; +const ID_NODE_VERSION_FALLBACK_INPUT = "node-version-fallback-input" as const; +const ID_PANEL_NODE_TOPOROLE_CONTAINER = "panel-node-topoviewerrole-dropdown-container" as const; +const ID_PANEL_NODE_TOPOROLE_FILTER_INPUT = + "panel-node-topoviewerrole-dropdown-container-filter-input" as const; +const ID_NODE_CERT_KEYSIZE_DROPDOWN = "node-cert-key-size-dropdown-container" as const; +const ID_NODE_CERT_VALIDITY = "node-cert-validity" as const; +const ID_NODE_SANS_CONTAINER = "node-sans-container" as const; +const ID_NODE_CERT_KEYSIZE_FILTER_INPUT = + "node-cert-key-size-dropdown-container-filter-input" as const; +const ID_NODE_NAME = "node-name" as const; +const ID_NODE_CUSTOM_NAME = "node-custom-name" as const; +const ID_NODE_CUSTOM_NAME_GROUP = "node-custom-name-group" as const; +const ID_NODE_NAME_GROUP = "node-name-group" as const; // Common labels and placeholders -const LABEL_DEFAULT = 'Default' as const; -const PH_SEARCH_KIND = 'Search for kind...' as const; -const PH_SEARCH_TYPE = 'Search for type...' as const; -const PH_SEARCH_RP = 'Search restart policy...' as const; -const PH_SEARCH_NM = 'Search network mode...' as const; -const PH_SEARCH_IPP = 'Search pull policy...' as const; -const PH_SEARCH_RUNTIME = 'Search runtime...' as const; -const PH_SEARCH_IMAGE = 'Search for image...' as const; -const PH_SELECT_VERSION = 'Select version...' as const; -const PH_IMAGE_EXAMPLE = 'e.g., ghcr.io/nokia/srlinux' as const; -const PH_VERSION_EXAMPLE = 'e.g., latest' as const; -const TYPE_UNSUPPORTED_WARNING_TEXT = 'Type is set in YAML, but the schema for this kind does not support it.' as const; +const LABEL_DEFAULT = "Default" as const; +const PH_SEARCH_KIND = "Search for kind..." as const; +const PH_SEARCH_TYPE = "Search for type..." as const; +const PH_SEARCH_RP = "Search restart policy..." as const; +const PH_SEARCH_NM = "Search network mode..." as const; +const PH_SEARCH_IPP = "Search pull policy..." as const; +const PH_SEARCH_RUNTIME = "Search runtime..." as const; +const PH_SEARCH_IMAGE = "Search for image..." as const; +const PH_SELECT_VERSION = "Select version..." as const; +const PH_IMAGE_EXAMPLE = "e.g., ghcr.io/nokia/srlinux" as const; +const PH_VERSION_EXAMPLE = "e.g., latest" as const; +const TYPE_UNSUPPORTED_WARNING_TEXT = + "Type is set in YAML, but the schema for this kind does not support it." as const; // Healthcheck IDs and prop -const ID_HC_TEST = 'node-healthcheck-test' as const; -const ID_HC_START = 'node-healthcheck-start-period' as const; -const ID_HC_INTERVAL = 'node-healthcheck-interval' as const; -const ID_HC_TIMEOUT = 'node-healthcheck-timeout' as const; -const ID_HC_RETRIES = 'node-healthcheck-retries' as const; -const PROP_HEALTHCHECK = 'healthcheck' as const; -const PH_SEARCH_KEY_SIZE = 'Search key size...' as const; - -const PH_BIND = 'Bind mount (host:container)' as const; -const PH_ENV_KEY = 'ENV_NAME' as const; -const PH_VALUE = 'value' as const; -const PH_ENV_FILE = 'Path to env file' as const; -const PH_LABEL_KEY = 'label-key' as const; -const PH_LABEL_VALUE = 'label-value' as const; -const PH_EXEC = 'Command to execute' as const; -const PH_PORT = 'Host:Container (e.g., 8080:80)' as const; -const PH_DNS_SERVER = 'DNS server IP' as const; -const PH_ALIAS = 'Network alias' as const; -const PH_CAP = 'Capability (e.g., NET_ADMIN)' as const; -const PH_SYSCTL_KEY = 'sysctl.key' as const; -const PH_DEVICE = 'Device path (e.g., /dev/net/tun)' as const; -const PH_SAN = 'SAN (e.g., test.com or 192.168.1.1)' as const; +const ID_HC_TEST = "node-healthcheck-test" as const; +const ID_HC_START = "node-healthcheck-start-period" as const; +const ID_HC_INTERVAL = "node-healthcheck-interval" as const; +const ID_HC_TIMEOUT = "node-healthcheck-timeout" as const; +const ID_HC_RETRIES = "node-healthcheck-retries" as const; +const PROP_HEALTHCHECK = "healthcheck" as const; +const PH_SEARCH_KEY_SIZE = "Search key size..." as const; + +const PH_BIND = "Bind mount (host:container)" as const; +const PH_ENV_KEY = "ENV_NAME" as const; +const PH_VALUE = "value" as const; +const PH_ENV_FILE = "Path to env file" as const; +const PH_LABEL_KEY = "label-key" as const; +const PH_LABEL_VALUE = "label-value" as const; +const PH_EXEC = "Command to execute" as const; +const PH_PORT = "Host:Container (e.g., 8080:80)" as const; +const PH_DNS_SERVER = "DNS server IP" as const; +const PH_ALIAS = "Network alias" as const; +const PH_CAP = "Capability (e.g., NET_ADMIN)" as const; +const PH_SYSCTL_KEY = "sysctl.key" as const; +const PH_DEVICE = "Device path (e.g., /dev/net/tun)" as const; +const PH_SAN = "SAN (e.g., test.com or 192.168.1.1)" as const; // Options -const OPTIONS_RP = [LABEL_DEFAULT, 'no', 'on-failure', 'always', 'unless-stopped'] as const; -const OPTIONS_NM = [LABEL_DEFAULT, 'host', 'none'] as const; -const OPTIONS_IPP = [LABEL_DEFAULT, 'IfNotPresent', 'Never', 'Always'] as const; -const OPTIONS_RUNTIME = [LABEL_DEFAULT, 'docker', 'podman', 'ignite'] as const; +const OPTIONS_RP = [LABEL_DEFAULT, "no", "on-failure", "always", "unless-stopped"] as const; +const OPTIONS_NM = [LABEL_DEFAULT, "host", "none"] as const; +const OPTIONS_IPP = [LABEL_DEFAULT, "IfNotPresent", "Never", "Always"] as const; +const OPTIONS_RUNTIME = [LABEL_DEFAULT, "docker", "podman", "ignite"] as const; // Common property keys used in extraData/inheritance -const PROP_STARTUP_CONFIG = 'startup-config' as const; -const PROP_ENFORCE_STARTUP_CONFIG = 'enforce-startup-config' as const; -const PROP_SUPPRESS_STARTUP_CONFIG = 'suppress-startup-config' as const; -const PROP_MGMT_IPV4 = 'mgmt-ipv4' as const; -const PROP_MGMT_IPV6 = 'mgmt-ipv6' as const; -const PROP_CPU_SET = 'cpu-set' as const; -const PROP_SHM_SIZE = 'shm-size' as const; -const PROP_RESTART_POLICY = 'restart-policy' as const; -const PROP_AUTO_REMOVE = 'auto-remove' as const; -const PROP_STARTUP_DELAY = 'startup-delay' as const; -const PROP_NETWORK_MODE = 'network-mode' as const; -const PROP_PORTS = 'ports' as const; -const PROP_DNS = 'dns' as const; -const PROP_ALIASES = 'aliases' as const; -const PROP_MEMORY = 'memory' as const; -const PROP_CPU = 'cpu' as const; -const PROP_CAP_ADD = 'cap-add' as const; -const PROP_SYSCTLS = 'sysctls' as const; -const PROP_DEVICES = 'devices' as const; -const PROP_CERTIFICATE = 'certificate' as const; -const PROP_IMAGE_PULL_POLICY = 'image-pull-policy' as const; -const PROP_RUNTIME = 'runtime' as const; +const PROP_STARTUP_CONFIG = "startup-config" as const; +const PROP_ENFORCE_STARTUP_CONFIG = "enforce-startup-config" as const; +const PROP_SUPPRESS_STARTUP_CONFIG = "suppress-startup-config" as const; +const PROP_MGMT_IPV4 = "mgmt-ipv4" as const; +const PROP_MGMT_IPV6 = "mgmt-ipv6" as const; +const PROP_CPU_SET = "cpu-set" as const; +const PROP_SHM_SIZE = "shm-size" as const; +const PROP_RESTART_POLICY = "restart-policy" as const; +const PROP_AUTO_REMOVE = "auto-remove" as const; +const PROP_STARTUP_DELAY = "startup-delay" as const; +const PROP_NETWORK_MODE = "network-mode" as const; +const PROP_PORTS = "ports" as const; +const PROP_DNS = "dns" as const; +const PROP_ALIASES = "aliases" as const; +const PROP_MEMORY = "memory" as const; +const PROP_CPU = "cpu" as const; +const PROP_CAP_ADD = "cap-add" as const; +const PROP_SYSCTLS = "sysctls" as const; +const PROP_DEVICES = "devices" as const; +const PROP_CERTIFICATE = "certificate" as const; +const PROP_IMAGE_PULL_POLICY = "image-pull-policy" as const; +const PROP_RUNTIME = "runtime" as const; // Data attributes used for dynamic entry buttons -const DATA_ATTR_CONTAINER = 'data-container' as const; -const DATA_ATTR_ENTRY_ID = 'data-entry-id' as const; -const DATA_ATTR_FIELD = 'data-field' as const; +const DATA_ATTR_CONTAINER = "data-container" as const; +const DATA_ATTR_ENTRY_ID = "data-entry-id" as const; +const DATA_ATTR_FIELD = "data-field" as const; // Reused DOM IDs -const ID_NODE_STARTUP_CONFIG = 'node-startup-config' as const; -const ID_NODE_ENFORCE_STARTUP_CONFIG = 'node-enforce-startup-config' as const; -const ID_NODE_SUPPRESS_STARTUP_CONFIG = 'node-suppress-startup-config' as const; -const ID_NODE_LICENSE = 'node-license' as const; -const ID_NODE_BINDS_CONTAINER = 'node-binds-container' as const; -const ID_NODE_ENV_CONTAINER = 'node-env-container' as const; -const ID_NODE_ENV_FILES_CONTAINER = 'node-env-files-container' as const; -const ID_NODE_LABELS_CONTAINER = 'node-labels-container' as const; -const ID_NODE_USER = 'node-user' as const; -const ID_NODE_ENTRYPOINT = 'node-entrypoint' as const; -const ID_NODE_CMD = 'node-cmd' as const; -const ID_NODE_EXEC_CONTAINER = 'node-exec-container' as const; -const ID_NODE_AUTO_REMOVE = 'node-auto-remove' as const; -const ID_NODE_STARTUP_DELAY = 'node-startup-delay' as const; -const ID_NODE_PORTS_CONTAINER = 'node-ports-container' as const; -const ID_NODE_DNS_SERVERS_CONTAINER = 'node-dns-servers-container' as const; -const ID_NODE_ALIASES_CONTAINER = 'node-aliases-container' as const; -const ID_NODE_MEMORY = 'node-memory' as const; -const ID_NODE_CPU = 'node-cpu' as const; -const ID_NODE_CAP_ADD_CONTAINER = 'node-cap-add-container' as const; -const ID_NODE_SYSCTLS_CONTAINER = 'node-sysctls-container' as const; -const ID_NODE_DEVICES_CONTAINER = 'node-devices-container' as const; -const ID_NODE_MGMT_IPV4 = 'node-mgmt-ipv4' as const; -const ID_NODE_MGMT_IPV6 = 'node-mgmt-ipv6' as const; -const ID_NODE_CPU_SET = 'node-cpu-set' as const; -const ID_NODE_SHM_SIZE = 'node-shm-size' as const; +const ID_NODE_STARTUP_CONFIG = "node-startup-config" as const; +const ID_NODE_ENFORCE_STARTUP_CONFIG = "node-enforce-startup-config" as const; +const ID_NODE_SUPPRESS_STARTUP_CONFIG = "node-suppress-startup-config" as const; +const ID_NODE_LICENSE = "node-license" as const; +const ID_NODE_BINDS_CONTAINER = "node-binds-container" as const; +const ID_NODE_ENV_CONTAINER = "node-env-container" as const; +const ID_NODE_ENV_FILES_CONTAINER = "node-env-files-container" as const; +const ID_NODE_LABELS_CONTAINER = "node-labels-container" as const; +const ID_NODE_USER = "node-user" as const; +const ID_NODE_ENTRYPOINT = "node-entrypoint" as const; +const ID_NODE_CMD = "node-cmd" as const; +const ID_NODE_EXEC_CONTAINER = "node-exec-container" as const; +const ID_NODE_AUTO_REMOVE = "node-auto-remove" as const; +const ID_NODE_STARTUP_DELAY = "node-startup-delay" as const; +const ID_NODE_PORTS_CONTAINER = "node-ports-container" as const; +const ID_NODE_DNS_SERVERS_CONTAINER = "node-dns-servers-container" as const; +const ID_NODE_ALIASES_CONTAINER = "node-aliases-container" as const; +const ID_NODE_MEMORY = "node-memory" as const; +const ID_NODE_CPU = "node-cpu" as const; +const ID_NODE_CAP_ADD_CONTAINER = "node-cap-add-container" as const; +const ID_NODE_SYSCTLS_CONTAINER = "node-sysctls-container" as const; +const ID_NODE_DEVICES_CONTAINER = "node-devices-container" as const; +const ID_NODE_MGMT_IPV4 = "node-mgmt-ipv4" as const; +const ID_NODE_MGMT_IPV6 = "node-mgmt-ipv6" as const; +const ID_NODE_CPU_SET = "node-cpu-set" as const; +const ID_NODE_SHM_SIZE = "node-shm-size" as const; // Dynamic container names -const CN_BINDS = 'binds' as const; -const CN_ENV = 'env' as const; -const CN_ENV_FILES = 'env-files' as const; -const CN_LABELS = 'labels' as const; -const CN_EXEC = 'exec' as const; -const CN_PORTS = 'ports' as const; -const CN_DNS_SERVERS = 'dns-servers' as const; -const CN_ALIASES = 'aliases' as const; -const CN_CAP_ADD = 'cap-add' as const; -const CN_SYSCTLS = 'sysctls' as const; -const CN_DEVICES = 'devices' as const; -const CN_SANS = 'sans' as const; +const CN_BINDS = "binds" as const; +const CN_ENV = "env" as const; +const CN_ENV_FILES = "env-files" as const; +const CN_LABELS = "labels" as const; +const CN_EXEC = "exec" as const; +const CN_PORTS = "ports" as const; +const CN_DNS_SERVERS = "dns-servers" as const; +const CN_ALIASES = "aliases" as const; +const CN_CAP_ADD = "cap-add" as const; +const CN_SYSCTLS = "sysctls" as const; +const CN_DEVICES = "devices" as const; +const CN_SANS = "sans" as const; // Special node IDs -const ID_TEMP_CUSTOM_NODE = 'temp-custom-node' as const; -const ID_EDIT_CUSTOM_NODE = 'edit-custom-node' as const; +const ID_TEMP_CUSTOM_NODE = "temp-custom-node" as const; +const ID_EDIT_CUSTOM_NODE = "edit-custom-node" as const; // Shared field→prop mappings for inheritance badges and change listeners type FieldMapping = { id: string; prop: string; badgeId?: string }; const FIELD_MAPPINGS_BASE: FieldMapping[] = [ - { id: ID_NODE_KIND_DROPDOWN, prop: 'kind' }, - { id: ID_NODE_TYPE, prop: 'type' }, - { id: ID_NODE_IMAGE_DROPDOWN, prop: 'image' }, + { id: ID_NODE_KIND_DROPDOWN, prop: "kind" }, + { id: ID_NODE_TYPE, prop: "type" }, + { id: ID_NODE_IMAGE_DROPDOWN, prop: "image" }, { id: ID_NODE_STARTUP_CONFIG, prop: PROP_STARTUP_CONFIG }, { id: ID_NODE_ENFORCE_STARTUP_CONFIG, prop: PROP_ENFORCE_STARTUP_CONFIG }, { id: ID_NODE_SUPPRESS_STARTUP_CONFIG, prop: PROP_SUPPRESS_STARTUP_CONFIG }, - { id: ID_NODE_LICENSE, prop: 'license' }, + { id: ID_NODE_LICENSE, prop: "license" }, { id: ID_NODE_BINDS_CONTAINER, prop: CN_BINDS }, { id: ID_NODE_ENV_CONTAINER, prop: CN_ENV }, { id: ID_NODE_ENV_FILES_CONTAINER, prop: CN_ENV_FILES }, { id: ID_NODE_LABELS_CONTAINER, prop: CN_LABELS }, - { id: ID_NODE_USER, prop: 'user' }, - { id: ID_NODE_ENTRYPOINT, prop: 'entrypoint' }, - { id: ID_NODE_CMD, prop: 'cmd' }, + { id: ID_NODE_USER, prop: "user" }, + { id: ID_NODE_ENTRYPOINT, prop: "entrypoint" }, + { id: ID_NODE_CMD, prop: "cmd" }, { id: ID_NODE_EXEC_CONTAINER, prop: CN_EXEC }, { id: ID_NODE_RP_DROPDOWN, prop: PROP_RESTART_POLICY }, { id: ID_NODE_AUTO_REMOVE, prop: PROP_AUTO_REMOVE }, @@ -278,7 +285,7 @@ const FIELD_MAPPINGS_BASE: FieldMapping[] = [ { id: ID_NODE_CERT_ISSUE, prop: PROP_CERTIFICATE }, { id: ID_HC_TEST, prop: PROP_HEALTHCHECK }, { id: ID_NODE_IPP_DROPDOWN, prop: PROP_IMAGE_PULL_POLICY }, - { id: ID_NODE_RUNTIME_DROPDOWN, prop: PROP_RUNTIME }, + { id: ID_NODE_RUNTIME_DROPDOWN, prop: PROP_RUNTIME } ]; /** @@ -295,12 +302,12 @@ export interface NodeProperties { // Configuration properties license?: string; - 'startup-config'?: string; - 'enforce-startup-config'?: boolean; - 'suppress-startup-config'?: boolean; + "startup-config"?: string; + "enforce-startup-config"?: boolean; + "suppress-startup-config"?: boolean; binds?: string[]; env?: Record; - 'env-files'?: string[]; + "env-files"?: string[]; labels?: Record; // Runtime properties @@ -308,14 +315,14 @@ export interface NodeProperties { entrypoint?: string; cmd?: string; exec?: string[]; - 'restart-policy'?: RestartPolicy; - 'auto-remove'?: boolean; - 'startup-delay'?: number; + "restart-policy"?: RestartPolicy; + "auto-remove"?: boolean; + "startup-delay"?: number; // Network properties - 'mgmt-ipv4'?: string; - 'mgmt-ipv6'?: string; - 'network-mode'?: string; + "mgmt-ipv4"?: string; + "mgmt-ipv6"?: string; + "network-mode"?: string; ports?: string[]; dns?: { servers?: string[]; @@ -327,25 +334,25 @@ export interface NodeProperties { // Advanced properties memory?: string; cpu?: number; - 'cpu-set'?: string; - 'shm-size'?: string; - 'cap-add'?: string[]; + "cpu-set"?: string; + "shm-size"?: string; + "cap-add"?: string[]; sysctls?: Record; devices?: string[]; certificate?: { issue?: boolean; - 'key-size'?: number; - 'validity-duration'?: string; + "key-size"?: number; + "validity-duration"?: string; sans?: string[]; }; healthcheck?: { test?: string[]; - 'start-period'?: number; + "start-period"?: number; interval?: number; timeout?: number; retries?: number; }; - 'image-pull-policy'?: ImagePullPolicy; + "image-pull-policy"?: ImagePullPolicy; runtime?: Runtime; components?: any[]; @@ -355,7 +362,7 @@ export interface NodeProperties { // Stages (for dependencies) stages?: { create?: { - 'wait-for'?: Array<{ + "wait-for"?: Array<{ node: string; stage: string; }>; @@ -365,7 +372,7 @@ export interface NodeProperties { phase?: ExecPhase; }>; }; - 'create-links'?: { + "create-links"?: { exec?: Array<{ command: string; target?: ExecTarget; @@ -399,14 +406,14 @@ export class ManagerNodeEditor { private typeSchemaLoaded = false; private kindsWithTypeSupport: Set = new Set(); // can add other kind which use components later... - private componentKinds: Set = new Set(['nokia_srsim']); + private componentKinds: Set = new Set(["nokia_srsim"]); private componentEntryCounter: number = 0; private componentMdaCounters: Map = new Map(); private componentXiomCounters: Map = new Map(); private xiomMdaCounters: Map = new Map(); private pendingExpandedComponentSlots: Set | undefined; private cachedNodeIcons: string[] = []; - private cachedCustomIconSignature: string = ''; + private cachedCustomIconSignature: string = ""; private currentIconColor: string | null = null; private currentIconCornerRadius: number = DEFAULT_ICON_CORNER_RADIUS; private iconEditorInitialized = false; @@ -418,13 +425,13 @@ export class ManagerNodeEditor { private srosXiomMdaTypes: string[] = []; private srosMdaTypes: string[] = []; private integratedSrosTypes: Set = new Set( - ['sr-1', 'sr-1s', 'ixr-r6', 'ixr-ec', 'ixr-e2', 'ixr-e2c'].map(t => t.toLowerCase()) + ["sr-1", "sr-1s", "ixr-r6", "ixr-ec", "ixr-e2", "ixr-e2c"].map((t) => t.toLowerCase()) ); private integratedMode = false; private integratedMdaCounter = 0; private readonly renderIconOption = (role: string): HTMLElement => createNodeIconOptionElement(role, { - onDelete: iconName => { + onDelete: (iconName) => { void this.handleIconDelete(iconName); } }); @@ -438,13 +445,13 @@ export class ManagerNodeEditor { public handleDockerImagesUpdated(images: string[]): void { (window as any).dockerImages = images; - if (!this.panel || this.panel.style.display === 'none') { + if (!this.panel || this.panel.style.display === "none") { return; } if (!this.currentNode) { return; } - const extraData = this.currentNode.data('extraData') || {}; + const extraData = this.currentNode.data("extraData") || {}; const actualInherited = this.computeActualInheritedProps(extraData); this.setupImageFields(extraData, actualInherited); } @@ -457,7 +464,7 @@ export class ManagerNodeEditor { for (const image of dockerImages) { // Split by colon to separate repository from tag - const lastColonIndex = image.lastIndexOf(':'); + const lastColonIndex = image.lastIndexOf(":"); if (lastColonIndex > 0) { const baseImage = image.substring(0, lastColonIndex); const version = image.substring(lastColonIndex + 1); @@ -469,7 +476,7 @@ export class ManagerNodeEditor { } else { // No version tag, treat whole thing as base image with 'latest' as version if (!this.imageVersionMap.has(image)) { - this.imageVersionMap.set(image, ['latest']); + this.imageVersionMap.set(image, ["latest"]); } } } @@ -478,8 +485,8 @@ export class ManagerNodeEditor { for (const versions of this.imageVersionMap.values()) { versions.sort((a, b) => { // Put 'latest' first - if (a === 'latest') return -1; - if (b === 'latest') return 1; + if (a === "latest") return -1; + if (b === "latest") return 1; // Then sort alphanumerically return b.localeCompare(a); // Reverse order to put newer versions first }); @@ -497,23 +504,27 @@ export class ManagerNodeEditor { createFilterableDropdown( ID_NODE_VERSION_DROPDOWN, versions, - versions[0] || 'latest', + versions[0] || "latest", () => {}, - 'Select version...', + "Select version...", true // Allow free text for custom versions ); - log.debug(`Base image changed to ${selectedBaseImage}, available versions: ${versions.join(', ')}`); + log.debug( + `Base image changed to ${selectedBaseImage}, available versions: ${versions.join(", ")}` + ); } else { // Unknown image - allow free text version entry createFilterableDropdown( ID_NODE_VERSION_DROPDOWN, - ['latest'], - 'latest', + ["latest"], + "latest", () => {}, - 'Enter version...', + "Enter version...", true // Allow free text ); - log.debug(`Base image changed to custom image ${selectedBaseImage}, allowing free text version entry`); + log.debug( + `Base image changed to custom image ${selectedBaseImage}, allowing free text version entry` + ); } } @@ -521,7 +532,9 @@ export class ManagerNodeEditor { * Handle kind change and update type field visibility */ private handleKindChange(selectedKind: string): void { - const typeFormGroup = document.getElementById(ID_NODE_TYPE)?.closest(SELECTOR_FORM_GROUP) as HTMLElement; + const typeFormGroup = document + .getElementById(ID_NODE_TYPE) + ?.closest(SELECTOR_FORM_GROUP) as HTMLElement; const typeDropdownContainer = document.getElementById(ID_NODE_TYPE_DROPDOWN); const typeInput = document.getElementById(ID_NODE_TYPE) as HTMLInputElement; @@ -529,7 +542,13 @@ export class ManagerNodeEditor { const typeOptions = this.getTypeOptionsForKind(selectedKind); if (typeOptions.length > 0) { - this.showTypeDropdown(typeFormGroup, typeDropdownContainer, typeInput, typeOptions, selectedKind); + this.showTypeDropdown( + typeFormGroup, + typeDropdownContainer, + typeInput, + typeOptions, + selectedKind + ); } else { this.toggleTypeInputForKind(selectedKind, typeFormGroup, typeDropdownContainer, typeInput); } @@ -540,13 +559,17 @@ export class ManagerNodeEditor { if (this.isCustomTemplateNode()) { const ifaceMap = (window as any).ifacePatternMapping || {}; const defaultPattern = ifaceMap[selectedKind] || DEFAULT_INTERFACE_PATTERN; - const ifaceInput = document.getElementById(ID_NODE_INTERFACE_PATTERN) as HTMLInputElement | null; + const ifaceInput = document.getElementById( + ID_NODE_INTERFACE_PATTERN + ) as HTMLInputElement | null; if (ifaceInput && !ifaceInput.value) { ifaceInput.value = defaultPattern; } } - log.debug(`Kind changed to ${selectedKind}, type field visibility: ${typeFormGroup?.style.display}`); + log.debug( + `Kind changed to ${selectedKind}, type field visibility: ${typeFormGroup?.style.display}` + ); } private updateComponentsTabVisibility(kind: string): void { @@ -575,16 +598,26 @@ export class ManagerNodeEditor { private hideComponentsTab(btn: HTMLElement, content: HTMLElement): void { if (btn.classList.contains(CLASS_TAB_ACTIVE)) { - this.switchToTab('basic'); + this.switchToTab("basic"); } btn.classList.add(CLASS_HIDDEN); content.classList.add(CLASS_HIDDEN); } - private getComponentContainers(): { cpmContainer: HTMLElement; cardContainer: HTMLElement; sfmContainer: HTMLElement } | null { - const cpmContainer = document.getElementById(ID_NODE_COMPONENTS_CPM_CONTAINER) as HTMLElement | null; - const cardContainer = document.getElementById(ID_NODE_COMPONENTS_CARD_CONTAINER) as HTMLElement | null; - const sfmContainer = document.getElementById(ID_NODE_COMPONENTS_SFM_CONTAINER) as HTMLElement | null; + private getComponentContainers(): { + cpmContainer: HTMLElement; + cardContainer: HTMLElement; + sfmContainer: HTMLElement; + } | null { + const cpmContainer = document.getElementById( + ID_NODE_COMPONENTS_CPM_CONTAINER + ) as HTMLElement | null; + const cardContainer = document.getElementById( + ID_NODE_COMPONENTS_CARD_CONTAINER + ) as HTMLElement | null; + const sfmContainer = document.getElementById( + ID_NODE_COMPONENTS_SFM_CONTAINER + ) as HTMLElement | null; if (!cpmContainer || !cardContainer || !sfmContainer) return null; return { cpmContainer, cardContainer, sfmContainer }; } @@ -597,7 +630,7 @@ export class ManagerNodeEditor { return; } const mdas = this.getIntegratedMdasForCurrentNode(); - mdas.forEach(mda => this.addIntegratedMdaEntry(mda)); + mdas.forEach((mda) => this.addIntegratedMdaEntry(mda)); this.updateIntegratedAddButtonState(); return; } @@ -620,11 +653,15 @@ export class ManagerNodeEditor { this.updateComponentAddButtonStates(); } - private resetComponentContainers(containers: { cpmContainer: HTMLElement; cardContainer: HTMLElement; sfmContainer: HTMLElement }): void { + private resetComponentContainers(containers: { + cpmContainer: HTMLElement; + cardContainer: HTMLElement; + sfmContainer: HTMLElement; + }): void { const { cpmContainer, cardContainer, sfmContainer } = containers; - cpmContainer.innerHTML = ''; - cardContainer.innerHTML = ''; - sfmContainer.innerHTML = ''; + cpmContainer.innerHTML = ""; + cardContainer.innerHTML = ""; + sfmContainer.innerHTML = ""; this.componentEntryCounter = 0; this.componentMdaCounters.clear(); this.componentXiomCounters.clear(); @@ -633,21 +670,21 @@ export class ManagerNodeEditor { private resetIntegratedMdaContainer(): void { const container = document.getElementById(ID_NODE_INTEGRATED_MDA_CONTAINER); - if (container) container.innerHTML = ''; + if (container) container.innerHTML = ""; this.integratedMdaCounter = 0; } private getComponentsForCurrentNode(): any[] { if (!this.currentNode) return []; - const extra = this.currentNode.data('extraData') || {}; + const extra = this.currentNode.data("extraData") || {}; return Array.isArray(extra.components) ? extra.components : []; } private getIntegratedMdasForCurrentNode(): any[] { const components = this.getComponentsForCurrentNode(); for (const comp of components) { - if (!comp || typeof comp !== 'object') continue; - if (Array.isArray(comp.mda) && (comp.slot == null || String(comp.slot).trim() === '')) { + if (!comp || typeof comp !== "object") continue; + if (Array.isArray(comp.mda) && (comp.slot == null || String(comp.slot).trim() === "")) { return comp.mda; } } @@ -656,7 +693,7 @@ export class ManagerNodeEditor { private addIntegratedMdaEntry(prefill?: any): number { const container = document.getElementById(ID_NODE_INTEGRATED_MDA_CONTAINER); - const tpl = document.getElementById('tpl-integrated-mda-entry') as HTMLTemplateElement | null; + const tpl = document.getElementById("tpl-integrated-mda-entry") as HTMLTemplateElement | null; if (!container || !tpl) return -1; const next = this.integratedMdaCounter + 1; @@ -669,9 +706,15 @@ export class ManagerNodeEditor { row.id = `integrated-mda-entry-${next}`; const slot = row.querySelector('[data-role="integrated-mda-slot"]') as HTMLInputElement | null; - const hiddenType = row.querySelector('[data-role="integrated-mda-type-value"]') as HTMLInputElement | null; - const typeDropdown = row.querySelector('[data-role="integrated-mda-type-dropdown"]') as HTMLElement | null; - const delBtn = row.querySelector('[data-action="remove-integrated-mda"]') as HTMLButtonElement | null; + const hiddenType = row.querySelector( + '[data-role="integrated-mda-type-value"]' + ) as HTMLInputElement | null; + const typeDropdown = row.querySelector( + '[data-role="integrated-mda-type-dropdown"]' + ) as HTMLElement | null; + const delBtn = row.querySelector( + '[data-action="remove-integrated-mda"]' + ) as HTMLButtonElement | null; if (slot) { slot.id = `integrated-mda-${next}-slot`; @@ -680,7 +723,7 @@ export class ManagerNodeEditor { if (hiddenType) { hiddenType.id = `integrated-mda-${next}-type`; - hiddenType.value = prefill?.type != null ? String(prefill.type) : ''; + hiddenType.value = prefill?.type != null ? String(prefill.type) : ""; } if (typeDropdown) { @@ -717,16 +760,20 @@ export class ManagerNodeEditor { } private updateIntegratedAddButtonState(): void { - const addBtn = document.getElementById(ID_ADD_INTEGRATED_MDA_BUTTON) as HTMLButtonElement | null; + const addBtn = document.getElementById( + ID_ADD_INTEGRATED_MDA_BUTTON + ) as HTMLButtonElement | null; if (!addBtn) return; addBtn.disabled = false; - addBtn.title = 'Add an MDA slot'; + addBtn.title = "Add an MDA slot"; } private collectIntegratedMdas(): any[] { const container = document.getElementById(ID_NODE_INTEGRATED_MDA_CONTAINER); if (!container) return []; - const rows = Array.from(container.querySelectorAll(`.${CLASS_INTEGRATED_MDA_ENTRY}`)) as HTMLElement[]; + const rows = Array.from( + container.querySelectorAll(`.${CLASS_INTEGRATED_MDA_ENTRY}`) + ) as HTMLElement[]; const list: any[] = []; for (const row of rows) { const mdaId = this.extractIndex(row.id, /integrated-mda-entry-(\d+)/); @@ -744,12 +791,18 @@ export class ManagerNodeEditor { private commitIntegratedMdaDropdowns(): void { const container = document.getElementById(ID_NODE_INTEGRATED_MDA_CONTAINER); if (!container) return; - const rows = Array.from(container.querySelectorAll(`.${CLASS_INTEGRATED_MDA_ENTRY}`)) as HTMLElement[]; + const rows = Array.from( + container.querySelectorAll(`.${CLASS_INTEGRATED_MDA_ENTRY}`) + ) as HTMLElement[]; for (const row of rows) { const mdaId = this.extractIndex(row.id, /integrated-mda-entry-(\d+)/); if (mdaId === null) continue; - const filter = document.getElementById(`integrated-mda-${mdaId}-type-dropdown-filter-input`) as HTMLInputElement | null; - const hidden = document.getElementById(`integrated-mda-${mdaId}-type`) as HTMLInputElement | null; + const filter = document.getElementById( + `integrated-mda-${mdaId}-type-dropdown-filter-input` + ) as HTMLInputElement | null; + const hidden = document.getElementById( + `integrated-mda-${mdaId}-type` + ) as HTMLInputElement | null; if (filter && hidden) hidden.value = filter.value; } } @@ -757,8 +810,10 @@ export class ManagerNodeEditor { private refreshIntegratedMdaDropdowns(): void { const container = document.getElementById(ID_NODE_INTEGRATED_MDA_CONTAINER); if (!container) return; - const rows = Array.from(container.querySelectorAll(`.${CLASS_INTEGRATED_MDA_ENTRY}`)) as HTMLElement[]; - rows.forEach(row => { + const rows = Array.from( + container.querySelectorAll(`.${CLASS_INTEGRATED_MDA_ENTRY}`) + ) as HTMLElement[]; + rows.forEach((row) => { const mdaId = this.extractIndex(row.id, /integrated-mda-entry-(\d+)/); if (mdaId !== null) this.initIntegratedMdaTypeDropdown(mdaId); }); @@ -777,9 +832,9 @@ export class ManagerNodeEditor { private renderComponentEntries(components: any[], expandSet?: Set): void { const sorted = [...components].sort((a, b) => this.compareComponentSlots(a?.slot, b?.slot)); - sorted.forEach(comp => { - const slotVal = String(comp?.slot ?? ''); - const slotType = this.isCpmSlot(slotVal) ? 'cpm' : 'card'; + sorted.forEach((comp) => { + const slotVal = String(comp?.slot ?? ""); + const slotType = this.isCpmSlot(slotVal) ? "cpm" : "card"; const prefill = this.createComponentPrefill(comp); const idx = this.addComponentEntry(prefill, { slotType }); if (idx > 0 && expandSet) { @@ -790,7 +845,7 @@ export class ManagerNodeEditor { } private createComponentPrefill(comp: any): any { - if (!comp || typeof comp !== 'object') return {}; + if (!comp || typeof comp !== "object") return {}; const prefill = { ...comp } as any; delete prefill.sfm; return prefill; @@ -799,7 +854,7 @@ export class ManagerNodeEditor { private extractSharedSfmValue(components: any[]): string | undefined { if (!Array.isArray(components) || components.length === 0) return undefined; for (const comp of components) { - const sfmVal = typeof comp?.sfm === 'string' ? comp.sfm.trim() : ''; + const sfmVal = typeof comp?.sfm === "string" ? comp.sfm.trim() : ""; if (sfmVal) return sfmVal; } return undefined; @@ -807,13 +862,13 @@ export class ManagerNodeEditor { private compareComponentSlots(a: unknown, b: unknown): number { const rank = (v: unknown): [number, number] => { - if (typeof v === 'string') { + if (typeof v === "string") { const t = v.trim().toUpperCase(); - if (t === 'A') return [0, 0]; - if (t === 'B') return [1, 0]; + if (t === "A") return [0, 0]; + if (t === "B") return [1, 0]; const n = parseInt(t, 10); if (!Number.isNaN(n)) return [2, n]; - } else if (typeof v === 'number' && Number.isFinite(v)) { + } else if (typeof v === "number" && Number.isFinite(v)) { return [2, v]; } // Unknown or empty -> push to end @@ -824,14 +879,15 @@ export class ManagerNodeEditor { return ra[0] !== rb[0] ? ra[0] - rb[0] : ra[1] - rb[1]; } - private addComponentEntry(prefill?: any, options?: { slotType?: 'cpm' | 'card' }): number { - const tpl = document.getElementById('tpl-component-entry') as HTMLTemplateElement | null; + private addComponentEntry(prefill?: any, options?: { slotType?: "cpm" | "card" }): number { + const tpl = document.getElementById("tpl-component-entry") as HTMLTemplateElement | null; if (!tpl) return -1; const prefillSlot = prefill?.slot; - const defaultType = this.isCpmSlot(String(prefillSlot ?? '')) ? 'cpm' : 'card'; + const defaultType = this.isCpmSlot(String(prefillSlot ?? "")) ? "cpm" : "card"; const slotType = options?.slotType ?? defaultType; - const containerId = slotType === 'cpm' ? ID_NODE_COMPONENTS_CPM_CONTAINER : ID_NODE_COMPONENTS_CARD_CONTAINER; + const containerId = + slotType === "cpm" ? ID_NODE_COMPONENTS_CPM_CONTAINER : ID_NODE_COMPONENTS_CARD_CONTAINER; const container = document.getElementById(containerId); if (!container) return -1; @@ -863,7 +919,7 @@ export class ManagerNodeEditor { } private autofillComponentSlotIfNeeded(idx: number, prefill: any, suggestedSlot: string): void { - if (prefill && prefill.slot != null && String(prefill.slot).trim() !== '') return; + if (prefill && prefill.slot != null && String(prefill.slot).trim() !== "") return; const slot = document.getElementById(`component-${idx}-slot`) as HTMLInputElement | null; if (slot && suggestedSlot) slot.value = suggestedSlot; } @@ -876,64 +932,69 @@ export class ManagerNodeEditor { } private prefillSubcomponents(idx: number, prefill: any): void { - const slotVal = (document.getElementById(`component-${idx}-slot`) as HTMLInputElement | null)?.value || ''; + const slotVal = + (document.getElementById(`component-${idx}-slot`) as HTMLInputElement | null)?.value || ""; const isCpm = this.isCpmSlot(slotVal); - if (!isCpm && Array.isArray(prefill?.mda)) prefill.mda.forEach((m: any) => this.addMdaEntry(idx, m)); + if (!isCpm && Array.isArray(prefill?.mda)) + prefill.mda.forEach((m: any) => this.addMdaEntry(idx, m)); if (isCpm) return; if (Array.isArray(prefill?.xiom)) prefill.xiom.forEach((x: any) => this.addXiomEntry(idx, x)); - else if (typeof prefill?.xiom === 'string' && prefill.xiom) this.addXiomEntry(idx, { slot: 1, type: String(prefill.xiom) }); + else if (typeof prefill?.xiom === "string" && prefill.xiom) + this.addXiomEntry(idx, { slot: 1, type: String(prefill.xiom) }); } private normalizeComponentSlot(v: string): string { - const t = (v || '').trim(); - if (t.toUpperCase() === 'A') return 'A'; - if (t.toUpperCase() === 'B') return 'B'; + const t = (v || "").trim(); + if (t.toUpperCase() === "A") return "A"; + if (t.toUpperCase() === "B") return "B"; if (/^[1-9]\d*$/.test(t)) return String(parseInt(t, 10)); - return ''; + return ""; } private collectUsedComponentSlots(excludeIdx?: number): Set { const container = document.getElementById(ID_NODE_COMPONENTS_CONTAINER); const used = new Set(); if (!container) return used; - const entries = Array.from(container.querySelectorAll(`.${CLASS_COMPONENT_ENTRY}`)) as HTMLElement[]; - entries.forEach(entry => { + const entries = Array.from( + container.querySelectorAll(`.${CLASS_COMPONENT_ENTRY}`) + ) as HTMLElement[]; + entries.forEach((entry) => { const idx = this.extractIndex(entry.id, /component-entry-(\d+)/); if (excludeIdx != null && idx === excludeIdx) return; const input = entry.querySelector('[data-role="component-slot"]') as HTMLInputElement | null; - const norm = this.normalizeComponentSlot(input?.value || ''); + const norm = this.normalizeComponentSlot(input?.value || ""); if (norm) used.add(norm); }); return used; } private sequenceValueByIndex(i: number): string { - if (i <= 0) return 'A'; - if (i === 1) return 'B'; + if (i <= 0) return "A"; + if (i === 1) return "B"; return String(i - 1); } private sequenceIndexOf(val: string): number { const v = this.normalizeComponentSlot(val); - if (v === 'A') return 0; - if (v === 'B') return 1; + if (v === "A") return 0; + if (v === "B") return 1; if (/^[1-9]\d*$/.test(v)) return parseInt(v, 10) + 1; // 1 -> 2, 2 -> 3 return 0; } - private findNextAvailableSlot(slotType: 'cpm' | 'card'): string { + private findNextAvailableSlot(slotType: "cpm" | "card"): string { const used = this.collectUsedComponentSlots(); - if (slotType === 'cpm') { - if (!used.has('A')) return 'A'; - if (!used.has('B')) return 'B'; - return ''; + if (slotType === "cpm") { + if (!used.has("A")) return "A"; + if (!used.has("B")) return "B"; + return ""; } const nums: number[] = []; - used.forEach(v => { + used.forEach((v) => { if (/^[1-9]\d*$/.test(v)) nums.push(parseInt(v, 10)); }); - if (nums.length === 0) return '1'; + if (nums.length === 0) return "1"; const next = Math.max(...nums) + 1; return String(next); } @@ -959,27 +1020,28 @@ export class ManagerNodeEditor { const addCpmBtn = document.getElementById(ID_ADD_CPM_BUTTON) as HTMLButtonElement | null; const addCardBtn = document.getElementById(ID_ADD_CARD_BUTTON) as HTMLButtonElement | null; const used = this.collectUsedComponentSlots(); - const cpmSlots: Array<'A' | 'B'> = ['A', 'B']; - const cpmCount = cpmSlots.filter(slot => used.has(slot)).length; + const cpmSlots: Array<"A" | "B"> = ["A", "B"]; + const cpmCount = cpmSlots.filter((slot) => used.has(slot)).length; if (addCpmBtn) { const maxCpms = cpmSlots.length; addCpmBtn.disabled = cpmCount >= maxCpms; addCpmBtn.title = addCpmBtn.disabled - ? 'CPM slots A and B are already defined' - : 'Add a CPM slot (A or B)'; + ? "CPM slots A and B are already defined" + : "Add a CPM slot (A or B)"; } if (addCardBtn) { addCardBtn.disabled = false; - addCardBtn.title = 'Add a line card slot'; + addCardBtn.title = "Add a line card slot"; } - } private addSfmEntry(prefill?: string): number { - const container = document.getElementById(ID_NODE_COMPONENTS_SFM_CONTAINER) as HTMLElement | null; - const tpl = document.getElementById('tpl-sfm-entry') as HTMLTemplateElement | null; + const container = document.getElementById( + ID_NODE_COMPONENTS_SFM_CONTAINER + ) as HTMLElement | null; + const tpl = document.getElementById("tpl-sfm-entry") as HTMLTemplateElement | null; if (!container || !tpl) return -1; let entry = container.querySelector(`.${CLASS_COMPONENT_SFM_ENTRY}`) as HTMLElement | null; @@ -1000,7 +1062,7 @@ export class ManagerNodeEditor { } const hidden = entry?.querySelector(SELECTOR_SFM_VALUE) as HTMLInputElement | null; - if (hidden) hidden.value = prefill ?? ''; + if (hidden) hidden.value = prefill ?? ""; this.refreshSfmDropdown(); this.updateComponentAddButtonStates(); return 1; @@ -1033,24 +1095,28 @@ export class ManagerNodeEditor { if (!container) return; const entry = container.querySelector(`.${CLASS_COMPONENT_SFM_ENTRY}`) as HTMLElement | null; if (!entry) return; - const hidden = document.getElementById(`${SFM_ENTRY_ID_PREFIX}1-value`) as HTMLInputElement | null; - const filter = document.getElementById(`${SFM_ENTRY_ID_PREFIX}1-dropdown-filter-input`) as HTMLInputElement | null; + const hidden = document.getElementById( + `${SFM_ENTRY_ID_PREFIX}1-value` + ) as HTMLInputElement | null; + const filter = document.getElementById( + `${SFM_ENTRY_ID_PREFIX}1-dropdown-filter-input` + ) as HTMLInputElement | null; if (hidden && filter) hidden.value = filter.value; } private getSfmValue(): string { const container = document.getElementById(ID_NODE_COMPONENTS_SFM_CONTAINER); - if (!container) return ''; + if (!container) return ""; const hidden = container.querySelector(SELECTOR_SFM_VALUE) as HTMLInputElement | null; - return hidden?.value?.trim() ?? ''; + return hidden?.value?.trim() ?? ""; } private applySfmToComponents(components: any[], sfmVal: string): void { - const normalized = (sfmVal || '').trim(); - components.forEach(comp => { - if (!comp || typeof comp !== 'object') return; + const normalized = (sfmVal || "").trim(); + components.forEach((comp) => { + if (!comp || typeof comp !== "object") return; if (!normalized) { - if ('sfm' in comp) delete comp.sfm; + if ("sfm" in comp) delete comp.sfm; return; } comp.sfm = normalized; @@ -1061,7 +1127,9 @@ export class ManagerNodeEditor { const entry = document.getElementById(`component-entry-${idx}`); if (!entry) return; const slotVal = this.getInputValue(`component-${idx}-slot`).trim(); - const targetContainerId = this.isCpmSlot(slotVal) ? ID_NODE_COMPONENTS_CPM_CONTAINER : ID_NODE_COMPONENTS_CARD_CONTAINER; + const targetContainerId = this.isCpmSlot(slotVal) + ? ID_NODE_COMPONENTS_CPM_CONTAINER + : ID_NODE_COMPONENTS_CARD_CONTAINER; const targetContainer = document.getElementById(targetContainerId); if (!targetContainer || entry.parentElement === targetContainer) return; targetContainer.appendChild(entry); @@ -1069,13 +1137,19 @@ export class ManagerNodeEditor { private initializeComponentEntry(entry: HTMLElement, idx: number, prefill?: any): void { const slotInput = entry.querySelector('[data-role="component-slot"]') as HTMLInputElement; - const typeHidden = entry.querySelector('[data-role="component-type-value"]') as HTMLInputElement; - const typeDropdown = entry.querySelector('[data-role="component-type-dropdown"]') as HTMLElement; + const typeHidden = entry.querySelector( + '[data-role="component-type-value"]' + ) as HTMLInputElement; + const typeDropdown = entry.querySelector( + '[data-role="component-type-dropdown"]' + ) as HTMLElement; const xiomList = entry.querySelector('[data-role="xiom-list"]') as HTMLElement; const addXiomBtn = entry.querySelector('[data-action="add-xiom"]') as HTMLButtonElement; const mdaList = entry.querySelector('[data-role="mda-list"]') as HTMLElement; const addMdaBtn = entry.querySelector('[data-action="add-mda"]') as HTMLButtonElement; - const removeComponentBtn = entry.querySelector('[data-action="remove-component"]') as HTMLButtonElement; + const removeComponentBtn = entry.querySelector( + '[data-action="remove-component"]' + ) as HTMLButtonElement; const header = entry.querySelector('[data-action="toggle-component"]') as HTMLElement | null; const caret = entry.querySelector(SELECTOR_COMPONENT_CARET) as HTMLElement | null; const body = entry.querySelector(SELECTOR_COMPONENT_BODY) as HTMLElement | null; @@ -1086,24 +1160,24 @@ export class ManagerNodeEditor { xiomList.id = `component-${idx}-xiom-container`; mdaList.id = `component-${idx}-mda-container`; - slotInput.value = String(prefill?.slot ?? ''); - typeHidden.value = String(prefill?.type ?? ''); + slotInput.value = String(prefill?.slot ?? ""); + typeHidden.value = String(prefill?.type ?? ""); this.wireComponentActions(idx, addMdaBtn, addXiomBtn, removeComponentBtn); // Prevent header toggle when interacting with the slot input (works before append) if (slotInput) { const stop = (e: Event) => e.stopPropagation(); // Stop both capture and bubble to be safe across listeners - slotInput.addEventListener('click', stop, true); - slotInput.addEventListener('mousedown', stop, true); - slotInput.addEventListener('pointerdown', stop, true); - slotInput.addEventListener('focus', stop, true); - slotInput.addEventListener('click', stop); - slotInput.addEventListener('mousedown', stop); - slotInput.addEventListener('pointerdown', stop); - slotInput.addEventListener('focus', stop); - slotInput.addEventListener('keydown', stop); - slotInput.addEventListener('keyup', stop); + slotInput.addEventListener("click", stop, true); + slotInput.addEventListener("mousedown", stop, true); + slotInput.addEventListener("pointerdown", stop, true); + slotInput.addEventListener("focus", stop, true); + slotInput.addEventListener("click", stop); + slotInput.addEventListener("mousedown", stop); + slotInput.addEventListener("pointerdown", stop); + slotInput.addEventListener("focus", stop); + slotInput.addEventListener("keydown", stop); + slotInput.addEventListener("keyup", stop); } this.wireComponentSlotIncDec(idx, entry); this.attachAccordion(header, body, caret); @@ -1114,14 +1188,18 @@ export class ManagerNodeEditor { private wireComponentSlotIncDec(idx: number, entry: HTMLElement): void { const slotInput = document.getElementById(`component-${idx}-slot`) as HTMLInputElement | null; - const decBtn = entry.querySelector('[data-action="component-slot-dec"]') as HTMLButtonElement | null; - const incBtn = entry.querySelector('[data-action="component-slot-inc"]') as HTMLButtonElement | null; + const decBtn = entry.querySelector( + '[data-action="component-slot-dec"]' + ) as HTMLButtonElement | null; + const incBtn = entry.querySelector( + '[data-action="component-slot-inc"]' + ) as HTMLButtonElement | null; if (!slotInput) return; // Prevent header accordion from toggling when interacting with slot input - slotInput.addEventListener('click', (e) => e.stopPropagation()); - slotInput.addEventListener('mousedown', (e) => e.stopPropagation()); - slotInput.addEventListener('pointerdown', (e) => e.stopPropagation()); - decBtn?.addEventListener('click', (e) => { + slotInput.addEventListener("click", (e) => e.stopPropagation()); + slotInput.addEventListener("mousedown", (e) => e.stopPropagation()); + slotInput.addEventListener("pointerdown", (e) => e.stopPropagation()); + decBtn?.addEventListener("click", (e) => { e.stopPropagation(); const next = this.stepComponentSlot(slotInput.value, -1, idx); slotInput.value = next; @@ -1132,7 +1210,7 @@ export class ManagerNodeEditor { this.relocateComponentEntryIfNeeded(idx); this.updateComponentAddButtonStates(); }); - incBtn?.addEventListener('click', (e) => { + incBtn?.addEventListener("click", (e) => { e.stopPropagation(); const next = this.stepComponentSlot(slotInput.value, 1, idx); slotInput.value = next; @@ -1146,11 +1224,11 @@ export class ManagerNodeEditor { // Enforce uniqueness and pattern on manual input let lastValid = this.normalizeComponentSlot(slotInput.value); - slotInput.addEventListener('input', () => { + slotInput.addEventListener("input", () => { const norm = this.normalizeComponentSlot(slotInput.value); if (!norm) { // allow empty while typing - lastValid = ''; + lastValid = ""; this.updateComponentAddButtonStates(); return; } @@ -1175,23 +1253,29 @@ export class ManagerNodeEditor { if (!container) return; const rawSlot = this.getInputValue(`component-${componentIdx}-slot`).trim(); const hasSlot = rawSlot.length > 0; - const slotPrefix = hasSlot ? `${rawSlot}/` : '--'; + const slotPrefix = hasSlot ? `${rawSlot}/` : "--"; container.querySelectorAll('[data-role="mda-card-slot"]').forEach((el) => { (el as HTMLElement).textContent = slotPrefix; }); // Also update labels on XIOM-scoped MDA entries to include component slot and xiom slot const xiomContainer = document.getElementById(`component-${componentIdx}-xiom-container`); if (xiomContainer) { - const xiomRows = Array.from(xiomContainer.querySelectorAll(`.${CLASS_COMPONENT_XIOM_ENTRY}`)) as HTMLElement[]; - xiomRows.forEach(row => { + const xiomRows = Array.from( + xiomContainer.querySelectorAll(`.${CLASS_COMPONENT_XIOM_ENTRY}`) + ) as HTMLElement[]; + xiomRows.forEach((row) => { const xiomId = this.extractIndex(row.id, /component-\d+-xiom-entry-(\d+)/); if (xiomId == null) return; // Update the XIOM entry's left label with the component slot - const xiomSlotLabel = row.querySelector('[data-role="xiom-card-slot"]') as HTMLElement | null; + const xiomSlotLabel = row.querySelector( + '[data-role="xiom-card-slot"]' + ) as HTMLElement | null; if (xiomSlotLabel) xiomSlotLabel.textContent = slotPrefix; - const xiomSlotRaw = this.getInputValue(`component-${componentIdx}-xiom-${xiomId}-slot`).trim(); - const xiomDisplay = xiomSlotRaw ? `x${xiomSlotRaw}` : ''; - let xiomPrefix = '--'; + const xiomSlotRaw = this.getInputValue( + `component-${componentIdx}-xiom-${xiomId}-slot` + ).trim(); + const xiomDisplay = xiomSlotRaw ? `x${xiomSlotRaw}` : ""; + let xiomPrefix = "--"; if (hasSlot) { xiomPrefix = `${rawSlot}/`; if (xiomDisplay) { @@ -1205,7 +1289,9 @@ export class ManagerNodeEditor { } } - private isCpmSlot(v: string): boolean { return /^[aAbB]$/.test((v || '').trim()); } + private isCpmSlot(v: string): boolean { + return /^[aAbB]$/.test((v || "").trim()); + } private updateXiomSectionVisibility(componentIdx: number): void { const slotVal = this.getInputValue(`component-${componentIdx}-slot`).trim(); @@ -1216,10 +1302,14 @@ export class ManagerNodeEditor { if (isCpm) { group.classList.add(CLASS_HIDDEN); // Remove any XIOM entries and reset counters - const rows = Array.from(container.querySelectorAll(`.${CLASS_COMPONENT_XIOM_ENTRY}`)) as HTMLElement[]; - rows.forEach(r => r.remove()); + const rows = Array.from( + container.querySelectorAll(`.${CLASS_COMPONENT_XIOM_ENTRY}`) + ) as HTMLElement[]; + rows.forEach((r) => r.remove()); this.componentXiomCounters.set(componentIdx, 0); - Array.from(this.xiomMdaCounters.keys()).forEach(k => { if (k.startsWith(`${componentIdx}:`)) this.xiomMdaCounters.delete(k); }); + Array.from(this.xiomMdaCounters.keys()).forEach((k) => { + if (k.startsWith(`${componentIdx}:`)) this.xiomMdaCounters.delete(k); + }); } else { group.classList.remove(CLASS_HIDDEN); } @@ -1237,17 +1327,24 @@ export class ManagerNodeEditor { const isCpm = this.isCpmSlot(slotVal); if (isCpm) { group.classList.add(CLASS_HIDDEN); - group.setAttribute(ATTR_ARIA_HIDDEN, 'true'); - Array.from(list.querySelectorAll(`.${CLASS_COMPONENT_MDA_ENTRY}`)).forEach(el => el.remove()); + group.setAttribute(ATTR_ARIA_HIDDEN, "true"); + Array.from(list.querySelectorAll(`.${CLASS_COMPONENT_MDA_ENTRY}`)).forEach((el) => + el.remove() + ); this.componentMdaCounters.set(componentIdx, 0); } else { group.classList.remove(CLASS_HIDDEN); - group.setAttribute(ATTR_ARIA_HIDDEN, 'false'); + group.setAttribute(ATTR_ARIA_HIDDEN, "false"); } if (addBtn) addBtn.disabled = isCpm; } - private wireComponentActions(idx: number, addMdaBtn: HTMLButtonElement, addXiomBtn: HTMLButtonElement, removeComponentBtn: HTMLButtonElement): void { + private wireComponentActions( + idx: number, + addMdaBtn: HTMLButtonElement, + addXiomBtn: HTMLButtonElement, + removeComponentBtn: HTMLButtonElement + ): void { addMdaBtn.onclick = () => this.addMdaEntry(idx); addXiomBtn.onclick = () => this.addXiomEntry(idx); if (removeComponentBtn) { @@ -1258,13 +1355,20 @@ export class ManagerNodeEditor { } } - private attachAccordion(header: HTMLElement | null, body: HTMLElement | null, caret: HTMLElement | null): void { + private attachAccordion( + header: HTMLElement | null, + body: HTMLElement | null, + caret: HTMLElement | null + ): void { if (!header || !body) return; - header.addEventListener('click', (e) => { + header.addEventListener("click", (e) => { const target = e.target as HTMLElement; if (target.closest('[data-action="remove-component"]')) return; // Do not toggle when clicking interactive controls (e.g., slot input) - if (target.closest('input, textarea, select, [contenteditable], [data-role="component-slot"]')) return; + if ( + target.closest('input, textarea, select, [contenteditable], [data-role="component-slot"]') + ) + return; const isHidden = body.classList.contains(CLASS_HIDDEN); if (isHidden) { body.classList.remove(CLASS_HIDDEN); @@ -1298,13 +1402,15 @@ export class ManagerNodeEditor { const set = new Set(); const container = document.getElementById(ID_NODE_COMPONENTS_CONTAINER); if (!container) return set; - const entries = Array.from(container.querySelectorAll(`.${CLASS_COMPONENT_ENTRY}`)) as HTMLElement[]; - entries.forEach(entry => { + const entries = Array.from( + container.querySelectorAll(`.${CLASS_COMPONENT_ENTRY}`) + ) as HTMLElement[]; + entries.forEach((entry) => { const idx = this.extractIndex(entry.id, /component-entry-(\d+)/); if (idx == null) return; const body = entry.querySelector(SELECTOR_COMPONENT_BODY) as HTMLElement | null; const slotInput = document.getElementById(`component-${idx}-slot`) as HTMLInputElement | null; - const raw = slotInput?.value ?? ''; + const raw = slotInput?.value ?? ""; const key = this.normalizeComponentSlot(raw); if (key && body && !body.classList.contains(CLASS_HIDDEN)) set.add(key); }); @@ -1318,20 +1424,22 @@ export class ManagerNodeEditor { entry?.remove(); this.componentMdaCounters.delete(idx); this.componentXiomCounters.delete(idx); - Array.from(this.xiomMdaCounters.keys()).forEach(k => { if (k.startsWith(`${idx}:`)) this.xiomMdaCounters.delete(k); }); + Array.from(this.xiomMdaCounters.keys()).forEach((k) => { + if (k.startsWith(`${idx}:`)) this.xiomMdaCounters.delete(k); + }); this.updateComponentAddButtonStates(); } private addMdaEntry(componentIdx: number, prefill?: any): void { const list = document.getElementById(`component-${componentIdx}-mda-container`); - const tpl = document.getElementById('tpl-mda-entry') as HTMLTemplateElement | null; + const tpl = document.getElementById("tpl-mda-entry") as HTMLTemplateElement | null; if (!list || !tpl) return; const next = (this.componentMdaCounters.get(componentIdx) || 0) + 1; this.componentMdaCounters.set(componentIdx, next); const mdaId = next; const frag = tpl.content.cloneNode(true) as DocumentFragment; - const row = frag.querySelector('.component-mda-entry') as HTMLElement; + const row = frag.querySelector(".component-mda-entry") as HTMLElement; row.id = `component-${componentIdx}-mda-entry-${mdaId}`; const slot = row.querySelector('[data-role="mda-slot"]') as HTMLInputElement; const hiddenType = row.querySelector('[data-role="mda-type-value"]') as HTMLInputElement; @@ -1343,7 +1451,7 @@ export class ManagerNodeEditor { // Autofill MDA slot index starting at 1; respect provided prefill slot.value = prefill?.slot != null ? String(prefill.slot) : String(mdaId); hiddenType.id = `component-${componentIdx}-mda-${mdaId}-type`; - hiddenType.value = prefill?.type != null ? String(prefill.type) : ''; + hiddenType.value = prefill?.type != null ? String(prefill.type) : ""; typeDropdown.id = `component-${componentIdx}-mda-${mdaId}-type-dropdown`; delBtn.onclick = () => this.removeMdaEntry(componentIdx, mdaId); @@ -1363,9 +1471,9 @@ export class ManagerNodeEditor { private enforceComponentSlotPattern(inputId: string): void { const input = document.getElementById(inputId) as HTMLInputElement | null; if (!input) return; - let lastValid = input.value || ''; + let lastValid = input.value || ""; const re = /^([aAbB]|[1-9]\d*)?$/; // allow A/B or positive integers; empty while typing - input.addEventListener('input', () => { + input.addEventListener("input", () => { const v = input.value; if (re.test(v)) { lastValid = v; @@ -1389,8 +1497,10 @@ export class ManagerNodeEditor { true ); // Re-init on slot change - const slotInput = document.getElementById(`component-${componentIdx}-slot`) as HTMLInputElement | null; - slotInput?.addEventListener('input', () => this.initComponentTypeDropdown(componentIdx)); + const slotInput = document.getElementById( + `component-${componentIdx}-slot` + ) as HTMLInputElement | null; + slotInput?.addEventListener("input", () => this.initComponentTypeDropdown(componentIdx)); } private initMdaTypeDropdown(componentIdx: number, mdaId: number): void { @@ -1399,7 +1509,8 @@ export class ManagerNodeEditor { `component-${componentIdx}-mda-${mdaId}-type-dropdown`, this.srosMdaTypes, initial, - (selected: string) => this.setInputValue(`component-${componentIdx}-mda-${mdaId}-type`, selected), + (selected: string) => + this.setInputValue(`component-${componentIdx}-mda-${mdaId}-type`, selected), PH_SEARCH_TYPE, true ); @@ -1407,7 +1518,7 @@ export class ManagerNodeEditor { private addXiomEntry(componentIdx: number, prefill?: any): void { const list = document.getElementById(`component-${componentIdx}-xiom-container`); - const tpl = document.getElementById('tpl-xiom-entry') as HTMLTemplateElement | null; + const tpl = document.getElementById("tpl-xiom-entry") as HTMLTemplateElement | null; if (!list || !tpl) return; if (this.shouldBlockAddXiom(componentIdx)) return; const next = (this.componentXiomCounters.get(componentIdx) || 0) + 1; @@ -1429,7 +1540,7 @@ export class ManagerNodeEditor { // Decide slot value (1 or 2) slotHidden.value = String(this.computeInitialXiomSlot(componentIdx, prefill)); hiddenType.id = `component-${componentIdx}-xiom-${xiomId}-type`; - hiddenType.value = prefill?.type != null ? String(prefill.type) : ''; + hiddenType.value = prefill?.type != null ? String(prefill.type) : ""; typeDropdown.id = `component-${componentIdx}-xiom-${xiomId}-type-dropdown`; xiomMdaList.id = `component-${componentIdx}-xiom-${xiomId}-mda-container`; delBtn.onclick = () => this.removeXiomEntry(componentIdx, xiomId); @@ -1454,15 +1565,23 @@ export class ManagerNodeEditor { } private computeInitialXiomSlot(componentIdx: number, prefill?: any): number { - const desired = (prefill?.slot != null ? Number(prefill.slot) : this.findNextAvailableXiomSlot(componentIdx)) || 1; + const desired = + (prefill?.slot != null + ? Number(prefill.slot) + : this.findNextAvailableXiomSlot(componentIdx)) || 1; return desired === 2 ? 2 : 1; } private maybePrefillXiomMdas(componentIdx: number, xiomId: number, prefill?: any): void { - if (Array.isArray(prefill?.mda)) prefill.mda.forEach((m: any) => this.addXiomMdaEntry(componentIdx, xiomId, m)); + if (Array.isArray(prefill?.mda)) + prefill.mda.forEach((m: any) => this.addXiomMdaEntry(componentIdx, xiomId, m)); } - private setXiomSlotDropdownId(componentIdx: number, xiomId: number, el: HTMLElement | null): void { + private setXiomSlotDropdownId( + componentIdx: number, + xiomId: number, + el: HTMLElement | null + ): void { if (el) el.id = `component-${componentIdx}-xiom-${xiomId}-slot-dropdown`; } @@ -1480,7 +1599,8 @@ export class ManagerNodeEditor { `component-${componentIdx}-xiom-${xiomId}-type-dropdown`, this.srosXiomTypes, initial, - (selected: string) => this.setInputValue(`component-${componentIdx}-xiom-${xiomId}-type`, selected), + (selected: string) => + this.setInputValue(`component-${componentIdx}-xiom-${xiomId}-type`, selected), PH_SEARCH_TYPE, true ); @@ -1488,7 +1608,7 @@ export class ManagerNodeEditor { private addXiomMdaEntry(componentIdx: number, xiomId: number, prefill?: any): void { const list = document.getElementById(`component-${componentIdx}-xiom-${xiomId}-mda-container`); - const tpl = document.getElementById('tpl-xiom-mda-entry') as HTMLTemplateElement | null; + const tpl = document.getElementById("tpl-xiom-mda-entry") as HTMLTemplateElement | null; if (!list || !tpl) return; const key = `${componentIdx}:${xiomId}`; const next = (this.xiomMdaCounters.get(key) || 0) + 1; @@ -1506,7 +1626,7 @@ export class ManagerNodeEditor { slot.id = `component-${componentIdx}-xiom-${xiomId}-mda-${mdaId}-slot`; slot.value = prefill?.slot != null ? String(prefill.slot) : String(mdaId); hiddenType.id = `component-${componentIdx}-xiom-${xiomId}-mda-${mdaId}-type`; - hiddenType.value = prefill?.type != null ? String(prefill.type) : ''; + hiddenType.value = prefill?.type != null ? String(prefill.type) : ""; typeDropdown.id = `component-${componentIdx}-xiom-${xiomId}-mda-${mdaId}-type-dropdown`; delBtn.onclick = () => this.removeXiomMdaEntry(componentIdx, xiomId, mdaId); @@ -1517,17 +1637,22 @@ export class ManagerNodeEditor { } private removeXiomMdaEntry(componentIdx: number, xiomId: number, mdaId: number): void { - const row = document.getElementById(`component-${componentIdx}-xiom-${xiomId}-mda-entry-${mdaId}`); + const row = document.getElementById( + `component-${componentIdx}-xiom-${xiomId}-mda-entry-${mdaId}` + ); row?.remove(); } private initXiomMdaTypeDropdown(componentIdx: number, xiomId: number, mdaId: number): void { - const initial = this.getInputValue(`component-${componentIdx}-xiom-${xiomId}-mda-${mdaId}-type`); + const initial = this.getInputValue( + `component-${componentIdx}-xiom-${xiomId}-mda-${mdaId}-type` + ); createFilterableDropdown( `component-${componentIdx}-xiom-${xiomId}-mda-${mdaId}-type-dropdown`, this.srosXiomMdaTypes, initial, - (selected: string) => this.setInputValue(`component-${componentIdx}-xiom-${xiomId}-mda-${mdaId}-type`, selected), + (selected: string) => + this.setInputValue(`component-${componentIdx}-xiom-${xiomId}-mda-${mdaId}-type`, selected), PH_SEARCH_TYPE, true ); @@ -1543,8 +1668,10 @@ export class ManagerNodeEditor { const used = new Set(); const container = document.getElementById(`component-${componentIdx}-xiom-container`); if (!container) return used; - const rows = Array.from(container.querySelectorAll(`.${CLASS_COMPONENT_XIOM_ENTRY}`)) as HTMLElement[]; - rows.forEach(row => { + const rows = Array.from( + container.querySelectorAll(`.${CLASS_COMPONENT_XIOM_ENTRY}`) + ) as HTMLElement[]; + rows.forEach((row) => { const xiomId = this.extractIndex(row.id, /component-\d+-xiom-entry-(\d+)/); if (excludeXiomId != null && xiomId === excludeXiomId) return; const v = this.getInputValue(`component-${componentIdx}-xiom-${xiomId}-slot`).trim(); @@ -1562,18 +1689,20 @@ export class ManagerNodeEditor { } private initXiomSlotDropdown(componentIdx: number, xiomId: number): void { - const hidden = document.getElementById(`component-${componentIdx}-xiom-${xiomId}-slot`) as HTMLInputElement | null; + const hidden = document.getElementById( + `component-${componentIdx}-xiom-${xiomId}-slot` + ) as HTMLInputElement | null; if (!hidden) return; - const initialDigit = hidden.value === '2' ? '2' : '1'; - const initialLabel = initialDigit === '2' ? 'x2' : 'x1'; + const initialDigit = hidden.value === "2" ? "2" : "1"; + const initialLabel = initialDigit === "2" ? "x2" : "x1"; const containerId = `component-${componentIdx}-xiom-${xiomId}-slot-dropdown`; - const options = ['x1', 'x2']; + const options = ["x1", "x2"]; createFilterableDropdown( containerId, options, initialLabel, (selected: string) => { - const chosen = selected === 'x2' ? 2 : 1; + const chosen = selected === "x2" ? 2 : 1; // Enforce uniqueness across XIOMs for this component const used = this.getUsedXiomSlots(componentIdx, xiomId); if (used.has(chosen)) { @@ -1582,7 +1711,9 @@ export class ManagerNodeEditor { if (!used.has(other)) { hidden.value = String(other); // also set the visible field to 'x' + other - const filter = document.getElementById(`${containerId}-filter-input`) as HTMLInputElement | null; + const filter = document.getElementById( + `${containerId}-filter-input` + ) as HTMLInputElement | null; if (filter) filter.value = `x${other}`; } } else { @@ -1610,16 +1741,16 @@ export class ManagerNodeEditor { typeDropdownContainer: HTMLElement | null, typeInput: HTMLInputElement | null, typeOptions: string[], - selectedKind: string, + selectedKind: string ) { - typeFormGroup.style.display = 'block'; + typeFormGroup.style.display = "block"; if (!typeDropdownContainer || !typeInput) return; - typeDropdownContainer.style.display = 'block'; - typeInput.style.display = 'none'; + typeDropdownContainer.style.display = "block"; + typeInput.style.display = "none"; - const typeOptionsWithEmpty = ['', ...typeOptions]; - const currentType = typeInput.value || ''; - const typeToSelect = typeOptionsWithEmpty.includes(currentType) ? currentType : ''; + const typeOptionsWithEmpty = ["", ...typeOptions]; + const currentType = typeInput.value || ""; + const typeToSelect = typeOptionsWithEmpty.includes(currentType) ? currentType : ""; this.setTypeWarningVisibility(false); createFilterableDropdown( @@ -1628,14 +1759,16 @@ export class ManagerNodeEditor { typeToSelect, (selectedType: string) => { if (typeInput) typeInput.value = selectedType; - log.debug(`Type ${selectedType || '(empty)'} selected for kind ${selectedKind}`); + log.debug(`Type ${selectedType || "(empty)"} selected for kind ${selectedKind}`); this.onTypeFieldChanged(); }, PH_SEARCH_TYPE, true ); - const filterInput = document.getElementById(ID_NODE_TYPE_FILTER_INPUT) as HTMLInputElement | null; + const filterInput = document.getElementById( + ID_NODE_TYPE_FILTER_INPUT + ) as HTMLInputElement | null; if (filterInput) { const syncTypeValue = () => { if (typeInput) typeInput.value = filterInput.value; @@ -1650,20 +1783,24 @@ export class ManagerNodeEditor { selectedKind: string, typeFormGroup: HTMLElement, typeDropdownContainer: HTMLElement | null, - typeInput: HTMLInputElement | null, + typeInput: HTMLInputElement | null ) { const schemaReady = this.typeSchemaLoaded; const hasTypeSupport = schemaReady ? this.kindSupportsType(selectedKind) : false; const existingTypeValue = this.getExistingNodeTypeValue(); - const hasExistingTypeValue = typeof existingTypeValue === 'string' && existingTypeValue.trim().length > 0; + const hasExistingTypeValue = + typeof existingTypeValue === "string" && existingTypeValue.trim().length > 0; if (!hasExistingTypeValue) { - this.setInputValue(ID_NODE_TYPE, ''); - const filterInput = document.getElementById(ID_NODE_TYPE_FILTER_INPUT) as HTMLInputElement | null; - if (filterInput) filterInput.value = ''; + this.setInputValue(ID_NODE_TYPE, ""); + const filterInput = document.getElementById( + ID_NODE_TYPE_FILTER_INPUT + ) as HTMLInputElement | null; + if (filterInput) filterInput.value = ""; } const hasTypeValue = this.hasTypeFieldValue(); - const shouldShowFreeformType = !schemaReady || hasTypeSupport || hasTypeValue || hasExistingTypeValue; + const shouldShowFreeformType = + !schemaReady || hasTypeSupport || hasTypeValue || hasExistingTypeValue; if (shouldShowFreeformType) { this.displayFreeformTypeField(typeFormGroup, typeDropdownContainer, typeInput); @@ -1672,18 +1809,23 @@ export class ManagerNodeEditor { return; } - this.hideTypeField(typeFormGroup, typeDropdownContainer, typeInput, hasTypeValue || hasExistingTypeValue); + this.hideTypeField( + typeFormGroup, + typeDropdownContainer, + typeInput, + hasTypeValue || hasExistingTypeValue + ); } private displayFreeformTypeField( typeFormGroup: HTMLElement, typeDropdownContainer: HTMLElement | null, - typeInput: HTMLInputElement | null, + typeInput: HTMLInputElement | null ): void { - typeFormGroup.style.display = 'block'; + typeFormGroup.style.display = "block"; if (typeDropdownContainer && typeInput) { - typeDropdownContainer.style.display = 'none'; - typeInput.style.display = 'block'; + typeDropdownContainer.style.display = "none"; + typeInput.style.display = "block"; } if (typeInput) { typeInput.oninput = () => this.onTypeFieldChanged(); @@ -1694,15 +1836,15 @@ export class ManagerNodeEditor { typeFormGroup: HTMLElement, typeDropdownContainer: HTMLElement | null, typeInput: HTMLInputElement | null, - hasTypeValue: boolean, + hasTypeValue: boolean ): void { - typeFormGroup.style.display = 'none'; + typeFormGroup.style.display = "none"; this.setTypeWarningVisibility(false); if (typeInput) { - typeInput.style.display = 'none'; - if (!hasTypeValue) typeInput.value = ''; + typeInput.style.display = "none"; + if (!hasTypeValue) typeInput.value = ""; } - if (typeDropdownContainer) typeDropdownContainer.style.display = 'none'; + if (typeDropdownContainer) typeDropdownContainer.style.display = "none"; } private onTypeFieldChanged(): void { @@ -1711,12 +1853,12 @@ export class ManagerNodeEditor { private getCurrentKindValue(): string { const input = document.getElementById(ID_NODE_KIND_FILTER_INPUT) as HTMLInputElement | null; - return input?.value?.trim() ?? ''; + return input?.value?.trim() ?? ""; } private isIntegratedSrosType(kind: string | undefined, type: string | undefined): boolean { - if (!kind || kind !== 'nokia_srsim') return false; - const normalized = (type || '').trim().toLowerCase(); + if (!kind || kind !== "nokia_srsim") return false; + const normalized = (type || "").trim().toLowerCase(); if (!normalized) return false; return this.integratedSrosTypes.has(normalized); } @@ -1726,17 +1868,17 @@ export class ManagerNodeEditor { if (!el) return; if (visible) { el.classList.remove(CLASS_HIDDEN); - el.setAttribute(ATTR_ARIA_HIDDEN, 'false'); + el.setAttribute(ATTR_ARIA_HIDDEN, "false"); } else { el.classList.add(CLASS_HIDDEN); - el.setAttribute(ATTR_ARIA_HIDDEN, 'true'); + el.setAttribute(ATTR_ARIA_HIDDEN, "true"); } } private setTypeWarningVisibility(visible: boolean): void { const warning = document.getElementById(ID_NODE_TYPE_WARNING); if (!warning) return; - warning.style.display = visible ? 'block' : 'none'; + warning.style.display = visible ? "block" : "none"; if (visible) { warning.textContent = TYPE_UNSUPPORTED_WARNING_TEXT; } @@ -1786,18 +1928,20 @@ export class ManagerNodeEditor { private initializePanel(): void { this.panel = document.getElementById(ID_PANEL_NODE_EDITOR); if (!this.panel) { - log.error('Enhanced node editor panel not found in DOM'); + log.error("Enhanced node editor panel not found in DOM"); return; } // Populate the Kind dropdown from the JSON schema so all kinds are available - this.populateKindsFromSchema().catch(err => { - log.error(`Failed to populate kinds from schema: ${err instanceof Error ? err.message : String(err)}`); + this.populateKindsFromSchema().catch((err) => { + log.error( + `Failed to populate kinds from schema: ${err instanceof Error ? err.message : String(err)}` + ); }); // Mark panel interaction to prevent closing, but don't stop propagation // as that breaks tabs and other interactive elements - this.panel.addEventListener('mousedown', () => { + this.panel.addEventListener("mousedown", () => { // Mark that we clicked on the panel if ((window as any).viewportPanels) { (window as any).viewportPanels.setNodeClicked(true); @@ -1805,35 +1949,39 @@ export class ManagerNodeEditor { }); // Set up event delegation for delete buttons - this.panel.addEventListener('click', (e) => { - const target = e.target as HTMLElement; - - // Check if click is on a delete button or its child (the icon) - const deleteBtn = target.closest(`.${CLASS_DYNAMIC_DELETE_BTN}`); - if (!deleteBtn) return; - - // Get the container and entry ID from the button's data attributes - const containerName = deleteBtn.getAttribute(DATA_ATTR_CONTAINER); - const entryId = deleteBtn.getAttribute(DATA_ATTR_ENTRY_ID); - - if (!containerName || !entryId) { - // Let other click handlers (e.g., component removal) process the event - return; - } + this.panel.addEventListener( + "click", + (e) => { + const target = e.target as HTMLElement; + + // Check if click is on a delete button or its child (the icon) + const deleteBtn = target.closest(`.${CLASS_DYNAMIC_DELETE_BTN}`); + if (!deleteBtn) return; + + // Get the container and entry ID from the button's data attributes + const containerName = deleteBtn.getAttribute(DATA_ATTR_CONTAINER); + const entryId = deleteBtn.getAttribute(DATA_ATTR_ENTRY_ID); + + if (!containerName || !entryId) { + // Let other click handlers (e.g., component removal) process the event + return; + } - e.preventDefault(); - e.stopPropagation(); + e.preventDefault(); + e.stopPropagation(); - log.debug(`Delete button clicked via delegation: ${containerName}-${entryId}`); + log.debug(`Delete button clicked via delegation: ${containerName}-${entryId}`); - // Mark panel as clicked to prevent closing - if ((window as any).viewportPanels) { - (window as any).viewportPanels.setNodeClicked(true); - } + // Mark panel as clicked to prevent closing + if ((window as any).viewportPanels) { + (window as any).viewportPanels.setNodeClicked(true); + } - // Remove the entry - this.removeEntry(containerName, parseInt(entryId)); - }, true); // Use capture phase to ensure we get the event first + // Remove the entry + this.removeEntry(containerName, parseInt(entryId)); + }, + true + ); // Use capture phase to ensure we get the event first // Initialize tab switching this.setupTabSwitching(); @@ -1853,7 +2001,7 @@ export class ManagerNodeEditor { // Setup listeners to clear inherited flags when fields are edited this.setupInheritanceChangeListeners(); - log.debug('Enhanced node editor panel initialized'); + log.debug("Enhanced node editor panel initialized"); } /** @@ -1868,23 +2016,26 @@ export class ManagerNodeEditor { const scrollByAmount = (dir: -1 | 1) => { const delta = Math.max(120, Math.floor(viewport.clientWidth * 0.6)); - viewport.scrollBy({ left: dir * delta, behavior: 'smooth' }); + viewport.scrollBy({ left: dir * delta, behavior: "smooth" }); }; - leftBtn.addEventListener('click', (e) => { + leftBtn.addEventListener("click", (e) => { e.preventDefault(); scrollByAmount(-1); }); - rightBtn.addEventListener('click', (e) => { + rightBtn.addEventListener("click", (e) => { e.preventDefault(); scrollByAmount(1); }); - viewport.addEventListener('scroll', () => this.updateTabScrollButtons(), { passive: true }); - window.addEventListener('resize', () => this.updateTabScrollButtons()); + viewport.addEventListener("scroll", () => this.updateTabScrollButtons(), { passive: true }); + window.addEventListener("resize", () => this.updateTabScrollButtons()); // Initial state - setTimeout(() => { this.ensureActiveTabVisible(); this.updateTabScrollButtons(); }, 0); + setTimeout(() => { + this.ensureActiveTabVisible(); + this.updateTabScrollButtons(); + }, 0); } /** Ensure the active tab button is visible inside the viewport */ @@ -1892,16 +2043,18 @@ export class ManagerNodeEditor { const viewport = document.getElementById(ID_TAB_VIEWPORT) as HTMLElement | null; const strip = document.getElementById(ID_TAB_STRIP) as HTMLElement | null; if (!viewport || !strip) return; - const active = strip.querySelector(`.${CLASS_PANEL_TAB_BUTTON}.${CLASS_TAB_ACTIVE}`) as HTMLElement | null; + const active = strip.querySelector( + `.${CLASS_PANEL_TAB_BUTTON}.${CLASS_TAB_ACTIVE}` + ) as HTMLElement | null; if (!active) return; const vpLeft = viewport.scrollLeft; const vpRight = vpLeft + viewport.clientWidth; const elLeft = active.offsetLeft; const elRight = elLeft + active.offsetWidth; if (elLeft < vpLeft) { - viewport.scrollTo({ left: Math.max(0, elLeft - 16), behavior: 'smooth' }); + viewport.scrollTo({ left: Math.max(0, elLeft - 16), behavior: "smooth" }); } else if (elRight > vpRight) { - viewport.scrollTo({ left: elRight - viewport.clientWidth + 16, behavior: 'smooth' }); + viewport.scrollTo({ left: elRight - viewport.clientWidth + 16, behavior: "smooth" }); } } @@ -1915,37 +2068,25 @@ export class ManagerNodeEditor { const current = viewport.scrollLeft; const canLeft = current > 1; const canRight = current < maxScroll - 1; - leftBtn.classList.toggle('hidden', !canLeft); - rightBtn.classList.toggle('hidden', !canRight); + leftBtn.classList.toggle("hidden", !canLeft); + rightBtn.classList.toggle("hidden", !canRight); } private initializeStaticDropdowns(): void { // Restart Policy const rpOptions = [...OPTIONS_RP]; - createFilterableDropdown( - ID_NODE_RP_DROPDOWN, - rpOptions, - LABEL_DEFAULT, - () => {}, - PH_SEARCH_RP - ); + createFilterableDropdown(ID_NODE_RP_DROPDOWN, rpOptions, LABEL_DEFAULT, () => {}, PH_SEARCH_RP); // Network Mode const nmOptions = [...OPTIONS_NM]; - createFilterableDropdown( - ID_NODE_NM_DROPDOWN, - nmOptions, - LABEL_DEFAULT, - () => {}, - PH_SEARCH_NM - ); + createFilterableDropdown(ID_NODE_NM_DROPDOWN, nmOptions, LABEL_DEFAULT, () => {}, PH_SEARCH_NM); // Cert key size - const keySizeOptions = ['2048', '4096']; + const keySizeOptions = ["2048", "4096"]; createFilterableDropdown( ID_NODE_CERT_KEYSIZE_DROPDOWN, keySizeOptions, - '2048', + "2048", () => {}, PH_SEARCH_KEY_SIZE ); @@ -1974,7 +2115,7 @@ export class ManagerNodeEditor { private getSchemaUrl(): string | undefined { const url = (window as any).schemaUrl as string | undefined; if (!url) { - log.warn('Schema URL is undefined; keeping existing Kind options'); + log.warn("Schema URL is undefined; keeping existing Kind options"); } return url; } @@ -1986,12 +2127,12 @@ export class ManagerNodeEditor { } private getSortedKinds(schema: any): string[] { - const kinds: string[] = schema?.definitions?.['node-config']?.properties?.kind?.enum || []; + const kinds: string[] = schema?.definitions?.["node-config"]?.properties?.kind?.enum || []; const nokiaKinds = kinds - .filter(k => k.startsWith('nokia_')) + .filter((k) => k.startsWith("nokia_")) .sort((a, b) => a.localeCompare(b)); const otherKinds = kinds - .filter(k => !k.startsWith('nokia_')) + .filter((k) => !k.startsWith("nokia_")) .sort((a, b) => a.localeCompare(b)); return [...nokiaKinds, ...otherKinds]; } @@ -2004,7 +2145,7 @@ export class ManagerNodeEditor { if (def && this.schemaKinds.includes(def)) { return def; } - return this.schemaKinds[0] || ''; + return this.schemaKinds[0] || ""; } /** @@ -2021,7 +2162,7 @@ export class ManagerNodeEditor { const kinds = this.getSortedKinds(json); if (kinds.length === 0) { - log.warn('No kind enum found in schema; keeping existing Kind options'); + log.warn("No kind enum found in schema; keeping existing Kind options"); return; } this.schemaKinds = kinds; @@ -2029,7 +2170,7 @@ export class ManagerNodeEditor { const desired = (this.currentNode?.data()?.extraData?.kind as string) || ((window as any).defaultKind as string) || - ''; + ""; const initial = this.determineInitialKind(desired); createFilterableDropdown( ID_NODE_KIND_DROPDOWN, @@ -2048,12 +2189,12 @@ export class ManagerNodeEditor { private extractComponentEnumsFromSchema(schema: any): void { const defs = schema?.definitions || {}; - this.srosSfmTypes = defs['sros-sfm-types']?.enum || []; - this.srosXiomTypes = defs['sros-xiom-types']?.enum || []; - this.srosCpmTypes = defs['sros-cpm-types']?.enum || []; - this.srosCardTypes = defs['sros-card-types']?.enum || []; - this.srosXiomMdaTypes = defs['sros-xiom-mda-types']?.enum || []; - this.srosMdaTypes = defs['sros-mda-types']?.enum || []; + this.srosSfmTypes = defs["sros-sfm-types"]?.enum || []; + this.srosXiomTypes = defs["sros-xiom-types"]?.enum || []; + this.srosCpmTypes = defs["sros-cpm-types"]?.enum || []; + this.srosCardTypes = defs["sros-card-types"]?.enum || []; + this.srosXiomMdaTypes = defs["sros-xiom-mda-types"]?.enum || []; + this.srosMdaTypes = defs["sros-mda-types"]?.enum || []; // If Components are already rendered, ensure dropdowns get initialized now that enums are present this.refreshComponentsDropdowns(); @@ -2066,32 +2207,40 @@ export class ManagerNodeEditor { } const container = document.getElementById(ID_NODE_COMPONENTS_CONTAINER); if (!container) return; - const entries = Array.from(container.querySelectorAll(`.${CLASS_COMPONENT_ENTRY}`)) as HTMLElement[]; - entries.forEach(entry => { + const entries = Array.from( + container.querySelectorAll(`.${CLASS_COMPONENT_ENTRY}`) + ) as HTMLElement[]; + entries.forEach((entry) => { const idx = this.extractIndex(entry.id, /component-entry-(\d+)/); if (idx === null) return; this.initComponentTypeDropdown(idx); // Initialize XIOM dropdowns for existing XIOM entries const xiomContainer = document.getElementById(`component-${idx}-xiom-container`); if (xiomContainer) { - const xiomRows = Array.from(xiomContainer.querySelectorAll(`.${CLASS_COMPONENT_XIOM_ENTRY}`)) as HTMLElement[]; - xiomRows.forEach(row => { + const xiomRows = Array.from( + xiomContainer.querySelectorAll(`.${CLASS_COMPONENT_XIOM_ENTRY}`) + ) as HTMLElement[]; + xiomRows.forEach((row) => { const xiomId = this.extractIndex(row.id, /component-\d+-xiom-entry-(\d+)/); if (xiomId !== null) { this.initXiomTypeDropdown(idx, xiomId); - const xmdaRows = Array.from(row.querySelectorAll(`.${CLASS_COMPONENT_XIOM_MDA_ENTRY}`)) as HTMLElement[]; - xmdaRows.forEach(xmda => { + const xmdaRows = Array.from( + row.querySelectorAll(`.${CLASS_COMPONENT_XIOM_MDA_ENTRY}`) + ) as HTMLElement[]; + xmdaRows.forEach((xmda) => { const mdaId = this.extractIndex(xmda.id, /component-\d+-xiom-\d+-mda-entry-(\d+)/); if (mdaId !== null) this.initXiomMdaTypeDropdown(idx, xiomId, mdaId); + }); + } }); + this.refreshSfmDropdown(); } - }); - this.refreshSfmDropdown(); - } const mdaContainer = document.getElementById(`component-${idx}-mda-container`); if (!mdaContainer) return; - const rows = Array.from(mdaContainer.querySelectorAll(`.${CLASS_COMPONENT_MDA_ENTRY}`)) as HTMLElement[]; - rows.forEach(row => { + const rows = Array.from( + mdaContainer.querySelectorAll(`.${CLASS_COMPONENT_MDA_ENTRY}`) + ) as HTMLElement[]; + rows.forEach((row) => { const mdaId = this.extractIndex(row.id, /component-\d+-mda-entry-(\d+)/); if (mdaId !== null) this.initMdaTypeDropdown(idx, mdaId); }); @@ -2105,7 +2254,7 @@ export class ManagerNodeEditor { this.typeSchemaLoaded = false; this.nodeTypeOptions.clear(); this.kindsWithTypeSupport.clear(); - const allOf = schema?.definitions?.['node-config']?.allOf; + const allOf = schema?.definitions?.["node-config"]?.allOf; if (!allOf) { this.typeSchemaLoaded = true; this.refreshTypeFieldVisibility(); @@ -2129,7 +2278,9 @@ export class ManagerNodeEditor { } private refreshTypeFieldVisibility(): void { - const typeFormGroup = document.getElementById(ID_NODE_TYPE)?.closest(SELECTOR_FORM_GROUP) as HTMLElement | null; + const typeFormGroup = document + .getElementById(ID_NODE_TYPE) + ?.closest(SELECTOR_FORM_GROUP) as HTMLElement | null; if (!typeFormGroup) return; const typeDropdownContainer = document.getElementById(ID_NODE_TYPE_DROPDOWN); const typeInput = document.getElementById(ID_NODE_TYPE) as HTMLInputElement | null; @@ -2139,7 +2290,13 @@ export class ManagerNodeEditor { const typeOptions = this.getTypeOptionsForKind(currentKind); if (typeOptions.length > 0) { - this.showTypeDropdown(typeFormGroup, typeDropdownContainer, typeInput, typeOptions, currentKind); + this.showTypeDropdown( + typeFormGroup, + typeDropdownContainer, + typeInput, + typeOptions, + currentKind + ); } else { this.toggleTypeInputForKind(currentKind, typeFormGroup, typeDropdownContainer, typeInput); } @@ -2148,8 +2305,8 @@ export class ManagerNodeEditor { private getKindFromCondition(condition: any): string | null { const pattern = condition?.if?.properties?.kind?.pattern as string | undefined; if (!pattern) return null; - const start = pattern.indexOf('('); - const end = start >= 0 ? pattern.indexOf(')', start + 1) : -1; + const start = pattern.indexOf("("); + const end = start >= 0 ? pattern.indexOf(")", start + 1) : -1; if (start < 0 || end <= start) return null; return pattern.slice(start + 1, end); } @@ -2180,16 +2337,16 @@ export class ManagerNodeEditor { const tabButtons = this.panel?.querySelectorAll(`.${CLASS_PANEL_TAB_BUTTON}`); const tabContents = this.panel?.querySelectorAll(`.${CLASS_TAB_CONTENT}`); - tabButtons?.forEach(button => { - button.addEventListener('click', () => { - const targetTab = button.getAttribute('data-tab'); + tabButtons?.forEach((button) => { + button.addEventListener("click", () => { + const targetTab = button.getAttribute("data-tab"); // Update active tab button - tabButtons.forEach(btn => btn.classList.remove(CLASS_TAB_ACTIVE)); + tabButtons.forEach((btn) => btn.classList.remove(CLASS_TAB_ACTIVE)); button.classList.add(CLASS_TAB_ACTIVE); // Show corresponding tab content - tabContents?.forEach(content => { + tabContents?.forEach((content) => { if (content.id === `tab-${targetTab}`) { content.classList.remove(CLASS_HIDDEN); } else { @@ -2205,25 +2362,28 @@ export class ManagerNodeEditor { } /** - * Setup event handlers for save/cancel buttons + * Setup event handlers for save/apply/close buttons */ private setupEventHandlers(): void { - // Close button + // Close button (title bar X) const closeBtn = document.getElementById(ID_PANEL_EDITOR_CLOSE); - closeBtn?.addEventListener('click', () => this.close()); + closeBtn?.addEventListener("click", () => this.close()); - // Cancel button - const cancelBtn = document.getElementById(ID_PANEL_EDITOR_CANCEL); - cancelBtn?.addEventListener('click', () => this.close()); + // Apply button (save without closing) + const applyBtn = document.getElementById(ID_PANEL_EDITOR_APPLY); + applyBtn?.addEventListener("click", () => this.save()); - // Save button + // OK button (save and close) const saveBtn = document.getElementById(ID_PANEL_EDITOR_SAVE); - saveBtn?.addEventListener('click', () => this.save()); + saveBtn?.addEventListener("click", async () => { + await this.save(); + this.close(); + }); // Certificate checkbox toggle const certCheckbox = document.getElementById(ID_NODE_CERT_ISSUE) as HTMLInputElement; const certOptions = document.getElementById(ID_CERT_OPTIONS); - certCheckbox?.addEventListener('change', () => { + certCheckbox?.addEventListener("change", () => { if (certCheckbox.checked) { certOptions?.classList.remove(CLASS_HIDDEN); } else { @@ -2234,15 +2394,17 @@ export class ManagerNodeEditor { private setupIconEditorControls(): void { if (this.iconEditorInitialized) return; - const editButton = document.getElementById(ID_NODE_ICON_EDIT_BUTTON) as HTMLButtonElement | null; + const editButton = document.getElementById( + ID_NODE_ICON_EDIT_BUTTON + ) as HTMLButtonElement | null; const addButton = document.getElementById(ID_NODE_ICON_ADD_BUTTON) as HTMLButtonElement | null; const modal = document.getElementById(ID_ICON_EDITOR_MODAL); const backdrop = document.getElementById(ID_ICON_EDITOR_BACKDROP); if (!editButton || !modal || !backdrop) return; this.iconEditorInitialized = true; - editButton.addEventListener('click', () => this.openIconEditor()); - addButton?.addEventListener('click', () => { + editButton.addEventListener("click", () => this.openIconEditor()); + addButton?.addEventListener("click", () => { void this.handleIconUpload(); }); this.registerIconEditorDismissHandlers(); @@ -2252,43 +2414,48 @@ export class ManagerNodeEditor { private registerIconEditorDismissHandlers(): void { const cancelBtn = document.getElementById(ID_ICON_EDITOR_CANCEL) as HTMLButtonElement | null; - cancelBtn?.addEventListener('click', () => this.closeIconEditor()); + cancelBtn?.addEventListener("click", () => this.closeIconEditor()); const closeBtn = document.getElementById(ID_ICON_EDITOR_CLOSE) as HTMLButtonElement | null; - closeBtn?.addEventListener('click', () => this.closeIconEditor()); + closeBtn?.addEventListener("click", () => this.closeIconEditor()); const backdrop = document.getElementById(ID_ICON_EDITOR_BACKDROP) as HTMLDivElement | null; - backdrop?.addEventListener('click', () => this.closeIconEditor()); + backdrop?.addEventListener("click", () => this.closeIconEditor()); } private registerIconEditorActionHandlers(): void { const saveBtn = document.getElementById(ID_ICON_EDITOR_SAVE) as HTMLButtonElement | null; - saveBtn?.addEventListener('click', () => this.applyIconEditorSelection()); + saveBtn?.addEventListener("click", () => this.applyIconEditorSelection()); } private registerIconEditorInputHandlers(): void { const colorInput = document.getElementById(ID_ICON_EDITOR_COLOR) as HTMLInputElement | null; const hexInput = document.getElementById(ID_ICON_EDITOR_HEX) as HTMLInputElement | null; if (colorInput) { - colorInput.addEventListener('input', () => this.handleIconEditorColorInput(colorInput, hexInput)); + colorInput.addEventListener("input", () => + this.handleIconEditorColorInput(colorInput, hexInput) + ); } if (hexInput) { - hexInput.addEventListener('input', () => this.handleIconEditorHexInput(hexInput, colorInput)); + hexInput.addEventListener("input", () => this.handleIconEditorHexInput(hexInput, colorInput)); } const shapeSelect = document.getElementById(ID_ICON_EDITOR_SHAPE) as HTMLSelectElement | null; if (shapeSelect) { this.populateIconShapeOptions(shapeSelect); - shapeSelect.addEventListener('change', () => this.updateIconPreviewElement()); + shapeSelect.addEventListener("change", () => this.updateIconPreviewElement()); } const cornerInput = document.getElementById(ID_ICON_EDITOR_CORNER) as HTMLInputElement | null; if (cornerInput) { - cornerInput.addEventListener('input', () => this.handleIconEditorCornerInput(cornerInput)); + cornerInput.addEventListener("input", () => this.handleIconEditorCornerInput(cornerInput)); } } - private handleIconEditorColorInput(colorInput: HTMLInputElement, hexInput: HTMLInputElement | null): void { + private handleIconEditorColorInput( + colorInput: HTMLInputElement, + hexInput: HTMLInputElement | null + ): void { const normalized = this.normalizeIconColor(colorInput.value, DEFAULT_ICON_COLOR); if (hexInput && normalized) { hexInput.value = normalized; @@ -2301,7 +2468,7 @@ export class ManagerNodeEditor { previousSelection: string, availableIcons: string[] ): string { - const candidates = [preferredIcon, previousSelection, 'pe']; + const candidates = [preferredIcon, previousSelection, "pe"]; for (const candidate of candidates) { if (candidate && availableIcons.includes(candidate)) { return candidate; @@ -2310,10 +2477,13 @@ export class ManagerNodeEditor { if (availableIcons.length > 0) { return availableIcons[0]; } - return 'pe'; + return "pe"; } - private handleIconEditorHexInput(hexInput: HTMLInputElement, colorInput: HTMLInputElement | null): void { + private handleIconEditorHexInput( + hexInput: HTMLInputElement, + colorInput: HTMLInputElement | null + ): void { const normalized = this.normalizeIconColor(hexInput.value, null); if (normalized && colorInput) { colorInput.value = normalized; @@ -2343,9 +2513,9 @@ export class ManagerNodeEditor { } private populateIconShapeOptions(select: HTMLSelectElement): void { - select.innerHTML = ''; + select.innerHTML = ""; for (const role of this.getNodeIconOptions()) { - const option = document.createElement('option'); + const option = document.createElement("option"); option.value = role; option.textContent = role; select.appendChild(option); @@ -2359,14 +2529,14 @@ export class ManagerNodeEditor { const cornerInput = document.getElementById(ID_ICON_EDITOR_CORNER) as HTMLInputElement | null; const currentShape = this.getCurrentIconValue(); if (shapeSelect) { - if (!Array.from(shapeSelect.options).some(opt => opt.value === currentShape)) { + if (!Array.from(shapeSelect.options).some((opt) => opt.value === currentShape)) { this.populateIconShapeOptions(shapeSelect); } shapeSelect.value = currentShape; } const colorValue = this.currentIconColor ?? DEFAULT_ICON_COLOR; if (colorInput) colorInput.value = colorValue; - if (hexInput) hexInput.value = this.currentIconColor ?? ''; + if (hexInput) hexInput.value = this.currentIconColor ?? ""; if (cornerInput) { cornerInput.value = `${this.currentIconCornerRadius}`; this.updateCornerRadiusLabel(this.currentIconCornerRadius); @@ -2383,8 +2553,8 @@ export class ManagerNodeEditor { const modal = document.getElementById(ID_ICON_EDITOR_MODAL) as HTMLDivElement | null; const backdrop = document.getElementById(ID_ICON_EDITOR_BACKDROP) as HTMLDivElement | null; if (!modal || !backdrop) return; - modal.style.display = show ? 'block' : 'none'; - backdrop.style.display = show ? 'block' : 'none'; + modal.style.display = show ? "block" : "none"; + backdrop.style.display = show ? "block" : "none"; } private updateIconPreviewElement(): void { @@ -2392,7 +2562,11 @@ export class ManagerNodeEditor { if (!preview) return; const colorInput = document.getElementById(ID_ICON_EDITOR_COLOR) as HTMLInputElement | null; const shapeSelect = document.getElementById(ID_ICON_EDITOR_SHAPE) as HTMLSelectElement | null; - const color = this.normalizeIconColor(colorInput?.value || this.currentIconColor || DEFAULT_ICON_COLOR, DEFAULT_ICON_COLOR) ?? DEFAULT_ICON_COLOR; + const color = + this.normalizeIconColor( + colorInput?.value || this.currentIconColor || DEFAULT_ICON_COLOR, + DEFAULT_ICON_COLOR + ) ?? DEFAULT_ICON_COLOR; const shape = shapeSelect?.value || this.getCurrentIconValue(); const dataUri = getIconDataUriForRole(shape, color); if (dataUri) { @@ -2407,27 +2581,31 @@ export class ManagerNodeEditor { const colorInput = document.getElementById(ID_ICON_EDITOR_COLOR) as HTMLInputElement | null; const shapeSelect = document.getElementById(ID_ICON_EDITOR_SHAPE) as HTMLSelectElement | null; const cornerInput = document.getElementById(ID_ICON_EDITOR_CORNER) as HTMLInputElement | null; - const rawColor = colorInput?.value || ''; + const rawColor = colorInput?.value || ""; const normalized = this.normalizeIconColor(rawColor, null); const effectiveColor = normalized && normalized.toLowerCase() !== DEFAULT_ICON_COLOR ? normalized : null; this.setIconColor(effectiveColor); const hexInput = document.getElementById(ID_ICON_EDITOR_HEX) as HTMLInputElement | null; if (hexInput) { - hexInput.value = effectiveColor ?? ''; + hexInput.value = effectiveColor ?? ""; } const shape = shapeSelect?.value || this.getCurrentIconValue(); this.setIconShapeValue(shape); const cornerRadius = cornerInput ? Number(cornerInput.value) : DEFAULT_ICON_CORNER_RADIUS; - this.setIconCornerRadius(Number.isFinite(cornerRadius) ? cornerRadius : DEFAULT_ICON_CORNER_RADIUS); + this.setIconCornerRadius( + Number.isFinite(cornerRadius) ? cornerRadius : DEFAULT_ICON_CORNER_RADIUS + ); this.closeIconEditor(); } private setIconShapeValue(shape: string): void { - const input = document.getElementById(ID_PANEL_NODE_TOPOROLE_FILTER_INPUT) as HTMLInputElement | null; + const input = document.getElementById( + ID_PANEL_NODE_TOPOROLE_FILTER_INPUT + ) as HTMLInputElement | null; if (!input) return; input.value = shape; - input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event("input", { bubbles: true })); } /** @@ -2438,13 +2616,15 @@ export class ManagerNodeEditor { (window as any).addBindEntry = () => this.addDynamicEntry(CN_BINDS, PH_BIND); (window as any).addEnvEntry = () => this.addDynamicKeyValueEntry(CN_ENV, PH_ENV_KEY, PH_VALUE); (window as any).addEnvFileEntry = () => this.addDynamicEntry(CN_ENV_FILES, PH_ENV_FILE); - (window as any).addLabelEntry = () => this.addDynamicKeyValueEntry(CN_LABELS, PH_LABEL_KEY, PH_LABEL_VALUE); + (window as any).addLabelEntry = () => + this.addDynamicKeyValueEntry(CN_LABELS, PH_LABEL_KEY, PH_LABEL_VALUE); (window as any).addExecEntry = () => this.addDynamicEntry(CN_EXEC, PH_EXEC); (window as any).addPortEntry = () => this.addDynamicEntry(CN_PORTS, PH_PORT); (window as any).addDnsServerEntry = () => this.addDynamicEntry(CN_DNS_SERVERS, PH_DNS_SERVER); (window as any).addAliasEntry = () => this.addDynamicEntry(CN_ALIASES, PH_ALIAS); (window as any).addCapabilityEntry = () => this.addDynamicEntry(CN_CAP_ADD, PH_CAP); - (window as any).addSysctlEntry = () => this.addDynamicKeyValueEntry(CN_SYSCTLS, PH_SYSCTL_KEY, PH_VALUE); + (window as any).addSysctlEntry = () => + this.addDynamicKeyValueEntry(CN_SYSCTLS, PH_SYSCTL_KEY, PH_VALUE); (window as any).addDeviceEntry = () => this.addDynamicEntry(CN_DEVICES, PH_DEVICE); (window as any).addSanEntry = () => this.addDynamicEntry(CN_SANS, PH_SAN); this.registerComponentEntryHandlers(); @@ -2459,10 +2639,18 @@ export class ManagerNodeEditor { } private registerComponentEntryHandlers(): void { - (window as any).addComponentEntry = () => { this.addComponentEntry(undefined, { slotType: 'card' }); }; - (window as any).addCpmComponentEntry = () => { this.addComponentEntry(undefined, { slotType: 'cpm' }); }; - (window as any).addCardComponentEntry = () => { this.addComponentEntry(undefined, { slotType: 'card' }); }; - (window as any).addIntegratedMdaEntry = () => { this.addIntegratedMdaEntry(); }; + (window as any).addComponentEntry = () => { + this.addComponentEntry(undefined, { slotType: "card" }); + }; + (window as any).addCpmComponentEntry = () => { + this.addComponentEntry(undefined, { slotType: "cpm" }); + }; + (window as any).addCardComponentEntry = () => { + this.addComponentEntry(undefined, { slotType: "card" }); + }; + (window as any).addIntegratedMdaEntry = () => { + this.addIntegratedMdaEntry(); + }; } /** @@ -2490,18 +2678,18 @@ export class ManagerNodeEditor { const count = (this.dynamicEntryCounters.get(containerName) || 0) + 1; this.dynamicEntryCounters.set(containerName, count); - const entryDiv = document.createElement('div'); + const entryDiv = document.createElement("div"); entryDiv.className = CLASS_DYNAMIC_ENTRY; entryDiv.id = `${containerName}-entry-${count}`; - const input = document.createElement('input'); - input.type = 'text'; + const input = document.createElement("input"); + input.type = "text"; input.className = CLASS_INPUT_FIELD; input.placeholder = placeholder; input.setAttribute(DATA_ATTR_FIELD, containerName); - const button = document.createElement('button'); - button.type = 'button'; // Prevent form submission + const button = document.createElement("button"); + button.type = "button"; // Prevent form submission button.className = CLASS_DYNAMIC_DELETE_BTN; button.setAttribute(DATA_ATTR_CONTAINER, containerName); button.setAttribute(DATA_ATTR_ENTRY_ID, count.toString()); @@ -2515,31 +2703,35 @@ export class ManagerNodeEditor { /** * Add a dynamic key-value entry field for object-based properties */ - private addDynamicKeyValueEntry(containerName: string, keyPlaceholder: string, valuePlaceholder: string): void { + private addDynamicKeyValueEntry( + containerName: string, + keyPlaceholder: string, + valuePlaceholder: string + ): void { const container = document.getElementById(`node-${containerName}-container`); if (!container) return; const count = (this.dynamicEntryCounters.get(containerName) || 0) + 1; this.dynamicEntryCounters.set(containerName, count); - const entryDiv = document.createElement('div'); + const entryDiv = document.createElement("div"); entryDiv.className = CLASS_DYNAMIC_ENTRY; entryDiv.id = `${containerName}-entry-${count}`; - const keyInput = document.createElement('input'); - keyInput.type = 'text'; + const keyInput = document.createElement("input"); + keyInput.type = "text"; keyInput.className = CLASS_INPUT_FIELD; keyInput.placeholder = keyPlaceholder; keyInput.setAttribute(DATA_ATTR_FIELD, `${containerName}-key`); - const valueInput = document.createElement('input'); - valueInput.type = 'text'; + const valueInput = document.createElement("input"); + valueInput.type = "text"; valueInput.className = CLASS_INPUT_FIELD; valueInput.placeholder = valuePlaceholder; valueInput.setAttribute(DATA_ATTR_FIELD, `${containerName}-value`); - const button = document.createElement('button'); - button.type = 'button'; // Prevent form submission + const button = document.createElement("button"); + button.type = "button"; // Prevent form submission button.className = CLASS_DYNAMIC_DELETE_BTN; button.setAttribute(DATA_ATTR_CONTAINER, containerName); button.setAttribute(DATA_ATTR_ENTRY_ID, count.toString()); @@ -2558,12 +2750,12 @@ export class ManagerNodeEditor { try { // Create a timeout promise that rejects after 2 seconds const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Docker image refresh timeout')), 2000); + setTimeout(() => reject(new Error("Docker image refresh timeout")), 2000); }); // Race between the refresh and timeout const response: any = await Promise.race([ - this.messageSender.sendMessageToVscodeEndpointPost('refresh-docker-images', {}), + this.messageSender.sendMessageToVscodeEndpointPost("refresh-docker-images", {}), timeoutPromise ]); @@ -2583,7 +2775,7 @@ export class ManagerNodeEditor { public async open(node: cytoscape.NodeSingular): Promise { this.currentNode = node; if (!this.panel) { - log.error('Panel not initialized'); + log.error("Panel not initialized"); return; } @@ -2592,10 +2784,10 @@ export class ManagerNodeEditor { await this.refreshNodeExtraData(node); this.clearAllDynamicEntries(); - this.switchToTab('basic'); + this.switchToTab("basic"); this.loadNodeData(node); this.alignKindSelection(node); - this.panel.style.display = 'block'; + this.panel.style.display = "block"; // After the panel becomes visible, update tab scroll UI once layout is ready const afterVisible = () => { try { @@ -2606,7 +2798,7 @@ export class ManagerNodeEditor { log.debug(`Post-open tab scroll update skipped: ${msg}`); } }; - if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { window.requestAnimationFrame(() => afterVisible()); // Also schedule a secondary tick to be safe after dynamic content inflates setTimeout(afterVisible, 50); @@ -2625,20 +2817,25 @@ export class ManagerNodeEditor { } const sender = this.saveManager.getMessageSender(); - const nodeName = node.data('name') || node.id(); - const freshData = await sender.sendMessageToVscodeEndpointPost('topo-editor-get-node-config', { node: nodeName }); - if (freshData && typeof freshData === 'object') { - node.data('extraData', freshData); + const nodeName = node.data("name") || node.id(); + const freshData = await sender.sendMessageToVscodeEndpointPost( + "topo-editor-get-node-config", + { node: nodeName } + ); + if (freshData && typeof freshData === "object") { + node.data("extraData", freshData); } } catch (err) { - log.warn(`Failed to refresh node data from YAML: ${err instanceof Error ? err.message : String(err)}`); + log.warn( + `Failed to refresh node data from YAML: ${err instanceof Error ? err.message : String(err)}` + ); } } private alignKindSelection(node: cytoscape.NodeSingular): void { try { - const input = document.getElementById(ID_NODE_KIND_FILTER_INPUT) as HTMLInputElement | null; - const desired = (node.data()?.extraData?.kind as string) || (window as any).defaultKind || ''; + const input = document.getElementById(ID_NODE_KIND_FILTER_INPUT) as HTMLInputElement | null; + const desired = (node.data()?.extraData?.kind as string) || (window as any).defaultKind || ""; if (!input || !desired || !this.kindsLoaded || this.schemaKinds.length === 0) { return; } @@ -2653,14 +2850,24 @@ export class ManagerNodeEditor { */ private clearAllDynamicEntries(): void { const containers = [ - CN_BINDS, CN_ENV, CN_ENV_FILES, CN_LABELS, CN_EXEC, CN_PORTS, CN_DNS_SERVERS, - CN_ALIASES, CN_CAP_ADD, CN_SYSCTLS, CN_DEVICES, CN_SANS + CN_BINDS, + CN_ENV, + CN_ENV_FILES, + CN_LABELS, + CN_EXEC, + CN_PORTS, + CN_DNS_SERVERS, + CN_ALIASES, + CN_CAP_ADD, + CN_SYSCTLS, + CN_DEVICES, + CN_SANS ]; - containers.forEach(name => { + containers.forEach((name) => { const container = document.getElementById(`node-${name}-container`); if (container) { - container.innerHTML = ''; + container.innerHTML = ""; } }); @@ -2680,7 +2887,7 @@ export class ManagerNodeEditor { */ private computeActualInheritedProps(nodeProps: any, topology?: any): string[] { // Properties that should never be marked as inherited - const neverInherited = ['kind', 'name', 'group']; + const neverInherited = ["kind", "name", "group"]; // If we have the pre-calculated inherited list from the topology loader, use it // This list was calculated when the topology was loaded and knows exactly which @@ -2698,7 +2905,7 @@ export class ManagerNodeEditor { topology: { defaults: (window as any).topologyDefaults || {}, kinds: (window as any).topologyKinds || {}, - groups: (window as any).topologyGroups || {}, + groups: (window as any).topologyGroups || {} } }; } @@ -2712,7 +2919,7 @@ export class ManagerNodeEditor { const actualInherited: string[] = []; - Object.keys(nodeProps).forEach(prop => { + Object.keys(nodeProps).forEach((prop) => { // Skip properties that should never be inherited if (neverInherited.includes(prop)) { return; @@ -2761,7 +2968,11 @@ export class ManagerNodeEditor { } } - private loadBasicTab(node: cytoscape.NodeSingular, extraData: Record, actualInherited: string[]): void { + private loadBasicTab( + node: cytoscape.NodeSingular, + extraData: Record, + actualInherited: string[] + ): void { const nodeData = node.data(); this.setInputValue(ID_NODE_NAME, nodeData.name || node.id()); this.setupKindAndTypeFields(extraData, actualInherited); @@ -2781,17 +2992,17 @@ export class ManagerNodeEditor { private computeCustomIconSignature(): string { const customIcons = (window as any)?.customIcons; - if (!customIcons || typeof customIcons !== 'object') { - return ''; + if (!customIcons || typeof customIcons !== "object") { + return ""; } return Object.keys(customIcons) .sort() - .map(key => `${key}-${(customIcons[key] as string)?.length ?? 0}`) - .join('|'); + .map((key) => `${key}-${(customIcons[key] as string)?.length ?? 0}`) + .join("|"); } private setupKindAndTypeFields(extraData: Record, actualInherited: string[]): void { - const desiredKind = extraData.kind || ((window as any).defaultKind || 'nokia_srlinux'); + const desiredKind = extraData.kind || (window as any).defaultKind || "nokia_srlinux"; const kindInitial = this.schemaKinds.length > 0 && this.schemaKinds.includes(desiredKind) ? desiredKind @@ -2803,11 +3014,11 @@ export class ManagerNodeEditor { (selectedKind: string) => this.handleKindChange(selectedKind), PH_SEARCH_KIND ); - this.markFieldInheritance(ID_NODE_KIND_DROPDOWN, actualInherited.includes('kind')); + this.markFieldInheritance(ID_NODE_KIND_DROPDOWN, actualInherited.includes("kind")); - const typeValue = extraData.type || ''; + const typeValue = extraData.type || ""; this.setInputValue(ID_NODE_TYPE, typeValue); - this.markFieldInheritance(ID_NODE_TYPE, actualInherited.includes('type')); + this.markFieldInheritance(ID_NODE_TYPE, actualInherited.includes("type")); this.handleKindChange(kindInitial); if (typeValue) { const typeInput = document.getElementById(ID_NODE_TYPE) as HTMLInputElement; @@ -2819,10 +3030,10 @@ export class ManagerNodeEditor { private setupIconField(nodeData: Record): void { const nodeIcons = this.getNodeIconOptions(); - let iconInitial = 'pe'; - if (nodeData.topoViewerRole && typeof nodeData.topoViewerRole === 'string') { + let iconInitial = "pe"; + if (nodeData.topoViewerRole && typeof nodeData.topoViewerRole === "string") { iconInitial = nodeData.topoViewerRole; - } else if (nodeData.extraData?.icon && typeof nodeData.extraData.icon === 'string') { + } else if (nodeData.extraData?.icon && typeof nodeData.extraData.icon === "string") { iconInitial = nodeData.extraData.icon; } createFilterableDropdown( @@ -2830,21 +3041,23 @@ export class ManagerNodeEditor { nodeIcons, iconInitial, () => {}, - 'Search for icon...', + "Search for icon...", false, { - menuClassName: 'max-h-96', + menuClassName: "max-h-96", dropdownWidth: 320, - renderOption: this.renderIconOption, + renderOption: this.renderIconOption } ); this.initializeIconColorState(nodeData); } private initializeIconColorState(nodeData: Record): void { - const fromNode = typeof nodeData.iconColor === 'string' ? nodeData.iconColor : ''; + const fromNode = typeof nodeData.iconColor === "string" ? nodeData.iconColor : ""; const fromExtra = - typeof nodeData.extraData?.iconColor === 'string' ? (nodeData.extraData.iconColor as string) : ''; + typeof nodeData.extraData?.iconColor === "string" + ? (nodeData.extraData.iconColor as string) + : ""; const normalized = this.normalizeIconColor(fromNode || fromExtra, null); this.setIconColor(normalized); const radiusSource = this.resolveNumericIconValue( @@ -2858,12 +3071,12 @@ export class ManagerNodeEditor { this.currentIconColor = color; const hidden = document.getElementById(ID_NODE_ICON_COLOR) as HTMLInputElement | null; if (hidden) { - hidden.value = color ?? ''; + hidden.value = color ?? ""; } } private setIconCornerRadius(radius: number | null): void { - if (typeof radius === 'number' && Number.isFinite(radius)) { + if (typeof radius === "number" && Number.isFinite(radius)) { this.currentIconCornerRadius = Math.max(0, Math.min(40, radius)); return; } @@ -2871,16 +3084,19 @@ export class ManagerNodeEditor { } private resolveNumericIconValue(primary: unknown, fallback: unknown): number | null { - if (typeof primary === 'number' && Number.isFinite(primary)) { + if (typeof primary === "number" && Number.isFinite(primary)) { return primary; } - if (typeof fallback === 'number' && Number.isFinite(fallback)) { + if (typeof fallback === "number" && Number.isFinite(fallback)) { return fallback; } return null; } - private normalizeIconColor(color: string | undefined, fallback: string | null = DEFAULT_ICON_COLOR): string | null { + private normalizeIconColor( + color: string | undefined, + fallback: string | null = DEFAULT_ICON_COLOR + ): string | null { if (!color) { return fallback; } @@ -2888,7 +3104,7 @@ export class ManagerNodeEditor { if (!candidate) { return fallback; } - if (!candidate.startsWith('#')) { + if (!candidate.startsWith("#")) { candidate = `#${candidate}`; } const hexRegex = /^#([0-9a-fA-F]{6})$/; @@ -2899,8 +3115,10 @@ export class ManagerNodeEditor { } private getCurrentIconValue(): string { - const input = document.getElementById(ID_PANEL_NODE_TOPOROLE_FILTER_INPUT) as HTMLInputElement | null; - return input?.value?.trim() || 'pe'; + const input = document.getElementById( + ID_PANEL_NODE_TOPOROLE_FILTER_INPUT + ) as HTMLInputElement | null; + return input?.value?.trim() || "pe"; } private async handleIconUpload(): Promise { @@ -2908,28 +3126,33 @@ export class ManagerNodeEditor { return; } try { - const response = await this.messageSender.sendMessageToVscodeEndpointPost('topo-editor-upload-icon', {}); + const response = await this.messageSender.sendMessageToVscodeEndpointPost( + "topo-editor-upload-icon", + {} + ); if (!response || response.cancelled || response.success !== true) { return; } - if (response.customIcons && typeof response.customIcons === 'object') { + if (response.customIcons && typeof response.customIcons === "object") { (window as any).customIcons = response.customIcons; this.cachedNodeIcons = []; - this.cachedCustomIconSignature = ''; + this.cachedCustomIconSignature = ""; this.refreshIconDropdownAfterIconChange(response.lastAddedIcon); } } catch (error) { - log.error(`Failed to upload custom icon: ${error instanceof Error ? error.message : String(error)}`); + log.error( + `Failed to upload custom icon: ${error instanceof Error ? error.message : String(error)}` + ); } } private shouldUseBrowserConfirm(): boolean { - if (typeof window === 'undefined' || typeof window.confirm !== 'function') { + if (typeof window === "undefined" || typeof window.confirm !== "function") { return false; } // VS Code webviews expose acquireVsCodeApi/vscode but do not support blocking dialogs const hasVscodeApi = - typeof (window as any).acquireVsCodeApi === 'function' || Boolean((window as any).vscode); + typeof (window as any).acquireVsCodeApi === "function" || Boolean((window as any).vscode); return !hasVscodeApi; } @@ -2943,23 +3166,30 @@ export class ManagerNodeEditor { } this.teardownIconDropdownMenu(); try { - const response = await this.messageSender.sendMessageToVscodeEndpointPost('topo-editor-delete-icon', { iconName }); + const response = await this.messageSender.sendMessageToVscodeEndpointPost( + "topo-editor-delete-icon", + { iconName } + ); if (!response || response.success !== true) { return; } - if (response.customIcons && typeof response.customIcons === 'object') { + if (response.customIcons && typeof response.customIcons === "object") { (window as any).customIcons = response.customIcons; } this.cachedNodeIcons = []; - this.cachedCustomIconSignature = ''; + this.cachedCustomIconSignature = ""; this.refreshIconDropdownAfterIconChange(); } catch (error) { - log.error(`Failed to delete custom icon "${iconName}": ${error instanceof Error ? error.message : String(error)}`); + log.error( + `Failed to delete custom icon "${iconName}": ${error instanceof Error ? error.message : String(error)}` + ); } } private teardownIconDropdownMenu(): void { - const dropdownMenu = document.getElementById(`${ID_PANEL_NODE_TOPOROLE_CONTAINER}-dropdown-menu`) as HTMLElement | null; + const dropdownMenu = document.getElementById( + `${ID_PANEL_NODE_TOPOROLE_CONTAINER}-dropdown-menu` + ) as HTMLElement | null; if (dropdownMenu) { dropdownMenu.remove(); } @@ -2968,38 +3198,45 @@ export class ManagerNodeEditor { private refreshIconDropdownAfterIconChange(preferredIcon?: string): void { const previousSelection = this.getCurrentIconValue(); const availableIcons = this.getNodeIconOptions(); - const selectedIcon = this.resolveIconSelectionAfterChange(preferredIcon, previousSelection, availableIcons); + const selectedIcon = this.resolveIconSelectionAfterChange( + preferredIcon, + previousSelection, + availableIcons + ); this.teardownIconDropdownMenu(); createFilterableDropdown( ID_PANEL_NODE_TOPOROLE_CONTAINER, availableIcons, selectedIcon, () => {}, - 'Search for icon...', + "Search for icon...", false, { - menuClassName: 'max-h-96', + menuClassName: "max-h-96", dropdownWidth: 320, - renderOption: this.renderIconOption, + renderOption: this.renderIconOption } ); - const filterInput = document.getElementById(ID_PANEL_NODE_TOPOROLE_FILTER_INPUT) as HTMLInputElement | null; + const filterInput = document.getElementById( + ID_PANEL_NODE_TOPOROLE_FILTER_INPUT + ) as HTMLInputElement | null; if (filterInput) { filterInput.value = selectedIcon; } const shapeSelect = document.getElementById(ID_ICON_EDITOR_SHAPE) as HTMLSelectElement | null; if (shapeSelect) { this.populateIconShapeOptions(shapeSelect); - const targetIcon = preferredIcon && availableIcons.includes(preferredIcon) ? preferredIcon : selectedIcon; + const targetIcon = + preferredIcon && availableIcons.includes(preferredIcon) ? preferredIcon : selectedIcon; shapeSelect.value = targetIcon; } this.updateIconPreviewElement(); } private setupCustomNodeFields(node: cytoscape.NodeSingular): void { - this.setInputValue(ID_NODE_CUSTOM_NAME, ''); + this.setInputValue(ID_NODE_CUSTOM_NAME, ""); this.setCheckboxValue(ID_NODE_CUSTOM_DEFAULT, false); - this.setInputValue(ID_NODE_INTERFACE_PATTERN, ''); + this.setInputValue(ID_NODE_INTERFACE_PATTERN, ""); const customNameGroup = document.getElementById(ID_NODE_CUSTOM_NAME_GROUP); const nodeNameGroup = document.getElementById(ID_NODE_NAME_GROUP); @@ -3007,38 +3244,83 @@ export class ManagerNodeEditor { const isEditNode = node.id() === ID_EDIT_CUSTOM_NODE; if (customNameGroup) { - customNameGroup.style.display = isTempNode || isEditNode ? 'block' : 'none'; + customNameGroup.style.display = isTempNode || isEditNode ? "block" : "none"; } if (nodeNameGroup) { - nodeNameGroup.style.display = isTempNode || isEditNode ? 'none' : 'block'; + nodeNameGroup.style.display = isTempNode || isEditNode ? "none" : "block"; } - const heading = document.getElementById(ID_PANEL_NODE_EDITOR_HEADING); + const heading = document.getElementById(ID_PANEL_NODE_EDITOR_HEADING); if (heading) { - if (isTempNode) { - heading.textContent = 'Create Custom Node Template'; - } else if (isEditNode) { - heading.textContent = 'Edit Custom Node Template'; - } else { - heading.textContent = 'Node Editor'; + // Update only the .panel-title span to preserve the close button + const titleSpan = heading.querySelector(".panel-title"); + if (titleSpan) { + if (isTempNode) { + titleSpan.textContent = "Create Custom Node Template"; + } else if (isEditNode) { + titleSpan.textContent = "Edit Custom Node Template"; + } else { + titleSpan.textContent = "Node Editor"; + } } } } private loadConfigurationTab(extraData: Record, actualInherited: string[]): void { - this.setInputValue(ID_NODE_STARTUP_CONFIG, extraData[PROP_STARTUP_CONFIG] || ''); - this.markFieldInheritance(ID_NODE_STARTUP_CONFIG, actualInherited.includes(PROP_STARTUP_CONFIG)); - this.setCheckboxValue(ID_NODE_ENFORCE_STARTUP_CONFIG, extraData[PROP_ENFORCE_STARTUP_CONFIG] || false); - this.markFieldInheritance(ID_NODE_ENFORCE_STARTUP_CONFIG, actualInherited.includes(PROP_ENFORCE_STARTUP_CONFIG)); - this.setCheckboxValue(ID_NODE_SUPPRESS_STARTUP_CONFIG, extraData[PROP_SUPPRESS_STARTUP_CONFIG] || false); - this.markFieldInheritance(ID_NODE_SUPPRESS_STARTUP_CONFIG, actualInherited.includes(PROP_SUPPRESS_STARTUP_CONFIG)); - this.setInputValue(ID_NODE_LICENSE, extraData.license || ''); - this.markFieldInheritance(ID_NODE_LICENSE, actualInherited.includes('license')); - - this.populateArrayProperty(extraData, CN_BINDS, CN_BINDS, PH_BIND, ID_NODE_BINDS_CONTAINER, actualInherited); - this.populateKeyValueProperty(extraData, CN_ENV, CN_ENV, ID_NODE_ENV_CONTAINER, actualInherited); - this.populateArrayProperty(extraData, CN_ENV_FILES, CN_ENV_FILES, PH_ENV_FILE, ID_NODE_ENV_FILES_CONTAINER, actualInherited); - this.populateKeyValueProperty(extraData, CN_LABELS, CN_LABELS, ID_NODE_LABELS_CONTAINER, actualInherited); + this.setInputValue(ID_NODE_STARTUP_CONFIG, extraData[PROP_STARTUP_CONFIG] || ""); + this.markFieldInheritance( + ID_NODE_STARTUP_CONFIG, + actualInherited.includes(PROP_STARTUP_CONFIG) + ); + this.setCheckboxValue( + ID_NODE_ENFORCE_STARTUP_CONFIG, + extraData[PROP_ENFORCE_STARTUP_CONFIG] || false + ); + this.markFieldInheritance( + ID_NODE_ENFORCE_STARTUP_CONFIG, + actualInherited.includes(PROP_ENFORCE_STARTUP_CONFIG) + ); + this.setCheckboxValue( + ID_NODE_SUPPRESS_STARTUP_CONFIG, + extraData[PROP_SUPPRESS_STARTUP_CONFIG] || false + ); + this.markFieldInheritance( + ID_NODE_SUPPRESS_STARTUP_CONFIG, + actualInherited.includes(PROP_SUPPRESS_STARTUP_CONFIG) + ); + this.setInputValue(ID_NODE_LICENSE, extraData.license || ""); + this.markFieldInheritance(ID_NODE_LICENSE, actualInherited.includes("license")); + + this.populateArrayProperty( + extraData, + CN_BINDS, + CN_BINDS, + PH_BIND, + ID_NODE_BINDS_CONTAINER, + actualInherited + ); + this.populateKeyValueProperty( + extraData, + CN_ENV, + CN_ENV, + ID_NODE_ENV_CONTAINER, + actualInherited + ); + this.populateArrayProperty( + extraData, + CN_ENV_FILES, + CN_ENV_FILES, + PH_ENV_FILE, + ID_NODE_ENV_FILES_CONTAINER, + actualInherited + ); + this.populateKeyValueProperty( + extraData, + CN_LABELS, + CN_LABELS, + ID_NODE_LABELS_CONTAINER, + actualInherited + ); } private populateArrayProperty( @@ -3047,11 +3329,13 @@ export class ManagerNodeEditor { containerName: string, placeholder: string, containerId: string, - actualInherited: string[], + actualInherited: string[] ): void { const values = source[propName]; if (Array.isArray(values)) { - values.forEach((value: string) => this.addDynamicEntryWithValue(containerName, value, placeholder)); + values.forEach((value: string) => + this.addDynamicEntryWithValue(containerName, value, placeholder) + ); } this.markFieldInheritance(containerId, actualInherited.includes(propName)); } @@ -3061,43 +3345,43 @@ export class ManagerNodeEditor { propName: string, containerName: string, containerId: string, - actualInherited: string[], + actualInherited: string[] ): void { const mapEntries = source[propName]; - if (mapEntries && typeof mapEntries === 'object' && !Array.isArray(mapEntries)) { + if (mapEntries && typeof mapEntries === "object" && !Array.isArray(mapEntries)) { Object.entries(mapEntries).forEach(([key, value]) => - this.addDynamicKeyValueEntryWithValue(containerName, key, value as string), + this.addDynamicKeyValueEntryWithValue(containerName, key, value as string) ); } this.markFieldInheritance(containerId, actualInherited.includes(propName)); } private loadRuntimeTab(extraData: Record, actualInherited: string[]): void { - this.setInputValue(ID_NODE_USER, extraData.user || ''); - this.markFieldInheritance(ID_NODE_USER, actualInherited.includes('user')); - this.setInputValue(ID_NODE_ENTRYPOINT, extraData.entrypoint || ''); - this.markFieldInheritance(ID_NODE_ENTRYPOINT, actualInherited.includes('entrypoint')); - this.setInputValue(ID_NODE_CMD, extraData.cmd || ''); - this.markFieldInheritance(ID_NODE_CMD, actualInherited.includes('cmd')); + this.setInputValue(ID_NODE_USER, extraData.user || ""); + this.markFieldInheritance(ID_NODE_USER, actualInherited.includes("user")); + this.setInputValue(ID_NODE_ENTRYPOINT, extraData.entrypoint || ""); + this.markFieldInheritance(ID_NODE_ENTRYPOINT, actualInherited.includes("entrypoint")); + this.setInputValue(ID_NODE_CMD, extraData.cmd || ""); + this.markFieldInheritance(ID_NODE_CMD, actualInherited.includes("cmd")); const rpOptions = [...OPTIONS_RP]; const rpInitial = extraData[PROP_RESTART_POLICY] || LABEL_DEFAULT; createFilterableDropdown(ID_NODE_RP_DROPDOWN, rpOptions, rpInitial, () => {}, PH_SEARCH_RP); this.markFieldInheritance(ID_NODE_RP_DROPDOWN, actualInherited.includes(PROP_RESTART_POLICY)); this.setCheckboxValue(ID_NODE_AUTO_REMOVE, extraData[PROP_AUTO_REMOVE] || false); this.markFieldInheritance(ID_NODE_AUTO_REMOVE, actualInherited.includes(PROP_AUTO_REMOVE)); - this.setInputValue(ID_NODE_STARTUP_DELAY, extraData[PROP_STARTUP_DELAY] || ''); + this.setInputValue(ID_NODE_STARTUP_DELAY, extraData[PROP_STARTUP_DELAY] || ""); this.markFieldInheritance(ID_NODE_STARTUP_DELAY, actualInherited.includes(PROP_STARTUP_DELAY)); if (extraData.exec && Array.isArray(extraData.exec)) { extraData.exec.forEach((cmd: string) => this.addDynamicEntryWithValue(CN_EXEC, cmd, PH_EXEC)); } - this.markFieldInheritance(ID_NODE_EXEC_CONTAINER, actualInherited.includes('exec')); + this.markFieldInheritance(ID_NODE_EXEC_CONTAINER, actualInherited.includes("exec")); } private loadNetworkTab(extraData: Record, actualInherited: string[]): void { - this.setInputValue(ID_NODE_MGMT_IPV4, extraData[PROP_MGMT_IPV4] || ''); + this.setInputValue(ID_NODE_MGMT_IPV4, extraData[PROP_MGMT_IPV4] || ""); this.markFieldInheritance(ID_NODE_MGMT_IPV4, actualInherited.includes(PROP_MGMT_IPV4)); - this.setInputValue(ID_NODE_MGMT_IPV6, extraData[PROP_MGMT_IPV6] || ''); + this.setInputValue(ID_NODE_MGMT_IPV6, extraData[PROP_MGMT_IPV6] || ""); this.markFieldInheritance(ID_NODE_MGMT_IPV6, actualInherited.includes(PROP_MGMT_IPV6)); const nmOptions = [...OPTIONS_NM]; const nmInitial = extraData[PROP_NETWORK_MODE] || LABEL_DEFAULT; @@ -3105,17 +3389,23 @@ export class ManagerNodeEditor { this.markFieldInheritance(ID_NODE_NM_DROPDOWN, actualInherited.includes(PROP_NETWORK_MODE)); if (extraData.ports && Array.isArray(extraData.ports)) { - extraData.ports.forEach((port: string) => this.addDynamicEntryWithValue(CN_PORTS, port, PH_PORT)); + extraData.ports.forEach((port: string) => + this.addDynamicEntryWithValue(CN_PORTS, port, PH_PORT) + ); } this.markFieldInheritance(ID_NODE_PORTS_CONTAINER, actualInherited.includes(PROP_PORTS)); if (extraData.dns && extraData.dns.servers && Array.isArray(extraData.dns.servers)) { - extraData.dns.servers.forEach((server: string) => this.addDynamicEntryWithValue(CN_DNS_SERVERS, server, PH_DNS_SERVER)); + extraData.dns.servers.forEach((server: string) => + this.addDynamicEntryWithValue(CN_DNS_SERVERS, server, PH_DNS_SERVER) + ); } this.markFieldInheritance(ID_NODE_DNS_SERVERS_CONTAINER, actualInherited.includes(PROP_DNS)); if (extraData.aliases && Array.isArray(extraData.aliases)) { - extraData.aliases.forEach((alias: string) => this.addDynamicEntryWithValue(CN_ALIASES, alias, PH_ALIAS)); + extraData.aliases.forEach((alias: string) => + this.addDynamicEntryWithValue(CN_ALIASES, alias, PH_ALIAS) + ); } this.markFieldInheritance(ID_NODE_ALIASES_CONTAINER, actualInherited.includes(PROP_ALIASES)); } @@ -3132,25 +3422,27 @@ export class ManagerNodeEditor { } private loadResourceLimits(extraData: Record, actualInherited: string[]): void { - this.setInputValue(ID_NODE_MEMORY, extraData.memory || ''); + this.setInputValue(ID_NODE_MEMORY, extraData.memory || ""); this.markFieldInheritance(ID_NODE_MEMORY, actualInherited.includes(PROP_MEMORY)); - this.setInputValue(ID_NODE_CPU, extraData.cpu || ''); + this.setInputValue(ID_NODE_CPU, extraData.cpu || ""); this.markFieldInheritance(ID_NODE_CPU, actualInherited.includes(PROP_CPU)); - this.setInputValue(ID_NODE_CPU_SET, extraData[PROP_CPU_SET] || ''); + this.setInputValue(ID_NODE_CPU_SET, extraData[PROP_CPU_SET] || ""); this.markFieldInheritance(ID_NODE_CPU_SET, actualInherited.includes(PROP_CPU_SET)); - this.setInputValue(ID_NODE_SHM_SIZE, extraData[PROP_SHM_SIZE] || ''); + this.setInputValue(ID_NODE_SHM_SIZE, extraData[PROP_SHM_SIZE] || ""); this.markFieldInheritance(ID_NODE_SHM_SIZE, actualInherited.includes(PROP_SHM_SIZE)); } private loadCapAdd(extraData: Record, actualInherited: string[]): void { if (extraData[PROP_CAP_ADD] && Array.isArray(extraData[PROP_CAP_ADD])) { - extraData[PROP_CAP_ADD].forEach((cap: string) => this.addDynamicEntryWithValue(CN_CAP_ADD, cap, PH_CAP)); + extraData[PROP_CAP_ADD].forEach((cap: string) => + this.addDynamicEntryWithValue(CN_CAP_ADD, cap, PH_CAP) + ); } this.markFieldInheritance(ID_NODE_CAP_ADD_CONTAINER, actualInherited.includes(PROP_CAP_ADD)); } private loadSysctls(extraData: Record, actualInherited: string[]): void { - if (extraData.sysctls && typeof extraData.sysctls === 'object') { + if (extraData.sysctls && typeof extraData.sysctls === "object") { Object.entries(extraData.sysctls).forEach(([key, value]) => this.addDynamicKeyValueEntryWithValue(CN_SYSCTLS, key, String(value)) ); @@ -3160,7 +3452,9 @@ export class ManagerNodeEditor { private loadDevices(extraData: Record, actualInherited: string[]): void { if (extraData.devices && Array.isArray(extraData.devices)) { - extraData.devices.forEach((device: string) => this.addDynamicEntryWithValue(CN_DEVICES, device, PH_DEVICE)); + extraData.devices.forEach((device: string) => + this.addDynamicEntryWithValue(CN_DEVICES, device, PH_DEVICE) + ); } this.markFieldInheritance(ID_NODE_DEVICES_CONTAINER, actualInherited.includes(PROP_DEVICES)); } @@ -3169,8 +3463,8 @@ export class ManagerNodeEditor { if (extraData.certificate) { this.setCheckboxValue(ID_NODE_CERT_ISSUE, extraData.certificate.issue || false); this.markFieldInheritance(ID_NODE_CERT_ISSUE, actualInherited.includes(PROP_CERTIFICATE)); - const keySizeOptions = ['2048', '4096']; - const keySizeInitial = String(extraData.certificate['key-size'] || '2048'); + const keySizeOptions = ["2048", "4096"]; + const keySizeInitial = String(extraData.certificate["key-size"] || "2048"); createFilterableDropdown( ID_NODE_CERT_KEYSIZE_DROPDOWN, keySizeOptions, @@ -3178,9 +3472,11 @@ export class ManagerNodeEditor { () => {}, PH_SEARCH_KEY_SIZE ); - this.setInputValue(ID_NODE_CERT_VALIDITY, extraData.certificate['validity-duration'] || ''); + this.setInputValue(ID_NODE_CERT_VALIDITY, extraData.certificate["validity-duration"] || ""); if (extraData.certificate.sans && Array.isArray(extraData.certificate.sans)) { - extraData.certificate.sans.forEach((san: string) => this.addDynamicEntryWithValue(CN_SANS, san, PH_SAN)); + extraData.certificate.sans.forEach((san: string) => + this.addDynamicEntryWithValue(CN_SANS, san, PH_SAN) + ); } } } @@ -3188,26 +3484,23 @@ export class ManagerNodeEditor { private loadHealthcheckSection(extraData: Record, actualInherited: string[]): void { if (extraData.healthcheck) { const hc = extraData.healthcheck; - this.setInputValue(ID_HC_TEST, hc.test ? hc.test.join(' ') : ''); - this.setInputValue(ID_HC_START, hc['start-period'] || ''); - this.setInputValue(ID_HC_INTERVAL, hc.interval || ''); - this.setInputValue(ID_HC_TIMEOUT, hc.timeout || ''); - this.setInputValue(ID_HC_RETRIES, hc.retries || ''); + this.setInputValue(ID_HC_TEST, hc.test ? hc.test.join(" ") : ""); + this.setInputValue(ID_HC_START, hc["start-period"] || ""); + this.setInputValue(ID_HC_INTERVAL, hc.interval || ""); + this.setInputValue(ID_HC_TIMEOUT, hc.timeout || ""); + this.setInputValue(ID_HC_RETRIES, hc.retries || ""); } this.markFieldInheritance(ID_HC_TEST, actualInherited.includes(PROP_HEALTHCHECK)); } private loadImagePullPolicy(extraData: Record, actualInherited: string[]): void { const ippOptions = [...OPTIONS_IPP]; - const ippInitial = extraData['image-pull-policy'] || LABEL_DEFAULT; - createFilterableDropdown( + const ippInitial = extraData["image-pull-policy"] || LABEL_DEFAULT; + createFilterableDropdown(ID_NODE_IPP_DROPDOWN, ippOptions, ippInitial, () => {}, PH_SEARCH_IPP); + this.markFieldInheritance( ID_NODE_IPP_DROPDOWN, - ippOptions, - ippInitial, - () => {}, - PH_SEARCH_IPP + actualInherited.includes(PROP_IMAGE_PULL_POLICY) ); - this.markFieldInheritance(ID_NODE_IPP_DROPDOWN, actualInherited.includes(PROP_IMAGE_PULL_POLICY)); } private loadRuntimeOption(extraData: Record, actualInherited: string[]): void { @@ -3225,8 +3518,8 @@ export class ManagerNodeEditor { private setupImageFields(extraData: Record, actualInherited: string[]): void { const dockerImages = (window as any).dockerImages as string[] | undefined; - const imageInitial = extraData.image || ''; - this.markFieldInheritance(ID_NODE_IMAGE_DROPDOWN, actualInherited.includes('image')); + const imageInitial = extraData.image || ""; + this.markFieldInheritance(ID_NODE_IMAGE_DROPDOWN, actualInherited.includes("image")); if (this.shouldUseImageDropdowns(dockerImages)) { this.setupImageDropdowns(dockerImages!, imageInitial); @@ -3236,20 +3529,23 @@ export class ManagerNodeEditor { } private shouldUseImageDropdowns(dockerImages: string[] | undefined): boolean { - return Array.isArray(dockerImages) && dockerImages.some(img => img && img.trim() !== ''); + return Array.isArray(dockerImages) && dockerImages.some((img) => img && img.trim() !== ""); } private setupImageDropdowns(dockerImages: string[], imageInitial: string): void { this.parseDockerImages(dockerImages); const baseImages = Array.from(this.imageVersionMap.keys()).sort((a, b) => { - const aIsNokia = a.includes('nokia'); - const bIsNokia = b.includes('nokia'); + const aIsNokia = a.includes("nokia"); + const bIsNokia = b.includes("nokia"); if (aIsNokia && !bIsNokia) return -1; if (!aIsNokia && bIsNokia) return 1; return a.localeCompare(b); }); - const { base: initialBaseImage, version: initialVersion } = this.splitImageName(imageInitial, baseImages); + const { base: initialBaseImage, version: initialVersion } = this.splitImageName( + imageInitial, + baseImages + ); createFilterableDropdown( ID_NODE_IMAGE_DROPDOWN, @@ -3260,8 +3556,8 @@ export class ManagerNodeEditor { true ); - const versions = this.imageVersionMap.get(initialBaseImage) || ['latest']; - const versionToSelect = initialVersion || versions[0] || 'latest'; + const versions = this.imageVersionMap.get(initialBaseImage) || ["latest"]; + const versionToSelect = initialVersion || versions[0] || "latest"; createFilterableDropdown( ID_NODE_VERSION_DROPDOWN, versions, @@ -3272,11 +3568,14 @@ export class ManagerNodeEditor { ); } - private splitImageName(imageInitial: string, baseImages: string[]): { base: string; version: string } { - let base = ''; - let version = 'latest'; + private splitImageName( + imageInitial: string, + baseImages: string[] + ): { base: string; version: string } { + let base = ""; + let version = "latest"; if (imageInitial) { - const lastColonIndex = imageInitial.lastIndexOf(':'); + const lastColonIndex = imageInitial.lastIndexOf(":"); if (lastColonIndex > 0) { base = imageInitial.substring(0, lastColonIndex); version = imageInitial.substring(lastColonIndex + 1); @@ -3297,24 +3596,26 @@ export class ManagerNodeEditor { private setupFallbackImageInputs(imageInitial: string): void { const container = document.getElementById(ID_NODE_IMAGE_DROPDOWN); if (container) { - const input = document.createElement('input'); - input.type = 'text'; + const input = document.createElement("input"); + input.type = "text"; input.className = `${CLASS_INPUT_FIELD} w-full`; input.placeholder = PH_IMAGE_EXAMPLE; input.id = ID_NODE_IMAGE_FALLBACK_INPUT; - input.value = imageInitial.includes(':') ? imageInitial.substring(0, imageInitial.lastIndexOf(':')) : imageInitial; + input.value = imageInitial.includes(":") + ? imageInitial.substring(0, imageInitial.lastIndexOf(":")) + : imageInitial; container.appendChild(input); } const versionContainer = document.getElementById(ID_NODE_VERSION_DROPDOWN); if (versionContainer) { - const versionInput = document.createElement('input'); - versionInput.type = 'text'; + const versionInput = document.createElement("input"); + versionInput.type = "text"; versionInput.className = `${CLASS_INPUT_FIELD} w-full`; versionInput.placeholder = PH_VERSION_EXAMPLE; versionInput.id = ID_NODE_VERSION_FALLBACK_INPUT; - const colon = imageInitial.lastIndexOf(':'); - versionInput.value = colon > 0 ? imageInitial.substring(colon + 1) : 'latest'; + const colon = imageInitial.lastIndexOf(":"); + versionInput.value = colon > 0 ? imageInitial.substring(colon + 1) : "latest"; versionContainer.appendChild(versionInput); } } @@ -3322,26 +3623,30 @@ export class ManagerNodeEditor { /** * Add a dynamic entry with a pre-filled value */ - private addDynamicEntryWithValue(containerName: string, value: string, placeholder: string): void { + private addDynamicEntryWithValue( + containerName: string, + value: string, + placeholder: string + ): void { const container = document.getElementById(`node-${containerName}-container`); if (!container) return; const count = (this.dynamicEntryCounters.get(containerName) || 0) + 1; this.dynamicEntryCounters.set(containerName, count); - const entryDiv = document.createElement('div'); + const entryDiv = document.createElement("div"); entryDiv.className = CLASS_DYNAMIC_ENTRY; entryDiv.id = `${containerName}-entry-${count}`; - const input = document.createElement('input'); - input.type = 'text'; + const input = document.createElement("input"); + input.type = "text"; input.className = CLASS_INPUT_FIELD; input.placeholder = placeholder; input.value = value; input.setAttribute(DATA_ATTR_FIELD, containerName); - const button = document.createElement('button'); - button.type = 'button'; // Prevent form submission + const button = document.createElement("button"); + button.type = "button"; // Prevent form submission button.className = CLASS_DYNAMIC_DELETE_BTN; button.setAttribute(DATA_ATTR_CONTAINER, containerName); button.setAttribute(DATA_ATTR_ENTRY_ID, count.toString()); @@ -3355,31 +3660,35 @@ export class ManagerNodeEditor { /** * Add a dynamic key-value entry with pre-filled values */ - private addDynamicKeyValueEntryWithValue(containerName: string, key: string, value: string): void { + private addDynamicKeyValueEntryWithValue( + containerName: string, + key: string, + value: string + ): void { const container = document.getElementById(`node-${containerName}-container`); if (!container) return; const count = (this.dynamicEntryCounters.get(containerName) || 0) + 1; this.dynamicEntryCounters.set(containerName, count); - const entryDiv = document.createElement('div'); + const entryDiv = document.createElement("div"); entryDiv.className = CLASS_DYNAMIC_ENTRY; entryDiv.id = `${containerName}-entry-${count}`; - const keyInput = document.createElement('input'); - keyInput.type = 'text'; + const keyInput = document.createElement("input"); + keyInput.type = "text"; keyInput.className = CLASS_INPUT_FIELD; keyInput.value = key; keyInput.setAttribute(DATA_ATTR_FIELD, `${containerName}-key`); - const valueInput = document.createElement('input'); - valueInput.type = 'text'; + const valueInput = document.createElement("input"); + valueInput.type = "text"; valueInput.className = CLASS_INPUT_FIELD; valueInput.value = value; valueInput.setAttribute(DATA_ATTR_FIELD, `${containerName}-value`); - const button = document.createElement('button'); - button.type = 'button'; // Prevent form submission + const button = document.createElement("button"); + button.type = "button"; // Prevent form submission button.className = CLASS_DYNAMIC_DELETE_BTN; button.setAttribute(DATA_ATTR_CONTAINER, containerName); button.setAttribute(DATA_ATTR_ENTRY_ID, count.toString()); @@ -3416,7 +3725,7 @@ export class ManagerNodeEditor { */ private getInputValue(id: string): string { const element = document.getElementById(id) as HTMLInputElement | HTMLSelectElement; - return element?.value || ''; + return element?.value || ""; } /** @@ -3439,8 +3748,8 @@ export class ManagerNodeEditor { } private getExistingNodeTypeValue(): string | undefined { - const currentType = this.currentNode?.data('extraData')?.type; - if (typeof currentType === 'string') { + const currentType = this.currentNode?.data("extraData")?.type; + if (typeof currentType === "string") { const trimmed = currentType.trim(); return trimmed.length > 0 ? trimmed : undefined; } @@ -3454,13 +3763,14 @@ export class ManagerNodeEditor { const el = document.getElementById(fieldId) as HTMLElement | null; const formGroup = el?.closest(SELECTOR_FORM_GROUP) as HTMLElement | null; if (!formGroup) return; - let badge = formGroup.querySelector('.inherited-badge') as HTMLElement | null; + let badge = formGroup.querySelector(".inherited-badge") as HTMLElement | null; if (inherited) { if (!badge) { - badge = document.createElement('span'); - badge.className = 'inherited-badge ml-2 px-1 py-0.5 text-xs bg-gray-200 text-gray-700 rounded'; - badge.textContent = 'inherited'; - const label = formGroup.querySelector('label'); + badge = document.createElement("span"); + badge.className = + "inherited-badge ml-2 px-1 py-0.5 text-xs bg-gray-200 text-gray-700 rounded"; + badge.textContent = "inherited"; + const label = formGroup.querySelector("label"); label?.appendChild(badge); } } else if (badge) { @@ -3473,7 +3783,7 @@ export class ManagerNodeEditor { */ private updateInheritedBadges(inheritedProps: string[]): void { // Properties that should never show inherited badge - const neverInherited = ['kind', 'name', 'group']; + const neverInherited = ["kind", "name", "group"]; // Use shared mappings FIELD_MAPPINGS_BASE.forEach(({ id, prop }) => { @@ -3493,7 +3803,7 @@ export class ManagerNodeEditor { const idx = arr.indexOf(prop); if (idx !== -1) { arr.splice(idx, 1); - this.currentNode?.data('extraData', data.extraData); + this.currentNode?.data("extraData", data.extraData); this.markFieldInheritance(fieldId, false); } } @@ -3503,21 +3813,21 @@ export class ManagerNodeEditor { */ private setupInheritanceChangeListeners(): void { const extraMappings: FieldMapping[] = [ - { id: ID_NODE_CERT_KEYSIZE_DROPDOWN, prop: 'certificate', badgeId: ID_NODE_CERT_ISSUE }, - { id: ID_NODE_CERT_VALIDITY, prop: 'certificate', badgeId: ID_NODE_CERT_ISSUE }, - { id: ID_NODE_SANS_CONTAINER, prop: 'certificate', badgeId: ID_NODE_CERT_ISSUE }, + { id: ID_NODE_CERT_KEYSIZE_DROPDOWN, prop: "certificate", badgeId: ID_NODE_CERT_ISSUE }, + { id: ID_NODE_CERT_VALIDITY, prop: "certificate", badgeId: ID_NODE_CERT_ISSUE }, + { id: ID_NODE_SANS_CONTAINER, prop: "certificate", badgeId: ID_NODE_CERT_ISSUE }, { id: ID_HC_START, prop: PROP_HEALTHCHECK, badgeId: ID_HC_TEST }, { id: ID_HC_INTERVAL, prop: PROP_HEALTHCHECK, badgeId: ID_HC_TEST }, { id: ID_HC_TIMEOUT, prop: PROP_HEALTHCHECK, badgeId: ID_HC_TEST }, - { id: ID_HC_RETRIES, prop: PROP_HEALTHCHECK, badgeId: ID_HC_TEST }, + { id: ID_HC_RETRIES, prop: PROP_HEALTHCHECK, badgeId: ID_HC_TEST } ]; const mappings: FieldMapping[] = [...FIELD_MAPPINGS_BASE, ...extraMappings]; mappings.forEach(({ id, prop, badgeId }) => { const el = document.getElementById(id); if (!el) return; - el.addEventListener('input', () => this.clearInherited(prop, badgeId || id)); - el.addEventListener('change', () => this.clearInherited(prop, badgeId || id)); + el.addEventListener("input", () => this.clearInherited(prop, badgeId || id)); + el.addEventListener("change", () => this.clearInherited(prop, badgeId || id)); }); } @@ -3552,8 +3862,12 @@ export class ManagerNodeEditor { const result: Record = {}; entries.forEach((entry: Element) => { - const keyInput = entry.querySelector(`input[data-field="${containerName}-key"]`) as HTMLInputElement; - const valueInput = entry.querySelector(`input[data-field="${containerName}-value"]`) as HTMLInputElement; + const keyInput = entry.querySelector( + `input[data-field="${containerName}-key"]` + ) as HTMLInputElement; + const valueInput = entry.querySelector( + `input[data-field="${containerName}-value"]` + ) as HTMLInputElement; if (keyInput && valueInput) { const key = keyInput.value.trim(); @@ -3572,7 +3886,7 @@ export class ManagerNodeEditor { */ private validateIPv4(ip: string): boolean { if (!ip) return true; // Empty is valid - const parts = ip.split('.'); + const parts = ip.split("."); if (parts.length !== 4) return false; for (const p of parts) { if (p.length === 0 || p.length > 3) return false; @@ -3580,7 +3894,7 @@ export class ManagerNodeEditor { const n = parseInt(p, 10); if (n < 0 || n > 255) return false; // Disallow leading zeros like 01 unless the value is exactly '0' - if (p.length > 1 && p.startsWith('0')) return false; + if (p.length > 1 && p.startsWith("0")) return false; } return true; } @@ -3591,17 +3905,17 @@ export class ManagerNodeEditor { private validateIPv6(ip: string): boolean { if (!ip) return true; // Empty is valid // Handle IPv4-mapped addresses - const lastColon = ip.lastIndexOf(':'); - if (lastColon !== -1 && ip.indexOf('.') > lastColon) { + const lastColon = ip.lastIndexOf(":"); + if (lastColon !== -1 && ip.indexOf(".") > lastColon) { const v6 = ip.slice(0, lastColon); const v4 = ip.slice(lastColon + 1); - return this.validateIPv6(v6 + '::') && this.validateIPv4(v4); + return this.validateIPv6(v6 + "::") && this.validateIPv4(v4); } - const hasDoubleColon = ip.includes('::'); - if (hasDoubleColon && ip.indexOf('::') !== ip.lastIndexOf('::')) return false; + const hasDoubleColon = ip.includes("::"); + if (hasDoubleColon && ip.indexOf("::") !== ip.lastIndexOf("::")) return false; - const parts = ip.split(':').filter(s => s.length > 0); + const parts = ip.split(":").filter((s) => s.length > 0); if (!hasDoubleColon && parts.length !== 8) return false; if (hasDoubleColon && parts.length > 7) return false; @@ -3651,7 +3965,7 @@ export class ManagerNodeEditor { private validateBindMount(bind: string): boolean { if (!bind) return true; // Empty is valid // Basic validation - check for at least host:container format - const parts = bind.split(':'); + const parts = bind.split(":"); return parts.length >= 2 && parts[0].length > 0 && parts[1].length > 0; } @@ -3662,14 +3976,14 @@ export class ManagerNodeEditor { // Find the input element and add error styling const element = document.getElementById(field); if (element) { - element.classList.add('border-red-500'); + element.classList.add("border-red-500"); // Create or update error message let errorElement = document.getElementById(`${field}-error`); if (!errorElement) { - errorElement = document.createElement('div'); + errorElement = document.createElement("div"); errorElement.id = `${field}-error`; - errorElement.className = 'text-red-500 text-xs mt-1'; + errorElement.className = "text-red-500 text-xs mt-1"; element.parentElement?.appendChild(errorElement); } errorElement.textContent = message; @@ -3678,16 +3992,15 @@ export class ManagerNodeEditor { log.warn(`Validation error for ${field}: ${message}`); } - /** * Clear all validation errors */ private clearAllValidationErrors(): void { // Clear all error styling and messages - this.panel?.querySelectorAll('.border-red-500').forEach(element => { - element.classList.remove('border-red-500'); + this.panel?.querySelectorAll(".border-red-500").forEach((element) => { + element.classList.remove("border-red-500"); }); - this.panel?.querySelectorAll('[id$="-error"]').forEach(element => { + this.panel?.querySelectorAll('[id$="-error"]').forEach((element) => { element.remove(); }); } @@ -3707,13 +4020,13 @@ export class ManagerNodeEditor { () => this.validateBindsField(), () => this.validateNodeNameField() ]; - return validators.every(validate => validate()); + return validators.every((validate) => validate()); } private validateMgmtIpv4(): boolean { const value = this.getInputValue(ID_NODE_MGMT_IPV4); if (value && !this.validateIPv4(value)) { - this.showValidationError(ID_NODE_MGMT_IPV4, 'Invalid IPv4 address format'); + this.showValidationError(ID_NODE_MGMT_IPV4, "Invalid IPv4 address format"); return false; } return true; @@ -3722,7 +4035,7 @@ export class ManagerNodeEditor { private validateMgmtIpv6(): boolean { const value = this.getInputValue(ID_NODE_MGMT_IPV6); if (value && !this.validateIPv6(value)) { - this.showValidationError(ID_NODE_MGMT_IPV6, 'Invalid IPv6 address format'); + this.showValidationError(ID_NODE_MGMT_IPV6, "Invalid IPv6 address format"); return false; } return true; @@ -3731,7 +4044,7 @@ export class ManagerNodeEditor { private validateMemoryField(): boolean { const value = this.getInputValue(ID_NODE_MEMORY); if (value && !this.validateMemory(value)) { - this.showValidationError(ID_NODE_MEMORY, 'Invalid memory format (e.g., 1Gb, 512Mb)'); + this.showValidationError(ID_NODE_MEMORY, "Invalid memory format (e.g., 1Gb, 512Mb)"); return false; } return true; @@ -3742,7 +4055,7 @@ export class ManagerNodeEditor { if (!value) return true; const cpuValue = parseFloat(value); if (isNaN(cpuValue) || cpuValue <= 0) { - this.showValidationError(ID_NODE_CPU, 'CPU must be a positive number'); + this.showValidationError(ID_NODE_CPU, "CPU must be a positive number"); return false; } return true; @@ -3751,7 +4064,7 @@ export class ManagerNodeEditor { private validateCpuSetField(): boolean { const value = this.getInputValue(ID_NODE_CPU_SET); if (value && !this.validateCpuSet(value)) { - this.showValidationError(ID_NODE_CPU_SET, 'Invalid CPU set format (e.g., 0-3, 0,3)'); + this.showValidationError(ID_NODE_CPU_SET, "Invalid CPU set format (e.g., 0-3, 0,3)"); return false; } return true; @@ -3761,7 +4074,10 @@ export class ManagerNodeEditor { const ports = this.collectDynamicEntries(CN_PORTS); for (const port of ports) { if (!this.validatePortMapping(port)) { - this.showValidationError(ID_NODE_PORTS_CONTAINER, 'Invalid port format (e.g., 8080:80 or 8080:80/tcp)'); + this.showValidationError( + ID_NODE_PORTS_CONTAINER, + "Invalid port format (e.g., 8080:80 or 8080:80/tcp)" + ); return false; } } @@ -3772,7 +4088,10 @@ export class ManagerNodeEditor { const binds = this.collectDynamicEntries(CN_BINDS); for (const bind of binds) { if (!this.validateBindMount(bind)) { - this.showValidationError(ID_NODE_BINDS_CONTAINER, 'Invalid bind mount format (e.g., /host/path:/container/path)'); + this.showValidationError( + ID_NODE_BINDS_CONTAINER, + "Invalid bind mount format (e.g., /host/path:/container/path)" + ); return false; } } @@ -3781,8 +4100,8 @@ export class ManagerNodeEditor { private validateNodeNameField(): boolean { const nodeName = this.getInputValue(ID_NODE_NAME); - if (!nodeName || nodeName.trim() === '') { - this.showValidationError(ID_NODE_NAME, 'Node name is required'); + if (!nodeName || nodeName.trim() === "") { + this.showValidationError(ID_NODE_NAME, "Node name is required"); return false; } return true; @@ -3798,10 +4117,19 @@ export class ManagerNodeEditor { iconColor: string | null; oldName?: string; }): any { - const { name, nodeProps, setDefault, iconValue, baseName, interfacePattern, iconColor, oldName } = params; + const { + name, + nodeProps, + setDefault, + iconValue, + baseName, + interfacePattern, + iconColor, + oldName + } = params; const payload: any = { name, - kind: nodeProps.kind || '', + kind: nodeProps.kind || "", type: nodeProps.type, image: nodeProps.image, icon: iconValue, @@ -3818,21 +4146,28 @@ export class ManagerNodeEditor { if (interfacePattern) { payload.interfacePattern = interfacePattern; } - Object.keys(nodeProps).forEach(key => { - if (!['name', 'kind', 'type', 'image'].includes(key)) { + Object.keys(nodeProps).forEach((key) => { + if (!["name", "kind", "type", "image"].includes(key)) { payload[key] = nodeProps[key as keyof NodeProperties]; } }); return payload; } - private async saveCustomNodeTemplate(name: string, nodeProps: NodeProperties, setDefault: boolean, oldName?: string): Promise { + private async saveCustomNodeTemplate( + name: string, + nodeProps: NodeProperties, + setDefault: boolean, + oldName?: string + ): Promise { try { // Get the icon/role value - const iconValue = (document.getElementById(ID_PANEL_NODE_TOPOROLE_FILTER_INPUT) as HTMLInputElement | null)?.value || 'pe'; + const iconValue = + (document.getElementById(ID_PANEL_NODE_TOPOROLE_FILTER_INPUT) as HTMLInputElement | null) + ?.value || "pe"; // Get the base name value - const baseName = this.getInputValue('node-base-name') || ''; + const baseName = this.getInputValue("node-base-name") || ""; const interfacePattern = this.getInputValue(ID_NODE_INTERFACE_PATTERN).trim(); const iconColor = this.currentIconColor; @@ -3849,7 +4184,7 @@ export class ManagerNodeEditor { }); const resp = await this.messageSender.sendMessageToVscodeEndpointPost( - 'topo-editor-save-custom-node', + "topo-editor-save-custom-node", payload ); if (resp?.customNodes) { @@ -3872,7 +4207,7 @@ export class ManagerNodeEditor { if (!this.currentNode) return; if (!this.validateForm()) { - log.warn('Form validation failed, cannot save'); + log.warn("Form validation failed, cannot save"); return; } @@ -3888,7 +4223,9 @@ export class ManagerNodeEditor { } await this.updateNode(nodeProps, expanded); } catch (error) { - log.error(`Failed to save node properties: ${error instanceof Error ? error.message : String(error)}`); + log.error( + `Failed to save node properties: ${error instanceof Error ? error.message : String(error)}` + ); } } @@ -3903,7 +4240,9 @@ export class ManagerNodeEditor { } const container = document.getElementById(ID_NODE_COMPONENTS_CONTAINER); if (!container) return; - const entries = Array.from(container.querySelectorAll(`.${CLASS_COMPONENT_ENTRY}`)) as HTMLElement[]; + const entries = Array.from( + container.querySelectorAll(`.${CLASS_COMPONENT_ENTRY}`) + ) as HTMLElement[]; for (const entry of entries) { const idx = this.extractIndex(entry.id, /component-entry-(\d+)/); if (idx === null) continue; @@ -3915,7 +4254,9 @@ export class ManagerNodeEditor { } private commitComponentBaseDropdowns(idx: number): void { - const typeFilter = document.getElementById(`component-${idx}-type-dropdown-filter-input`) as HTMLInputElement | null; + const typeFilter = document.getElementById( + `component-${idx}-type-dropdown-filter-input` + ) as HTMLInputElement | null; const typeHidden = document.getElementById(`component-${idx}-type`) as HTMLInputElement | null; if (typeFilter && typeHidden) typeHidden.value = typeFilter.value; } @@ -3923,24 +4264,36 @@ export class ManagerNodeEditor { private commitXiomDropdowns(idx: number): void { const xiomContainer = document.getElementById(`component-${idx}-xiom-container`); if (!xiomContainer) return; - const xiomRows = Array.from(xiomContainer.querySelectorAll(`.${CLASS_COMPONENT_XIOM_ENTRY}`)) as HTMLElement[]; + const xiomRows = Array.from( + xiomContainer.querySelectorAll(`.${CLASS_COMPONENT_XIOM_ENTRY}`) + ) as HTMLElement[]; for (const row of xiomRows) { const xiomId = this.extractIndex(row.id, /component-\d+-xiom-entry-(\d+)/); if (xiomId === null) continue; - const xiomTypeFilter = document.getElementById(`component-${idx}-xiom-${xiomId}-type-dropdown-filter-input`) as HTMLInputElement | null; - const xiomTypeHidden = document.getElementById(`component-${idx}-xiom-${xiomId}-type`) as HTMLInputElement | null; + const xiomTypeFilter = document.getElementById( + `component-${idx}-xiom-${xiomId}-type-dropdown-filter-input` + ) as HTMLInputElement | null; + const xiomTypeHidden = document.getElementById( + `component-${idx}-xiom-${xiomId}-type` + ) as HTMLInputElement | null; if (xiomTypeFilter && xiomTypeHidden) xiomTypeHidden.value = xiomTypeFilter.value; this.commitXiomMdaDropdowns(idx, xiomId, row); } } private commitXiomMdaDropdowns(idx: number, xiomId: number, xiomRow: HTMLElement): void { - const xmdaRows = Array.from(xiomRow.querySelectorAll(`.${CLASS_COMPONENT_XIOM_MDA_ENTRY}`)) as HTMLElement[]; + const xmdaRows = Array.from( + xiomRow.querySelectorAll(`.${CLASS_COMPONENT_XIOM_MDA_ENTRY}`) + ) as HTMLElement[]; for (const xmda of xmdaRows) { const mdaId = this.extractIndex(xmda.id, /component-\d+-xiom-\d+-mda-entry-(\d+)/); if (mdaId === null) continue; - const mdaFilter = document.getElementById(`component-${idx}-xiom-${xiomId}-mda-${mdaId}-type-dropdown-filter-input`) as HTMLInputElement | null; - const mdaHidden = document.getElementById(`component-${idx}-xiom-${xiomId}-mda-${mdaId}-type`) as HTMLInputElement | null; + const mdaFilter = document.getElementById( + `component-${idx}-xiom-${xiomId}-mda-${mdaId}-type-dropdown-filter-input` + ) as HTMLInputElement | null; + const mdaHidden = document.getElementById( + `component-${idx}-xiom-${xiomId}-mda-${mdaId}-type` + ) as HTMLInputElement | null; if (mdaFilter && mdaHidden) mdaHidden.value = mdaFilter.value; } } @@ -3948,12 +4301,18 @@ export class ManagerNodeEditor { private commitMdaDropdowns(idx: number): void { const mdaContainer = document.getElementById(`component-${idx}-mda-container`); if (!mdaContainer) return; - const rows = Array.from(mdaContainer.querySelectorAll(`.${CLASS_COMPONENT_MDA_ENTRY}`)) as HTMLElement[]; + const rows = Array.from( + mdaContainer.querySelectorAll(`.${CLASS_COMPONENT_MDA_ENTRY}`) + ) as HTMLElement[]; for (const row of rows) { const mdaId = this.extractIndex(row.id, /component-\d+-mda-entry-(\d+)/); if (mdaId === null) continue; - const mdaFilter = document.getElementById(`component-${idx}-mda-${mdaId}-type-dropdown-filter-input`) as HTMLInputElement | null; - const mdaHidden = document.getElementById(`component-${idx}-mda-${mdaId}-type`) as HTMLInputElement | null; + const mdaFilter = document.getElementById( + `component-${idx}-mda-${mdaId}-type-dropdown-filter-input` + ) as HTMLInputElement | null; + const mdaHidden = document.getElementById( + `component-${idx}-mda-${mdaId}-type` + ) as HTMLInputElement | null; if (mdaFilter && mdaHidden) mdaHidden.value = mdaFilter.value; } } @@ -3961,7 +4320,9 @@ export class ManagerNodeEditor { private collectNodeProperties(): NodeProperties { const nodeProps: NodeProperties = { name: this.getInputValue(ID_NODE_NAME), - kind: (document.getElementById(ID_NODE_KIND_FILTER_INPUT) as HTMLInputElement | null)?.value || undefined, + kind: + (document.getElementById(ID_NODE_KIND_FILTER_INPUT) as HTMLInputElement | null)?.value || + undefined }; this.applyTypeFieldValue(nodeProps); @@ -3991,12 +4352,13 @@ export class ManagerNodeEditor { } const existingType = this.getExistingNodeTypeValue(); if (existingType) { - nodeProps.type = ''; + nodeProps.type = ""; } } private collectComponentsProps(nodeProps: NodeProperties): void { - const kind = nodeProps.kind || (this.currentNode?.data('extraData')?.kind as string | undefined); + const kind = + nodeProps.kind || (this.currentNode?.data("extraData")?.kind as string | undefined); if (!kind || !this.componentKinds.has(kind)) return; if (this.integratedMode) { this.commitIntegratedMdaDropdowns(); @@ -4006,7 +4368,9 @@ export class ManagerNodeEditor { } const container = document.getElementById(ID_NODE_COMPONENTS_CONTAINER); if (!container) return; - const entries = Array.from(container.querySelectorAll(`.${CLASS_COMPONENT_ENTRY}`)) as HTMLElement[]; + const entries = Array.from( + container.querySelectorAll(`.${CLASS_COMPONENT_ENTRY}`) + ) as HTMLElement[]; const components = entries .map((entry) => this.buildComponentFromEntry(entry)) .filter((c): c is any => !!c); @@ -4047,7 +4411,7 @@ export class ManagerNodeEditor { private collectMdas(componentIdx: number): any[] { const container = document.getElementById(`component-${componentIdx}-mda-container`); if (!container) return []; - const rows = Array.from(container.querySelectorAll('.component-mda-entry')) as HTMLElement[]; + const rows = Array.from(container.querySelectorAll(".component-mda-entry")) as HTMLElement[]; const list: any[] = []; for (const row of rows) { const mdaId = this.extractIndex(row.id, /component-\d+-mda-entry-(\d+)/); @@ -4067,7 +4431,9 @@ export class ManagerNodeEditor { if (this.isCpmSlot(compSlot)) return []; const container = document.getElementById(`component-${componentIdx}-xiom-container`); if (!container) return []; - const rows = Array.from(container.querySelectorAll(`.${CLASS_COMPONENT_XIOM_ENTRY}`)) as HTMLElement[]; + const rows = Array.from( + container.querySelectorAll(`.${CLASS_COMPONENT_XIOM_ENTRY}`) + ) as HTMLElement[]; const list: any[] = []; for (const row of rows) { const xiomId = this.extractIndex(row.id, /component-\d+-xiom-entry-(\d+)/); @@ -4085,15 +4451,23 @@ export class ManagerNodeEditor { } private collectXiomMdas(componentIdx: number, xiomId: number): any[] { - const container = document.getElementById(`component-${componentIdx}-xiom-${xiomId}-mda-container`); + const container = document.getElementById( + `component-${componentIdx}-xiom-${xiomId}-mda-container` + ); if (!container) return []; - const rows = Array.from(container.querySelectorAll(`.${CLASS_COMPONENT_XIOM_MDA_ENTRY}`)) as HTMLElement[]; + const rows = Array.from( + container.querySelectorAll(`.${CLASS_COMPONENT_XIOM_MDA_ENTRY}`) + ) as HTMLElement[]; const list: any[] = []; for (const row of rows) { const mdaId = this.extractIndex(row.id, /component-\d+-xiom-\d+-mda-entry-(\d+)/); if (mdaId === null) continue; - const slotRaw = this.getInputValue(`component-${componentIdx}-xiom-${xiomId}-mda-${mdaId}-slot`).trim(); - const typeVal = this.getInputValue(`component-${componentIdx}-xiom-${xiomId}-mda-${mdaId}-type`).trim(); + const slotRaw = this.getInputValue( + `component-${componentIdx}-xiom-${xiomId}-mda-${mdaId}-slot` + ).trim(); + const typeVal = this.getInputValue( + `component-${componentIdx}-xiom-${xiomId}-mda-${mdaId}-type` + ).trim(); const mda: any = {}; if (/^\d+$/.test(slotRaw)) mda.slot = parseInt(slotRaw, 10); if (typeVal) mda.type = typeVal; @@ -4109,16 +4483,27 @@ export class ManagerNodeEditor { private collectImage(nodeProps: NodeProperties): void { const dockerImages = (window as any).dockerImages as string[] | undefined; - const hasDockerImages = Array.isArray(dockerImages) && dockerImages.length > 0 && dockerImages.some(img => img && img.trim() !== ''); + const hasDockerImages = + Array.isArray(dockerImages) && + dockerImages.length > 0 && + dockerImages.some((img) => img && img.trim() !== ""); if (hasDockerImages) { - const baseImg = (document.getElementById(ID_NODE_IMAGE_FILTER_INPUT) as HTMLInputElement | null)?.value || ''; - const version = (document.getElementById(ID_NODE_VERSION_FILTER_INPUT) as HTMLInputElement | null)?.value || 'latest'; + const baseImg = + (document.getElementById(ID_NODE_IMAGE_FILTER_INPUT) as HTMLInputElement | null)?.value || + ""; + const version = + (document.getElementById(ID_NODE_VERSION_FILTER_INPUT) as HTMLInputElement | null)?.value || + "latest"; if (baseImg) { nodeProps.image = `${baseImg}:${version}`; } } else { - const baseImg = (document.getElementById(ID_NODE_IMAGE_FALLBACK_INPUT) as HTMLInputElement | null)?.value || ''; - const version = (document.getElementById(ID_NODE_VERSION_FALLBACK_INPUT) as HTMLInputElement | null)?.value || 'latest'; + const baseImg = + (document.getElementById(ID_NODE_IMAGE_FALLBACK_INPUT) as HTMLInputElement | null)?.value || + ""; + const version = + (document.getElementById(ID_NODE_VERSION_FALLBACK_INPUT) as HTMLInputElement | null) + ?.value || "latest"; if (baseImg) { nodeProps.image = `${baseImg}:${version}`; } @@ -4136,44 +4521,45 @@ export class ManagerNodeEditor { nodeProps[PROP_SUPPRESS_STARTUP_CONFIG] = true as any; } - const license = this.getInputValue('node-license'); + const license = this.getInputValue("node-license"); if (license) nodeProps.license = license; const binds = this.collectDynamicEntries(CN_BINDS); if (binds.length > 0) nodeProps.binds = binds; - const env = this.collectDynamicKeyValueEntries('env'); + const env = this.collectDynamicKeyValueEntries("env"); if (Object.keys(env).length > 0) nodeProps.env = env; const envFiles = this.collectDynamicEntries(CN_ENV_FILES); if (envFiles.length > 0) nodeProps[CN_ENV_FILES] = envFiles; - const labels = this.collectDynamicKeyValueEntries('labels'); + const labels = this.collectDynamicKeyValueEntries("labels"); if (Object.keys(labels).length > 0) nodeProps.labels = labels; } private collectRuntimeProps(nodeProps: NodeProperties): void { - const user = this.getInputValue('node-user'); + const user = this.getInputValue("node-user"); if (user) nodeProps.user = user; - const entrypoint = this.getInputValue('node-entrypoint'); + const entrypoint = this.getInputValue("node-entrypoint"); if (entrypoint) nodeProps.entrypoint = entrypoint; - const cmd = this.getInputValue('node-cmd'); + const cmd = this.getInputValue("node-cmd"); if (cmd) nodeProps.cmd = cmd; const exec = this.collectDynamicEntries(CN_EXEC); if (exec.length > 0) nodeProps.exec = exec; - const rpVal = (document.getElementById(ID_NODE_RP_FILTER_INPUT) as HTMLInputElement | null)?.value || ''; - if (rpVal && rpVal !== LABEL_DEFAULT) nodeProps['restart-policy'] = rpVal as any; + const rpVal = + (document.getElementById(ID_NODE_RP_FILTER_INPUT) as HTMLInputElement | null)?.value || ""; + if (rpVal && rpVal !== LABEL_DEFAULT) nodeProps["restart-policy"] = rpVal as any; - if (this.getCheckboxValue('node-auto-remove')) { - nodeProps['auto-remove'] = true; + if (this.getCheckboxValue("node-auto-remove")) { + nodeProps["auto-remove"] = true; } - const startupDelay = this.getInputValue('node-startup-delay'); - if (startupDelay) nodeProps['startup-delay'] = parseInt(startupDelay); + const startupDelay = this.getInputValue("node-startup-delay"); + if (startupDelay) nodeProps["startup-delay"] = parseInt(startupDelay); } private collectNetworkProps(nodeProps: NodeProperties): void { @@ -4183,8 +4569,9 @@ export class ManagerNodeEditor { const mgmtIpv6 = this.getInputValue(ID_NODE_MGMT_IPV6); if (mgmtIpv6) (nodeProps as any)[PROP_MGMT_IPV6] = mgmtIpv6; - const nmVal = (document.getElementById(ID_NODE_NM_FILTER_INPUT) as HTMLInputElement | null)?.value || ''; - if (nmVal && nmVal !== LABEL_DEFAULT) nodeProps['network-mode'] = nmVal; + const nmVal = + (document.getElementById(ID_NODE_NM_FILTER_INPUT) as HTMLInputElement | null)?.value || ""; + if (nmVal && nmVal !== LABEL_DEFAULT) nodeProps["network-mode"] = nmVal; const ports = this.collectDynamicEntries(CN_PORTS); if (ports.length > 0) nodeProps.ports = ports; @@ -4200,7 +4587,7 @@ export class ManagerNodeEditor { } private collectAdvancedProps(nodeProps: NodeProperties): void { - const memory = this.getInputValue('node-memory'); + const memory = this.getInputValue("node-memory"); if (memory) nodeProps.memory = memory; const cpu = this.getInputValue(ID_NODE_CPU); @@ -4213,9 +4600,9 @@ export class ManagerNodeEditor { if (shmSize) (nodeProps as any)[PROP_SHM_SIZE] = shmSize; const capAdd = this.collectDynamicEntries(CN_CAP_ADD); - if (capAdd.length > 0) nodeProps['cap-add'] = capAdd; + if (capAdd.length > 0) nodeProps["cap-add"] = capAdd; - const sysctls = this.collectDynamicKeyValueEntries('sysctls'); + const sysctls = this.collectDynamicKeyValueEntries("sysctls"); if (Object.keys(sysctls).length > 0) { nodeProps.sysctls = {}; Object.entries(sysctls).forEach(([key, value]) => { @@ -4232,11 +4619,13 @@ export class ManagerNodeEditor { if (!this.getCheckboxValue(ID_NODE_CERT_ISSUE)) return; nodeProps.certificate = { issue: true }; - const keySize = (document.getElementById(ID_NODE_CERT_KEYSIZE_FILTER_INPUT) as HTMLInputElement | null)?.value || ''; - if (keySize) nodeProps.certificate['key-size'] = parseInt(keySize); + const keySize = + (document.getElementById(ID_NODE_CERT_KEYSIZE_FILTER_INPUT) as HTMLInputElement | null) + ?.value || ""; + if (keySize) nodeProps.certificate["key-size"] = parseInt(keySize); const validity = this.getInputValue(ID_NODE_CERT_VALIDITY); - if (validity) nodeProps.certificate['validity-duration'] = validity; + if (validity) nodeProps.certificate["validity-duration"] = validity; const sans = this.collectDynamicEntries(CN_SANS); if (sans.length > 0) nodeProps.certificate.sans = sans; @@ -4246,18 +4635,21 @@ export class ManagerNodeEditor { const hcTest = this.getInputValue(ID_HC_TEST); if (hcTest) { this.ensureHealthcheck(nodeProps); - nodeProps.healthcheck!.test = hcTest.split(' '); + nodeProps.healthcheck!.test = hcTest.split(" "); } - this.setHealthcheckNumber(nodeProps, ID_HC_START, 'start-period'); - this.setHealthcheckNumber(nodeProps, ID_HC_INTERVAL, 'interval'); - this.setHealthcheckNumber(nodeProps, ID_HC_TIMEOUT, 'timeout'); - this.setHealthcheckNumber(nodeProps, ID_HC_RETRIES, 'retries'); + this.setHealthcheckNumber(nodeProps, ID_HC_START, "start-period"); + this.setHealthcheckNumber(nodeProps, ID_HC_INTERVAL, "interval"); + this.setHealthcheckNumber(nodeProps, ID_HC_TIMEOUT, "timeout"); + this.setHealthcheckNumber(nodeProps, ID_HC_RETRIES, "retries"); - const ippVal = (document.getElementById(ID_NODE_IPP_FILTER_INPUT) as HTMLInputElement | null)?.value || ''; - if (ippVal && ippVal !== LABEL_DEFAULT) nodeProps['image-pull-policy'] = ippVal as any; + const ippVal = + (document.getElementById(ID_NODE_IPP_FILTER_INPUT) as HTMLInputElement | null)?.value || ""; + if (ippVal && ippVal !== LABEL_DEFAULT) nodeProps["image-pull-policy"] = ippVal as any; - const runtimeVal = (document.getElementById(ID_NODE_RUNTIME_FILTER_INPUT) as HTMLInputElement | null)?.value || ''; + const runtimeVal = + (document.getElementById(ID_NODE_RUNTIME_FILTER_INPUT) as HTMLInputElement | null)?.value || + ""; if (runtimeVal && runtimeVal !== LABEL_DEFAULT) nodeProps.runtime = runtimeVal as any; } @@ -4268,7 +4660,7 @@ export class ManagerNodeEditor { private setHealthcheckNumber( nodeProps: NodeProperties, inputId: string, - prop: keyof NonNullable + prop: keyof NonNullable ): void { const value = this.getInputValue(inputId); if (!value) return; @@ -4302,9 +4694,14 @@ export class ManagerNodeEditor { const currentData = this.currentNode!.data(); const { updatedExtraData, inheritedProps } = this.mergeNodeData(nodeProps, currentData); const iconValue = - (document.getElementById(ID_PANEL_NODE_TOPOROLE_FILTER_INPUT) as HTMLInputElement | null)?.value || - 'pe'; - const updatedData = { ...currentData, name: nodeProps.name, topoViewerRole: iconValue, extraData: updatedExtraData }; + (document.getElementById(ID_PANEL_NODE_TOPOROLE_FILTER_INPUT) as HTMLInputElement | null) + ?.value || "pe"; + const updatedData = { + ...currentData, + name: nodeProps.name, + topoViewerRole: iconValue, + extraData: updatedExtraData + }; if (this.currentIconColor) { updatedData.iconColor = this.currentIconColor; } else { @@ -4316,7 +4713,8 @@ export class ManagerNodeEditor { delete updatedData.iconCornerRadius; } this.currentNode!.data(updatedData); - const hadColorBefore = typeof currentData.iconColor === 'string' && currentData.iconColor.trim() !== ''; + const hadColorBefore = + typeof currentData.iconColor === "string" && currentData.iconColor.trim() !== ""; const preserveBackground = !hadColorBefore && !this.currentIconColor; applyIconColorToNode( this.currentNode!, @@ -4330,20 +4728,27 @@ export class ManagerNodeEditor { log.info(`Node ${this.currentNode!.id()} updated with enhanced properties`); } - private mergeNodeData(nodeProps: NodeProperties, currentData: any): { updatedExtraData: any; inheritedProps: string[] } { + private mergeNodeData( + nodeProps: NodeProperties, + currentData: any + ): { updatedExtraData: any; inheritedProps: string[] } { const updatedExtraData = this.prepareExtraData(nodeProps, currentData.extraData || {}); const topology: ClabTopology = { topology: { defaults: (window as any).topologyDefaults || {}, kinds: (window as any).topologyKinds || {}, - groups: (window as any).topologyGroups || {}, + groups: (window as any).topologyGroups || {} } }; const kindName = nodeProps.kind ?? currentData.extraData?.kind; const groupName = currentData.extraData?.group; const inheritBase = resolveNodeConfig(topology, { group: groupName, kind: kindName }); - const mergedNode = resolveNodeConfig(topology, { ...nodeProps, group: groupName, kind: kindName }); + const mergedNode = resolveNodeConfig(topology, { + ...nodeProps, + group: groupName, + kind: kindName + }); const inheritedProps = this.computeInheritedProps(mergedNode, nodeProps, inheritBase); Object.assign(updatedExtraData, mergedNode); @@ -4358,25 +4763,62 @@ export class ManagerNodeEditor { private prepareExtraData(nodeProps: NodeProperties, currentExtraData: any): any { const updatedExtraData: any = { ...currentExtraData }; const formManagedProperties = [ - 'name', 'kind', 'type', 'image', - PROP_STARTUP_CONFIG, PROP_ENFORCE_STARTUP_CONFIG, PROP_SUPPRESS_STARTUP_CONFIG, - 'license', CN_BINDS, CN_ENV, CN_ENV_FILES, CN_LABELS, 'user', - 'entrypoint', 'cmd', 'exec', PROP_RESTART_POLICY, PROP_AUTO_REMOVE, PROP_STARTUP_DELAY, - PROP_MGMT_IPV4, PROP_MGMT_IPV6, PROP_NETWORK_MODE, PROP_PORTS, PROP_DNS, PROP_ALIASES, - PROP_MEMORY, PROP_CPU, PROP_CPU_SET, PROP_SHM_SIZE, PROP_CAP_ADD, PROP_SYSCTLS, PROP_DEVICES, - PROP_CERTIFICATE, 'healthcheck', PROP_IMAGE_PULL_POLICY, PROP_RUNTIME, 'components', 'inherited' + "name", + "kind", + "type", + "image", + PROP_STARTUP_CONFIG, + PROP_ENFORCE_STARTUP_CONFIG, + PROP_SUPPRESS_STARTUP_CONFIG, + "license", + CN_BINDS, + CN_ENV, + CN_ENV_FILES, + CN_LABELS, + "user", + "entrypoint", + "cmd", + "exec", + PROP_RESTART_POLICY, + PROP_AUTO_REMOVE, + PROP_STARTUP_DELAY, + PROP_MGMT_IPV4, + PROP_MGMT_IPV6, + PROP_NETWORK_MODE, + PROP_PORTS, + PROP_DNS, + PROP_ALIASES, + PROP_MEMORY, + PROP_CPU, + PROP_CPU_SET, + PROP_SHM_SIZE, + PROP_CAP_ADD, + PROP_SYSCTLS, + PROP_DEVICES, + PROP_CERTIFICATE, + "healthcheck", + PROP_IMAGE_PULL_POLICY, + PROP_RUNTIME, + "components", + "inherited" ]; - formManagedProperties.forEach(prop => { delete updatedExtraData[prop]; }); + formManagedProperties.forEach((prop) => { + delete updatedExtraData[prop]; + }); Object.assign(updatedExtraData, nodeProps); return updatedExtraData; } - private computeInheritedProps(mergedNode: any, nodeProps: NodeProperties, inheritBase: any): string[] { + private computeInheritedProps( + mergedNode: any, + nodeProps: NodeProperties, + inheritBase: any + ): string[] { const deepEqual = (a: any, b: any) => this.deepEqualNormalized(a, b); const shouldPersist = (val: any) => this.shouldPersistValue(val); const inheritedProps: string[] = []; - const neverInherited = ['kind', 'name', 'group']; - Object.keys(mergedNode).forEach(prop => { + const neverInherited = ["kind", "name", "group"]; + Object.keys(mergedNode).forEach((prop) => { if (neverInherited.includes(prop)) { return; } @@ -4392,12 +4834,14 @@ export class ManagerNodeEditor { } private normalizeObject(obj: any): any { - if (Array.isArray(obj)) return obj.map(o => this.normalizeObject(o)); - if (obj && typeof obj === 'object') { - return Object.keys(obj).sort().reduce((acc, k) => { - acc[k] = this.normalizeObject(obj[k]); - return acc; - }, {} as any); + if (Array.isArray(obj)) return obj.map((o) => this.normalizeObject(o)); + if (obj && typeof obj === "object") { + return Object.keys(obj) + .sort() + .reduce((acc, k) => { + acc[k] = this.normalizeObject(obj[k]); + return acc; + }, {} as any); } return obj; } @@ -4409,7 +4853,7 @@ export class ManagerNodeEditor { private shouldPersistValue(val: any): boolean { if (val === undefined) return false; if (Array.isArray(val)) return val.length > 0; - if (val && typeof val === 'object') return Object.keys(val).length > 0; + if (val && typeof val === "object") return Object.keys(val).length > 0; return true; } @@ -4418,22 +4862,27 @@ export class ManagerNodeEditor { return; } if (this.isCustomTemplateNode()) { - log.debug('Skipping YAML refresh after save for custom node template'); + log.debug("Skipping YAML refresh after save for custom node template"); return; } try { const sender = this.saveManager.getMessageSender(); - const nodeName = this.currentNode!.data('name') || this.currentNode!.id(); - const freshData = await sender.sendMessageToVscodeEndpointPost('topo-editor-get-node-config', { node: nodeName }); - if (freshData && typeof freshData === 'object') { - this.currentNode!.data('extraData', freshData); + const nodeName = this.currentNode!.data("name") || this.currentNode!.id(); + const freshData = await sender.sendMessageToVscodeEndpointPost( + "topo-editor-get-node-config", + { node: nodeName } + ); + if (freshData && typeof freshData === "object") { + this.currentNode!.data("extraData", freshData); this.clearAllDynamicEntries(); // Defer restoration to the next components render that happens in handleKindChange this.pendingExpandedComponentSlots = expandedSlots; this.loadNodeData(this.currentNode!); } } catch (err) { - log.warn(`Failed to refresh node data from YAML after save: ${err instanceof Error ? err.message : String(err)}`); + log.warn( + `Failed to refresh node data from YAML after save: ${err instanceof Error ? err.message : String(err)}` + ); } } @@ -4442,7 +4891,7 @@ export class ManagerNodeEditor { */ private close(): void { if (this.panel) { - this.panel.style.display = 'none'; + this.panel.style.display = "none"; } this.currentNode = null; this.clearAllDynamicEntries(); diff --git a/src/topoViewer/webview-ui/managerViewportPanels.ts b/src/topoViewer/webview-ui/managerViewportPanels.ts index d36cb8c63..49cfb19ff 100644 --- a/src/topoViewer/webview-ui/managerViewportPanels.ts +++ b/src/topoViewer/webview-ui/managerViewportPanels.ts @@ -1,19 +1,18 @@ // file: managerViewportPanels.ts -import cytoscape from 'cytoscape'; -import { ManagerSaveTopo } from './managerSaveTopo'; -import { createFilterableDropdown } from './utilities/filterableDropdown'; -import { extractNodeIcons } from './managerCytoscapeBaseStyles'; -import { createNodeIconOptionElement } from './utilities/iconDropdownRenderer'; -import { log } from '../logging/logger'; -import { isSpecialNodeOrBridge } from '../utilities/specialNodes'; +import cytoscape from "cytoscape"; +import { ManagerSaveTopo } from "./managerSaveTopo"; +import { createFilterableDropdown } from "./utilities/filterableDropdown"; +import { extractNodeIcons } from "./managerCytoscapeBaseStyles"; +import { createNodeIconOptionElement } from "./utilities/iconDropdownRenderer"; +import { log } from "../logging/logger"; +import { isSpecialNodeOrBridge } from "../utilities/specialNodes"; import { DEFAULT_INTERFACE_PATTERN, generateInterfaceName, getInterfaceIndex, - parseInterfacePattern, -} from './utilities/interfacePatternUtils'; - + parseInterfacePattern +} from "./utilities/interfacePatternUtils"; /** * ManagerViewportPanels handles the UI panels associated with the Cytoscape viewport. @@ -38,50 +37,52 @@ export class ManagerViewportPanels { private linkDynamicEntryCounters = new Map(); // Common classes and IDs reused throughout this file - private static readonly CLASS_DYNAMIC_ENTRY = 'dynamic-entry' as const; - private static readonly CLASS_INPUT_FIELD = 'input-field' as const; - private static readonly CLASS_DYNAMIC_DELETE_BTN = 'dynamic-delete-btn' as const; - private static readonly CLASS_PANEL_OVERLAY = 'panel-overlay' as const; - private static readonly CLASS_VIEWPORT_DRAWER = 'viewport-drawer' as const; - private static readonly CLASS_VIEWPORT_DRAWER_ALT = 'ViewPortDrawer' as const; - private static readonly CLASS_OPACITY_50 = 'opacity-50' as const; - private static readonly CLASS_CURSOR_NOT_ALLOWED = 'cursor-not-allowed' as const; - - private static readonly DISPLAY_BLOCK = 'block' as const; - private static readonly DISPLAY_NONE = 'none' as const; - - private static readonly ID_NETWORK_INTERFACE = 'panel-network-interface' as const; - - private static readonly ID_NETWORK_REMOTE = 'panel-network-remote' as const; - private static readonly ID_NETWORK_VNI = 'panel-network-vni' as const; - private static readonly ID_NETWORK_UDP_PORT = 'panel-network-udp-port' as const; + private static readonly CLASS_DYNAMIC_ENTRY = "dynamic-entry" as const; + private static readonly CLASS_INPUT_FIELD = "input-field" as const; + private static readonly CLASS_DYNAMIC_DELETE_BTN = "dynamic-delete-btn" as const; + private static readonly CLASS_PANEL_OVERLAY = "panel-overlay" as const; + private static readonly CLASS_VIEWPORT_DRAWER = "viewport-drawer" as const; + private static readonly CLASS_VIEWPORT_DRAWER_ALT = "ViewPortDrawer" as const; + private static readonly CLASS_OPACITY_50 = "opacity-50" as const; + private static readonly CLASS_CURSOR_NOT_ALLOWED = "cursor-not-allowed" as const; + + private static readonly DISPLAY_BLOCK = "block" as const; + private static readonly DISPLAY_NONE = "none" as const; + + private static readonly ID_NETWORK_INTERFACE = "panel-network-interface" as const; + + private static readonly ID_NETWORK_REMOTE = "panel-network-remote" as const; + private static readonly ID_NETWORK_VNI = "panel-network-vni" as const; + private static readonly ID_NETWORK_UDP_PORT = "panel-network-udp-port" as const; private static readonly VXLAN_INPUT_IDS = [ ManagerViewportPanels.ID_NETWORK_REMOTE, ManagerViewportPanels.ID_NETWORK_VNI, - ManagerViewportPanels.ID_NETWORK_UDP_PORT, + ManagerViewportPanels.ID_NETWORK_UDP_PORT ] as const; - private static readonly ID_LINK_EDITOR_SAVE_BUTTON = 'panel-link-editor-save-button' as const; - private static readonly ID_LINK_EXT_MTU = 'panel-link-ext-mtu' as const; + private static readonly ID_LINK_EDITOR_SAVE_BUTTON = "panel-link-editor-save-button" as const; + private static readonly ID_LINK_EXT_MTU = "panel-link-ext-mtu" as const; - private static readonly ID_NETWORK_TYPE_DROPDOWN = 'panel-network-type-dropdown-container' as const; - private static readonly ID_NETWORK_TYPE_FILTER_INPUT = 'panel-network-type-dropdown-container-filter-input' as const; - private static readonly ID_NETWORK_SAVE_BUTTON = 'panel-network-editor-save-button' as const; + private static readonly ID_NETWORK_TYPE_DROPDOWN = + "panel-network-type-dropdown-container" as const; + private static readonly ID_NETWORK_TYPE_FILTER_INPUT = + "panel-network-type-dropdown-container-filter-input" as const; + private static readonly ID_NETWORK_SAVE_BUTTON = "panel-network-editor-save-button" as const; private static readonly HTML_ICON_TRASH = '' as const; - private static readonly ATTR_DATA_FIELD = 'data-field' as const; - private static readonly ID_NETWORK_LABEL = 'panel-network-label' as const; + private static readonly ATTR_DATA_FIELD = "data-field" as const; + private static readonly ID_NETWORK_LABEL = "panel-network-label" as const; - private static readonly PH_SEARCH_NETWORK_TYPE = 'Search for network type...' as const; + private static readonly PH_SEARCH_NETWORK_TYPE = "Search for network type..." as const; // Network type constants - private static readonly TYPE_HOST = 'host' as const; - private static readonly TYPE_MGMT = 'mgmt-net' as const; - private static readonly TYPE_MACVLAN = 'macvlan' as const; - private static readonly TYPE_VXLAN = 'vxlan' as const; - private static readonly TYPE_VXLAN_STITCH = 'vxlan-stitch' as const; - private static readonly TYPE_DUMMY = 'dummy' as const; - private static readonly TYPE_BRIDGE = 'bridge' as const; - private static readonly TYPE_OVS_BRIDGE = 'ovs-bridge' as const; + private static readonly TYPE_HOST = "host" as const; + private static readonly TYPE_MGMT = "mgmt-net" as const; + private static readonly TYPE_MACVLAN = "macvlan" as const; + private static readonly TYPE_VXLAN = "vxlan" as const; + private static readonly TYPE_VXLAN_STITCH = "vxlan-stitch" as const; + private static readonly TYPE_DUMMY = "dummy" as const; + private static readonly TYPE_BRIDGE = "bridge" as const; + private static readonly TYPE_OVS_BRIDGE = "ovs-bridge" as const; private static readonly NETWORK_TYPE_OPTIONS = [ ManagerViewportPanels.TYPE_HOST, @@ -91,26 +92,26 @@ export class ManagerViewportPanels { ManagerViewportPanels.TYPE_VXLAN_STITCH, ManagerViewportPanels.TYPE_DUMMY, ManagerViewportPanels.TYPE_BRIDGE, - ManagerViewportPanels.TYPE_OVS_BRIDGE, + ManagerViewportPanels.TYPE_OVS_BRIDGE ] as const; private static readonly VX_TYPES = [ ManagerViewportPanels.TYPE_VXLAN, - ManagerViewportPanels.TYPE_VXLAN_STITCH, + ManagerViewportPanels.TYPE_VXLAN_STITCH ] as const; private static readonly HOSTY_TYPES = [ ManagerViewportPanels.TYPE_HOST, ManagerViewportPanels.TYPE_MGMT, - ManagerViewportPanels.TYPE_MACVLAN, + ManagerViewportPanels.TYPE_MACVLAN ] as const; private static readonly BRIDGE_TYPES = [ ManagerViewportPanels.TYPE_BRIDGE, - ManagerViewportPanels.TYPE_OVS_BRIDGE, + ManagerViewportPanels.TYPE_OVS_BRIDGE ] as const; - private static readonly LABEL_INTERFACE = 'Interface' as const; - private static readonly LABEL_BRIDGE_NAME = 'Bridge Name' as const; - private static readonly LABEL_HOST_INTERFACE = 'Host Interface' as const; + private static readonly LABEL_INTERFACE = "Interface" as const; + private static readonly LABEL_BRIDGE_NAME = "Bridge Name" as const; + private static readonly LABEL_HOST_INTERFACE = "Host Interface" as const; /** * Generate a unique ID for dummy network nodes (dummy1, dummy2, ...). @@ -128,16 +129,18 @@ export class ManagerViewportPanels { */ private initializeDynamicEntryHandlers(): void { // Network Editor handlers - (window as any).addNetworkVarEntry = () => this.addNetworkKeyValueEntry('vars', 'key', 'value'); - (window as any).addNetworkLabelEntry = () => this.addNetworkKeyValueEntry('labels', 'label-key', 'label-value'); + (window as any).addNetworkVarEntry = () => this.addNetworkKeyValueEntry("vars", "key", "value"); + (window as any).addNetworkLabelEntry = () => + this.addNetworkKeyValueEntry("labels", "label-key", "label-value"); (window as any).removeNetworkEntry = (containerName: string, entryId: number) => { this.removeNetworkEntry(containerName, entryId); return false; }; // Link Editor handlers - (window as any).addLinkVarEntry = () => this.addLinkKeyValueEntry('vars', 'key', 'value'); - (window as any).addLinkLabelEntry = () => this.addLinkKeyValueEntry('labels', 'label-key', 'label-value'); + (window as any).addLinkVarEntry = () => this.addLinkKeyValueEntry("vars", "key", "value"); + (window as any).addLinkLabelEntry = () => + this.addLinkKeyValueEntry("labels", "label-key", "label-value"); (window as any).removeLinkEntry = (containerName: string, entryId: number) => { this.removeLinkEntry(containerName, entryId); return false; @@ -147,31 +150,38 @@ export class ManagerViewportPanels { /** * Add a key-value entry for Network Editor */ - private addNetworkKeyValueEntry(containerName: string, keyPlaceholder: string, valuePlaceholder: string): void { + private addNetworkKeyValueEntry( + containerName: string, + keyPlaceholder: string, + valuePlaceholder: string + ): void { const container = document.getElementById(`panel-network-${containerName}-container`); if (!container) return; const count = (this.networkDynamicEntryCounters.get(containerName) || 0) + 1; this.networkDynamicEntryCounters.set(containerName, count); - const entryDiv = document.createElement('div'); + const entryDiv = document.createElement("div"); entryDiv.className = ManagerViewportPanels.CLASS_DYNAMIC_ENTRY; entryDiv.id = `network-${containerName}-entry-${count}`; - const keyInput = document.createElement('input'); - keyInput.type = 'text'; + const keyInput = document.createElement("input"); + keyInput.type = "text"; keyInput.className = ManagerViewportPanels.CLASS_INPUT_FIELD; keyInput.placeholder = keyPlaceholder; keyInput.setAttribute(ManagerViewportPanels.ATTR_DATA_FIELD, `network-${containerName}-key`); - const valueInput = document.createElement('input'); - valueInput.type = 'text'; + const valueInput = document.createElement("input"); + valueInput.type = "text"; valueInput.className = ManagerViewportPanels.CLASS_INPUT_FIELD; valueInput.placeholder = valuePlaceholder; - valueInput.setAttribute(ManagerViewportPanels.ATTR_DATA_FIELD, `network-${containerName}-value`); + valueInput.setAttribute( + ManagerViewportPanels.ATTR_DATA_FIELD, + `network-${containerName}-value` + ); - const button = document.createElement('button'); - button.type = 'button'; + const button = document.createElement("button"); + button.type = "button"; button.className = ManagerViewportPanels.CLASS_DYNAMIC_DELETE_BTN; button.innerHTML = ManagerViewportPanels.HTML_ICON_TRASH; button.onclick = () => this.removeNetworkEntry(containerName, count); @@ -185,31 +195,38 @@ export class ManagerViewportPanels { /** * Add a key-value entry with value for Network Editor */ - private addNetworkKeyValueEntryWithValue(containerName: string, key: string, value: string): void { + private addNetworkKeyValueEntryWithValue( + containerName: string, + key: string, + value: string + ): void { const container = document.getElementById(`panel-network-${containerName}-container`); if (!container) return; const count = (this.networkDynamicEntryCounters.get(containerName) || 0) + 1; this.networkDynamicEntryCounters.set(containerName, count); - const entryDiv = document.createElement('div'); + const entryDiv = document.createElement("div"); entryDiv.className = ManagerViewportPanels.CLASS_DYNAMIC_ENTRY; entryDiv.id = `network-${containerName}-entry-${count}`; - const keyInput = document.createElement('input'); - keyInput.type = 'text'; + const keyInput = document.createElement("input"); + keyInput.type = "text"; keyInput.className = ManagerViewportPanels.CLASS_INPUT_FIELD; keyInput.value = key; keyInput.setAttribute(ManagerViewportPanels.ATTR_DATA_FIELD, `network-${containerName}-key`); - const valueInput = document.createElement('input'); - valueInput.type = 'text'; + const valueInput = document.createElement("input"); + valueInput.type = "text"; valueInput.className = ManagerViewportPanels.CLASS_INPUT_FIELD; valueInput.value = value; - valueInput.setAttribute(ManagerViewportPanels.ATTR_DATA_FIELD, `network-${containerName}-value`); + valueInput.setAttribute( + ManagerViewportPanels.ATTR_DATA_FIELD, + `network-${containerName}-value` + ); - const button = document.createElement('button'); - button.type = 'button'; + const button = document.createElement("button"); + button.type = "button"; button.className = ManagerViewportPanels.CLASS_DYNAMIC_DELETE_BTN; button.innerHTML = ManagerViewportPanels.HTML_ICON_TRASH; button.onclick = () => this.removeNetworkEntry(containerName, count); @@ -233,31 +250,35 @@ export class ManagerViewportPanels { /** * Add a key-value entry for Link Editor */ - private addLinkKeyValueEntry(containerName: string, keyPlaceholder: string, valuePlaceholder: string): void { + private addLinkKeyValueEntry( + containerName: string, + keyPlaceholder: string, + valuePlaceholder: string + ): void { const container = document.getElementById(`panel-link-ext-${containerName}-container`); if (!container) return; const count = (this.linkDynamicEntryCounters.get(containerName) || 0) + 1; this.linkDynamicEntryCounters.set(containerName, count); - const entryDiv = document.createElement('div'); + const entryDiv = document.createElement("div"); entryDiv.className = ManagerViewportPanels.CLASS_DYNAMIC_ENTRY; entryDiv.id = `link-${containerName}-entry-${count}`; - const keyInput = document.createElement('input'); - keyInput.type = 'text'; + const keyInput = document.createElement("input"); + keyInput.type = "text"; keyInput.className = ManagerViewportPanels.CLASS_INPUT_FIELD; keyInput.placeholder = keyPlaceholder; keyInput.setAttribute(ManagerViewportPanels.ATTR_DATA_FIELD, `link-${containerName}-key`); - const valueInput = document.createElement('input'); - valueInput.type = 'text'; + const valueInput = document.createElement("input"); + valueInput.type = "text"; valueInput.className = ManagerViewportPanels.CLASS_INPUT_FIELD; valueInput.placeholder = valuePlaceholder; valueInput.setAttribute(ManagerViewportPanels.ATTR_DATA_FIELD, `link-${containerName}-value`); - const button = document.createElement('button'); - button.type = 'button'; + const button = document.createElement("button"); + button.type = "button"; button.className = ManagerViewportPanels.CLASS_DYNAMIC_DELETE_BTN; button.innerHTML = ManagerViewportPanels.HTML_ICON_TRASH; button.onclick = () => this.removeLinkEntry(containerName, count); @@ -278,24 +299,24 @@ export class ManagerViewportPanels { const count = (this.linkDynamicEntryCounters.get(containerName) || 0) + 1; this.linkDynamicEntryCounters.set(containerName, count); - const entryDiv = document.createElement('div'); + const entryDiv = document.createElement("div"); entryDiv.className = ManagerViewportPanels.CLASS_DYNAMIC_ENTRY; entryDiv.id = `link-${containerName}-entry-${count}`; - const keyInput = document.createElement('input'); - keyInput.type = 'text'; + const keyInput = document.createElement("input"); + keyInput.type = "text"; keyInput.className = ManagerViewportPanels.CLASS_INPUT_FIELD; keyInput.value = key; keyInput.setAttribute(ManagerViewportPanels.ATTR_DATA_FIELD, `link-${containerName}-key`); - const valueInput = document.createElement('input'); - valueInput.type = 'text'; + const valueInput = document.createElement("input"); + valueInput.type = "text"; valueInput.className = ManagerViewportPanels.CLASS_INPUT_FIELD; valueInput.value = value; valueInput.setAttribute(ManagerViewportPanels.ATTR_DATA_FIELD, `link-${containerName}-value`); - const button = document.createElement('button'); - button.type = 'button'; + const button = document.createElement("button"); + button.type = "button"; button.className = ManagerViewportPanels.CLASS_DYNAMIC_DELETE_BTN; button.innerHTML = ManagerViewportPanels.HTML_ICON_TRASH; button.onclick = () => this.removeLinkEntry(containerName, count); @@ -325,12 +346,12 @@ export class ManagerViewportPanels { // Create a timeout promise that rejects after 2 seconds const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Docker image refresh timeout')), 2000); + setTimeout(() => reject(new Error("Docker image refresh timeout")), 2000); }); // Race between the refresh and timeout const response: any = await Promise.race([ - messageSender.sendMessageToVscodeEndpointPost('refresh-docker-images', {}), + messageSender.sendMessageToVscodeEndpointPost("refresh-docker-images", {}), timeoutPromise ]); @@ -349,15 +370,12 @@ export class ManagerViewportPanels { * @param saveManager - The ManagerSaveTopo instance. * @param cy - The Cytoscape instance. */ - constructor( - saveManager: ManagerSaveTopo, - cy: cytoscape.Core - ) { - this.saveManager = saveManager; - this.cy = cy; - this.initializeDynamicEntryHandlers(); // Initialize dynamic entry handlers - this.toggleHidePanels("cy"); // Initialize the toggle for hiding panels. - } + constructor(saveManager: ManagerSaveTopo, cy: cytoscape.Core) { + this.saveManager = saveManager; + this.cy = cy; + this.initializeDynamicEntryHandlers(); // Initialize dynamic entry handlers + this.toggleHidePanels("cy"); // Initialize the toggle for hiding panels. + } /** * Toggle to hide UI panels. @@ -372,32 +390,38 @@ export class ManagerViewportPanels { return; } - container.addEventListener('click', async () => { - log.debug('cy container clicked'); + container.addEventListener("click", async () => { + log.debug("cy container clicked"); // Execute toggle logic only when no node or edge was clicked. if (!this.nodeClicked && !this.edgeClicked) { if (!this.isPanel01Cy) { // Remove all overlay panels. - const panelOverlays = document.getElementsByClassName(ManagerViewportPanels.CLASS_PANEL_OVERLAY); + const panelOverlays = document.getElementsByClassName( + ManagerViewportPanels.CLASS_PANEL_OVERLAY + ); for (let i = 0; i < panelOverlays.length; i++) { - (panelOverlays[i] as HTMLElement).style.display = 'none'; + (panelOverlays[i] as HTMLElement).style.display = "none"; } // Hide viewport drawers. - const viewportDrawers = document.getElementsByClassName(ManagerViewportPanels.CLASS_VIEWPORT_DRAWER); + const viewportDrawers = document.getElementsByClassName( + ManagerViewportPanels.CLASS_VIEWPORT_DRAWER + ); for (let i = 0; i < viewportDrawers.length; i++) { - (viewportDrawers[i] as HTMLElement).style.display = 'none'; + (viewportDrawers[i] as HTMLElement).style.display = "none"; } // Hide any elements with the class "ViewPortDrawer". - const viewPortDrawerElements = document.getElementsByClassName(ManagerViewportPanels.CLASS_VIEWPORT_DRAWER_ALT); + const viewPortDrawerElements = document.getElementsByClassName( + ManagerViewportPanels.CLASS_VIEWPORT_DRAWER_ALT + ); Array.from(viewPortDrawerElements).forEach((element) => { - (element as HTMLElement).style.display = 'none'; + (element as HTMLElement).style.display = "none"; }); } else { - this.removeElementById('Panel-01'); - this.appendMessage('try to remove panel01-Cy'); + this.removeElementById("Panel-01"); + this.appendMessage("try to remove panel01-Cy"); } } // Reset the click flags. @@ -411,9 +435,9 @@ export class ManagerViewportPanels { * Removes any overlay panels, updates editor fields with the node's data, * and fetches additional configuration from a JSON schema. * - * @param node - The Cytoscape node for which to show the editor. - * @returns A promise that resolves when the panel is configured. - */ + * @param node - The Cytoscape node for which to show the editor. + * @returns A promise that resolves when the panel is configured. + */ public async panelNodeEditor(node: cytoscape.NodeSingular): Promise { this.nodeClicked = true; this.panelNodeEditorNode = node; @@ -425,7 +449,7 @@ export class ManagerViewportPanels { await this.refreshDockerImages(); const url = window.schemaUrl; - if (!url) throw new Error('Schema URL is undefined.'); + if (!url) throw new Error("Schema URL is undefined."); try { const jsonData = await this.fetchNodeSchema(url); this.nodeSchemaData = jsonData; @@ -440,28 +464,30 @@ export class ManagerViewportPanels { } private hidePanelOverlays(): void { - const panelOverlays = document.getElementsByClassName(ManagerViewportPanels.CLASS_PANEL_OVERLAY); - Array.from(panelOverlays).forEach(panel => { - (panel as HTMLElement).style.display = 'none'; + const panelOverlays = document.getElementsByClassName( + ManagerViewportPanels.CLASS_PANEL_OVERLAY + ); + Array.from(panelOverlays).forEach((panel) => { + (panel as HTMLElement).style.display = "none"; }); } private populateNodeEditorBasics(node: cytoscape.NodeSingular): void { - log.debug(`panelNodeEditor - node ID: ${node.data('id')}`); - const idLabel = document.getElementById('panel-node-editor-id'); - if (idLabel) idLabel.textContent = node.data('id'); - const nameInput = document.getElementById('node-name') as HTMLInputElement; - if (nameInput) nameInput.value = node.data('name'); - const extra = node.data('extraData') || {}; + log.debug(`panelNodeEditor - node ID: ${node.data("id")}`); + const idLabel = document.getElementById("panel-node-editor-id"); + if (idLabel) idLabel.textContent = node.data("id"); + const nameInput = document.getElementById("node-name") as HTMLInputElement; + if (nameInput) nameInput.value = node.data("name"); + const extra = node.data("extraData") || {}; this.panelNodeEditorKind = extra.kind || this.panelNodeEditorKind; - this.panelNodeEditorType = extra.type || ''; + this.panelNodeEditorType = extra.type || ""; this.panelNodeEditorUseDropdownForType = false; - this.panelNodeEditorTopoViewerRole = node.data('topoViewerRole') || 'pe'; + this.panelNodeEditorTopoViewerRole = node.data("topoViewerRole") || "pe"; } private showNodeEditorPanel(): HTMLElement | null { - const panel = document.getElementById('panel-node-editor'); - if (panel) panel.style.display = 'block'; + const panel = document.getElementById("panel-node-editor"); + if (panel) panel.style.display = "block"; return panel; } @@ -476,32 +502,35 @@ export class ManagerViewportPanels { private populateKindAndType(jsonData: any): void { const { kindOptions } = this.panelNodeEditorGetKindEnums(jsonData); this.panelNodeEditorPopulateKindDropdown(kindOptions); - const typeOptions = this.panelNodeEditorGetTypeEnumsByKindPattern(jsonData, `(${this.panelNodeEditorKind})`); + const typeOptions = this.panelNodeEditorGetTypeEnumsByKindPattern( + jsonData, + `(${this.panelNodeEditorKind})` + ); this.panelNodeEditorSetupTypeField(typeOptions); } private populateIconDropdown(nodeIcons: string[]): void { - const iconContainer = document.getElementById('panel-node-topoviewerrole-dropdown-container'); + const iconContainer = document.getElementById("panel-node-topoviewerrole-dropdown-container"); if (!iconContainer) { - log.error('Icon dropdown container not found in DOM!'); + log.error("Icon dropdown container not found in DOM!"); return; } this.panelNodeEditorPopulateTopoViewerRoleDropdown(nodeIcons); } private registerNodeEditorButtons(panel: HTMLElement | null, node: cytoscape.NodeSingular): void { - const closeBtn = document.getElementById('panel-node-editor-cancel'); + const closeBtn = document.getElementById("panel-node-editor-cancel"); if (closeBtn && panel) { - closeBtn.addEventListener('click', () => { - panel.style.display = 'none'; + closeBtn.addEventListener("click", () => { + panel.style.display = "none"; }); } - const saveBtn = document.getElementById('panel-node-editor-save'); + const saveBtn = document.getElementById("panel-node-editor-save"); if (saveBtn) { const newSave = saveBtn.cloneNode(true) as HTMLElement; saveBtn.parentNode?.replaceChild(newSave, saveBtn); - newSave.addEventListener('click', async () => { + newSave.addEventListener("click", async () => { await this.updateNodeFromEditor(node); await this.saveManager.saveTopo(this.cy, false); }); @@ -509,18 +538,25 @@ export class ManagerViewportPanels { } /** - * Updates the network editor fields based on the selected network type. - * @param networkType - The selected network type. - */ + * Updates the network editor fields based on the selected network type. + * @param networkType - The selected network type. + */ private updateNetworkEditorFields(networkType: string): void { - const interfaceInput = document.getElementById(ManagerViewportPanels.ID_NETWORK_INTERFACE) as HTMLInputElement | null; - const interfaceLabel = Array.from(document.querySelectorAll('.vscode-label')).find(el => - el.textContent?.includes(ManagerViewportPanels.LABEL_INTERFACE) || el.textContent === ManagerViewportPanels.LABEL_BRIDGE_NAME + const interfaceInput = document.getElementById( + ManagerViewportPanels.ID_NETWORK_INTERFACE + ) as HTMLInputElement | null; + const interfaceLabel = Array.from(document.querySelectorAll(".vscode-label")).find( + (el) => + el.textContent?.includes(ManagerViewportPanels.LABEL_INTERFACE) || + el.textContent === ManagerViewportPanels.LABEL_BRIDGE_NAME ); - const interfaceSection = interfaceInput?.closest('.form-group') as HTMLElement | null; + const interfaceSection = interfaceInput?.closest(".form-group") as HTMLElement | null; const cfg = this.getInterfaceFieldConfig(networkType); - if (interfaceSection) interfaceSection.style.display = cfg.showInterface ? ManagerViewportPanels.DISPLAY_BLOCK : ManagerViewportPanels.DISPLAY_NONE; + if (interfaceSection) + interfaceSection.style.display = cfg.showInterface + ? ManagerViewportPanels.DISPLAY_BLOCK + : ManagerViewportPanels.DISPLAY_NONE; if (interfaceLabel) interfaceLabel.textContent = cfg.label; if (interfaceInput) interfaceInput.placeholder = cfg.placeholder; @@ -528,32 +564,60 @@ export class ManagerViewportPanels { this.toggleBridgeAliasLabelSection(networkType); } - private getInterfaceFieldConfig(networkType: string): { label: string; placeholder: string; showInterface: boolean } { + private getInterfaceFieldConfig(networkType: string): { + label: string; + placeholder: string; + showInterface: boolean; + } { const base: { label: string; placeholder: string; showInterface: boolean } = { label: ManagerViewportPanels.LABEL_INTERFACE, - placeholder: 'Enter interface name', - showInterface: true, + placeholder: "Enter interface name", + showInterface: true }; const map: Record> = { - [ManagerViewportPanels.TYPE_BRIDGE]: { label: ManagerViewportPanels.LABEL_BRIDGE_NAME, placeholder: 'Enter bridge name' }, - [ManagerViewportPanels.TYPE_OVS_BRIDGE]: { label: ManagerViewportPanels.LABEL_BRIDGE_NAME, placeholder: 'Enter bridge name' }, + [ManagerViewportPanels.TYPE_BRIDGE]: { + label: ManagerViewportPanels.LABEL_BRIDGE_NAME, + placeholder: "Enter bridge name" + }, + [ManagerViewportPanels.TYPE_OVS_BRIDGE]: { + label: ManagerViewportPanels.LABEL_BRIDGE_NAME, + placeholder: "Enter bridge name" + }, [ManagerViewportPanels.TYPE_DUMMY]: { showInterface: false }, - [ManagerViewportPanels.TYPE_HOST]: { label: ManagerViewportPanels.LABEL_HOST_INTERFACE, placeholder: 'e.g., eth0, eth1' }, - [ManagerViewportPanels.TYPE_MGMT]: { label: ManagerViewportPanels.LABEL_HOST_INTERFACE, placeholder: 'e.g., eth0, eth1' }, - [ManagerViewportPanels.TYPE_MACVLAN]: { label: ManagerViewportPanels.LABEL_HOST_INTERFACE, placeholder: 'Parent interface (e.g., eth0)' }, - [ManagerViewportPanels.TYPE_VXLAN]: { label: ManagerViewportPanels.LABEL_INTERFACE, placeholder: 'VXLAN interface name' }, - [ManagerViewportPanels.TYPE_VXLAN_STITCH]: { label: ManagerViewportPanels.LABEL_INTERFACE, placeholder: 'VXLAN interface name' } + [ManagerViewportPanels.TYPE_HOST]: { + label: ManagerViewportPanels.LABEL_HOST_INTERFACE, + placeholder: "e.g., eth0, eth1" + }, + [ManagerViewportPanels.TYPE_MGMT]: { + label: ManagerViewportPanels.LABEL_HOST_INTERFACE, + placeholder: "e.g., eth0, eth1" + }, + [ManagerViewportPanels.TYPE_MACVLAN]: { + label: ManagerViewportPanels.LABEL_HOST_INTERFACE, + placeholder: "Parent interface (e.g., eth0)" + }, + [ManagerViewportPanels.TYPE_VXLAN]: { + label: ManagerViewportPanels.LABEL_INTERFACE, + placeholder: "VXLAN interface name" + }, + [ManagerViewportPanels.TYPE_VXLAN_STITCH]: { + label: ManagerViewportPanels.LABEL_INTERFACE, + placeholder: "VXLAN interface name" + } }; return { ...base, ...(map[networkType] || {}) }; } private toggleExtendedSections(networkType: string): void { - const modeSection = document.getElementById('panel-network-mode-section') as HTMLElement | null; - const vxlanSection = document.getElementById('panel-network-vxlan-section') as HTMLElement | null; + const modeSection = document.getElementById("panel-network-mode-section") as HTMLElement | null; + const vxlanSection = document.getElementById( + "panel-network-vxlan-section" + ) as HTMLElement | null; if (modeSection) - modeSection.style.display = (networkType === ManagerViewportPanels.TYPE_MACVLAN) - ? ManagerViewportPanels.DISPLAY_BLOCK - : ManagerViewportPanels.DISPLAY_NONE; + modeSection.style.display = + networkType === ManagerViewportPanels.TYPE_MACVLAN + ? ManagerViewportPanels.DISPLAY_BLOCK + : ManagerViewportPanels.DISPLAY_NONE; if (vxlanSection) vxlanSection.style.display = ManagerViewportPanels.VX_TYPES.includes(networkType as any) ? ManagerViewportPanels.DISPLAY_BLOCK @@ -561,11 +625,15 @@ export class ManagerViewportPanels { } private toggleBridgeAliasLabelSection(networkType: string): void { - const labelInput = document.getElementById(ManagerViewportPanels.ID_NETWORK_LABEL) as HTMLInputElement | null; - const labelGroup = labelInput?.closest('.form-group') as HTMLElement | null; + const labelInput = document.getElementById( + ManagerViewportPanels.ID_NETWORK_LABEL + ) as HTMLInputElement | null; + const labelGroup = labelInput?.closest(".form-group") as HTMLElement | null; if (labelGroup) { const show = ManagerViewportPanels.BRIDGE_TYPES.includes(networkType as any); - labelGroup.style.display = show ? ManagerViewportPanels.DISPLAY_BLOCK : ManagerViewportPanels.DISPLAY_NONE; + labelGroup.style.display = show + ? ManagerViewportPanels.DISPLAY_BLOCK + : ManagerViewportPanels.DISPLAY_NONE; } } @@ -585,7 +653,9 @@ export class ManagerViewportPanels { // Re-validate when network type changes setTimeout(() => { const { isValid } = this.validateNetworkFields(selectedValue); - const saveButton = document.getElementById(ManagerViewportPanels.ID_NETWORK_SAVE_BUTTON) as HTMLButtonElement; + const saveButton = document.getElementById( + ManagerViewportPanels.ID_NETWORK_SAVE_BUTTON + ) as HTMLButtonElement; if (saveButton) { saveButton.disabled = !isValid; saveButton.classList.toggle(ManagerViewportPanels.CLASS_OPACITY_50, !isValid); @@ -603,9 +673,13 @@ export class ManagerViewportPanels { private configureInterfaceField(networkType: string, interfaceName: string): void { if (networkType === ManagerViewportPanels.TYPE_DUMMY) return; // Dummy nodes don't have interfaces - const interfaceInput = document.getElementById(ManagerViewportPanels.ID_NETWORK_INTERFACE) as HTMLInputElement | null; - const interfaceLabel = Array.from(document.querySelectorAll('.vscode-label')).find(el => - el.textContent === ManagerViewportPanels.LABEL_INTERFACE || el.textContent === ManagerViewportPanels.LABEL_BRIDGE_NAME + const interfaceInput = document.getElementById( + ManagerViewportPanels.ID_NETWORK_INTERFACE + ) as HTMLInputElement | null; + const interfaceLabel = Array.from(document.querySelectorAll(".vscode-label")).find( + (el) => + el.textContent === ManagerViewportPanels.LABEL_INTERFACE || + el.textContent === ManagerViewportPanels.LABEL_BRIDGE_NAME ); const isBridge = ManagerViewportPanels.BRIDGE_TYPES.includes(networkType as any); @@ -629,26 +703,26 @@ export class ManagerViewportPanels { this.resetNetworkDynamicEntries(); - this.setInputValue('panel-network-mac', extraData.extMac ?? extraFallback.extMac); - this.setInputValue('panel-network-mtu', extraData.extMtu ?? extraFallback.extMtu); - const modeSelect = document.getElementById('panel-network-mode') as HTMLSelectElement | null; + this.setInputValue("panel-network-mac", extraData.extMac ?? extraFallback.extMac); + this.setInputValue("panel-network-mtu", extraData.extMtu ?? extraFallback.extMtu); + const modeSelect = document.getElementById("panel-network-mode") as HTMLSelectElement | null; if (modeSelect) modeSelect.value = extraData.extMode || ManagerViewportPanels.TYPE_BRIDGE; this.setInputValue(ManagerViewportPanels.ID_NETWORK_REMOTE, extraData.extRemote); this.setInputValue(ManagerViewportPanels.ID_NETWORK_VNI, extraData.extVni); this.setInputValue(ManagerViewportPanels.ID_NETWORK_UDP_PORT, extraData.extUdpPort); - this.loadNetworkDynamicEntries('vars', extraData.extVars || extraFallback.extVars); - this.loadNetworkDynamicEntries('labels', extraData.extLabels || extraFallback.extLabels); + this.loadNetworkDynamicEntries("vars", extraData.extVars || extraFallback.extVars); + this.loadNetworkDynamicEntries("labels", extraData.extLabels || extraFallback.extLabels); } private getNetworkExtraFallback(node: cytoscape.NodeSingular, extraData: any): any { if (extraData.extMac || extraData.extMtu || extraData.extVars || extraData.extLabels) return {}; const edges = node.connectedEdges(); for (const e of edges) { - const ed = e.data('extraData') || {}; + const ed = e.data("extraData") || {}; const fb: any = {}; if (ed.extMac) fb.extMac = ed.extMac; - if (ed.extMtu !== undefined && ed.extMtu !== '') fb.extMtu = ed.extMtu; + if (ed.extMtu !== undefined && ed.extMtu !== "") fb.extMtu = ed.extMtu; if (ed.extVars) fb.extVars = ed.extVars; if (ed.extLabels) fb.extLabels = ed.extLabels; if (Object.keys(fb).length) return fb; @@ -658,19 +732,19 @@ export class ManagerViewportPanels { private setInputValue(id: string, value: any): void { const input = document.getElementById(id) as HTMLInputElement | null; - if (input) input.value = value != null ? String(value) : ''; + if (input) input.value = value != null ? String(value) : ""; } private resetNetworkDynamicEntries(): void { - const varsContainer = document.getElementById('panel-network-vars-container'); - const labelsContainer = document.getElementById('panel-network-labels-container'); - if (varsContainer) varsContainer.innerHTML = ''; - if (labelsContainer) labelsContainer.innerHTML = ''; + const varsContainer = document.getElementById("panel-network-vars-container"); + const labelsContainer = document.getElementById("panel-network-labels-container"); + if (varsContainer) varsContainer.innerHTML = ""; + if (labelsContainer) labelsContainer.innerHTML = ""; this.networkDynamicEntryCounters.clear(); } - private loadNetworkDynamicEntries(type: 'vars' | 'labels', data?: Record): void { - if (!data || typeof data !== 'object') return; + private loadNetworkDynamicEntries(type: "vars" | "labels", data?: Record): void { + if (!data || typeof data !== "object") return; Object.entries(data).forEach(([key, value]) => { this.addNetworkKeyValueEntryWithValue(type, key, String(value)); }); @@ -679,14 +753,20 @@ export class ManagerViewportPanels { /** * Set up validation listeners and save button behavior for the network editor. */ - private setupNetworkValidation(networkType: string, node: cytoscape.NodeSingular): void { + private setupNetworkValidation( + networkType: string, + node: cytoscape.NodeSingular, + panel: HTMLElement | null + ): void { const vxlanInputs = ManagerViewportPanels.VXLAN_INPUT_IDS; - vxlanInputs.forEach(inputId => { + vxlanInputs.forEach((inputId) => { const input = document.getElementById(inputId) as HTMLInputElement; if (input) { - input.addEventListener('input', () => { + input.addEventListener("input", () => { const { isValid } = this.validateNetworkFields(networkType); - const saveButton = document.getElementById(ManagerViewportPanels.ID_NETWORK_SAVE_BUTTON) as HTMLButtonElement; + const saveButton = document.getElementById( + ManagerViewportPanels.ID_NETWORK_SAVE_BUTTON + ) as HTMLButtonElement; if (saveButton) { saveButton.disabled = !isValid; saveButton.classList.toggle(ManagerViewportPanels.CLASS_OPACITY_50, !isValid); @@ -706,16 +786,54 @@ export class ManagerViewportPanels { newSaveBtn.classList.toggle(ManagerViewportPanels.CLASS_OPACITY_50, !initialValid); newSaveBtn.classList.toggle(ManagerViewportPanels.CLASS_CURSOR_NOT_ALLOWED, !initialValid); - newSaveBtn.addEventListener('click', async () => { + // OK button (save and close) + newSaveBtn.addEventListener("click", async () => { const { isValid, errors } = this.validateNetworkFields(networkType, true); if (!isValid) { - console.error('Cannot save network node:', errors); + console.error("Cannot save network node:", errors); return; } await this.updateNetworkFromEditor(node); const suppressNotification = false; await this.saveManager.saveTopo(this.cy, suppressNotification); + if (panel) panel.style.display = "none"; + }); + } + + // Apply button (save without closing) + const applyBtn = document.getElementById("panel-network-editor-apply-button"); + if (applyBtn) { + const newApplyBtn = applyBtn.cloneNode(true) as HTMLElement; + applyBtn.parentNode?.replaceChild(newApplyBtn, applyBtn); + + newApplyBtn.addEventListener("click", async () => { + const { isValid, errors } = this.validateNetworkFields(networkType, true); + if (!isValid) { + console.error("Cannot apply network node changes:", errors); + return; + } + + await this.updateNetworkFromEditor(node); + const suppressNotification = false; + await this.saveManager.saveTopo(this.cy, suppressNotification); + }); + + // Also handle disabled state for apply button based on validation + const updateApplyState = () => { + const { isValid } = this.validateNetworkFields(networkType); + (newApplyBtn as HTMLButtonElement).disabled = !isValid; + newApplyBtn.classList.toggle(ManagerViewportPanels.CLASS_OPACITY_50, !isValid); + newApplyBtn.classList.toggle(ManagerViewportPanels.CLASS_CURSOR_NOT_ALLOWED, !isValid); + }; + updateApplyState(); + + // Update apply button state when inputs change + vxlanInputs.forEach((inputId) => { + const input = document.getElementById(inputId) as HTMLInputElement; + if (input) { + input.addEventListener("input", updateApplyState); + } }); } } @@ -723,53 +841,62 @@ export class ManagerViewportPanels { /** * Validate network editor fields. When showErrors is true, highlight missing values. */ - private validateNetworkFields(networkType: string, showErrors = false): { isValid: boolean; errors: string[] } { - const currentType = (document.getElementById(ManagerViewportPanels.ID_NETWORK_TYPE_FILTER_INPUT) as HTMLInputElement)?.value || networkType; + private validateNetworkFields( + networkType: string, + showErrors = false + ): { isValid: boolean; errors: string[] } { + const currentType = + ( + document.getElementById( + ManagerViewportPanels.ID_NETWORK_TYPE_FILTER_INPUT + ) as HTMLInputElement + )?.value || networkType; this.clearNetworkValidationStyles(); const errors = this.collectNetworkErrors(currentType, showErrors); - if (showErrors) this.displayNetworkValidationErrors(errors); else this.hideNetworkValidationErrors(); + if (showErrors) this.displayNetworkValidationErrors(errors); + else this.hideNetworkValidationErrors(); return { isValid: errors.length === 0, errors }; } private clearNetworkValidationStyles(): void { - ManagerViewportPanels.VXLAN_INPUT_IDS.forEach(id => { - document.getElementById(id)?.classList.remove('border-red-500', 'border-2'); + ManagerViewportPanels.VXLAN_INPUT_IDS.forEach((id) => { + document.getElementById(id)?.classList.remove("border-red-500", "border-2"); }); } private collectNetworkErrors(currentType: string, showErrors: boolean): string[] { if (!(ManagerViewportPanels.VX_TYPES as readonly string[]).includes(currentType)) return []; const fields = [ - { id: ManagerViewportPanels.ID_NETWORK_REMOTE, msg: 'Remote IP is required' }, - { id: ManagerViewportPanels.ID_NETWORK_VNI, msg: 'VNI is required' }, - { id: ManagerViewportPanels.ID_NETWORK_UDP_PORT, msg: 'UDP Port is required' } + { id: ManagerViewportPanels.ID_NETWORK_REMOTE, msg: "Remote IP is required" }, + { id: ManagerViewportPanels.ID_NETWORK_VNI, msg: "VNI is required" }, + { id: ManagerViewportPanels.ID_NETWORK_UDP_PORT, msg: "UDP Port is required" } ]; const errors: string[] = []; fields.forEach(({ id, msg }) => { const el = document.getElementById(id) as HTMLInputElement; if (!el?.value?.trim()) { errors.push(msg); - if (showErrors) el?.classList.add('border-red-500', 'border-2'); + if (showErrors) el?.classList.add("border-red-500", "border-2"); } }); return errors; } private displayNetworkValidationErrors(errors: string[]): void { - const errorContainer = document.getElementById('panel-network-validation-errors'); - const errorList = document.getElementById('panel-network-validation-errors-list'); + const errorContainer = document.getElementById("panel-network-validation-errors"); + const errorList = document.getElementById("panel-network-validation-errors-list"); if (!errorContainer || !errorList) return; if (errors.length > 0) { - errorList.innerHTML = errors.map(err => `
  • ${err}
  • `).join(''); - errorContainer.style.display = 'block'; + errorList.innerHTML = errors.map((err) => `
  • ${err}
  • `).join(""); + errorContainer.style.display = "block"; } else { - errorContainer.style.display = 'none'; + errorContainer.style.display = "none"; } } private hideNetworkValidationErrors(): void { - const errorContainer = document.getElementById('panel-network-validation-errors'); - if (errorContainer) errorContainer.style.display = 'none'; + const errorContainer = document.getElementById("panel-network-validation-errors"); + if (errorContainer) errorContainer.style.display = "none"; } /** @@ -778,15 +905,15 @@ export class ManagerViewportPanels { */ public async panelNetworkEditor(node: cytoscape.NodeSingular): Promise { this.nodeClicked = true; - this.hideOverlayPanels(); + this.hidePanelOverlays(); - const nodeId = node.data('id') as string; + const nodeId = node.data("id") as string; const nodeData = node.data(); - const parts = nodeId.split(':'); + const parts = nodeId.split(":"); const networkType = nodeData.extraData?.kind || parts[0] || ManagerViewportPanels.TYPE_HOST; const interfaceName = this.getInterfaceNameForEditor(networkType, nodeId, nodeData); - const idLabel = document.getElementById('panel-network-editor-id'); + const idLabel = document.getElementById("panel-network-editor-id"); if (idLabel) idLabel.textContent = nodeId; this.initializeNetworkTypeDropdown(networkType); @@ -795,45 +922,49 @@ export class ManagerViewportPanels { this.updateNetworkEditorFields(networkType); this.setBridgeAliasLabelInput(nodeData, networkType); - const panel = document.getElementById('panel-network-editor'); - if (panel) panel.style.display = 'block'; + const panel = document.getElementById("panel-network-editor"); + if (panel) panel.style.display = "block"; - const closeBtn = document.getElementById('panel-network-editor-close-button'); + // Title bar close button + const closeBtn = document.getElementById("panel-network-editor-close"); if (closeBtn && panel) { - closeBtn.addEventListener('click', () => { panel.style.display = 'none'; }); + const freshClose = closeBtn.cloneNode(true) as HTMLElement; + closeBtn.parentNode?.replaceChild(freshClose, closeBtn); + freshClose.addEventListener("click", () => { + panel.style.display = "none"; + }); } - this.setupNetworkValidation(networkType, node); - } - - private hideOverlayPanels(): void { - const panelOverlays = document.getElementsByClassName(ManagerViewportPanels.CLASS_PANEL_OVERLAY); - Array.from(panelOverlays).forEach(panel => { (panel as HTMLElement).style.display = 'none'; }); + this.setupNetworkValidation(networkType, node, panel); } private getInterfaceNameForEditor(networkType: string, nodeId: string, nodeData: any): string { if (ManagerViewportPanels.BRIDGE_TYPES.includes(networkType as any)) { - const yamlId = nodeData?.extraData && typeof nodeData.extraData.extYamlNodeId === 'string' ? nodeData.extraData.extYamlNodeId : ''; + const yamlId = + nodeData?.extraData && typeof nodeData.extraData.extYamlNodeId === "string" + ? nodeData.extraData.extYamlNodeId + : ""; return yamlId || nodeId; } if (networkType === ManagerViewportPanels.TYPE_DUMMY) { - return ''; + return ""; } - const parts = nodeId.split(':'); - return parts[1] || 'eth1'; + const parts = nodeId.split(":"); + return parts[1] || "eth1"; } private setBridgeAliasLabelInput(nodeData: any, networkType: string): void { - const input = document.getElementById(ManagerViewportPanels.ID_NETWORK_LABEL) as HTMLInputElement | null; + const input = document.getElementById( + ManagerViewportPanels.ID_NETWORK_LABEL + ) as HTMLInputElement | null; if (!input) return; if (ManagerViewportPanels.BRIDGE_TYPES.includes(networkType as any)) { - const currentName = (nodeData && typeof nodeData.name === 'string' && nodeData.name) || ''; + const currentName = (nodeData && typeof nodeData.name === "string" && nodeData.name) || ""; input.value = currentName; } else { - input.value = ''; + input.value = ""; } } - /** * Displays the edge editor panel for the provided edge. * Removes any overlay panels, updates form fields with the edge's current source/target endpoints, @@ -847,7 +978,10 @@ export class ManagerViewportPanels { this.edgeClicked = true; this.hideAllPanels(); const elems = this.getEdgeEditorElements(); - if (!elems) { this.edgeClicked = false; return; } + if (!elems) { + this.edgeClicked = false; + return; + } const ctx = this.getEdgeContext(edge); this.showEdgePanel(elems.panel, ctx.isVethLink, elems.btnExt); @@ -856,80 +990,131 @@ export class ManagerViewportPanels { this.setupBasicTab(edge, ctx, elems.panel); await this.panelEdgeEditorExtended(edge); - setTimeout(() => { this.edgeClicked = false; }, 100); + setTimeout(() => { + this.edgeClicked = false; + }, 100); } catch (err) { - log.error(`panelEdgeEditor: unexpected error: ${err instanceof Error ? err.message : String(err)}`); + log.error( + `panelEdgeEditor: unexpected error: ${err instanceof Error ? err.message : String(err)}` + ); this.edgeClicked = false; } } private hideAllPanels(): void { const overlays = document.getElementsByClassName(ManagerViewportPanels.CLASS_PANEL_OVERLAY); - Array.from(overlays).forEach(el => (el as HTMLElement).style.display = 'none'); - } - - private getEdgeEditorElements(): { panel: HTMLElement; basicTab: HTMLElement; extTab: HTMLElement; btnBasic: HTMLElement; btnExt: HTMLElement } | null { - const panel = document.getElementById('panel-link-editor') as HTMLElement | null; - const basicTab = document.getElementById('panel-link-tab-basic') as HTMLElement | null; - const extTab = document.getElementById('panel-link-tab-extended') as HTMLElement | null; - const btnBasic = document.getElementById('panel-link-tab-btn-basic') as HTMLElement | null; - const btnExt = document.getElementById('panel-link-tab-btn-extended') as HTMLElement | null; + Array.from(overlays).forEach((el) => ((el as HTMLElement).style.display = "none")); + } + + private getEdgeEditorElements(): { + panel: HTMLElement; + basicTab: HTMLElement; + extTab: HTMLElement; + btnBasic: HTMLElement; + btnExt: HTMLElement; + } | null { + const panel = document.getElementById("panel-link-editor") as HTMLElement | null; + const basicTab = document.getElementById("panel-link-tab-basic") as HTMLElement | null; + const extTab = document.getElementById("panel-link-tab-extended") as HTMLElement | null; + const btnBasic = document.getElementById("panel-link-tab-btn-basic") as HTMLElement | null; + const btnExt = document.getElementById("panel-link-tab-btn-extended") as HTMLElement | null; if (!panel || !basicTab || !extTab || !btnBasic || !btnExt) { - log.error('panelEdgeEditor: missing unified tabbed panel elements'); + log.error("panelEdgeEditor: missing unified tabbed panel elements"); return null; } return { panel, basicTab, extTab, btnBasic, btnExt }; } private getEdgeContext(edge: cytoscape.EdgeSingular) { - const source = edge.data('source') as string; - const target = edge.data('target') as string; - const sourceEP = (edge.data('sourceEndpoint') as string) || ''; - const targetEP = (edge.data('targetEndpoint') as string) || ''; + const source = edge.data("source") as string; + const target = edge.data("target") as string; + const sourceEP = (edge.data("sourceEndpoint") as string) || ""; + const targetEP = (edge.data("targetEndpoint") as string) || ""; const sourceIsNetwork = isSpecialNodeOrBridge(source, this.cy); const targetIsNetwork = isSpecialNodeOrBridge(target, this.cy); const isVethLink = !sourceIsNetwork && !targetIsNetwork; const sourceNode = this.cy.getElementById(source); const targetNode = this.cy.getElementById(target); - const sourceIsBridge = sourceNode.length > 0 && ManagerViewportPanels.BRIDGE_TYPES.includes(sourceNode.data('extraData')?.kind as any); - const targetIsBridge = targetNode.length > 0 && ManagerViewportPanels.BRIDGE_TYPES.includes(targetNode.data('extraData')?.kind as any); - return { source, target, sourceEP, targetEP, sourceIsNetwork, targetIsNetwork, isVethLink, sourceIsBridge, targetIsBridge }; + const sourceIsBridge = + sourceNode.length > 0 && + ManagerViewportPanels.BRIDGE_TYPES.includes(sourceNode.data("extraData")?.kind as any); + const targetIsBridge = + targetNode.length > 0 && + ManagerViewportPanels.BRIDGE_TYPES.includes(targetNode.data("extraData")?.kind as any); + return { + source, + target, + sourceEP, + targetEP, + sourceIsNetwork, + targetIsNetwork, + isVethLink, + sourceIsBridge, + targetIsBridge + }; } private showEdgePanel(panel: HTMLElement, isVethLink: boolean, btnExt: HTMLElement): void { - panel.style.display = 'block'; - btnExt.style.display = isVethLink ? '' : 'none'; - } - - private setupEdgeTabs(elems: { panel: HTMLElement; basicTab: HTMLElement; extTab: HTMLElement; btnBasic: HTMLElement; btnExt: HTMLElement }, isVethLink: boolean): void { + panel.style.display = "block"; + btnExt.style.display = isVethLink ? "" : "none"; + } + + private setupEdgeTabs( + elems: { + panel: HTMLElement; + basicTab: HTMLElement; + extTab: HTMLElement; + btnBasic: HTMLElement; + btnExt: HTMLElement; + }, + isVethLink: boolean + ): void { const { basicTab, extTab, btnBasic, btnExt } = elems; - const setTab = (which: 'basic' | 'extended') => { - if (which === 'extended' && !isVethLink) which = 'basic'; - basicTab.style.display = which === 'basic' ? 'block' : 'none'; - extTab.style.display = which === 'extended' ? 'block' : 'none'; - btnBasic.classList.toggle('tab-active', which === 'basic'); - btnExt.classList.toggle('tab-active', which === 'extended'); + const setTab = (which: "basic" | "extended") => { + if (which === "extended" && !isVethLink) which = "basic"; + basicTab.style.display = which === "basic" ? "block" : "none"; + extTab.style.display = which === "extended" ? "block" : "none"; + btnBasic.classList.toggle("tab-active", which === "basic"); + btnExt.classList.toggle("tab-active", which === "extended"); }; - setTab('basic'); - btnBasic.addEventListener('click', () => setTab('basic')); - if (isVethLink) btnExt.addEventListener('click', () => setTab('extended')); + setTab("basic"); + btnBasic.addEventListener("click", () => setTab("basic")); + if (isVethLink) btnExt.addEventListener("click", () => setTab("extended")); } private populateEdgePreviews(edge: cytoscape.EdgeSingular): void { - const source = edge.data('source') as string; - const target = edge.data('target') as string; - const sourceEP = (edge.data('sourceEndpoint') as string) || ''; - const targetEP = (edge.data('targetEndpoint') as string) || ''; - const updatePreview = (el: HTMLElement | null) => { if (el) el.innerHTML = `┌▪${source} : ${sourceEP}
    └▪${target} : ${targetEP}`; }; - updatePreview(document.getElementById('panel-link-editor-id')); - updatePreview(document.getElementById('panel-link-extended-editor-id')); + const source = edge.data("source") as string; + const target = edge.data("target") as string; + const sourceEP = (edge.data("sourceEndpoint") as string) || ""; + const targetEP = (edge.data("targetEndpoint") as string) || ""; + const updatePreview = (el: HTMLElement | null) => { + if (el) el.innerHTML = `┌▪${source} : ${sourceEP}
    └▪${target} : ${targetEP}`; + }; + updatePreview(document.getElementById("panel-link-editor-id")); + updatePreview(document.getElementById("panel-link-extended-editor-id")); } private setupBasicTab(edge: cytoscape.EdgeSingular, ctx: any, panel: HTMLElement): void { - const srcInput = document.getElementById('panel-link-editor-source-endpoint') as HTMLInputElement | null; - const tgtInput = document.getElementById('panel-link-editor-target-endpoint') as HTMLInputElement | null; - this.configureEndpointInput(srcInput, ctx.sourceIsNetwork, ctx.sourceIsBridge, ctx.sourceEP, ctx.source); - this.configureEndpointInput(tgtInput, ctx.targetIsNetwork, ctx.targetIsBridge, ctx.targetEP, ctx.target); + const srcInput = document.getElementById( + "panel-link-editor-source-endpoint" + ) as HTMLInputElement | null; + const tgtInput = document.getElementById( + "panel-link-editor-target-endpoint" + ) as HTMLInputElement | null; + this.configureEndpointInput( + srcInput, + ctx.sourceIsNetwork, + ctx.sourceIsBridge, + ctx.sourceEP, + ctx.source + ); + this.configureEndpointInput( + tgtInput, + ctx.targetIsNetwork, + ctx.targetIsBridge, + ctx.targetEP, + ctx.target + ); this.setupBasicTabButtons(panel, edge, ctx, srcInput, tgtInput); } @@ -944,35 +1129,91 @@ export class ManagerViewportPanels { if (isNetwork && !isBridge) { input.value = networkName; input.readOnly = true; - input.style.backgroundColor = 'var(--vscode-input-background)'; - input.style.opacity = '0.7'; + input.style.backgroundColor = "var(--vscode-input-background)"; + input.style.opacity = "0.7"; } else { input.value = endpoint; input.readOnly = false; - input.style.backgroundColor = ''; - input.style.opacity = ''; + input.style.backgroundColor = ""; + input.style.opacity = ""; } } - private setupBasicTabButtons(panel: HTMLElement, edge: cytoscape.EdgeSingular, ctx: any, srcInput: HTMLInputElement | null, tgtInput: HTMLInputElement | null): void { - const basicClose = document.getElementById('panel-link-editor-close-button'); - if (basicClose) { - const freshClose = basicClose.cloneNode(true) as HTMLElement; - basicClose.parentNode?.replaceChild(freshClose, basicClose); - freshClose.addEventListener('click', () => { panel.style.display = 'none'; this.edgeClicked = false; }, { once: true }); + private getEndpointValues( + ctx: any, + srcInput: HTMLInputElement | null, + tgtInput: HTMLInputElement | null + ): { sourceEP: string; targetEP: string } { + const sourceEP = + ctx.sourceIsNetwork && !ctx.sourceIsBridge ? "" : srcInput?.value?.trim() || ""; + const targetEP = + ctx.targetIsNetwork && !ctx.targetIsBridge ? "" : tgtInput?.value?.trim() || ""; + return { sourceEP, targetEP }; + } + + private async saveEdgeEndpoints( + edge: cytoscape.EdgeSingular, + ctx: any, + srcInput: HTMLInputElement | null, + tgtInput: HTMLInputElement | null + ): Promise { + const { sourceEP, targetEP } = this.getEndpointValues(ctx, srcInput, tgtInput); + edge.data({ sourceEndpoint: sourceEP, targetEndpoint: targetEP }); + await this.saveManager.saveTopo(this.cy, false); + } + + private setupBasicTabButtons( + panel: HTMLElement, + edge: cytoscape.EdgeSingular, + ctx: any, + srcInput: HTMLInputElement | null, + tgtInput: HTMLInputElement | null + ): void { + // Title bar close button + const titleBarClose = document.getElementById("panel-link-editor-close"); + if (titleBarClose) { + const freshClose = titleBarClose.cloneNode(true) as HTMLElement; + titleBarClose.parentNode?.replaceChild(freshClose, titleBarClose); + freshClose.addEventListener( + "click", + () => { + panel.style.display = "none"; + this.edgeClicked = false; + }, + { once: true } + ); } + + // OK button (save and close) const basicSave = document.getElementById(ManagerViewportPanels.ID_LINK_EDITOR_SAVE_BUTTON); if (basicSave) { const freshSave = basicSave.cloneNode(true) as HTMLElement; basicSave.parentNode?.replaceChild(freshSave, basicSave); - freshSave.addEventListener('click', async () => { + freshSave.addEventListener("click", async () => { try { - const newSourceEP = (ctx.sourceIsNetwork && !ctx.sourceIsBridge) ? '' : (srcInput?.value?.trim() || ''); - const newTargetEP = (ctx.targetIsNetwork && !ctx.targetIsBridge) ? '' : (tgtInput?.value?.trim() || ''); - edge.data({ sourceEndpoint: newSourceEP, targetEndpoint: newTargetEP }); - await this.saveManager.saveTopo(this.cy, false); + await this.saveEdgeEndpoints(edge, ctx, srcInput, tgtInput); + panel.style.display = "none"; + this.edgeClicked = false; } catch (err) { - log.error(`panelEdgeEditor basic save error: ${err instanceof Error ? err.message : String(err)}`); + log.error( + `panelEdgeEditor basic save error: ${err instanceof Error ? err.message : String(err)}` + ); + } + }); + } + + // Apply button (save without closing) + const basicApply = document.getElementById("panel-link-editor-apply-button"); + if (basicApply) { + const freshApply = basicApply.cloneNode(true) as HTMLElement; + basicApply.parentNode?.replaceChild(freshApply, basicApply); + freshApply.addEventListener("click", async () => { + try { + await this.saveEdgeEndpoints(edge, ctx, srcInput, tgtInput); + } catch (err) { + log.error( + `panelEdgeEditor basic apply error: ${err instanceof Error ? err.message : String(err)}` + ); } }); } @@ -986,18 +1227,21 @@ export class ManagerViewportPanels { this.hideAllPanels(); const elements = this.getExtendedEditorElements(); - if (!elements) { this.edgeClicked = false; return; } + if (!elements) { + this.edgeClicked = false; + return; + } const { panel, idLabel, closeBtn, saveBtn } = elements; - const source = edge.data('source') as string; - const target = edge.data('target') as string; - const sourceEP = (edge.data('sourceEndpoint') as string) || ''; - const targetEP = (edge.data('targetEndpoint') as string) || ''; + const source = edge.data("source") as string; + const target = edge.data("target") as string; + const sourceEP = (edge.data("sourceEndpoint") as string) || ""; + const targetEP = (edge.data("targetEndpoint") as string) || ""; this.updateExtendedPreview(idLabel, source, target, sourceEP, targetEP); - panel.style.display = 'block'; + panel.style.display = "block"; this.setupExtendedClose(panel, closeBtn); - const extraData = edge.data('extraData') || {}; + const extraData = edge.data("extraData") || {}; const ctx = this.inferLinkContext(source, target); this.prepareExtendedFields(extraData, ctx.isVeth); const renderErrors = (errors: string[]) => this.renderExtendedErrors(errors); @@ -1005,53 +1249,101 @@ export class ManagerViewportPanels { renderErrors(validate()); this.attachExtendedValidators(validate, renderErrors); + // OK button (save and close) const freshSave = saveBtn.cloneNode(true) as HTMLElement; saveBtn.parentNode?.replaceChild(freshSave, saveBtn); - freshSave.addEventListener('click', async () => { + freshSave.addEventListener("click", async () => { await this.handleExtendedSave(edge, ctx, validate, renderErrors); + panel.style.display = "none"; + this.edgeClicked = false; }); - setTimeout(() => { this.edgeClicked = false; }, 100); - } + // Apply button (save without closing) + const applyBtn = document.getElementById("panel-link-editor-apply-button"); + if (applyBtn) { + const freshApply = applyBtn.cloneNode(true) as HTMLElement; + applyBtn.parentNode?.replaceChild(freshApply, applyBtn); + freshApply.addEventListener("click", async () => { + await this.handleExtendedSave(edge, ctx, validate, renderErrors); + }); + } - private getExtendedEditorElements(): { panel: HTMLElement; idLabel: HTMLElement; closeBtn: HTMLElement; saveBtn: HTMLElement } | null { - const panel = document.getElementById('panel-link-editor') as HTMLElement | null; - const idLabel = document.getElementById('panel-link-extended-editor-id') as HTMLElement | null; - const closeBtn = document.getElementById('panel-link-editor-close-button') as HTMLElement | null; - const saveBtn = document.getElementById(ManagerViewportPanels.ID_LINK_EDITOR_SAVE_BUTTON) as HTMLElement | null; + setTimeout(() => { + this.edgeClicked = false; + }, 100); + } + + private getExtendedEditorElements(): { + panel: HTMLElement; + idLabel: HTMLElement; + closeBtn: HTMLElement; + saveBtn: HTMLElement; + } | null { + const panel = document.getElementById("panel-link-editor") as HTMLElement | null; + const idLabel = document.getElementById("panel-link-extended-editor-id") as HTMLElement | null; + const closeBtn = document.getElementById("panel-link-editor-close") as HTMLElement | null; + const saveBtn = document.getElementById( + ManagerViewportPanels.ID_LINK_EDITOR_SAVE_BUTTON + ) as HTMLElement | null; if (!panel || !idLabel || !closeBtn || !saveBtn) { - log.error('panelEdgeEditorExtended: missing required DOM elements'); + log.error("panelEdgeEditorExtended: missing required DOM elements"); return null; } return { panel, idLabel, closeBtn, saveBtn }; } - private updateExtendedPreview(labelEl: HTMLElement, source: string, target: string, sourceEP: string, targetEP: string): void { + private updateExtendedPreview( + labelEl: HTMLElement, + source: string, + target: string, + sourceEP: string, + targetEP: string + ): void { labelEl.innerHTML = `┌▪${source} : ${sourceEP}
    └▪${target} : ${targetEP}`; } private setupExtendedClose(panel: HTMLElement, closeBtn: HTMLElement): void { const freshClose = closeBtn.cloneNode(true) as HTMLElement; closeBtn.parentNode?.replaceChild(freshClose, closeBtn); - freshClose.addEventListener('click', () => { panel.style.display = 'none'; this.edgeClicked = false; }, { once: true }); + freshClose.addEventListener( + "click", + () => { + panel.style.display = "none"; + this.edgeClicked = false; + }, + { once: true } + ); } - private inferLinkContext(source: string, target: string): { inferredType: string; isVeth: boolean } { + private inferLinkContext( + source: string, + target: string + ): { inferredType: string; isVeth: boolean } { const special = (id: string): string | null => { - if (id === ManagerViewportPanels.TYPE_HOST || id.startsWith(`${ManagerViewportPanels.TYPE_HOST}:`)) return ManagerViewportPanels.TYPE_HOST; - if (id === ManagerViewportPanels.TYPE_MGMT || id.startsWith(`${ManagerViewportPanels.TYPE_MGMT}:`)) return ManagerViewportPanels.TYPE_MGMT; - if (id.startsWith('macvlan:')) return 'macvlan'; - if (id.startsWith('vxlan:')) return ManagerViewportPanels.TYPE_VXLAN; - if (id.startsWith('vxlan-stitch:')) return ManagerViewportPanels.TYPE_VXLAN_STITCH; - if (id.startsWith('dummy')) return ManagerViewportPanels.TYPE_DUMMY; + if ( + id === ManagerViewportPanels.TYPE_HOST || + id.startsWith(`${ManagerViewportPanels.TYPE_HOST}:`) + ) + return ManagerViewportPanels.TYPE_HOST; + if ( + id === ManagerViewportPanels.TYPE_MGMT || + id.startsWith(`${ManagerViewportPanels.TYPE_MGMT}:`) + ) + return ManagerViewportPanels.TYPE_MGMT; + if (id.startsWith("macvlan:")) return "macvlan"; + if (id.startsWith("vxlan:")) return ManagerViewportPanels.TYPE_VXLAN; + if (id.startsWith("vxlan-stitch:")) return ManagerViewportPanels.TYPE_VXLAN_STITCH; + if (id.startsWith("dummy")) return ManagerViewportPanels.TYPE_DUMMY; return null; }; const sourceType = special(source); const targetType = special(target); - const inferredType = sourceType || targetType || 'veth'; - const typeDisplayEl = document.getElementById('panel-link-ext-type-display') as HTMLElement | null; + const inferredType = sourceType || targetType || "veth"; + const typeDisplayEl = document.getElementById( + "panel-link-ext-type-display" + ) as HTMLElement | null; if (typeDisplayEl) typeDisplayEl.textContent = inferredType; - return { inferredType, isVeth: inferredType === 'veth' }; + return { inferredType, isVeth: inferredType === "veth" }; } private prepareExtendedFields(extraData: any, isVeth: boolean): void { @@ -1062,15 +1354,17 @@ export class ManagerViewportPanels { } private resetExtendedDynamicContainers(): void { - const varsContainer = document.getElementById('panel-link-ext-vars-container'); - const labelsContainer = document.getElementById('panel-link-ext-labels-container'); - if (varsContainer) varsContainer.innerHTML = ''; - if (labelsContainer) labelsContainer.innerHTML = ''; + const varsContainer = document.getElementById("panel-link-ext-vars-container"); + const labelsContainer = document.getElementById("panel-link-ext-labels-container"); + if (varsContainer) varsContainer.innerHTML = ""; + if (labelsContainer) labelsContainer.innerHTML = ""; this.linkDynamicEntryCounters.clear(); } private setNonVethInfoVisibility(isVeth: boolean): void { - const nonVethInfo = document.getElementById('panel-link-ext-non-veth-info') as HTMLElement | null; + const nonVethInfo = document.getElementById( + "panel-link-ext-non-veth-info" + ) as HTMLElement | null; if (nonVethInfo) nonVethInfo.style.display = isVeth ? ManagerViewportPanels.DISPLAY_NONE @@ -1078,32 +1372,36 @@ export class ManagerViewportPanels { } private setMacAndMtu(extraData: any, isVeth: boolean): void { - const srcMacEl = document.getElementById('panel-link-ext-src-mac') as HTMLInputElement | null; - const tgtMacEl = document.getElementById('panel-link-ext-tgt-mac') as HTMLInputElement | null; - const mtuEl = document.getElementById(ManagerViewportPanels.ID_LINK_EXT_MTU) as HTMLInputElement | null; - if (srcMacEl) srcMacEl.value = extraData.extSourceMac || ''; - if (tgtMacEl) tgtMacEl.value = extraData.extTargetMac || ''; - if (isVeth && mtuEl) mtuEl.value = extraData.extMtu != null ? String(extraData.extMtu) : ''; + const srcMacEl = document.getElementById("panel-link-ext-src-mac") as HTMLInputElement | null; + const tgtMacEl = document.getElementById("panel-link-ext-tgt-mac") as HTMLInputElement | null; + const mtuEl = document.getElementById( + ManagerViewportPanels.ID_LINK_EXT_MTU + ) as HTMLInputElement | null; + if (srcMacEl) srcMacEl.value = extraData.extSourceMac || ""; + if (tgtMacEl) tgtMacEl.value = extraData.extTargetMac || ""; + if (isVeth && mtuEl) mtuEl.value = extraData.extMtu != null ? String(extraData.extMtu) : ""; } private populateExtendedKeyValues(extraData: any): void { - if (extraData.extVars && typeof extraData.extVars === 'object') { + if (extraData.extVars && typeof extraData.extVars === "object") { Object.entries(extraData.extVars).forEach(([k, v]) => - this.addLinkKeyValueEntryWithValue('vars', k, String(v)) + this.addLinkKeyValueEntryWithValue("vars", k, String(v)) ); } - if (extraData.extLabels && typeof extraData.extLabels === 'object') { + if (extraData.extLabels && typeof extraData.extLabels === "object") { Object.entries(extraData.extLabels).forEach(([k, v]) => - this.addLinkKeyValueEntryWithValue('labels', k, String(v)) + this.addLinkKeyValueEntryWithValue("labels", k, String(v)) ); } } private renderExtendedErrors(errors: string[]): void { - const banner = document.getElementById('panel-link-ext-errors') as HTMLElement | null; - const bannerList = document.getElementById('panel-link-ext-errors-list') as HTMLElement | null; + const banner = document.getElementById("panel-link-ext-errors") as HTMLElement | null; + const bannerList = document.getElementById("panel-link-ext-errors-list") as HTMLElement | null; const setSaveDisabled = (disabled: boolean) => { - const btn = document.getElementById(ManagerViewportPanels.ID_LINK_EDITOR_SAVE_BUTTON) as HTMLButtonElement | null; + const btn = document.getElementById( + ManagerViewportPanels.ID_LINK_EDITOR_SAVE_BUTTON + ) as HTMLButtonElement | null; if (!btn) return; btn.disabled = disabled; btn.classList.toggle(ManagerViewportPanels.CLASS_OPACITY_50, disabled); @@ -1111,21 +1409,21 @@ export class ManagerViewportPanels { }; if (!banner || !bannerList) return; if (!errors.length) { - banner.style.display = 'none'; - bannerList.innerHTML = ''; + banner.style.display = "none"; + bannerList.innerHTML = ""; setSaveDisabled(false); return; } - banner.style.display = 'block'; + banner.style.display = "block"; const labels: Record = { - 'missing-host-interface': 'Host Interface is required for this type', - 'missing-remote': 'Remote (VTEP IP) is required', - 'missing-vni': 'VNI is required', - 'missing-udp-port': 'UDP Port is required', - 'invalid-veth-endpoints': 'veth requires two endpoints with node and interface', - 'invalid-endpoint': 'Endpoint with node and interface is required', + "missing-host-interface": "Host Interface is required for this type", + "missing-remote": "Remote (VTEP IP) is required", + "missing-vni": "VNI is required", + "missing-udp-port": "UDP Port is required", + "invalid-veth-endpoints": "veth requires two endpoints with node and interface", + "invalid-endpoint": "Endpoint with node and interface is required" }; - bannerList.innerHTML = errors.map(e => `• ${labels[e] || e}`).join('
    '); + bannerList.innerHTML = errors.map((e) => `• ${labels[e] || e}`).join("
    "); setSaveDisabled(true); } @@ -1134,17 +1432,24 @@ export class ManagerViewportPanels { return []; } - // eslint-disable-next-line no-unused-vars - private attachExtendedValidators(validate: () => string[], renderErrors: (errors: string[]) => void): void { + private attachExtendedValidators( + validate: () => string[], + // eslint-disable-next-line no-unused-vars + renderErrors: (errors: string[]) => void + ): void { const mtuEl = document.getElementById(ManagerViewportPanels.ID_LINK_EXT_MTU); - const attach = (el: HTMLElement | null) => { if (el) { el.addEventListener('input', () => renderErrors(validate())); } }; + const attach = (el: HTMLElement | null) => { + if (el) { + el.addEventListener("input", () => renderErrors(validate())); + } + }; attach(mtuEl as HTMLElement); } private collectDynamicEntries(prefix: string): Record { const entries = document.querySelectorAll(`[id^="${prefix}-entry-"]`); const parsed: Record = {}; - entries.forEach(entry => { + entries.forEach((entry) => { const keyInput = entry.querySelector(`[data-field="${prefix}-key"]`) as HTMLInputElement; const valueInput = entry.querySelector(`[data-field="${prefix}-value"]`) as HTMLInputElement; if (keyInput && valueInput && keyInput.value.trim()) { @@ -1154,12 +1459,20 @@ export class ManagerViewportPanels { return parsed; } - // eslint-disable-next-line no-unused-vars - private async handleExtendedSave(edge: cytoscape.EdgeSingular, ctx: { inferredType: string; isVeth: boolean }, validate: () => string[], renderErrors: (errors: string[]) => void): Promise { + private async handleExtendedSave( + edge: cytoscape.EdgeSingular, + ctx: { inferredType: string; isVeth: boolean }, + validate: () => string[], + // eslint-disable-next-line no-unused-vars + renderErrors: (errors: string[]) => void + ): Promise { try { this.updateEdgeEndpoints(edge); const errsNow = validate(); - if (errsNow.length) { renderErrors(errsNow); return; } + if (errsNow.length) { + renderErrors(errsNow); + return; + } const current = edge.data(); if (!ctx.isVeth) { @@ -1171,37 +1484,45 @@ export class ManagerViewportPanels { edge.data({ ...current, extraData: updatedExtra }); await this.saveManager.saveTopo(this.cy, false); } catch (err) { - log.error(`panelEdgeEditorExtended: error during save: ${err instanceof Error ? err.message : String(err)}`); + log.error( + `panelEdgeEditorExtended: error during save: ${err instanceof Error ? err.message : String(err)}` + ); } } private updateEdgeEndpoints(edge: cytoscape.EdgeSingular): void { - const source = edge.data('source') as string; - const target = edge.data('target') as string; - const srcInput = document.getElementById('panel-link-editor-source-endpoint') as HTMLInputElement | null; - const tgtInput = document.getElementById('panel-link-editor-target-endpoint') as HTMLInputElement | null; - const newSourceEP = this.shouldClearEndpoint(source) ? '' : (srcInput?.value?.trim() || ''); - const newTargetEP = this.shouldClearEndpoint(target) ? '' : (tgtInput?.value?.trim() || ''); + const source = edge.data("source") as string; + const target = edge.data("target") as string; + const srcInput = document.getElementById( + "panel-link-editor-source-endpoint" + ) as HTMLInputElement | null; + const tgtInput = document.getElementById( + "panel-link-editor-target-endpoint" + ) as HTMLInputElement | null; + const newSourceEP = this.shouldClearEndpoint(source) ? "" : srcInput?.value?.trim() || ""; + const newTargetEP = this.shouldClearEndpoint(target) ? "" : tgtInput?.value?.trim() || ""; edge.data({ sourceEndpoint: newSourceEP, targetEndpoint: newTargetEP }); } private shouldClearEndpoint(nodeId: string): boolean { if (!isSpecialNodeOrBridge(nodeId, this.cy)) return false; const node = this.cy.getElementById(nodeId); - const kind = node.data('extraData')?.kind; + const kind = node.data("extraData")?.kind; return !ManagerViewportPanels.BRIDGE_TYPES.includes(kind as any); } private buildLinkExtendedData(existing: any): any { const updated = { ...existing } as any; - const srcMacEl = document.getElementById('panel-link-ext-src-mac') as HTMLInputElement | null; - const tgtMacEl = document.getElementById('panel-link-ext-tgt-mac') as HTMLInputElement | null; - const mtuEl = document.getElementById(ManagerViewportPanels.ID_LINK_EXT_MTU) as HTMLInputElement | null; + const srcMacEl = document.getElementById("panel-link-ext-src-mac") as HTMLInputElement | null; + const tgtMacEl = document.getElementById("panel-link-ext-tgt-mac") as HTMLInputElement | null; + const mtuEl = document.getElementById( + ManagerViewportPanels.ID_LINK_EXT_MTU + ) as HTMLInputElement | null; if (srcMacEl) updated.extSourceMac = srcMacEl.value.trim() || undefined; if (tgtMacEl) updated.extTargetMac = tgtMacEl.value.trim() || undefined; if (mtuEl) updated.extMtu = mtuEl.value ? Number(mtuEl.value) : undefined; - const vars = this.collectDynamicEntries('link-vars'); - const labels = this.collectDynamicEntries('link-labels'); + const vars = this.collectDynamicEntries("link-vars"); + const labels = this.collectDynamicEntries("link-labels"); updated.extVars = Object.keys(vars).length ? vars : undefined; updated.extLabels = Object.keys(labels).length ? labels : undefined; return updated; @@ -1217,24 +1538,38 @@ export class ManagerViewportPanels { public async updateNodeFromEditor(node: cytoscape.NodeSingular): Promise { const targetNode = this.ensureSingleNode(node); const nodeNameInput = document.getElementById("node-name") as HTMLInputElement; - const nodeImageInput = document.getElementById("node-image-dropdown-container-filter-input") as HTMLInputElement; - const typeDropdownInput = document.getElementById("panel-node-type-dropdown-container-filter-input") as HTMLInputElement; + const nodeImageInput = document.getElementById( + "node-image-dropdown-container-filter-input" + ) as HTMLInputElement; + const typeDropdownInput = document.getElementById( + "panel-node-type-dropdown-container-filter-input" + ) as HTMLInputElement; const typeInput = document.getElementById("node-type") as HTMLInputElement; - const kindDropdownInput = document.getElementById("panel-node-kind-dropdown-container-filter-input") as HTMLInputElement; - const topoViewerRoleDropdownInput = document.getElementById("panel-node-topoviewerrole-dropdown-container-filter-input") as HTMLInputElement; + const kindDropdownInput = document.getElementById( + "panel-node-kind-dropdown-container-filter-input" + ) as HTMLInputElement; + const topoViewerRoleDropdownInput = document.getElementById( + "panel-node-topoviewerrole-dropdown-container-filter-input" + ) as HTMLInputElement; const currentData = targetNode.data(); const oldName = currentData.name as string; const newName = nodeNameInput.value; const typeValue = this.getNodeTypeValue(typeDropdownInput, typeInput); - const updatedExtraData = this.buildNodeExtraData(currentData.extraData, nodeNameInput.value, nodeImageInput.value, kindDropdownInput?.value, typeValue); + const updatedExtraData = this.buildNodeExtraData( + currentData.extraData, + nodeNameInput.value, + nodeImageInput.value, + kindDropdownInput?.value, + typeValue + ); const updatedData = { ...currentData, name: nodeNameInput.value, - topoViewerRole: topoViewerRoleDropdownInput ? topoViewerRoleDropdownInput.value : 'pe', - extraData: updatedExtraData, + topoViewerRole: topoViewerRoleDropdownInput ? topoViewerRoleDropdownInput.value : "pe", + extraData: updatedExtraData }; targetNode.data(updatedData); @@ -1267,37 +1602,56 @@ export class ManagerViewportPanels { const extendedData = this.buildNetworkExtendedData(inputs, currentData.extraData || {}); if (idInfo.oldId === idInfo.newId) { - this.applyNetworkDataSameId(targetNode, currentData, idInfo.displayName, inputs.networkType, extendedData); + this.applyNetworkDataSameId( + targetNode, + currentData, + idInfo.displayName, + inputs.networkType, + extendedData + ); } else { this.recreateNetworkNode(targetNode, currentData, idInfo, inputs.networkType, extendedData); } } - private getNodeTypeValue(typeDropdownInput: HTMLInputElement | null, typeInput: HTMLInputElement | null): string { + private getNodeTypeValue( + typeDropdownInput: HTMLInputElement | null, + typeInput: HTMLInputElement | null + ): string { if (this.panelNodeEditorUseDropdownForType) { - return typeDropdownInput ? (typeDropdownInput.value || '') : ''; + return typeDropdownInput ? typeDropdownInput.value || "" : ""; } - return typeInput ? typeInput.value : ''; + return typeInput ? typeInput.value : ""; } - private buildNodeExtraData(currentExtra: any, name: string, image: string, kindValue: string | undefined, typeValue: string): any { + private buildNodeExtraData( + currentExtra: any, + name: string, + image: string, + kindValue: string | undefined, + typeValue: string + ): any { const updatedExtraData = { ...currentExtra, name, image, - kind: kindValue || 'nokia_srlinux', + kind: kindValue || "nokia_srlinux" }; - if (this.panelNodeEditorUseDropdownForType || typeValue.trim() !== '') { + if (this.panelNodeEditorUseDropdownForType || typeValue.trim() !== "") { updatedExtraData.type = typeValue; - } else if ('type' in updatedExtraData) { + } else if ("type" in updatedExtraData) { delete updatedExtraData.type; } return updatedExtraData; } - private updateEdgesForRenamedNode(targetNode: cytoscape.NodeSingular, oldName: string, newName: string): void { + private updateEdgesForRenamedNode( + targetNode: cytoscape.NodeSingular, + oldName: string, + newName: string + ): void { const edges = targetNode.connectedEdges(); - edges.forEach(edge => { + edges.forEach((edge) => { const edgeData = edge.data(); const updatedEdgeData: any = { ...edgeData }; let modified = false; @@ -1311,7 +1665,9 @@ export class ManagerViewportPanels { } if (modified) { edge.data(updatedEdgeData); - log.debug(`Edge ${edge.id()} updated to reflect node rename: ${JSON.stringify(updatedEdgeData)}`); + log.debug( + `Edge ${edge.id()} updated to reflect node rename: ${JSON.stringify(updatedEdgeData)}` + ); } }); } @@ -1328,18 +1684,38 @@ export class ManagerViewportPanels { } private getNetworkEditorInputs() { - const networkType = (document.getElementById(ManagerViewportPanels.ID_NETWORK_TYPE_FILTER_INPUT) as HTMLInputElement | null)?.value || ManagerViewportPanels.TYPE_HOST; - const interfaceName = (document.getElementById(ManagerViewportPanels.ID_NETWORK_INTERFACE) as HTMLInputElement | null)?.value || 'eth1'; + const networkType = + ( + document.getElementById( + ManagerViewportPanels.ID_NETWORK_TYPE_FILTER_INPUT + ) as HTMLInputElement | null + )?.value || ManagerViewportPanels.TYPE_HOST; + const interfaceName = + ( + document.getElementById( + ManagerViewportPanels.ID_NETWORK_INTERFACE + ) as HTMLInputElement | null + )?.value || "eth1"; return { networkType, interfaceName, - label: (document.getElementById(ManagerViewportPanels.ID_NETWORK_LABEL) as HTMLInputElement | null)?.value, - mac: (document.getElementById('panel-network-mac') as HTMLInputElement | null)?.value, - mtu: (document.getElementById('panel-network-mtu') as HTMLInputElement | null)?.value, - mode: (document.getElementById('panel-network-mode') as HTMLSelectElement | null)?.value, - remote: (document.getElementById(ManagerViewportPanels.ID_NETWORK_REMOTE) as HTMLInputElement | null)?.value, - vni: (document.getElementById(ManagerViewportPanels.ID_NETWORK_VNI) as HTMLInputElement | null)?.value, - udpPort: (document.getElementById(ManagerViewportPanels.ID_NETWORK_UDP_PORT) as HTMLInputElement | null)?.value, + label: ( + document.getElementById(ManagerViewportPanels.ID_NETWORK_LABEL) as HTMLInputElement | null + )?.value, + mac: (document.getElementById("panel-network-mac") as HTMLInputElement | null)?.value, + mtu: (document.getElementById("panel-network-mtu") as HTMLInputElement | null)?.value, + mode: (document.getElementById("panel-network-mode") as HTMLSelectElement | null)?.value, + remote: ( + document.getElementById(ManagerViewportPanels.ID_NETWORK_REMOTE) as HTMLInputElement | null + )?.value, + vni: ( + document.getElementById(ManagerViewportPanels.ID_NETWORK_VNI) as HTMLInputElement | null + )?.value, + udpPort: ( + document.getElementById( + ManagerViewportPanels.ID_NETWORK_UDP_PORT + ) as HTMLInputElement | null + )?.value }; } @@ -1356,21 +1732,23 @@ export class ManagerViewportPanels { const oldName = currentData.name as string; const isBridgeType = ManagerViewportPanels.BRIDGE_TYPES.includes(networkType as any); const isDummyType = networkType === ManagerViewportPanels.TYPE_DUMMY; - let newId = ''; + let newId = ""; if (isBridgeType) { // Preserve existing ID for bridge nodes to support alias visuals; // Interface field maps to YAML id via extYamlNodeId and becomes display name. newId = oldId; } else if (isDummyType) { - newId = oldId.startsWith(ManagerViewportPanels.TYPE_DUMMY) ? oldId : this.generateUniqueDummyId(); + newId = oldId.startsWith(ManagerViewportPanels.TYPE_DUMMY) + ? oldId + : this.generateUniqueDummyId(); } else if ((ManagerViewportPanels.VX_TYPES as readonly string[]).includes(networkType)) { - newId = `${networkType}:${remote ?? ''}/${vni ?? ''}/${udpPort ?? ''}`; + newId = `${networkType}:${remote ?? ""}/${vni ?? ""}/${udpPort ?? ""}`; } else { newId = `${networkType}:${interfaceName}`; } let displayName: string; if (isBridgeType) { - const trimmedLabel = (label && label.trim()) || ''; + const trimmedLabel = (label && label.trim()) || ""; displayName = trimmedLabel || interfaceName || oldName || newId; } else if (isDummyType) { displayName = ManagerViewportPanels.TYPE_DUMMY; @@ -1393,14 +1771,14 @@ export class ManagerViewportPanels { private assignCommonNetworkExt(target: any, inputs: any): void { if (inputs.mac) target.extMac = inputs.mac; if (inputs.mtu) target.extMtu = Number(inputs.mtu); - const vars = this.collectDynamicEntries('network-vars'); + const vars = this.collectDynamicEntries("network-vars"); if (Object.keys(vars).length) target.extVars = vars; - const labels = this.collectDynamicEntries('network-labels'); + const labels = this.collectDynamicEntries("network-labels"); if (Object.keys(labels).length) target.extLabels = labels; } private assignMacvlanPropsNetwork(target: any, inputs: any): void { - if (inputs.networkType === 'macvlan' && inputs.mode) target.extMode = inputs.mode; + if (inputs.networkType === "macvlan" && inputs.mode) target.extMode = inputs.mode; } private assignVxlanPropsNetwork(target: any, inputs: any): void { @@ -1412,38 +1790,61 @@ export class ManagerViewportPanels { } private assignHostInterfaceProp(target: any, inputs: any): void { - if ((ManagerViewportPanels.HOSTY_TYPES as readonly string[]).includes(inputs.networkType) && inputs.interfaceName) { + if ( + (ManagerViewportPanels.HOSTY_TYPES as readonly string[]).includes(inputs.networkType) && + inputs.interfaceName + ) { target.extHostInterface = inputs.interfaceName; } } private assignYamlNodeMappingIfBridge(target: any, inputs: any): void { - if (ManagerViewportPanels.BRIDGE_TYPES.includes(inputs.networkType as any) && inputs.interfaceName) { + if ( + ManagerViewportPanels.BRIDGE_TYPES.includes(inputs.networkType as any) && + inputs.interfaceName + ) { target.extYamlNodeId = String(inputs.interfaceName).trim(); } } - private applyNetworkDataSameId(targetNode: cytoscape.NodeSingular, currentData: any, newName: string, networkType: string, extendedData: any): void { + private applyNetworkDataSameId( + targetNode: cytoscape.NodeSingular, + currentData: any, + newName: string, + networkType: string, + extendedData: any + ): void { const updatedData = { ...currentData, name: newName, - topoViewerRole: (ManagerViewportPanels.BRIDGE_TYPES.includes(networkType as any)) ? 'bridge' : 'cloud', + topoViewerRole: ManagerViewportPanels.BRIDGE_TYPES.includes(networkType as any) + ? "bridge" + : "cloud", extraData: { ...extendedData, kind: networkType } }; targetNode.data(updatedData); - targetNode.connectedEdges().forEach(edge => { + targetNode.connectedEdges().forEach((edge) => { const edgeData = edge.data(); const updatedEdgeData = { ...edgeData, - extraData: { ...(edgeData.extraData || {}), ...this.getNetworkExtendedPropertiesForEdge(networkType, extendedData) } + extraData: { + ...(edgeData.extraData || {}), + ...this.getNetworkExtendedPropertiesForEdge(networkType, extendedData) + } }; edge.data(updatedEdgeData); }); } - private recreateNetworkNode(targetNode: cytoscape.NodeSingular, currentData: any, ids: any, networkType: string, extendedData: any): void { + private recreateNetworkNode( + targetNode: cytoscape.NodeSingular, + currentData: any, + ids: any, + networkType: string, + extendedData: any + ): void { const position = targetNode.position(); - const connectedEdges = targetNode.connectedEdges().map(edge => ({ + const connectedEdges = targetNode.connectedEdges().map((edge) => ({ data: edge.data(), classes: edge.classes() })); @@ -1452,21 +1853,29 @@ export class ManagerViewportPanels { ...currentData, id: ids.newId, name: ids.displayName, - topoViewerRole: (ManagerViewportPanels.BRIDGE_TYPES.includes(networkType as any)) ? 'bridge' : 'cloud', + topoViewerRole: ManagerViewportPanels.BRIDGE_TYPES.includes(networkType as any) + ? "bridge" + : "cloud", extraData: { ...extendedData, kind: networkType } }; - this.cy.add({ group: 'nodes', data: newNodeData, position }); - connectedEdges.forEach(edgeInfo => { + this.cy.add({ group: "nodes", data: newNodeData, position }); + connectedEdges.forEach((edgeInfo) => { const newEdgeData = { ...edgeInfo.data }; if (newEdgeData.source === ids.oldId) newEdgeData.source = ids.newId; if (newEdgeData.target === ids.oldId) newEdgeData.target = ids.newId; if (newEdgeData.sourceName === ids.oldName) newEdgeData.sourceName = ids.displayName; if (newEdgeData.targetName === ids.oldName) newEdgeData.targetName = ids.displayName; - newEdgeData.extraData = { ...(newEdgeData.extraData || {}), ...this.getNetworkExtendedPropertiesForEdge(networkType, extendedData) }; + newEdgeData.extraData = { + ...(newEdgeData.extraData || {}), + ...this.getNetworkExtendedPropertiesForEdge(networkType, extendedData) + }; let edgeClasses = edgeInfo.classes || []; - const isStubLink = isSpecialNodeOrBridge(newEdgeData.source, this.cy) || isSpecialNodeOrBridge(newEdgeData.target, this.cy); - if (isStubLink && !edgeClasses.includes('stub-link')) edgeClasses = [...edgeClasses, 'stub-link']; - this.cy.add({ group: 'edges', data: newEdgeData, classes: edgeClasses.join(' ') }); + const isStubLink = + isSpecialNodeOrBridge(newEdgeData.source, this.cy) || + isSpecialNodeOrBridge(newEdgeData.target, this.cy); + if (isStubLink && !edgeClasses.includes("stub-link")) + edgeClasses = [...edgeClasses, "stub-link"]; + this.cy.add({ group: "edges", data: newEdgeData, classes: edgeClasses.join(" ") }); }); } @@ -1486,9 +1895,9 @@ export class ManagerViewportPanels { } private pickCommonExtProps(nodeExtraData: any): any { - const props = ['extMac', 'extMtu', 'extVars', 'extLabels']; + const props = ["extMac", "extMtu", "extVars", "extLabels"]; const result: any = {}; - props.forEach(prop => { + props.forEach((prop) => { if (nodeExtraData[prop] !== undefined) result[prop] = nodeExtraData[prop]; }); return result; @@ -1498,14 +1907,14 @@ export class ManagerViewportPanels { const copy = (prop: string) => { if (nodeExtraData[prop] !== undefined) target[prop] = nodeExtraData[prop]; }; - if ((ManagerViewportPanels.HOSTY_TYPES as readonly string[]).includes(networkType)) copy('extHostInterface'); - if (networkType === 'macvlan') copy('extMode'); + if ((ManagerViewportPanels.HOSTY_TYPES as readonly string[]).includes(networkType)) + copy("extHostInterface"); + if (networkType === "macvlan") copy("extMode"); if ((ManagerViewportPanels.VX_TYPES as readonly string[]).includes(networkType)) { - ['extRemote', 'extVni', 'extUdpPort'].forEach(copy); + ["extRemote", "extVni", "extUdpPort"].forEach(copy); } } - /** * Updates connected edge endpoints when a node's kind changes. * Only endpoints matching the old kind's pattern are updated. @@ -1520,8 +1929,9 @@ export class ManagerViewportPanels { newKind: string ): void { const ifaceMap = window.ifacePatternMapping || {}; - const extraData = node.data('extraData') as { interfacePattern?: unknown } | undefined; - const overridePattern = typeof extraData?.interfacePattern === 'string' ? extraData.interfacePattern.trim() : ''; + const extraData = node.data("extraData") as { interfacePattern?: unknown } | undefined; + const overridePattern = + typeof extraData?.interfacePattern === "string" ? extraData.interfacePattern.trim() : ""; const oldPattern = overridePattern || ifaceMap[oldKind] || DEFAULT_INTERFACE_PATTERN; const newPattern = overridePattern || ifaceMap[newKind] || DEFAULT_INTERFACE_PATTERN; const oldParsed = parseInterfacePattern(oldPattern); @@ -1529,12 +1939,12 @@ export class ManagerViewportPanels { const nodeId = node.id(); const edges = this.cy.edges(`[source = "${nodeId}"], [target = "${nodeId}"]`); - edges.forEach(edge => { - ['sourceEndpoint', 'targetEndpoint'].forEach(key => { + edges.forEach((edge) => { + ["sourceEndpoint", "targetEndpoint"].forEach((key) => { const endpoint = edge.data(key); const isNodeEndpoint = - (edge.data('source') === nodeId && key === 'sourceEndpoint') || - (edge.data('target') === nodeId && key === 'targetEndpoint'); + (edge.data("source") === nodeId && key === "sourceEndpoint") || + (edge.data("target") === nodeId && key === "targetEndpoint"); if (!endpoint || !isNodeEndpoint) return; const index = getInterfaceIndex(oldParsed, endpoint); if (index !== null) { @@ -1556,8 +1966,8 @@ export class ManagerViewportPanels { */ private panelNodeEditorGetKindEnums(jsonData: any): { kindOptions: string[]; schemaData: any } { let kindOptions: string[] = []; - if (jsonData && jsonData.definitions && jsonData.definitions['node-config']) { - kindOptions = jsonData.definitions['node-config'].properties.kind.enum || []; + if (jsonData && jsonData.definitions && jsonData.definitions["node-config"]) { + kindOptions = jsonData.definitions["node-config"].properties.kind.enum || []; } else { throw new Error("Invalid JSON structure or 'kind' enum not found"); } @@ -1573,7 +1983,7 @@ export class ManagerViewportPanels { // Sort kinds alphabetically (no explicit ordering) const sortedOptions = this.sortKindsWithNokiaTop(options); createFilterableDropdown( - 'panel-node-kind-dropdown-container', + "panel-node-kind-dropdown-container", sortedOptions, this.panelNodeEditorKind, (selectedValue: string) => { @@ -1581,36 +1991,47 @@ export class ManagerViewportPanels { this.panelNodeEditorKind = selectedValue; log.debug(`${this.panelNodeEditorKind} selected`); - const typeOptions = this.panelNodeEditorGetTypeEnumsByKindPattern(this.nodeSchemaData, `(${selectedValue})`); + const typeOptions = this.panelNodeEditorGetTypeEnumsByKindPattern( + this.nodeSchemaData, + `(${selectedValue})` + ); // Reset the stored type when kind changes this.panelNodeEditorType = ""; this.panelNodeEditorSetupTypeField(typeOptions); if (this.panelNodeEditorNode && window.updateLinkEndpointsOnKindChange) { - this.updateNodeEndpointsForKindChange(this.panelNodeEditorNode, previousKind, selectedValue); + this.updateNodeEndpointsForKindChange( + this.panelNodeEditorNode, + previousKind, + selectedValue + ); } const imageMap = window.imageMapping || {}; - const imageInput = document.getElementById('panel-node-editor-image') as HTMLInputElement; + const imageInput = document.getElementById("panel-node-editor-image") as HTMLInputElement; if (imageInput) { if (Object.prototype.hasOwnProperty.call(imageMap, selectedValue)) { const mappedImage = imageMap[selectedValue] as string; imageInput.value = mappedImage; - imageInput.dispatchEvent(new Event('input')); + imageInput.dispatchEvent(new Event("input")); } else { - imageInput.value = ''; - imageInput.dispatchEvent(new Event('input')); + imageInput.value = ""; + imageInput.dispatchEvent(new Event("input")); } } }, - 'Search for kind...' + "Search for kind..." ); } // Group Nokia kinds on top (prefix 'nokia_'), each group sorted alphabetically private sortKindsWithNokiaTop(options: string[]): string[] { - const nokiaKinds = options.filter(k => k.startsWith('nokia_')).sort((a, b) => a.localeCompare(b)); - const otherKinds = options.filter(k => !k.startsWith('nokia_')).sort((a, b) => a.localeCompare(b)); + const nokiaKinds = options + .filter((k) => k.startsWith("nokia_")) + .sort((a, b) => a.localeCompare(b)); + const otherKinds = options + .filter((k) => !k.startsWith("nokia_")) + .sort((a, b) => a.localeCompare(b)); return [...nokiaKinds, ...otherKinds]; } @@ -1619,7 +2040,7 @@ export class ManagerViewportPanels { const input = document.getElementById("node-type") as HTMLInputElement; if (!dropdownContainer || !input) { - log.error('Type input elements not found in the DOM.'); + log.error("Type input elements not found in the DOM."); return; } @@ -1649,14 +2070,14 @@ export class ManagerViewportPanels { } createFilterableDropdown( - 'panel-node-type-dropdown-container', + "panel-node-type-dropdown-container", options, this.panelNodeEditorType, (selectedValue: string) => { this.panelNodeEditorType = selectedValue; log.debug(`Type ${this.panelNodeEditorType} selected`); }, - 'Search for type...' + "Search for type..." ); } @@ -1667,54 +2088,53 @@ export class ManagerViewportPanels { */ private panelNodeEditorPopulateTopoViewerRoleDropdown(options: string[]): void { createFilterableDropdown( - 'panel-node-topoviewerrole-dropdown-container', + "panel-node-topoviewerrole-dropdown-container", options, this.panelNodeEditorTopoViewerRole, (selectedValue: string) => { this.panelNodeEditorTopoViewerRole = selectedValue; log.debug(`${this.panelNodeEditorTopoViewerRole} selected`); }, - 'Search for role...', + "Search for role...", false, { - menuClassName: 'max-h-96', + menuClassName: "max-h-96", dropdownWidth: 320, - renderOption: createNodeIconOptionElement, + renderOption: createNodeIconOptionElement } ); } /** - * Displays the TopoViewer panel - * Removes any overlay panels, updates form fields with the edge’s current source/target endpoints, - * - * @returns A promise that resolves when the panel is fully configured and shown. - */ + * Displays the TopoViewer panel + * Removes any overlay panels, updates form fields with the edge’s current source/target endpoints, + * + * @returns A promise that resolves when the panel is fully configured and shown. + */ public async panelAbout(): Promise { try { // 1) Hide other overlays - const overlays = document.getElementsByClassName(ManagerViewportPanels.CLASS_PANEL_OVERLAY); - Array.from(overlays).forEach(el => (el as HTMLElement).style.display = "none"); + const overlays = document.getElementsByClassName(ManagerViewportPanels.CLASS_PANEL_OVERLAY); + Array.from(overlays).forEach((el) => ((el as HTMLElement).style.display = "none")); // 2) Grab the static parts and initial data const panelTopoviewerAbout = document.getElementById("panel-topoviewer-about"); if (!panelTopoviewerAbout) { - log.error('panelTopoviewerAbout: missing required DOM elements'); + log.error("panelTopoviewerAbout: missing required DOM elements"); return; } // 3) Show the panel panelTopoviewerAbout.style.display = "block"; - - } - catch (err) { - log.error(`panelEdgeEditor: unexpected error: ${err instanceof Error ? err.message : String(err)}`); + } catch (err) { + log.error( + `panelEdgeEditor: unexpected error: ${err instanceof Error ? err.message : String(err)}` + ); // NOTE: Consider surfacing a user-facing notification } } - /** * Extracts type enumeration options from the JSON schema based on a kind pattern. * @@ -1723,7 +2143,7 @@ export class ManagerViewportPanels { * @returns An array of type enum strings. */ private panelNodeEditorGetTypeEnumsByKindPattern(jsonData: any, pattern: string): string[] { - const nodeConfig = jsonData?.definitions?.['node-config']; + const nodeConfig = jsonData?.definitions?.["node-config"]; if (!nodeConfig?.allOf) return []; for (const condition of nodeConfig.allOf) { @@ -1760,7 +2180,6 @@ export class ManagerViewportPanels { * */ - /** * Removes a DOM element by its ID. * @@ -1814,19 +2233,23 @@ export class ManagerViewportPanels { * Sets up a global click handler to close dropdowns when clicking outside. */ private setupDropdownCloseHandler(): void { - document.addEventListener('click', (e) => { + document.addEventListener("click", (e) => { const target = e.target as HTMLElement; // Check if click is outside all dropdowns - const dropdowns = ['panel-node-kind-dropdown', 'panel-node-topoviewerrole-dropdown', 'panel-node-type-dropdown']; + const dropdowns = [ + "panel-node-kind-dropdown", + "panel-node-topoviewerrole-dropdown", + "panel-node-type-dropdown" + ]; - dropdowns.forEach(dropdownId => { + dropdowns.forEach((dropdownId) => { const dropdown = document.getElementById(dropdownId); if (dropdown && !dropdown.contains(target)) { - dropdown.classList.remove('is-active'); - const content = dropdown.querySelector('.dropdown-menu'); + dropdown.classList.remove("is-active"); + const content = dropdown.querySelector(".dropdown-menu"); if (content) { - content.classList.add('hidden'); + content.classList.add("hidden"); } } }); diff --git a/src/topoViewer/webview-ui/tailwind.css b/src/topoViewer/webview-ui/tailwind.css index e0d6ab922..7fa074626 100644 --- a/src/topoViewer/webview-ui/tailwind.css +++ b/src/topoViewer/webview-ui/tailwind.css @@ -78,17 +78,22 @@ --info: var(--vscode-charts-blue); --diff-add: var(--vscode-diffEditor-insertedTextBorder, var(--vscode-charts-green)); --diff-add-bg: var(--vscode-diffEditor-insertedTextBackground); - --diff-add-line-bg: var(--vscode-diffEditor-insertedLineBackground, var(--vscode-diffEditor-insertedTextBackground)); + --diff-add-line-bg: var( + --vscode-diffEditor-insertedLineBackground, + var(--vscode-diffEditor-insertedTextBackground) + ); --diff-remove: var(--vscode-diffEditor-removedTextBorder, var(--vscode-charts-red)); --diff-remove-bg: var(--vscode-diffEditor-removedTextBackground); - --diff-remove-line-bg: var(--vscode-diffEditor-removedLineBackground, var(--vscode-diffEditor-removedTextBackground)); + --diff-remove-line-bg: var( + --vscode-diffEditor-removedLineBackground, + var(--vscode-diffEditor-removedTextBackground) + ); --diff-line-number: var(--vscode-editorLineNumber-foreground); /* Navbar height for viewport offset */ --navbar-height: 4.5rem; } @layer base { - html, body { height: 100%; @@ -105,7 +110,6 @@ /* Custom component classes using Tailwind */ @layer components { - /* Ensure filterable dropdown menus don't propagate scroll to parents */ .filterable-dropdown-menu { overscroll-behavior: contain; @@ -119,6 +123,7 @@ padding: 0.5rem 1rem; font-weight: 500; border-radius: 0.375rem; + border: 1px solid transparent; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; transition-duration: 150ms; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); @@ -128,13 +133,17 @@ } .btn:hover { - background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground)); + background-color: var( + --vscode-button-secondaryHoverBackground, + var(--vscode-button-hoverBackground) + ); cursor: pointer; } .btn-primary { background-color: var(--accent); color: var(--vscode-button-foreground); + border-color: transparent; } .btn-primary:hover { @@ -147,7 +156,10 @@ } .btn-secondary:hover { - background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-secondaryBackground)); + background-color: var( + --vscode-button-secondaryHoverBackground, + var(--vscode-button-secondaryBackground) + ); } .btn-outlined { @@ -157,7 +169,8 @@ } .btn-outlined:hover { - background-color: var(--bg-hover); + background-color: var(--vscode-button-hoverBackground); + color: var(--vscode-button-foreground); } .btn-danger { @@ -168,7 +181,7 @@ .btn-danger:hover { background-color: var(--error); - color: var(--color-vscode-button-fg) + color: var(--color-vscode-button-fg); } .btn-small { @@ -212,22 +225,88 @@ font-size: 0.875rem; } + /* Window-style title bar for editor panels */ + .panel-heading.panel-title-bar, + .panel-title-bar { + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + background-color: var(--vscode-titleBar-activeBackground) !important; + color: var(--vscode-titleBar-activeForeground) !important; + border-bottom: 1px solid var(--vscode-panel-border) !important; + padding: 0.5rem 1rem !important; + font-weight: 600 !important; + font-size: 0.875rem !important; + cursor: grab !important; + user-select: none !important; + min-height: 2.5rem !important; + } + + .panel-title-bar:active { + cursor: grabbing !important; + } + + .panel-title { + flex: 1 !important; + font-weight: 600 !important; + } + + .panel-close-btn { + background: none !important; + border: none !important; + outline: none !important; + color: var(--vscode-titleBar-activeForeground) !important; + cursor: pointer !important; + padding: 0.25rem !important; + border-radius: 0.25rem !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + transition: background-color 0.15s ease !important; + font-size: 0.875rem !important; + opacity: 1 !important; + width: 1.75rem !important; + height: 1.75rem !important; + flex-shrink: 0 !important; + visibility: visible !important; + } + + .panel-close-btn i { + display: inline-block !important; + visibility: visible !important; + opacity: 1 !important; + font-size: 0.875rem !important; + line-height: 1 !important; + } + + .panel-close-btn:hover { + background-color: var(--vscode-toolbar-hoverBackground) !important; + opacity: 1 !important; + } + + .panel-close-btn:active { + background-color: var(--vscode-toolbar-activeBackground) !important; + } + + .panel-close-btn:focus { + outline: none !important; + box-shadow: none !important; + } + .panel-block { border-bottom: 1px solid var(--border); padding: 0.75rem 1rem; } /*only the footer (last direct child of .panel) removes it */ - .panel>.panel-block:last-child { + .panel > .panel-block:last-child { border-bottom: 0; } /* Dropdown styles */ .dropdown-menu { - background-color: var(--vscode-dropdown-background, - var(--vscode-editor-background)); - border: 1px solid var(--vscode-dropdown-border, - var(--vscode-editor-foreground)); + background-color: var(--vscode-dropdown-background, var(--vscode-editor-background)); + border: 1px solid var(--vscode-dropdown-border, var(--vscode-editor-foreground)); position: absolute; z-index: 50; margin-top: 0.25rem; @@ -243,20 +322,16 @@ border-radius: 0.25rem; display: inline-flex; align-items: center; - background-color: var(--vscode-dropdown-background, - var(--vscode-editor-background)); - color: var(--vscode-dropdown-foreground, - var(--text-primary)); + background-color: var(--vscode-dropdown-background, var(--vscode-editor-background)); + color: var(--vscode-dropdown-foreground, var(--text-primary)); } .dropdown-button:hover { - background-color: var(--vscode-list-hoverBackground, - var(--bg-hover)); + background-color: var(--vscode-list-hoverBackground, var(--bg-hover)); } .dropdown-item { - color: var(--vscode-dropdown-foreground, - var(--text-primary)); + color: var(--vscode-dropdown-foreground, var(--text-primary)); display: block; padding: 0.5rem 1rem; font-size: var(--vscode-font-size); @@ -264,12 +339,11 @@ } .dropdown-item:hover { - background-color: var(--vscode-list-hoverBackground, - var(--bg-hover)); + background-color: var(--vscode-list-hoverBackground, var(--bg-hover)); } /* Tippy.js theme for add node menu */ - .tippy-box[data-theme~='dropdown-menu'] { + .tippy-box[data-theme~="dropdown-menu"] { background-color: var(--vscode-dropdown-background) !important; color: var(--vscode-dropdown-foreground) !important; border: 1px solid var(--vscode-dropdown-border) !important; @@ -280,16 +354,16 @@ font-size: var(--vscode-font-size, 13px); } - .tippy-box[data-theme~='dropdown-menu'] .tippy-content { + .tippy-box[data-theme~="dropdown-menu"] .tippy-content { padding: 0; background: transparent !important; } - .tippy-box[data-theme~='dropdown-menu'] .tippy-arrow { + .tippy-box[data-theme~="dropdown-menu"] .tippy-arrow { color: var(--vscode-dropdown-background) !important; } - .tippy-box[data-theme~='dropdown-menu'] .tippy-backdrop { + .tippy-box[data-theme~="dropdown-menu"] .tippy-backdrop { background-color: var(--vscode-dropdown-background) !important; } @@ -335,13 +409,16 @@ .add-node-delete-btn { opacity: 0; transition: opacity 0.15s ease; - padding: 0.25rem 0.5rem; - font-size: 1.125rem; - line-height: 1; + padding: 0.375rem 0.5rem; + font-size: 1rem; + line-height: 1.25; border-radius: 0.25rem; background: transparent; border: none; cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; } .add-node-default-btn { @@ -404,7 +481,9 @@ border-radius: 0.375rem; background-color: var(--vscode-input-background); overflow: visible; - transition: border-color 0.12s ease, box-shadow 0.12s ease; + transition: + border-color 0.12s ease, + box-shadow 0.12s ease; } .slot-field-shell:focus-within { @@ -504,7 +583,10 @@ top: calc(100% + 0.25rem); min-width: 14rem; padding: 0.25rem 0; - background-color: var(--vscode-menu-background, var(--vscode-dropdown-background, var(--vscode-editorWidget-background))); + background-color: var( + --vscode-menu-background, + var(--vscode-dropdown-background, var(--vscode-editorWidget-background)) + ); color: var(--vscode-menu-foreground, var(--vscode-foreground)); border: 1px solid var(--vscode-menu-border, rgba(128, 128, 128, 0.35)); border-radius: 0.375rem; @@ -524,7 +606,9 @@ font-size: 0.875rem; cursor: pointer; gap: 0.5rem; - transition: background-color 0.1s ease, color 0.1s ease; + transition: + background-color 0.1s ease, + color 0.1s ease; } .navbar-menu-option span { @@ -627,7 +711,11 @@ overflow: hidden; opacity: 0; transition: opacity 180ms ease-in-out; - background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground, rgba(255, 255, 255, 0.1)) 70%, transparent); + background-color: color-mix( + in srgb, + var(--vscode-toolbar-hoverBackground, rgba(255, 255, 255, 0.1)) 70%, + transparent + ); border-bottom: 1px solid var(--vscode-tab-border, transparent); } @@ -649,22 +737,26 @@ } .navbar-loading-indicator.is-active.is-deploy .navbar-loading-indicator__bar { - background-image: linear-gradient(90deg, - transparent 0%, - color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 25%, transparent) 15%, - color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 90%, transparent) 45%, - color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 25%, transparent) 75%, - transparent 100%); + background-image: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 25%, transparent) 15%, + color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 90%, transparent) 45%, + color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 25%, transparent) 75%, + transparent 100% + ); animation: navbar-progress-sweep 1.15s linear infinite; } .navbar-loading-indicator.is-active.is-destroy .navbar-loading-indicator__bar { - background-image: linear-gradient(90deg, - transparent 0%, - color-mix(in srgb, var(--vscode-testing-iconFailed, #f14c4c) 30%, transparent) 15%, - color-mix(in srgb, var(--vscode-testing-iconFailed, #f14c4c) 95%, transparent) 45%, - color-mix(in srgb, var(--vscode-testing-iconFailed, #f14c4c) 30%, transparent) 75%, - transparent 100%); + background-image: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--vscode-testing-iconFailed, #f14c4c) 30%, transparent) 15%, + color-mix(in srgb, var(--vscode-testing-iconFailed, #f14c4c) 95%, transparent) 45%, + color-mix(in srgb, var(--vscode-testing-iconFailed, #f14c4c) 30%, transparent) 75%, + transparent 100% + ); animation: navbar-progress-sweep 1.15s linear infinite; } @@ -688,7 +780,7 @@ } button.processing::after { - content: ''; + content: ""; pointer-events: none; position: absolute; inset: 4px; @@ -700,10 +792,13 @@ button.processing.processing--deploy { color: var(--vscode-button-foreground); - background-color: color-mix(in srgb, - var(--vscode-testing-iconPassed, #73c991) 40%, - var(--vscode-button-background, rgba(0, 0, 0, 0))); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 65%, transparent), + background-color: color-mix( + in srgb, + var(--vscode-testing-iconPassed, #73c991) 40%, + var(--vscode-button-background, rgba(0, 0, 0, 0)) + ); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 65%, transparent), 0 0 0 4px color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 25%, transparent); } @@ -716,10 +811,13 @@ button.processing.processing--destroy { color: var(--vscode-button-foreground); - background-color: color-mix(in srgb, - var(--vscode-testing-iconFailed, #f14c4c) 40%, - var(--vscode-button-background, rgba(0, 0, 0, 0))); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--vscode-testing-iconFailed, #f14c4c) 65%, transparent), + background-color: color-mix( + in srgb, + var(--vscode-testing-iconFailed, #f14c4c) 40%, + var(--vscode-button-background, rgba(0, 0, 0, 0)) + ); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--vscode-testing-iconFailed, #f14c4c) 65%, transparent), 0 0 0 4px color-mix(in srgb, var(--vscode-testing-iconFailed, #f14c4c) 25%, transparent); } @@ -765,7 +863,7 @@ margin-right: -0.5rem; } - .columns>* { + .columns > * { padding-left: 0.5rem; padding-right: 0.5rem; } @@ -845,7 +943,7 @@ } .vscode-checkbox:checked::after { - content: '✓'; + content: "✓"; position: absolute; top: 50%; left: 50%; @@ -1004,7 +1102,10 @@ border-radius: 4px; padding: 2px 6px; cursor: pointer; - transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease; + transition: + background 0.15s ease, + color 0.15s ease, + opacity 0.15s ease; } .tab-scroll-btn:hover { @@ -1119,7 +1220,6 @@ /* Custom utility classes */ @layer utilities { - /* Text utilities */ .has-text-weight-normal { font-weight: 400; @@ -1265,7 +1365,10 @@ ); opacity: 0.3; pointer-events: none; - transition: opacity 120ms ease, transform 120ms ease, background-image 120ms ease; + transition: + opacity 120ms ease, + transform 120ms ease, + background-image 120ms ease; } .free-text-overlay-scrollbar-visible { @@ -1312,7 +1415,7 @@ } .free-text-markdown code { - font-family: 'Fira Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-family: "Fira Code", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; border-radius: 0; } @@ -1384,7 +1487,7 @@ } .free-text-overlay-resize::before { - content: ''; + content: ""; width: 8px; height: 8px; border-right: 2px solid rgba(255, 255, 255, 0.9); @@ -1421,7 +1524,7 @@ } .free-text-overlay-rotate::before { - content: '\21bb'; + content: "\21bb"; color: rgba(255, 255, 255, 0.9); font-size: 12px; line-height: 1; From f7071b9deea2ae3fe3f437c1cc05e0acc6ef2ca2 Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Sun, 30 Nov 2025 01:25:27 +0800 Subject: [PATCH 17/55] Move properties on link capture wheel to bottom --- .../webview-ui/topologyWebviewController.ts | 1094 ++++++++++------- 1 file changed, 616 insertions(+), 478 deletions(-) diff --git a/src/topoViewer/webview-ui/topologyWebviewController.ts b/src/topoViewer/webview-ui/topologyWebviewController.ts index 038e27f8e..9fbc828e6 100644 --- a/src/topoViewer/webview-ui/topologyWebviewController.ts +++ b/src/topoViewer/webview-ui/topologyWebviewController.ts @@ -1,53 +1,58 @@ // file: topologyWebviewController.ts -import type cytoscape from 'cytoscape'; -import { createConfiguredCytoscape, loadExtension } from '../cytoscapeInstanceFactory'; +import type cytoscape from "cytoscape"; +import { createConfiguredCytoscape, loadExtension } from "../cytoscapeInstanceFactory"; // Import Tailwind CSS and Font Awesome -import './tailwind.css'; -import '@fortawesome/fontawesome-free/css/all.min.css'; +import "./tailwind.css"; +import "@fortawesome/fontawesome-free/css/all.min.css"; // Import Leaflet CSS for map tiles -import 'leaflet/dist/leaflet.css'; -import 'tippy.js/dist/tippy.css'; -import 'highlight.js/styles/github-dark.css'; -import loadCytoStyle from './managerCytoscapeBaseStyles'; -import { VscodeMessageSender } from './managerVscodeWebview'; -import { fetchAndLoadData, fetchAndLoadDataEnvironment } from './managerCytoscapeFetchAndLoad'; -import { ManagerSaveTopo } from './managerSaveTopo'; -import { ManagerUndo } from './managerUndo'; -import { ManagerAddContainerlabNode } from './managerAddContainerlabNode'; -import { ManagerViewportPanels } from './managerViewportPanels'; -import { ManagerUnifiedFloatingPanel } from './managerUnifiedFloatingPanel'; -import { ManagerFreeText } from './managerFreeText'; -import { ManagerNodeEditor } from './managerNodeEditor'; -import { ManagerGroupStyle } from './managerGroupStyle'; -import { CopyPasteManager } from './managerCopyPaste'; -import { ManagerLabSettings } from './managerLabSettings'; -import { viewportButtonsCaptureViewportAsSvg } from './uiHandlers'; -import type { ManagerGroupManagement } from './managerGroupManagement'; -import type { ManagerLayoutAlgo } from './managerLayoutAlgo'; -import type { ManagerZoomToFit } from './managerZoomToFit'; -import type { ManagerLabelEndpoint } from './managerLabelEndpoint'; -import { ManagerShortcutDisplay } from './managerShortcutDisplay'; -import { layoutAlgoManager as layoutAlgoManagerSingleton, getGroupManager, zoomToFitManager as zoomToFitManagerSingleton, labelEndpointManager as labelEndpointManagerSingleton } from '../core/managerRegistry'; -import { log } from '../logging/logger'; -import { perfMark, perfMeasure } from '../utilities/performanceMonitor'; -import { registerCyEventHandlers } from './cyEventHandlers'; -import { PerformanceMonitor } from '../utilities/performanceMonitor'; -import { debounce } from '../utilities/asyncUtils'; -import { ManagerGridGuide } from './managerGridGuide'; -import topoViewerState from '../state'; -import type { EdgeData } from '../types/topoViewerGraph'; -import { FilterUtils } from '../../helpers/filterUtils'; -import { isSpecialNodeOrBridge, isSpecialEndpoint } from '../utilities/specialNodes'; +import "leaflet/dist/leaflet.css"; +import "tippy.js/dist/tippy.css"; +import "highlight.js/styles/github-dark.css"; +import loadCytoStyle from "./managerCytoscapeBaseStyles"; +import { VscodeMessageSender } from "./managerVscodeWebview"; +import { fetchAndLoadData, fetchAndLoadDataEnvironment } from "./managerCytoscapeFetchAndLoad"; +import { ManagerSaveTopo } from "./managerSaveTopo"; +import { ManagerUndo } from "./managerUndo"; +import { ManagerAddContainerlabNode } from "./managerAddContainerlabNode"; +import { ManagerViewportPanels } from "./managerViewportPanels"; +import { ManagerUnifiedFloatingPanel } from "./managerUnifiedFloatingPanel"; +import { ManagerFreeText } from "./managerFreeText"; +import { ManagerNodeEditor } from "./managerNodeEditor"; +import { ManagerGroupStyle } from "./managerGroupStyle"; +import { CopyPasteManager } from "./managerCopyPaste"; +import { ManagerLabSettings } from "./managerLabSettings"; +import { viewportButtonsCaptureViewportAsSvg } from "./uiHandlers"; +import type { ManagerGroupManagement } from "./managerGroupManagement"; +import type { ManagerLayoutAlgo } from "./managerLayoutAlgo"; +import type { ManagerZoomToFit } from "./managerZoomToFit"; +import type { ManagerLabelEndpoint } from "./managerLabelEndpoint"; +import { ManagerShortcutDisplay } from "./managerShortcutDisplay"; +import { + layoutAlgoManager as layoutAlgoManagerSingleton, + getGroupManager, + zoomToFitManager as zoomToFitManagerSingleton, + labelEndpointManager as labelEndpointManagerSingleton +} from "../core/managerRegistry"; +import { log } from "../logging/logger"; +import { perfMark, perfMeasure } from "../utilities/performanceMonitor"; +import { registerCyEventHandlers } from "./cyEventHandlers"; +import { PerformanceMonitor } from "../utilities/performanceMonitor"; +import { debounce } from "../utilities/asyncUtils"; +import { ManagerGridGuide } from "./managerGridGuide"; +import topoViewerState from "../state"; +import type { EdgeData } from "../types/topoViewerGraph"; +import { FilterUtils } from "../../helpers/filterUtils"; +import { isSpecialNodeOrBridge, isSpecialEndpoint } from "../utilities/specialNodes"; import { DEFAULT_INTERFACE_PATTERN, generateInterfaceName, getInterfaceIndex, - parseInterfacePattern, -} from './utilities/interfacePatternUtils'; + parseInterfacePattern +} from "./utilities/interfacePatternUtils"; -if (typeof window !== 'undefined') { +if (typeof window !== "undefined") { (window as any).topoViewerState = topoViewerState; } @@ -88,7 +93,7 @@ type InterfaceStatsPayload = { }; interface ModeSwitchPayload { - mode: 'viewer' | 'editor' | string; + mode: "viewer" | "editor" | string; deploymentState?: string; viewerParams?: ViewerParamsPayload; editorParams?: EditorParamsPayload; @@ -96,8 +101,6 @@ interface ModeSwitchPayload { // Grid guide options now come from shared builder in utilities/gridGuide - - /** * TopologyWebviewController is responsible for initializing the Cytoscape instance, * managing edge creation, node editing and viewport panels/buttons. @@ -110,11 +113,11 @@ class TopologyWebviewController { private isViewportDrawerClabEditorChecked: boolean = true; // Editor mode flag // Reused UI literals to avoid duplicate strings - public static readonly UI_FILL_COLOR = 'rgba(31, 31, 31, 0.75)'; - public static readonly UI_ACTIVE_FILL_COLOR = 'rgba(66, 88, 255, 1)'; - public static readonly UI_ITEM_COLOR = 'white'; - public static readonly UI_ITEM_TEXT_SHADOW = 'rgba(61, 62, 64, 1)'; - public static readonly UI_OPEN_EVENT = 'cxttap'; + public static readonly UI_FILL_COLOR = "rgba(31, 31, 31, 0.75)"; + public static readonly UI_ACTIVE_FILL_COLOR = "rgba(66, 88, 255, 1)"; + public static readonly UI_ITEM_COLOR = "white"; + public static readonly UI_ITEM_TEXT_SHADOW = "rgba(61, 62, 64, 1)"; + public static readonly UI_OPEN_EVENT = "cxttap"; public messageSender!: VscodeMessageSender; public saveManager!: ManagerSaveTopo; @@ -133,15 +136,15 @@ class TopologyWebviewController { public copyPasteManager!: CopyPasteManager; public captureViewportManager!: { viewportButtonsCaptureViewportAsSvg: () => void }; public labSettingsManager?: ManagerLabSettings; - private static readonly CLASS_PANEL_OVERLAY = 'panel-overlay' as const; - private static readonly CLASS_VIEWPORT_DRAWER = 'viewport-drawer' as const; - private static readonly STYLE_LINE_COLOR = 'line-color' as const; - private static readonly KIND_BRIDGE = 'bridge' as const; - private static readonly KIND_OVS_BRIDGE = 'ovs-bridge' as const; + private static readonly CLASS_PANEL_OVERLAY = "panel-overlay" as const; + private static readonly CLASS_VIEWPORT_DRAWER = "viewport-drawer" as const; + private static readonly STYLE_LINE_COLOR = "line-color" as const; + private static readonly KIND_BRIDGE = "bridge" as const; + private static readonly KIND_OVS_BRIDGE = "ovs-bridge" as const; private interfaceCounters: Record = {}; private interfacePatternCache: Map> = new Map(); private labLocked = true; - private currentMode: 'edit' | 'view' = 'edit'; + private currentMode: "edit" | "view" = "edit"; private nodeMenu: any; private edgeMenu: any; private groupMenu: any; @@ -173,29 +176,29 @@ class TopologyWebviewController { g: () => { this.groupManager.viewportButtonsAddGroup(); }, - 'ctrl+a': (event) => { + "ctrl+a": (event) => { event.preventDefault(); this.handleSelectAll(); }, - 'ctrl+c': (event) => { + "ctrl+c": (event) => { event.preventDefault(); this.copyPasteManager.handleCopy(); }, - 'ctrl+v': (event) => { + "ctrl+v": (event) => { if (!this.isViewportDrawerClabEditorChecked) { return; } event.preventDefault(); this.copyPasteManager.handlePaste(); }, - 'ctrl+x': (event) => { + "ctrl+x": (event) => { if (!this.isViewportDrawerClabEditorChecked) { return; } event.preventDefault(); this.handleCutKeyPress(); }, - 'ctrl+d': (event) => { + "ctrl+d": (event) => { if (!this.isViewportDrawerClabEditorChecked) { return; } @@ -203,7 +206,7 @@ class TopologyWebviewController { this.copyPasteManager.handleDuplicate(); } }; - public async initAsync(mode: 'edit' | 'view'): Promise { + public async initAsync(mode: "edit" | "view"): Promise { await this.loadInitialGraph(mode); this.scheduleInitialFit(); this.gridManager.enableSnapping(true); @@ -213,23 +216,23 @@ class TopologyWebviewController { await this.loadGroupStylesSafe(); } - private async loadInitialGraph(mode: 'edit' | 'view'): Promise { - perfMark('cytoscape_style_start'); + private async loadInitialGraph(mode: "edit" | "view"): Promise { + perfMark("cytoscape_style_start"); await loadCytoStyle(this.cy); - perfMeasure('cytoscape_style', 'cytoscape_style_start'); - perfMark('fetch_data_start'); + perfMeasure("cytoscape_style", "cytoscape_style_start"); + perfMark("fetch_data_start"); await fetchAndLoadData(this.cy, this.messageSender); - if (mode === 'edit') { - this.cy.edges().forEach(edge => { - edge.removeClass('link-up'); - edge.removeClass('link-down'); + if (mode === "edit") { + this.cy.edges().forEach((edge) => { + edge.removeClass("link-up"); + edge.removeClass("link-down"); }); - log.debug('initAsync: cleared link state classes for edit mode'); + log.debug("initAsync: cleared link state classes for edit mode"); } - perfMeasure('fetch_data', 'fetch_data_start'); - perfMeasure('topoViewer_init_total', 'topoViewer_init_start'); + perfMeasure("fetch_data", "fetch_data_start"); + perfMeasure("topoViewer_init_total", "topoViewer_init_start"); this.initialGraphLoaded = true; - this.messageSender.sendMessageToVscodeEndpointPost('performance-metrics', { + this.messageSender.sendMessageToVscodeEndpointPost("performance-metrics", { metrics: PerformanceMonitor.getMeasures() }); } @@ -238,9 +241,9 @@ class TopologyWebviewController { if (this.cy.elements().length === 0) { return; } - if (typeof requestAnimationFrame === 'undefined') { + if (typeof requestAnimationFrame === "undefined") { this.cy.fit(this.cy.elements(), 50); - log.debug('Viewport fitted immediately (no RAF available)'); + log.debug("Viewport fitted immediately (no RAF available)"); return; } // eslint-disable-next-line no-undef @@ -248,7 +251,7 @@ class TopologyWebviewController { this.cy.animate({ fit: { eles: this.cy.elements(), padding: 50 }, duration: 150, - easing: 'ease-out' + easing: "ease-out" }); }); } @@ -256,15 +259,17 @@ class TopologyWebviewController { private fetchEnvironmentMetadata(): void { void (async () => { try { - const result = await fetchAndLoadDataEnvironment(['clab-name', 'clab-prefix']); - const labName = result['clab-name'] || 'Unknown'; + const result = await fetchAndLoadDataEnvironment(["clab-name", "clab-prefix"]); + const labName = result["clab-name"] || "Unknown"; this.updateSubtitle(labName); topoViewerState.labName = labName; - if (typeof result['clab-prefix'] === 'string') { - topoViewerState.prefixName = result['clab-prefix'] as string; + if (typeof result["clab-prefix"] === "string") { + topoViewerState.prefixName = result["clab-prefix"] as string; } } catch (error) { - log.error(`Error loading environment data: ${error instanceof Error ? error.message : String(error)}`); + log.error( + `Error loading environment data: ${error instanceof Error ? error.message : String(error)}` + ); } })(); } @@ -274,9 +279,9 @@ class TopologyWebviewController { this.applyLockState(this.labLocked); } - private async configureModeHandlers(mode: 'edit' | 'view'): Promise { + private async configureModeHandlers(mode: "edit" | "view"): Promise { await this.registerEvents(mode); - if (mode === 'edit') { + if (mode === "edit") { this.setupAutoSave(); setTimeout(() => this.initializeEdgehandles(), 50); } else { @@ -293,8 +298,6 @@ class TopologyWebviewController { } } - - // Add automatic save on change private setupAutoSave(): void { if (this.editAutoSaveConfigured) { @@ -302,9 +305,9 @@ class TopologyWebviewController { } this.editAutoSaveConfigured = true; const autoSave = this.createDebouncedAutoSave(); - this.cy.on('add remove data', (event) => this.handleNodeEvent(event, autoSave)); - this.cy.on('position', (event) => this.handleNodePositionEvent(event, autoSave)); - this.cy.on('dragfree', 'node', (event) => this.handleNodeEvent(event, autoSave)); + this.cy.on("add remove data", (event) => this.handleNodeEvent(event, autoSave)); + this.cy.on("position", (event) => this.handleNodePositionEvent(event, autoSave)); + this.cy.on("dragfree", "node", (event) => this.handleNodeEvent(event, autoSave)); } // Add automatic save for view mode (only saves annotations.json) @@ -314,8 +317,8 @@ class TopologyWebviewController { } this.viewAutoSaveConfigured = true; const autoSaveViewMode = this.createDebouncedViewAutoSave(); - this.cy.on('position', (event) => this.handleNodePositionEvent(event, autoSaveViewMode)); - this.cy.on('dragfree', 'node', (event) => this.handleNodeEvent(event, autoSaveViewMode)); + this.cy.on("position", (event) => this.handleNodePositionEvent(event, autoSaveViewMode)); + this.cy.on("dragfree", "node", (event) => this.handleNodeEvent(event, autoSaveViewMode)); } private createDebouncedAutoSave(): () => void { @@ -371,7 +374,7 @@ class TopologyWebviewController { if (!target || !target.isNode()) { return true; } - return target.data('topoViewerRole') === 'freeText'; + return target.data("topoViewerRole") === "freeText"; } private suspendAutoSave(): void { @@ -400,7 +403,7 @@ class TopologyWebviewController { private registerCustomZoom(): void { this.cy.userZoomingEnabled(false); const container = this.cy.container(); - container?.addEventListener('wheel', this.handleCustomWheel, { passive: false }); + container?.addEventListener("wheel", this.handleCustomWheel, { passive: false }); } private handleCustomWheel = (event: WheelEvent): void => { @@ -411,13 +414,14 @@ class TopologyWebviewController { } else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) { step *= window.innerHeight; } - const isTrackpad = event.deltaMode === WheelEvent.DOM_DELTA_PIXEL && Math.abs(event.deltaY) < 50; + const isTrackpad = + event.deltaMode === WheelEvent.DOM_DELTA_PIXEL && Math.abs(event.deltaY) < 50; const sensitivity = isTrackpad ? 0.002 : 0.0002; const factor = Math.pow(10, -step * sensitivity); const newZoom = this.cy.zoom() * factor; this.cy.zoom({ level: newZoom, - renderedPosition: { x: event.offsetX, y: event.offsetY }, + renderedPosition: { x: event.offsetX, y: event.offsetY } }); }; @@ -426,8 +430,8 @@ class TopologyWebviewController { * @param containerId - The ID of the container element for Cytoscape. * @throws Will throw an error if the container element is not found. */ - constructor(containerId: string, mode: 'edit' | 'view' = 'edit') { - perfMark('topoViewer_init_start'); + constructor(containerId: string, mode: "edit" | "view" = "edit") { + perfMark("topoViewer_init_start"); this.currentMode = mode; (topoViewerState as any).currentMode = mode; const container = this.getContainer(containerId); @@ -436,7 +440,7 @@ class TopologyWebviewController { this.initializeCytoscape(container, theme); this.initializeManagers(mode); - window.addEventListener('topology-lock-change', (e: any) => { + window.addEventListener("topology-lock-change", (e: any) => { this.applyLockState(!!e.detail); }); } @@ -450,42 +454,42 @@ class TopologyWebviewController { } private initializeCytoscape(container: HTMLElement, theme: string): void { - perfMark('cytoscape_create_start'); + perfMark("cytoscape_create_start"); this.cy = createConfiguredCytoscape(container); - perfMeasure('cytoscape_create', 'cytoscape_create_start'); + perfMeasure("cytoscape_create", "cytoscape_create_start"); this.cy.viewport({ zoom: 1, - pan: { x: container.clientWidth / 2, y: container.clientHeight / 2 }, + pan: { x: container.clientWidth / 2, y: container.clientHeight / 2 } }); - const cyContainer = document.getElementById('cy') as HTMLDivElement | null; + const cyContainer = document.getElementById("cy") as HTMLDivElement | null; if (cyContainer) { cyContainer.tabIndex = 0; - cyContainer.addEventListener('mousedown', () => { + cyContainer.addEventListener("mousedown", () => { cyContainer.focus(); }); } this.registerCustomZoom(); - this.cy.on('tap', (event) => { + this.cy.on("tap", (event) => { log.debug(`Cytoscape event: ${event.type}`); }); // Initialize unified GridManager (overlay + plugin config) this.gridManager = new ManagerGridGuide(this.cy); - this.gridManager.initialize(theme as 'light' | 'dark'); + this.gridManager.initialize(theme as "light" | "dark"); // Provide a global hook for theme updates from outside - (window as any).updateTopoGridTheme = (newTheme: 'light' | 'dark') => { + (window as any).updateTopoGridTheme = (newTheme: "light" | "dark") => { this.gridManager.updateTheme(newTheme); }; } - private initializeManagers(mode: 'edit' | 'view'): void { + private initializeManagers(mode: "edit" | "view"): void { this.setupManagers(mode); this.registerDoubleClickHandlers(); this.exposeWindowFunctions(); this.registerMessageListener(); - document.getElementById('cy')?.focus(); + document.getElementById("cy")?.focus(); } - private setupManagers(mode: 'edit' | 'view'): void { + private setupManagers(mode: "edit" | "view"): void { // eslint-disable-next-line sonarjs/constructor-for-side-effects new ManagerShortcutDisplay(); this.saveManager = new ManagerSaveTopo(this.messageSender); @@ -494,15 +498,29 @@ class TopologyWebviewController { this.labSettingsManager = new ManagerLabSettings(this.messageSender); this.labSettingsManager.init(); this.freeTextManager = new ManagerFreeText(this.cy, this.messageSender); - this.groupStyleManager = new ManagerGroupStyle(this.cy, this.messageSender, this.freeTextManager); + this.groupStyleManager = new ManagerGroupStyle( + this.cy, + this.messageSender, + this.freeTextManager + ); this.freeTextManager.setGroupStyleManager(this.groupStyleManager); - this.copyPasteManager = new CopyPasteManager(this.cy, this.messageSender, this.groupStyleManager, this.freeTextManager); - if (mode === 'edit') { + this.copyPasteManager = new CopyPasteManager( + this.cy, + this.messageSender, + this.groupStyleManager, + this.freeTextManager + ); + if (mode === "edit") { this.viewportPanels = new ManagerViewportPanels(this.saveManager, this.cy); (window as any).viewportPanels = this.viewportPanels; this.nodeEditor = new ManagerNodeEditor(this.cy, this.saveManager); } - this.unifiedFloatingPanel = new ManagerUnifiedFloatingPanel(this.cy, this.messageSender, this.addNodeManager, this.nodeEditor); + this.unifiedFloatingPanel = new ManagerUnifiedFloatingPanel( + this.cy, + this.messageSender, + this.addNodeManager, + this.nodeEditor + ); this.groupManager = getGroupManager(this.cy, this.groupStyleManager, mode); this.groupManager.initializeWheelSelection(); this.groupManager.initializeGroupManagement(); @@ -510,11 +528,11 @@ class TopologyWebviewController { this.zoomToFitManager = zoomToFitManagerSingleton; this.labelEndpointManager = labelEndpointManagerSingleton; this.labelEndpointManager.initialize(this.cy); - this.isViewportDrawerClabEditorChecked = mode === 'edit'; + this.isViewportDrawerClabEditorChecked = mode === "edit"; this.captureViewportManager = { viewportButtonsCaptureViewportAsSvg: () => { viewportButtonsCaptureViewportAsSvg(); - }, + } }; } @@ -534,26 +552,30 @@ class TopologyWebviewController { ): string { const hasNode = node && !node.empty(); const extraData = hasNode - ? (node!.data('extraData') as { interfacePattern?: unknown; kind?: unknown } | undefined) + ? (node!.data("extraData") as { interfacePattern?: unknown; kind?: unknown } | undefined) : undefined; - const customPattern = typeof extraData?.interfacePattern === 'string' ? extraData.interfacePattern.trim() : ''; + const customPattern = + typeof extraData?.interfacePattern === "string" ? extraData.interfacePattern.trim() : ""; if (customPattern) { return customPattern; } - const kind = typeof extraData?.kind === 'string' && extraData.kind ? (extraData.kind as string) : 'default'; + const kind = + typeof extraData?.kind === "string" && extraData.kind + ? (extraData.kind as string) + : "default"; return ifaceMap[kind] || DEFAULT_INTERFACE_PATTERN; } private registerDoubleClickHandlers(): void { - this.cy.on('dblclick', 'node[topoViewerRole != "freeText"]', (event) => { + this.cy.on("dblclick", 'node[topoViewerRole != "freeText"]', (event) => { if (this.labLocked) { this.showLockedMessage(); return; } const node = event.target; - if (node.data('topoViewerRole') === 'group') { + if (node.data("topoViewerRole") === "group") { this.groupManager.showGroupEditor(node); - } else if (node.data('topoViewerRole') === 'cloud') { + } else if (node.data("topoViewerRole") === "cloud") { this.viewportPanels?.panelNetworkEditor(node); } else if (this.nodeEditor) { void this.nodeEditor.open(node); @@ -561,7 +583,7 @@ class TopologyWebviewController { this.viewportPanels?.panelNodeEditor(node); } }); - this.cy.on('dblclick', 'edge', (event) => { + this.cy.on("dblclick", "edge", (event) => { if (this.labLocked) { this.showLockedMessage(); return; @@ -572,38 +594,58 @@ class TopologyWebviewController { } private exposeWindowFunctions(): void { - window.viewportButtonsLayoutAlgo = this.layoutAlgoManager.viewportButtonsLayoutAlgo.bind(this.layoutAlgoManager); + window.viewportButtonsLayoutAlgo = this.layoutAlgoManager.viewportButtonsLayoutAlgo.bind( + this.layoutAlgoManager + ); window.layoutAlgoChange = this.layoutAlgoManager.layoutAlgoChange.bind(this.layoutAlgoManager); - window.viewportDrawerLayoutGeoMap = this.layoutAlgoManager.viewportDrawerLayoutGeoMap.bind(this.layoutAlgoManager); - window.viewportDrawerDisableGeoMap = this.layoutAlgoManager.viewportDrawerDisableGeoMap.bind(this.layoutAlgoManager); - window.viewportDrawerLayoutForceDirected = this.layoutAlgoManager.viewportDrawerLayoutForceDirected.bind(this.layoutAlgoManager); - window.viewportDrawerLayoutForceDirectedRadial = this.layoutAlgoManager.viewportDrawerLayoutForceDirectedRadial.bind(this.layoutAlgoManager); - window.viewportDrawerLayoutVertical = this.layoutAlgoManager.viewportDrawerLayoutVertical.bind(this.layoutAlgoManager); - window.viewportDrawerLayoutHorizontal = this.layoutAlgoManager.viewportDrawerLayoutHorizontal.bind(this.layoutAlgoManager); - window.viewportDrawerPreset = this.layoutAlgoManager.viewportDrawerPreset.bind(this.layoutAlgoManager); - window.viewportButtonsGeoMapPan = this.layoutAlgoManager.viewportButtonsGeoMapPan.bind(this.layoutAlgoManager); - window.viewportButtonsGeoMapEdit = this.layoutAlgoManager.viewportButtonsGeoMapEdit.bind(this.layoutAlgoManager); + window.viewportDrawerLayoutGeoMap = this.layoutAlgoManager.viewportDrawerLayoutGeoMap.bind( + this.layoutAlgoManager + ); + window.viewportDrawerDisableGeoMap = this.layoutAlgoManager.viewportDrawerDisableGeoMap.bind( + this.layoutAlgoManager + ); + window.viewportDrawerLayoutForceDirected = + this.layoutAlgoManager.viewportDrawerLayoutForceDirected.bind(this.layoutAlgoManager); + window.viewportDrawerLayoutForceDirectedRadial = + this.layoutAlgoManager.viewportDrawerLayoutForceDirectedRadial.bind(this.layoutAlgoManager); + window.viewportDrawerLayoutVertical = this.layoutAlgoManager.viewportDrawerLayoutVertical.bind( + this.layoutAlgoManager + ); + window.viewportDrawerLayoutHorizontal = + this.layoutAlgoManager.viewportDrawerLayoutHorizontal.bind(this.layoutAlgoManager); + window.viewportDrawerPreset = this.layoutAlgoManager.viewportDrawerPreset.bind( + this.layoutAlgoManager + ); + window.viewportButtonsGeoMapPan = this.layoutAlgoManager.viewportButtonsGeoMapPan.bind( + this.layoutAlgoManager + ); + window.viewportButtonsGeoMapEdit = this.layoutAlgoManager.viewportButtonsGeoMapEdit.bind( + this.layoutAlgoManager + ); window.viewportButtonsTopologyOverview = this.viewportButtonsTopologyOverview.bind(this); window.viewportButtonsZoomToFit = () => this.zoomToFitManager.viewportButtonsZoomToFit(this.cy); - window.viewportButtonsCaptureViewportAsSvg = () => this.captureViewportManager.viewportButtonsCaptureViewportAsSvg(); + window.viewportButtonsCaptureViewportAsSvg = () => + this.captureViewportManager.viewportButtonsCaptureViewportAsSvg(); window.viewportButtonsUndo = () => this.undoManager.viewportButtonsUndo(); // Grid controls: allow UI to adjust grid line width at runtime (window as any).viewportDrawerGridLineWidthChange = (value: string | number) => { - const n = typeof value === 'number' ? value : parseFloat(String(value)); + const n = typeof value === "number" ? value : parseFloat(String(value)); if (!Number.isNaN(n)) { this.gridManager?.setLineWidth(n); } }; (window as any).viewportDrawerGridLineWidthReset = () => { const def = 0.5; - const el = document.getElementById('viewport-drawer-grid-line-width') as HTMLInputElement | null; + const el = document.getElementById( + "viewport-drawer-grid-line-width" + ) as HTMLInputElement | null; if (el) el.value = String(def); this.gridManager?.setLineWidth(def); }; } private registerMessageListener(): void { - window.addEventListener('message', (event) => { + window.addEventListener("message", (event) => { if (event.origin !== window.location.origin) { return; } @@ -618,27 +660,31 @@ class TopologyWebviewController { private dispatchIncomingMessage(msg: any): void { const runHandler = (type: string, fn: () => void | Promise): void => { Promise.resolve(fn()).catch((error) => { - log.error(`Error handling message "${type}": ${error instanceof Error ? error.message : String(error)}`); + log.error( + `Error handling message "${type}": ${error instanceof Error ? error.message : String(error)}` + ); }); }; switch (msg.type) { - case 'yaml-saved': + case "yaml-saved": runHandler(msg.type, async () => { await fetchAndLoadData(this.cy, this.messageSender, { incremental: true }); }); return; - case 'updateTopology': + case "updateTopology": runHandler(msg.type, () => this.updateTopology(msg.data)); return; - case 'copiedElements': + case "copiedElements": runHandler(msg.type, () => this.handleCopiedElements(msg.data)); return; - case 'topo-mode-changed': + case "topo-mode-changed": runHandler(msg.type, () => this.handleModeSwitchMessage(msg.data as ModeSwitchPayload)); return; - case 'docker-images-updated': - runHandler(msg.type, () => this.handleDockerImagesUpdatedMessage(msg.dockerImages as string[])); + case "docker-images-updated": + runHandler(msg.type, () => + this.handleDockerImagesUpdatedMessage(msg.dockerImages as string[]) + ); return; default: return; @@ -647,7 +693,7 @@ class TopologyWebviewController { private handleDockerImagesUpdatedMessage(images?: string[]): void { const nextImages = Array.isArray(images) ? images : []; - this.assignWindowValue('dockerImages', nextImages, []); + this.assignWindowValue("dockerImages", nextImages, []); this.nodeEditor?.handleDockerImagesUpdated(nextImages); } @@ -665,12 +711,12 @@ class TopologyWebviewController { const existing = this.cy.getElementById(id); if (existing && existing.length > 0) { existing.data(el.data); - if (typeof el.classes === 'string') { + if (typeof el.classes === "string") { existing.classes(el.classes); } - if (this.currentMode === 'edit' && existing.isEdge()) { - existing.removeClass('link-up'); - existing.removeClass('link-down'); + if (this.currentMode === "edit" && existing.isEdge()) { + existing.removeClass("link-up"); + existing.removeClass("link-down"); } if (existing.isEdge()) { this.refreshLinkPanelIfSelected(existing); @@ -697,27 +743,34 @@ class TopologyWebviewController { } } - private normalizeModeFromPayload(payload: ModeSwitchPayload): { normalized: 'viewer' | 'editor'; target: 'edit' | 'view' } { - const normalized = payload.mode === 'viewer' ? 'viewer' : 'editor'; - const target: 'edit' | 'view' = normalized === 'viewer' ? 'view' : 'edit'; + private normalizeModeFromPayload(payload: ModeSwitchPayload): { + normalized: "viewer" | "editor"; + target: "edit" | "view"; + } { + const normalized = payload.mode === "viewer" ? "viewer" : "editor"; + const target: "edit" | "view" = normalized === "viewer" ? "view" : "edit"; return { normalized, target }; } - private setGlobalModeState(normalized: 'viewer' | 'editor', target: 'edit' | 'view', deploymentState?: string): void { + private setGlobalModeState( + normalized: "viewer" | "editor", + target: "edit" | "view", + deploymentState?: string + ): void { (window as any).topoViewerMode = normalized; (topoViewerState as any).currentMode = target; this.currentMode = target; - this.isViewportDrawerClabEditorChecked = target === 'edit'; - if (typeof deploymentState === 'string') { + this.isViewportDrawerClabEditorChecked = target === "edit"; + if (typeof deploymentState === "string") { topoViewerState.deploymentType = deploymentState; } } private resolveLockPreference(payload: ModeSwitchPayload): boolean | undefined { - if (typeof payload.editorParams?.lockLabByDefault === 'boolean') { + if (typeof payload.editorParams?.lockLabByDefault === "boolean") { return payload.editorParams.lockLabByDefault; } - if (typeof payload.viewerParams?.lockLabByDefault === 'boolean') { + if (typeof payload.viewerParams?.lockLabByDefault === "boolean") { return payload.viewerParams.lockLabByDefault; } return undefined; @@ -727,28 +780,31 @@ class TopologyWebviewController { if (!params) { return; } - this.assignWindowValue('lockLabByDefault', params.lockLabByDefault); - this.assignWindowValue('currentLabPath', params.currentLabPath); + this.assignWindowValue("lockLabByDefault", params.lockLabByDefault); + this.assignWindowValue("currentLabPath", params.currentLabPath); } private applyEditorParameters(params?: EditorParamsPayload): void { if (!params) { return; } - this.assignWindowValue('lockLabByDefault', params.lockLabByDefault); - this.assignWindowValue('imageMapping', params.imageMapping, {}); - this.assignWindowValue('ifacePatternMapping', params.ifacePatternMapping, {}); - this.assignWindowValue('defaultKind', params.defaultKind, 'nokia_srlinux'); - this.assignWindowValue('defaultType', params.defaultType, ''); - this.assignWindowValue('updateLinkEndpointsOnKindChange', params.updateLinkEndpointsOnKindChange); - this.assignWindowValue('customNodes', params.customNodes, []); - this.assignWindowValue('defaultNode', params.defaultNode, ''); - this.assignWindowValue('topologyDefaults', params.topologyDefaults, {}); - this.assignWindowValue('topologyKinds', params.topologyKinds, {}); - this.assignWindowValue('topologyGroups', params.topologyGroups, {}); - this.assignWindowValue('dockerImages', params.dockerImages, []); - this.assignWindowValue('currentLabPath', params.currentLabPath); - this.assignWindowValue('customIcons', params.customIcons, {}); + this.assignWindowValue("lockLabByDefault", params.lockLabByDefault); + this.assignWindowValue("imageMapping", params.imageMapping, {}); + this.assignWindowValue("ifacePatternMapping", params.ifacePatternMapping, {}); + this.assignWindowValue("defaultKind", params.defaultKind, "nokia_srlinux"); + this.assignWindowValue("defaultType", params.defaultType, ""); + this.assignWindowValue( + "updateLinkEndpointsOnKindChange", + params.updateLinkEndpointsOnKindChange + ); + this.assignWindowValue("customNodes", params.customNodes, []); + this.assignWindowValue("defaultNode", params.defaultNode, ""); + this.assignWindowValue("topologyDefaults", params.topologyDefaults, {}); + this.assignWindowValue("topologyKinds", params.topologyKinds, {}); + this.assignWindowValue("topologyGroups", params.topologyGroups, {}); + this.assignWindowValue("dockerImages", params.dockerImages, []); + this.assignWindowValue("currentLabPath", params.currentLabPath); + this.assignWindowValue("customIcons", params.customIcons, {}); } private assignWindowValue(key: string, value: T | undefined, fallback?: T): void { @@ -761,9 +817,9 @@ class TopologyWebviewController { } } - private async ensureModeResources(mode: 'edit' | 'view'): Promise { + private async ensureModeResources(mode: "edit" | "view"): Promise { await this.registerEvents(mode); - if (mode === 'edit') { + if (mode === "edit") { if (!this.viewportPanels) { this.viewportPanels = new ManagerViewportPanels(this.saveManager, this.cy); (window as any).viewportPanels = this.viewportPanels; @@ -776,13 +832,13 @@ class TopologyWebviewController { this.setupAutoSaveViewMode(); } this.unifiedFloatingPanel?.setNodeEditor(this.nodeEditor ?? null); - this.toggleEdgehandles(mode === 'edit'); + this.toggleEdgehandles(mode === "edit"); await this.initializeContextMenu(); } - private finalizeModeChange(normalized: 'viewer' | 'editor'): void { + private finalizeModeChange(normalized: "viewer" | "editor"): void { this.updateModeIndicator(normalized); - document.dispatchEvent(new CustomEvent('topo-mode-changed')); + document.dispatchEvent(new CustomEvent("topo-mode-changed")); this.unifiedFloatingPanel?.updateState(); } @@ -792,7 +848,7 @@ class TopologyWebviewController { } if (this.modeTransitionInProgress) { - log.warn('Mode transition already in progress; ignoring new mode switch request'); + log.warn("Mode transition already in progress; ignoring new mode switch request"); return; } @@ -811,15 +867,17 @@ class TopologyWebviewController { this.initialGraphLoaded = true; } await this.ensureModeResources(target); - if (target === 'edit') { - this.cy.edges().forEach(edge => { - edge.removeClass('link-up'); - edge.removeClass('link-down'); + if (target === "edit") { + this.cy.edges().forEach((edge) => { + edge.removeClass("link-up"); + edge.removeClass("link-down"); }); - window.writeTopoDebugLog?.('handleModeSwitchMessage: cleared link state classes for edit mode'); + window.writeTopoDebugLog?.( + "handleModeSwitchMessage: cleared link state classes for edit mode" + ); } - if (typeof resolvedLock === 'boolean') { + if (typeof resolvedLock === "boolean") { this.labLocked = resolvedLock; } this.applyLockState(this.labLocked); @@ -827,7 +885,9 @@ class TopologyWebviewController { this.finalizeModeChange(normalized); log.info(`Mode switched to ${target}`); } catch (error) { - log.error(`Error handling mode switch: ${error instanceof Error ? error.message : String(error)}`); + log.error( + `Error handling mode switch: ${error instanceof Error ? error.message : String(error)}` + ); } finally { this.modeTransitionInProgress = false; } @@ -841,7 +901,7 @@ class TopologyWebviewController { private async initializeEdgehandles(): Promise { // Load edgehandles extension lazily this.interfaceCounters = {}; - await loadExtension('edgehandles'); + await loadExtension("edgehandles"); const edgehandlesOptions = { hoverDelay: 50, snap: false, @@ -850,19 +910,25 @@ class TopologyWebviewController { noEdgeEventsInDraw: false, disableBrowserGestures: false, handleNodes: 'node[topoViewerRole != "freeText"]', - canConnect: (sourceNode: cytoscape.NodeSingular, targetNode: cytoscape.NodeSingular): boolean => { - const sourceRole = sourceNode.data('topoViewerRole'); - const targetRole = targetNode.data('topoViewerRole'); + canConnect: ( + sourceNode: cytoscape.NodeSingular, + targetNode: cytoscape.NodeSingular + ): boolean => { + const sourceRole = sourceNode.data("topoViewerRole"); + const targetRole = targetNode.data("topoViewerRole"); return ( - sourceRole !== 'freeText' && - targetRole !== 'freeText' && + sourceRole !== "freeText" && + targetRole !== "freeText" && !sourceNode.same(targetNode) && !sourceNode.isParent() && !targetNode.isParent() && - targetRole !== 'group' + targetRole !== "group" ); }, - edgeParams: (sourceNode: cytoscape.NodeSingular, targetNode: cytoscape.NodeSingular): EdgeData => { + edgeParams: ( + sourceNode: cytoscape.NodeSingular, + targetNode: cytoscape.NodeSingular + ): EdgeData => { const ifaceMap = window.ifacePatternMapping || {}; const srcPattern = this.resolveInterfacePattern(sourceNode, ifaceMap); const dstPattern = this.resolveInterfacePattern(targetNode, ifaceMap); @@ -883,9 +949,9 @@ class TopologyWebviewController { source: sourceNode.id(), target: targetNode.id(), sourceEndpoint, - targetEndpoint, + targetEndpoint }; - }, + } }; this.eh = (this.cy as any).edgehandles(edgehandlesOptions); @@ -908,12 +974,11 @@ class TopologyWebviewController { } } - /** * Initializes the circular context menus. */ private async initializeContextMenu(): Promise { - await loadExtension('cxtmenu'); + await loadExtension("cxtmenu"); if (!this.freeTextMenu) { this.freeTextMenu = this.initializeFreeTextContextMenu(); } @@ -943,7 +1008,7 @@ class TopologyWebviewController { return; } this.freeTextManager?.editFreeText(ele.id()); - }, + } }, { content: `
    Remove Text
    `, @@ -952,8 +1017,8 @@ class TopologyWebviewController { return; } this.freeTextManager?.removeFreeTextAnnotation(ele.id()); - }, - }, + } + } ]; }, menuRadius: 60, @@ -971,7 +1036,7 @@ class TopologyWebviewController { itemTextShadowColor: TopologyWebviewController.UI_ITEM_TEXT_SHADOW, zIndex: 9999, atMouse: false, - outsideMenuCancel: 10, + outsideMenuCancel: 10 }); } @@ -994,12 +1059,12 @@ class TopologyWebviewController { itemTextShadowColor: TopologyWebviewController.UI_ITEM_TEXT_SHADOW, zIndex: 9999, atMouse: false, - outsideMenuCancel: 10, + outsideMenuCancel: 10 }); } private buildNodeMenuCommands(ele: cytoscape.Singular): any[] { - if (this.currentMode === 'view') { + if (this.currentMode === "view") { return this.buildViewerNodeCommands(ele); } if (this.labLocked) { @@ -1036,8 +1101,8 @@ class TopologyWebviewController { } private createEditCommand(isNetwork: boolean): any { - const label = isNetwork ? 'Edit Network' : 'Edit Node'; - return this.createNodeMenuItem('fas fa-pen-to-square', label, (node) => { + const label = isNetwork ? "Edit Network" : "Edit Node"; + return this.createNodeMenuItem("fas fa-pen-to-square", label, (node) => { this.viewportPanels?.setNodeClicked(true); if (isNetwork) { this.viewportPanels?.panelNetworkEditor(node); @@ -1048,7 +1113,7 @@ class TopologyWebviewController { } private createDeleteCommand(): any { - return this.createNodeMenuItem('fas fa-trash-alt', 'Delete Node', (node) => { + return this.createNodeMenuItem("fas fa-trash-alt", "Delete Node", (node) => { const parent = node.parent(); node.remove(); if (parent.nonempty() && parent.children().length === 0) { @@ -1062,7 +1127,7 @@ class TopologyWebviewController { await this.initializeEdgehandles(); return; } - if (typeof this.eh.enable === 'function') { + if (typeof this.eh.enable === "function") { this.eh.enable(); } } @@ -1070,7 +1135,7 @@ class TopologyWebviewController { private async startEdgeCreationFromNode(node: cytoscape.NodeSingular): Promise { await this.ensureEdgehandlesReady(); if (!this.eh) { - log.error('Edgehandles is not available; unable to start edge creation.'); + log.error("Edgehandles is not available; unable to start edge creation."); return; } this.isEdgeHandlerActive = true; @@ -1078,13 +1143,13 @@ class TopologyWebviewController { } private createAddLinkCommand(): any { - return this.createNodeMenuItem('fas fa-link', 'Add Link', async (node) => { + return this.createNodeMenuItem("fas fa-link", "Add Link", async (node) => { await this.startEdgeCreationFromNode(node); }); } private createReleaseFromGroupCommand(): any { - return this.createNodeMenuItem('fas fa-users-slash', 'Release from Group', (node) => { + return this.createNodeMenuItem("fas fa-users-slash", "Release from Group", (node) => { setTimeout(() => { this.groupManager.orphaningNode(node); }, 50); @@ -1092,7 +1157,7 @@ class TopologyWebviewController { } private getNodeName(node: cytoscape.NodeSingular): string { - return node.data('extraData')?.longname || node.data('name') || node.id(); + return node.data("extraData")?.longname || node.data("name") || node.id(); } private initializeGroupContextMenu(): any { @@ -1114,7 +1179,7 @@ class TopologyWebviewController { itemTextShadowColor: TopologyWebviewController.UI_ITEM_TEXT_SHADOW, zIndex: 9999, atMouse: false, - outsideMenuCancel: 10, + outsideMenuCancel: 10 }); } @@ -1137,13 +1202,13 @@ class TopologyWebviewController { return; } this.viewportPanels?.setNodeClicked(true); - if (node.data('topoViewerRole') === 'group') { - if (this.currentMode === 'view') { + if (node.data("topoViewerRole") === "group") { + if (this.currentMode === "view") { this.suppressViewerCanvasClose = true; } this.groupManager.showGroupEditor(node); } - }, + } }, { content: `
    Delete Group
    `, @@ -1152,19 +1217,19 @@ class TopologyWebviewController { if (!node) { return; } - const role = node.data('topoViewerRole'); - if (role === 'group' || node.isParent()) { + const role = node.data("topoViewerRole"); + if (role === "group" || node.isParent()) { this.groupManager.directGroupRemoval(node.id()); } - }, - }, + } + } ]; } private resolveGroupMenuTarget(ele?: cytoscape.Singular): cytoscape.NodeSingular | undefined { if (ele && ele.isNode()) { const node = ele as cytoscape.NodeSingular; - if (!node.removed() && (node.data('topoViewerRole') === 'group' || node.isParent())) { + if (!node.removed() && (node.data("topoViewerRole") === "group" || node.isParent())) { this.activeGroupMenuTarget = node; return node; } @@ -1180,9 +1245,9 @@ class TopologyWebviewController { private initializeEdgeContextMenu(): any { return this.cy.cxtmenu({ - selector: 'edge', + selector: "edge", commands: (ele: cytoscape.Singular) => { - if (this.currentMode === 'view') { + if (this.currentMode === "view") { return this.buildViewerEdgeMenuCommands(ele); } if (this.labLocked) { @@ -1205,7 +1270,7 @@ class TopologyWebviewController { itemTextShadowColor: TopologyWebviewController.UI_ITEM_TEXT_SHADOW, zIndex: 9999, atMouse: false, - outsideMenuCancel: 10, + outsideMenuCancel: 10 }); } @@ -1218,7 +1283,7 @@ class TopologyWebviewController { if (!edge.isEdge()) return; this.viewportPanels?.setEdgeClicked(true); this.viewportPanels?.panelEdgeEditor(edge); - }, + } }); // Delete link @@ -1226,31 +1291,33 @@ class TopologyWebviewController { content: `
    Delete Link
    `, select: (edge: cytoscape.Singular) => { edge.remove(); - }, + } }); return commands; } - private buildViewerNodeCommands(ele: cytoscape.Singular): any[] { if (this.isNetworkNode(ele.id())) { return []; } const commands = [ - this.createNodeMenuItem('fas fa-terminal', 'SSH', async (node) => { + this.createNodeMenuItem("fas fa-terminal", "SSH", async (node) => { const nodeName = this.getNodeName(node); - await this.messageSender.sendMessageToVscodeEndpointPost('clab-node-connect-ssh', nodeName); + await this.messageSender.sendMessageToVscodeEndpointPost("clab-node-connect-ssh", nodeName); }), - this.createNodeMenuItem('fas fa-cube', 'Shell', async (node) => { + this.createNodeMenuItem("fas fa-cube", "Shell", async (node) => { const nodeName = this.getNodeName(node); - await this.messageSender.sendMessageToVscodeEndpointPost('clab-node-attach-shell', nodeName); + await this.messageSender.sendMessageToVscodeEndpointPost( + "clab-node-attach-shell", + nodeName + ); }), - this.createNodeMenuItem('fas fa-file-alt', 'Logs', async (node) => { + this.createNodeMenuItem("fas fa-file-alt", "Logs", async (node) => { const nodeName = this.getNodeName(node); - await this.messageSender.sendMessageToVscodeEndpointPost('clab-node-view-logs', nodeName); + await this.messageSender.sendMessageToVscodeEndpointPost("clab-node-view-logs", nodeName); }), - this.createNodeMenuItem('fas fa-info-circle', 'Properties', (node) => { + this.createNodeMenuItem("fas fa-info-circle", "Properties", (node) => { setTimeout(() => this.showNodePropertiesPanel(node as unknown as cytoscape.Singular), 50); }) ]; @@ -1261,19 +1328,25 @@ class TopologyWebviewController { } private buildViewerEdgeMenuCommands(ele: cytoscape.Singular): any[] { - const commands = [ - ...this.buildEdgeCaptureCommands(ele), - { - content: `
    Properties
    `, - select: (edge: cytoscape.Singular) => { - if (!edge.isEdge()) { - return; - } - setTimeout(() => this.showLinkPropertiesPanel(edge), 50); - }, - }, - ]; - return commands; + const captureCommands = this.buildEdgeCaptureCommands(ele); + const propertiesCommand = { + content: `
    Properties
    `, + select: (edge: cytoscape.Singular) => { + if (!edge.isEdge()) { + return; + } + setTimeout(() => this.showLinkPropertiesPanel(edge), 50); + } + }; + + // For 3 items in circular menu: item at 0°=top, 120°=bottom-right, 240°=bottom-left + // Place: Capture1 (top), Properties (bottom-right), Capture2 (bottom-left) + // This gives packet captures the top 2/3 arc and properties in bottom 1/3 + if (captureCommands.length === 2) { + return [captureCommands[0], propertiesCommand, captureCommands[1]]; + } + // Fallback for other cases + return [...captureCommands, propertiesCommand]; } private buildEdgeCaptureCommands(ele: cytoscape.Singular): any[] { @@ -1286,13 +1359,13 @@ class TopologyWebviewController { if (srcNode && srcIf) { items.push({ content: this.buildCaptureMenuContent(imagesUrl, srcNode, srcIf), - select: this.captureInterface.bind(this, srcNode, srcIf), + select: this.captureInterface.bind(this, srcNode, srcIf) }); } if (dstNode && dstIf) { items.push({ content: this.buildCaptureMenuContent(imagesUrl, dstNode, dstIf), - select: this.captureInterface.bind(this, dstNode, dstIf), + select: this.captureInterface.bind(this, dstNode, dstIf) }); } @@ -1300,7 +1373,7 @@ class TopologyWebviewController { } private getImagesUrl(): string { - return (window as any).imagesUrl || ''; + return (window as any).imagesUrl || ""; } private buildCaptureMenuContent(imagesUrl: string, name: string, endpoint: string): string { @@ -1311,33 +1384,36 @@ class TopologyWebviewController {
    `; } - private computeEdgeCaptureEndpoints(ele: cytoscape.Singular): { srcNode: string; srcIf: string; dstNode: string; dstIf: string } { + private computeEdgeCaptureEndpoints(ele: cytoscape.Singular): { + srcNode: string; + srcIf: string; + dstNode: string; + dstIf: string; + } { const data = ele.data(); const extra = data.extraData || {}; - const srcNode: string = extra.clabSourceLongName || data.source || ''; - const dstNode: string = extra.clabTargetLongName || data.target || ''; - const srcIf: string = data.sourceEndpoint || ''; - const dstIf: string = data.targetEndpoint || ''; + const srcNode: string = extra.clabSourceLongName || data.source || ""; + const dstNode: string = extra.clabTargetLongName || data.target || ""; + const srcIf: string = data.sourceEndpoint || ""; + const dstIf: string = data.targetEndpoint || ""; return { srcNode, srcIf, dstNode, dstIf }; } private async captureInterface(nodeName: string, interfaceName: string): Promise { - await this.messageSender.sendMessageToVscodeEndpointPost('clab-interface-capture', { + await this.messageSender.sendMessageToVscodeEndpointPost("clab-interface-capture", { nodeName, - interfaceName, + interfaceName }); } - - /** * Registers event handlers for Cytoscape elements such as canvas, nodes, and edges. * @private */ - private async registerEvents(mode: 'edit' | 'view'): Promise { + private async registerEvents(mode: "edit" | "view"): Promise { if (!this.commonTapstartHandlerRegistered) { - this.cy.on('tapstart', 'node', (e) => { - if (this.labLocked && this.currentMode === 'edit') { + this.cy.on("tapstart", "node", (e) => { + if (this.labLocked && this.currentMode === "edit") { this.showLockedMessage(); e.preventDefault(); } @@ -1346,7 +1422,7 @@ class TopologyWebviewController { } if (!this.freeTextContextGuardRegistered) { - this.cy.on('cxttapstart', 'node[topoViewerRole = "freeText"]', (e) => { + this.cy.on("cxttapstart", 'node[topoViewerRole = "freeText"]', (e) => { if (!this.labLocked) { return; } @@ -1357,7 +1433,7 @@ class TopologyWebviewController { this.freeTextContextGuardRegistered = true; } - if (mode === 'edit') { + if (mode === "edit") { if (!this.editModeEventsRegistered) { await this.registerEditModeEvents(); this.editModeEventsRegistered = true; @@ -1369,7 +1445,7 @@ class TopologyWebviewController { } private handleCanvasClick(event: cytoscape.EventObject): void { - if (this.currentMode !== 'edit') { + if (this.currentMode !== "edit") { return; } if (this.labLocked) { @@ -1377,7 +1453,7 @@ class TopologyWebviewController { } const mouseEvent = event.originalEvent as MouseEvent; if (mouseEvent.shiftKey && this.isViewportDrawerClabEditorChecked) { - log.debug('Canvas clicked with Shift key - adding node.'); + log.debug("Canvas clicked with Shift key - adding node."); const defaultName = (window as any).defaultNode; let template: any | undefined; if (defaultName) { @@ -1389,7 +1465,7 @@ class TopologyWebviewController { } private handleEditModeEdgeClick(event: cytoscape.EventObject): void { - if (this.currentMode !== 'edit') { + if (this.currentMode !== "edit") { return; } if (this.labLocked) { @@ -1405,10 +1481,10 @@ class TopologyWebviewController { } private registerEdgehandlesLifecycleEvents(): void { - this.cy.on('ehstart', () => { + this.cy.on("ehstart", () => { this.isEdgeHandlerActive = true; }); - this.cy.on('ehstop ehcancel', () => { + this.cy.on("ehstop ehcancel", () => { this.isEdgeHandlerActive = false; }); } @@ -1425,7 +1501,7 @@ class TopologyWebviewController { private getInitialLockState(): boolean { const configured = (window as any).lockLabByDefault; - return typeof configured === 'boolean' ? configured : true; + return typeof configured === "boolean" ? configured : true; } private showLockedMessage(): void { @@ -1453,12 +1529,12 @@ class TopologyWebviewController { e.stopPropagation(); } }; - this.cy.on('cxttapstart', '*', blockContextMenu); - this.cy.on('cxttap', '*', blockContextMenu); + this.cy.on("cxttapstart", "*", blockContextMenu); + this.cy.on("cxttap", "*", blockContextMenu); this.registerEdgehandlesLifecycleEvents(); - document.addEventListener('keydown', (event) => this.handleKeyDown(event)); - this.cy.on('ehcomplete', (_event, sourceNode, targetNode, addedEdge) => + document.addEventListener("keydown", (event) => this.handleKeyDown(event)); + this.cy.on("ehcomplete", (_event, sourceNode, targetNode, addedEdge) => this.handleEdgeCreation(sourceNode, targetNode, addedEdge) ); } @@ -1467,15 +1543,15 @@ class TopologyWebviewController { const cy = this.cy; let radialMenuOpen = false; - cy.on('cxtmenu:open', () => { - if (this.currentMode !== 'view') { + cy.on("cxtmenu:open", () => { + if (this.currentMode !== "view") { return; } radialMenuOpen = true; }); - cy.on('cxtmenu:close', () => { - if (this.currentMode !== 'view') { + cy.on("cxtmenu:close", () => { + if (this.currentMode !== "view") { return; } setTimeout(() => { @@ -1486,7 +1562,7 @@ class TopologyWebviewController { registerCyEventHandlers({ cy, onCanvasClick: () => { - if (this.currentMode !== 'view') { + if (this.currentMode !== "view") { return; } if (this.suppressViewerCanvasClose) { @@ -1500,14 +1576,14 @@ class TopologyWebviewController { } }); - document.addEventListener('keydown', (event) => { - if (this.currentMode !== 'view') { + document.addEventListener("keydown", (event) => { + if (this.currentMode !== "view") { return; } if (!this.shouldHandleKeyboardEvent(event)) { return; } - if (event.ctrlKey && event.key === 'a') { + if (event.ctrlKey && event.key === "a") { event.preventDefault(); this.handleSelectAll(); } @@ -1515,13 +1591,17 @@ class TopologyWebviewController { } private closePanelsAndResetState(): void { - const panelOverlays = document.getElementsByClassName(TopologyWebviewController.CLASS_PANEL_OVERLAY); + const panelOverlays = document.getElementsByClassName( + TopologyWebviewController.CLASS_PANEL_OVERLAY + ); for (let i = 0; i < panelOverlays.length; i++) { - (panelOverlays[i] as HTMLElement).style.display = 'none'; + (panelOverlays[i] as HTMLElement).style.display = "none"; } - const viewportDrawer = document.getElementsByClassName(TopologyWebviewController.CLASS_VIEWPORT_DRAWER); + const viewportDrawer = document.getElementsByClassName( + TopologyWebviewController.CLASS_VIEWPORT_DRAWER + ); for (let i = 0; i < viewportDrawer.length; i++) { - (viewportDrawer[i] as HTMLElement).style.display = 'none'; + (viewportDrawer[i] as HTMLElement).style.display = "none"; } topoViewerState.nodeClicked = false; topoViewerState.edgeClicked = false; @@ -1529,9 +1609,8 @@ class TopologyWebviewController { topoViewerState.selectedEdge = null; } - private async handleEditModeNodeClick(event: cytoscape.EventObject): Promise { - if (this.currentMode !== 'edit') { + if (this.currentMode !== "edit") { return; } if (this.labLocked) { @@ -1541,8 +1620,8 @@ class TopologyWebviewController { const node = event.target; log.debug(`Node clicked: ${node.id()}`); const originalEvent = event.originalEvent as MouseEvent; - const extraData = node.data('extraData'); - const isNodeInEditMode = this.currentMode === 'edit'; + const extraData = node.data("extraData"); + const isNodeInEditMode = this.currentMode === "edit"; if (originalEvent.ctrlKey && node.isChild()) { log.debug(`Orphaning node: ${node.id()} from parent: ${node.parent().id()}`); @@ -1550,30 +1629,34 @@ class TopologyWebviewController { return; } - if (originalEvent.shiftKey && node.data('topoViewerRole') !== 'freeText') { - log.debug(`Shift+click on node: starting edge creation from node: ${extraData?.longname || node.id()}`); + if (originalEvent.shiftKey && node.data("topoViewerRole") !== "freeText") { + log.debug( + `Shift+click on node: starting edge creation from node: ${extraData?.longname || node.id()}` + ); await this.startEdgeCreationFromNode(node); return; } if ( originalEvent.altKey && - (isNodeInEditMode || node.data('topoViewerRole') === 'group' || node.data('topoViewerRole') === 'freeText') + (isNodeInEditMode || + node.data("topoViewerRole") === "group" || + node.data("topoViewerRole") === "freeText") ) { this.handleAltNodeClick(node, extraData); return; } - if (node.data('topoViewerRole') === 'textbox') { + if (node.data("topoViewerRole") === "textbox") { return; } } private handleAltNodeClick(node: cytoscape.Singular, extraData: any): void { - if (node.data('topoViewerRole') === 'group') { + if (node.data("topoViewerRole") === "group") { log.debug(`Alt+click on group: deleting group ${node.id()}`); this.groupManager?.directGroupRemoval(node.id()); - } else if (node.data('topoViewerRole') === 'freeText') { + } else if (node.data("topoViewerRole") === "freeText") { log.debug(`Alt+click on freeText: deleting text ${node.id()}`); this.freeTextManager?.removeFreeTextAnnotation(node.id()); } else { @@ -1591,7 +1674,7 @@ class TopologyWebviewController { return; } const key = event.key.toLowerCase(); - const combo = `${event.ctrlKey ? 'ctrl+' : ''}${key}`; + const combo = `${event.ctrlKey ? "ctrl+" : ""}${key}`; const handler = this.keyHandlers[combo] || this.keyHandlers[key]; if (handler) { handler(event); @@ -1599,27 +1682,29 @@ class TopologyWebviewController { } private showNodePropertiesPanel(node: cytoscape.Singular): void { - const panelOverlays = document.getElementsByClassName(TopologyWebviewController.CLASS_PANEL_OVERLAY); - Array.from(panelOverlays).forEach(panel => (panel as HTMLElement).style.display = 'none'); - const panelNode = document.getElementById('panel-node'); + const panelOverlays = document.getElementsByClassName( + TopologyWebviewController.CLASS_PANEL_OVERLAY + ); + Array.from(panelOverlays).forEach((panel) => ((panel as HTMLElement).style.display = "none")); + const panelNode = document.getElementById("panel-node"); if (!panelNode) { return; } - panelNode.style.display = 'block'; - const extraData = node.data('extraData') || {}; + panelNode.style.display = "block"; + const extraData = node.data("extraData") || {}; const entries: Array<[string, string | undefined]> = [ - ['panel-node-name', extraData.longname || node.data('name') || node.id()], - ['panel-node-kind', extraData.kind], - ['panel-node-mgmtipv4', extraData.mgmtIpv4Address], - ['panel-node-mgmtipv6', extraData.mgmtIpv6Address], - ['panel-node-fqdn', extraData.fqdn], - ['panel-node-topoviewerrole', node.data('topoViewerRole')], - ['panel-node-state', extraData.state], - ['panel-node-image', extraData.image] + ["panel-node-name", extraData.longname || node.data("name") || node.id()], + ["panel-node-kind", extraData.kind], + ["panel-node-mgmtipv4", extraData.mgmtIpv4Address], + ["panel-node-mgmtipv6", extraData.mgmtIpv6Address], + ["panel-node-fqdn", extraData.fqdn], + ["panel-node-topoviewerrole", node.data("topoViewerRole")], + ["panel-node-state", extraData.state], + ["panel-node-image", extraData.image] ]; entries.forEach(([id, value]) => { const el = document.getElementById(id); - if (el) el.textContent = value || ''; + if (el) el.textContent = value || ""; }); topoViewerState.selectedNode = extraData.longname || node.id(); topoViewerState.nodeClicked = true; @@ -1628,29 +1713,31 @@ class TopologyWebviewController { private showLinkPropertiesPanel(ele: cytoscape.Singular): void { this.hideAllPanels(); this.highlightLink(ele); - const panelLink = document.getElementById('panel-link'); + const panelLink = document.getElementById("panel-link"); if (!panelLink) { return; } - panelLink.style.display = 'block'; + panelLink.style.display = "block"; this.populateLinkPanel(ele); topoViewerState.selectedEdge = ele.id(); topoViewerState.edgeClicked = true; } private hideAllPanels(): void { - const panelOverlays = document.getElementsByClassName(TopologyWebviewController.CLASS_PANEL_OVERLAY); - Array.from(panelOverlays).forEach(panel => (panel as HTMLElement).style.display = 'none'); + const panelOverlays = document.getElementsByClassName( + TopologyWebviewController.CLASS_PANEL_OVERLAY + ); + Array.from(panelOverlays).forEach((panel) => ((panel as HTMLElement).style.display = "none")); } private highlightLink(ele: cytoscape.Singular): void { this.cy.edges().removeStyle(TopologyWebviewController.STYLE_LINE_COLOR); - const highlightColor = this.currentMode === 'edit' ? '#32CD32' : '#0043BF'; + const highlightColor = this.currentMode === "edit" ? "#32CD32" : "#0043BF"; ele.style(TopologyWebviewController.STYLE_LINE_COLOR, highlightColor); } private populateLinkPanel(ele: cytoscape.Singular): void { - const extraData = ele.data('extraData') || {}; + const extraData = ele.data("extraData") || {}; this.updateLinkName(ele); this.updateLinkEndpointInfo(ele, extraData); } @@ -1663,51 +1750,57 @@ class TopologyWebviewController { if (!selectedId || edge.id() !== selectedId) { return; } - const panelLink = document.getElementById('panel-link') as HTMLElement | null; - if (!panelLink || panelLink.style.display === 'none') { + const panelLink = document.getElementById("panel-link") as HTMLElement | null; + if (!panelLink || panelLink.style.display === "none") { return; } this.populateLinkPanel(edge); } private updateLinkName(ele: cytoscape.Singular): void { - const linkNameEl = document.getElementById('panel-link-name'); + const linkNameEl = document.getElementById("panel-link-name"); if (linkNameEl) { - linkNameEl.innerHTML = `┌ ${ele.data('source')} :: ${ele.data('sourceEndpoint') || ''}
    └ ${ele.data('target')} :: ${ele.data('targetEndpoint') || ''}`; + linkNameEl.innerHTML = `┌ ${ele.data("source")} :: ${ele.data("sourceEndpoint") || ""}
    └ ${ele.data("target")} :: ${ele.data("targetEndpoint") || ""}`; } } private updateLinkEndpointInfo(ele: cytoscape.Singular, extraData: any): void { - this.setEndpointFields('a', { - name: `${ele.data('source')} :: ${ele.data('sourceEndpoint') || ''}`, + this.setEndpointFields("a", { + name: `${ele.data("source")} :: ${ele.data("sourceEndpoint") || ""}`, mac: extraData?.clabSourceMacAddress, mtu: extraData?.clabSourceMtu, type: extraData?.clabSourceType, - stats: extraData?.clabSourceStats as InterfaceStatsPayload | undefined, + stats: extraData?.clabSourceStats as InterfaceStatsPayload | undefined }); - this.setEndpointFields('b', { - name: `${ele.data('target')} :: ${ele.data('targetEndpoint') || ''}`, + this.setEndpointFields("b", { + name: `${ele.data("target")} :: ${ele.data("targetEndpoint") || ""}`, mac: extraData?.clabTargetMacAddress, mtu: extraData?.clabTargetMtu, type: extraData?.clabTargetType, - stats: extraData?.clabTargetStats as InterfaceStatsPayload | undefined, + stats: extraData?.clabTargetStats as InterfaceStatsPayload | undefined }); } private setEndpointFields( - letter: 'a' | 'b', - data: { name: string; mac?: string; mtu?: string | number; type?: string; stats?: InterfaceStatsPayload } + letter: "a" | "b", + data: { + name: string; + mac?: string; + mtu?: string | number; + type?: string; + stats?: InterfaceStatsPayload; + } ): void { const prefix = `panel-link-endpoint-${letter}`; - this.setLabelText(`${prefix}-name`, data.name, 'N/A'); - this.setLabelText(`${prefix}-mac-address`, data.mac, 'N/A'); - this.setLabelText(`${prefix}-mtu`, data.mtu, 'N/A'); - this.setLabelText(`${prefix}-type`, data.type, 'N/A'); - this.setLabelText(`${prefix}-rx-rate`, this.buildRateLine(data.stats, 'rx'), 'N/A'); - this.setLabelText(`${prefix}-tx-rate`, this.buildRateLine(data.stats, 'tx'), 'N/A'); - this.setLabelText(`${prefix}-rx-total`, this.buildCounterLine(data.stats, 'rx'), 'N/A'); - this.setLabelText(`${prefix}-tx-total`, this.buildCounterLine(data.stats, 'tx'), 'N/A'); - this.setLabelText(`${prefix}-stats-interval`, this.buildIntervalLine(data.stats), 'N/A'); + this.setLabelText(`${prefix}-name`, data.name, "N/A"); + this.setLabelText(`${prefix}-mac-address`, data.mac, "N/A"); + this.setLabelText(`${prefix}-mtu`, data.mtu, "N/A"); + this.setLabelText(`${prefix}-type`, data.type, "N/A"); + this.setLabelText(`${prefix}-rx-rate`, this.buildRateLine(data.stats, "rx"), "N/A"); + this.setLabelText(`${prefix}-tx-rate`, this.buildRateLine(data.stats, "tx"), "N/A"); + this.setLabelText(`${prefix}-rx-total`, this.buildCounterLine(data.stats, "rx"), "N/A"); + this.setLabelText(`${prefix}-tx-total`, this.buildCounterLine(data.stats, "tx"), "N/A"); + this.setLabelText(`${prefix}-stats-interval`, this.buildIntervalLine(data.stats), "N/A"); } private setLabelText(id: string, value: string | number | undefined, fallback: string): void { @@ -1718,9 +1811,9 @@ class TopologyWebviewController { let text: string; if (value === undefined) { text = fallback; - } else if (typeof value === 'number') { + } else if (typeof value === "number") { text = value.toLocaleString(); - } else if (value.trim() === '') { + } else if (value.trim() === "") { text = fallback; } else { text = value; @@ -1728,17 +1821,20 @@ class TopologyWebviewController { el.textContent = text; } - private buildRateLine(stats: InterfaceStatsPayload | undefined, direction: 'rx' | 'tx'): string | undefined { + private buildRateLine( + stats: InterfaceStatsPayload | undefined, + direction: "rx" | "tx" + ): string | undefined { if (!stats) { return undefined; } - const bpsKey = direction === 'rx' ? 'rxBps' : 'txBps'; - const ppsKey = direction === 'rx' ? 'rxPps' : 'txPps'; + const bpsKey = direction === "rx" ? "rxBps" : "txBps"; + const ppsKey = direction === "rx" ? "rxPps" : "txPps"; const bps = stats[bpsKey]; const pps = stats[ppsKey]; - if (typeof bps !== 'number' || !Number.isFinite(bps)) { - if (typeof pps !== 'number' || !Number.isFinite(pps)) { + if (typeof bps !== "number" || !Number.isFinite(bps)) { + if (typeof pps !== "number" || !Number.isFinite(pps)) { return undefined; } return `PPS ${this.formatWithPrecision(pps, 2)}`; @@ -1748,30 +1844,33 @@ class TopologyWebviewController { `${this.formatWithPrecision(bps, 0)} bps`, `${this.formatWithPrecision(bps / 1_000, 2)} Kbps`, `${this.formatWithPrecision(bps / 1_000_000, 2)} Mbps`, - `${this.formatWithPrecision(bps / 1_000_000_000, 2)} Gbps`, + `${this.formatWithPrecision(bps / 1_000_000_000, 2)} Gbps` ]; - let line = rateParts.join(' / '); - if (typeof pps === 'number' && Number.isFinite(pps)) { + let line = rateParts.join(" / "); + if (typeof pps === "number" && Number.isFinite(pps)) { line += ` | PPS: ${this.formatWithPrecision(pps, 2)}`; } return line; } - private buildCounterLine(stats: InterfaceStatsPayload | undefined, direction: 'rx' | 'tx'): string | undefined { + private buildCounterLine( + stats: InterfaceStatsPayload | undefined, + direction: "rx" | "tx" + ): string | undefined { if (!stats) { return undefined; } - const bytesKey = direction === 'rx' ? 'rxBytes' : 'txBytes'; - const packetsKey = direction === 'rx' ? 'rxPackets' : 'txPackets'; + const bytesKey = direction === "rx" ? "rxBytes" : "txBytes"; + const packetsKey = direction === "rx" ? "rxPackets" : "txPackets"; const bytes = stats[bytesKey]; const packets = stats[packetsKey]; const segments: string[] = []; - if (typeof bytes === 'number' && Number.isFinite(bytes)) { + if (typeof bytes === "number" && Number.isFinite(bytes)) { segments.push(`${this.formatWithPrecision(bytes, 0)} bytes`); } - if (typeof packets === 'number' && Number.isFinite(packets)) { + if (typeof packets === "number" && Number.isFinite(packets)) { segments.push(`${this.formatWithPrecision(packets, 0)} packets`); } @@ -1779,7 +1878,7 @@ class TopologyWebviewController { return undefined; } - return segments.join(' / '); + return segments.join(" / "); } private buildIntervalLine(stats: InterfaceStatsPayload | undefined): string | undefined { @@ -1787,7 +1886,7 @@ class TopologyWebviewController { return undefined; } const interval = stats.statsIntervalSeconds; - if (typeof interval !== 'number' || !Number.isFinite(interval)) { + if (typeof interval !== "number" || !Number.isFinite(interval)) { return undefined; } return `${this.formatWithPrecision(interval, 3)} s`; @@ -1796,11 +1895,15 @@ class TopologyWebviewController { private formatWithPrecision(value: number, fractionDigits: number): string { return value.toLocaleString(undefined, { minimumFractionDigits: fractionDigits, - maximumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits }); } - private handleEdgeCreation(sourceNode: cytoscape.NodeSingular, targetNode: cytoscape.NodeSingular, addedEdge: cytoscape.EdgeSingular): void { + private handleEdgeCreation( + sourceNode: cytoscape.NodeSingular, + targetNode: cytoscape.NodeSingular, + addedEdge: cytoscape.EdgeSingular + ): void { log.debug(`Edge created from ${sourceNode.id()} to ${targetNode.id()}`); log.debug(`Added edge: ${addedEdge.id()}`); setTimeout(() => { @@ -1808,21 +1911,26 @@ class TopologyWebviewController { }, 100); const sourceEndpoint = this.getNextEndpoint(sourceNode.id()); const targetEndpoint = this.getNextEndpoint(targetNode.id()); - const edgeData: any = { sourceEndpoint, targetEndpoint, editor: 'true' }; + const edgeData: any = { sourceEndpoint, targetEndpoint, editor: "true" }; this.addNetworkEdgeProperties(sourceNode, targetNode, addedEdge, edgeData); addedEdge.data(edgeData); } - private addNetworkEdgeProperties(sourceNode: cytoscape.NodeSingular, targetNode: cytoscape.NodeSingular, addedEdge: cytoscape.EdgeSingular, edgeData: any): void { + private addNetworkEdgeProperties( + sourceNode: cytoscape.NodeSingular, + targetNode: cytoscape.NodeSingular, + addedEdge: cytoscape.EdgeSingular, + edgeData: any + ): void { const sourceIsNetwork = this.isNetworkNode(sourceNode.id()); const targetIsNetwork = this.isNetworkNode(targetNode.id()); if (!(sourceIsNetwork || targetIsNetwork)) { return; } - addedEdge.addClass('stub-link'); + addedEdge.addClass("stub-link"); const networkNode = sourceIsNetwork ? sourceNode : targetNode; const networkData = networkNode.data(); - const networkType = networkData.extraData?.kind || networkNode.id().split(':')[0]; + const networkType = networkData.extraData?.kind || networkNode.id().split(":")[0]; const extra = networkData.extraData || {}; const extData = this.collectNetworkExtraData(networkType, extra, sourceIsNetwork); if (Object.keys(extData).length > 0) { @@ -1830,30 +1938,37 @@ class TopologyWebviewController { } } - private collectNetworkExtraData(networkType: string, extra: any, sourceIsNetwork: boolean): Record { + private collectNetworkExtraData( + networkType: string, + extra: any, + sourceIsNetwork: boolean + ): Record { const extData: Record = {}; const assignIf = (key: string, value: any) => { if (value !== undefined) { extData[key] = value; } }; - if (networkType !== TopologyWebviewController.KIND_BRIDGE && networkType !== TopologyWebviewController.KIND_OVS_BRIDGE) { + if ( + networkType !== TopologyWebviewController.KIND_BRIDGE && + networkType !== TopologyWebviewController.KIND_OVS_BRIDGE + ) { extData.extType = networkType; } - assignIf(sourceIsNetwork ? 'extSourceMac' : 'extTargetMac', extra.extMac); - assignIf('extMtu', extra.extMtu); - assignIf('extVars', extra.extVars); - assignIf('extLabels', extra.extLabels); - if (['host', 'mgmt-net', 'macvlan'].includes(networkType)) { - assignIf('extHostInterface', extra.extHostInterface); + assignIf(sourceIsNetwork ? "extSourceMac" : "extTargetMac", extra.extMac); + assignIf("extMtu", extra.extMtu); + assignIf("extVars", extra.extVars); + assignIf("extLabels", extra.extLabels); + if (["host", "mgmt-net", "macvlan"].includes(networkType)) { + assignIf("extHostInterface", extra.extHostInterface); } - if (networkType === 'macvlan') { - assignIf('extMode', extra.extMode); + if (networkType === "macvlan") { + assignIf("extMode", extra.extMode); } - if (['vxlan', 'vxlan-stitch'].includes(networkType)) { - assignIf('extRemote', extra.extRemote); - assignIf('extVni', extra.extVni); - assignIf('extUdpPort', extra.extUdpPort); + if (["vxlan", "vxlan-stitch"].includes(networkType)) { + assignIf("extRemote", extra.extRemote); + assignIf("extVni", extra.extVni); + assignIf("extUdpPort", extra.extUdpPort); } return extData; } @@ -1863,8 +1978,11 @@ class TopologyWebviewController { return true; } const node = this.cy.getElementById(nodeId); - const kind = node.data('extraData')?.kind; - return kind === TopologyWebviewController.KIND_BRIDGE || kind === TopologyWebviewController.KIND_OVS_BRIDGE; + const kind = node.data("extraData")?.kind; + return ( + kind === TopologyWebviewController.KIND_BRIDGE || + kind === TopologyWebviewController.KIND_OVS_BRIDGE + ); } /** @@ -1875,32 +1993,36 @@ class TopologyWebviewController { const target = event.target as HTMLElement; // Don't handle if focus is on an input, textarea, or contenteditable element - if (target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.contentEditable === 'true' || - target.isContentEditable) { + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.contentEditable === "true" || + target.isContentEditable + ) { return false; } // Don't handle if focus is on a dropdown or select element - if (target.tagName === 'SELECT') { + if (target.tagName === "SELECT") { return false; } // Don't handle if we're inside a dialog or modal that's not our confirmation dialog - const isInDialog = target.closest(`.free-text-dialog, .${TopologyWebviewController.CLASS_PANEL_OVERLAY}, .dropdown-menu`); - const isInOurConfirmDialog = target.closest('.delete-confirmation-dialog'); + const isInDialog = target.closest( + `.free-text-dialog, .${TopologyWebviewController.CLASS_PANEL_OVERLAY}, .dropdown-menu` + ); + const isInOurConfirmDialog = target.closest(".delete-confirmation-dialog"); if (isInDialog && !isInOurConfirmDialog) { return false; } // Only handle if the event target is: doc body, cytoscape/canvas area - const cyContainer = document.getElementById('cy'); + const cyContainer = document.getElementById("cy"); const isInCyContainer = cyContainer && (target === cyContainer || cyContainer.contains(target)); const isDocumentBody = target === document.body; - return isDocumentBody || isInCyContainer || target.tagName === 'CANVAS'; + return isDocumentBody || isInCyContainer || target.tagName === "CANVAS"; } /** @@ -1909,13 +2031,13 @@ class TopologyWebviewController { */ private handleSelectAll(): void { // Get all nodes and edges that are selectable - const selectableElements = this.cy.$('node, edge').filter((element) => { + const selectableElements = this.cy.$("node, edge").filter((element) => { // Only select elements that are actually selectable return element.selectable(); }); // Deselect all first, then select all selectable elements - this.cy.$(':selected').unselect(); + this.cy.$(":selected").unselect(); selectableElements.select(); log.debug(`Selected ${selectableElements.length} elements with Ctrl+A`); @@ -1927,7 +2049,7 @@ class TopologyWebviewController { */ private async handleDeleteKeyPress(): Promise { // Get all selected elements - const selectedElements = this.cy.$(':selected'); + const selectedElements = this.cy.$(":selected"); if (selectedElements.length === 0) { return; @@ -1943,13 +2065,13 @@ class TopologyWebviewController { // Handle selected nodes const selectedNodes = selectedElements.nodes(); - selectedNodes.forEach(node => { - const topoViewerRole = node.data('topoViewerRole'); + selectedNodes.forEach((node) => { + const topoViewerRole = node.data("topoViewerRole"); // Handle free text nodes using the existing manager - if (topoViewerRole === 'freeText') { + if (topoViewerRole === "freeText") { this.freeTextManager?.removeFreeTextAnnotation(node.id()); - } else if (topoViewerRole === 'group') { + } else if (topoViewerRole === "group") { // Handle group nodes - use the group management system if (this.isViewportDrawerClabEditorChecked) { log.debug(`Delete key: removing group ${node.id()}`); @@ -1959,7 +2081,7 @@ class TopologyWebviewController { // Handle regular nodes - only delete if in edit mode and node is editable const isNodeInEditMode = node.data("editor") === "true"; if (this.isViewportDrawerClabEditorChecked && isNodeInEditMode) { - log.debug(`Delete key: removing node ${node.data('extraData')?.longname || node.id()}`); + log.debug(`Delete key: removing node ${node.data("extraData")?.longname || node.id()}`); node.remove(); } } @@ -1967,7 +2089,7 @@ class TopologyWebviewController { // Handle selected edges const selectedEdges = selectedElements.edges(); - selectedEdges.forEach(edge => { + selectedEdges.forEach((edge) => { if (this.isViewportDrawerClabEditorChecked) { log.debug(`Delete key: removing edge ${edge.id()}`); edge.remove(); @@ -1984,24 +2106,24 @@ class TopologyWebviewController { this.copyPasteManager.handleCopy(); // Get all selected elements - const selectedElements = this.cy.$(':selected'); + const selectedElements = this.cy.$(":selected"); if (selectedElements.length === 0) { return; } // Remove selected nodes const selectedNodes = selectedElements.nodes(); - selectedNodes.forEach(node => { - const topoViewerRole = node.data('topoViewerRole'); + selectedNodes.forEach((node) => { + const topoViewerRole = node.data("topoViewerRole"); - if (topoViewerRole === 'freeText') { + if (topoViewerRole === "freeText") { this.freeTextManager?.removeFreeTextAnnotation(node.id()); - } else if (topoViewerRole === 'group') { + } else if (topoViewerRole === "group") { if (this.isViewportDrawerClabEditorChecked) { this.groupManager?.directGroupRemoval(node.id()); } } else { - const isNodeInEditMode = this.currentMode === 'edit'; + const isNodeInEditMode = this.currentMode === "edit"; if (this.isViewportDrawerClabEditorChecked && isNodeInEditMode) { node.remove(); } @@ -2010,7 +2132,7 @@ class TopologyWebviewController { // Remove selected edges const selectedEdges = selectedElements.edges(); - selectedEdges.forEach(edge => { + selectedEdges.forEach((edge) => { if (this.isViewportDrawerClabEditorChecked) { edge.remove(); } @@ -2025,7 +2147,7 @@ class TopologyWebviewController { * @param nodeId - The ID of the node. * @returns The next available endpoint string. * @private - */ + */ private getNextEndpoint(nodeId: string): string { // Cloud-based nodes like host, mgmt-net or macvlan do not expose // regular interfaces. When creating a link to such nodes we must not @@ -2033,7 +2155,7 @@ class TopologyWebviewController { // empty string here ensures that the calling code stores only the node ID // itself as the link endpoint. if (isSpecialEndpoint(nodeId)) { - return ''; + return ""; } const ifaceMap = window.ifacePatternMapping || {}; @@ -2072,7 +2194,9 @@ class TopologyWebviewController { if (members.length > 1 && !this.loggedBridgeAliasGroups.has(baseYamlId)) { this.loggedBridgeAliasGroups.add(baseYamlId); try { - log.info(`Bridge alias group detected for YAML node '${baseYamlId}': members [${members.join(', ')}]`); + log.info( + `Bridge alias group detected for YAML node '${baseYamlId}': members [${members.join(", ")}]` + ); } catch { // no-op if logger throws unexpectedly in webview } @@ -2081,35 +2205,45 @@ class TopologyWebviewController { } private isBridgeNode(node: cytoscape.NodeSingular): boolean { - const kind = node.data('extraData')?.kind as string | undefined; - return kind === TopologyWebviewController.KIND_BRIDGE || kind === TopologyWebviewController.KIND_OVS_BRIDGE; + const kind = node.data("extraData")?.kind as string | undefined; + return ( + kind === TopologyWebviewController.KIND_BRIDGE || + kind === TopologyWebviewController.KIND_OVS_BRIDGE + ); } private getBaseYamlIdForNode(node: cytoscape.NodeSingular): string | null { - const extra = node.data('extraData') || {}; - const ref = typeof extra.extYamlNodeId === 'string' ? extra.extYamlNodeId.trim() : ''; + const extra = node.data("extraData") || {}; + const ref = typeof extra.extYamlNodeId === "string" ? extra.extYamlNodeId.trim() : ""; return ref || node.id() || null; } private listBridgeMembersForYaml(baseYamlId: string): string[] { const out: string[] = []; - this.cy.nodes().forEach(n => { + this.cy.nodes().forEach((n) => { if (!this.isBridgeNode(n)) return; const id = n.id(); - const ref = typeof n.data('extraData')?.extYamlNodeId === 'string' ? n.data('extraData').extYamlNodeId.trim() : ''; + const ref = + typeof n.data("extraData")?.extYamlNodeId === "string" + ? n.data("extraData").extYamlNodeId.trim() + : ""; if (id === baseYamlId || (ref && ref === baseYamlId)) out.push(id); }); return out; } - private collectUsedIndices(memberIds: string[], parsedPattern: ReturnType, sink: Set): void { - memberIds.forEach(memberId => { + private collectUsedIndices( + memberIds: string[], + parsedPattern: ReturnType, + sink: Set + ): void { + memberIds.forEach((memberId) => { const edges = this.cy.edges(`[source = "${memberId}"], [target = "${memberId}"]`); - edges.forEach(edge => { - const src = edge.data('source'); - const tgt = edge.data('target'); - const epSrc = edge.data('sourceEndpoint'); - const epTgt = edge.data('targetEndpoint'); + edges.forEach((edge) => { + const src = edge.data("source"); + const tgt = edge.data("target"); + const epSrc = edge.data("sourceEndpoint"); + const epTgt = edge.data("targetEndpoint"); if (src === memberId && epSrc) { const idx = getInterfaceIndex(parsedPattern, epSrc); if (idx !== null) sink.add(idx); @@ -2126,10 +2260,11 @@ class TopologyWebviewController { * Detects the user's preferred color scheme and applies the corresponding theme. * @returns The applied theme ("dark" or "light"). */ - public detectColorScheme(): 'light' | 'dark' { + public detectColorScheme(): "light" | "dark" { const bodyClassList = document.body?.classList; - const darkMode = bodyClassList?.contains('vscode-dark') || bodyClassList?.contains('vscode-high-contrast'); - const theme: 'light' | 'dark' = darkMode ? 'dark' : 'light'; + const darkMode = + bodyClassList?.contains("vscode-dark") || bodyClassList?.contains("vscode-high-contrast"); + const theme: "light" | "dark" = darkMode ? "dark" : "light"; this.applyTheme(theme); return theme; } @@ -2139,26 +2274,26 @@ class TopologyWebviewController { * @param theme - The theme to apply ("dark" or "light"). * @private */ - private applyTheme(theme: 'light' | 'dark'): void { - const rootElement = document.getElementById('root'); + private applyTheme(theme: "light" | "dark"): void { + const rootElement = document.getElementById("root"); if (rootElement) { - rootElement.setAttribute('data-theme', theme); + rootElement.setAttribute("data-theme", theme); log.debug(`Applied Theme: ${theme}`); } else { log.warn(`'root' element not found; cannot apply theme: ${theme}`); } } - private updateModeIndicator(mode: 'viewer' | 'editor'): void { - const indicator = document.getElementById('mode-indicator'); + private updateModeIndicator(mode: "viewer" | "editor"): void { + const indicator = document.getElementById("mode-indicator"); if (indicator) { indicator.textContent = mode; - indicator.classList.remove('mode-viewer', 'mode-editor'); + indicator.classList.remove("mode-viewer", "mode-editor"); indicator.classList.add(`mode-${mode}`); } else { - log.warn('Mode indicator element not found'); + log.warn("Mode indicator element not found"); } - document.title = mode === 'editor' ? 'TopoViewer Editor' : 'TopoViewer'; + document.title = mode === "editor" ? "TopoViewer Editor" : "TopoViewer"; } /** @@ -2170,13 +2305,10 @@ class TopologyWebviewController { if (subtitleElement) { subtitleElement.textContent = `Topology Editor ::: ${newText}`; } else { - log.warn('Subtitle element not found'); + log.warn("Subtitle element not found"); } } - - - /** * Show/hide topology overview panel */ @@ -2184,7 +2316,7 @@ class TopologyWebviewController { try { const overviewDrawer = document.getElementById("viewport-drawer-topology-overview"); if (!overviewDrawer) { - log.warn('Topology overview drawer not found'); + log.warn("Topology overview drawer not found"); return; } @@ -2193,7 +2325,9 @@ class TopologyWebviewController { overviewDrawer.style.display = "none"; } else { // Hide all viewport drawers first - const viewportDrawer = document.getElementsByClassName(TopologyWebviewController.CLASS_VIEWPORT_DRAWER); + const viewportDrawer = document.getElementsByClassName( + TopologyWebviewController.CLASS_VIEWPORT_DRAWER + ); for (let i = 0; i < viewportDrawer.length; i++) { (viewportDrawer[i] as HTMLElement).style.display = "none"; } @@ -2206,9 +2340,9 @@ class TopologyWebviewController { } public showBulkLinkPanel(): void { - const panel = document.getElementById('panel-bulk-link'); + const panel = document.getElementById("panel-bulk-link"); if (panel) { - panel.style.display = 'block'; + panel.style.display = "block"; } } @@ -2217,33 +2351,40 @@ class TopologyWebviewController { return pattern; } - return pattern.replace(/\$\$|\$<([^>]+)>|\$(\d+)/g, (fullMatch: string, namedGroup?: string, numberedGroup?: string) => { - if (fullMatch === '$$') { - return '$'; - } + return pattern.replace( + /\$\$|\$<([^>]+)>|\$(\d+)/g, + (fullMatch: string, namedGroup?: string, numberedGroup?: string) => { + if (fullMatch === "$$") { + return "$"; + } - if (!match) { - return fullMatch; - } + if (!match) { + return fullMatch; + } - if (fullMatch.startsWith('$<')) { - if (namedGroup && match.groups && Object.prototype.hasOwnProperty.call(match.groups, namedGroup)) { - const value = match.groups[namedGroup]; - return value ?? ''; + if (fullMatch.startsWith("$<")) { + if ( + namedGroup && + match.groups && + Object.prototype.hasOwnProperty.call(match.groups, namedGroup) + ) { + const value = match.groups[namedGroup]; + return value ?? ""; + } + return fullMatch; } - return fullMatch; - } - if (numberedGroup) { - const index = Number(numberedGroup); - if (!Number.isNaN(index) && index < match.length) { - return match[index] ?? ''; + if (numberedGroup) { + const index = Number(numberedGroup); + if (!Number.isNaN(index) && index < match.length) { + return match[index] ?? ""; + } + return fullMatch; } + return fullMatch; } - - return fullMatch; - }); + ); } private getSourceMatch( @@ -2265,17 +2406,16 @@ class TopologyWebviewController { public async bulkCreateLinks(sourceFilterText: string, targetFilterText: string): Promise { const nodes = this.cy.nodes('node[topoViewerRole != "freeText"][topoViewerRole != "group"]'); - const candidateLinks: Array<{ source: cytoscape.NodeSingular; target: cytoscape.NodeSingular }> = []; + const candidateLinks: Array<{ + source: cytoscape.NodeSingular; + target: cytoscape.NodeSingular; + }> = []; const sourceRegex = FilterUtils.tryCreateRegExp(sourceFilterText); const sourceFallbackFilter = sourceRegex ? null : FilterUtils.createFilter(sourceFilterText); nodes.forEach((sourceNode) => { - const match = this.getSourceMatch( - sourceNode.data('name'), - sourceRegex, - sourceFallbackFilter - ); + const match = this.getSourceMatch(sourceNode.data("name"), sourceRegex, sourceFallbackFilter); if (match === undefined) { return; @@ -2287,7 +2427,7 @@ class TopologyWebviewController { nodes.forEach((targetNode) => { if ( sourceNode.id() === targetNode.id() || - !targetFilter(targetNode.data('name')) || + !targetFilter(targetNode.data("name")) || sourceNode.edgesTo(targetNode).nonempty() ) { return; @@ -2304,11 +2444,11 @@ class TopologyWebviewController { if (potentialLinks === 0) { (window as any).showConfirmDialog({ - title: 'No Links to Create', - message: 'No new links would be created with the specified patterns.', - icon: 'fas fa-info-circle text-blue-500', - confirmText: 'OK', - confirmStyle: 'btn-primary', + title: "No Links to Create", + message: "No new links would be created with the specified patterns.", + icon: "fas fa-info-circle text-blue-500", + confirmText: "OK", + confirmStyle: "btn-primary", cancelText: null // Hide cancel button for info dialogs }); return; @@ -2316,7 +2456,7 @@ class TopologyWebviewController { // Show confirmation dialog const result = await (window as any).showBulkActionConfirm( - 'Bulk Link Creation', + "Bulk Link Creation", sourceFilterText, targetFilterText, potentialLinks @@ -2333,14 +2473,13 @@ class TopologyWebviewController { target: target.id(), sourceEndpoint: this.getNextEndpoint(source.id()), targetEndpoint: this.getNextEndpoint(target.id()), - editor: 'true' + editor: "true" }; - const isStubLink = - this.isNetworkNode(source.id()) || this.isNetworkNode(target.id()); + const isStubLink = this.isNetworkNode(source.id()) || this.isNetworkNode(target.id()); this.cy.add({ - group: 'edges', + group: "edges", data: edgeData, - classes: isStubLink ? 'stub-link' : undefined + classes: isStubLink ? "stub-link" : undefined }); }); this.saveManager.saveTopo(this.cy, true); @@ -2354,10 +2493,9 @@ class TopologyWebviewController { } } - -document.addEventListener('DOMContentLoaded', () => { - const mode = (window as any).topoViewerMode === 'viewer' ? 'view' : 'edit'; - const controller = new TopologyWebviewController('cy', mode); +document.addEventListener("DOMContentLoaded", () => { + const mode = (window as any).topoViewerMode === "viewer" ? "view" : "edit"; + const controller = new TopologyWebviewController("cy", mode); void controller.initAsync(mode); // Store the instance for other modules topoViewerState.editorEngine = controller; @@ -2375,7 +2513,7 @@ document.addEventListener('DOMContentLoaded', () => { window.viewportButtonsAddGroup = gm.viewportButtonsAddGroup.bind(gm); window.showPanelGroupEditor = gm.showGroupEditor.bind(gm); - window.addEventListener('unload', () => { + window.addEventListener("unload", () => { controller.dispose(); }); @@ -2384,7 +2522,7 @@ document.addEventListener('DOMContentLoaded', () => { setTimeout(() => { if (controller.cy.elements().length > 0) { controller.cy.fit(controller.cy.elements(), 50); - log.debug('Final viewport adjustment completed'); + log.debug("Final viewport adjustment completed"); } }, 100); // Much shorter delay - just for final adjustments }); From f2a347011262f430b543a641b5f2660ddb7b5475 Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Sun, 30 Nov 2025 01:57:26 +0800 Subject: [PATCH 18/55] Install uplot lib --- package-lock.json | 8 ++++++++ package.json | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 282140718..acb7cef95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,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" }, @@ -12082,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 867c491c9..cca1e4648 100644 --- a/package.json +++ b/package.json @@ -1439,7 +1439,8 @@ "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" } -} \ No newline at end of file +} From d22874fa9bdc058014fb3e91da11712e98d06ba6 Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Sun, 30 Nov 2025 03:19:29 +0800 Subject: [PATCH 19/55] add --interface-stats arg to events --- src/services/containerlabEvents.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/containerlabEvents.ts b/src/services/containerlabEvents.ts index 5403ccb11..a0c7ddedc 100644 --- a/src/services/containerlabEvents.ts +++ b/src/services/containerlabEvents.ts @@ -988,7 +988,7 @@ function startProcess(runtime: string): void { }); const containerlabBinary = containerlabBinaryPath - const baseArgs = ["events", "--format", "json", "--initial-state"]; + const baseArgs = ["events", "--format", "json", "--initial-state", "--interface-stats"]; if (runtime) { baseArgs.splice(1, 0, "-r", runtime); } From 4e1a1d403e821074361b1ef447354cdcbdde4a26 Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Sun, 30 Nov 2025 17:34:35 +0800 Subject: [PATCH 20/55] Make link panel resizable and add graphs for intf stats --- .../templates/partials/panel-link.html | 156 ++++---- .../webview-ui/topologyWebviewController.ts | 373 +++++++++++++++++- 2 files changed, 446 insertions(+), 83 deletions(-) diff --git a/src/topoViewer/templates/partials/panel-link.html b/src/topoViewer/templates/partials/panel-link.html index db4af5c19..bfec291ac 100644 --- a/src/topoViewer/templates/partials/panel-link.html +++ b/src/topoViewer/templates/partials/panel-link.html @@ -1,8 +1,8 @@ diff --git a/src/topoViewer/webview-ui/topologyWebviewController.ts b/src/topoViewer/webview-ui/topologyWebviewController.ts index 9fbc828e6..011d590fa 100644 --- a/src/topoViewer/webview-ui/topologyWebviewController.ts +++ b/src/topoViewer/webview-ui/topologyWebviewController.ts @@ -10,6 +10,9 @@ import "@fortawesome/fontawesome-free/css/all.min.css"; import "leaflet/dist/leaflet.css"; import "tippy.js/dist/tippy.css"; import "highlight.js/styles/github-dark.css"; +// Import uPlot for graphs +import uPlot from "uplot"; +import "uplot/dist/uPlot.min.css"; import loadCytoStyle from "./managerCytoscapeBaseStyles"; import { VscodeMessageSender } from "./managerVscodeWebview"; import { fetchAndLoadData, fetchAndLoadDataEnvironment } from "./managerCytoscapeFetchAndLoad"; @@ -139,6 +142,7 @@ class TopologyWebviewController { private static readonly CLASS_PANEL_OVERLAY = "panel-overlay" as const; private static readonly CLASS_VIEWPORT_DRAWER = "viewport-drawer" as const; private static readonly STYLE_LINE_COLOR = "line-color" as const; + private static readonly PANEL_LINK_ID = "panel-link" as const; private static readonly KIND_BRIDGE = "bridge" as const; private static readonly KIND_OVS_BRIDGE = "ovs-bridge" as const; private interfaceCounters: Record = {}; @@ -163,6 +167,14 @@ class TopologyWebviewController { private freeTextContextGuardRegistered = false; private initialGraphLoaded = false; public gridManager!: ManagerGridGuide; + + // uPlot graph instances for link panel + private linkGraphs: { a: uPlot | null; b: uPlot | null } = { a: null, b: null }; + // Data buffers for each endpoint (max 60 data points = 1 minute at 1s interval) + private linkStatsHistory: Map = new Map(); + private readonly MAX_GRAPH_POINTS = 60; + // ResizeObserver for link panel graph resizing + private linkPanelResizeObserver: ResizeObserver | null = null; // eslint-disable-next-line no-unused-vars private keyHandlers: Record void> = { delete: (event) => { @@ -1713,14 +1725,18 @@ class TopologyWebviewController { private showLinkPropertiesPanel(ele: cytoscape.Singular): void { this.hideAllPanels(); this.highlightLink(ele); - const panelLink = document.getElementById("panel-link"); + const panelLink = document.getElementById(TopologyWebviewController.PANEL_LINK_ID); if (!panelLink) { return; } + // Clear history for new link to avoid mixing data from different links + this.linkStatsHistory.clear(); panelLink.style.display = "block"; this.populateLinkPanel(ele); topoViewerState.selectedEdge = ele.id(); topoViewerState.edgeClicked = true; + // Set up resize observer for graph resizing + this.setupLinkPanelResizeObserver(); } private hideAllPanels(): void { @@ -1728,6 +1744,9 @@ class TopologyWebviewController { TopologyWebviewController.CLASS_PANEL_OVERLAY ); Array.from(panelOverlays).forEach((panel) => ((panel as HTMLElement).style.display = "none")); + // Clean up graphs and resize observer when hiding panels + this.destroyGraphs(); + this.disconnectLinkPanelResizeObserver(); } private highlightLink(ele: cytoscape.Singular): void { @@ -1738,7 +1757,6 @@ class TopologyWebviewController { private populateLinkPanel(ele: cytoscape.Singular): void { const extraData = ele.data("extraData") || {}; - this.updateLinkName(ele); this.updateLinkEndpointInfo(ele, extraData); } @@ -1750,20 +1768,13 @@ class TopologyWebviewController { if (!selectedId || edge.id() !== selectedId) { return; } - const panelLink = document.getElementById("panel-link") as HTMLElement | null; + const panelLink = document.getElementById(TopologyWebviewController.PANEL_LINK_ID) as HTMLElement | null; if (!panelLink || panelLink.style.display === "none") { return; } this.populateLinkPanel(edge); } - private updateLinkName(ele: cytoscape.Singular): void { - const linkNameEl = document.getElementById("panel-link-name"); - if (linkNameEl) { - linkNameEl.innerHTML = `┌ ${ele.data("source")} :: ${ele.data("sourceEndpoint") || ""}
    └ ${ele.data("target")} :: ${ele.data("targetEndpoint") || ""}`; - } - } - private updateLinkEndpointInfo(ele: cytoscape.Singular, extraData: any): void { this.setEndpointFields("a", { name: `${ele.data("source")} :: ${ele.data("sourceEndpoint") || ""}`, @@ -1792,15 +1803,23 @@ class TopologyWebviewController { } ): void { const prefix = `panel-link-endpoint-${letter}`; - this.setLabelText(`${prefix}-name`, data.name, "N/A"); this.setLabelText(`${prefix}-mac-address`, data.mac, "N/A"); this.setLabelText(`${prefix}-mtu`, data.mtu, "N/A"); this.setLabelText(`${prefix}-type`, data.type, "N/A"); - this.setLabelText(`${prefix}-rx-rate`, this.buildRateLine(data.stats, "rx"), "N/A"); - this.setLabelText(`${prefix}-tx-rate`, this.buildRateLine(data.stats, "tx"), "N/A"); - this.setLabelText(`${prefix}-rx-total`, this.buildCounterLine(data.stats, "rx"), "N/A"); - this.setLabelText(`${prefix}-tx-total`, this.buildCounterLine(data.stats, "tx"), "N/A"); - this.setLabelText(`${prefix}-stats-interval`, this.buildIntervalLine(data.stats), "N/A"); + + // Update tab label with interface name + this.updateTabLabel(letter, data.name); + + // Update graph with stats + const endpointKey = `${letter}:${data.name}`; + this.initOrUpdateGraph(letter, endpointKey, data.stats); + } + + private updateTabLabel(endpoint: "a" | "b", name: string): void { + const tabButton = document.querySelector(`button.endpoint-tab[data-endpoint="${endpoint}"]`); + if (tabButton) { + tabButton.textContent = name || `Endpoint ${endpoint.toUpperCase()}`; + } } private setLabelText(id: string, value: string | number | undefined, fallback: string): void { @@ -1899,6 +1918,328 @@ class TopologyWebviewController { }); } + private initOrUpdateGraph(endpoint: "a" | "b", endpointKey: string, stats: InterfaceStatsPayload | undefined): void { + const containerEl = document.getElementById(`panel-link-endpoint-${endpoint}-graph`); + if (!containerEl) { + return; + } + + // Initialize graph with empty data if it doesn't exist yet + if (!this.linkGraphs[endpoint]) { + // Use parent container's bounding rect to get actual available space + const rect = containerEl.getBoundingClientRect(); + const width = rect.width || 500; + // Reduce height to leave room for legend (subtract ~60px for legend space) + const height = (rect.height || 400) - 60; + const emptyData = [[], [], [], [], []]; + const opts = this.createGraphOptions(width, height); + this.linkGraphs[endpoint] = new uPlot(opts, emptyData, containerEl); + } + + // Update with actual data if stats are available + if (stats) { + const history = this.updateStatsHistory(endpointKey, stats); + const data = this.prepareGraphData(history); + this.linkGraphs[endpoint]?.setData(data); + } + } + + private updateStatsHistory(endpointKey: string, stats: InterfaceStatsPayload): { timestamps: number[]; rxBps: number[]; rxPps: number[]; txBps: number[]; txPps: number[] } { + let history = this.linkStatsHistory.get(endpointKey); + if (!history) { + history = { + timestamps: [], + rxBps: [], + rxPps: [], + txBps: [], + txPps: [] + }; + this.linkStatsHistory.set(endpointKey, history); + } + + const now = Date.now() / 1000; + history.timestamps.push(now); + history.rxBps.push(stats.rxBps ?? 0); + history.rxPps.push(stats.rxPps ?? 0); + history.txBps.push(stats.txBps ?? 0); + history.txPps.push(stats.txPps ?? 0); + + if (history.timestamps.length > this.MAX_GRAPH_POINTS) { + history.timestamps.shift(); + history.rxBps.shift(); + history.rxPps.shift(); + history.txBps.shift(); + history.txPps.shift(); + } + + return history; + } + + private prepareGraphData(history: { timestamps: number[]; rxBps: number[]; rxPps: number[]; txBps: number[]; txPps: number[] }): number[][] { + const rxKbps = history.rxBps.map(v => v / 1000); + const txKbps = history.txBps.map(v => v / 1000); + + return [ + history.timestamps, + rxKbps, + txKbps, + history.rxPps, + history.txPps + ]; + } + + private createGraphSeries(): uPlot.Series[] { + const formatValue = (_self: uPlot, rawValue: number | null): string => { + return rawValue == null ? "-" : rawValue.toFixed(2); + }; + + return [ + {}, + { + label: "RX Kbps", + stroke: "#4ec9b0", + width: 2, + scale: "kbps", + value: formatValue + }, + { + label: "TX Kbps", + stroke: "#569cd6", + width: 2, + scale: "kbps", + value: formatValue + }, + { + label: "RX PPS", + stroke: "#b5cea8", + width: 2, + scale: "pps", + value: formatValue + }, + { + label: "TX PPS", + stroke: "#9cdcfe", + width: 2, + scale: "pps", + value: formatValue + } + ]; + } + + private createGraphOptions(width: number, height: number = 300): uPlot.Options { + return { + width, + height, + padding: [12, 12, 12, 0], + cursor: { + show: true, + x: false, + y: false, + points: { + show: false + } + }, + series: this.createGraphSeries(), + axes: [ + { + scale: "x", + show: false + }, + { + scale: "kbps", + side: 3, + label: "Kbps", + labelSize: 20, + labelFont: "12px sans-serif", + size: 60, + stroke: "#cccccc", + grid: { + show: true, + stroke: "#3e3e42", + width: 1 + }, + ticks: { + show: true, + stroke: "#3e3e42", + width: 1 + }, + values: (_self, ticks) => ticks.map(v => v.toFixed(1)) + }, + { + scale: "pps", + side: 1, + label: "PPS", + labelSize: 20, + labelFont: "12px sans-serif", + size: 60, + stroke: "#cccccc", + grid: { + show: false + }, + ticks: { + show: true, + stroke: "#3e3e42", + width: 1 + }, + values: (_self, ticks) => ticks.map(v => v.toFixed(1)) + } + ], + scales: { + x: {}, + kbps: { + auto: true, + range: (_self, dataMin, dataMax) => { + // Set minimum range to make lines visible even with small values + const minRange = 10; // 10 Kbps minimum + const actualMax = Math.max(dataMax, minRange); + const pad = (actualMax - dataMin) * 0.1; + return [0, actualMax + pad]; + } + }, + pps: { + auto: true, + range: (_self, dataMin, dataMax) => { + // Set minimum range to make lines visible even with small values + const minRange = 10; // 10 PPS minimum + const actualMax = Math.max(dataMax, minRange); + const pad = (actualMax - dataMin) * 0.1; + return [0, actualMax + pad]; + } + } + }, + legend: { + show: true, + live: true, + isolate: false, + markers: { + show: true, + width: 2 + }, + mount: (self, legend) => { + // Mount legend inside the uPlot container + self.root.appendChild(legend); + } + }, + hooks: this.createGraphHooks() + }; + } + + private createGraphHooks(): uPlot.Hooks.Arrays { + const setCursorToLatest = (u: uPlot): void => { + if (u.data && u.data[0] && u.data[0].length > 0) { + const lastIdx = u.data[0].length - 1; + window.requestAnimationFrame(() => { + // Set legend to show the latest values + u.setLegend({ idx: lastIdx }); + }); + } + }; + + const setupMouseLeaveHandler = (u: uPlot): void => { + // Reset cursor to latest when mouse leaves the graph + u.over.addEventListener("mouseleave", () => { + setCursorToLatest(u); + }); + }; + + return { + init: [(u: uPlot) => { + this.fixLegendDisplay(u); + setupMouseLeaveHandler(u); + setCursorToLatest(u); + }], + setData: [setCursorToLatest] + }; + } + + private destroyGraphs(): void { + if (this.linkGraphs.a) { + this.linkGraphs.a.destroy(); + this.linkGraphs.a = null; + } + if (this.linkGraphs.b) { + this.linkGraphs.b.destroy(); + this.linkGraphs.b = null; + } + } + + private fixLegendDisplay(u: uPlot): void { + // Hide the first legend series (Time/x-axis) + window.requestAnimationFrame(() => { + const legendEl = u.root.querySelector(".u-legend"); + if (!legendEl) { + return; + } + + // uPlot creates inline legend items, find and hide the first one (index 0 = time series) + const seriesItems = legendEl.querySelectorAll(".u-series"); + if (seriesItems && seriesItems.length > 0) { + (seriesItems[0] as HTMLElement).style.display = "none"; + } + }); + } + + private setupLinkPanelResizeObserver(): void { + // Clean up any existing observer first + this.disconnectLinkPanelResizeObserver(); + + const panelLink = document.getElementById(TopologyWebviewController.PANEL_LINK_ID); + if (!panelLink) { + return; + } + + this.linkPanelResizeObserver = new ResizeObserver(() => { + this.resizeLinkGraphs(); + }); + + this.linkPanelResizeObserver.observe(panelLink); + + // Listen for tab switch events to resize graphs + window.addEventListener("link-tab-switched", () => { + // Use setTimeout to ensure the tab content is visible before resizing + setTimeout(() => { + this.resizeLinkGraphs(); + }, 0); + }); + } + + private disconnectLinkPanelResizeObserver(): void { + if (this.linkPanelResizeObserver) { + this.linkPanelResizeObserver.disconnect(); + this.linkPanelResizeObserver = null; + } + } + + private resizeLinkGraphs(): void { + // Resize endpoint A graph using getBoundingClientRect + if (this.linkGraphs.a) { + const containerA = document.getElementById("panel-link-endpoint-a-graph"); + if (containerA) { + const rect = containerA.getBoundingClientRect(); + const width = rect.width; + const height = rect.height - 60; // Reserve space for legend + if (width > 0 && height > 0) { + this.linkGraphs.a.setSize({ width, height }); + this.fixLegendDisplay(this.linkGraphs.a); + } + } + } + + // Resize endpoint B graph using getBoundingClientRect + if (this.linkGraphs.b) { + const containerB = document.getElementById("panel-link-endpoint-b-graph"); + if (containerB) { + const rect = containerB.getBoundingClientRect(); + const width = rect.width; + const height = rect.height - 60; // Reserve space for legend + if (width > 0 && height > 0) { + this.linkGraphs.b.setSize({ width, height }); + this.fixLegendDisplay(this.linkGraphs.b); + } + } + } + } + private handleEdgeCreation( sourceNode: cytoscape.NodeSingular, targetNode: cytoscape.NodeSingular, From a557a1b1fa3dc5b82c74940cab48c9db0f617161 Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Sun, 30 Nov 2025 20:32:53 +0800 Subject: [PATCH 21/55] Let camera button toggle/close svg export panel --- src/topoViewer/webview-ui/uiHandlers.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/topoViewer/webview-ui/uiHandlers.ts b/src/topoViewer/webview-ui/uiHandlers.ts index f1e62ac7e..088a83eb3 100644 --- a/src/topoViewer/webview-ui/uiHandlers.ts +++ b/src/topoViewer/webview-ui/uiHandlers.ts @@ -18,6 +18,7 @@ const DISPLAY_NONE = 'none' as const; const ERR_NO_CY = 'Cytoscape instance not available' as const; const LINK_LABEL_MENU_ID = 'viewport-link-label-menu' as const; const LINK_LABEL_TRIGGER_ID = 'viewport-link-label-button' as const; +const CAPTURE_PANEL_ID = 'viewport-drawer-capture-sceenshoot' as const; // Global message sender instance let messageSender: VscodeMessageSender | null = null; @@ -374,7 +375,7 @@ export async function viewportDrawerCaptureFunc(event: Event): Promise { borderPadding }); - const panel = document.getElementById('viewport-drawer-capture-sceenshoot'); + const panel = document.getElementById(CAPTURE_PANEL_ID); if (panel) { panel.style.display = 'none'; } @@ -387,19 +388,18 @@ export async function viewportDrawerCaptureFunc(event: Event): Promise { * Capture viewport as SVG - called by the navbar button */ export function viewportButtonsCaptureViewportAsSvg(): void { - const panel = document.getElementById('viewport-drawer-capture-sceenshoot'); + const panel = document.getElementById(CAPTURE_PANEL_ID); if (!panel) return; - // Hide other viewport drawers + const isVisible = panel.style.display === 'block'; const drawers = document.getElementsByClassName('viewport-drawer'); for (let i = 0; i < drawers.length; i++) { (drawers[i] as HTMLElement).style.display = 'none'; } - panel.style.display = 'block'; + panel.style.display = isVisible ? 'none' : 'block'; } - /** * Toggle split view with YAML editor */ @@ -434,4 +434,4 @@ export function initializeGlobalHandlers(): void { (globalThis as any).viewportCloseLinkLabelMenu = viewportCloseLinkLabelMenu; log.info('Global UI handlers initialized'); -} +} \ No newline at end of file From 644720daeb79458de2dd07618c776dde06524b79 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Sun, 30 Nov 2025 13:41:02 +0100 Subject: [PATCH 22/55] Add native node modules plugin to esbuild configuration --- esbuild.config.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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/'); From 7028da8b27742c8aedb694b7a84693b3f5f73d50 Mon Sep 17 00:00:00 2001 From: flosch62 Date: Mon, 24 Nov 2025 15:28:15 +0100 Subject: [PATCH 23/55] first try --- .../providers/topoViewerEditorWebUiFacade.ts | 8 +- src/topoViewer/templates/main.html | 1 + .../templates/partials/panel-free-shapes.html | 151 +++ .../partials/unified-floating-panel.html | 18 + src/topoViewer/types/topoViewerGraph.ts | 32 + .../webview-ui/managerCytoscapeBaseStyles.ts | 35 +- .../managerCytoscapeFetchAndLoad.ts | 10 + .../webview-ui/managerFreeShapes.ts | 1021 +++++++++++++++++ .../webview-ui/managerUnifiedFloatingPanel.ts | 113 +- src/topoViewer/webview-ui/tailwind.css | 67 ++ .../webview-ui/topologyWebviewController.ts | 56 +- 11 files changed, 1499 insertions(+), 13 deletions(-) create mode 100644 src/topoViewer/templates/partials/panel-free-shapes.html create mode 100644 src/topoViewer/webview-ui/managerFreeShapes.ts diff --git a/src/topoViewer/providers/topoViewerEditorWebUiFacade.ts b/src/topoViewer/providers/topoViewerEditorWebUiFacade.ts index 882cb1058..328ed6997 100644 --- a/src/topoViewer/providers/topoViewerEditorWebUiFacade.ts +++ b/src/topoViewer/providers/topoViewerEditorWebUiFacade.ts @@ -1750,15 +1750,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 }; } } @@ -1772,6 +1773,7 @@ topology: const existing = await annotationsManager.loadAnnotations(this.lastYamlFilePath); await annotationsManager.saveAnnotations(this.lastYamlFilePath, { freeTextAnnotations: data.annotations, + freeShapeAnnotations: data.freeShapeAnnotations || existing.freeShapeAnnotations, groupStyleAnnotations: data.groupStyles, cloudNodeAnnotations: existing.cloudNodeAnnotations, nodeAnnotations: existing.nodeAnnotations, @@ -1779,7 +1781,7 @@ topology: 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) { 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/panel-free-shapes.html b/src/topoViewer/templates/partials/panel-free-shapes.html new file mode 100644 index 000000000..9880e07f0 --- /dev/null +++ b/src/topoViewer/templates/partials/panel-free-shapes.html @@ -0,0 +1,151 @@ + + diff --git a/src/topoViewer/templates/partials/unified-floating-panel.html b/src/topoViewer/templates/partials/unified-floating-panel.html index fc036fb35..76902e2ed 100644 --- a/src/topoViewer/templates/partials/unified-floating-panel.html +++ b/src/topoViewer/templates/partials/unified-floating-panel.html @@ -163,6 +163,15 @@ + + +
    - -
    + +
    @@ -56,12 +56,28 @@

    100%
    +
    + + +
    - +
    - +
    - +
    - + Date: Tue, 25 Nov 2025 12:08:19 +0100 Subject: [PATCH 30/55] copy paste works --- src/topoViewer/webview-ui/managerCopyPaste.ts | 65 +++++++++++++++++-- .../webview-ui/managerCytoscapeBaseStyles.ts | 2 - .../webview-ui/managerFreeShapes.ts | 3 +- .../webview-ui/topologyWebviewController.ts | 1 + 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/topoViewer/webview-ui/managerCopyPaste.ts b/src/topoViewer/webview-ui/managerCopyPaste.ts index 2ddd785ef..0691e1423 100644 --- a/src/topoViewer/webview-ui/managerCopyPaste.ts +++ b/src/topoViewer/webview-ui/managerCopyPaste.ts @@ -1,6 +1,7 @@ import { VscodeMessageSender } from './managerVscodeWebview'; import { ManagerGroupStyle } from './managerGroupStyle'; import { ManagerFreeText } from './managerFreeText'; +import { ManagerFreeShapes } from './managerFreeShapes'; import loadCytoStyle from './managerCytoscapeBaseStyles'; import { isSpecialEndpoint } from '../utilities/specialNodes'; import { log } from '../logging/logger'; @@ -28,6 +29,7 @@ export class CopyPasteManager { private messageSender: VscodeMessageSender; private groupStyleManager: ManagerGroupStyle; private freeTextManager: ManagerFreeText; + private freeShapesManager: ManagerFreeShapes | null = null; private pasteCounter: number = 0; private lastPasteCenter: { x: number; y: number } | null = null; @@ -45,6 +47,14 @@ export class CopyPasteManager { this.lastPasteCenter = null; } + /** + * Sets the free shapes manager for copy/paste operations. + * Called after initialization since freeShapesManager may be created later. + */ + public setFreeShapesManager(manager: ManagerFreeShapes): void { + this.freeShapesManager = manager; + } + /** * Handles copy operation for selected elements. * Collects selected nodes, edges, and annotations, then sends to VSCode for storage. @@ -99,6 +109,9 @@ export class CopyPasteManager { const allAnnotations = this.freeTextManager.getAnnotations(); return allAnnotations.find(annotation => annotation.id === node.id()); }).filter(Boolean), + freeShapeAnnotations: nodes.filter('[topoViewerRole = "freeShape"]').map((node: any) => { + return node.data('freeShapeData'); + }).filter(Boolean), cloudNodeAnnotations: [], nodeAnnotations: [] }; @@ -125,10 +138,17 @@ export class CopyPasteManager { const added = this.cy.add(newElements); this.postProcess(added, idMap, data.annotations); - this.pasteAnnotations(data.annotations, data.originalCenter); + const pastedAnnotationIds = this.pasteAnnotations(data.annotations, data.originalCenter); this.cy.$(':selected').unselect(); added.select(); + // Also select pasted annotation nodes (freeText, freeShapes) + pastedAnnotationIds.forEach(id => { + const node = this.cy.$id(id); + if (node && node.length > 0) { + node.select(); + } + }); this.pasteCounter++; return added; } @@ -145,7 +165,7 @@ export class CopyPasteManager { const newElements: any[] = []; elements.forEach(el => { - if (el.group === 'nodes' && el.data.topoViewerRole !== 'freeText') { + if (el.group === 'nodes' && el.data.topoViewerRole !== 'freeText' && el.data.topoViewerRole !== 'freeShape') { const { newNode, newId, nodeName } = this.createNode(el, usedIds, usedNames); idMap.set(el.data.id, newId); usedIds.add(newId); @@ -315,7 +335,10 @@ export class CopyPasteManager { * @param annotations - The annotations to apply to the new elements. */ private postProcess(added: any, idMap: Map, annotations: TopologyAnnotations): void { - loadCytoStyle(this.cy).catch((error) => { + loadCytoStyle(this.cy).then(() => { + // After styles are reloaded, reapply shape-specific styles to prevent dimension reset + this.freeShapesManager?.reapplyAllShapeStyles(); + }).catch((error) => { log.error(`Failed to load cytoscape styles during paste operation: ${error}`); }); @@ -379,15 +402,17 @@ export class CopyPasteManager { * Handle annotation pasting directly with managers * @param annotations - The annotations to paste. * @param originalCenter - The original center position for calculating deltas. + * @returns Array of IDs of pasted annotation nodes. */ - private pasteAnnotations(annotations: TopologyAnnotations, originalCenter: { x: number; y: number }): void { + private pasteAnnotations(annotations: TopologyAnnotations, originalCenter: { x: number; y: number }): string[] { + const pastedIds: string[] = []; const delta = this._getPasteDelta(originalCenter); - if (!delta) return; + if (!delta) return pastedIds; const { deltaX, deltaY } = delta; // Handle free text annotations with position adjustment - annotations.freeTextAnnotations?.forEach(annotation => { - const newId = `freeText_${Date.now()}_${this.pasteCounter}`; + annotations.freeTextAnnotations?.forEach((annotation, index) => { + const newId = `freeText_${Date.now()}_${this.pasteCounter}_${index}`; const newAnnotation = { ...annotation, id: newId, @@ -397,11 +422,37 @@ export class CopyPasteManager { } }; this.freeTextManager.addFreeTextAnnotation(newAnnotation); + pastedIds.push(newId); }); + // Handle free shape annotations with position adjustment + if (this.freeShapesManager) { + annotations.freeShapeAnnotations?.forEach((annotation, index) => { + const newId = `freeShape_${Date.now()}_${this.pasteCounter}_${index}`; + const newAnnotation = { + ...annotation, + id: newId, + position: { + x: annotation.position.x + deltaX, + y: annotation.position.y + deltaY + } + }; + // For lines, also adjust the end position + if (annotation.shapeType === 'line' && annotation.endPosition) { + newAnnotation.endPosition = { + x: annotation.endPosition.x + deltaX, + y: annotation.endPosition.y + deltaY + }; + } + this.freeShapesManager!.addFreeShapeAnnotation(newAnnotation); + pastedIds.push(newId); + }); + } + // Group style annotations are handled in postProcess // Future special annotation handling can be added here: // annotations.cloudNodeAnnotations?.forEach(...) // annotations.nodeAnnotations?.forEach(...) + return pastedIds; } } diff --git a/src/topoViewer/webview-ui/managerCytoscapeBaseStyles.ts b/src/topoViewer/webview-ui/managerCytoscapeBaseStyles.ts index 44940f56a..5da17482e 100644 --- a/src/topoViewer/webview-ui/managerCytoscapeBaseStyles.ts +++ b/src/topoViewer/webview-ui/managerCytoscapeBaseStyles.ts @@ -637,8 +637,6 @@ const freeShapeStyles = [ 'border-width': 0, label: '', 'z-index': 10, - width: 100, - height: 100, 'events': 'yes' } }, diff --git a/src/topoViewer/webview-ui/managerFreeShapes.ts b/src/topoViewer/webview-ui/managerFreeShapes.ts index e6ea2628f..c272d65aa 100644 --- a/src/topoViewer/webview-ui/managerFreeShapes.ts +++ b/src/topoViewer/webview-ui/managerFreeShapes.ts @@ -1507,8 +1507,9 @@ export class ManagerFreeShapes { /** * Reapply styles to all shape annotations. * Called before position restore to ensure nodes are in correct state. + * Also called after paste operations to restore proper dimensions. */ - private reapplyAllShapeStyles(): void { + public reapplyAllShapeStyles(): void { this.annotationNodes.forEach((node, id) => { const annotation = this.annotations.get(id); if (annotation) { diff --git a/src/topoViewer/webview-ui/topologyWebviewController.ts b/src/topoViewer/webview-ui/topologyWebviewController.ts index c976cd6f6..cd8fb9605 100644 --- a/src/topoViewer/webview-ui/topologyWebviewController.ts +++ b/src/topoViewer/webview-ui/topologyWebviewController.ts @@ -526,6 +526,7 @@ class TopologyWebviewController { this.groupStyleManager, this.freeTextManager ); + this.copyPasteManager.setFreeShapesManager(this.freeShapesManager); if (mode === "edit") { this.viewportPanels = new ManagerViewportPanels(this.saveManager, this.cy); (window as any).viewportPanels = this.viewportPanels; From fdf0bf9f21f5a77c2ffc14118ba37e4c0abd48a3 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Tue, 25 Nov 2025 12:10:22 +0100 Subject: [PATCH 31/55] del to delete --- src/topoViewer/webview-ui/topologyWebviewController.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/topoViewer/webview-ui/topologyWebviewController.ts b/src/topoViewer/webview-ui/topologyWebviewController.ts index cd8fb9605..050ad4ffb 100644 --- a/src/topoViewer/webview-ui/topologyWebviewController.ts +++ b/src/topoViewer/webview-ui/topologyWebviewController.ts @@ -2467,6 +2467,9 @@ class TopologyWebviewController { // Handle free text nodes using the existing manager if (topoViewerRole === "freeText") { this.freeTextManager?.removeFreeTextAnnotation(node.id()); + } else if (topoViewerRole === "freeShape") { + // Handle free shape nodes using the existing manager + this.freeShapesManager?.removeFreeShapeAnnotation(node.id()); } else if (topoViewerRole === "group") { // Handle group nodes - use the group management system if (this.isViewportDrawerClabEditorChecked) { From d64f43dad2dc7067157a74adc8d3a12513282cc6 Mon Sep 17 00:00:00 2001 From: Kaelem Chandra Date: Sun, 30 Nov 2025 22:54:37 +0800 Subject: [PATCH 32/55] Migrate to new window manager class for topoviewer panels --- .../templates/partials/draggable-panels.html | 437 ++--- .../partials/panel-lab-settings.html | 345 ++-- .../templates/partials/panel-link-editor.html | 284 +-- .../templates/partials/panel-link.html | 4 +- .../partials/panel-network-editor.html | 279 +-- .../partials/panel-node-editor-parent.html | 379 ++-- .../templates/partials/panel-node-editor.html | 1636 +++++++++-------- .../templates/partials/panel-node.html | 198 +- src/topoViewer/webview-ui/index.ts | 27 + .../webview-ui/lib/windowManager.ts | 560 ++++++ .../lib/windowManagerIntegration.ts | 506 +++++ .../webview-ui/managerViewportPanels.ts | 54 +- src/topoViewer/webview-ui/tailwind.css | 108 ++ .../webview-ui/topologyWebviewController.ts | 205 ++- src/topoViewer/webview-ui/uiHandlers.ts | 11 +- 15 files changed, 3120 insertions(+), 1913 deletions(-) create mode 100644 src/topoViewer/webview-ui/lib/windowManager.ts create mode 100644 src/topoViewer/webview-ui/lib/windowManagerIntegration.ts diff --git a/src/topoViewer/templates/partials/draggable-panels.html b/src/topoViewer/templates/partials/draggable-panels.html index 37e7ba278..3f746876f 100644 --- a/src/topoViewer/templates/partials/draggable-panels.html +++ b/src/topoViewer/templates/partials/draggable-panels.html @@ -1,338 +1,167 @@ diff --git a/src/topoViewer/templates/partials/panel-lab-settings.html b/src/topoViewer/templates/partials/panel-lab-settings.html index e4c815758..7845bbf14 100644 --- a/src/topoViewer/templates/partials/panel-lab-settings.html +++ b/src/topoViewer/templates/partials/panel-lab-settings.html @@ -1,6 +1,6 @@ -
    -
    - -
    -
    - -
    - - - - Unique name to identify and distinguish this topology from others - -
    +
    + +
    +
    + +
    + + + + Unique name to identify and distinguish this topology from others + +
    - -
    - - - - - Default: clab-<lab-name>-<node-name> | No prefix: <node-name> - -
    + +
    + + + + + Default: clab-<lab-name>-<node-name> | No prefix: <node-name> +
    +
    - -