diff --git a/CHANGELOG.md b/CHANGELOG.md index e21af59d..593b4283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # 2.11.0 - Upgrade default Camel JBang version from 4.18.0 to 4.19.0 +- Add `Infrastructure` section into Kaoto view + - list running infrastructure services + - start/stop/restart infrastructure service # 2.10.2 diff --git a/README.md b/README.md index 3613a43b..f7da47d7 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,15 @@ You can follow [Installation Guide](https://kaoto.io/docs/installation) on a Kao 2. Install **JBang** 3. Install the **Kaoto extension** from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-kaoto) or [Open VSX Registry](https://open-vsx.org/extension/redhat/vscode-kaoto) +### Requirements for Infrastructure Services + +To use the **Infrastructure Services** feature (e.g., running Kafka, databases, etc.), you need a container runtime: + +- **Docker** or **Podman** must be installed and running +- The container runtime must be accessible from the command line + +If you don't have a container runtime installed, we recommend [Podman Desktop](https://podman-desktop.io) as a free, open-source alternative to Docker Desktop. + ## Documentation Learn more about Kaoto and how to use it effectively: diff --git a/it-tests/Util.ts b/it-tests/Util.ts index a704c3a4..75603862 100644 --- a/it-tests/Util.ts +++ b/it-tests/Util.ts @@ -526,7 +526,7 @@ export async function expandViews(kaotoView: SideBarView | undefined, ...views: export async function getKaotoViewControl(): Promise<{ kaotoViewContainer: ViewControl | undefined; kaotoView: SideBarView | undefined }> { const kaotoViewContainer = await new ActivityBar().getViewControl('Kaoto'); const kaotoView = await kaotoViewContainer?.openView(); - await collapseViews(kaotoView, 'Integrations', 'Deployments', 'OpenAPI', 'Tests', 'Help & Feedback'); + await collapseViews(kaotoView, 'Integrations', 'Deployments', 'OpenAPI', 'Tests', 'Infrastructure', 'Help & Feedback'); return { kaotoViewContainer, kaotoView }; } diff --git a/package.json b/package.json index e4cca17e..982dec7e 100644 --- a/package.json +++ b/package.json @@ -501,6 +501,41 @@ "category": "Kaoto", "icon": "$(clear-all)" }, + { + "command": "kaoto.infrastructure.start", + "title": "Start Infra Service...", + "category": "Kaoto", + "icon": "$(add)", + "enablement": "!kaoto.infrastructureStarting" + }, + { + "command": "kaoto.infrastructure.refresh", + "title": "Refresh", + "category": "Kaoto", + "icon": "$(refresh)" + }, + { + "command": "kaoto.infrastructure.stop", + "title": "Stop Infra Service", + "category": "Kaoto", + "icon": "$(circle-slash)" + }, + { + "command": "kaoto.infrastructure.logs", + "title": "Follow Infra Logs", + "category": "Kaoto", + "icon": "$(terminal)" + }, + { + "command": "kaoto.infrastructure.copyUrl", + "title": "Copy URL", + "category": "Kaoto" + }, + { + "command": "kaoto.infrastructure.copyPort", + "title": "Copy Port", + "category": "Kaoto" + }, { "command": "kaoto.openapi.refresh", "title": "Refresh", @@ -703,6 +738,26 @@ { "command": "kaoto.openapi.import", "when": "false" + }, + { + "command": "kaoto.infrastructure.start", + "when": "false" + }, + { + "command": "kaoto.infrastructure.stop", + "when": "false" + }, + { + "command": "kaoto.infrastructure.logs", + "when": "false" + }, + { + "command": "kaoto.infrastructure.copyUrl", + "when": "false" + }, + { + "command": "kaoto.infrastructure.copyPort", + "when": "false" } ], "editor/title": [ @@ -770,6 +825,16 @@ "group": "navigation@2", "when": "view == kaoto.tests && kaoto.testResultsExist" }, + { + "command": "kaoto.infrastructure.start", + "group": "navigation@1", + "when": "view == kaoto.infrastructure && !virtualWorkspace && kaoto.jbangAvailable" + }, + { + "command": "kaoto.infrastructure.refresh", + "group": "navigation@2", + "when": "view == kaoto.infrastructure" + }, { "command": "kaoto.openapi.refresh", "group": "navigation@2", @@ -872,6 +937,26 @@ "when": "view == kaoto.tests && viewItem == test-folder && kaoto.jbangAvailable", "group": "inline@1" }, + { + "command": "kaoto.infrastructure.stop", + "when": "view == kaoto.infrastructure && viewItem =~ /^infrastructure-service.*/", + "group": "inline@1" + }, + { + "command": "kaoto.infrastructure.logs", + "when": "view == kaoto.infrastructure && viewItem =~ /^infrastructure-service(?!-external).*/", + "group": "inline@2" + }, + { + "command": "kaoto.infrastructure.copyUrl", + "when": "view == kaoto.infrastructure && viewItem =~ /.*-has-url.*/", + "group": "navigation@1" + }, + { + "command": "kaoto.infrastructure.copyPort", + "when": "view == kaoto.infrastructure && viewItem =~ /.*-has-port.*/", + "group": "navigation@2" + }, { "command": "kaoto.openapi.showSource", "when": "view == kaoto.openapi && viewItem == openapi", @@ -911,6 +996,16 @@ "view": "kaoto.tests", "contents": "In order to start with Citrus, you can create a new Citrus Test file.\n[New Citrus Test...](command:kaoto.citrus.jbang.init.test)\nTo learn more about Camel Testing [read docs](https://camel.apache.org/manual/camel-jbang-test.html).", "when": "!virtualWorkspace && kaoto.jbangAvailable && workspaceFolderCount > 0" + }, + { + "view": "kaoto.infrastructure", + "contents": "Run local infrastructure services with Camel Infra.\n[Start Infrastructure Service...](command:kaoto.infrastructure.start)\nRunning services appear here with startup status, logs, stop actions, and service details.", + "when": "!virtualWorkspace && kaoto.jbangAvailable && workspaceFolderCount > 0" + }, + { + "view": "kaoto.infrastructure", + "contents": "You have not yet added a folder to the workspace.\n[Open Folder](command:vscode.openFolder)", + "when": "workspaceFolderCount == 0" } ], "configuration": [ @@ -943,9 +1038,9 @@ "markdownDescription": "Camel version used for internal Camel JBang CLI commands execution. As default Camel Version is used `#kaoto.camelJbang.version#`.", "order": 2 }, - "kaoto.deployments.refresh.interval": { + "kaoto.views.refresh.interval": { "type": "number", - "markdownDescription": "Set default auto-refresh interval in milliseconds for a `Kaoto > Deployments` view. **Default recommended interval is 30s.**", + "markdownDescription": "Set default auto-refresh interval in milliseconds for a views (Deployments, Infrastructure). **Default recommended interval is 30s.**", "enum": [ 1000, 5000, @@ -996,7 +1091,8 @@ "*.citrus.it.yaml", "*.citrus-test.yaml", "*.citrus-it.yaml", - "jbang.properties" + "jbang.properties", + "citrus*.properties" ], "markdownDescription": "Regular expression to filter files to be displayed in `Kaoto > Tests` view.", "scope": "window", @@ -1138,6 +1234,19 @@ "--dev" ], "order": 5 + }, + "kaoto.camelJbang.infraArguments": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + }, + "additionalProperties": false, + "markdownDescription": "User defined arguments to be applied by default when starting services from `Kaoto > Infrastructure`. (See [Camel Infra Run](https://camel.apache.org/manual/jbang-commands/camel-jbang-infra-run.html))", + "default": [ + "--log" + ], + "order": 6 } } }, @@ -1246,6 +1355,14 @@ "icon": "icons/kaoto.png", "initialSize": 2 }, + { + "id": "kaoto.infrastructure", + "name": "Infrastructure", + "contextualTitle": "Kaoto", + "icon": "icons/kaoto.png", + "initialSize": 2, + "when": "!virtualWorkspace" + }, { "id": "kaoto.help", "name": "Help & Feedback", diff --git a/src/commands/StartInfrastructureServiceCommand.ts b/src/commands/StartInfrastructureServiceCommand.ts new file mode 100644 index 00000000..1f79311a --- /dev/null +++ b/src/commands/StartInfrastructureServiceCommand.ts @@ -0,0 +1,255 @@ +/** + * Copyright 2026 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { exec } from 'child_process'; +import * as vscode from 'vscode'; +import { CamelInfraJBang } from '../helpers/CamelInfraJBang'; +import { KaotoOutputChannel } from '../extension/KaotoOutputChannel'; +import { CamelInfraRunJBangTask } from '../tasks/CamelInfraRunJBangTask'; +import { InfrastructureProvider } from '../views/providers/InfrastructureProvider'; +import { DockerErrorDetector } from '../helpers/DockerErrorDetector'; + +export class StartInfrastructureServiceCommand { + public static readonly ID_COMMAND = 'kaoto.infrastructure.start'; + + constructor(private readonly infrastructureProvider: InfrastructureProvider) {} + + private getServiceTargetUrl(service: { url?: string; port?: number }): string | undefined { + return service.url ?? (service.port ? `localhost:${service.port}` : undefined); + } + + private showServiceAlreadyRunningMessage(serviceName: string, target?: string): void { + vscode.window.showInformationMessage( + target ? `Infrastructure service "${serviceName}" is already running at ${target}.` : `Infrastructure service "${serviceName}" is already running.`, + ); + } + + public async execute(): Promise { + // Prevent starting a new service if one is already being started + if (this.infrastructureProvider.isServiceStarting()) { + return; + } + + try { + // Set the starting flag to disable the button + this.infrastructureProvider.setStartingService(true); + + const selectedService = await this.selectService(); + if (!selectedService) { + this.infrastructureProvider.setStartingService(false); + return; + } + + const shouldContinue = await this.handleExistingService(selectedService.label); + if (!shouldContinue) { + this.infrastructureProvider.setStartingService(false); + return; + } + + await this.configureAndStartService(selectedService); + } catch (error) { + this.handleError(error); + } + } + + private async selectService(): Promise<{ label: string; description: string } | undefined> { + const services = await this.infrastructureProvider.ensureAvailableServicesLoaded(); + if (services.length === 0) { + vscode.window.showInformationMessage('No infrastructure services are available from Camel JBang infra.'); + return undefined; + } + + return vscode.window.showQuickPick( + services.map((service) => ({ + label: service.name, + description: service.description ?? '', + })), + { + title: 'Select infrastructure service', + placeHolder: 'Choose a Camel Infra service to start', + }, + ); + } + + private async handleExistingService(serviceName: string): Promise { + // Fast check: in-memory state first + const existingService = this.infrastructureProvider.getRunningService(serviceName); + if (existingService) { + if (existingService.isExternal) { + return this.handleExternalServiceConflict(serviceName, existingService); + } else { + // It's a managed service already running + const target = this.getServiceTargetUrl(existingService); + this.showServiceAlreadyRunningMessage(serviceName, target); + this.infrastructureProvider.setStartingService(false); + return false; + } + } + + // Slower check: CLI state (only if not in memory) + const cliRunningService = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Checking if ${serviceName} is already running...`, + cancellable: false, + }, + async () => this.infrastructureProvider.getCliRunningService(serviceName), + ); + + if (cliRunningService) { + // Service is running but not yet tracked - register it and handle inline + this.infrastructureProvider.registerRunningService({ + name: cliRunningService.name, + description: cliRunningService.description ?? '', + port: cliRunningService.port, + url: cliRunningService.url, + args: [], + terminalName: `${cliRunningService.name} (external)`, + status: 'running', + isExternal: true, + }); + + // Handle the external service conflict inline + return this.handleExternalServiceConflict(serviceName, { + port: cliRunningService.port, + url: cliRunningService.url, + isExternal: true, + }); + } + + return true; + } + + private async handleExternalServiceConflict(serviceName: string, existingService: any): Promise { + const action = await vscode.window.showWarningMessage( + `Infrastructure service "${serviceName}" is already running externally at ${this.getServiceTargetUrl(existingService) || 'unknown location'}. What would you like to do?`, + 'Use Existing', + 'Stop and Restart', + 'Cancel', + ); + + if (action === 'Cancel' || !action) { + this.infrastructureProvider.setStartingService(false); + return false; + } + + if (action === 'Use Existing') { + this.infrastructureProvider.setStartingService(false); + return false; + } + + // User chose "Stop and Restart" - stop the external service first + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Stopping external ${serviceName}...`, + cancellable: false, + }, + async () => { + const stopTask = new CamelInfraJBang().stop(serviceName); + const command = typeof stopTask.command === 'string' ? stopTask.command : (stopTask.command?.value ?? 'jbang'); + const args = stopTask.args?.map((arg) => (typeof arg === 'string' ? arg : arg.value)).join(' ') ?? ''; + + await new Promise((resolve, reject) => { + exec(`${command} ${args}`, (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr || error.message)); + return; + } + resolve(); + }); + }); + }, + ); + + // Wait a moment for the service to fully stop + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Remove from tracked services + this.infrastructureProvider.unregisterRunningService(serviceName); + return true; + } catch (error) { + KaotoOutputChannel.logError(`[Infrastructure] Failed to stop external service "${serviceName}"`, error); + vscode.window.showErrorMessage(`Failed to stop external service: ${String(error)}`); + this.infrastructureProvider.setStartingService(false); + return false; + } + } + + private async configureAndStartService(selectedService: { label: string; description: string }): Promise { + const portValue = await vscode.window.showInputBox({ + title: `Configure ${selectedService.label}`, + prompt: 'Enter a port number or leave EMPTY to use the default', + ignoreFocusOut: true, + validateInput: (value) => { + if (!value) { + return undefined; + } + const port = Number(value); + return Number.isInteger(port) && port >= 1 && port <= 65535 ? undefined : 'Port must be between 1 and 65535.'; + }, + }); + + if (portValue === undefined) { + this.infrastructureProvider.setStartingService(false); + return; + } + + const args = [...new CamelInfraJBang().getConfiguredDefaultArgs()]; + const port = portValue ? Number(portValue) : undefined; + + const runTask = CamelInfraRunJBangTask.create( + selectedService.label, + { + port, + args, + }, + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + ); + + await runTask.execute(); + + // Clear the starting flag after task is executed - the service is now starting in background + this.infrastructureProvider.setStartingService(false); + + this.infrastructureProvider.registerRunningService({ + name: selectedService.label, + description: selectedService.description, + port, + url: this.getServiceTargetUrl({ port }), + args, + terminalName: runTask.name, + status: 'starting', + }); + } + + private handleError(error: unknown): void { + const errorMessage = String(error); + const dockerError = DockerErrorDetector.detectDockerError(errorMessage); + + if (dockerError) { + KaotoOutputChannel.logError('[Infrastructure] Docker environment error', error); + vscode.window.showErrorMessage(dockerError.userMessage); + } else { + KaotoOutputChannel.logError('[Infrastructure] Failed to start infrastructure service.', error); + vscode.window.showWarningMessage(`Unable to start infrastructure service: ${errorMessage}`); + } + + // Clear the starting flag on error + this.infrastructureProvider.setStartingService(false); + } +} diff --git a/src/extension/ExtensionContextHandler.ts b/src/extension/ExtensionContextHandler.ts index e901b1bb..58a7e2ee 100644 --- a/src/extension/ExtensionContextHandler.ts +++ b/src/extension/ExtensionContextHandler.ts @@ -33,7 +33,7 @@ import { } from '../helpers/helpers'; import { KaotoOutputChannel } from './KaotoOutputChannel'; import { NewCamelFileCommand } from '../commands/NewCamelFileCommand'; -import { confirmFileDeleteDialog } from '../helpers/modals'; +import { confirmFileDeleteDialog, confirmInfrastructureServiceStop } from '../helpers/modals'; import { TelemetryEvent, TelemetryService } from '@redhat-developer/vscode-redhat-telemetry'; import { NewCamelProjectCommand } from '../commands/NewCamelProjectCommand'; import { CamelRunJBangTask } from '../tasks/CamelRunJBangTask'; @@ -61,6 +61,10 @@ import { CamelTestRunJBangTask } from '../tasks/CamelTestRunJBangTask'; import { Test } from '../views/testTreeItems/Test'; import { OpenApiProvider } from '../views/providers/OpenApiProvider'; import { ImportOpenApiCommand } from '../commands/ImportOpenApiCommand'; +import { InfrastructureProvider } from '../views/providers/InfrastructureProvider'; +import { InfrastructureItem } from '../views/infrastructureTreeItems/InfrastructureItem'; +import { CamelInfraStopJBangTask } from '../tasks/CamelInfraStopJBangTask'; +import { StartInfrastructureServiceCommand } from '../commands/StartInfrastructureServiceCommand'; export class ExtensionContextHandler { protected kieEditorStore: KogitoVsCode.VsCodeKieEditorStore; @@ -69,6 +73,7 @@ export class ExtensionContextHandler { protected testsProvider: TestsProvider; protected deploymentsProvider: DeploymentsProvider; protected openApiProvider: OpenApiProvider; + protected infrastructureProvider: InfrastructureProvider; constructor( context: vscode.ExtensionContext, @@ -274,6 +279,31 @@ export class ExtensionContextHandler { this.registerViewItemContextMenu(this.testsProvider); } + public registerInfrastructureView() { + this.infrastructureProvider = new InfrastructureProvider(); + const infrastructureTreeView = vscode.window.createTreeView('kaoto.infrastructure', { + treeDataProvider: this.infrastructureProvider, + showCollapseAll: false, + }); + + const refreshCommand = vscode.commands.registerCommand('kaoto.infrastructure.refresh', async () => { + this.infrastructureProvider.refresh(); + }); + + const visibilityChange = infrastructureTreeView.onDidChangeVisibility(async (event) => { + if (event.visible) { + try { + await this.infrastructureProvider.ensureAvailableServicesLoaded(); + } catch (error) { + vscode.window.showWarningMessage(`Unable to load infrastructure services: ${String(error)}`); + } + this.infrastructureProvider.refresh(); + } + }); + + this.context.subscriptions.push(infrastructureTreeView, this.infrastructureProvider, refreshCommand, visibilityChange); + } + public registerTestsInitCommands() { this.context.subscriptions.push( vscode.commands.registerCommand(NewCamelTestCommand.ID_COMMAND_CITRUS_INIT, async () => { @@ -647,6 +677,79 @@ export class ExtensionContextHandler { this.context.subscriptions.push(startCommand, stopCommand, resumeCommand, suspendCommand); } + public registerInfrastructureCommands() { + const INFRASTRUCTURE_START_COMMAND_ID = 'kaoto.infrastructure.start'; + const INFRASTRUCTURE_STOP_COMMAND_ID = 'kaoto.infrastructure.stop'; + const INFRASTRUCTURE_LOGS_COMMAND_ID = 'kaoto.infrastructure.logs'; + const INFRASTRUCTURE_COPY_URL_COMMAND_ID = 'kaoto.infrastructure.copyUrl'; + const INFRASTRUCTURE_COPY_PORT_COMMAND_ID = 'kaoto.infrastructure.copyPort'; + + const startInfrastructureServiceCommand = new StartInfrastructureServiceCommand(this.infrastructureProvider); + const startCommand = vscode.commands.registerCommand(INFRASTRUCTURE_START_COMMAND_ID, async () => { + await startInfrastructureServiceCommand.execute(); + await this.sendCommandTrackingEvent(INFRASTRUCTURE_START_COMMAND_ID); + }); + + const stopCommand = vscode.commands.registerCommand(INFRASTRUCTURE_STOP_COMMAND_ID, async (item: InfrastructureItem) => { + // CRITICAL: Capture service name at the VERY START before ANY async operation + // This prevents issues when tree refreshes (triggered by other stop operations) invalidate the item reference + const serviceName = item?.service?.name; + + if (!serviceName) { + KaotoOutputChannel.logWarning('[Infrastructure] Stop command called with invalid item'); + return; + } + + const confirmation = await confirmInfrastructureServiceStop(serviceName); + + if (confirmation !== 'Stop') { + return; + } + + try { + await new CamelInfraStopJBangTask(serviceName).executeAndWait(); + this.infrastructureProvider.unregisterRunningService(serviceName); + await this.sendCommandTrackingEvent(INFRASTRUCTURE_STOP_COMMAND_ID); + } catch (error) { + KaotoOutputChannel.logError(`[Infrastructure] Failed to stop service "${serviceName}"`, error); + vscode.window.showErrorMessage(`Failed to stop ${serviceName}: ${String(error)}`); + } + }); + + const logsCommand = vscode.commands.registerCommand(INFRASTRUCTURE_LOGS_COMMAND_ID, async (item: InfrastructureItem) => { + const terminal = vscode.window.terminals.find((t) => t.name === item.service.terminalName); + if (terminal) { + terminal.show(); + } else { + KaotoOutputChannel.logWarning(`Terminal with a name "${item.service.terminalName}" was not found.`); + vscode.window.showWarningMessage(`Terminal for "${item.service.name}" was not found.`); + } + await this.sendCommandTrackingEvent(INFRASTRUCTURE_LOGS_COMMAND_ID); + }); + + const copyUrlCommand = vscode.commands.registerCommand(INFRASTRUCTURE_COPY_URL_COMMAND_ID, async (item: InfrastructureItem) => { + if (item.service.url) { + await vscode.env.clipboard.writeText(item.service.url); + vscode.window.showInformationMessage(`URL copied to clipboard: ${item.service.url}`); + await this.sendCommandTrackingEvent(INFRASTRUCTURE_COPY_URL_COMMAND_ID); + } else { + vscode.window.showWarningMessage(`No URL available for service "${item.service.name}"`); + } + }); + + const copyPortCommand = vscode.commands.registerCommand(INFRASTRUCTURE_COPY_PORT_COMMAND_ID, async (item: InfrastructureItem) => { + if (item.service.port) { + await vscode.env.clipboard.writeText(item.service.port.toString()); + vscode.window.showInformationMessage(`Port copied to clipboard: ${item.service.port}`); + await this.sendCommandTrackingEvent(INFRASTRUCTURE_COPY_PORT_COMMAND_ID); + } else { + vscode.window.showWarningMessage(`No port available for service "${item.service.name}"`); + } + }); + + this.context.subscriptions.push(startCommand, stopCommand, logsCommand, copyUrlCommand, copyPortCommand); + } + public async hideIntegrationsViewButtonsForMavenProjects() { // Initial check await this.updatePomContext(); diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 6b732203..a053af73 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -80,11 +80,12 @@ export async function activate(context: vscode.ExtensionContext) { contextHandler.registerOpenWithKaoto(); /* - * register all views (Integrations, Deployments, Tests, Help & Feedback, OpenAPI) first to avoid race conditions + * register all views (Integrations, Deployments, Infrastructure, Tests, Help & Feedback, OpenAPI) first to avoid race conditions */ contextHandler.registerHelpAndFeedbackView(); contextHandler.registerIntegrationsView(); contextHandler.registerDeploymentsView(portManager); + contextHandler.registerInfrastructureView(); contextHandler.registerTestsView(); contextHandler.registerOpenApiView(); @@ -104,6 +105,11 @@ export async function activate(context: vscode.ExtensionContext) { contextHandler.registerDeploymentsIntegrationCommands(); // Stop and Logs view item action buttons contextHandler.registerDeploymentsRouteCommands(); // Stop/Start/Resume/Suspend route level buttons + /* + * register commands for 'Infrastructure' view + */ + contextHandler.registerInfrastructureCommands(); + /* * register commands for 'OpenAPI' view */ diff --git a/src/helpers/CamelInfraJBang.ts b/src/helpers/CamelInfraJBang.ts new file mode 100644 index 00000000..b005da80 --- /dev/null +++ b/src/helpers/CamelInfraJBang.ts @@ -0,0 +1,205 @@ +/** + * Copyright 2026 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ShellExecution, ShellExecutionOptions, workspace } from 'vscode'; +import { CamelJBang } from './CamelJBang'; +import { KAOTO_CAMEL_JBANG_INFRA_ARGUMENTS_SETTING_ID } from './helpers'; +import { KaotoOutputChannel } from '../extension/KaotoOutputChannel'; + +export interface InfraServiceDefinition { + name: string; + description?: string; +} + +export interface InfraRunningServiceDetails { + name: string; + description?: string; + host?: string; + port?: number; + url?: string; + serviceData?: Record; +} + +export interface InfraRunConfiguration { + port?: number; + args: string[]; +} + +export class CamelInfraJBang extends CamelJBang { + constructor(jbang: string = 'jbang') { + super(jbang); + } + + public list(): ShellExecution { + return new ShellExecution(this.jbang, [...this.defaultJbangArgs, 'infra', 'list', '--json']); + } + + public start(service: string, config: InfraRunConfiguration, cwd?: string): ShellExecution { + const shellExecOptions: ShellExecutionOptions = { + cwd: cwd ?? undefined, + }; + + const args = [...config.args]; + if (config.port !== undefined && !args.some((arg) => arg === '--port' || arg.startsWith('--port='))) { + args.unshift(`--port=${config.port}`); + } + + return new ShellExecution(this.jbang, [...this.defaultJbangArgs, 'infra', 'run', service, ...args], shellExecOptions); + } + + public stop(service: string): ShellExecution { + return new ShellExecution(this.jbang, [...this.defaultJbangArgs, 'infra', 'stop', service]); + } + + public ps(): ShellExecution { + return new ShellExecution(this.jbang, [...this.defaultJbangArgs, 'infra', 'ps', '--json']); + } + + public getConfiguredDefaultArgs(): string[] { + const args = workspace.getConfiguration().get(KAOTO_CAMEL_JBANG_INFRA_ARGUMENTS_SETTING_ID); + return Array.isArray(args) ? args : []; + } + + public extractAvailableServices(output: string): InfraServiceDefinition[] { + let parsed: Array<{ name?: string; alias?: string; description?: string; aliasImplementation?: string }>; + try { + parsed = JSON.parse(output); + } catch (error) { + KaotoOutputChannel.logError('Failed to parse available services JSON. Raw output: ' + output, error); + return []; + } + const services: InfraServiceDefinition[] = []; + + for (const service of parsed) { + const name = service.alias ?? service.name; + if (typeof name !== 'string' || name.trim().length === 0) { + continue; + } + + const aliasImplementation = service.aliasImplementation?.trim(); + const description = [service.description?.trim(), aliasImplementation ? `Implementations: ${aliasImplementation}` : undefined] + .filter(Boolean) + .join(' — '); + + services.push({ + name: name.trim(), + description: description || undefined, + }); + } + + return services.sort((a, b) => a.name.localeCompare(b.name)); + } + + public extractRunningServices(output: string): InfraRunningServiceDetails[] { + let parsed: Array<{ + name?: string; + alias?: string; + description?: string; + serviceData?: Record; + }>; + try { + parsed = JSON.parse(output); + } catch (error) { + KaotoOutputChannel.logError('Failed to parse running services JSON. Raw output: ' + output, error); + return []; + } + + const services: InfraRunningServiceDetails[] = []; + for (const service of parsed) { + // Use alias if present, otherwise fall back to name + const identifier = service.alias?.trim() || service.name?.trim(); + + if (!identifier) { + continue; + } + + const serviceData = service.serviceData; + const host = this.extractHost(serviceData); + const port = this.extractPort(serviceData); + const url = host && port ? `${host}:${port}` : undefined; + + services.push({ + name: identifier, + description: service.description?.trim() || undefined, + host, + port, + url, + serviceData, + }); + } + + return services.sort((a, b) => a.name.localeCompare(b.name)); + } + + private extractPort(serviceData?: Record): number | undefined { + if (!serviceData) { + return undefined; + } + + const directPort = serviceData.port; + if (typeof directPort === 'number' && Number.isFinite(directPort)) { + return directPort; + } + + if (typeof directPort === 'string') { + const parsed = Number(directPort); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + for (const value of Object.values(serviceData)) { + if (typeof value !== 'string') { + continue; + } + + // Matches port numbers in URLs like "http://host:8080" or "host:8080/path" + // Captures digits after a colon, followed by either a slash or end of string + const match = /:(\d+)(?:\/|$)/.exec(value); + if (match) { + return Number(match[1]); + } + } + + return undefined; + } + + private extractHost(serviceData?: Record): string | undefined { + if (!serviceData) { + return undefined; + } + + const directHost = serviceData.host; + if (typeof directHost === 'string' && directHost.trim().length > 0) { + return directHost.trim(); + } + + for (const value of Object.values(serviceData)) { + if (typeof value !== 'string') { + continue; + } + + // Matches hostnames in URLs like "http://hostname:8080" or "hostname:8080" + // Captures the hostname part (excluding protocol, port, and whitespace) + const match = /^(?:[a-z]+:\/\/)?([^:/\s]+):\d+/i.exec(value); + if (match) { + return match[1]; + } + } + + return undefined; + } +} diff --git a/src/helpers/DockerErrorDetector.ts b/src/helpers/DockerErrorDetector.ts new file mode 100644 index 00000000..179694e8 --- /dev/null +++ b/src/helpers/DockerErrorDetector.ts @@ -0,0 +1,29 @@ +export interface DockerErrorInfo { + isDockerError: boolean; + userMessage: string; +} + +export class DockerErrorDetector { + private static readonly DOCKER_ERROR_PATTERN = /Could not find a valid Docker environment/i; + + /** + * Detects if the error message indicates Docker environment is not available + * @param errorOutput The error output to analyze + * @returns DockerErrorInfo if Docker error detected, null otherwise + */ + public static detectDockerError(errorOutput: string): DockerErrorInfo | null { + if (!errorOutput || typeof errorOutput !== 'string') { + return null; + } + + if (this.DOCKER_ERROR_PATTERN.test(errorOutput)) { + return { + isDockerError: true, + userMessage: + 'Docker environment not found. Infrastructure services require a container runtime (Docker or Podman) to be installed and running.', + }; + } + + return null; + } +} diff --git a/src/helpers/helpers.ts b/src/helpers/helpers.ts index f613fa07..9f026bd2 100644 --- a/src/helpers/helpers.ts +++ b/src/helpers/helpers.ts @@ -38,6 +38,8 @@ export const KAOTO_CAMEL_JBANG_RED_HAT_MAVEN_REPOSITORY_GLOBAL_SETTING_ID: strin export const KAOTO_CAMEL_JBANG_KUBERNETES_RUN_ARGUMENTS_SETTING_ID: string = 'kaoto.camelJbang.kubernetesRunArguments'; +export const KAOTO_CAMEL_JBANG_INFRA_ARGUMENTS_SETTING_ID: string = 'kaoto.camelJbang.infraArguments'; + export const KAOTO_MAVEN_CAMEL_JBANG_EXPORT_FOLDER_ARGUMENTS_SETTING_ID: string = 'kaoto.maven.camelJbang.exportProjectArguments'; export const KAOTO_LOCAL_KAMELET_DIRECTORIES_SETTING_ID: string = 'kaoto.localKameletDirectories'; diff --git a/src/helpers/modals.ts b/src/helpers/modals.ts index cc7801d8..59922b90 100644 --- a/src/helpers/modals.ts +++ b/src/helpers/modals.ts @@ -24,7 +24,7 @@ import { window } from 'vscode'; export async function confirmFileDeleteDialog(filename: string) { const message = `Are you sure you want to delete '${filename}'?`; const continueOption = 'Delete'; - return await window.showWarningMessage(message, { modal: true }, continueOption); + return await confirmDialog(message, continueOption); } /** @@ -38,5 +38,25 @@ export async function confirmFileDeleteDialog(filename: string) { export async function confirmDestructiveActionInSelectedFolder(outputPath: string) { const message = `Files in the folder: ${outputPath} WILL BE DELETED before project creation, continue?`; const continueOption = 'Continue'; + return await confirmDialog(message, continueOption); +} + +/** + * Shows a modal asking for user confirmation of stopping an infrastructure service. + * VS Code automatically provides a 'Cancel' option which return `undefined`. + * The continue option will return the string `Stop`. + * The function will return `undefined` if the user cancels the operation. + * + * @param serviceName name of the service to be stopped. + * + * @returns string | undefined + */ +export async function confirmInfrastructureServiceStop(serviceName: string) { + const message = `Stop infrastructure service "${serviceName}"?`; + const continueOption = 'Stop'; + return await confirmDialog(message, continueOption); +} + +async function confirmDialog(message: string, continueOption: string) { return await window.showWarningMessage(message, { modal: true }, continueOption); } diff --git a/src/tasks/CamelInfraRunJBangTask.ts b/src/tasks/CamelInfraRunJBangTask.ts new file mode 100644 index 00000000..22028571 --- /dev/null +++ b/src/tasks/CamelInfraRunJBangTask.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2026 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TaskRevealKind, TaskScope } from 'vscode'; +import { CamelJBangTask } from './CamelJBangTask'; +import { CamelInfraJBang, InfraRunConfiguration } from '../helpers/CamelInfraJBang'; + +export class CamelInfraRunJBangTask extends CamelJBangTask { + private constructor(service: string, shellExecution: ReturnType) { + super(TaskScope.Workspace, `Infrastructure - ${service}`, shellExecution, true, TaskRevealKind.Silent); + this.isBackground = true; + } + + static create(service: string, config: InfraRunConfiguration, cwd?: string): CamelInfraRunJBangTask { + const execution = new CamelInfraJBang().start(service, config, cwd); + return new CamelInfraRunJBangTask(service, execution); + } +} diff --git a/src/tasks/CamelInfraStopJBangTask.ts b/src/tasks/CamelInfraStopJBangTask.ts new file mode 100644 index 00000000..4d378a9a --- /dev/null +++ b/src/tasks/CamelInfraStopJBangTask.ts @@ -0,0 +1,25 @@ +/** + * Copyright 2026 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TaskRevealKind, TaskScope } from 'vscode'; +import { CamelJBangTask } from './CamelJBangTask'; +import { CamelInfraJBang } from '../helpers/CamelInfraJBang'; + +export class CamelInfraStopJBangTask extends CamelJBangTask { + constructor(service: string) { + super(TaskScope.Workspace, `Infrastructure Stop - ${service}`, new CamelInfraJBang().stop(service), true, TaskRevealKind.Silent); + } +} diff --git a/src/test/helpers/DockerErrorDetector.test.ts b/src/test/helpers/DockerErrorDetector.test.ts new file mode 100644 index 00000000..a427987b --- /dev/null +++ b/src/test/helpers/DockerErrorDetector.test.ts @@ -0,0 +1,66 @@ +import { assert } from 'chai'; +import { DockerErrorDetector } from '../../helpers/DockerErrorDetector'; + +suite('DockerErrorDetector', () => { + suite('detectDockerError', () => { + test('should detect Docker environment error from typical error message', () => { + const errorOutput = ` +Starting service kafka (PID: 52744) +java.lang.reflect.InvocationTargetException +Caused by: java.lang.IllegalStateException: Could not find a valid Docker environment. Please see logs and check configuration + `; + + const result = DockerErrorDetector.detectDockerError(errorOutput); + + assert.isNotNull(result, 'Should detect Docker error'); + assert.strictEqual(result?.isDockerError, true); + assert.include(result?.userMessage ?? '', 'Docker environment not found'); + assert.include(result?.userMessage ?? '', 'container runtime'); + }); + + test('should return null for non-Docker errors', () => { + const errorOutput = 'Some other error message'; + + const result = DockerErrorDetector.detectDockerError(errorOutput); + + assert.isNull(result); + }); + + test('should return null for empty string', () => { + const result = DockerErrorDetector.detectDockerError(''); + + assert.isNull(result); + }); + + test('should return null for null input', () => { + const result = DockerErrorDetector.detectDockerError(null as any); + + assert.isNull(result); + }); + + test('should return null for undefined input', () => { + const result = DockerErrorDetector.detectDockerError(undefined as any); + + assert.isNull(result); + }); + + test('should detect Docker error case-insensitively', () => { + const errorOutput = 'could not find a valid docker environment'; + + const result = DockerErrorDetector.detectDockerError(errorOutput); + + assert.isNotNull(result, 'Should detect Docker error case-insensitively'); + assert.strictEqual(result?.isDockerError, true); + }); + + test('should provide user-friendly message', () => { + const errorOutput = 'Could not find a valid Docker environment'; + + const result = DockerErrorDetector.detectDockerError(errorOutput); + + assert.isNotNull(result); + assert.isNotEmpty(result?.userMessage); + assert.isTrue(result?.userMessage.includes('Docker') || result?.userMessage.includes('Podman'), 'Message should mention Docker or Podman'); + }); + }); +}); diff --git a/src/views/infrastructureTreeItems/InfrastructureItem.ts b/src/views/infrastructureTreeItems/InfrastructureItem.ts new file mode 100644 index 00000000..35d1d4ed --- /dev/null +++ b/src/views/infrastructureTreeItems/InfrastructureItem.ts @@ -0,0 +1,100 @@ +/** + * Copyright 2026 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; + +export interface RunningInfrastructureService { + name: string; + port?: number; + url?: string; + description?: string; + args: string[]; + terminalName: string; + status: 'starting' | 'running' | 'stopping'; + isExternal?: boolean; +} + +export class InfrastructureItem extends TreeItem { + constructor(public readonly service: RunningInfrastructureService) { + super(service.name, TreeItemCollapsibleState.None); + + // Build context value with availability flags + let contextValue = service.isExternal ? 'infrastructure-service-external' : 'infrastructure-service'; + if (service.url) { + contextValue += '-has-url'; + } + if (service.port) { + contextValue += '-has-port'; + } + this.contextValue = contextValue; + + this.iconPath = new ThemeIcon(service.status === 'running' ? 'server-environment' : 'loading~spin'); + + this.description = this.buildDescription(service); + this.tooltip = this.buildTooltip(service); + } + + private buildTooltip(service: RunningInfrastructureService): string { + const statusText = this.getStatusText(service.status); + const serviceText = service.description ? `Service: ${service.description}` : `Service: ${service.name}`; + const urlText = service.url ? `URL: ${service.url}` : undefined; + const portText = service.port ? `Port: ${service.port}` : undefined; + const argsText = service.args.length > 0 ? `Args: ${service.args.join(' ')}` : undefined; + + return [statusText, serviceText, urlText, portText, argsText].filter(Boolean).join('\n'); + } + + private getStatusText(status: 'starting' | 'running' | 'stopping'): string { + switch (status) { + case 'starting': + return 'Status: Starting'; + case 'stopping': + return 'Status: Stopping'; + case 'running': + return 'Status: Running'; + } + } + + private buildDescription(service: RunningInfrastructureService): string { + const externalLabel = service.isExternal ? ' (external)' : ''; + + if (service.status === 'starting') { + if (service.port) { + return `Starting on :${service.port}${externalLabel}`; + } + + return `Starting...${externalLabel}`; + } + + if (service.status === 'stopping') { + if (service.port) { + return `Stopping on :${service.port}${externalLabel}`; + } + + return `Stopping...${externalLabel}`; + } + + if (service.port) { + return `:${service.port}${externalLabel}`; + } + + if (service.description) { + return `${service.description}${externalLabel}`; + } + + return externalLabel.trim(); + } +} diff --git a/src/views/providers/DeploymentsProvider.ts b/src/views/providers/DeploymentsProvider.ts index 211b86b7..2c1d8aa1 100644 --- a/src/views/providers/DeploymentsProvider.ts +++ b/src/views/providers/DeploymentsProvider.ts @@ -27,7 +27,7 @@ export class DeploymentsProvider implements TreeDataProvider { private readonly _onDidChangeTreeData = new EventEmitter(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - private static readonly SETTINGS_REFRESH_INTERVAL = 'kaoto.deployments.refresh.interval'; + private static readonly SETTINGS_REFRESH_INTERVAL = 'kaoto.views.refresh.interval'; private readonly CONTEXT_LOCALHOST_ITEM = 'root-localhost'; private readonly CONTEXT_INTEGRATION_LOCALHOST_ITEM = 'parent-localhost'; diff --git a/src/views/providers/InfrastructureProvider.ts b/src/views/providers/InfrastructureProvider.ts new file mode 100644 index 00000000..2305448e --- /dev/null +++ b/src/views/providers/InfrastructureProvider.ts @@ -0,0 +1,131 @@ +import { commands, Disposable, Event, EventEmitter, TreeDataProvider, TreeItem } from 'vscode'; +import { InfraRunningServiceDetails, InfraServiceDefinition } from '../../helpers/CamelInfraJBang'; +import { InfrastructureItem, RunningInfrastructureService } from '../infrastructureTreeItems/InfrastructureItem'; +import { InfrastructureServiceManager } from './InfrastructureServiceManager'; +import { InfrastructureRefreshManager } from './InfrastructureRefreshManager'; + +/** + * Tree data provider for infrastructure services view. + * Delegates service lifecycle management to InfrastructureServiceManager + * and auto-refresh logic to InfrastructureRefreshManager. + */ +export class InfrastructureProvider implements TreeDataProvider, Disposable { + private readonly _onDidChangeTreeData = new EventEmitter(); + readonly onDidChangeTreeData: Event = this._onDidChangeTreeData.event; + + private readonly serviceManager: InfrastructureServiceManager; + private readonly refreshManager: InfrastructureRefreshManager; + private readonly disposables: Disposable[] = []; + + constructor() { + this.serviceManager = new InfrastructureServiceManager(() => this.handleServiceChange()); + this.refreshManager = new InfrastructureRefreshManager(() => this.handleAutoRefresh()); + this.disposables.push(this.serviceManager, this.refreshManager); + } + + dispose(): void { + this._onDidChangeTreeData.dispose(); + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables.length = 0; + } + + refresh(): void { + void this.refreshRunningServicesFromCli(); + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: TreeItem): TreeItem { + return element; + } + + async getChildren(): Promise { + await this.refreshRunningServicesFromCli(false); + this.updateContexts(); + return Array.from(this.serviceManager.getRunningServices().values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((service) => new InfrastructureItem(service)); + } + + async ensureAvailableServicesLoaded(forceRefresh: boolean = false): Promise { + const services = await this.serviceManager.ensureAvailableServicesLoaded(forceRefresh); + this.updateContexts(); + return services; + } + + registerRunningService(service: RunningInfrastructureService): void { + this.serviceManager.registerRunningService(service); + this.updateAutoRefreshState(); + this._onDidChangeTreeData.fire(); + } + + updateRunningService(name: string, partial: Partial, skipRefresh: boolean = false): void { + this.serviceManager.updateRunningService(name, partial, skipRefresh); + if (!skipRefresh) { + this.refresh(); + } + } + + unregisterRunningService(name: string): void { + this.serviceManager.unregisterRunningService(name); + this.updateAutoRefreshState(); + this._onDidChangeTreeData.fire(); + } + + markServiceStopping(name: string): void { + this.serviceManager.markServiceStopping(name); + } + + getRunningService(name: string): RunningInfrastructureService | undefined { + return this.serviceManager.getRunningService(name); + } + + async getCliRunningService(name: string): Promise { + return await this.serviceManager.getCliRunningService(name); + } + + getAvailableServices(): InfraServiceDefinition[] { + return this.serviceManager.getAvailableServices(); + } + + setStartingService(isStarting: boolean): void { + this.serviceManager.setStartingService(isStarting); + this.updateContexts(); + } + + isServiceStarting(): boolean { + return this.serviceManager.isServiceStarting(); + } + + private updateContexts(): void { + commands.executeCommand('setContext', 'kaoto.infrastructureCatalogLoaded', this.serviceManager.isServicesLoaded()); + commands.executeCommand('setContext', 'kaoto.infrastructureRunning', this.serviceManager.getRunningServices().size > 0); + commands.executeCommand('setContext', 'kaoto.infrastructureStarting', this.serviceManager.isServiceStarting()); + } + + private handleServiceChange(): void { + this.updateAutoRefreshState(); + this._onDidChangeTreeData.fire(); + } + + private async handleAutoRefresh(): Promise { + await this.refreshRunningServicesFromCli(); + } + + private async refreshRunningServicesFromCli(fireChangeEvent: boolean = true): Promise { + const changed = await this.serviceManager.refreshRunningServicesFromCli(); + this.updateAutoRefreshState(); + if (changed && fireChangeEvent) { + this._onDidChangeTreeData.fire(); + } + } + + private updateAutoRefreshState(): void { + const hasRunningServices = this.serviceManager.getRunningServices().size > 0; + if (hasRunningServices) { + this.refreshManager.startAutoRefresh(); + } else { + this.refreshManager.stopAutoRefresh(); + } + this.updateContexts(); + } +} diff --git a/src/views/providers/InfrastructureRefreshManager.ts b/src/views/providers/InfrastructureRefreshManager.ts new file mode 100644 index 00000000..18380cc0 --- /dev/null +++ b/src/views/providers/InfrastructureRefreshManager.ts @@ -0,0 +1,64 @@ +import { Disposable, workspace } from 'vscode'; +import { KaotoOutputChannel } from '../../extension/KaotoOutputChannel'; + +/** + * Manages auto-refresh functionality for infrastructure services. + * Handles periodic refresh intervals and configuration changes. + */ +export class InfrastructureRefreshManager implements Disposable { + private static readonly SETTINGS_REFRESH_INTERVAL = 'kaoto.views.refresh.interval'; + + private refreshInterval: number; + private autoRefreshHandle?: NodeJS.Timeout; + private readonly disposables: Disposable[] = []; + + constructor(private readonly onRefresh: () => Promise) { + this.refreshInterval = this.getRefreshInterval(); + this.registerConfigurationListener(); + } + + dispose(): void { + this.stopAutoRefresh(); + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables.length = 0; + } + + startAutoRefresh(): void { + this.stopAutoRefresh(); + this.autoRefreshHandle = setInterval(() => { + // Auto-refresh will be skipped if manual operation is in progress + this.onRefresh().catch((error) => { + KaotoOutputChannel.logError('[Infrastructure] Auto-refresh failed', error); + }); + }, this.refreshInterval); + } + + stopAutoRefresh(): void { + if (this.autoRefreshHandle) { + clearInterval(this.autoRefreshHandle); + this.autoRefreshHandle = undefined; + } + } + + private restartAutoRefresh(): void { + // Only restart if there's an active refresh handle + if (this.autoRefreshHandle) { + this.startAutoRefresh(); + } + } + + private getRefreshInterval(): number { + return workspace.getConfiguration().get(InfrastructureRefreshManager.SETTINGS_REFRESH_INTERVAL, 5000); + } + + private registerConfigurationListener(): void { + this.disposables.push( + workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration(InfrastructureRefreshManager.SETTINGS_REFRESH_INTERVAL)) { + this.refreshInterval = this.getRefreshInterval(); + this.restartAutoRefresh(); + } + }), + ); + } +} diff --git a/src/views/providers/InfrastructureServiceManager.ts b/src/views/providers/InfrastructureServiceManager.ts new file mode 100644 index 00000000..660ba09d --- /dev/null +++ b/src/views/providers/InfrastructureServiceManager.ts @@ -0,0 +1,347 @@ +import { exec } from 'child_process'; +import { Disposable, tasks, window } from 'vscode'; +import { CamelInfraJBang, InfraRunningServiceDetails, InfraServiceDefinition } from '../../helpers/CamelInfraJBang'; +import { KaotoOutputChannel } from '../../extension/KaotoOutputChannel'; +import { RunningInfrastructureService } from '../infrastructureTreeItems/InfrastructureItem'; +import { DockerErrorDetector } from '../../helpers/DockerErrorDetector'; + +/** + * Manages infrastructure service lifecycle operations including: + * - Loading available services + * - Tracking running services + * - Service registration/unregistration + * - CLI interaction for service status + */ +export class InfrastructureServiceManager implements Disposable { + private readonly availableServices = new Map(); + private readonly runningServices = new Map(); + private readonly terminalNameToServiceName = new Map(); + private servicesLoaded = false; + private readonly disposables: Disposable[] = []; + private isStartingService = false; + private isManualOperationInProgress = false; + + constructor(private readonly onServiceChange: () => void) { + this.registerTaskListeners(); + } + + dispose(): void { + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables.length = 0; + } + + async ensureAvailableServicesLoaded(forceRefresh: boolean = false): Promise { + if (this.servicesLoaded && !forceRefresh) { + return Array.from(this.availableServices.values()); + } + + try { + const output = await window.withProgress( + { + location: { viewId: 'kaoto.infrastructure' }, + title: 'Loading infrastructure services...', + }, + async () => { + const execution = new CamelInfraJBang().list(); + const command = typeof execution.command === 'string' ? execution.command : (execution.command?.value ?? 'jbang'); + const args = execution.args?.map((arg) => (typeof arg === 'string' ? arg : arg.value)).join(' ') ?? ''; + return await new Promise((resolve, reject) => { + exec(`${command} ${args}`, (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr || error.message)); + return; + } + resolve(stdout || stderr); + }); + }); + }, + ); + + const services = new CamelInfraJBang().extractAvailableServices(output); + this.availableServices.clear(); + for (const service of services) { + this.availableServices.set(service.name, service); + } + this.servicesLoaded = true; + KaotoOutputChannel.logInfo(`[InfrastructureServiceManager] Loaded ${services.length} infrastructure services.`); + return services; + } catch (error) { + KaotoOutputChannel.logError('[InfrastructureServiceManager] Failed to load infrastructure services.', error); + throw error; + } + } + + registerRunningService(service: RunningInfrastructureService): void { + this.runningServices.set(service.name, service); + this.terminalNameToServiceName.set(service.terminalName, service.name); + this.onServiceChange(); + void this.waitForRunningService(service.name); + } + + updateRunningService(name: string, partial: Partial, skipRefresh: boolean = false): void { + const current = this.runningServices.get(name); + if (!current) { + return; + } + const updated = { ...current, ...partial }; + + // If terminal name changed, update the index + if (partial.terminalName && partial.terminalName !== current.terminalName) { + this.terminalNameToServiceName.delete(current.terminalName); + this.terminalNameToServiceName.set(updated.terminalName, name); + } + + this.runningServices.set(name, updated); + if (!skipRefresh) { + this.onServiceChange(); + } + } + + unregisterRunningService(name: string): void { + this.isManualOperationInProgress = true; + try { + const service = this.runningServices.get(name); + if (service) { + this.terminalNameToServiceName.delete(service.terminalName); + } + this.runningServices.delete(name); + this.onServiceChange(); + } finally { + this.isManualOperationInProgress = false; + } + } + + markServiceStopping(name: string): void { + this.isManualOperationInProgress = true; + try { + this.updateRunningService(name, { status: 'stopping' }); + } finally { + this.isManualOperationInProgress = false; + } + } + + getRunningService(name: string): RunningInfrastructureService | undefined { + return this.runningServices.get(name); + } + + getRunningServices(): Map { + return this.runningServices; + } + + async getCliRunningService(name: string): Promise { + try { + const runningByName = await this.fetchRunningServicesByName(); + return runningByName.get(name); + } catch (error) { + KaotoOutputChannel.logWarning(`[InfrastructureServiceManager] Unable to fetch CLI running services: ${String(error)}`); + return undefined; + } + } + + getAvailableServices(): InfraServiceDefinition[] { + return Array.from(this.availableServices.values()); + } + + isServicesLoaded(): boolean { + return this.servicesLoaded; + } + + setStartingService(isStarting: boolean): void { + this.isStartingService = isStarting; + } + + isServiceStarting(): boolean { + return this.isStartingService; + } + + isManualOperation(): boolean { + return this.isManualOperationInProgress; + } + + async refreshRunningServicesFromCli(): Promise { + // Skip refresh if a manual operation is in progress to prevent race conditions + if (this.isManualOperationInProgress) { + return false; + } + + try { + const runningByName = await this.fetchRunningServicesByName(); + let changed = false; + + // Update existing tracked services and remove those no longer running + for (const [name, currentService] of this.runningServices.entries()) { + const cliService = runningByName.get(name); + if (!cliService) { + // Service is tracked but not running in CLI + // Only remove external services immediately - managed services might still be starting + if (currentService.isExternal) { + this.terminalNameToServiceName.delete(currentService.terminalName); + this.runningServices.delete(name); + changed = true; + KaotoOutputChannel.logInfo(`[InfrastructureServiceManager] Removed external service "${name}" - no longer running`); + } + // For managed services in 'starting' status, keep them - they'll be handled by waitForRunningService timeout + // For managed services in 'running' or 'stopping' status, keep them - they'll be cleaned up by task end handler + continue; + } + + // Update service details but preserve isExternal flag and other managed properties + this.runningServices.set(name, { + ...currentService, + description: cliService.description ?? currentService.description, + port: cliService.port ?? currentService.port, + url: cliService.url ?? currentService.url, + status: 'running', + // Keep isExternal as it was - don't change managed services to external + }); + changed = true; + } + + // Register new external services not yet tracked + for (const [name, cliService] of runningByName.entries()) { + if (!this.runningServices.has(name)) { + const terminalName = `${cliService.name} (external)`; + this.runningServices.set(name, { + name: cliService.name, + description: cliService.description, + port: cliService.port, + url: cliService.url, + args: [], + terminalName: terminalName, + status: 'running', + isExternal: true, + }); + this.terminalNameToServiceName.set(terminalName, name); + changed = true; + KaotoOutputChannel.logInfo(`[InfrastructureServiceManager] Discovered external service: ${name}`); + } + } + + return changed; + } catch (error) { + KaotoOutputChannel.logWarning(`[InfrastructureServiceManager] Unable to refresh running infrastructure services: ${String(error)}`); + return false; + } + } + + private registerTaskListeners(): void { + this.disposables.push( + tasks.onDidEndTaskProcess((event) => { + const taskName = event.execution.task.name; + const serviceName = this.terminalNameToServiceName.get(taskName); + if (!serviceName) { + return; + } + + // Check if task failed with non-zero exit code + if (event.exitCode !== undefined && event.exitCode !== 0) { + void this.handleTaskFailure(serviceName, event.exitCode); + } else { + void this.reconcileServiceAfterTaskEnd(serviceName); + } + }), + ); + } + + private async reconcileServiceAfterTaskEnd(name: string): Promise { + try { + const runningByName = await this.fetchRunningServicesByName(); + if (runningByName.has(name)) { + const currentService = this.runningServices.get(name); + const cliService = runningByName.get(name); + if (!currentService || !cliService) { + return; + } + + this.runningServices.set(name, { + ...currentService, + description: cliService.description ?? currentService.description, + port: cliService.port ?? currentService.port, + url: cliService.url ?? currentService.url, + status: 'running', + }); + this.onServiceChange(); + KaotoOutputChannel.logInfo(`[InfrastructureServiceManager] Task ended for "${name}" but the infrastructure service is still running.`); + return; + } + + this.unregisterRunningService(name); + } catch (error) { + const errorMessage = String(error); + const dockerError = DockerErrorDetector.detectDockerError(errorMessage); + + if (dockerError) { + KaotoOutputChannel.logError(`[InfrastructureServiceManager] Docker environment error for service "${name}"`, error); + window.showErrorMessage(dockerError.userMessage); + } else { + KaotoOutputChannel.logWarning( + `[InfrastructureServiceManager] Unable to reconcile infrastructure service "${name}" after task termination: ${errorMessage}`, + ); + } + this.onServiceChange(); + } + } + + private async handleTaskFailure(name: string, exitCode: number): Promise { + KaotoOutputChannel.logWarning(`[InfrastructureServiceManager] Task for service "${name}" failed with exit code ${exitCode}`); + + // When infrastructure task fails with exit code 1, it's commonly due to Docker not being available + // Show Docker error message to help users understand the requirement + if (exitCode === 1) { + const dockerError = DockerErrorDetector.detectDockerError('Could not find a valid Docker environment'); + if (dockerError) { + KaotoOutputChannel.logError( + `[InfrastructureServiceManager] Infrastructure service "${name}" failed to start. This is commonly caused by Docker not being available.`, + ); + window.showErrorMessage(`Failed to start ${name}: ${dockerError.userMessage}`); + } + } + + // Remove the service from running services + this.unregisterRunningService(name); + } + + private async fetchRunningServicesByName(): Promise> { + const output = await this.executeShellExecution(new CamelInfraJBang().ps()); + const runningServices = new CamelInfraJBang().extractRunningServices(output); + return new Map(runningServices.map((service) => [service.name, service])); + } + + private async waitForRunningService(name: string, timeoutMs: number = 30000, intervalMs: number = 1000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const changed = await this.refreshRunningServicesFromCli(); + + const service = this.runningServices.get(name); + if (!service) { + return; + } + + if (service.status === 'running') { + if (changed) { + this.onServiceChange(); + } + return; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + KaotoOutputChannel.logWarning(`[InfrastructureServiceManager] Timeout waiting for infrastructure service "${name}" to become available.`); + } + + private async executeShellExecution(execution: ReturnType): Promise { + const command = typeof execution.command === 'string' ? execution.command : (execution.command?.value ?? 'jbang'); + const args = execution.args?.map((arg) => (typeof arg === 'string' ? arg : arg.value)).join(' ') ?? ''; + + return await new Promise((resolve, reject) => { + exec(`${command} ${args}`, (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr || error.message)); + return; + } + resolve(stdout || stderr); + }); + }); + } +}