From b6fd2951adbfacea76c6ec954961e0170047c366 Mon Sep 17 00:00:00 2001 From: dhavalpiqud Date: Thu, 25 Jun 2026 13:16:16 +0530 Subject: [PATCH 1/4] refactor: Replace port availability checks with resource validation for deployments - Updated the deployment validation process to check for RAM, CPU, and port availability before deployment. - Removed the PortAvailabilityService and integrated resource checks into the ResourceAvailabilityService. - Renamed relevant methods and endpoints to reflect the new validation approach. - Enhanced error handling for insufficient resources during deployment. - Updated UI components and API calls to align with the new validation logic. - Added copy button and link for the container to redirect into new tab --- apps/agent-app/src/app.module.ts | 4 +- .../src/executors/deploy-template.executor.ts | 205 ++++++++++----- .../resource-availability.service.ts} | 99 +++++++- .../server-resources.service.ts | 17 +- .../socket-client/socket-client.service.ts | 48 ++-- .../deployments/deployments.controller.ts | 235 ++++++++++++------ .../deployments/deployments.service.ts | 37 +-- .../src/websocket/constants/index.ts | 2 +- .../src/websocket/interfaces/index.ts | 6 +- .../src/websocket/websocket.gateway.ts | 67 ++--- .../src/components/server-detail-tabs.css | 12 + console-app/src/components/servers-table.css | 68 ----- console-app/src/components/servers-table.tsx | 51 +--- .../src/components/shared/copy-button.css | 67 +++++ .../src/components/shared/copy-button.tsx | 61 +++++ .../src/features/deployments/api/index.ts | 4 +- .../constants/deployment-failure-messages.ts | 16 ++ .../deployment-validation-messages.ts | 2 + .../server-detail/connected-service-card.tsx | 31 ++- .../server-detail/server-detail-tabs.tsx | 1 + .../server-detail/tabs/overview-tab.tsx | 3 + .../server-detail/utils/container-display.ts | 17 ++ .../components/deploy-configuration-form.tsx | 21 +- .../deploy-service-summary-card.tsx | 16 ++ .../src/features/templates/templates-ui.css | 16 ++ console-app/src/index.css | 7 + console-app/src/pages/server-detail-page.tsx | 7 +- .../parse-compose-resource-limits.util.ts | 142 +++++++++++ .../src/constants/app-strings.constants.ts | 2 + libs/common/src/index.ts | 1 + .../parse-server-resources.util.ts | 15 +- libs/socket-events/src/deployment.events.ts | 16 +- 32 files changed, 920 insertions(+), 376 deletions(-) rename apps/agent-app/src/{port-availability/port-availability.service.ts => resource-availability/resource-availability.service.ts} (63%) create mode 100644 console-app/src/components/shared/copy-button.css create mode 100644 console-app/src/components/shared/copy-button.tsx create mode 100644 console-app/src/features/deployments/constants/deployment-validation-messages.ts create mode 100644 libs/common/src/compose-parser/parse-compose-resource-limits.util.ts diff --git a/apps/agent-app/src/app.module.ts b/apps/agent-app/src/app.module.ts index 43cabd8..220e80d 100644 --- a/apps/agent-app/src/app.module.ts +++ b/apps/agent-app/src/app.module.ts @@ -7,7 +7,7 @@ import { HealthController } from "./health/health.controller"; import { FilesystemService } from "./filesystem/filesystem.service"; import { DeployTemplateExecutor } from "./executors/deploy-template.executor"; import { TraefikProxyService } from "./proxy/traefik-proxy.service"; -import { PortAvailabilityService } from "./port-availability/port-availability.service"; +import { ResourceAvailabilityService } from "./resource-availability/resource-availability.service"; import { TerminalService } from "./terminal/terminal.service"; import { ComposeParserModule, @@ -34,7 +34,7 @@ import { FilesystemService, DeployTemplateExecutor, TraefikProxyService, - PortAvailabilityService, + ResourceAvailabilityService, TerminalService, ], }) diff --git a/apps/agent-app/src/executors/deploy-template.executor.ts b/apps/agent-app/src/executors/deploy-template.executor.ts index d70f59d..00dbd89 100644 --- a/apps/agent-app/src/executors/deploy-template.executor.ts +++ b/apps/agent-app/src/executors/deploy-template.executor.ts @@ -19,6 +19,7 @@ import { applyTraefikRoutingToCompose, extractOccupiedPortFromError, formatDeploymentPortInUseMessage, + sumComposeResourceLimitsFromYaml, } from "@shared/common"; import { EnvFileInput, @@ -27,9 +28,11 @@ import { } from "./env-file.util"; import { TraefikProxyService } from "../proxy/traefik-proxy.service"; import { - PortAvailabilityService, + InsufficientCpuError, + InsufficientRamError, PortUnavailableError, -} from "../port-availability/port-availability.service"; + ResourceAvailabilityService, +} from "../resource-availability/resource-availability.service"; import * as yaml from "js-yaml"; export interface ExecutionNotifier { @@ -66,7 +69,7 @@ export class DeployTemplateExecutor { private readonly templateConfigService: TemplateConfigService, private readonly composeParserService: ComposeParserService, private readonly traefikProxy: TraefikProxyService, - private readonly portAvailabilityService: PortAvailabilityService, + private readonly resourceAvailabilityService: ResourceAvailabilityService, ) {} async execute(opts: { @@ -141,10 +144,10 @@ export class DeployTemplateExecutor { } /** - * Validates host port availability using the same resolution path as deployment, - * without starting containers or creating a deployment record. + * Validates RAM, ports, and CPU using the same resolution path as deployment, + * without starting containers. */ - async checkPortsBeforeDeploy(opts: { + async validateBeforeDeploy(opts: { name: string; compose: string; env?: @@ -157,7 +160,7 @@ export class DeployTemplateExecutor { composeOnly?: boolean; useTraefik?: boolean; }): Promise { - const deploymentId = `port-check-${randomUUID()}`; + const deploymentId = `validate-${randomUUID()}`; const projectName = this.fsService.sanitizeName(deploymentId); const noopNotifier: ExecutionNotifier = { sendStatus: () => undefined, @@ -198,19 +201,6 @@ export class DeployTemplateExecutor { : []; const applyTraefikRouting = useTraefik && traefikRoutes.length > 0; - if (applyTraefikRouting) { - this.logger.log( - `Port availability check skipped for ${opts.name}: Traefik routing enabled`, - ); - return; - } - - if (Object.keys(resolved.portValues).length > 0) { - await this.portAvailabilityService.assertPortsAvailable( - resolved.portValues, - ); - } - await this.fsService.writeFile(dir, "docker-compose.yml", composeYaml); const generatedEnv = generateEnvFileDetails( @@ -246,16 +236,15 @@ export class DeployTemplateExecutor { throw new Error(errorText); } - const hostPorts = this.extractHostPortsFromComposeConfig( - validation.stdout, - ); - if (hostPorts.length > 0) { - await this.portAvailabilityService.assertHostPortsAvailable(hostPorts); - } + await this.validateResolvedComposeBeforeDeploy({ + resolvedConfig: validation.stdout, + applyTraefikRouting, + portValues: resolved.portValues, + }); } finally { await this.fsService.removeDeploymentDir(deploymentId).catch((error) => { this.logger.warn( - `Failed to remove temporary port-check directory for ${deploymentId}: ${ + `Failed to remove temporary validation directory for ${deploymentId}: ${ error instanceof Error ? error.message : String(error) }`, ); @@ -367,23 +356,6 @@ export class DeployTemplateExecutor { timestamp: new Date().toISOString(), source: "deployment", }); - } else if (Object.keys(resolved.portValues).length > 0) { - try { - await this.portAvailabilityService.assertPortsAvailable( - resolved.portValues, - ); - } catch (err) { - if (err instanceof PortUnavailableError) { - this.handlePortUnavailableFailure( - deploymentId, - name, - notifier, - err, - ); - return; - } - throw err; - } } await this.fsService.writeFile(dir, "docker-compose.yml", composeYaml); @@ -450,31 +422,32 @@ export class DeployTemplateExecutor { return; } - // Check if any host ports are in use by other deployments - if (!applyTraefikRouting) { - const hostPorts = this.extractHostPortsFromComposeConfig( - validation.stdout, - ); - if (hostPorts.length > 0) { - try { - await this.portAvailabilityService.assertHostPortsAvailable( - hostPorts, - ); - } catch (err) { - if (err instanceof PortUnavailableError) { - this.handlePortUnavailableFailure( - deploymentId, - name, - notifier, - err, - ); - return; - } - throw err; - } + // Validate RAM, ports, and CPU before starting containers. + try { + await this.validateResolvedComposeBeforeDeploy({ + resolvedConfig: validation.stdout, + applyTraefikRouting, + portValues: resolved.portValues, + expectedPorts: generatedEnv.ports, + }); + } catch (err) { + if (err instanceof PortUnavailableError) { + this.handlePortUnavailableFailure(deploymentId, name, notifier, err); + return; } - - this.validateResolvedConfig(validation.stdout, generatedEnv.ports); + if ( + err instanceof InsufficientRamError || + err instanceof InsufficientCpuError + ) { + this.handleResourceUnavailableFailure( + deploymentId, + name, + notifier, + err, + ); + return; + } + throw err; } notifier.sendStatus({ @@ -525,6 +498,19 @@ export class DeployTemplateExecutor { return; } + if ( + err instanceof InsufficientRamError || + err instanceof InsufficientCpuError + ) { + this.handleResourceUnavailableFailure( + deploymentId, + name, + notifier, + err, + ); + return; + } + const msg = err instanceof Error ? err.message : String(err); await this.handleDeploymentFailure( deploymentId, @@ -657,6 +643,89 @@ export class DeployTemplateExecutor { }); } + /** + * Validates the resolved compose before deploying. + */ + private async validateResolvedComposeBeforeDeploy(opts: { + resolvedConfig: string; + applyTraefikRouting: boolean; + portValues: PortFileInput; + expectedPorts?: Record; + }): Promise { + try { + const requirements = sumComposeResourceLimitsFromYaml( + opts.resolvedConfig, + ); + + await this.resourceAvailabilityService.assertRamAvailable( + requirements.memoryBytes, + ); + + if (!opts.applyTraefikRouting) { + if (Object.keys(opts.portValues).length > 0) { + await this.resourceAvailabilityService.assertPortsAvailable( + opts.portValues, + ); + } + + const hostPorts = this.extractHostPortsFromComposeConfig( + opts.resolvedConfig, + ); + if (hostPorts.length > 0) { + await this.resourceAvailabilityService.assertHostPortsAvailable( + hostPorts, + ); + } + + if (opts.expectedPorts) { + this.validateResolvedConfig(opts.resolvedConfig, opts.expectedPorts); + } + } else { + this.logger.log( + "Port availability check skipped: Traefik routing enabled", + ); + } + + await this.resourceAvailabilityService.assertCpuAvailable( + requirements.cpuCores, + ); + } catch (error) { + this.logger.error( + `Validation before deploy failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } + } + + /** + * Handles a RAM or CPU resource unavailable failure. + */ + private handleResourceUnavailableFailure( + deploymentId: string, + name: string, + notifier: ExecutionNotifier, + err: InsufficientRamError | InsufficientCpuError, + ): void { + const message = err.message; + this.logger.error(message); + notifier.sendLog({ + deployment: name, + deploymentId, + type: "stderr", + message, + timestamp: new Date().toISOString(), + source: "deployment", + }); + notifier.sendStatus({ + deploymentId, + templateSlug: name, + status: "failed", + message, + error: message, + completedAt: new Date().toISOString(), + }); + } + private async handleDeploymentFailure( deploymentId: string, name: string, diff --git a/apps/agent-app/src/port-availability/port-availability.service.ts b/apps/agent-app/src/resource-availability/resource-availability.service.ts similarity index 63% rename from apps/agent-app/src/port-availability/port-availability.service.ts rename to apps/agent-app/src/resource-availability/resource-availability.service.ts index 62d5658..28f0468 100644 --- a/apps/agent-app/src/port-availability/port-availability.service.ts +++ b/apps/agent-app/src/resource-availability/resource-availability.service.ts @@ -1,7 +1,13 @@ import { Injectable, Logger } from "@nestjs/common"; import { spawn } from "node:child_process"; -import { ERROR_MESSAGES } from "@shared/common"; +import { + computeAvailableCpuCores, + ERROR_MESSAGES, + type ComposeResourceRequirements, +} from "@shared/common"; + import type { PortFileInput } from "../executors/env-file.util"; +import { ServerResourcesService } from "../server-resources/server-resources.service"; export class PortUnavailableError extends Error { readonly port: number; @@ -13,13 +19,30 @@ export class PortUnavailableError extends Error { } } +export class InsufficientRamError extends Error { + constructor() { + super(ERROR_MESSAGES.INSUFFICIENT_RAM); + this.name = "InsufficientRamError"; + } +} + +export class InsufficientCpuError extends Error { + constructor() { + super(ERROR_MESSAGES.INSUFFICIENT_CPU); + this.name = "InsufficientCpuError"; + } +} + @Injectable() -export class PortAvailabilityService { - private readonly logger = new Logger(PortAvailabilityService.name); +export class ResourceAvailabilityService { + private readonly logger = new Logger(ResourceAvailabilityService.name); + + constructor( + private readonly serverResourcesService: ServerResourcesService, + ) {} /** * Verifies that each configured host port can be bound before deployment starts. - * ports - Resolved SERVICE_PORT_* values from deployment environment variables. */ async assertPortsAvailable(ports: PortFileInput): Promise { const entries = Object.entries(ports).filter( @@ -55,6 +78,68 @@ export class PortAvailabilityService { } } + /** + * Verifies the host has enough available RAM for compose memory limits. + */ + async assertRamAvailable(requiredMemoryBytes: number): Promise { + if (requiredMemoryBytes <= 0) { + this.logger.log( + "RAM availability check skipped: no compose memory limits defined", + ); + return; + } + + const metrics = await this.serverResourcesService.getCurrentMetrics(); + const availableRam = metrics.memory.available; + + if (availableRam < requiredMemoryBytes) { + this.logger.warn( + `RAM availability check failed: required memory=${requiredMemoryBytes} bytes, available=${availableRam} bytes`, + ); + throw new InsufficientRamError(); + } + + this.logger.log( + `RAM availability check passed: required=${requiredMemoryBytes} bytes, available=${availableRam} bytes`, + ); + } + + /** + * Verifies the host has enough available CPU for compose CPU limits. + */ + async assertCpuAvailable(requiredCpuCores: number): Promise { + if (requiredCpuCores <= 0) { + this.logger.log( + "CPU availability check skipped: no compose CPU limits defined", + ); + return; + } + + const metrics = await this.serverResourcesService.getCurrentMetrics(); + const availableCpu = computeAvailableCpuCores(metrics.cpu); + + if (availableCpu + Number.EPSILON < requiredCpuCores) { + this.logger.warn( + `CPU availability check failed: required cpu=${requiredCpuCores}, available=${availableCpu}`, + ); + throw new InsufficientCpuError(); + } + + this.logger.log( + `CPU availability check passed: required=${requiredCpuCores}, available=${availableCpu}`, + ); + } + + /** + * Verifies the host has enough available RAM and CPU for compose resource limits. + */ + async assertResourcesAvailable( + required: ComposeResourceRequirements, + ): Promise { + await this.assertRamAvailable(required.memoryBytes); + await this.assertCpuAvailable(required.cpuCores); + } + private async assertHostPortAvailable( port: number, label?: string, @@ -85,9 +170,6 @@ export class PortAvailabilityService { ); } - /** - * Checks whether a TCP port is free on the Docker host (not inside the agent container). - */ private async isHostPortAvailable(port: number): Promise { if (await this.isPortPublishedOnDockerHost(port)) { return false; @@ -159,9 +241,6 @@ export class PortAvailabilityService { ); } - /** - * Executes a command and captures the output. - */ private execCapture( cmd: string, args: string[], diff --git a/apps/agent-app/src/server-resources/server-resources.service.ts b/apps/agent-app/src/server-resources/server-resources.service.ts index f4c126b..cf4fd2e 100644 --- a/apps/agent-app/src/server-resources/server-resources.service.ts +++ b/apps/agent-app/src/server-resources/server-resources.service.ts @@ -33,11 +33,7 @@ export class ServerResourcesService { requestId: string, ): Promise { try { - const resources = await this.withTimeout( - this.gatherMetrics(), - SERVER_RESOURCES_TIMEOUT_MS, - "Server resource collection timed out", - ); + const resources = await this.getCurrentMetrics(); this.logger.log(`Collected server resources for requestId=${requestId}`); return { requestId, resources }; } catch (error) { @@ -47,6 +43,17 @@ export class ServerResourcesService { } } + /** + * Gathers current server resource metrics without socket correlation metadata. + */ + async getCurrentMetrics() { + return this.withTimeout( + this.gatherMetrics(), + SERVER_RESOURCES_TIMEOUT_MS, + "Server resource collection timed out", + ); + } + /** * Gathers current server resource metrics. * @returns Server resources metrics diff --git a/apps/agent-app/src/socket-client/socket-client.service.ts b/apps/agent-app/src/socket-client/socket-client.service.ts index a9be282..f4309d5 100644 --- a/apps/agent-app/src/socket-client/socket-client.service.ts +++ b/apps/agent-app/src/socket-client/socket-client.service.ts @@ -12,8 +12,8 @@ import { ContainerActionResponsePayload, ContainerDiscoverRequestPayload, ContainerDiscoverResponsePayload, - PortsCheckRequestPayload, - PortsCheckResponsePayload, + DeploymentValidateRequestPayload, + DeploymentValidateResponsePayload, ServerGetResourcesRequestPayload, ServerGetResourcesResponsePayload, TerminalConnectRequestPayload, @@ -47,7 +47,11 @@ import { detectOutboundPublicIp, localLoopbackHost, } from "./agent-public-ip.util"; -import { PortUnavailableError } from "../port-availability/port-availability.service"; +import { + InsufficientCpuError, + InsufficientRamError, + PortUnavailableError, +} from "../resource-availability/resource-availability.service"; @Injectable() export class SocketClientService { @@ -199,7 +203,7 @@ export class SocketClientService { this.socket.off(DeploymentEvents.CONTAINER_ACTION); this.socket.off(DeploymentEvents.CONTAINER_DISCOVER); this.socket.off(DeploymentEvents.SERVER_GET_RESOURCES); - this.socket.off(DeploymentEvents.PORTS_CHECK); + this.socket.off(DeploymentEvents.DEPLOYMENT_VALIDATE); this.socket.off(DeploymentEvents.TERMINAL_CONNECT); this.socket.off(DeploymentEvents.TERMINAL_INPUT); this.socket.off(DeploymentEvents.TERMINAL_RESIZE); @@ -247,12 +251,12 @@ export class SocketClientService { ); this.socket.on( - DeploymentEvents.PORTS_CHECK, - (payload: PortsCheckRequestPayload) => { + DeploymentEvents.DEPLOYMENT_VALIDATE, + (payload: DeploymentValidateRequestPayload) => { this.logger.log( - `[PORTS_CHECK] socket event received requestId=${payload?.requestId ?? "unknown"} template=${payload?.templateSlug ?? "unknown"}`, + `[DEPLOYMENT_VALIDATE] socket event received requestId=${payload?.requestId ?? "unknown"} template=${payload?.templateSlug ?? "unknown"}`, ); - void this.handlePortsCheck(payload); + void this.handleDeploymentValidate(payload); }, ); @@ -328,7 +332,7 @@ export class SocketClientService { DeploymentEvents.CONTAINER_DISCOVER, DeploymentEvents.CONTAINER_ACTION, DeploymentEvents.SERVER_GET_RESOURCES, - DeploymentEvents.PORTS_CHECK, + DeploymentEvents.DEPLOYMENT_VALIDATE, DeploymentEvents.TERMINAL_CONNECT, DeploymentEvents.CONTAINER_LOGS_START, DeploymentEvents.AGENT_REMOVE, @@ -488,15 +492,15 @@ export class SocketClientService { } /** - * Handles pre-deploy host port availability checks from the control panel. + * Handles pre-deploy validation (RAM, ports, CPU) from the control panel. */ - private async handlePortsCheck( - payload: PortsCheckRequestPayload, + private async handleDeploymentValidate( + payload: DeploymentValidateRequestPayload, ): Promise { const requestId = payload?.requestId?.trim() ?? ""; const templateSlug = payload?.templateSlug?.trim() ?? ""; - let response: PortsCheckResponsePayload = { + let response: DeploymentValidateResponsePayload = { requestId, available: false, error: "Missing requestId", @@ -534,7 +538,7 @@ export class SocketClientService { ); } - await this.executor.checkPortsBeforeDeploy({ + await this.executor.validateBeforeDeploy({ name: templateSlug, compose: composeYaml, env: { env: envValues, ports: portValues }, @@ -547,12 +551,16 @@ export class SocketClientService { } } catch (error) { const message = - error instanceof PortUnavailableError + error instanceof PortUnavailableError || + error instanceof InsufficientRamError || + error instanceof InsufficientCpuError ? error.message : error instanceof Error ? error.message : String(error); - this.logger.warn(`Ports check failed requestId=${requestId}: ${message}`); + this.logger.warn( + `Deployment validation failed requestId=${requestId}: ${message}`, + ); response = { requestId, available: false, @@ -561,13 +569,15 @@ export class SocketClientService { } if (!this.socket?.connected) { - this.logger.warn("Cannot send ports check result: socket disconnected"); + this.logger.warn( + "Cannot send deployment validation result: socket disconnected", + ); return; } - this.socket.emit(DeploymentEvents.PORTS_CHECK_RESULT, response); + this.socket.emit(DeploymentEvents.DEPLOYMENT_VALIDATE_RESULT, response); this.logger.log( - `Ports check result sent requestId=${requestId} available=${response.available}`, + `Deployment validation result sent requestId=${requestId} available=${response.available}`, ); } diff --git a/apps/control-panel-app/src/modules/deployments/deployments.controller.ts b/apps/control-panel-app/src/modules/deployments/deployments.controller.ts index dc940aa..2d051bf 100644 --- a/apps/control-panel-app/src/modules/deployments/deployments.controller.ts +++ b/apps/control-panel-app/src/modules/deployments/deployments.controller.ts @@ -117,29 +117,36 @@ export class DeploymentsController { } /** - * Verifies configured host ports are available on the target agent before deploy. + * Verifies RAM, ports, and CPU are available on the target agent before deploy. */ - @Post("ports/check") + @Post("resources/check") @HttpCode(200) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) - async checkPortsBeforeDeploy( + async validateBeforeDeploy( @Req() req: { user: UserEntity }, @Body() body: DeployTemplateDto, ): Promise<{ available: true }> { - const { env: requestEnv, ports: requestPorts } = - normalizeDeployRequestVariables(body.env ?? {}, body.ports ?? {}); - - await this.deploymentsService.checkPortsBeforeDeploy({ - userId: req.user.id, - serverId: body.serverId, - deployOnLocal: body.deployOnLocal, - templateSlug: body.templateSlug, - requestEnv, - requestPorts, - useTraefikRequest: body.useTraefik, - }); - - return { available: true }; + try { + const { env: requestEnv, ports: requestPorts } = + normalizeDeployRequestVariables(body.env ?? {}, body.ports ?? {}); + + await this.deploymentsService.validateBeforeDeploy({ + userId: req.user.id, + serverId: body.serverId, + deployOnLocal: body.deployOnLocal, + templateSlug: body.templateSlug, + requestEnv, + requestPorts, + useTraefikRequest: body.useTraefik, + }); + + return { available: true }; + } catch (error) { + this.logger.error( + `Validation before deploy failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** @@ -280,12 +287,19 @@ export class DeploymentsController { @Param("serverId") serverId: string, @Param("containerId") containerId: string, ) { - return this.deploymentsService.executeContainerAction( - serverId, - req.user.id, - containerId, - "stop", - ); + try { + return this.deploymentsService.executeContainerAction( + serverId, + req.user.id, + containerId, + "stop", + ); + } catch (error) { + this.logger.error( + `Stop container failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** @@ -298,12 +312,19 @@ export class DeploymentsController { @Param("serverId") serverId: string, @Param("containerId") containerId: string, ) { - return this.deploymentsService.executeContainerAction( - serverId, - req.user.id, - containerId, - "start", - ); + try { + return this.deploymentsService.executeContainerAction( + serverId, + req.user.id, + containerId, + "start", + ); + } catch (error) { + this.logger.error( + `Start container failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** @@ -316,12 +337,19 @@ export class DeploymentsController { @Param("serverId") serverId: string, @Param("containerId") containerId: string, ) { - return this.deploymentsService.executeContainerAction( - serverId, - req.user.id, - containerId, - "restart", - ); + try { + return this.deploymentsService.executeContainerAction( + serverId, + req.user.id, + containerId, + "restart", + ); + } catch (error) { + this.logger.error( + `Restart container failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** @@ -334,12 +362,19 @@ export class DeploymentsController { @Param("serverId") serverId: string, @Param("containerId") containerId: string, ) { - return this.deploymentsService.executeContainerAction( - serverId, - req.user.id, - containerId, - "delete", - ); + try { + return this.deploymentsService.executeContainerAction( + serverId, + req.user.id, + containerId, + "delete", + ); + } catch (error) { + this.logger.error( + `Delete container failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** @@ -352,12 +387,19 @@ export class DeploymentsController { @Param("serverId") serverId: string, @Param("containerId") containerId: string, ) { - const data = await this.deploymentsService.startContainerLogs( - serverId, - req.user.id, - containerId, - ); - return { message: "Container log stream started", data }; + try { + const data = await this.deploymentsService.startContainerLogs( + serverId, + req.user.id, + containerId, + ); + return { message: "Container log stream started", data }; + } catch (error) { + this.logger.error( + `Start container logs failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** @@ -371,11 +413,18 @@ export class DeploymentsController { @Param("serverId") serverId: string, @Body() body: ContainerLogsStopDto, ) { - return this.deploymentsService.stopContainerLogs( - serverId, - req.user.id, - body.sessionId, - ); + try { + return this.deploymentsService.stopContainerLogs( + serverId, + req.user.id, + body.sessionId, + ); + } catch (error) { + this.logger.error( + `Stop container logs failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** @@ -383,9 +432,16 @@ export class DeploymentsController { */ @Get(":deploymentId/env") async listEnvironmentVariables(@Param("deploymentId") deploymentId: string) { - return this.deploymentsService.listEnvironmentVariables(deploymentId, { - maskSecrets: true, - }); + try { + return this.deploymentsService.listEnvironmentVariables(deploymentId, { + maskSecrets: true, + }); + } catch (error) { + this.logger.error( + `List environment variables failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** @@ -393,26 +449,33 @@ export class DeploymentsController { */ @Get(":deploymentId") async getDeployment(@Param("deploymentId") deploymentId: string) { - const deployment = - await this.deploymentsService.getDeployment(deploymentId); - const environmentVariables = - await this.deploymentsService.listEnvironmentVariables(deploymentId, { - maskSecrets: true, - }); + try { + const deployment = + await this.deploymentsService.getDeployment(deploymentId); + const environmentVariables = + await this.deploymentsService.listEnvironmentVariables(deploymentId, { + maskSecrets: true, + }); - return { - id: deployment.id, - templateSlug: deployment.templateSlug, - serverId: deployment.serverId, - userId: deployment.userId, - status: deployment.status, - deploymentStatus: deployment.deploymentStatus, - statusMessage: deployment.statusMessage, - lastError: deployment.lastError, - createdAt: deployment.createdAt, - updatedAt: deployment.updatedAt, - environmentVariables, - }; + return { + id: deployment.id, + templateSlug: deployment.templateSlug, + serverId: deployment.serverId, + userId: deployment.userId, + status: deployment.status, + deploymentStatus: deployment.deploymentStatus, + statusMessage: deployment.statusMessage, + lastError: deployment.lastError, + createdAt: deployment.createdAt, + updatedAt: deployment.updatedAt, + environmentVariables, + }; + } catch (error) { + this.logger.error( + `Get deployment failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** @@ -424,10 +487,17 @@ export class DeploymentsController { @Param("deploymentId") deploymentId: string, @Body() body: UpdateEnvironmentVariablesDto, ) { - return this.deploymentsService.updateEnvironmentVariables(deploymentId, { - env: body.env, - ports: body.ports, - }); + try { + return this.deploymentsService.updateEnvironmentVariables(deploymentId, { + env: body.env, + ports: body.ports, + }); + } catch (error) { + this.logger.error( + `Update environment variables failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** @@ -436,6 +506,13 @@ export class DeploymentsController { */ @Delete(":deploymentId") async removeDeployment(@Param("deploymentId") deploymentId: string) { - return this.deploymentsService.removeDeployment(deploymentId); + try { + return this.deploymentsService.removeDeployment(deploymentId); + } catch (error) { + this.logger.error( + `Remove deployment failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/deployments/deployments.service.ts b/apps/control-panel-app/src/modules/deployments/deployments.service.ts index d612016..494a19b 100644 --- a/apps/control-panel-app/src/modules/deployments/deployments.service.ts +++ b/apps/control-panel-app/src/modules/deployments/deployments.service.ts @@ -15,7 +15,6 @@ import { In, IsNull, Not, Repository } from "typeorm"; import { ComposeParserService, EncryptionService, - formatDeploymentPortInUseMessage, ServerUrlContext, SUCCESS_MESSAGES, TemplateConfigService, @@ -497,10 +496,9 @@ export class DeploymentsService { } /** - * Verifies host port availability on the target agent before starting deployment. - * Resolves template variables locally, then delegates the bind check to the agent. + * Verifies RAM, ports, and CPU on the target agent before starting deployment. */ - async checkPortsBeforeDeploy(input: { + async validateBeforeDeploy(input: { userId: string; serverId?: string; deployOnLocal?: boolean; @@ -521,19 +519,19 @@ export class DeploymentsService { if (!agentInstalled) { this.logger.log( - `Skipping pre-deploy port check for server '${serverId}': agent is not installed`, + `Skipping pre-deploy validation for server '${serverId}': agent is not installed`, ); return; } this.logger.log( - `Agent installed on server '${serverId}' but socket disconnected — waiting for connection before port check`, + `Agent installed on server '${serverId}' but socket disconnected — waiting for connection before validation`, ); await this.waitForAgentConnection(serverId); if (!this.deploymentGateway.isAgentConnectedForServer(serverId)) { throw new ConflictException( - `Agent is installed on server '${serverId}' but is not connected. Cannot verify port availability.`, + `Agent is installed on server '${serverId}' but is not connected. Cannot validate deployment resources.`, ); } } @@ -566,20 +564,23 @@ export class DeploymentsService { JSON.stringify(prepared.mergedPorts), ); - const result = await this.deploymentGateway.requestPortsCheck(serverId, { - requestId: randomUUID(), - templateSlug: prepared.templateSlug, - compose: encryptedCompose, - env: encryptedEnv, - ports: encryptedPorts, - schema: prepared.schema, - composeOnly: prepared.composeOnly, - useTraefik: prepared.useTraefik, - }); + const result = await this.deploymentGateway.requestDeploymentValidate( + serverId, + { + requestId: randomUUID(), + templateSlug: prepared.templateSlug, + compose: encryptedCompose, + env: encryptedEnv, + ports: encryptedPorts, + schema: prepared.schema, + composeOnly: prepared.composeOnly, + useTraefik: prepared.useTraefik, + }, + ); if (!result.available) { throw new ConflictException( - result.error?.trim() || formatDeploymentPortInUseMessage(), + result.error?.trim() || "Deployment validation failed", ); } } diff --git a/apps/control-panel-app/src/websocket/constants/index.ts b/apps/control-panel-app/src/websocket/constants/index.ts index d65a664..01b41ec 100644 --- a/apps/control-panel-app/src/websocket/constants/index.ts +++ b/apps/control-panel-app/src/websocket/constants/index.ts @@ -1,6 +1,6 @@ export const SERVER_ID_HEADER = "x-kubeara-server-id"; export const CONTAINER_DISCOVER_TIMEOUT_MS = 15_000; -export const PORTS_CHECK_TIMEOUT_MS = 30_000; +export const DEPLOYMENT_VALIDATE_TIMEOUT_MS = 30_000; export const SERVER_GET_RESOURCES_TIMEOUT_MS = 15_000; export const CONTAINER_ACTION_TIMEOUT_MS = 60_000; export const DEPLOYMENT_REMOVE_TIMEOUT_MS = 120_000; diff --git a/apps/control-panel-app/src/websocket/interfaces/index.ts b/apps/control-panel-app/src/websocket/interfaces/index.ts index 484ab65..771563c 100644 --- a/apps/control-panel-app/src/websocket/interfaces/index.ts +++ b/apps/control-panel-app/src/websocket/interfaces/index.ts @@ -1,13 +1,13 @@ import { ContainerActionResponsePayload, + DeploymentValidateResponsePayload, DiscoveredContainerPayload, - PortsCheckResponsePayload, ServerResourcesMetricsPayload, } from "@shared/socket-events/deployment.events"; -export interface PendingPortsCheck { +export interface PendingDeploymentValidate { serverId: string; - resolve: (result: PortsCheckResponsePayload) => void; + resolve: (result: DeploymentValidateResponsePayload) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; } diff --git a/apps/control-panel-app/src/websocket/websocket.gateway.ts b/apps/control-panel-app/src/websocket/websocket.gateway.ts index 8d56d98..5a4ca05 100644 --- a/apps/control-panel-app/src/websocket/websocket.gateway.ts +++ b/apps/control-panel-app/src/websocket/websocket.gateway.ts @@ -26,8 +26,8 @@ import { ContainerDiscoverRequestPayload, ContainerDiscoverResponsePayload, DiscoveredContainerPayload, - PortsCheckRequestPayload, - PortsCheckResponsePayload, + DeploymentValidateRequestPayload, + DeploymentValidateResponsePayload, ServerGetResourcesRequestPayload, ServerGetResourcesResponsePayload, AgentRemoveRequestPayload, @@ -60,7 +60,7 @@ import type { PendingContainerLogsStart, PendingDeploymentRemove, PendingAgentRemove, - PendingPortsCheck, + PendingDeploymentValidate, PendingServerResources, PendingTerminalConnect, TerminalSessionRecord, @@ -70,7 +70,7 @@ import { SERVER_ID_HEADER, CONTAINER_ACTION_TIMEOUT_MS, CONTAINER_DISCOVER_TIMEOUT_MS, - PORTS_CHECK_TIMEOUT_MS, + DEPLOYMENT_VALIDATE_TIMEOUT_MS, CONTAINER_LOGS_START_TIMEOUT_MS, DEPLOYMENT_REMOVE_TIMEOUT_MS, AGENT_REMOVE_TIMEOUT_MS, @@ -127,7 +127,10 @@ export class DeploymentGateway string, PendingServerResources >(); - private readonly pendingPortsChecks = new Map(); + private readonly pendingDeploymentValidations = new Map< + string, + PendingDeploymentValidate + >(); private readonly pendingContainerActions = new Map< string, PendingContainerAction @@ -263,9 +266,9 @@ export class DeploymentGateway serverId, "Agent disconnected during server resource collection", ); - this.rejectPendingPortsChecksForServer( + this.rejectPendingDeploymentValidatesForServer( serverId, - "Agent disconnected during port availability check", + "Agent disconnected during deployment validation", ); this.rejectPendingContainerActionsForServer( serverId, @@ -384,40 +387,42 @@ export class DeploymentGateway } } - @SubscribeMessage(DeploymentEvents.PORTS_CHECK_RESULT) - handlePortsCheckResult( + @SubscribeMessage(DeploymentEvents.DEPLOYMENT_VALIDATE_RESULT) + handleDeploymentValidateResult( @ConnectedSocket() client: Socket, - @MessageBody() payload: PortsCheckResponsePayload, + @MessageBody() payload: DeploymentValidateResponsePayload, ): void { try { const requestId = payload?.requestId?.trim(); if (!requestId) { this.logger.warn( - `Ignoring ports check result without requestId from ${client.id}`, + `Ignoring deployment validate result without requestId from ${client.id}`, ); return; } - const pending = this.pendingPortsChecks.get(requestId); + const pending = this.pendingDeploymentValidations.get(requestId); if (!pending) { - this.logger.warn(`No pending ports check for requestId=${requestId}`); + this.logger.warn( + `No pending deployment validation for requestId=${requestId}`, + ); return; } const serverId = this.serverIdBySocketId.get(client.id); if (serverId && serverId !== pending.serverId) { this.logger.warn( - `Ports check result server mismatch requestId=${requestId} expected=${pending.serverId} got=${serverId}`, + `Deployment validate result server mismatch requestId=${requestId} expected=${pending.serverId} got=${serverId}`, ); return; } clearTimeout(pending.timer); - this.pendingPortsChecks.delete(requestId); + this.pendingDeploymentValidations.delete(requestId); pending.resolve(payload); } catch (error) { this.logger.error( - `Failed to process ports check result: ${error instanceof Error ? error.message : String(error)}`, + `Failed to process deployment validate result: ${error instanceof Error ? error.message : String(error)}`, ); } } @@ -1216,13 +1221,13 @@ export class DeploymentGateway } /** - * Requests a pre-deploy host port availability check from the connected agent. + * Requests pre-deploy validation (RAM, ports, CPU) from the connected agent. */ - requestPortsCheck( + requestDeploymentValidate( serverId: string, - payload: PortsCheckRequestPayload, - timeoutMs: number = PORTS_CHECK_TIMEOUT_MS, - ): Promise { + payload: DeploymentValidateRequestPayload, + timeoutMs: number = DEPLOYMENT_VALIDATE_TIMEOUT_MS, + ): Promise { return new Promise((resolve, reject) => { try { const client = this.agentsByServerId.get(serverId); @@ -1233,20 +1238,20 @@ export class DeploymentGateway const requestId = payload.requestId?.trim(); if (!requestId) { - reject(new Error("Missing requestId for ports check")); + reject(new Error("Missing requestId for deployment validation")); return; } const timer = setTimeout(() => { - this.pendingPortsChecks.delete(requestId); + this.pendingDeploymentValidations.delete(requestId); reject( new Error( - `Port availability check timed out after ${timeoutMs / 1000}s for server '${serverId}'`, + `Deployment validation timed out after ${timeoutMs / 1000}s for server '${serverId}'`, ), ); }, timeoutMs); - this.pendingPortsChecks.set(requestId, { + this.pendingDeploymentValidations.set(requestId, { serverId, resolve, reject, @@ -1254,10 +1259,10 @@ export class DeploymentGateway }); this.logger.log( - `[PORTS_CHECK] emitting event=${DeploymentEvents.PORTS_CHECK} to agentSocket=${client.id} serverId=${serverId} requestId=${requestId} template=${payload.templateSlug}`, + `[DEPLOYMENT_VALIDATE] emitting event=${DeploymentEvents.DEPLOYMENT_VALIDATE} to agentSocket=${client.id} serverId=${serverId} requestId=${requestId} template=${payload.templateSlug}`, ); - client.emit(DeploymentEvents.PORTS_CHECK, payload); + client.emit(DeploymentEvents.DEPLOYMENT_VALIDATE, payload); } catch (error) { reject(error instanceof Error ? error : new Error(String(error))); } @@ -2098,18 +2103,18 @@ export class DeploymentGateway } /** - * Rejects pending ports checks for a server. + * Rejects pending deployment validations for a server. */ - private rejectPendingPortsChecksForServer( + private rejectPendingDeploymentValidatesForServer( serverId: string, reason: string, ): void { - for (const [requestId, pending] of this.pendingPortsChecks) { + for (const [requestId, pending] of this.pendingDeploymentValidations) { if (pending.serverId !== serverId) { continue; } clearTimeout(pending.timer); - this.pendingPortsChecks.delete(requestId); + this.pendingDeploymentValidations.delete(requestId); pending.reject(new Error(reason)); } } diff --git a/console-app/src/components/server-detail-tabs.css b/console-app/src/components/server-detail-tabs.css index 12c0979..8c6767e 100644 --- a/console-app/src/components/server-detail-tabs.css +++ b/console-app/src/components/server-detail-tabs.css @@ -99,6 +99,18 @@ padding-right: 2.25rem; } +.container-port-link { + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 0.75rem; + font-weight: 600; + color: color-mix(in srgb, var(--primary) 82%, #2563eb); + text-decoration: none; +} + +.container-port-link:hover { + text-decoration: underline; +} + .container-actions-menu { position: absolute; top: 0.75rem; diff --git a/console-app/src/components/servers-table.css b/console-app/src/components/servers-table.css index 30ac7a3..29487b6 100644 --- a/console-app/src/components/servers-table.css +++ b/console-app/src/components/servers-table.css @@ -372,74 +372,6 @@ color: var(--foreground); } -.server-copy-wrap { - position: relative; - display: inline-flex; -} - -.server-copy-popover { - position: absolute; - bottom: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); - padding: 0.25rem 0.5rem; - border-radius: 4px; - background: #031b4e; - color: #fff; - font-size: 0.75rem; - font-weight: 600; - line-height: 1.2; - white-space: nowrap; - pointer-events: none; - z-index: 2; - box-shadow: 0 2px 8px rgb(0 0 0 / 15%); -} - -.server-copy-popover::after { - content: ""; - position: absolute; - top: 100%; - left: 50%; - transform: translateX(-50%); - border: 4px solid transparent; - border-top-color: #031b4e; -} - -[data-theme="dark"] .server-copy-popover { - background: #e2e8f0; - color: #0f172a; -} - -[data-theme="dark"] .server-copy-popover::after { - border-top-color: #e2e8f0; -} - -.server-copy-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.75rem; - height: 1.75rem; - padding: 0; - border: none; - border-radius: 4px; - background: transparent; - color: #62788e; - cursor: pointer; - opacity: 0.7; - transition: opacity 0.15s, background 0.15s; -} - -.server-copy-btn:hover { - opacity: 1; - background: rgb(0 0 0 / 6%); -} - -.server-copy-btn.copied { - color: #00b871; - opacity: 1; -} - /* Created cell */ .server-created-link { font-size: 0.875rem; diff --git a/console-app/src/components/servers-table.tsx b/console-app/src/components/servers-table.tsx index c022574..6a7f534 100644 --- a/console-app/src/components/servers-table.tsx +++ b/console-app/src/components/servers-table.tsx @@ -1,5 +1,6 @@ import { Link } from "react-router-dom"; import { useEffect, useMemo, useRef, useState } from "react"; +import { CopyButton } from "@/components/shared/copy-button"; import { useDeleteServerMutation, useServersQuery, @@ -35,27 +36,6 @@ const TABLE_COLUMNS: { { key: "createdAt", label: "Created At" }, ]; -function CopyIcon() { - return ( - - - - - ); -} - function EditIcon() { return ( @@ -165,37 +145,10 @@ function ServerNameCell({ server }: { server: Server }) { } function HostCell({ host }: { host: string }) { - const [copied, setCopied] = useState(false); - - async function copyHost() { - try { - await navigator.clipboard.writeText(host); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - /* clipboard unavailable */ - } - } - return (
{host} -
- {copied && ( - - Copied - - )} - -
+
); } diff --git a/console-app/src/components/shared/copy-button.css b/console-app/src/components/shared/copy-button.css new file mode 100644 index 0000000..da702b5 --- /dev/null +++ b/console-app/src/components/shared/copy-button.css @@ -0,0 +1,67 @@ +.copy-btn-wrap { + position: relative; + display: inline-flex; +} + +.copy-btn-popover { + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: #031b4e; + color: #fff; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + pointer-events: none; + z-index: 2; + box-shadow: 0 2px 8px rgb(0 0 0 / 15%); +} + +.copy-btn-popover::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 4px solid transparent; + border-top-color: #031b4e; +} + +[data-theme="dark"] .copy-btn-popover { + background: #e2e8f0; + color: #0f172a; +} + +[data-theme="dark"] .copy-btn-popover::after { + border-top-color: #e2e8f0; +} + +.copy-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: #62788e; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s, background 0.15s; +} + +.copy-btn:hover { + opacity: 1; + background: rgb(0 0 0 / 6%); +} + +.copy-btn.copied { + color: #00b871; + opacity: 1; +} diff --git a/console-app/src/components/shared/copy-button.tsx b/console-app/src/components/shared/copy-button.tsx new file mode 100644 index 0000000..00fe0e8 --- /dev/null +++ b/console-app/src/components/shared/copy-button.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import "./copy-button.css"; + +function CopyIcon() { + return ( + + + + + ); +} + +type CopyButtonProps = { + text: string; + label?: string; +}; + +export function CopyButton({ text, label = "Copy" }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + /* clipboard unavailable */ + } + } + + return ( +
+ {copied ? ( + + Copied + + ) : null} + +
+ ); +} diff --git a/console-app/src/features/deployments/api/index.ts b/console-app/src/features/deployments/api/index.ts index f261c06..12580ce 100644 --- a/console-app/src/features/deployments/api/index.ts +++ b/console-app/src/features/deployments/api/index.ts @@ -29,10 +29,10 @@ export async function deployTemplate( ); } -export async function checkDeploymentPorts( +export async function validateDeploymentResources( input: DeployTemplateInput, ): Promise { - await apiClient.post("/deployments/ports/check", { + await apiClient.post("/deployments/resources/check", { templateSlug: input.templateSlug, serverId: input.serverId, env: input.env ?? {}, diff --git a/console-app/src/features/deployments/constants/deployment-failure-messages.ts b/console-app/src/features/deployments/constants/deployment-failure-messages.ts index ffb21b4..64e7b94 100644 --- a/console-app/src/features/deployments/constants/deployment-failure-messages.ts +++ b/console-app/src/features/deployments/constants/deployment-failure-messages.ts @@ -43,6 +43,15 @@ export function isDeploymentPortConflict(text: string): boolean { ); } +export function isDeploymentResourceConflict(text: string): boolean { + const normalized = text.toLowerCase(); + + return ( + normalized.includes("not enough ram available") || + normalized.includes("not enough cpu available") + ); +} + export function mapDeploymentFailureMessage( error?: string | null, statusMessage?: string | null, @@ -58,6 +67,13 @@ export function mapDeploymentFailureMessage( ); } + if (isDeploymentResourceConflict(combined)) { + const detail = error?.trim() || statusMessage?.trim(); + if (detail) { + return detail; + } + } + const detail = error?.trim() || statusMessage?.trim(); if (detail && detail !== "Deployment failed") { return detail.startsWith("Deployment failed") diff --git a/console-app/src/features/deployments/constants/deployment-validation-messages.ts b/console-app/src/features/deployments/constants/deployment-validation-messages.ts new file mode 100644 index 0000000..b06b507 --- /dev/null +++ b/console-app/src/features/deployments/constants/deployment-validation-messages.ts @@ -0,0 +1,2 @@ +export const DEPLOYMENT_VALIDATION_IN_PROGRESS_MESSAGE = + "Validating server configuration..."; diff --git a/console-app/src/features/servers/components/server-detail/connected-service-card.tsx b/console-app/src/features/servers/components/server-detail/connected-service-card.tsx index f125b63..ccbd23c 100644 --- a/console-app/src/features/servers/components/server-detail/connected-service-card.tsx +++ b/console-app/src/features/servers/components/server-detail/connected-service-card.tsx @@ -8,6 +8,7 @@ import { containerStatusClass, getContainerCardHeadline, getContainerDockerName, + getContainerHostPorts, getContainerServiceName, getManagedTypeLabel, getContainerStatusLabel, @@ -16,6 +17,7 @@ import { type ConnectedServiceCardProps = { container: ServerContainer; + serverHost: string; logo?: string | null; pendingAction: { containerId: string | null; @@ -27,6 +29,7 @@ type ConnectedServiceCardProps = { export function ConnectedServiceCard({ container, + serverHost, logo, pendingAction, onAction, @@ -50,7 +53,13 @@ export function ConnectedServiceCard({ dockerName !== headline; const statusLabel = getContainerStatusLabel(container); - const portsDisplay = container.ports?.match(/:(\d+)->/)?.[1] ?? "N/A"; + const hostPorts = getContainerHostPorts(container.ports ?? ""); + const portsDisplay = + hostPorts.length > 0 + ? hostPorts.join(", ") + : container.ports?.trim() + ? container.ports + : "N/A"; return (
Ports
-
- {portsDisplay} +
+ {hostPorts.length > 0 && serverHost ? ( + hostPorts.map((port, index) => ( + + {index > 0 ? ", " : null} + + {port} + + + )) + ) : ( + {portsDisplay} + )}
{container.runningSince ? ( diff --git a/console-app/src/features/servers/components/server-detail/server-detail-tabs.tsx b/console-app/src/features/servers/components/server-detail/server-detail-tabs.tsx index c1fce86..794e227 100644 --- a/console-app/src/features/servers/components/server-detail/server-detail-tabs.tsx +++ b/console-app/src/features/servers/components/server-detail/server-detail-tabs.tsx @@ -91,6 +91,7 @@ export function ServerDetailTabs({ server }: ServerDetailTabsProps) { {activeTab === "overview" && ( 80/tcp`). */ +export function getContainerHostPorts(ports: string): number[] { + if (!ports.trim()) { + return []; + } + + const hostPorts: number[] = []; + for (const match of ports.matchAll(/:(\d+)->/g)) { + const port = Number.parseInt(match[1], 10); + if (!Number.isNaN(port) && !hostPorts.includes(port)) { + hostPorts.push(port); + } + } + + return hostPorts; +} + export function getContainerDockerName(container: ServerContainer): string { const raw = container.containerName?.trim(); if (!raw) { diff --git a/console-app/src/features/templates/components/deploy-configuration-form.tsx b/console-app/src/features/templates/components/deploy-configuration-form.tsx index 1f831ef..ac28ad5 100644 --- a/console-app/src/features/templates/components/deploy-configuration-form.tsx +++ b/console-app/src/features/templates/components/deploy-configuration-form.tsx @@ -15,10 +15,12 @@ import { import { groupTemplateVariables } from "../utils/field-utils"; import { getDeploymentSocket } from "@/lib/socket/deployment-socket-client"; import { showErrorToast } from "@/lib/toast"; -import { checkDeploymentPorts } from "@/features/deployments/api"; +import { validateDeploymentResources } from "@/features/deployments/api"; +import { DEPLOYMENT_VALIDATION_IN_PROGRESS_MESSAGE } from "@/features/deployments/constants/deployment-validation-messages"; import { mapDeploymentFailureMessage } from "@/features/deployments/constants/deployment-failure-messages"; import { DynamicDeployFields } from "./dynamic-deploy-fields"; import { DeployServiceSummaryCard } from "./deploy-service-summary-card"; +import type { DeployServiceSummaryStatus } from "./deploy-service-summary-card"; type DeployConfigurationFormProps = { template: ApiTemplate; @@ -33,6 +35,8 @@ export function DeployConfigurationForm({ }: DeployConfigurationFormProps) { const navigate = useNavigate(); const [isSubmitting, setIsSubmitting] = useState(false); + const [summaryStatus, setSummaryStatus] = + useState(null); const [isEditing, setIsEditing] = useState(false); const editSnapshotRef = useRef>({}); const detailsQuery = useTemplateDetailsQuery(template.slug); @@ -66,6 +70,7 @@ export function DeployConfigurationForm({ function handleEdit() { editSnapshotRef.current = form.getValues(); + setSummaryStatus(null); setIsEditing(true); } @@ -90,10 +95,14 @@ export function DeployConfigurationForm({ async function handleSubmit(values: Record) { setIsSubmitting(true); + setSummaryStatus({ + type: "validating", + message: DEPLOYMENT_VALIDATION_IN_PROGRESS_MESSAGE, + }); const { env, ports: portValues } = splitDeployFormValues(variables, values); try { - await checkDeploymentPorts({ + await validateDeploymentResources({ templateSlug: template.slug, serverId, env, @@ -111,9 +120,8 @@ export function DeployConfigurationForm({ }, }); } catch (error) { - showErrorToast( - mapDeploymentFailureMessage(getErrorMessage(error)), - ); + setSummaryStatus(null); + showErrorToast(mapDeploymentFailureMessage(getErrorMessage(error))); setIsSubmitting(false); } } @@ -125,6 +133,7 @@ export function DeployConfigurationForm({ serverName={serverName} serverId={serverId} variableCount={isLoadingFields ? "loading" : fieldCount} + status={summaryStatus} />
@@ -203,7 +212,7 @@ export function DeployConfigurationForm({ } aria-busy={isSubmitting} > - {isSubmitting ? "Checking ports…" : "Deploy"} + Deploy )} diff --git a/console-app/src/features/templates/components/deploy-service-summary-card.tsx b/console-app/src/features/templates/components/deploy-service-summary-card.tsx index c1c3c4a..3b08d76 100644 --- a/console-app/src/features/templates/components/deploy-service-summary-card.tsx +++ b/console-app/src/features/templates/components/deploy-service-summary-card.tsx @@ -3,17 +3,24 @@ import type { ApiTemplate } from "../types"; import { getTemplateAccentColor } from "../utils/deploy-form-schema"; import { formatTemplateCategory } from "../utils/format-template-category"; +export type DeployServiceSummaryStatus = { + type: "validating"; + message: string; +}; + type DeployServiceSummaryCardProps = { template: ApiTemplate; serverName?: string; serverId: string; variableCount: number | "loading"; + status?: DeployServiceSummaryStatus | null; }; export function DeployServiceSummaryCard({ template, serverName, serverId, + status, }: DeployServiceSummaryCardProps) { const accent = getTemplateAccentColor(template.slug); const categoryLabel = formatTemplateCategory(template.category); @@ -34,6 +41,15 @@ export function DeployServiceSummaryCard({

{template.name}

+ {status?.type === "validating" ? ( + + {status.message} + + ) : null}
{categoryLabel ? (

{categoryLabel}

diff --git a/console-app/src/features/templates/templates-ui.css b/console-app/src/features/templates/templates-ui.css index b94bd00..5e286f9 100644 --- a/console-app/src/features/templates/templates-ui.css +++ b/console-app/src/features/templates/templates-ui.css @@ -863,6 +863,22 @@ letter-spacing: -0.02em; } +.deploy-configure-page .deploy-service-status { + display: inline-flex; + align-items: center; + max-width: min(100%, 22rem); + padding: 0.3rem 0.75rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.35; +} + +.deploy-configure-page .deploy-service-status-validating { + background: color-mix(in srgb, var(--primary) 18%, transparent); + color: var(--primary); +} + .deploy-configure-page .deploy-service-category { margin: 0 0 0.5rem; font-size: 0.6875rem; diff --git a/console-app/src/index.css b/console-app/src/index.css index 3d3ff8d..74f62ce 100644 --- a/console-app/src/index.css +++ b/console-app/src/index.css @@ -1222,6 +1222,13 @@ a:hover { font-size: 0.8125rem; } +.server-detail-host-row { + display: inline-flex; + align-items: center; + gap: 0.125rem; + vertical-align: middle; +} + @media (max-width: 640px) { .app-main:has(.server-detail) { padding-left: 1rem; diff --git a/console-app/src/pages/server-detail-page.tsx b/console-app/src/pages/server-detail-page.tsx index af575a3..13da9cd 100644 --- a/console-app/src/pages/server-detail-page.tsx +++ b/console-app/src/pages/server-detail-page.tsx @@ -1,5 +1,6 @@ import { useParams } from "react-router-dom"; import { BackLink } from "@/components/shared/back-link"; +import { CopyButton } from "@/components/shared/copy-button"; import { ServerDetailTabs } from "@/components/server-detail-tabs"; import { useServerQuery } from "@/features/servers/hooks"; import { isServerOperationBusy } from "@/features/servers/types"; @@ -62,7 +63,11 @@ export function ServerDetailPage() {

{server.name}

- {server.host} · {server.username} + + {server.host} + + {" "} + · {server.username}

{operationLabel && ( ; +} + +const MEMORY_UNIT_MULTIPLIERS: Record = { + b: 1, + k: 1024, + m: 1024 ** 2, + g: 1024 ** 3, + t: 1024 ** 4, + ki: 1024, + mi: 1024 ** 2, + gi: 1024 ** 3, + ti: 1024 ** 4, +}; + +/** + * Parses a Docker memory limit string into bytes. + */ +export function parseMemoryLimitToBytes(value: string | number): number { + if (typeof value === "number") { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`Invalid memory limit: ${value}`); + } + + return value; + } + + const normalized = value.trim(); + if (!normalized) { + throw new Error("Invalid memory limit: empty value"); + } + + const match = normalized.match(/^(\d+(?:\.\d+)?)\s*([a-z]+)?$/i); + if (!match) { + throw new Error(`Invalid memory limit: ${value}`); + } + + const amount = Number(match[1]); + if (!Number.isFinite(amount) || amount < 0) { + throw new Error(`Invalid memory limit: ${value}`); + } + + const unit = (match[2] ?? "b").toLowerCase(); + const multiplier = MEMORY_UNIT_MULTIPLIERS[unit]; + if (!multiplier) { + throw new Error(`Invalid memory limit unit: ${value}`); + } + + return Math.round(amount * multiplier); +} + +/** + * Parses a Docker CPU limit into logical core count. + */ +export function parseCpuLimitToCores(value: string | number): number { + if (typeof value === "number") { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`Invalid CPU limit: ${value}`); + } + + return value; + } + + const normalized = value.trim(); + if (!normalized) { + throw new Error("Invalid CPU limit: empty value"); + } + + const parsed = Number(normalized); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid CPU limit: ${value}`); + } + + return parsed; +} + +/** + * Sums `deploy.resources.limits` across all services in a compose object. + */ +export function sumComposeResourceLimits( + compose: unknown, +): ComposeResourceRequirements { + if (!compose || typeof compose !== "object" || Array.isArray(compose)) { + return { memoryBytes: 0, cpuCores: 0 }; + } + + const services = (compose as ComposeFileWithServices).services; + if (!services || typeof services !== "object") { + return { memoryBytes: 0, cpuCores: 0 }; + } + + let memoryBytes = 0; + let cpuCores = 0; + + for (const service of Object.values(services)) { + const limits = service?.deploy?.resources?.limits; + if (!limits) { + continue; + } + + if (limits.memory !== undefined) { + memoryBytes += parseMemoryLimitToBytes(limits.memory); + } + + if (limits.cpus !== undefined) { + cpuCores += parseCpuLimitToCores(limits.cpus); + } + } + + return { memoryBytes, cpuCores }; +} + +/** + * Parses compose YAML and returns total resource limits for all services. + */ +export function sumComposeResourceLimitsFromYaml( + composeYaml: string, +): ComposeResourceRequirements { + const parsed = yaml.load(composeYaml); + return sumComposeResourceLimits(parsed); +} diff --git a/libs/common/src/constants/app-strings.constants.ts b/libs/common/src/constants/app-strings.constants.ts index 6343f67..cf3d608 100644 --- a/libs/common/src/constants/app-strings.constants.ts +++ b/libs/common/src/constants/app-strings.constants.ts @@ -41,6 +41,8 @@ export const ERROR_MESSAGES = { DEPLOYMENT_PORT_IN_USE: (port: number) => formatDeploymentPortInUseMessage(port), COMPOSE_VALIDATION_FAILED: "Docker compose validation failed", + INSUFFICIENT_RAM: "Not enough RAM available to run this service container", + INSUFFICIENT_CPU: "Not enough CPU available to run this service container", DEPLOYMENT_FAILED: "Deployment failed", CLEANUP_FAILED: "Deployment cleanup failed", REMOVAL_FAILED: "Deployment removal failed", diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index 445c20d..43ac426 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -16,4 +16,5 @@ export * from "./server-url/server-url.util"; export * from "./traefik/traefik-labels.util"; export * from "./utils/deployment.utils"; export * from "./container-discovery/parse-docker-ps.util"; +export * from "./compose-parser/parse-compose-resource-limits.util"; export * from "./server-resources/parse-server-resources.util"; diff --git a/libs/common/src/server-resources/parse-server-resources.util.ts b/libs/common/src/server-resources/parse-server-resources.util.ts index ce8d4bc..7c64905 100644 --- a/libs/common/src/server-resources/parse-server-resources.util.ts +++ b/libs/common/src/server-resources/parse-server-resources.util.ts @@ -185,9 +185,18 @@ export function parseNetDev(content: string): ServerNetworkMetrics { return { rxBytes, txBytes }; } -/** - * Builds a full resource snapshot from raw Linux command/file output. - */ +export function computeAvailableCpuCores(input: { + cores: number; + usagePercent: number; +}): number { + const cores = Number.isFinite(input.cores) && input.cores > 0 ? input.cores : 0; + const usagePercent = Number.isFinite(input.usagePercent) + ? Math.min(Math.max(input.usagePercent, 0), 100) + : 0; + + return Math.round(cores * (1 - usagePercent / 100) * 1000) / 1000; +} + export function buildServerResourcesMetrics(input: { cpuStatFirstLine: string; cpuStatSecondLine: string; diff --git a/libs/socket-events/src/deployment.events.ts b/libs/socket-events/src/deployment.events.ts index 95c9794..3d3119b 100644 --- a/libs/socket-events/src/deployment.events.ts +++ b/libs/socket-events/src/deployment.events.ts @@ -59,10 +59,10 @@ export enum DeploymentEvents { AGENT_REMOVE = "agent:remove", /** Agent → control panel: agent uninstall response. */ AGENT_REMOVE_RESULT = "agent:remove:result", - /** Control panel → agent: verify host ports before deployment. */ - PORTS_CHECK = "ports:check", - /** Agent → control panel: host port availability response. */ - PORTS_CHECK_RESULT = "ports:check:result", + /** Control panel → agent: verify host resources before deployment. */ + DEPLOYMENT_VALIDATE = "deployment:validate", + /** Agent → control panel: pre-deploy validation response. */ + DEPLOYMENT_VALIDATE_RESULT = "deployment:validate:result", /** Control panel → console: server add/delete background operation update. */ SERVER_OPERATION_UPDATED = "server:operation-updated", } @@ -422,8 +422,8 @@ export interface AgentRemoveResponsePayload { imageRefs?: string[]; } -/** Control panel → agent: pre-deploy host port availability check. */ -export interface PortsCheckRequestPayload { +/** Control panel → agent: pre-deploy resource and port validation. */ +export interface DeploymentValidateRequestPayload { requestId: string; templateSlug: string; /** Encrypted base64-encoded compose JSON (same as deploy). */ @@ -437,8 +437,8 @@ export interface PortsCheckRequestPayload { useTraefik?: boolean; } -/** Agent → control panel: pre-deploy host port availability response. */ -export interface PortsCheckResponsePayload { +/** Agent → control panel: pre-deploy validation response. */ +export interface DeploymentValidateResponsePayload { requestId: string; available: boolean; error?: string; From d04718dc08971cd1909549b0fd40d1fcf2c23cd3 Mon Sep 17 00:00:00 2001 From: dhavalpiqud Date: Thu, 25 Jun 2026 14:42:23 +0530 Subject: [PATCH 2/4] fix: linting issue and add util service --- .../common/src/server-resources/parse-server-resources.util.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/common/src/server-resources/parse-server-resources.util.ts b/libs/common/src/server-resources/parse-server-resources.util.ts index 7c64905..9d5026e 100644 --- a/libs/common/src/server-resources/parse-server-resources.util.ts +++ b/libs/common/src/server-resources/parse-server-resources.util.ts @@ -189,7 +189,8 @@ export function computeAvailableCpuCores(input: { cores: number; usagePercent: number; }): number { - const cores = Number.isFinite(input.cores) && input.cores > 0 ? input.cores : 0; + const cores = + Number.isFinite(input.cores) && input.cores > 0 ? input.cores : 0; const usagePercent = Number.isFinite(input.usagePercent) ? Math.min(Math.max(input.usagePercent, 0), 100) : 0; From 26b246e573b15a7167aea51d80d1aded8e714361 Mon Sep 17 00:00:00 2001 From: dhavalpiqud Date: Thu, 25 Jun 2026 15:31:16 +0530 Subject: [PATCH 3/4] fix: comments --- .../src/constants/app-strings.constants.ts | 6 +- .../parse-server-resources.util.ts | 88 ++++++++++++------- 2 files changed, 59 insertions(+), 35 deletions(-) diff --git a/libs/common/src/constants/app-strings.constants.ts b/libs/common/src/constants/app-strings.constants.ts index cf3d608..5437293 100644 --- a/libs/common/src/constants/app-strings.constants.ts +++ b/libs/common/src/constants/app-strings.constants.ts @@ -41,8 +41,10 @@ export const ERROR_MESSAGES = { DEPLOYMENT_PORT_IN_USE: (port: number) => formatDeploymentPortInUseMessage(port), COMPOSE_VALIDATION_FAILED: "Docker compose validation failed", - INSUFFICIENT_RAM: "Not enough RAM available to run this service container", - INSUFFICIENT_CPU: "Not enough CPU available to run this service container", + INSUFFICIENT_RAM: + "Not enough RAM available in server to run this service container", + INSUFFICIENT_CPU: + "Not enough CPU available in server to run this service container", DEPLOYMENT_FAILED: "Deployment failed", CLEANUP_FAILED: "Deployment cleanup failed", REMOVAL_FAILED: "Deployment removal failed", diff --git a/libs/common/src/server-resources/parse-server-resources.util.ts b/libs/common/src/server-resources/parse-server-resources.util.ts index 9d5026e..9d98788 100644 --- a/libs/common/src/server-resources/parse-server-resources.util.ts +++ b/libs/common/src/server-resources/parse-server-resources.util.ts @@ -185,19 +185,35 @@ export function parseNetDev(content: string): ServerNetworkMetrics { return { rxBytes, txBytes }; } +/** + * Computes the available CPU cores from the server resources. + * @param input - The input object containing the server resources metrics. + * @returns The available CPU cores. + */ export function computeAvailableCpuCores(input: { cores: number; usagePercent: number; }): number { - const cores = - Number.isFinite(input.cores) && input.cores > 0 ? input.cores : 0; - const usagePercent = Number.isFinite(input.usagePercent) - ? Math.min(Math.max(input.usagePercent, 0), 100) - : 0; - - return Math.round(cores * (1 - usagePercent / 100) * 1000) / 1000; + try { + const cores = + Number.isFinite(input.cores) && input.cores > 0 ? input.cores : 0; + const usagePercent = Number.isFinite(input.usagePercent) + ? Math.min(Math.max(input.usagePercent, 0), 100) + : 0; + + return Math.round(cores * (1 - usagePercent / 100) * 1000) / 1000; + } catch (error) { + throw new Error( + `Failed to compute available CPU cores: ${error instanceof Error ? error.message : String(error)}`, + ); + } } +/** + * Builds the server resources metrics from the input. + * @param input - The input object containing the server resources metrics. + * @returns The server resources metrics. + */ export function buildServerResourcesMetrics(input: { cpuStatFirstLine: string; cpuStatSecondLine: string; @@ -212,30 +228,36 @@ export function buildServerResourcesMetrics(input: { architecture: string; timestamp?: string; }): ServerResourcesMetricsPayload { - const start = parseCpuStatLine(input.cpuStatFirstLine); - const end = parseCpuStatLine(input.cpuStatSecondLine); - const usagePercent = computeCpuUsagePercent(start, end); - - const cpu: ServerCpuMetrics = { - usagePercent, - cores: input.cpuCores, - loadAverage: parseLoadAverage(input.loadAverageContent), - }; - - const uptimeSeconds = Number(input.uptimeContent.trim().split(/\s+/)[0]); - const system: ServerSystemMetrics = { - uptime: Number.isFinite(uptimeSeconds) ? uptimeSeconds : 0, - hostname: input.hostname.trim(), - platform: input.platform.trim(), - architecture: input.architecture.trim(), - timestamp: input.timestamp ?? new Date().toISOString(), - }; - - return { - cpu, - memory: parseMeminfo(input.meminfo), - disk: parseDfOutput(input.dfStdout), - network: parseNetDev(input.netDev), - system, - }; + try { + const start = parseCpuStatLine(input.cpuStatFirstLine); + const end = parseCpuStatLine(input.cpuStatSecondLine); + const usagePercent = computeCpuUsagePercent(start, end); + + const cpu: ServerCpuMetrics = { + usagePercent, + cores: input.cpuCores, + loadAverage: parseLoadAverage(input.loadAverageContent), + }; + + const uptimeSeconds = Number(input.uptimeContent.trim().split(/\s+/)[0]); + const system: ServerSystemMetrics = { + uptime: Number.isFinite(uptimeSeconds) ? uptimeSeconds : 0, + hostname: input.hostname.trim(), + platform: input.platform.trim(), + architecture: input.architecture.trim(), + timestamp: input.timestamp ?? new Date().toISOString(), + }; + + return { + cpu, + memory: parseMeminfo(input.meminfo), + disk: parseDfOutput(input.dfStdout), + network: parseNetDev(input.netDev), + system, + }; + } catch (error) { + throw new Error( + `Failed to build server resources metrics: ${error instanceof Error ? error.message : String(error)}`, + ); + } } From e2d68bfb40c9bdc223205d873fd21bf086699c3d Mon Sep 17 00:00:00 2001 From: dhavalpiqud Date: Thu, 25 Jun 2026 19:33:38 +0530 Subject: [PATCH 4/4] refactor: Enhance error handling and logging across services - Improved error handling in various services, including ContainerService, DeployTemplateExecutor, and ResourceAvailabilityService, to provide clearer error messages and logging. - Updated terminal service methods to handle errors gracefully and log relevant information. - Refactored health controller to log errors during health checks. - Enhanced SocketClientService to utilize shared error messages for consistency. - Improved resource validation checks to ensure robust error reporting during deployment processes. --- .../src/common/constants/terminal.constant.ts | 20 +- .../src/container/container.service.ts | 145 +- .../src/executors/deploy-template.executor.ts | 58 +- .../src/filesystem/filesystem.service.ts | 4 +- .../agent-app/src/health/health.controller.ts | 50 +- .../src/proxy/traefik-proxy.service.ts | 7 + .../resource-availability.service.ts | 151 +- .../server-resources.service.ts | 128 +- .../src/socket-client/agent-public-ip.util.ts | 2 +- .../socket-client/socket-client.service.ts | 46 +- .../src/terminal/terminal.service.ts | 107 +- apps/control-panel-app/src/app.controller.ts | 18 +- apps/control-panel-app/src/app.module.ts | 2 - .../interfaces/api-response.interface.ts | 5 - .../src/common/utils/error.util.ts | 6 + apps/control-panel-app/src/constants/error.ts | 4 + .../src/modules/auth/auth.controller.ts | 107 +- .../src/modules/auth/auth.service.ts | 524 +++--- .../auth/services/auth-cookie.service.ts | 53 +- .../services/auth-session-lookup.service.ts | 77 +- .../deploy/controllers/deploy.controller.ts | 112 -- .../src/modules/deploy/deploy.module.ts | 20 - .../modules/deploy/dto/deploy-template.dto.ts | 19 - .../src/modules/deploy/index.ts | 3 - .../deployments/deployments.controller.ts | 94 +- .../deployments/deployments.service.ts | 855 +++++---- .../controllers/mcp-api-keys.controller.ts | 39 +- .../src/modules/mcp-api-keys/index.ts | 3 - .../src/modules/mcp-server/index.ts | 4 - .../interfaces/mcp-demo-data.interface.ts | 31 - .../mcp-server/services/mcp-auth.service.ts | 38 +- .../mcp-server/services/mcp-server.service.ts | 130 +- .../modules/mcp-server/tools/tools.module.ts | 3 +- .../modules/mcp-server/tools/tools.service.ts | 31 +- .../organizations/organizations.controller.ts | 4 - .../organizations/organizations.module.ts | 9 - .../organizations/organizations.service.ts | 4 - .../src/modules/profile/profile.controller.ts | 33 +- .../src/modules/profile/profile.service.ts | 108 +- .../adapters/local-agent-host.adapter.ts | 5 +- .../controllers/servers.controller.ts | 107 +- .../src/modules/server-connections/index.ts | 7 - .../server-connections.constants.ts | 1 - .../services/remote-agent-install.service.ts | 17 +- .../services/server-connections.service.ts | 1018 ++++++----- .../public-service-template.controller.ts | 37 +- .../service-template.controller.ts | 73 +- .../src/modules/service-template/index.ts | 3 - .../terminal/constants/terminal.constants.ts | 14 +- .../modules/terminal/ssh-terminal.service.ts | 245 ++- .../modules/terminal/terminal.controller.ts | 33 +- .../src/modules/terminal/terminal.service.ts | 210 ++- .../src/modules/users/users.controller.ts | 4 - .../src/modules/users/users.module.ts | 2 - .../src/modules/users/users.service.ts | 14 +- .../constants/error-messages.constants.ts | 70 + .../src/websocket/constants/index.ts | 8 + .../deployment-stream-buffer.service.ts | 63 +- .../src/websocket/websocket.gateway.ts | 1605 +++++++++++------ .../src/components/shared/copy-button.css | 182 +- .../src/components/shared/copy-button.tsx | 34 +- .../deployments/hooks/use-container-logs.ts | 3 - .../servers/hooks/use-server-terminal.ts | 14 +- console-app/src/pages/server-detail-page.tsx | 2 +- .../parse-compose-resource-limits.util.ts | 14 +- .../src/constants/app-strings.constants.ts | 11 + libs/common/src/constants/index.ts | 3 +- libs/common/src/constants/shell.constants.ts | 10 + libs/common/src/constants/socket.constants.ts | 12 - .../src/constants/terminal.constants.ts | 35 + 70 files changed, 4097 insertions(+), 2813 deletions(-) delete mode 100644 apps/control-panel-app/src/common/interfaces/api-response.interface.ts create mode 100644 apps/control-panel-app/src/common/utils/error.util.ts delete mode 100644 apps/control-panel-app/src/modules/deploy/controllers/deploy.controller.ts delete mode 100644 apps/control-panel-app/src/modules/deploy/deploy.module.ts delete mode 100644 apps/control-panel-app/src/modules/deploy/dto/deploy-template.dto.ts delete mode 100644 apps/control-panel-app/src/modules/deploy/index.ts delete mode 100644 apps/control-panel-app/src/modules/mcp-api-keys/index.ts delete mode 100644 apps/control-panel-app/src/modules/mcp-server/index.ts delete mode 100644 apps/control-panel-app/src/modules/mcp-server/interfaces/mcp-demo-data.interface.ts delete mode 100644 apps/control-panel-app/src/modules/organizations/organizations.controller.ts delete mode 100644 apps/control-panel-app/src/modules/organizations/organizations.module.ts delete mode 100644 apps/control-panel-app/src/modules/organizations/organizations.service.ts delete mode 100644 apps/control-panel-app/src/modules/server-connections/index.ts delete mode 100644 apps/control-panel-app/src/modules/service-template/index.ts delete mode 100644 apps/control-panel-app/src/modules/users/users.controller.ts create mode 100644 apps/control-panel-app/src/websocket/constants/error-messages.constants.ts create mode 100644 libs/common/src/constants/shell.constants.ts delete mode 100644 libs/common/src/constants/socket.constants.ts create mode 100644 libs/common/src/constants/terminal.constants.ts diff --git a/apps/agent-app/src/common/constants/terminal.constant.ts b/apps/agent-app/src/common/constants/terminal.constant.ts index 4e94046..4a35cce 100644 --- a/apps/agent-app/src/common/constants/terminal.constant.ts +++ b/apps/agent-app/src/common/constants/terminal.constant.ts @@ -1,12 +1,22 @@ import { existsSync } from "node:fs"; +import { + SHELL_PATHS, + TERMINAL_COLOR_TERM, + TERMINAL_TERM_TYPE, +} from "@shared/common"; -export const DEFAULT_TERMINAL_COLS = 80; -export const DEFAULT_TERMINAL_ROWS = 24; +export { + DEFAULT_TERMINAL_COLS, + DEFAULT_TERMINAL_ROWS, + TERMINAL_TERM_TYPE, +} from "@shared/common"; -export const TERMINAL_SHELL = existsSync("/bin/bash") ? "/bin/bash" : "/bin/sh"; +export const TERMINAL_SHELL = existsSync(SHELL_PATHS.BASH) + ? SHELL_PATHS.BASH + : SHELL_PATHS.SH; export const TERMINAL_ENV = { ...process.env, - TERM: "xterm-256color", - COLORTERM: "truecolor", + TERM: TERMINAL_TERM_TYPE, + COLORTERM: TERMINAL_COLOR_TERM, } as NodeJS.ProcessEnv; diff --git a/apps/agent-app/src/container/container.service.ts b/apps/agent-app/src/container/container.service.ts index b7ad4ed..088c871 100644 --- a/apps/agent-app/src/container/container.service.ts +++ b/apps/agent-app/src/container/container.service.ts @@ -50,89 +50,97 @@ export class ContainerService { sessionId: string, containerId: string, ): Promise { - const trimmedId = containerId.trim(); - if (!trimmedId) { - return "Missing containerId"; - } + try { + const trimmedId = containerId.trim(); + if (!trimmedId) { + return "Missing containerId"; + } - if (this.logSessions.has(sessionId)) { - this.stopLogStream(sessionId); - } + if (this.logSessions.has(sessionId)) { + this.stopLogStream(sessionId); + } - const inspectResult = await this.execCapture( - "docker", - ["inspect", "-f", "{{.Id}}", trimmedId], - CONTAINER_ACTION_TIMEOUT_MS, - ); + const inspectResult = await this.execCapture( + "docker", + ["inspect", "-f", "{{.Id}}", trimmedId], + CONTAINER_ACTION_TIMEOUT_MS, + ); - if (inspectResult.exitCode !== 0) { - const inspectDetail = - inspectResult.stderr.trim() || - inspectResult.stdout.trim() || - `Container '${trimmedId}' not found`; - return this.classifyDockerError(inspectDetail); - } + if (inspectResult.exitCode !== 0) { + const inspectDetail = + inspectResult.stderr.trim() || + inspectResult.stdout.trim() || + `Container '${trimmedId}' not found`; + return this.classifyDockerError(inspectDetail); + } - const resolvedId = - inspectResult.stdout.trim().replace(/^sha256:/, "") || trimmedId; - const logsTarget = - resolvedId.length >= 12 ? resolvedId.slice(0, 12) : trimmedId; + const resolvedId = + inspectResult.stdout.trim().replace(/^sha256:/, "") || trimmedId; + const logsTarget = + resolvedId.length >= 12 ? resolvedId.slice(0, 12) : trimmedId; - const args = CONTAINER_LOGS_COMMAND(logsTarget); - this.logger.log( - `[CONTAINER_LOGS] starting docker ${args.join(" ")} sessionId=${sessionId}`, - ); + const args = CONTAINER_LOGS_COMMAND(logsTarget); + this.logger.log( + `[CONTAINER_LOGS] starting docker ${args.join(" ")} sessionId=${sessionId}`, + ); - const child = spawn("docker", args, { cwd: process.cwd() }); - const session: ContainerLogSession = { - sessionId, - containerId: resolvedId, - child, - stopping: false, - stderr: "", - }; + const child = spawn("docker", args, { cwd: process.cwd() }); + const session: ContainerLogSession = { + sessionId, + containerId: resolvedId, + child, + stopping: false, + stderr: "", + }; - child.on("error", (err) => { - const message = this.classifyDockerError(err.message); - this.logger.error( - `[CONTAINER_LOGS] failed to start docker logs sessionId=${sessionId}: ${message}`, - ); - this.cleanupLogSession(sessionId, message); - }); + child.on("error", (err) => { + const message = this.classifyDockerError(err.message); + this.logger.error( + `[CONTAINER_LOGS] failed to start docker logs sessionId=${sessionId}: ${message}`, + ); + this.cleanupLogSession(sessionId, message); + }); - child.stdout.on("data", (chunk: Buffer | string) => { - this.dataHandler?.(sessionId, String(chunk)); - }); + child.stdout.on("data", (chunk: Buffer | string) => { + this.dataHandler?.(sessionId, String(chunk)); + }); - child.stderr.on("data", (chunk: Buffer | string) => { - const text = String(chunk); - session.stderr += text; - this.dataHandler?.(sessionId, text); - }); + child.stderr.on("data", (chunk: Buffer | string) => { + const text = String(chunk); + session.stderr += text; + this.dataHandler?.(sessionId, text); + }); - child.on("close", (code) => { - const activeSession = this.logSessions.get(sessionId); - if (!activeSession) { - return; - } + child.on("close", (code) => { + const activeSession = this.logSessions.get(sessionId); + if (!activeSession) { + return; + } - this.logSessions.delete(sessionId); + this.logSessions.delete(sessionId); - if (!activeSession.stopping && code !== 0 && code !== null) { - const detail = - activeSession.stderr.trim() || `docker logs exited with code ${code}`; - this.errorHandler?.(sessionId, this.classifyDockerError(detail)); - } + if (!activeSession.stopping && code !== 0 && code !== null) { + const detail = + activeSession.stderr.trim() || + `docker logs exited with code ${code}`; + this.errorHandler?.(sessionId, this.classifyDockerError(detail)); + } - this.closeHandler?.(sessionId); - this.logger.log( - `[CONTAINER_LOGS] stream closed sessionId=${sessionId} exitCode=${code ?? "null"} stopping=${activeSession.stopping}`, - ); - }); + this.closeHandler?.(sessionId); + this.logger.log( + `[CONTAINER_LOGS] stream closed sessionId=${sessionId} exitCode=${code ?? "null"} stopping=${activeSession.stopping}`, + ); + }); - this.logSessions.set(sessionId, session); + this.logSessions.set(sessionId, session); - return null; + return null; + } catch (error) { + this.logger.error( + `[CONTAINER_LOGS] start log stream failed sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** @@ -519,6 +527,7 @@ export class ContainerService { }); child.on("error", (err) => { stderr += `Failed to start process: ${err.message}`; + finish(1); }); child.on("close", (code) => { finish(code ?? 1); diff --git a/apps/agent-app/src/executors/deploy-template.executor.ts b/apps/agent-app/src/executors/deploy-template.executor.ts index 00dbd89..a33a9af 100644 --- a/apps/agent-app/src/executors/deploy-template.executor.ts +++ b/apps/agent-app/src/executors/deploy-template.executor.ts @@ -19,6 +19,7 @@ import { applyTraefikRoutingToCompose, extractOccupiedPortFromError, formatDeploymentPortInUseMessage, + maskEnvContents, sumComposeResourceLimitsFromYaml, } from "@shared/common"; import { @@ -137,6 +138,19 @@ export class DeployTemplateExecutor { startedAt, projectName, }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.logger.error( + `Unexpected deployment error deploymentId=${deploymentId}: ${msg}`, + ); + notifier.sendStatus({ + deploymentId, + templateSlug: name, + status: "failed", + message: ERROR_MESSAGES.DEPLOYMENT_FAILED, + error: msg, + completedAt: new Date().toISOString(), + }); } finally { this.activeDeployments.delete(deploymentId); this.clearDeploymentStreamLineBuffers(deploymentId); @@ -737,9 +751,21 @@ export class DeployTemplateExecutor { ): Promise { this.stopContainerLogStreaming(deploymentId); const resolved = this.resolvePortConflictFailure(message, error); - this.logger.error(`${resolved.message}: ${resolved.error}`); + this.logger.error( + `${resolved.message}: ${this.sanitizeDockerOutput(resolved.error)}`, + ); if (dir && projectName) { - await this.cleanupDeployment(projectName, dir, name, notifier); + try { + await this.cleanupDeployment(projectName, dir, name, notifier); + } catch (cleanupErr) { + this.logger.warn( + `Cleanup after failure failed deploymentId=${deploymentId}: ${ + cleanupErr instanceof Error + ? cleanupErr.message + : String(cleanupErr) + }`, + ); + } } notifier.sendStatus({ @@ -1449,19 +1475,30 @@ export class DeployTemplateExecutor { const child = spawn(cmd, args.filter(Boolean), { cwd }); let stdout = ""; let stderr = ""; + let settled = false; + + const finish = (exitCode: number) => { + if (settled) { + return; + } + settled = true; + resolve({ exitCode, stdout, stderr }); + }; child.stdout.on("data", (chunk) => (stdout += String(chunk))); child.stderr.on("data", (chunk) => (stderr += String(chunk))); - child.on( - "error", - (err) => (stderr += `Failed to start process: ${err.message}`), - ); - child.on("close", (code) => - resolve({ exitCode: code ?? 1, stdout, stderr }), - ); + child.on("error", (err) => { + stderr += `Failed to start process: ${err.message}`; + finish(1); + }); + child.on("close", (code) => finish(code ?? 1)); }); } + private sanitizeDockerOutput(text: string, maxLen = 500): string { + return maskEnvContents(text).slice(0, maxLen); + } + private clearDeploymentStreamLineBuffers(deploymentId: string): void { for (const key of this.streamLineBuffers.keys()) { if (key.startsWith(`${deploymentId}:deployment:`)) { @@ -2143,9 +2180,8 @@ export class DeployTemplateExecutor { }); } catch (error) { this.logger.error( - `Failed to run agent removal after ack: ${String(error)}`, + `Failed to run agent removal after ack: ${error instanceof Error ? error.message : String(error)}`, ); - throw error; } } diff --git a/apps/agent-app/src/filesystem/filesystem.service.ts b/apps/agent-app/src/filesystem/filesystem.service.ts index bef9a81..1474317 100644 --- a/apps/agent-app/src/filesystem/filesystem.service.ts +++ b/apps/agent-app/src/filesystem/filesystem.service.ts @@ -80,14 +80,14 @@ export class FilesystemService { * @returns Absolute path to ensured deployment directory. */ async ensureDeploymentDir(deploymentId: string): Promise { - const targetDir = ""; + let targetDir = ""; try { const safeId = this.sanitizeName(deploymentId); if (!safeId) { throw new Error(`Invalid deployment ID: ${deploymentId}`); } - const targetDir = path.join(this.getDeploymentsRoot(), safeId); + targetDir = path.join(this.getDeploymentsRoot(), safeId); await fs.mkdir(targetDir, { recursive: true }); return targetDir; } catch (error) { diff --git a/apps/agent-app/src/health/health.controller.ts b/apps/agent-app/src/health/health.controller.ts index 812d946..71d2c97 100644 --- a/apps/agent-app/src/health/health.controller.ts +++ b/apps/agent-app/src/health/health.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get } from "@nestjs/common"; +import { Controller, Get, Logger } from "@nestjs/common"; import { SocketClientService } from "../socket-client/socket-client.service"; import { ContainerService } from "../container/container.service"; import { ServerResourcesService } from "../server-resources/server-resources.service"; @@ -6,10 +6,8 @@ import type { ServerResourcesMetricsPayload } from "@shared/socket-events"; @Controller("health") export class HealthController { - /** - * Creates health controller with socket client dependency. - * @param socketClientService Connected agent socket state provider. - */ + private readonly logger = new Logger(HealthController.name); + constructor( private readonly socketClientService: SocketClientService, private readonly containerService: ContainerService, @@ -18,7 +16,6 @@ export class HealthController { /** * Returns current health and socket metadata for the running agent. - * @returns Health payload used by probes and diagnostics. */ @Get() health(): { @@ -35,9 +32,8 @@ export class HealthController { timestamp: new Date().toISOString(), }; } catch (error) { - throw new Error( - `Failed to build health response: ${error instanceof Error ? error.message : String(error)}`, - ); + this.logger.error(`Health check failed`); + throw error; } } @@ -50,13 +46,18 @@ export class HealthController { containers: unknown[]; error?: string; }> { - const result = - await this.containerService.discoverContainers("health-check"); - return { - count: result.containers.length, - containers: result.containers, - error: result.error, - }; + try { + const result = + await this.containerService.discoverContainers("health-check"); + return { + count: result.containers.length, + containers: result.containers, + error: result.error, + }; + } catch (error) { + this.logger.error(`Health container discovery failed`); + throw error; + } } /** @@ -67,11 +68,16 @@ export class HealthController { resources?: ServerResourcesMetricsPayload; error?: string; }> { - const result = - await this.serverResourcesService.collectResources("health-check"); - return { - resources: result.resources, - error: result.error, - }; + try { + const result = + await this.serverResourcesService.collectResources("health-check"); + return { + resources: result.resources, + error: result.error, + }; + } catch (error) { + this.logger.error(`Health resource collection failed`); + throw error; + } } } diff --git a/apps/agent-app/src/proxy/traefik-proxy.service.ts b/apps/agent-app/src/proxy/traefik-proxy.service.ts index c945491..662fcde 100644 --- a/apps/agent-app/src/proxy/traefik-proxy.service.ts +++ b/apps/agent-app/src/proxy/traefik-proxy.service.ts @@ -160,6 +160,13 @@ export class TraefikProxyService { childProcess.stderr.on("data", (chunk: Buffer) => { standardError += chunk.toString(); }); + childProcess.on("error", (err) => { + resolve({ + exitCode: 1, + stdout: standardOutput, + stderr: err.message, + }); + }); childProcess.on("close", (code) => { resolve({ exitCode: code ?? 1, diff --git a/apps/agent-app/src/resource-availability/resource-availability.service.ts b/apps/agent-app/src/resource-availability/resource-availability.service.ts index 28f0468..4b889dc 100644 --- a/apps/agent-app/src/resource-availability/resource-availability.service.ts +++ b/apps/agent-app/src/resource-availability/resource-availability.service.ts @@ -45,36 +45,50 @@ export class ResourceAvailabilityService { * Verifies that each configured host port can be bound before deployment starts. */ async assertPortsAvailable(ports: PortFileInput): Promise { - const entries = Object.entries(ports).filter( - (entry): entry is [string, number] => this.isValidHostPort(entry[1]), - ); - - if (entries.length === 0) { - this.logger.log( - "Port availability check skipped: no host ports configured", + try { + const entries = Object.entries(ports).filter( + (entry): entry is [string, number] => this.isValidHostPort(entry[1]), ); - return; - } - for (const [key, port] of entries) { - await this.assertHostPortAvailable(port, key); + if (entries.length === 0) { + this.logger.log( + "Port availability check skipped: no host ports configured", + ); + return; + } + + for (const [key, port] of entries) { + await this.assertHostPortAvailable(port, key); + } + } catch (error) { + this.logger.error( + `Port availability check failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; } } async assertHostPortsAvailable(ports: number[]): Promise { - const uniquePorts = [ - ...new Set(ports.filter((port) => this.isValidHostPort(port))), - ]; - - if (uniquePorts.length === 0) { - this.logger.log( - "Port availability check skipped: no resolved host ports to validate", + try { + const uniquePorts = [ + ...new Set(ports.filter((port) => this.isValidHostPort(port))), + ]; + + if (uniquePorts.length === 0) { + this.logger.log( + "Port availability check skipped: no resolved host ports to validate", + ); + return; + } + + for (const port of uniquePorts) { + await this.assertHostPortAvailable(port); + } + } catch (error) { + this.logger.error( + `Host ports availability check failed: ${error instanceof Error ? error.message : String(error)}`, ); - return; - } - - for (const port of uniquePorts) { - await this.assertHostPortAvailable(port); + throw error; } } @@ -82,52 +96,66 @@ export class ResourceAvailabilityService { * Verifies the host has enough available RAM for compose memory limits. */ async assertRamAvailable(requiredMemoryBytes: number): Promise { - if (requiredMemoryBytes <= 0) { + try { + if (requiredMemoryBytes <= 0) { + this.logger.log( + "RAM availability check skipped: no compose memory limits defined", + ); + return; + } + + const metrics = await this.serverResourcesService.getCurrentMetrics(); + const availableRam = metrics.memory.available; + + if (availableRam < requiredMemoryBytes) { + this.logger.warn( + `RAM availability check failed: required memory=${requiredMemoryBytes} bytes, available=${availableRam} bytes`, + ); + throw new InsufficientRamError(); + } + this.logger.log( - "RAM availability check skipped: no compose memory limits defined", + `RAM availability check passed: required=${requiredMemoryBytes} bytes, available=${availableRam} bytes`, ); - return; - } - - const metrics = await this.serverResourcesService.getCurrentMetrics(); - const availableRam = metrics.memory.available; - - if (availableRam < requiredMemoryBytes) { - this.logger.warn( - `RAM availability check failed: required memory=${requiredMemoryBytes} bytes, available=${availableRam} bytes`, + } catch (error) { + this.logger.error( + `RAM availability check failed: ${error instanceof Error ? error.message : String(error)}`, ); - throw new InsufficientRamError(); + throw error; } - - this.logger.log( - `RAM availability check passed: required=${requiredMemoryBytes} bytes, available=${availableRam} bytes`, - ); } /** * Verifies the host has enough available CPU for compose CPU limits. */ async assertCpuAvailable(requiredCpuCores: number): Promise { - if (requiredCpuCores <= 0) { + try { + if (requiredCpuCores <= 0) { + this.logger.log( + "CPU availability check skipped: no compose CPU limits defined", + ); + return; + } + + const metrics = await this.serverResourcesService.getCurrentMetrics(); + const availableCpu = computeAvailableCpuCores(metrics.cpu); + + if (availableCpu + Number.EPSILON < requiredCpuCores) { + this.logger.warn( + `CPU availability check failed: required cpu=${requiredCpuCores}, available=${availableCpu}`, + ); + throw new InsufficientCpuError(); + } + this.logger.log( - "CPU availability check skipped: no compose CPU limits defined", + `CPU availability check passed: required=${requiredCpuCores}, available=${availableCpu}`, ); - return; - } - - const metrics = await this.serverResourcesService.getCurrentMetrics(); - const availableCpu = computeAvailableCpuCores(metrics.cpu); - - if (availableCpu + Number.EPSILON < requiredCpuCores) { - this.logger.warn( - `CPU availability check failed: required cpu=${requiredCpuCores}, available=${availableCpu}`, + } catch (error) { + this.logger.error( + `CPU availability check failed: ${error instanceof Error ? error.message : String(error)}`, ); - throw new InsufficientCpuError(); + throw error; } - - this.logger.log( - `CPU availability check passed: required=${requiredCpuCores}, available=${availableCpu}`, - ); } /** @@ -136,8 +164,15 @@ export class ResourceAvailabilityService { async assertResourcesAvailable( required: ComposeResourceRequirements, ): Promise { - await this.assertRamAvailable(required.memoryBytes); - await this.assertCpuAvailable(required.cpuCores); + try { + await this.assertRamAvailable(required.memoryBytes); + await this.assertCpuAvailable(required.cpuCores); + } catch (error) { + this.logger.error( + `Resource availability check failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } private async assertHostPortAvailable( @@ -190,7 +225,7 @@ export class ResourceAvailabilityService { this.logger.warn( `Docker publish filter check failed for port ${port}: ${result.stderr || result.stdout}`, ); - return false; + throw new Error(`Docker publish check failed for port ${port}`); } return result.stdout.trim().length > 0; diff --git a/apps/agent-app/src/server-resources/server-resources.service.ts b/apps/agent-app/src/server-resources/server-resources.service.ts index cf4fd2e..b0757d4 100644 --- a/apps/agent-app/src/server-resources/server-resources.service.ts +++ b/apps/agent-app/src/server-resources/server-resources.service.ts @@ -47,11 +47,17 @@ export class ServerResourcesService { * Gathers current server resource metrics without socket correlation metadata. */ async getCurrentMetrics() { - return this.withTimeout( - this.gatherMetrics(), - SERVER_RESOURCES_TIMEOUT_MS, - "Server resource collection timed out", - ); + try { + return await this.withTimeout( + this.gatherMetrics(), + SERVER_RESOURCES_TIMEOUT_MS, + "Server resource collection timed out", + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Get current metrics failed: ${message}`); + throw error; + } } /** @@ -59,51 +65,57 @@ export class ServerResourcesService { * @returns Server resources metrics */ private async gatherMetrics() { - const procPrefix = await this.procPrefixPromise; - const hostRoot = await this.resolveHostRootPath(procPrefix); + try { + const procPrefix = await this.procPrefixPromise; + const hostRoot = await this.resolveHostRootPath(procPrefix); - const cpuStatFirstLine = this.extractCpuLine( - await this.readProcFile(procPrefix, "stat"), - ); - await this.sleep(CPU_SAMPLE_INTERVAL_MS); - const cpuStatSecondLine = this.extractCpuLine( - await this.readProcFile(procPrefix, "stat"), - ); + const cpuStatFirstLine = this.extractCpuLine( + await this.readProcFile(procPrefix, "stat"), + ); + await this.sleep(CPU_SAMPLE_INTERVAL_MS); + const cpuStatSecondLine = this.extractCpuLine( + await this.readProcFile(procPrefix, "stat"), + ); - const [ - meminfo, - dfStdout, - netDev, - uptimeContent, - loadAverageContent, - cpuinfo, - hostnameContent, - ] = await Promise.all([ - this.readProcFile(procPrefix, "meminfo"), - this.collectDfStdout(hostRoot), - this.readProcFile(procPrefix, "net/dev"), - this.readProcFile(procPrefix, "uptime"), - this.readProcFile(procPrefix, "loadavg"), - this.readProcFile(procPrefix, "cpuinfo"), - this.readProcFile(procPrefix, "sys/kernel/hostname"), - ]); + const [ + meminfo, + dfStdout, + netDev, + uptimeContent, + loadAverageContent, + cpuinfo, + hostnameContent, + ] = await Promise.all([ + this.readProcFile(procPrefix, "meminfo"), + this.collectDfStdout(hostRoot), + this.readProcFile(procPrefix, "net/dev"), + this.readProcFile(procPrefix, "uptime"), + this.readProcFile(procPrefix, "loadavg"), + this.readProcFile(procPrefix, "cpuinfo"), + this.readProcFile(procPrefix, "sys/kernel/hostname"), + ]); - const cpuCores = parseCpuCoresFromCpuinfo(cpuinfo) || os.cpus().length; - const hostname = parseHostnameFromProc(hostnameContent) || os.hostname(); + const cpuCores = parseCpuCoresFromCpuinfo(cpuinfo) || os.cpus().length; + const hostname = parseHostnameFromProc(hostnameContent) || os.hostname(); - return buildServerResourcesMetrics({ - cpuStatFirstLine, - cpuStatSecondLine, - loadAverageContent, - cpuCores, - meminfo, - dfStdout, - netDev, - uptimeContent, - hostname, - platform: os.platform(), - architecture: os.arch(), - }); + return buildServerResourcesMetrics({ + cpuStatFirstLine, + cpuStatSecondLine, + loadAverageContent, + cpuCores, + meminfo, + dfStdout, + netDev, + uptimeContent, + hostname, + platform: os.platform(), + architecture: os.arch(), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Get current metrics failed: ${message}`); + throw error; + } } /** @@ -157,18 +169,32 @@ export class ServerResourcesService { prefix: string, relativePath: string, ): Promise { - return readFile(path.join(prefix, relativePath), "utf8"); + try { + return await readFile(path.join(prefix, relativePath), "utf8"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to read proc file ${relativePath}: ${message}`); + throw error; + } } /** * Extracts the CPU line from the content. */ private extractCpuLine(content: string): string { - const cpuLine = content.split("\n").find((line) => line.startsWith("cpu ")); - if (!cpuLine) { - throw new Error("Failed to read CPU stats from /proc/stat"); + try { + const cpuLine = content + .split("\n") + .find((line) => line.startsWith("cpu ")); + if (!cpuLine) { + throw new Error("Failed to read CPU stats from /proc/stat"); + } + return cpuLine; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to extract CPU line: ${message}`); + throw error; } - return cpuLine; } /** diff --git a/apps/agent-app/src/socket-client/agent-public-ip.util.ts b/apps/agent-app/src/socket-client/agent-public-ip.util.ts index c74ba32..7dc094b 100644 --- a/apps/agent-app/src/socket-client/agent-public-ip.util.ts +++ b/apps/agent-app/src/socket-client/agent-public-ip.util.ts @@ -2,7 +2,7 @@ const DETECT_TIMEOUT_MS = 5000; const DETECT_URL = "https://api.ipify.org?format=text"; /** - * Best-effort outbound public IPv4 for matching control-panel `servers.host`. + * Detects the outbound public IPv4 address for matching control-panel `servers.host`. * Returns empty string when detection fails (control panel may still bind local server). */ export async function detectOutboundPublicIp(): Promise { diff --git a/apps/agent-app/src/socket-client/socket-client.service.ts b/apps/agent-app/src/socket-client/socket-client.service.ts index f4309d5..6de9aa3 100644 --- a/apps/agent-app/src/socket-client/socket-client.service.ts +++ b/apps/agent-app/src/socket-client/socket-client.service.ts @@ -39,6 +39,9 @@ import { EncryptionService, TemplatePayloadService, SUCCESS_MESSAGES, + DEFAULT_TERMINAL_COLS, + DEFAULT_TERMINAL_ROWS, + SOCKET_ERROR_MESSAGES, } from "@shared/common"; import * as yaml from "js-yaml"; import * as os from "os"; @@ -412,7 +415,7 @@ export class SocketClientService { // 3. Schema required for legacy deploy path only if (!composeOnly && !schema) { - throw new Error(`Missing deployment schema for template ${name}`); + throw new Error(SOCKET_ERROR_MESSAGES.MISSING_DEPLOYMENT_SCHEMA(name)); } this.logger.log( @@ -470,7 +473,7 @@ export class SocketClientService { ? await this.serverResourcesService.collectResources(requestId) : { requestId: "", - error: "Missing requestId", + error: SOCKET_ERROR_MESSAGES.MISSING_REQUEST_ID, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -503,7 +506,7 @@ export class SocketClientService { let response: DeploymentValidateResponsePayload = { requestId, available: false, - error: "Missing requestId", + error: SOCKET_ERROR_MESSAGES.MISSING_REQUEST_ID, }; try { @@ -511,7 +514,7 @@ export class SocketClientService { response = { requestId, available: false, - error: "Missing requestId, templateSlug, or compose payload", + error: SOCKET_ERROR_MESSAGES.MISSING_REQUEST_ID_TEMPLATE_COMPOSE, }; } else { const decryptedEncodedCompose = this.encryptionService.decrypt( @@ -534,7 +537,7 @@ export class SocketClientService { if (!payload.composeOnly && !payload.schema) { throw new Error( - `Missing deployment schema for template ${templateSlug}`, + SOCKET_ERROR_MESSAGES.MISSING_DEPLOYMENT_SCHEMA(templateSlug), ); } @@ -606,7 +609,7 @@ export class SocketClientService { stdout: "", stderr: "", exitCode: 1, - error: "Missing requestId, containerId, or action", + error: SOCKET_ERROR_MESSAGES.MISSING_REQUEST_ID_CONTAINER_ACTION, }; } else { response = await this.containerService.executeAction( @@ -648,14 +651,17 @@ export class SocketClientService { */ private handleTerminalConnect(payload: TerminalConnectRequestPayload): void { const requestId = payload?.requestId?.trim() ?? ""; - const cols = payload?.cols ?? 80; - const rows = payload?.rows ?? 24; + const cols = payload?.cols ?? DEFAULT_TERMINAL_COLS; + const rows = payload?.rows ?? DEFAULT_TERMINAL_ROWS; let response: TerminalConnectResponsePayload; try { if (!requestId) { - response = { requestId: "", error: "Missing requestId" }; + response = { + requestId: "", + error: SOCKET_ERROR_MESSAGES.MISSING_REQUEST_ID, + }; } else { const sessionId = this.terminalService.createSession(cols, rows); response = { requestId, sessionId }; @@ -668,7 +674,7 @@ export class SocketClientService { if (!this.socket?.connected) { this.logger.warn( - "Cannot send terminal connect result: socket disconnected", + SOCKET_ERROR_MESSAGES.CANNOT_SEND_TERMINAL_CONNECT_RESULT, ); return; } @@ -723,7 +729,7 @@ export class SocketClientService { response = { requestId, sessionId, - error: "Missing requestId, sessionId, or containerId", + error: SOCKET_ERROR_MESSAGES.MISSING_REQUEST_ID_CONTAINER_LOGS_START, }; } else { const startError = await this.containerService.startLogStream( @@ -846,7 +852,7 @@ export class SocketClientService { : { requestId: "", containers: [], - error: "Missing requestId", + error: SOCKET_ERROR_MESSAGES.MISSING_REQUEST_ID, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -913,7 +919,7 @@ export class SocketClientService { response = { requestId: "", success: false, - error: "Missing requestId", + error: SOCKET_ERROR_MESSAGES.MISSING_REQUEST_ID, }; } else { const imageRefs = await this.executor.collectAgentRemovalTargets({ @@ -933,10 +939,16 @@ export class SocketClientService { `Agent remove result sent requestId=${requestId} success=true imageRefs=${imageRefs.join(", ") || "none"}`, ); - void this.executor.runAgentRemovalAfterAck({ - installDir, - imageRefs, - }); + void this.executor + .runAgentRemovalAfterAck({ + installDir, + imageRefs, + }) + .catch((err) => { + this.logger.error( + `Post-ack agent removal failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); return; } } catch (error) { diff --git a/apps/agent-app/src/terminal/terminal.service.ts b/apps/agent-app/src/terminal/terminal.service.ts index 7a3b416..dc0c8ec 100644 --- a/apps/agent-app/src/terminal/terminal.service.ts +++ b/apps/agent-app/src/terminal/terminal.service.ts @@ -5,6 +5,13 @@ import type { IPty } from "node-pty"; import { DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS, + MAX_TERMINAL_COLS, + MAX_TERMINAL_ROWS, + MIN_TERMINAL_COLS, + MIN_TERMINAL_ROWS, + TERMINAL_TERM_TYPE, +} from "@shared/common"; +import { TERMINAL_ENV, TERMINAL_SHELL, } from "../common/constants/terminal.constant"; @@ -29,14 +36,26 @@ export class TerminalService { * Sets the output handler for the terminal service. */ setOutputHandler(handler: TerminalOutputHandler): void { - this.outputHandler = handler; + try { + this.outputHandler = handler; + } catch (error) { + this.logger.error( + `Failed to set terminal output handler: ${error instanceof Error ? error.message : String(error)}`, + ); + } } /** * Sets the close handler for the terminal service. */ setCloseHandler(handler: TerminalCloseHandler): void { - this.closeHandler = handler; + try { + this.closeHandler = handler; + } catch (error) { + this.logger.error( + `Failed to set terminal close handler: ${error instanceof Error ? error.message : String(error)}`, + ); + } } /** @@ -46,33 +65,40 @@ export class TerminalService { cols: number = DEFAULT_TERMINAL_COLS, rows: number = DEFAULT_TERMINAL_ROWS, ): string { - const sessionId = randomUUID(); - const shell = TERMINAL_SHELL; - - const ptyProcess = pty.spawn(shell, [], { - name: "xterm-256color", - cols: this.normalizeCols(cols), - rows: this.normalizeRows(rows), - cwd: process.env.HOME ?? process.cwd(), - env: TERMINAL_ENV, - }); - - ptyProcess.onData((data: string) => { - this.outputHandler?.(sessionId, data); - }); - - ptyProcess.onExit(() => { - this.logger.log(`Terminal session exited sessionId=${sessionId}`); - this.sessions.delete(sessionId); - this.closeHandler?.(sessionId); - }); - - this.sessions.set(sessionId, { sessionId, pty: ptyProcess }); - this.logger.log( - `Terminal session created sessionId=${sessionId} shell=${shell} cols=${cols} rows=${rows}`, - ); + try { + const sessionId = randomUUID(); + const shell = TERMINAL_SHELL; + + const ptyProcess = pty.spawn(shell, [], { + name: TERMINAL_TERM_TYPE, + cols: this.normalizeCols(cols), + rows: this.normalizeRows(rows), + cwd: process.env.HOME ?? process.cwd(), + env: TERMINAL_ENV, + }); + + ptyProcess.onData((data: string) => { + this.outputHandler?.(sessionId, data); + }); + + ptyProcess.onExit(() => { + this.logger.log(`Terminal session exited sessionId=${sessionId}`); + this.sessions.delete(sessionId); + this.closeHandler?.(sessionId); + }); + + this.sessions.set(sessionId, { sessionId, pty: ptyProcess }); + this.logger.log( + `Terminal session created sessionId=${sessionId} shell=${shell} cols=${cols} rows=${rows}`, + ); - return sessionId; + return sessionId; + } catch (error) { + this.logger.error( + `Failed to create terminal session: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** @@ -87,7 +113,13 @@ export class TerminalService { return; } - session.pty.write(data); + try { + session.pty.write(data); + } catch (error) { + this.logger.warn( + `Write failed sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } /** @@ -133,20 +165,27 @@ export class TerminalService { * Checks if a terminal session exists. */ hasSession(sessionId: string): boolean { - return this.sessions.has(sessionId); + try { + return this.sessions.has(sessionId); + } catch (error) { + this.logger.error( + `Failed to check terminal session sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } } private normalizeCols(cols: number): number { return Math.min( - 500, - Math.max(10, Math.floor(cols) || DEFAULT_TERMINAL_COLS), + MAX_TERMINAL_COLS, + Math.max(MIN_TERMINAL_COLS, Math.floor(cols) || DEFAULT_TERMINAL_COLS), ); } private normalizeRows(rows: number): number { return Math.min( - 200, - Math.max(5, Math.floor(rows) || DEFAULT_TERMINAL_ROWS), + MAX_TERMINAL_ROWS, + Math.max(MIN_TERMINAL_ROWS, Math.floor(rows) || DEFAULT_TERMINAL_ROWS), ); } } diff --git a/apps/control-panel-app/src/app.controller.ts b/apps/control-panel-app/src/app.controller.ts index a4ee65c..3bb0036 100644 --- a/apps/control-panel-app/src/app.controller.ts +++ b/apps/control-panel-app/src/app.controller.ts @@ -1,15 +1,23 @@ -import { Controller, Get } from "@nestjs/common"; +import { Controller, Get, Logger } from "@nestjs/common"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; @Controller("health") export class AppController { + private readonly logger = new Logger(AppController.name); + /** * Liveness probe for the control panel API (GET /api/health). */ @Get() getHealth(): { status: string; service: string } { - return { - status: "ok", - service: "control-panel-app", - }; + try { + return { + status: "ok", + service: "control-panel-app", + }; + } catch (error) { + this.logger.error(`Health check failed: ${toErrorMessage(error)}`); + throw error; + } } } diff --git a/apps/control-panel-app/src/app.module.ts b/apps/control-panel-app/src/app.module.ts index e1de046..b38f979 100644 --- a/apps/control-panel-app/src/app.module.ts +++ b/apps/control-panel-app/src/app.module.ts @@ -12,7 +12,6 @@ import { SshModule } from "@shared/ssh"; import { DeploymentsModule } from "./modules/deployments/deployments.module"; import { AuthModule } from "./modules/auth/auth.module"; import { UsersModule } from "./modules/users/users.module"; -import { OrganizationsModule } from "./modules/organizations/organizations.module"; import { ProfileModule } from "./modules/profile/profile.module"; import { McpApiKeysModule } from "./modules/mcp-api-keys/mcp-api-keys.module"; import { McpServerModule } from "./modules/mcp-server/mcp-server.module"; @@ -63,7 +62,6 @@ import { isProductionEnv } from "@control-panel/constants/env.constant"; WebsocketModule, AuthModule, UsersModule, - OrganizationsModule, ProfileModule, McpApiKeysModule, McpServerModule, diff --git a/apps/control-panel-app/src/common/interfaces/api-response.interface.ts b/apps/control-panel-app/src/common/interfaces/api-response.interface.ts deleted file mode 100644 index c7cd4ca..0000000 --- a/apps/control-panel-app/src/common/interfaces/api-response.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface ApiResponse { - success: boolean; - message?: string; - data?: T; -} diff --git a/apps/control-panel-app/src/common/utils/error.util.ts b/apps/control-panel-app/src/common/utils/error.util.ts new file mode 100644 index 0000000..e554dff --- /dev/null +++ b/apps/control-panel-app/src/common/utils/error.util.ts @@ -0,0 +1,6 @@ +/** + * Normalizes an unknown thrown value into a log-safe error message. + */ +export function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/control-panel-app/src/constants/error.ts b/apps/control-panel-app/src/constants/error.ts index 6f6cdcb..683a74c 100644 --- a/apps/control-panel-app/src/constants/error.ts +++ b/apps/control-panel-app/src/constants/error.ts @@ -33,6 +33,10 @@ export const ERROR_MESSAGES = { SESSION_NOT_FOUND: "Terminal session not found", CONNECT_FAILED: "Failed to create terminal session", DISCONNECT_FAILED: "Failed to disconnect terminal session", + SSH_LOCAL_UNAVAILABLE: + "SSH terminal fallback is not available for local servers without an agent", + SSH_SHELL_FAILED: "SSH shell failed", + UNKNOWN_ERROR: "unknown error", }, CONTAINER_LOGS: { diff --git a/apps/control-panel-app/src/modules/auth/auth.controller.ts b/apps/control-panel-app/src/modules/auth/auth.controller.ts index 773667d..a65368c 100644 --- a/apps/control-panel-app/src/modules/auth/auth.controller.ts +++ b/apps/control-panel-app/src/modules/auth/auth.controller.ts @@ -2,12 +2,14 @@ import { Body, Controller, Get, + Logger, Post, Req, Res, UseGuards, } from "@nestjs/common"; import { Response } from "express"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { AuthService } from "./auth.service"; import { SignupDto } from "./dto/signup.dto"; import { LoginDto } from "./dto/login.dto"; @@ -21,6 +23,8 @@ import { RefreshTokenPayload } from "./strategies/refresh-jwt.strategy"; @Controller("auth") export class AuthController { + private readonly logger = new Logger(AuthController.name); + constructor( private readonly authService: AuthService, private readonly authCookieService: AuthCookieService, @@ -31,7 +35,12 @@ export class AuthController { */ @Post("signup") async signup(@Body() signupDto: SignupDto) { - return await this.authService.signup(signupDto); + try { + return await this.authService.signup(signupDto); + } catch (error) { + this.logger.error(`Signup failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -42,15 +51,20 @@ export class AuthController { @Body() loginDto: LoginDto, @Res({ passthrough: true }) res: Response, ) { - const result = await this.authService.login(loginDto); - this.authCookieService.setAuthCookies(res, result.data.tokens); + try { + const result = await this.authService.login(loginDto); + this.authCookieService.setAuthCookies(res, result.data.tokens); - return { - message: result.message, - data: { - user: result.data.user, - }, - }; + return { + message: result.message, + data: { + user: result.data.user, + }, + }; + } catch (error) { + this.logger.error(`Login failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -62,13 +76,18 @@ export class AuthController { @Req() req: { user: RefreshTokenPayload }, @Res({ passthrough: true }) res: Response, ) { - const result = await this.authService.refreshToken(req.user); - this.authCookieService.setAuthCookies(res, result.data.tokens); + try { + const result = await this.authService.refreshToken(req.user); + this.authCookieService.setAuthCookies(res, result.data.tokens); - return { - message: result.message, - data: null, - }; + return { + message: result.message, + data: null, + }; + } catch (error) { + this.logger.error(`Refresh token failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -76,8 +95,13 @@ export class AuthController { */ @UseGuards(AccessTokenGuard) @Get("me") - me(@Req() req: { user: AuthenticatedUser }) { - return this.authService.getProfile(req.user.id); + async me(@Req() req: { user: AuthenticatedUser }) { + try { + return await this.authService.getProfile(req.user.id); + } catch (error) { + this.logger.error(`Get profile failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -89,13 +113,18 @@ export class AuthController { @Req() req: { user: AuthenticatedUser }, @Res({ passthrough: true }) res: Response, ) { - const result = await this.authService.logout( - req.user.id, - req.user.accessToken, - ); - this.authCookieService.clearAuthCookies(res); + try { + const result = await this.authService.logout( + req.user.id, + req.user.accessToken, + ); + this.authCookieService.clearAuthCookies(res); - return result; + return result; + } catch (error) { + this.logger.error(`Logout failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -107,10 +136,15 @@ export class AuthController { @Req() req: { user: AuthenticatedUser }, @Res({ passthrough: true }) res: Response, ) { - const result = await this.authService.logoutAllDevices(req.user.id); - this.authCookieService.clearAuthCookies(res); + try { + const result = await this.authService.logoutAllDevices(req.user.id); + this.authCookieService.clearAuthCookies(res); - return result; + return result; + } catch (error) { + this.logger.error(`Logout all failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -118,7 +152,12 @@ export class AuthController { */ @Post("forgot-password") async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) { - return this.authService.forgotPassword(forgotPasswordDto); + try { + return await this.authService.forgotPassword(forgotPasswordDto); + } catch (error) { + this.logger.error(`Forgot password failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -126,7 +165,12 @@ export class AuthController { */ @Post("verify-otp") async verifyOtp(@Body() verifyOtpDto: VerifyOtpDto) { - return this.authService.verifyOtp(verifyOtpDto); + try { + return await this.authService.verifyOtp(verifyOtpDto); + } catch (error) { + this.logger.error(`Verify OTP failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -134,6 +178,11 @@ export class AuthController { */ @Post("reset-password") async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) { - return this.authService.resetPassword(resetPasswordDto); + try { + return await this.authService.resetPassword(resetPasswordDto); + } catch (error) { + this.logger.error(`Reset password failed: ${toErrorMessage(error)}`); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/auth/auth.service.ts b/apps/control-panel-app/src/modules/auth/auth.service.ts index d8a9b88..ae36248 100644 --- a/apps/control-panel-app/src/modules/auth/auth.service.ts +++ b/apps/control-panel-app/src/modules/auth/auth.service.ts @@ -1,9 +1,11 @@ import { ConflictException, Injectable, + Logger, UnauthorizedException, NotFoundException, } from "@nestjs/common"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { InjectRepository } from "@nestjs/typeorm"; import { DataSource, IsNull, Repository } from "typeorm"; import { JwtService } from "@nestjs/jwt"; @@ -39,6 +41,8 @@ export interface AuthTokens { @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + constructor( @InjectRepository(UserEntity) private readonly userRepository: Repository, @@ -165,7 +169,7 @@ export class AuthService { const savedOrganization = await organizationRepository.save(organization); - const passwordHash = await bcrypt.hash(signupDto.password, 10); + const passwordHash = await bcrypt.hash(signupDto.password, SALT_ROUNDS); const userRepository = queryRunner.manager.getRepository(UserEntity); @@ -194,6 +198,7 @@ export class AuthService { }; } catch (error) { await queryRunner.rollbackTransaction(); + this.logger.error(`Signup failed: ${toErrorMessage(error)}`); throw error; } finally { await queryRunner.release(); @@ -215,52 +220,61 @@ export class AuthService { tokens: AuthTokens; }; }> { - const emailNormalized = loginDto.email.toLowerCase().trim(); + try { + const emailNormalized = loginDto.email.toLowerCase().trim(); - const user = await this.userRepository.findOne({ - where: { email: emailNormalized }, - relations: { organization: true }, - }); + const user = await this.userRepository.findOne({ + where: { email: emailNormalized }, + relations: { organization: true }, + }); - if (!user) { - throw new UnauthorizedException(ERROR_MESSAGES.AUTH.INVALID_CREDENTIALS); - } + if (!user) { + throw new UnauthorizedException( + ERROR_MESSAGES.AUTH.INVALID_CREDENTIALS, + ); + } - const isPasswordValid = await bcrypt.compare( - loginDto.password, - user.passwordHash, - ); - if (!isPasswordValid) { - throw new UnauthorizedException(ERROR_MESSAGES.AUTH.INVALID_CREDENTIALS); - } + const isPasswordValid = await bcrypt.compare( + loginDto.password, + user.passwordHash, + ); + if (!isPasswordValid) { + throw new UnauthorizedException( + ERROR_MESSAGES.AUTH.INVALID_CREDENTIALS, + ); + } - user.lastLoginAt = dayjs().valueOf(); - await this.userRepository.save(user); - - const tokens = await this.generateTokens(user); - const session = this.authSessionRepository.create({ - userId: user.id, - tokenType: "jwt", - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresAt: this.getRefreshExpiresAt(), - status: EntityStatus.ACTIVE, - }); + user.lastLoginAt = dayjs().valueOf(); + await this.userRepository.save(user); - await this.persistSessionTokens(session, tokens); + const tokens = await this.generateTokens(user); + const session = this.authSessionRepository.create({ + userId: user.id, + tokenType: "jwt", + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: this.getRefreshExpiresAt(), + status: EntityStatus.ACTIVE, + }); - return { - message: SUCCESS_MESSAGES.AUTH.LOGIN, - data: { - user: { - id: user.id, - name: user.name, - email: user.email, - organizationId: user.organizationId, + await this.persistSessionTokens(session, tokens); + + return { + message: SUCCESS_MESSAGES.AUTH.LOGIN, + data: { + user: { + id: user.id, + name: user.name, + email: user.email, + organizationId: user.organizationId, + }, + tokens, }, - tokens, - }, - }; + }; + } catch (error) { + this.logger.error(`Login failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -270,283 +284,315 @@ export class AuthService { userId: string; refreshToken: string; }): Promise<{ message: string; data: { tokens: AuthTokens } }> { - const { userId, refreshToken } = input; - - if (!isJwtToken(refreshToken)) { - throw new UnauthorizedException( - ERROR_MESSAGES.AUTH.INVALID_REFRESH_TOKEN, - ); - } - - const authSession = - await this.authSessionLookupService.findSessionByRefreshToken( - userId, - refreshToken, - ); - - if (!authSession) { - await this.revokeAllUserSessions(userId); - throw new UnauthorizedException( - ERROR_MESSAGES.AUTH.INVALID_REFRESH_TOKEN, - ); - } + try { + const { userId, refreshToken } = input; + + if (!isJwtToken(refreshToken)) { + throw new UnauthorizedException( + ERROR_MESSAGES.AUTH.INVALID_REFRESH_TOKEN, + ); + } + + const authSession = + await this.authSessionLookupService.findSessionByRefreshToken( + userId, + refreshToken, + ); + + if (!authSession) { + await this.revokeAllUserSessions(userId); + throw new UnauthorizedException( + ERROR_MESSAGES.AUTH.INVALID_REFRESH_TOKEN, + ); + } + + if (authSession.status !== EntityStatus.ACTIVE) { + await this.revokeAllUserSessions(userId); + throw new UnauthorizedException( + ERROR_MESSAGES.AUTH.INVALID_REFRESH_TOKEN, + ); + } + + if (Number(authSession.expiresAt) <= dayjs().unix()) { + authSession.status = EntityStatus.INACTIVE; + await this.authSessionRepository.save(authSession); + + throw new UnauthorizedException(ERROR_MESSAGES.AUTH.SESSION_EXPIRED); + } + + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); - if (authSession.status !== EntityStatus.ACTIVE) { - await this.revokeAllUserSessions(userId); - throw new UnauthorizedException( - ERROR_MESSAGES.AUTH.INVALID_REFRESH_TOKEN, - ); - } + if (!user || user.status !== EntityStatus.ACTIVE) { + throw new UnauthorizedException(ERROR_MESSAGES.AUTH.UNAUTHORIZED); + } - if (Number(authSession.expiresAt) <= dayjs().unix()) { - authSession.status = EntityStatus.INACTIVE; - await this.authSessionRepository.save(authSession); + const tokens = await this.generateTokens(user); - throw new UnauthorizedException(ERROR_MESSAGES.AUTH.SESSION_EXPIRED); - } + authSession.metadata = { + ...(authSession.metadata || {}), + refreshedAt: dayjs().unix(), + }; - const user = await this.userRepository.findOne({ - where: { id: userId }, - }); + await this.persistSessionTokens(authSession, tokens); - if (!user || user.status !== EntityStatus.ACTIVE) { - throw new UnauthorizedException(ERROR_MESSAGES.AUTH.UNAUTHORIZED); + return { + message: SUCCESS_MESSAGES.AUTH.REFRESH, + data: { tokens }, + }; + } catch (error) { + this.logger.error(`Refresh token failed: ${toErrorMessage(error)}`); + throw error; } - - const tokens = await this.generateTokens(user); - - authSession.metadata = { - ...(authSession.metadata || {}), - refreshedAt: dayjs().unix(), - }; - - await this.persistSessionTokens(authSession, tokens); - - return { - message: SUCCESS_MESSAGES.AUTH.REFRESH, - data: { tokens }, - }; } /** * Logout a user */ async logout(userId: string, accessToken?: string) { - if (!accessToken) { - throw new UnauthorizedException(ERROR_MESSAGES.AUTH.UNAUTHORIZED); - } + try { + if (!accessToken) { + throw new UnauthorizedException(ERROR_MESSAGES.AUTH.UNAUTHORIZED); + } - const authSession = - await this.authSessionLookupService.findActiveSessionByAccessToken( - userId, - accessToken, - ); + const authSession = + await this.authSessionLookupService.findActiveSessionByAccessToken( + userId, + accessToken, + ); - if (!authSession) { - throw new UnauthorizedException(ERROR_MESSAGES.AUTH.UNAUTHORIZED); - } + if (!authSession) { + throw new UnauthorizedException(ERROR_MESSAGES.AUTH.UNAUTHORIZED); + } - authSession.status = EntityStatus.INACTIVE; - await this.authSessionRepository.save(authSession); + authSession.status = EntityStatus.INACTIVE; + await this.authSessionRepository.save(authSession); - return { - message: SUCCESS_MESSAGES.AUTH.LOGOUT, - data: null, - }; + return { + message: SUCCESS_MESSAGES.AUTH.LOGOUT, + data: null, + }; + } catch (error) { + this.logger.error(`Logout failed: ${toErrorMessage(error)}`); + throw error; + } } async logoutAllDevices(userId: string) { - await this.revokeAllUserSessions(userId); + try { + await this.revokeAllUserSessions(userId); - return { - message: SUCCESS_MESSAGES.AUTH.LOGOUT_ALL, - data: null, - }; + return { + message: SUCCESS_MESSAGES.AUTH.LOGOUT_ALL, + data: null, + }; + } catch (error) { + this.logger.error(`Logout all devices failed: ${toErrorMessage(error)}`); + throw error; + } } /** * Get the profile of the authenticated user */ async getProfile(userId: string) { - const user = await this.userRepository.findOne({ - where: { id: userId }, - relations: { organization: true }, - select: { - id: true, - name: true, - email: true, - organizationId: true, - profilePictureUrl: true, - dateOfBirth: true, - organization: { + try { + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: { organization: true }, + select: { id: true, name: true, - logo: true, + email: true, + organizationId: true, + profilePictureUrl: true, + dateOfBirth: true, + organization: { + id: true, + name: true, + logo: true, + }, }, - }, - }); + }); - if (!user) { - throw new NotFoundException(ERROR_MESSAGES.AUTH.USER_NOT_FOUND); - } + if (!user) { + throw new NotFoundException(ERROR_MESSAGES.AUTH.USER_NOT_FOUND); + } - return { - message: SUCCESS_MESSAGES.AUTH.PROFILE, - data: user, - }; + return { + message: SUCCESS_MESSAGES.AUTH.PROFILE, + data: user, + }; + } catch (error) { + this.logger.error(`Get profile failed: ${toErrorMessage(error)}`); + throw error; + } } /** * Forgot password */ async forgotPassword(forgotPasswordDto: ForgotPasswordDto) { - const email = forgotPasswordDto.email.toLowerCase().trim(); - - const user = await this.userRepository.findOne({ - where: { email }, - }); + try { + const email = forgotPasswordDto.email.toLowerCase().trim(); - if (!user) { - return { - message: SUCCESS_MESSAGES.AUTH.OTP_SENT, - }; - } + const user = await this.userRepository.findOne({ + where: { email }, + }); - await this.userCodeRepository.update( - { - userId: user.id, - codeType: CODE_TYPE.FORGOT_PASSWORD, - verifiedAt: IsNull(), - }, - { - status: EntityStatus.INACTIVE, - }, - ); + if (!user) { + return { + message: SUCCESS_MESSAGES.AUTH.OTP_SENT, + }; + } + + await this.userCodeRepository.update( + { + userId: user.id, + codeType: CODE_TYPE.FORGOT_PASSWORD, + verifiedAt: IsNull(), + }, + { + status: EntityStatus.INACTIVE, + }, + ); - const otp = GenerateOTP(); + const otp = GenerateOTP(); - const otpHash = await bcrypt.hash(otp, 10); + const otpHash = await bcrypt.hash(otp, 10); - await this.userCodeRepository.save( - this.userCodeRepository.create({ - userId: user.id, - codeType: CODE_TYPE.FORGOT_PASSWORD, - otpHash, - expiresAt: dayjs().add(10, "minute").unix(), - attempts: 0, - }), - ); + await this.userCodeRepository.save( + this.userCodeRepository.create({ + userId: user.id, + codeType: CODE_TYPE.FORGOT_PASSWORD, + otpHash, + expiresAt: dayjs().add(10, "minute").unix(), + attempts: 0, + }), + ); - return { - message: SUCCESS_MESSAGES.AUTH.OTP_SENT, - data: { - otp, - }, - }; + return { + message: SUCCESS_MESSAGES.AUTH.OTP_SENT, + }; + } catch (error) { + this.logger.error(`Forgot password failed: ${toErrorMessage(error)}`); + throw error; + } } /** * Verify OTP */ async verifyOtp(verifyOtpDto: VerifyOtpDto) { - const email = verifyOtpDto.email.toLowerCase().trim(); + try { + const email = verifyOtpDto.email.toLowerCase().trim(); - const user = await this.userRepository.findOne({ - where: { email }, - }); + const user = await this.userRepository.findOne({ + where: { email }, + }); - if (!user) { - throw new UnauthorizedException(ERROR_MESSAGES.AUTH.INVALID_OTP); - } + if (!user) { + throw new UnauthorizedException(ERROR_MESSAGES.AUTH.INVALID_OTP); + } - const otpRecord = await this.userCodeRepository.findOne({ - where: { - userId: user.id, - codeType: CODE_TYPE.FORGOT_PASSWORD, - status: EntityStatus.ACTIVE, - }, - order: { - createdAt: "DESC", - }, - }); + const otpRecord = await this.userCodeRepository.findOne({ + where: { + userId: user.id, + codeType: CODE_TYPE.FORGOT_PASSWORD, + status: EntityStatus.ACTIVE, + }, + order: { + createdAt: "DESC", + }, + }); - if (!otpRecord) { - throw new UnauthorizedException(ERROR_MESSAGES.AUTH.INVALID_OTP); - } + if (!otpRecord) { + throw new UnauthorizedException(ERROR_MESSAGES.AUTH.INVALID_OTP); + } - if (Number(otpRecord.expiresAt) < dayjs().unix()) { - throw new UnauthorizedException(ERROR_MESSAGES.AUTH.OTP_EXPIRED); - } + if (Number(otpRecord.expiresAt) < dayjs().unix()) { + throw new UnauthorizedException(ERROR_MESSAGES.AUTH.OTP_EXPIRED); + } - if (otpRecord.attempts >= 3) { - throw new UnauthorizedException(ERROR_MESSAGES.AUTH.MAX_OTP_ATTEMPTS); - } + if (otpRecord.attempts >= 3) { + throw new UnauthorizedException(ERROR_MESSAGES.AUTH.MAX_OTP_ATTEMPTS); + } - const isValid = await bcrypt.compare(verifyOtpDto.otp, otpRecord.otpHash); + const isValid = await bcrypt.compare(verifyOtpDto.otp, otpRecord.otpHash); - if (!isValid) { - otpRecord.attempts += 1; + if (!isValid) { + otpRecord.attempts += 1; - await this.userCodeRepository.save(otpRecord); + await this.userCodeRepository.save(otpRecord); - throw new UnauthorizedException(ERROR_MESSAGES.AUTH.INVALID_OTP); - } + throw new UnauthorizedException(ERROR_MESSAGES.AUTH.INVALID_OTP); + } - otpRecord.verifiedAt = dayjs().unix(); + otpRecord.verifiedAt = dayjs().unix(); - await this.userCodeRepository.save(otpRecord); + await this.userCodeRepository.save(otpRecord); - return { - message: SUCCESS_MESSAGES.AUTH.OTP_VERIFIED, - }; + return { + message: SUCCESS_MESSAGES.AUTH.OTP_VERIFIED, + }; + } catch (error) { + this.logger.error(`Verify OTP failed: ${toErrorMessage(error)}`); + throw error; + } } /** * Reset password */ async resetPassword(resetPasswordDto: ResetPasswordDto) { - const email = resetPasswordDto.email.toLowerCase().trim(); + try { + const email = resetPasswordDto.email.toLowerCase().trim(); - const user = await this.userRepository.findOne({ - where: { email }, - }); + const user = await this.userRepository.findOne({ + where: { email }, + }); - if (!user) { - throw new UnauthorizedException(ERROR_MESSAGES.AUTH.USER_NOT_FOUND); - } + if (!user) { + throw new UnauthorizedException(ERROR_MESSAGES.AUTH.USER_NOT_FOUND); + } - const otpRecord = await this.userCodeRepository.findOne({ - where: { - userId: user.id, - codeType: CODE_TYPE.FORGOT_PASSWORD, - status: EntityStatus.ACTIVE, - }, - order: { - createdAt: "DESC", - }, - }); + const otpRecord = await this.userCodeRepository.findOne({ + where: { + userId: user.id, + codeType: CODE_TYPE.FORGOT_PASSWORD, + status: EntityStatus.ACTIVE, + }, + order: { + createdAt: "DESC", + }, + }); - if (!otpRecord?.verifiedAt) { - throw new UnauthorizedException(ERROR_MESSAGES.AUTH.OTP_NOT_VERIFIED); - } + if (!otpRecord?.verifiedAt) { + throw new UnauthorizedException(ERROR_MESSAGES.AUTH.OTP_NOT_VERIFIED); + } - user.passwordHash = await bcrypt.hash( - resetPasswordDto.newPassword, - SALT_ROUNDS, - ); + user.passwordHash = await bcrypt.hash( + resetPasswordDto.newPassword, + SALT_ROUNDS, + ); - user.lastPasswordResetAt = dayjs().unix(); + user.lastPasswordResetAt = dayjs().unix(); - await this.userRepository.save(user); + await this.userRepository.save(user); - otpRecord.status = EntityStatus.INACTIVE; + otpRecord.status = EntityStatus.INACTIVE; - await this.userCodeRepository.save(otpRecord); + await this.userCodeRepository.save(otpRecord); - await this.revokeAllUserSessions(user.id); + await this.revokeAllUserSessions(user.id); - return { - message: SUCCESS_MESSAGES.AUTH.PASSWORD_RESET, - }; + return { + message: SUCCESS_MESSAGES.AUTH.PASSWORD_RESET, + }; + } catch (error) { + this.logger.error(`Reset password failed: ${toErrorMessage(error)}`); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/auth/services/auth-cookie.service.ts b/apps/control-panel-app/src/modules/auth/services/auth-cookie.service.ts index 1391ecd..aee24d3 100644 --- a/apps/control-panel-app/src/modules/auth/services/auth-cookie.service.ts +++ b/apps/control-panel-app/src/modules/auth/services/auth-cookie.service.ts @@ -1,7 +1,12 @@ -import { Injectable, InternalServerErrorException } from "@nestjs/common"; +import { + Injectable, + InternalServerErrorException, + Logger, +} from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { CookieOptions, Response } from "express"; import ms, { StringValue } from "ms"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { isJwtToken } from "../utils/cookie-extractor.util"; /** Canonical path for all auth cookies — must match on set and clear. */ @@ -14,6 +19,8 @@ export interface AuthCookieTokens { @Injectable() export class AuthCookieService { + private readonly logger = new Logger(AuthCookieService.name); + constructor(private readonly configService: ConfigService) {} getAccessTokenCookieName(): string { @@ -28,29 +35,39 @@ export class AuthCookieService { * Set the authentication cookies */ setAuthCookies(res: Response, tokens: AuthCookieTokens): void { - this.assertJwtTokens(tokens); - - res.cookie( - this.getAccessTokenCookieName(), - tokens.accessToken, - this.buildOptions(this.getAccessTokenMaxAgeMs()), - ); - - res.cookie( - this.getRefreshTokenCookieName(), - tokens.refreshToken, - this.buildOptions(this.getRefreshTokenMaxAgeMs()), - ); + try { + this.assertJwtTokens(tokens); + + res.cookie( + this.getAccessTokenCookieName(), + tokens.accessToken, + this.buildOptions(this.getAccessTokenMaxAgeMs()), + ); + + res.cookie( + this.getRefreshTokenCookieName(), + tokens.refreshToken, + this.buildOptions(this.getRefreshTokenMaxAgeMs()), + ); + } catch (error) { + this.logger.error(`Set auth cookies failed: ${toErrorMessage(error)}`); + throw error; + } } /** * Clear the authentication cookies */ clearAuthCookies(res: Response): void { - const clearOptions = this.buildOptions(0); - - res.clearCookie(this.getAccessTokenCookieName(), clearOptions); - res.clearCookie(this.getRefreshTokenCookieName(), clearOptions); + try { + const clearOptions = this.buildOptions(0); + + res.clearCookie(this.getAccessTokenCookieName(), clearOptions); + res.clearCookie(this.getRefreshTokenCookieName(), clearOptions); + } catch (error) { + this.logger.error(`Clear auth cookies failed: ${toErrorMessage(error)}`); + throw error; + } } private assertJwtTokens(tokens: AuthCookieTokens): void { diff --git a/apps/control-panel-app/src/modules/auth/services/auth-session-lookup.service.ts b/apps/control-panel-app/src/modules/auth/services/auth-session-lookup.service.ts index 6908780..d228a6a 100644 --- a/apps/control-panel-app/src/modules/auth/services/auth-session-lookup.service.ts +++ b/apps/control-panel-app/src/modules/auth/services/auth-session-lookup.service.ts @@ -1,12 +1,15 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { EntityStatus } from "@control-panel/common/entity/base.entity"; import { AuthSessionsEntity } from "../entities/auth-sessions.entity"; import { verifyTokenHash } from "../utils/token-hash.util"; @Injectable() export class AuthSessionLookupService { + private readonly logger = new Logger(AuthSessionLookupService.name); + constructor( @InjectRepository(AuthSessionsEntity) private readonly authSessionRepository: Repository, @@ -19,22 +22,29 @@ export class AuthSessionLookupService { userId: string, accessToken: string, ): Promise { - const sessions = await this.authSessionRepository.find({ - where: { - userId, - status: EntityStatus.ACTIVE, - }, - }); - - const session = sessions.find((session) => - verifyTokenHash(accessToken, session.accessToken), - ); - - if (!session) { - return null; - } + try { + const sessions = await this.authSessionRepository.find({ + where: { + userId, + status: EntityStatus.ACTIVE, + }, + }); + + const session = sessions.find((session) => + verifyTokenHash(accessToken, session.accessToken), + ); - return session; + if (!session) { + return null; + } + + return session; + } catch (error) { + this.logger.error( + `Find active session by access token failed: ${toErrorMessage(error)}`, + ); + throw error; + } } /** @@ -44,20 +54,27 @@ export class AuthSessionLookupService { userId: string, refreshToken: string, ): Promise { - const sessions = await this.authSessionRepository.find({ - where: { - userId, - }, - }); - - const session = sessions.find((session) => - verifyTokenHash(refreshToken, session.refreshToken), - ); - - if (!session) { - return null; - } + try { + const sessions = await this.authSessionRepository.find({ + where: { + userId, + }, + }); + + const session = sessions.find((session) => + verifyTokenHash(refreshToken, session.refreshToken), + ); - return session; + if (!session) { + return null; + } + + return session; + } catch (error) { + this.logger.error( + `Find session by refresh token failed: ${toErrorMessage(error)}`, + ); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/deploy/controllers/deploy.controller.ts b/apps/control-panel-app/src/modules/deploy/controllers/deploy.controller.ts deleted file mode 100644 index 71fa5dc..0000000 --- a/apps/control-panel-app/src/modules/deploy/controllers/deploy.controller.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - Controller, - Post, - Body, - HttpCode, - Logger, - UsePipes, - ValidationPipe, - BadRequestException, -} from "@nestjs/common"; - -import { ServiceTemplateService } from "../../service-template/services/service-template.service"; -import { DeploymentGateway } from "../../../websocket/websocket.gateway"; -import { SocketDeployMessage } from "@shared/socket-events"; -import { - EncryptionService, - normalizeDeployRequestVariables, - TemplateConfigService, -} from "@shared/common"; -import { DeployTemplateDto } from "../dto/deploy-template.dto"; -import { TemplateSchema, SchemaFieldDetails } from "@shared/socket-events"; -@Controller("deploy") -export class DeployController { - private readonly logger = new Logger(DeployController.name); - - constructor( - private readonly serviceTemplateService: ServiceTemplateService, - private readonly deploymentGateway: DeploymentGateway, - private readonly encryptionService: EncryptionService, - private readonly templateConfigService: TemplateConfigService, - ) {} - - @Post() - @HttpCode(202) - @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) - async deploy(@Body() body: DeployTemplateDto): Promise<{ - message: string; - template: string; - deploymentId: string; - serverId: string; - }> { - const { templateSlug } = body; - const { env: requestEnv, ports: requestPorts } = - normalizeDeployRequestVariables(body.env ?? {}, body.ports ?? {}); - - this.logger.log(`Received deployment request for '${templateSlug}'`); - - const tplEntity = - await this.serviceTemplateService.getTemplateEntity(templateSlug); - const encodedCompose = tplEntity.compose; - - if (!encodedCompose) { - throw new BadRequestException("Template has no compose content"); - } - - const schema: TemplateSchema = { - env_schema: tplEntity.envSchema as Record, - port_schema: tplEntity.portSchema as Record, - }; - - const normalized = this.templateConfigService.normalizeSchema(schema); - const { env: mergedEnv, ports: mergedPorts } = - this.templateConfigService.mergeAndValidate( - { ...schema, normalized }, - { env: requestEnv, ports: requestPorts }, - ); - - const deploymentId = `deployment-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; - - const encryptedCompose = this.encryptionService.encrypt(encodedCompose); - const encryptedEnv = this.encryptionService.encrypt( - JSON.stringify(mergedEnv), - ); - const encryptedPorts = this.encryptionService.encrypt( - JSON.stringify(mergedPorts), - ); - - const message: SocketDeployMessage = { - type: "DEPLOY", - payload: { - name: templateSlug, - compose: encryptedCompose, - env: encryptedEnv, - ports: encryptedPorts, - deploymentId, - schema: { ...schema, normalized }, - }, - }; - - try { - if (!body.serverId) { - throw new BadRequestException( - "serverId is required. Use POST /deploy/compose with deployOnLocal for local deploy.", - ); - } - - this.deploymentGateway.emitDeploy(message, body.serverId); - - return { - message: "Deployment initiated", - template: templateSlug, - deploymentId, - serverId: body.serverId, - }; - } catch (error) { - this.logger.error( - `Legacy deploy emit failed: ${error instanceof Error ? error.message : String(error)}`, - ); - throw error; - } - } -} diff --git a/apps/control-panel-app/src/modules/deploy/deploy.module.ts b/apps/control-panel-app/src/modules/deploy/deploy.module.ts deleted file mode 100644 index 41549b7..0000000 --- a/apps/control-panel-app/src/modules/deploy/deploy.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from "@nestjs/common"; -import { DeployController } from "./controllers/deploy.controller"; -import { ServiceTemplateModule } from "../service-template/service-template.module"; -import { WebsocketModule } from "../../websocket/websocket.module"; -import { ServerConnectionsModule } from "../server-connections/server-connections.module"; -import { EncryptionModule, TemplateConfigModule } from "@shared/common"; - -@Module({ - imports: [ - ServiceTemplateModule, - ServerConnectionsModule, - WebsocketModule, - EncryptionModule, - TemplateConfigModule, - ], - controllers: [DeployController], - providers: [], - exports: [], -}) -export class DeployModule {} diff --git a/apps/control-panel-app/src/modules/deploy/dto/deploy-template.dto.ts b/apps/control-panel-app/src/modules/deploy/dto/deploy-template.dto.ts deleted file mode 100644 index b10638d..0000000 --- a/apps/control-panel-app/src/modules/deploy/dto/deploy-template.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IsString, IsOptional, IsObject, IsUUID } from "class-validator"; - -export class DeployTemplateDto { - @IsString() - templateSlug!: string; - - /** Target server (from `servers` table). When omitted, the local machine server is used. */ - @IsOptional() - @IsUUID() - serverId?: string; - - @IsOptional() - @IsObject() - env?: Record; - - @IsOptional() - @IsObject() - ports?: Record; -} diff --git a/apps/control-panel-app/src/modules/deploy/index.ts b/apps/control-panel-app/src/modules/deploy/index.ts deleted file mode 100644 index d5d12d0..0000000 --- a/apps/control-panel-app/src/modules/deploy/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./deploy.module"; -export * from "./controllers/deploy.controller"; -export * from "./dto/deploy-template.dto"; diff --git a/apps/control-panel-app/src/modules/deployments/deployments.controller.ts b/apps/control-panel-app/src/modules/deployments/deployments.controller.ts index 2d051bf..bf1660d 100644 --- a/apps/control-panel-app/src/modules/deployments/deployments.controller.ts +++ b/apps/control-panel-app/src/modules/deployments/deployments.controller.ts @@ -270,69 +270,77 @@ export class DeploymentsController { @Req() req: { user: UserEntity }, @Param("serverId") serverId: string, ) { - const containers = await this.deploymentsService.listServerContainers( - serverId, - req.user.id, - ); - return { containers }; + try { + const containers = await this.deploymentsService.listServerContainers( + serverId, + req.user.id, + ); + return { containers }; + } catch (error) { + this.logger.error( + `List server containers failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** - * Stop a container on a server (agent-first, host fallback). + * Stop an active container log stream. + * Must be registered before :containerId/stop so "logs" is not captured as a container id. */ - @Post(":serverId/containers/:containerId/stop") + @Post(":serverId/containers/logs/stop") @HttpCode(200) - async stopContainer( + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async stopContainerLogs( @Req() req: { user: UserEntity }, @Param("serverId") serverId: string, - @Param("containerId") containerId: string, + @Body() body: ContainerLogsStopDto, ) { try { - return this.deploymentsService.executeContainerAction( + return this.deploymentsService.stopContainerLogs( serverId, req.user.id, - containerId, - "stop", + body.sessionId, ); } catch (error) { this.logger.error( - `Stop container failed: ${error instanceof Error ? error.message : String(error)}`, + `Stop container logs failed: ${error instanceof Error ? error.message : String(error)}`, ); throw error; } } /** - * Start a container on a server (agent-first, host fallback). + * Start streaming logs for a container via the connected agent. */ - @Post(":serverId/containers/:containerId/start") + @Post(":serverId/containers/:containerId/logs/start") @HttpCode(200) - async startContainer( + async startContainerLogs( @Req() req: { user: UserEntity }, @Param("serverId") serverId: string, @Param("containerId") containerId: string, ) { try { - return this.deploymentsService.executeContainerAction( + const data = await this.deploymentsService.startContainerLogs( serverId, req.user.id, containerId, - "start", ); + return { message: "Container log stream started", data }; } catch (error) { this.logger.error( - `Start container failed: ${error instanceof Error ? error.message : String(error)}`, + `Start container logs failed: ${error instanceof Error ? error.message : String(error)}`, ); throw error; } } /** - * Restart a container on a server (agent-first, host fallback). + * Stop a container on a server (agent-first, host fallback). */ - @Post(":serverId/containers/:containerId/restart") + @Post(":serverId/containers/:containerId/stop") @HttpCode(200) - async restartContainer( + async stopContainer( @Req() req: { user: UserEntity }, @Param("serverId") serverId: string, @Param("containerId") containerId: string, @@ -342,22 +350,22 @@ export class DeploymentsController { serverId, req.user.id, containerId, - "restart", + "stop", ); } catch (error) { this.logger.error( - `Restart container failed: ${error instanceof Error ? error.message : String(error)}`, + `Stop container failed: ${error instanceof Error ? error.message : String(error)}`, ); throw error; } } /** - * Delete a container on a server (agent-first, host fallback). + * Start a container on a server (agent-first, host fallback). */ - @Delete(":serverId/containers/:containerId") + @Post(":serverId/containers/:containerId/start") @HttpCode(200) - async deleteContainer( + async startContainer( @Req() req: { user: UserEntity }, @Param("serverId") serverId: string, @Param("containerId") containerId: string, @@ -367,61 +375,61 @@ export class DeploymentsController { serverId, req.user.id, containerId, - "delete", + "start", ); } catch (error) { this.logger.error( - `Delete container failed: ${error instanceof Error ? error.message : String(error)}`, + `Start container failed: ${error instanceof Error ? error.message : String(error)}`, ); throw error; } } /** - * Start streaming logs for a container via the connected agent. + * Restart a container on a server (agent-first, host fallback). */ - @Post(":serverId/containers/:containerId/logs/start") + @Post(":serverId/containers/:containerId/restart") @HttpCode(200) - async startContainerLogs( + async restartContainer( @Req() req: { user: UserEntity }, @Param("serverId") serverId: string, @Param("containerId") containerId: string, ) { try { - const data = await this.deploymentsService.startContainerLogs( + return this.deploymentsService.executeContainerAction( serverId, req.user.id, containerId, + "restart", ); - return { message: "Container log stream started", data }; } catch (error) { this.logger.error( - `Start container logs failed: ${error instanceof Error ? error.message : String(error)}`, + `Restart container failed: ${error instanceof Error ? error.message : String(error)}`, ); throw error; } } /** - * Stop an active container log stream. + * Delete a container on a server (agent-first, host fallback). */ - @Post(":serverId/containers/logs/stop") + @Delete(":serverId/containers/:containerId") @HttpCode(200) - @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) - async stopContainerLogs( + async deleteContainer( @Req() req: { user: UserEntity }, @Param("serverId") serverId: string, - @Body() body: ContainerLogsStopDto, + @Param("containerId") containerId: string, ) { try { - return this.deploymentsService.stopContainerLogs( + return this.deploymentsService.executeContainerAction( serverId, req.user.id, - body.sessionId, + containerId, + "delete", ); } catch (error) { this.logger.error( - `Stop container logs failed: ${error instanceof Error ? error.message : String(error)}`, + `Delete container failed: ${error instanceof Error ? error.message : String(error)}`, ); throw error; } diff --git a/apps/control-panel-app/src/modules/deployments/deployments.service.ts b/apps/control-panel-app/src/modules/deployments/deployments.service.ts index 494a19b..895dfaa 100644 --- a/apps/control-panel-app/src/modules/deployments/deployments.service.ts +++ b/apps/control-panel-app/src/modules/deployments/deployments.service.ts @@ -59,6 +59,7 @@ import { } from "./utils/container-discovery.util"; import { ERROR_MESSAGES } from "@control-panel/constants/error"; import { SUCCESS_MESSAGES as CP_SUCCESS_MESSAGES } from "@control-panel/constants/success"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { assertValidContainerId } from "./utils/container-action.util"; import type { EnvironmentVariableView } from "./interfaces/deployments.interface"; @@ -397,7 +398,7 @@ export class DeploymentsService { }> { try { this.logger.debug( - `[emitPreparedDeployment] deploymentId=${prepared.deploymentId} serverId=${prepared.serverId} mergedPorts=${JSON.stringify(prepared.mergedPorts)}`, + `[emitPreparedDeployment] deploymentId=${prepared.deploymentId} serverId=${prepared.serverId} portCount=${Object.keys(prepared.mergedPorts).length}`, ); const encryptedCompose = this.encryptionService.encrypt( @@ -733,7 +734,7 @@ export class DeploymentsService { baseEnv = { ...stored.env, ...requestEnv }; basePorts = { ...stored.ports, ...requestPorts }; this.logger.debug( - `[prepareComposeDeployment] merged redeploy ports deploymentId=${deploymentId} basePorts=${JSON.stringify(basePorts)}`, + `[prepareComposeDeployment] merged redeploy ports deploymentId=${deploymentId} portCount=${Object.keys(basePorts).length}`, ); } @@ -877,35 +878,42 @@ export class DeploymentsService { serverId: string, userId: string, ): Promise { - await this.assertActiveServerForUser(serverId, userId); + try { + await this.assertActiveServerForUser(serverId, userId); - const discovered = - await this.serverConnectionsService.discoverContainers(serverId); + const discovered = + await this.serverConnectionsService.discoverContainers(serverId); - const deploymentRows = await this.deploymentRepository.find({ - where: { - serverId, - deletedAt: IsNull(), - deploymentStatus: Not( - In(DeploymentsService.OVERVIEW_EXCLUDED_STATUSES), - ), - }, - relations: { template: true }, - order: { updatedAt: "DESC" }, - }); + const deploymentRows = await this.deploymentRepository.find({ + where: { + serverId, + deletedAt: IsNull(), + deploymentStatus: Not( + In(DeploymentsService.OVERVIEW_EXCLUDED_STATUSES), + ), + }, + relations: { template: true }, + order: { updatedAt: "DESC" }, + }); - const deployments = deploymentRows.map((deployment) => ({ - id: deployment.id, - templateSlug: deployment.templateSlug, - serviceName: deployment.template?.name?.trim() || null, - composeProject: sanitizeDeploymentProjectName(deployment.id), - })); + const deployments = deploymentRows.map((deployment) => ({ + id: deployment.id, + templateSlug: deployment.templateSlug, + serviceName: deployment.template?.name?.trim() || null, + composeProject: sanitizeDeploymentProjectName(deployment.id), + })); - return mergeDiscoveredContainersWithDeployments( - discovered, - deployments, - serverId, - ); + return mergeDiscoveredContainersWithDeployments( + discovered, + deployments, + serverId, + ); + } catch (error) { + this.logger.error( + `List server containers failed for server '${serverId}': ${toErrorMessage(error)}`, + ); + throw error; + } } /** @@ -917,109 +925,117 @@ export class DeploymentsService { containerId: string, action: ContainerActionType, ): Promise { - await this.assertActiveServerForUser(serverId, userId); - const safeContainerId = assertValidContainerId(containerId); + try { + await this.assertActiveServerForUser(serverId, userId); + const safeContainerId = assertValidContainerId(containerId); - let result: ContainerActionResponsePayload | null = null; - let socketError: string | null = null; - let executedVia: ContainerActionResponseDto["executedVia"] = "agent"; + let result: ContainerActionResponsePayload | null = null; + let socketError: string | null = null; + let executedVia: ContainerActionResponseDto["executedVia"] = "agent"; - if (this.deploymentGateway.isAgentConnectedForServer(serverId)) { - const agentVersion = - this.deploymentGateway.getAgentVersion(serverId) ?? "unknown"; - const supportsContainerAction = this.deploymentGateway.agentSupports( - serverId, - DeploymentEvents.CONTAINER_ACTION, - ); + if (this.deploymentGateway.isAgentConnectedForServer(serverId)) { + const agentVersion = + this.deploymentGateway.getAgentVersion(serverId) ?? "unknown"; + const supportsContainerAction = this.deploymentGateway.agentSupports( + serverId, + DeploymentEvents.CONTAINER_ACTION, + ); - this.logger.log( - `[CONTAINER_ACTION] serverId=${serverId} agentVersion=${agentVersion} supportsContainerAction=${supportsContainerAction}`, - ); + this.logger.log( + `[CONTAINER_ACTION] serverId=${serverId} agentVersion=${agentVersion} supportsContainerAction=${supportsContainerAction}`, + ); - if (!supportsContainerAction) { - socketError = `Connected agent (version ${agentVersion}) does not support container actions — rebuild or update the agent image to include the container:action handler`; + if (!supportsContainerAction) { + socketError = `Connected agent (version ${agentVersion}) does not support container actions — rebuild or update the agent image to include the container:action handler`; + this.logger.warn( + `[CONTAINER_ACTION] skipping socket for server '${serverId}': ${socketError}`, + ); + } else { + try { + result = await this.deploymentGateway.requestContainerAction( + serverId, + safeContainerId, + action, + ); + this.logger.log( + `[CONTAINER_ACTION] agent completed action=${action} containerId=${safeContainerId} serverId=${serverId} success=${result.success}`, + ); + } catch (error) { + socketError = + error instanceof Error ? error.message : String(error); + this.logger.warn( + `[CONTAINER_ACTION] agent socket failed for server '${serverId}': ${socketError}`, + ); + } + } + } else { + socketError = `No connected agent for server '${serverId}'`; this.logger.warn( - `[CONTAINER_ACTION] skipping socket for server '${serverId}': ${socketError}`, + `[CONTAINER_ACTION] no connected agent for server '${serverId}'`, ); - } else { + } + + if (!result) { + this.logger.warn( + `[CONTAINER_ACTION] using host fallback for ${action} on server '${serverId}'` + + (socketError ? `: ${socketError}` : ""), + ); + executedVia = "host"; try { - result = await this.deploymentGateway.requestContainerAction( - serverId, - safeContainerId, - action, - ); - this.logger.log( - `[CONTAINER_ACTION] agent completed action=${action} containerId=${safeContainerId} serverId=${serverId} success=${result.success}`, - ); + result = + await this.serverConnectionsService.executeContainerActionOnHost( + serverId, + safeContainerId, + action, + ); } catch (error) { - socketError = error instanceof Error ? error.message : String(error); - this.logger.warn( - `[CONTAINER_ACTION] agent socket failed for server '${serverId}': ${socketError}`, + const hostMessage = + error instanceof Error ? error.message : String(error); + const detail = socketError + ? `Agent: ${socketError}. Host: ${hostMessage}` + : hostMessage; + throw new BadRequestException( + `Failed to ${action} container: ${detail}`, ); } } - } else { - socketError = `No connected agent for server '${serverId}'`; - this.logger.warn( - `[CONTAINER_ACTION] no connected agent for server '${serverId}'`, - ); - } - if (!result) { - this.logger.warn( - `[CONTAINER_ACTION] using host fallback for ${action} on server '${serverId}'` + - (socketError ? `: ${socketError}` : ""), - ); - executedVia = "host"; - try { - result = - await this.serverConnectionsService.executeContainerActionOnHost( - serverId, - safeContainerId, - action, - ); - } catch (error) { - const hostMessage = - error instanceof Error ? error.message : String(error); - const detail = socketError - ? `Agent: ${socketError}. Host: ${hostMessage}` - : hostMessage; + if (!result.success) { throw new BadRequestException( - `Failed to ${action} container: ${detail}`, + result.error?.trim() || + result.stderr?.trim() || + `Failed to ${action} container '${safeContainerId}'`, ); } - } - if (!result.success) { - throw new BadRequestException( - result.error?.trim() || - result.stderr?.trim() || - `Failed to ${action} container '${safeContainerId}'`, + const actionPastTense: Record = { + stop: "stopped", + start: "started", + restart: "restarted", + delete: "deleted", + }; + const viaLabel = + executedVia === "agent" + ? "via agent" + : "via server host (agent unavailable or outdated)"; + const message = `Container ${actionPastTense[action]} ${viaLabel}.`; + + return { + action: result.action, + containerId: result.containerId, + success: true, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + executedVia, + message, + }; + } catch (error) { + this.logger.error( + `Container action '${action}' failed for server '${serverId}': ${toErrorMessage(error)}`, ); + throw error; } - - const actionPastTense: Record = { - stop: "stopped", - start: "started", - restart: "restarted", - delete: "deleted", - }; - const viaLabel = - executedVia === "agent" - ? "via agent" - : "via server host (agent unavailable or outdated)"; - const message = `Container ${actionPastTense[action]} ${viaLabel}.`; - - return { - action: result.action, - containerId: result.containerId, - success: true, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - executedVia, - message, - }; } /** @@ -1030,43 +1046,51 @@ export class DeploymentsService { userId: string, containerId: string, ): Promise { - await this.assertActiveServerForUser(serverId, userId); - const safeContainerId = assertValidContainerId(containerId); - - if (!this.deploymentGateway.isAgentConnectedForServer(serverId)) { - throw new BadRequestException( - ERROR_MESSAGES.CONTAINER_LOGS.AGENT_UNAVAILABLE, - ); - } - - const supportsLogs = this.deploymentGateway.agentSupports( - serverId, - DeploymentEvents.CONTAINER_LOGS_START, - ); + try { + await this.assertActiveServerForUser(serverId, userId); + const safeContainerId = assertValidContainerId(containerId); - if (!supportsLogs) { - throw new BadRequestException( - ERROR_MESSAGES.CONTAINER_LOGS.AGENT_UNSUPPORTED, - ); - } + if (!this.deploymentGateway.isAgentConnectedForServer(serverId)) { + throw new BadRequestException( + ERROR_MESSAGES.CONTAINER_LOGS.AGENT_UNAVAILABLE, + ); + } - try { - const sessionId = await this.deploymentGateway.requestContainerLogsStart( + const supportsLogs = this.deploymentGateway.agentSupports( serverId, - userId, - safeContainerId, + DeploymentEvents.CONTAINER_LOGS_START, ); - return { - sessionId, - serverId, - containerId: safeContainerId, - }; + if (!supportsLogs) { + throw new BadRequestException( + ERROR_MESSAGES.CONTAINER_LOGS.AGENT_UNSUPPORTED, + ); + } + + try { + const sessionId = + await this.deploymentGateway.requestContainerLogsStart( + serverId, + userId, + safeContainerId, + ); + + return { + sessionId, + serverId, + containerId: safeContainerId, + }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new BadRequestException( + `${ERROR_MESSAGES.CONTAINER_LOGS.START_FAILED}: ${detail}`, + ); + } } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new BadRequestException( - `${ERROR_MESSAGES.CONTAINER_LOGS.START_FAILED}: ${detail}`, + this.logger.error( + `Failed to start container logs for server '${serverId}': ${error instanceof Error ? error.message : String(error)}`, ); + throw error; } } @@ -1078,151 +1102,192 @@ export class DeploymentsService { userId: string, sessionId: string, ): Promise<{ stopped: true; message: string }> { - await this.assertActiveServerForUser(serverId, userId); - - const trimmedSessionId = sessionId.trim(); - const session = - this.deploymentGateway.getContainerLogsSession(trimmedSessionId); - - if ( - !session || - session.serverId !== serverId || - session.userId !== userId - ) { - throw new NotFoundException( - ERROR_MESSAGES.CONTAINER_LOGS.SESSION_NOT_FOUND, - ); - } - try { - this.deploymentGateway.closeContainerLogsSession(trimmedSessionId, { - notifyAgent: true, - }); + await this.assertActiveServerForUser(serverId, userId); + + const trimmedSessionId = sessionId.trim(); + const session = + this.deploymentGateway.getContainerLogsSession(trimmedSessionId); + + if ( + session && + (session.serverId !== serverId || session.userId !== userId) + ) { + throw new NotFoundException( + ERROR_MESSAGES.CONTAINER_LOGS.SESSION_NOT_FOUND, + ); + } + + if (!session) { + this.deploymentGateway.notifyAgentContainerLogsStop( + serverId, + trimmedSessionId, + ); + return { + stopped: true, + message: CP_SUCCESS_MESSAGES.CONTAINER_LOGS.STOPPED, + }; + } + + try { + this.deploymentGateway.closeContainerLogsSession(trimmedSessionId, { + notifyAgent: true, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new BadRequestException( + `${ERROR_MESSAGES.CONTAINER_LOGS.STOP_FAILED}: ${detail}`, + ); + } + + return { + stopped: true, + message: CP_SUCCESS_MESSAGES.CONTAINER_LOGS.STOPPED, + }; } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new BadRequestException( - `${ERROR_MESSAGES.CONTAINER_LOGS.STOP_FAILED}: ${detail}`, + this.logger.error( + `Failed to stop container logs for server '${serverId}': ${error instanceof Error ? error.message : String(error)}`, ); + throw error; } - - return { - stopped: true, - message: CP_SUCCESS_MESSAGES.CONTAINER_LOGS.STOPPED, - }; } async getDeployment(deploymentId: string): Promise { - const deployment = await this.deploymentRepository.findOne({ - where: { id: deploymentId, deletedAt: IsNull() }, - }); + try { + const deployment = await this.deploymentRepository.findOne({ + where: { id: deploymentId, deletedAt: IsNull() }, + }); - if (!deployment) { - throw new NotFoundException(`Deployment '${deploymentId}' not found`); - } + if (!deployment) { + throw new NotFoundException(`Deployment '${deploymentId}' not found`); + } - return deployment; + return deployment; + } catch (error) { + this.logger.error( + `Get deployment '${deploymentId}' failed: ${toErrorMessage(error)}`, + ); + throw error; + } } async listEnvironmentVariables( deploymentId: string, options: { maskSecrets?: boolean } = {}, ): Promise { - await this.getDeployment(deploymentId); + try { + await this.getDeployment(deploymentId); - const rows = await this.environmentVariableRepository.find({ - where: { deploymentId: deploymentId }, - order: { key: "ASC" }, - }); + const rows = await this.environmentVariableRepository.find({ + where: { deploymentId: deploymentId }, + order: { key: "ASC" }, + }); - const { maskSecrets = true } = options; - const decrypted: Record = {}; - for (const row of rows) { - decrypted[row.key] = this.decryptValue(row.value); - } + const { maskSecrets = true } = options; + const decrypted: Record = {}; + for (const row of rows) { + decrypted[row.key] = this.decryptValue(row.value); + } - const display = maskSecrets ? maskEnvMap(decrypted) : decrypted; + const display = maskSecrets ? maskEnvMap(decrypted) : decrypted; - return rows.map((row) => { - const raw = display[row.key]; + return rows.map((row) => { + const raw = display[row.key]; - let value: string | null; + let value: string | null; - if (raw == null) { - value = null; - } else if (typeof raw === "string") { - value = raw; - } else if ( - typeof raw === "number" || - typeof raw === "boolean" || - typeof raw === "bigint" - ) { - value = `${raw}`; - } else { - value = JSON.stringify(raw); - } + if (raw == null) { + value = null; + } else if (typeof raw === "string") { + value = raw; + } else if ( + typeof raw === "number" || + typeof raw === "boolean" || + typeof raw === "bigint" + ) { + value = `${raw}`; + } else { + value = JSON.stringify(raw); + } - return { - key: row.key, - value, - isRequired: row.isRequired, - isGenerated: row.isGenerated, - comment: row.comment, - updatedAt: row.updatedAt, - }; - }); + return { + key: row.key, + value, + isRequired: row.isRequired, + isGenerated: row.isGenerated, + comment: row.comment, + updatedAt: row.updatedAt, + }; + }); + } catch (error) { + this.logger.error( + `List environment variables failed for deployment '${deploymentId}': ${toErrorMessage(error)}`, + ); + throw error; + } } async updateEnvironmentVariables( deploymentId: string, updates: { env?: Record; ports?: Record }, ): Promise { - const deployment = await this.getDeployment(deploymentId); - const template = await this.templateRepository.findOne({ - where: { slug: deployment.templateSlug }, - }); - - if (!template) { - throw new NotFoundException( - `Template '${deployment.templateSlug}' not found`, - ); - } + try { + const deployment = await this.getDeployment(deploymentId); + const template = await this.templateRepository.findOne({ + where: { slug: deployment.templateSlug }, + }); - const schema: TemplateSchema = { - env_schema: template.envSchema as Record, - port_schema: template.portSchema as Record, - }; - const portSchemaKeys = Object.keys(schema.port_schema ?? {}); + if (!template) { + throw new NotFoundException( + `Template '${deployment.templateSlug}' not found`, + ); + } - const stored = await this.loadStoredVariables(deploymentId, portSchemaKeys); - const mergedEnv = { ...stored.env, ...(updates.env ?? {}) }; - const mergedPorts = { ...stored.ports, ...(updates.ports ?? {}) }; + const schema: TemplateSchema = { + env_schema: template.envSchema as Record, + port_schema: template.portSchema as Record, + }; + const portSchemaKeys = Object.keys(schema.port_schema ?? {}); - const composeYaml = this.templatePayloadService.decodeBase64ToYaml( - template.compose, - ); - const parsedFromCompose = this.composeParserService.resolveFromCompose({ - compose: composeYaml, - userEnv: mergedEnv, - userPorts: mergedPorts, - portSchemaKeys, - }); + const stored = await this.loadStoredVariables( + deploymentId, + portSchemaKeys, + ); + const mergedEnv = { ...stored.env, ...(updates.env ?? {}) }; + const mergedPorts = { ...stored.ports, ...(updates.ports ?? {}) }; - const normalized = this.templateConfigService.normalizeSchema(schema); - const { env: validatedEnv, ports: validatedPorts } = - this.templateConfigService.mergeAndValidate( - { ...schema, normalized }, - { env: parsedFromCompose.env, ports: parsedFromCompose.ports }, + const composeYaml = this.templatePayloadService.decodeBase64ToYaml( + template.compose, ); + const parsedFromCompose = this.composeParserService.resolveFromCompose({ + compose: composeYaml, + userEnv: mergedEnv, + userPorts: mergedPorts, + portSchemaKeys, + }); - await this.persistEnvironmentVariables({ - deploymentId, - env: validatedEnv, - ports: validatedPorts, - generatedKeys: [], - schema, - }); + const normalized = this.templateConfigService.normalizeSchema(schema); + const { env: validatedEnv, ports: validatedPorts } = + this.templateConfigService.mergeAndValidate( + { ...schema, normalized }, + { env: parsedFromCompose.env, ports: parsedFromCompose.ports }, + ); - return this.listEnvironmentVariables(deploymentId); + await this.persistEnvironmentVariables({ + deploymentId, + env: validatedEnv, + ports: validatedPorts, + generatedKeys: [], + schema, + }); + + return this.listEnvironmentVariables(deploymentId); + } catch (error) { + this.logger.error( + `Update environment variables failed for deployment '${deploymentId}': ${toErrorMessage(error)}`, + ); + throw error; + } } /** @@ -1273,22 +1338,36 @@ export class DeploymentsService { status: DeploymentStatus, options: { message?: string; error?: string } = {}, ): Promise { - const deployment = await this.getDeployment(deploymentId); + try { + const deployment = await this.getDeployment(deploymentId); - deployment.deploymentStatus = status; - deployment.statusMessage = options.message ?? deployment.statusMessage; - if (options.error) { - deployment.lastError = options.error; - } + deployment.deploymentStatus = status; + deployment.statusMessage = options.message ?? deployment.statusMessage; + if (options.error) { + deployment.lastError = options.error; + } - await this.deploymentRepository.save(deployment); + await this.deploymentRepository.save(deployment); + } catch (error) { + this.logger.error( + `Update deployment status failed for '${deploymentId}': ${toErrorMessage(error)}`, + ); + throw error; + } } async loadResolvedForAgent( deploymentId: string, portSchemaKeys: string[], ): Promise<{ env: Record; ports: Record }> { - return this.loadStoredVariables(deploymentId, portSchemaKeys); + try { + return this.loadStoredVariables(deploymentId, portSchemaKeys); + } catch (error) { + this.logger.error( + `Load resolved variables failed for deployment '${deploymentId}': ${toErrorMessage(error)}`, + ); + throw error; + } } private async upsertDeploymentRecord(opts: { @@ -1429,68 +1508,75 @@ export class DeploymentsService { userId: string, options: { removeManagedServices: boolean }, ): Promise { - const terminalStatuses: DeploymentStatus[] = ["removed", "removing"]; - - const deployments = await this.deploymentRepository.find({ - where: { - serverId, - userId, - deletedAt: IsNull(), - status: EntityStatus.ACTIVE, - deploymentStatus: Not(In(terminalStatuses)), - }, - }); - - if (deployments.length === 0) { - return; - } + try { + const terminalStatuses: DeploymentStatus[] = ["removed", "removing"]; - if (options.removeManagedServices) { - await this.ensureAgentConnectedForServer(serverId); + const deployments = await this.deploymentRepository.find({ + where: { + serverId, + userId, + deletedAt: IsNull(), + status: EntityStatus.ACTIVE, + deploymentStatus: Not(In(terminalStatuses)), + }, + }); - for (const deployment of deployments) { - if (!this.deploymentGateway.isAgentConnectedForServer(serverId)) { - this.logger.warn( - `Server delete: no connected agent for deployment '${deployment.id}' on server '${serverId}'`, - ); - continue; - } + if (deployments.length === 0) { + return; + } - try { - await this.deploymentGateway.requestDeploymentRemove( - serverId, - deployment.id, - deployment.templateSlug, - ); - this.logger.log( - `Server delete: agent removed deployment '${deployment.id}' on server '${serverId}'`, - ); - } catch (error) { - this.logger.warn( - `Server delete: agent removal failed for deployment '${deployment.id}' on server '${serverId}': ${ - error instanceof Error ? error.message : String(error) - }`, - ); + if (options.removeManagedServices) { + await this.ensureAgentConnectedForServer(serverId); + + for (const deployment of deployments) { + if (!this.deploymentGateway.isAgentConnectedForServer(serverId)) { + this.logger.warn( + `Server delete: no connected agent for deployment '${deployment.id}' on server '${serverId}'`, + ); + continue; + } + + try { + await this.deploymentGateway.requestDeploymentRemove( + serverId, + deployment.id, + deployment.templateSlug, + ); + this.logger.log( + `Server delete: agent removed deployment '${deployment.id}' on server '${serverId}'`, + ); + } catch (error) { + this.logger.warn( + `Server delete: agent removal failed for deployment '${deployment.id}' on server '${serverId}': ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } } } - } - const now = dayjs().unix(); - await this.deploymentRepository.update( - { id: In(deployments.map((deployment) => deployment.id)) }, - { - status: EntityStatus.INACTIVE, - deploymentStatus: "removed", - statusMessage: DEPLOYMENT_MESSAGES.SERVER_DELETE_DEACTIVATED, - lastError: null, - deletedAt: now, - updatedAt: now, - }, - ); + const now = dayjs().unix(); + await this.deploymentRepository.update( + { id: In(deployments.map((deployment) => deployment.id)) }, + { + status: EntityStatus.INACTIVE, + deploymentStatus: "removed", + statusMessage: DEPLOYMENT_MESSAGES.SERVER_DELETE_DEACTIVATED, + lastError: null, + deletedAt: now, + updatedAt: now, + }, + ); - this.logger.log( - `Marked ${deployments.length} deployment(s) inactive for deleted server '${serverId}'`, - ); + this.logger.log( + `Marked ${deployments.length} deployment(s) inactive for deleted server '${serverId}'`, + ); + } catch (error) { + this.logger.error( + `Deactivate deployments for server deletion failed for '${serverId}': ${toErrorMessage(error)}`, + ); + throw error; + } } /** @@ -1502,58 +1588,78 @@ export class DeploymentsService { status: DeploymentStatus; message: string; }> { - const deployment = await this.getDeployment(deploymentId); - const blockingStatuses: DeploymentStatus[] = [ - "pending", - "validating", - "pulling", - "building", - "deploying", - "removing", - "removed", - ]; - - if (blockingStatuses.includes(deployment.deploymentStatus)) { - throw new ConflictException( - `Deployment '${deploymentId}' cannot be removed while status is '${deployment.deploymentStatus}'`, - ); - } + try { + const deployment = await this.getDeployment(deploymentId); + const blockingStatuses: DeploymentStatus[] = [ + "pending", + "validating", + "pulling", + "building", + "deploying", + "removing", + "removed", + ]; - let serverId = deployment.serverId; - if (!serverId) { - if (!deployment.userId) { - throw new BadRequestException( - `Deployment '${deploymentId}' has no server_id; cannot remove.`, + if (blockingStatuses.includes(deployment.deploymentStatus)) { + throw new ConflictException( + `Deployment '${deploymentId}' cannot be removed while status is '${deployment.deploymentStatus}'`, ); } - serverId = ( - await this.localServerService.ensureLocalServer(deployment.userId) - ).id; - } - await this.ensureAgentConnectedForServer(serverId); + let serverId = deployment.serverId; + if (!serverId) { + if (!deployment.userId) { + throw new BadRequestException( + `Deployment '${deploymentId}' has no server_id; cannot remove.`, + ); + } + serverId = ( + await this.localServerService.ensureLocalServer(deployment.userId) + ).id; + } - await this.updateStatus(deploymentId, "removing", { - message: SUCCESS_MESSAGES.REMOVING, - }); + await this.ensureAgentConnectedForServer(serverId); - const message: SocketRemoveMessage = { - type: "REMOVE", - payload: { - deploymentId, - templateSlug: deployment.templateSlug, - }, - }; + await this.updateStatus(deploymentId, "removing", { + message: SUCCESS_MESSAGES.REMOVING, + }); - this.deploymentGateway.emitRemove(message, serverId); + const message: SocketRemoveMessage = { + type: "REMOVE", + payload: { + deploymentId, + templateSlug: deployment.templateSlug, + }, + }; - this.logger.log(`Removal requested for deployment '${deploymentId}'`); + try { + this.deploymentGateway.emitRemove(message, serverId); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + `Failed to emit removal for deployment '${deploymentId}': ${errorMessage}`, + ); + await this.updateStatus(deploymentId, "failed", { + message: "Failed to remove deployment", + error: errorMessage, + }); + throw error; + } - return { - deploymentId, - status: "removing", - message: SUCCESS_MESSAGES.REMOVING, - }; + this.logger.log(`Removal requested for deployment '${deploymentId}'`); + + return { + deploymentId, + status: "removing", + message: SUCCESS_MESSAGES.REMOVING, + }; + } catch (error) { + this.logger.error( + `Remove deployment '${deploymentId}' failed: ${toErrorMessage(error)}`, + ); + throw error; + } } /** @@ -1563,23 +1669,30 @@ export class DeploymentsService { deploymentId: string, options: { message?: string } = {}, ): Promise { - const deployment = await this.deploymentRepository.findOne({ - where: { id: deploymentId }, - withDeleted: true, - }); + try { + const deployment = await this.deploymentRepository.findOne({ + where: { id: deploymentId }, + withDeleted: true, + }); - if (!deployment || deployment.deletedAt) { - return; - } + if (!deployment || deployment.deletedAt) { + return; + } - deployment.deploymentStatus = "removed"; - deployment.statusMessage = - options.message ?? SUCCESS_MESSAGES.REMOVAL_COMPLETED; - deployment.lastError = null; + deployment.deploymentStatus = "removed"; + deployment.statusMessage = + options.message ?? SUCCESS_MESSAGES.REMOVAL_COMPLETED; + deployment.lastError = null; - await this.deploymentRepository.softRemove(deployment); + await this.deploymentRepository.softRemove(deployment); - this.logger.log(`Soft-deleted deployment record '${deploymentId}'`); + this.logger.log(`Soft-deleted deployment record '${deploymentId}'`); + } catch (error) { + this.logger.error( + `Soft delete deployment '${deploymentId}' failed: ${toErrorMessage(error)}`, + ); + throw error; + } } /** diff --git a/apps/control-panel-app/src/modules/mcp-api-keys/controllers/mcp-api-keys.controller.ts b/apps/control-panel-app/src/modules/mcp-api-keys/controllers/mcp-api-keys.controller.ts index 9367090..0e45536 100644 --- a/apps/control-panel-app/src/modules/mcp-api-keys/controllers/mcp-api-keys.controller.ts +++ b/apps/control-panel-app/src/modules/mcp-api-keys/controllers/mcp-api-keys.controller.ts @@ -5,12 +5,14 @@ import { Get, HttpCode, HttpStatus, + Logger, Param, Post, Req, UseGuards, } from "@nestjs/common"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { AuthenticatedRequest } from "@control-panel/common/interfaces/authenticated-request.interface"; import { ServiceResponse } from "@control-panel/common/interfaces/success-response.interface"; import { AccessTokenGuard } from "@control-panel/modules/auth/guards/auth.guards"; @@ -23,46 +25,55 @@ import { McpApiKeysService } from "../services/mcp-api-keys.service"; @UseGuards(AccessTokenGuard) @Controller("mcp-api-keys") export class McpApiKeysController { + private readonly logger = new Logger(McpApiKeysController.name); + constructor(private readonly mcpApiKeysService: McpApiKeysService) {} /** * Create a new MCP API key for the authenticated user. - * @param req - * @param body - * @returns */ @Post() @HttpCode(HttpStatus.CREATED) - createKey( + async createKey( @Req() req: AuthenticatedRequest, @Body() body: CreateMcpApiKeyDto, ): Promise> { - return this.mcpApiKeysService.createKey(req.user.id, body); + try { + return await this.mcpApiKeysService.createKey(req.user.id, body); + } catch (error) { + this.logger.error(`Create MCP API key failed: ${toErrorMessage(error)}`); + throw error; + } } /** * List MCP API keys for the authenticated user. - * @param req - * @returns */ @Get() - listKeys( + async listKeys( @Req() req: AuthenticatedRequest, ): Promise> { - return this.mcpApiKeysService.listKeys(req.user.id); + try { + return await this.mcpApiKeysService.listKeys(req.user.id); + } catch (error) { + this.logger.error(`List MCP API keys failed: ${toErrorMessage(error)}`); + throw error; + } } /** * Revoke an MCP API key owned by the authenticated user. - * @param req - * @param keyId - * @returns */ @Delete(":id") - revokeKey( + async revokeKey( @Req() req: AuthenticatedRequest, @Param("id") keyId: string, ): Promise> { - return this.mcpApiKeysService.revokeKey(req.user.id, keyId); + try { + return await this.mcpApiKeysService.revokeKey(req.user.id, keyId); + } catch (error) { + this.logger.error(`Revoke MCP API key failed: ${toErrorMessage(error)}`); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/mcp-api-keys/index.ts b/apps/control-panel-app/src/modules/mcp-api-keys/index.ts deleted file mode 100644 index 28a0a70..0000000 --- a/apps/control-panel-app/src/modules/mcp-api-keys/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./mcp-api-keys.module"; -export * from "./services/mcp-api-keys.service"; -export * from "./entities/mcp-api-key.entity"; diff --git a/apps/control-panel-app/src/modules/mcp-server/index.ts b/apps/control-panel-app/src/modules/mcp-server/index.ts deleted file mode 100644 index a41f4ff..0000000 --- a/apps/control-panel-app/src/modules/mcp-server/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./mcp-server.module"; -export * from "./services/mcp-server.service"; -export * from "./services/mcp-auth.service"; -export * from "./tools/tools.service"; diff --git a/apps/control-panel-app/src/modules/mcp-server/interfaces/mcp-demo-data.interface.ts b/apps/control-panel-app/src/modules/mcp-server/interfaces/mcp-demo-data.interface.ts deleted file mode 100644 index 14e32ab..0000000 --- a/apps/control-panel-app/src/modules/mcp-server/interfaces/mcp-demo-data.interface.ts +++ /dev/null @@ -1,31 +0,0 @@ -export interface McpServerListItem { - name: string; - ip: string; - status: string; - gpu: string | null; -} - -export interface McpServerStatus { - name: string; - status: string; - uptime: string; - cpu: string; - ram: string; - disk: string; -} - -export interface McpServerDeployResult { - success: boolean; - message: string; - port: number; - status: string; -} - -export interface McpServerGpuMetrics { - server: string; - gpu: string; - utilization: string; - vramUsed: string; - vramTotal: string; - temp: string; -} diff --git a/apps/control-panel-app/src/modules/mcp-server/services/mcp-auth.service.ts b/apps/control-panel-app/src/modules/mcp-server/services/mcp-auth.service.ts index 3be3657..5d90215 100644 --- a/apps/control-panel-app/src/modules/mcp-server/services/mcp-auth.service.ts +++ b/apps/control-panel-app/src/modules/mcp-server/services/mcp-auth.service.ts @@ -1,12 +1,15 @@ -import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { Injectable, Logger, UnauthorizedException } from "@nestjs/common"; import { ERROR_MESSAGES } from "@control-panel/constants/error"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { McpApiKeysService } from "@control-panel/modules/mcp-api-keys/services/mcp-api-keys.service"; import { McpAuthUser } from "../interfaces/mcp-auth-user.interface"; @Injectable() export class McpAuthService { + private readonly logger = new Logger(McpAuthService.name); + constructor(private readonly mcpApiKeysService: McpApiKeysService) {} /** @@ -15,22 +18,29 @@ export class McpAuthService { * @returns A promise that resolves to the MCP auth user. */ async validateToken(authHeader: string | undefined): Promise { - if (!authHeader) { - throw new UnauthorizedException( - ERROR_MESSAGES.MCP_API_KEYS.MISSING_AUTHORIZATION, - ); - } + try { + if (!authHeader) { + throw new UnauthorizedException( + ERROR_MESSAGES.MCP_API_KEYS.MISSING_AUTHORIZATION, + ); + } - const token = authHeader.startsWith("Bearer ") - ? authHeader.slice(7) - : authHeader; + const token = authHeader.startsWith("Bearer ") + ? authHeader.slice(7) + : authHeader; - if (!token.trim()) { - throw new UnauthorizedException( - ERROR_MESSAGES.MCP_API_KEYS.MISSING_AUTHORIZATION, + if (!token.trim()) { + throw new UnauthorizedException( + ERROR_MESSAGES.MCP_API_KEYS.MISSING_AUTHORIZATION, + ); + } + + return await this.mcpApiKeysService.validateBearerToken(token); + } catch (error) { + this.logger.error( + `MCP token validation failed: ${toErrorMessage(error)}`, ); + throw error; } - - return this.mcpApiKeysService.validateBearerToken(token); } } diff --git a/apps/control-panel-app/src/modules/mcp-server/services/mcp-server.service.ts b/apps/control-panel-app/src/modules/mcp-server/services/mcp-server.service.ts index bc97638..4400333 100644 --- a/apps/control-panel-app/src/modules/mcp-server/services/mcp-server.service.ts +++ b/apps/control-panel-app/src/modules/mcp-server/services/mcp-server.service.ts @@ -1,6 +1,7 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import * as z from "zod"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { MCP_SERVER_NAME, @@ -11,6 +12,8 @@ import { McpToolsService } from "../tools/tools.service"; @Injectable() export class McpServerService { + private readonly logger = new Logger(McpServerService.name); + constructor(private readonly mcpToolsService: McpToolsService) {} /** @@ -19,71 +22,78 @@ export class McpServerService { * @returns A MCP server. */ createServer(userId: string): McpServer { - const server = new McpServer( - { name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION }, - { capabilities: { tools: {} } }, - ); + try { + const server = new McpServer( + { name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION }, + { capabilities: { tools: {} } }, + ); - server.registerTool( - MCP_TOOL_NAMES.LIST_SERVERS, - { - description: "List all servers for user", - inputSchema: {}, - }, - () => - this.mcpToolsService.executeTool( - MCP_TOOL_NAMES.LIST_SERVERS, - {}, - userId, - ), - ); + server.registerTool( + MCP_TOOL_NAMES.LIST_SERVERS, + { + description: "List all servers for user", + inputSchema: {}, + }, + () => + this.mcpToolsService.executeTool( + MCP_TOOL_NAMES.LIST_SERVERS, + {}, + userId, + ), + ); - server.registerTool( - MCP_TOOL_NAMES.GET_SERVER_STATUS, - { - description: "Server status/metrics", - inputSchema: { - serverName: z.string().describe("Name of the server"), + server.registerTool( + MCP_TOOL_NAMES.GET_SERVER_STATUS, + { + description: "Server status/metrics", + inputSchema: { + serverName: z.string().describe("Name of the server"), + }, }, - }, - ({ serverName }) => - this.mcpToolsService.executeTool( - MCP_TOOL_NAMES.GET_SERVER_STATUS, - { serverName }, - userId, - ), - ); + ({ serverName }) => + this.mcpToolsService.executeTool( + MCP_TOOL_NAMES.GET_SERVER_STATUS, + { serverName }, + userId, + ), + ); - server.registerTool( - MCP_TOOL_NAMES.GET_GPU_METRICS, - { - description: "GPU utilization/VRAM", - inputSchema: { - serverName: z.string().describe("Name of the server"), + server.registerTool( + MCP_TOOL_NAMES.GET_GPU_METRICS, + { + description: "GPU utilization/VRAM", + inputSchema: { + serverName: z.string().describe("Name of the server"), + }, }, - }, - ({ serverName }) => - this.mcpToolsService.executeTool( - MCP_TOOL_NAMES.GET_GPU_METRICS, - { serverName }, - userId, - ), - ); + ({ serverName }) => + this.mcpToolsService.executeTool( + MCP_TOOL_NAMES.GET_GPU_METRICS, + { serverName }, + userId, + ), + ); - server.registerTool( - MCP_TOOL_NAMES.GET_CURRENT_USER, - { - description: "Get the authenticated user's profile", - inputSchema: {}, - }, - () => - this.mcpToolsService.executeTool( - MCP_TOOL_NAMES.GET_CURRENT_USER, - {}, - userId, - ), - ); + server.registerTool( + MCP_TOOL_NAMES.GET_CURRENT_USER, + { + description: "Get the authenticated user's profile", + inputSchema: {}, + }, + () => + this.mcpToolsService.executeTool( + MCP_TOOL_NAMES.GET_CURRENT_USER, + {}, + userId, + ), + ); - return server; + return server; + } catch (error) { + this.logger.error( + `Create MCP server failed for user '${userId}': ${toErrorMessage(error)}`, + ); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/mcp-server/tools/tools.module.ts b/apps/control-panel-app/src/modules/mcp-server/tools/tools.module.ts index 816c6ef..abe600b 100644 --- a/apps/control-panel-app/src/modules/mcp-server/tools/tools.module.ts +++ b/apps/control-panel-app/src/modules/mcp-server/tools/tools.module.ts @@ -1,13 +1,12 @@ import { Module } from "@nestjs/common"; import { AuthModule } from "@control-panel/modules/auth/auth.module"; -import { DeploymentsModule } from "@control-panel/modules/deployments/deployments.module"; import { ServerConnectionsModule } from "@control-panel/modules/server-connections/server-connections.module"; import { McpToolsService } from "./tools.service"; @Module({ - imports: [AuthModule, ServerConnectionsModule, DeploymentsModule], + imports: [AuthModule, ServerConnectionsModule], providers: [McpToolsService], exports: [McpToolsService], }) diff --git a/apps/control-panel-app/src/modules/mcp-server/tools/tools.service.ts b/apps/control-panel-app/src/modules/mcp-server/tools/tools.service.ts index 7da5fea..945f270 100644 --- a/apps/control-panel-app/src/modules/mcp-server/tools/tools.service.ts +++ b/apps/control-panel-app/src/modules/mcp-server/tools/tools.service.ts @@ -8,15 +8,11 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { ERROR_MESSAGES } from "@control-panel/constants/error"; import { AuthService } from "@control-panel/modules/auth/auth.service"; -import { DeploymentsService } from "@control-panel/modules/deployments/deployments.service"; import { ServerResponseDto } from "@control-panel/modules/server-connections/dto/server-response.dto"; import { ServerResourcesResponseDto } from "@control-panel/modules/server-connections/dto/server-resources-response.dto"; import { ServerConnectionsService } from "@control-panel/modules/server-connections/services/server-connections.service"; -import { - MCP_SERVER_LIST_LIMIT, - SERVICE_NAME_TO_TEMPLATE_SLUG, -} from "../constants/mcp-tools.constants"; +import { MCP_SERVER_LIST_LIMIT } from "../constants/mcp-tools.constants"; import { MCP_TOOL_NAMES, McpToolName } from "../constants/mcp-server.constants"; @Injectable() @@ -24,7 +20,6 @@ export class McpToolsService { constructor( private readonly authService: AuthService, private readonly serverConnectionsService: ServerConnectionsService, - private readonly deploymentsService: DeploymentsService, ) {} /** @@ -114,14 +109,12 @@ export class McpToolsService { */ private async listServers(userId: string) { try { - console.log("listServers", userId); const response = await this.serverConnectionsService.listServers(userId, { page: 1, limit: MCP_SERVER_LIST_LIMIT, sortBy: "createdAt", sortOrder: "desc", }); - console.log("response", response); return response.data.data.map((server) => ({ id: server.id, @@ -137,7 +130,6 @@ export class McpToolsService { lastConnectedAt: server.lastConnectedAt, })); } catch (error) { - console.log("error", error); if ( error instanceof NotFoundException || error instanceof BadRequestException @@ -300,27 +292,6 @@ export class McpToolsService { }; } - /** - * Resolve a service name to a deployment template slug. - * @param serviceName - The service name provided by the caller. - * @returns The resolved template slug. - */ - private resolveTemplateSlug(serviceName: string): string { - const normalized = serviceName.trim().toLowerCase(); - const mapped = SERVICE_NAME_TO_TEMPLATE_SLUG[normalized]; - - if (mapped) { - return mapped; - } - - const slug = normalized.replace(/\s+/g, "-"); - if (!slug) { - throw new BadRequestException("serviceName is required"); - } - - return slug; - } - /** * Format a byte count into a human-readable string. * @param bytes - The number of bytes to format. diff --git a/apps/control-panel-app/src/modules/organizations/organizations.controller.ts b/apps/control-panel-app/src/modules/organizations/organizations.controller.ts deleted file mode 100644 index 283d1ef..0000000 --- a/apps/control-panel-app/src/modules/organizations/organizations.controller.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Controller } from "@nestjs/common"; - -@Controller("organizations") -export class OrganizationsController {} diff --git a/apps/control-panel-app/src/modules/organizations/organizations.module.ts b/apps/control-panel-app/src/modules/organizations/organizations.module.ts deleted file mode 100644 index 3eb29ad..0000000 --- a/apps/control-panel-app/src/modules/organizations/organizations.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from "@nestjs/common"; -import { OrganizationsService } from "./organizations.service"; -import { OrganizationsController } from "./organizations.controller"; - -@Module({ - providers: [OrganizationsService], - controllers: [OrganizationsController], -}) -export class OrganizationsModule {} diff --git a/apps/control-panel-app/src/modules/organizations/organizations.service.ts b/apps/control-panel-app/src/modules/organizations/organizations.service.ts deleted file mode 100644 index e0bf17f..0000000 --- a/apps/control-panel-app/src/modules/organizations/organizations.service.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Injectable } from "@nestjs/common"; - -@Injectable() -export class OrganizationsService {} diff --git a/apps/control-panel-app/src/modules/profile/profile.controller.ts b/apps/control-panel-app/src/modules/profile/profile.controller.ts index 175ca22..d4e3880 100644 --- a/apps/control-panel-app/src/modules/profile/profile.controller.ts +++ b/apps/control-panel-app/src/modules/profile/profile.controller.ts @@ -1,4 +1,13 @@ -import { Body, Controller, Patch, Post, Req, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Logger, + Patch, + Post, + Req, + UseGuards, +} from "@nestjs/common"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { ProfileService } from "./profile.service"; import { AccessTokenGuard } from "@control-panel/modules/auth/guards/auth.guards"; import { AuthenticatedRequest } from "@control-panel/common/interfaces/authenticated-request.interface"; @@ -9,27 +18,41 @@ import { ProfileUser } from "./interfaces/profile-user.interface"; @UseGuards(AccessTokenGuard) @Controller("profile") export class ProfileController { + private readonly logger = new Logger(ProfileController.name); + constructor(private readonly profileService: ProfileService) {} /** * Update user's general profile information. */ @Patch("general") - updateGeneralProfile( + async updateGeneralProfile( @Req() req: AuthenticatedRequest, @Body() body: UpdateGeneralProfileDto, ): Promise> { - return this.profileService.updateGeneralProfile(req.user.id, body); + try { + return await this.profileService.updateGeneralProfile(req.user.id, body); + } catch (error) { + this.logger.error( + `Update general profile failed: ${toErrorMessage(error)}`, + ); + throw error; + } } /** * Change user's password. */ @Post("password") - changePassword( + async changePassword( @Req() req: AuthenticatedRequest, @Body() body: ChangePasswordDto, ): Promise> { - return this.profileService.changePassword(req.user.id, body); + try { + return await this.profileService.changePassword(req.user.id, body); + } catch (error) { + this.logger.error(`Change password failed: ${toErrorMessage(error)}`); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/profile/profile.service.ts b/apps/control-panel-app/src/modules/profile/profile.service.ts index 4ea64a7..504fe20 100644 --- a/apps/control-panel-app/src/modules/profile/profile.service.ts +++ b/apps/control-panel-app/src/modules/profile/profile.service.ts @@ -1,8 +1,10 @@ import { BadRequestException, Injectable, + Logger, NotFoundException, } from "@nestjs/common"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import * as bcrypt from "bcrypt"; @@ -18,6 +20,8 @@ import { ProfileUser } from "./interfaces/profile-user.interface"; @Injectable() export class ProfileService { + private readonly logger = new Logger(ProfileService.name); + constructor( @InjectRepository(UserEntity) private readonly userRepository: Repository, @@ -60,28 +64,35 @@ export class ProfileService { userId: string, dto: UpdateGeneralProfileDto, ): Promise> { - const user = await this.userRepository.findOne({ - where: { id: userId }, - }); + try { + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); - if (!user) { - throw new NotFoundException(ERROR_MESSAGES.AUTH.USER_NOT_FOUND); - } + if (!user) { + throw new NotFoundException(ERROR_MESSAGES.AUTH.USER_NOT_FOUND); + } - user.name = this.buildFullName(dto.firstName, dto.lastName); + user.name = this.buildFullName(dto.firstName, dto.lastName); - if (dto.profilePicture !== undefined) { - user.profilePictureUrl = dto.profilePicture ?? ""; - } + if (dto.profilePicture !== undefined) { + user.profilePictureUrl = dto.profilePicture ?? ""; + } - await this.userRepository.save(user); + await this.userRepository.save(user); - const updatedUser = await this.findProfileUser(userId); + const updatedUser = await this.findProfileUser(userId); - return { - message: SUCCESS_MESSAGES.PROFILE.UPDATED, - data: updatedUser, - }; + return { + message: SUCCESS_MESSAGES.PROFILE.UPDATED, + data: updatedUser, + }; + } catch (error) { + this.logger.error( + `Update general profile failed: ${toErrorMessage(error)}`, + ); + throw error; + } } /** @@ -91,42 +102,47 @@ export class ProfileService { userId: string, dto: ChangePasswordDto, ): Promise> { - const user = await this.userRepository.findOne({ - where: { id: userId }, - }); - - if (!user) { - throw new NotFoundException(ERROR_MESSAGES.AUTH.USER_NOT_FOUND); - } + try { + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException(ERROR_MESSAGES.AUTH.USER_NOT_FOUND); + } + + const isCurrentPasswordValid = await bcrypt.compare( + dto.currentPassword, + user.passwordHash, + ); - const isCurrentPasswordValid = await bcrypt.compare( - dto.currentPassword, - user.passwordHash, - ); + if (!isCurrentPasswordValid) { + throw new BadRequestException( + ERROR_MESSAGES.PROFILE.INVALID_CURRENT_PASSWORD, + ); + } - if (!isCurrentPasswordValid) { - throw new BadRequestException( - ERROR_MESSAGES.PROFILE.INVALID_CURRENT_PASSWORD, + const isSamePassword = await bcrypt.compare( + dto.newPassword, + user.passwordHash, ); - } - const isSamePassword = await bcrypt.compare( - dto.newPassword, - user.passwordHash, - ); + if (isSamePassword) { + throw new BadRequestException(ERROR_MESSAGES.AUTH.OLD_SAME_PASSWORD); + } - if (isSamePassword) { - throw new BadRequestException(ERROR_MESSAGES.AUTH.OLD_SAME_PASSWORD); - } + user.passwordHash = await bcrypt.hash(dto.newPassword, SALT_ROUNDS); + user.lastPasswordResetAt = dayjs().unix(); - user.passwordHash = await bcrypt.hash(dto.newPassword, SALT_ROUNDS); - user.lastPasswordResetAt = dayjs().unix(); + await this.userRepository.save(user); - await this.userRepository.save(user); - - return { - message: SUCCESS_MESSAGES.PROFILE.PASSWORD_CHANGED, - data: null, - }; + return { + message: SUCCESS_MESSAGES.PROFILE.PASSWORD_CHANGED, + data: null, + }; + } catch (error) { + this.logger.error(`Change password failed: ${toErrorMessage(error)}`); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/server-connections/adapters/local-agent-host.adapter.ts b/apps/control-panel-app/src/modules/server-connections/adapters/local-agent-host.adapter.ts index 9632c3d..018a25a 100644 --- a/apps/control-panel-app/src/modules/server-connections/adapters/local-agent-host.adapter.ts +++ b/apps/control-panel-app/src/modules/server-connections/adapters/local-agent-host.adapter.ts @@ -1,6 +1,7 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; +import { EXEC_DEFAULTS, SHELL_PATHS } from "@shared/common"; import { ExecuteResult } from "@shared/ssh"; import { AgentHostAdapter } from "../interfaces/agent-host.adapter"; @@ -19,8 +20,8 @@ export class LocalAgentHostAdapter implements AgentHostAdapter { try { const { stdout, stderr } = await execAsync(command, { timeout: timeoutMs, - maxBuffer: 16 * 1024 * 1024, - shell: "/bin/bash", + maxBuffer: EXEC_DEFAULTS.MAX_BUFFER_BYTES, + shell: SHELL_PATHS.BASH, env: process.env, }); return { diff --git a/apps/control-panel-app/src/modules/server-connections/controllers/servers.controller.ts b/apps/control-panel-app/src/modules/server-connections/controllers/servers.controller.ts index a761388..82dcfe7 100644 --- a/apps/control-panel-app/src/modules/server-connections/controllers/servers.controller.ts +++ b/apps/control-panel-app/src/modules/server-connections/controllers/servers.controller.ts @@ -4,6 +4,7 @@ import { Get, HttpCode, HttpStatus, + Logger, NotFoundException, Param, Patch, @@ -12,6 +13,7 @@ import { Req, UseGuards, } from "@nestjs/common"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { ServerConnectionsService } from "../services/server-connections.service"; import { LocalServerService } from "../services/local-server.service"; import { @@ -34,6 +36,8 @@ import { ERROR_MESSAGES } from "@control-panel/constants/error"; @UseGuards(AccessTokenGuard) @Controller("servers") export class ServersController { + private readonly logger = new Logger(ServersController.name); + constructor( private readonly connectionsService: ServerConnectionsService, private readonly localServerService: LocalServerService, @@ -41,22 +45,28 @@ export class ServersController { /** * Returns the current user's local machine server when it already exists. - * Create it via deploy with `deployOnLocal: true` (POST /deployments/compose). */ @Get("local") async getLocalServer(@Req() req: { user: UserEntity }) { - const server = await this.localServerService.findLocalServer(req.user.id); + try { + const server = await this.localServerService.findLocalServer(req.user.id); - if (!server) { - throw new NotFoundException(ERROR_MESSAGES.SERVER.LOCAL_SERVER_NOT_FOUND); - } + if (!server) { + throw new NotFoundException( + ERROR_MESSAGES.SERVER.LOCAL_SERVER_NOT_FOUND, + ); + } - return { - serverId: server.id, - name: server.name, - host: server.host, - serverType: server.serverType, - }; + return { + serverId: server.id, + name: server.name, + host: server.host, + serverType: server.serverType, + }; + } catch (error) { + this.logger.error(`Get local server failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -67,7 +77,12 @@ export class ServersController { @Req() req: AuthenticatedRequest, @Query() query: ListServersQueryDto, ): Promise>> { - return await this.connectionsService.listServers(req.user.id, query); + try { + return await this.connectionsService.listServers(req.user.id, query); + } catch (error) { + this.logger.error(`List servers failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -78,7 +93,17 @@ export class ServersController { @Req() req: AuthenticatedRequest, @Param("serverId") serverId: string, ): Promise { - return this.connectionsService.getServerResources(req.user.id, serverId); + try { + return await this.connectionsService.getServerResources( + req.user.id, + serverId, + ); + } catch (error) { + this.logger.error( + `Get server resources failed: ${toErrorMessage(error)}`, + ); + throw error; + } } /** @@ -89,7 +114,12 @@ export class ServersController { @Req() req: AuthenticatedRequest, @Param("id") id: string, ): Promise> { - return await this.connectionsService.getServerById(req.user.id, id); + try { + return await this.connectionsService.getServerById(req.user.id, id); + } catch (error) { + this.logger.error(`Get server failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -97,23 +127,33 @@ export class ServersController { */ @Post("onboard") @HttpCode(HttpStatus.CREATED) - onboard( + async onboard( @Req() req: AuthenticatedRequest, @Body() body: CreateServerOnboardRequestDto, ): Promise> { - return this.connectionsService.onboardServer(req.user.id, body); + try { + return await this.connectionsService.onboardServer(req.user.id, body); + } catch (error) { + this.logger.error(`Onboard server failed: ${toErrorMessage(error)}`); + throw error; + } } /** * Update server name. */ @Patch(":id") - update( + async update( @Req() req: AuthenticatedRequest, @Param("id") id: string, @Body() body: UpdateServerDto, ): Promise> { - return this.connectionsService.updateServer(req.user.id, id, body); + try { + return await this.connectionsService.updateServer(req.user.id, id, body); + } catch (error) { + this.logger.error(`Update server failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -121,11 +161,16 @@ export class ServersController { */ @Post(":id/connect") @HttpCode(HttpStatus.OK) - connect( + async connect( @Req() req: AuthenticatedRequest, @Param("id") id: string, ): Promise> { - return this.connectionsService.connectServer(req.user.id, id); + try { + return await this.connectionsService.connectServer(req.user.id, id); + } catch (error) { + this.logger.error(`Connect server failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -133,11 +178,16 @@ export class ServersController { */ @Post(":id/disconnect") @HttpCode(HttpStatus.OK) - disconnect( + async disconnect( @Req() req: AuthenticatedRequest, @Param("id") id: string, ): Promise> { - return this.connectionsService.disconnectServer(req.user.id, id); + try { + return await this.connectionsService.disconnectServer(req.user.id, id); + } catch (error) { + this.logger.error(`Disconnect server failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -145,13 +195,18 @@ export class ServersController { */ @Post(":id/delete") @HttpCode(HttpStatus.OK) - deleteServer( + async deleteServer( @Req() req: AuthenticatedRequest, @Param("id") id: string, @Body() body: DeleteServerRequestDto, ): Promise> { - return this.connectionsService.deleteServer(req.user.id, id, { - removeManagedServices: body.removeManagedServices === true, - }); + try { + return await this.connectionsService.deleteServer(req.user.id, id, { + removeManagedServices: body.removeManagedServices === true, + }); + } catch (error) { + this.logger.error(`Delete server failed: ${toErrorMessage(error)}`); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/server-connections/index.ts b/apps/control-panel-app/src/modules/server-connections/index.ts deleted file mode 100644 index 5b75042..0000000 --- a/apps/control-panel-app/src/modules/server-connections/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from "./server-connections.module"; -export * from "./entities/server.entity"; -export * from "./entities/server-ssh-credential.entity"; -export * from "./services/server-connections.service"; -export * from "./dto"; -export * from "./enums/server-provider.enum"; -export * from "./enums/server-ssh-auth-type.enum"; diff --git a/apps/control-panel-app/src/modules/server-connections/server-connections.constants.ts b/apps/control-panel-app/src/modules/server-connections/server-connections.constants.ts index eebf003..dc3e63b 100644 --- a/apps/control-panel-app/src/modules/server-connections/server-connections.constants.ts +++ b/apps/control-panel-app/src/modules/server-connections/server-connections.constants.ts @@ -1,6 +1,5 @@ export const DEFAULT_SSH_PORT = 22; export const ONBOARD_SSH_TEST_SERVER_ID = "onboard-ssh-test"; -export const SERVER_NAME_MAXLength = 120; export const SERVER_NAME_MAX_LENGTH = 120; export const SERVER_HOST_MAX_LENGTH = 255; export const SERVER_REGION_MAX_LENGTH = 120; diff --git a/apps/control-panel-app/src/modules/server-connections/services/remote-agent-install.service.ts b/apps/control-panel-app/src/modules/server-connections/services/remote-agent-install.service.ts index 33962d5..f504e2b 100644 --- a/apps/control-panel-app/src/modules/server-connections/services/remote-agent-install.service.ts +++ b/apps/control-panel-app/src/modules/server-connections/services/remote-agent-install.service.ts @@ -1,5 +1,5 @@ -import { Injectable } from "@nestjs/common"; - +import { Injectable, Logger } from "@nestjs/common"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { AgentInstallLogCallback, AgentInstallResult, @@ -11,12 +11,21 @@ export type { AgentInstallResult, RemoteAgentInstallInput }; @Injectable() export class RemoteAgentInstallService { + private readonly logger = new Logger(RemoteAgentInstallService.name); + constructor(private readonly agentInstall: AgentInstallService) {} - install( + async install( input: RemoteAgentInstallInput, options?: { onLogLine?: AgentInstallLogCallback }, ): Promise { - return this.agentInstall.installOnRemote(input, options); + try { + return await this.agentInstall.installOnRemote(input, options); + } catch (error) { + this.logger.error( + `Remote agent install failed for server '${input.connection.serverId}': ${toErrorMessage(error)}`, + ); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/server-connections/services/server-connections.service.ts b/apps/control-panel-app/src/modules/server-connections/services/server-connections.service.ts index fc800cb..868e8f6 100644 --- a/apps/control-panel-app/src/modules/server-connections/services/server-connections.service.ts +++ b/apps/control-panel-app/src/modules/server-connections/services/server-connections.service.ts @@ -39,7 +39,6 @@ import { EncryptionService } from "@shared/common"; import { SshHealthCheckService, SshCommandExecutorService, - ExecuteCommandDto, ExecuteResult, SshConnectionManager, SshConnectionOptions, @@ -66,6 +65,7 @@ import { EntityStatus } from "@control-panel/common/entity/base.entity"; import dayjs from "dayjs"; import { ERROR_MESSAGES } from "@control-panel/constants/error"; import { SUCCESS_MESSAGES } from "@control-panel/constants/success"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { ServiceResponse } from "@control-panel/common/interfaces/success-response.interface"; import { PaginatedResponse, parseDockerPsStdout } from "@shared/common"; import { @@ -147,49 +147,60 @@ export class ServerConnectionsService { onLogLine?: AgentInstallLogCallback; }, ): Promise { - const server = await this.serverRepository.findOne({ - where: { id: serverId, status: EntityStatus.ACTIVE, deletedAt: IsNull() }, - }); + try { + const server = await this.serverRepository.findOne({ + where: { + id: serverId, + status: EntityStatus.ACTIVE, + deletedAt: IsNull(), + }, + }); - if (!server) { - return { - success: false, - logs: [], - error: ERROR_MESSAGES.SERVER.INACTIVE_OR_MISSING, - }; - } + if (!server) { + return { + success: false, + logs: [], + error: ERROR_MESSAGES.SERVER.INACTIVE_OR_MISSING, + }; + } - if (server.serverType === ServerType.LOCAL) { - return this.agentInstall.installOnLocal( - { serverId }, - { onLogLine: options?.onLogLine }, - ); - } + if (server.serverType === ServerType.LOCAL) { + return this.agentInstall.installOnLocal( + { serverId }, + { onLogLine: options?.onLogLine }, + ); + } - const credential = await this.credentialRepository.findOne({ - where: { serverId, status: EntityStatus.ACTIVE, deletedAt: IsNull() }, - }); + const credential = await this.credentialRepository.findOne({ + where: { serverId, status: EntityStatus.ACTIVE, deletedAt: IsNull() }, + }); - if (!credential) { - return { - success: false, - logs: [], - error: ERROR_MESSAGES.SERVER.AGENT_CREDENTIALS_MISSING, - }; - } + if (!credential) { + return { + success: false, + logs: [], + error: ERROR_MESSAGES.SERVER.AGENT_CREDENTIALS_MISSING, + }; + } - return this.remoteAgentInstall.install( - { - connection: this.buildSshOptions( - server, - credential, - options?.plainPrivateKey, - ), - serverHost: server.host, - plainPrivateKey: options?.plainPrivateKey, - }, - { onLogLine: options?.onLogLine }, - ); + return this.remoteAgentInstall.install( + { + connection: this.buildSshOptions( + server, + credential, + options?.plainPrivateKey, + ), + serverHost: server.host, + plainPrivateKey: options?.plainPrivateKey, + }, + { onLogLine: options?.onLogLine }, + ); + } catch (error) { + this.logger.error( + `Ensure agent installed failed for server '${serverId}': ${toErrorMessage(error)}`, + ); + throw error; + } } private shouldInstallAgent(installAgent: boolean | undefined): boolean { @@ -208,58 +219,66 @@ export class ServerConnectionsService { async discoverContainers( serverId: string, ): Promise { - let discovered: DiscoveredContainerPayload[] | null = null; - let socketError: string | null = null; - - if (this.deploymentGateway.isAgentConnectedForServer(serverId)) { - const agentVersion = - this.deploymentGateway.getAgentVersion(serverId) ?? "unknown"; - const supportsDiscovery = this.deploymentGateway.agentSupports( - serverId, - DeploymentEvents.CONTAINER_DISCOVER, - ); + try { + let discovered: DiscoveredContainerPayload[] | null = null; + let socketError: string | null = null; - this.logger.log( - `[CONTAINER_DISCOVER] serverId=${serverId} agentVersion=${agentVersion} supportsContainerDiscover=${supportsDiscovery}`, - ); + if (this.deploymentGateway.isAgentConnectedForServer(serverId)) { + const agentVersion = + this.deploymentGateway.getAgentVersion(serverId) ?? "unknown"; + const supportsDiscovery = this.deploymentGateway.agentSupports( + serverId, + DeploymentEvents.CONTAINER_DISCOVER, + ); - if (!supportsDiscovery) { - socketError = `Connected agent (version ${agentVersion}) does not support container discovery`; - this.logger.warn( - `[CONTAINER_DISCOVER] skipping socket for server '${serverId}': ${socketError}`, + this.logger.log( + `[CONTAINER_DISCOVER] serverId=${serverId} agentVersion=${agentVersion} supportsContainerDiscover=${supportsDiscovery}`, ); - } else { - try { - discovered = await this.deploymentGateway.requestContainerDiscovery( - serverId, - SERVER_CONNECTIONS.SOCKET_CONTAINER_DISCOVER_ATTEMPT_MS, - ); - this.logger.log( - `[CONTAINER_DISCOVER] agent returned ${discovered.length} container(s) for server '${serverId}'`, - ); - } catch (error) { - socketError = error instanceof Error ? error.message : String(error); + + if (!supportsDiscovery) { + socketError = `Connected agent (version ${agentVersion}) does not support container discovery`; this.logger.warn( - `[CONTAINER_DISCOVER] agent socket failed for server '${serverId}': ${socketError}`, + `[CONTAINER_DISCOVER] skipping socket for server '${serverId}': ${socketError}`, ); + } else { + try { + discovered = await this.deploymentGateway.requestContainerDiscovery( + serverId, + SERVER_CONNECTIONS.SOCKET_CONTAINER_DISCOVER_ATTEMPT_MS, + ); + this.logger.log( + `[CONTAINER_DISCOVER] agent returned ${discovered.length} container(s) for server '${serverId}'`, + ); + } catch (error) { + socketError = + error instanceof Error ? error.message : String(error); + this.logger.warn( + `[CONTAINER_DISCOVER] agent socket failed for server '${serverId}': ${socketError}`, + ); + } } + } else { + socketError = `No connected agent for server '${serverId}'`; + this.logger.warn( + `[CONTAINER_DISCOVER] no connected agent for server '${serverId}'`, + ); } - } else { - socketError = `No connected agent for server '${serverId}'`; - this.logger.warn( - `[CONTAINER_DISCOVER] no connected agent for server '${serverId}'`, - ); - } - if (!discovered) { - this.logger.warn( - `[CONTAINER_DISCOVER] using host fallback for server '${serverId}'` + - (socketError ? `: ${socketError}` : ""), + if (!discovered) { + this.logger.warn( + `[CONTAINER_DISCOVER] using host fallback for server '${serverId}'` + + (socketError ? `: ${socketError}` : ""), + ); + return this.discoverContainersOnHost(serverId); + } + + return discovered; + } catch (error) { + this.logger.error( + `Discover containers failed for server '${serverId}': ${toErrorMessage(error)}`, ); - return this.discoverContainersOnHost(serverId); + throw error; } - - return discovered; } /** @@ -269,62 +288,73 @@ export class ServerConnectionsService { async discoverContainersOnHost( serverId: string, ): Promise { - const server = await this.serverRepository.findOne({ - where: { id: serverId, status: EntityStatus.ACTIVE, deletedAt: IsNull() }, - }); - - if (!server) { - throw new NotFoundException(ERROR_MESSAGES.SERVER.NOT_FOUND); - } - - let result: ExecuteResult; - - if (server.serverType === ServerType.LOCAL) { - const host = new LocalAgentHostAdapter(); - result = await host.executeCommand( - ServerConnectionsService.DOCKER_PS_COMMAND, - ServerConnectionsService.DOCKER_PS_TIMEOUT_MS, - ); - } else { - const credential = await this.credentialRepository.findOne({ + try { + const server = await this.serverRepository.findOne({ where: { - serverId, + id: serverId, status: EntityStatus.ACTIVE, deletedAt: IsNull(), }, - order: { createdAt: "DESC" }, }); - if (!credential) { - throw new BadRequestException( - ERROR_MESSAGES.SERVER.CREDENTIALS_NOT_FOUND, - ); + if (!server) { + throw new NotFoundException(ERROR_MESSAGES.SERVER.NOT_FOUND); } - const sshOptions = this.buildSshOptions(server, credential); - let client = this.sshManager.getConnection(serverId); - if (!client) { - client = await this.sshManager.connect(sshOptions); + let result: ExecuteResult; + + if (server.serverType === ServerType.LOCAL) { + const host = new LocalAgentHostAdapter(); + result = await host.executeCommand( + ServerConnectionsService.DOCKER_PS_COMMAND, + ServerConnectionsService.DOCKER_PS_TIMEOUT_MS, + ); + } else { + const credential = await this.credentialRepository.findOne({ + where: { + serverId, + status: EntityStatus.ACTIVE, + deletedAt: IsNull(), + }, + order: { createdAt: "DESC" }, + }); + + if (!credential) { + throw new BadRequestException( + ERROR_MESSAGES.SERVER.CREDENTIALS_NOT_FOUND, + ); + } + + const sshOptions = this.buildSshOptions(server, credential); + let client = this.sshManager.getConnection(serverId); + if (!client) { + client = await this.sshManager.connect(sshOptions); + } + + const host = new SshAgentHostAdapter(client, this.executor); + result = await host.executeCommand( + ServerConnectionsService.DOCKER_PS_COMMAND, + ServerConnectionsService.DOCKER_PS_TIMEOUT_MS, + ); } - const host = new SshAgentHostAdapter(client, this.executor); - result = await host.executeCommand( - ServerConnectionsService.DOCKER_PS_COMMAND, - ServerConnectionsService.DOCKER_PS_TIMEOUT_MS, - ); - } + if (!result.success) { + const detail = + result.stderr?.trim() || + result.stdout?.trim() || + `docker ps failed (exit ${result.exitCode ?? "unknown"})`; + throw new BadRequestException( + `Failed to list containers on server: ${detail}`, + ); + } - if (!result.success) { - const detail = - result.stderr?.trim() || - result.stdout?.trim() || - `docker ps failed (exit ${result.exitCode ?? "unknown"})`; - throw new BadRequestException( - `Failed to list containers on server: ${detail}`, + return parseDockerPsStdout(result.stdout); + } catch (error) { + this.logger.error( + `Discover containers on host failed for server '${serverId}': ${toErrorMessage(error)}`, ); + throw error; } - - return parseDockerPsStdout(result.stdout); } /** @@ -361,71 +391,82 @@ export class ServerConnectionsService { containerId: string, action: ContainerActionType, ): Promise { - const server = await this.serverRepository.findOne({ - where: { id: serverId, status: EntityStatus.ACTIVE, deletedAt: IsNull() }, - }); - - if (!server) { - throw new NotFoundException(ERROR_MESSAGES.SERVER.NOT_FOUND); - } - - const command = buildHostContainerActionCommand(action, containerId); - this.logger.log( - `[CONTAINER_ACTION] host fallback executing '${command}' on serverId=${serverId}`, - ); - let result: ExecuteResult; - - if (server.serverType === ServerType.LOCAL) { - const host = new LocalAgentHostAdapter(); - result = await host.executeCommand( - command, - ServerConnectionsService.CONTAINER_ACTION_TIMEOUT_MS, - ); - } else { - const credential = await this.credentialRepository.findOne({ + try { + const server = await this.serverRepository.findOne({ where: { - serverId, + id: serverId, status: EntityStatus.ACTIVE, deletedAt: IsNull(), }, - order: { createdAt: "DESC" }, }); - if (!credential) { - throw new BadRequestException( - ERROR_MESSAGES.SERVER.CREDENTIALS_NOT_FOUND, - ); + if (!server) { + throw new NotFoundException(ERROR_MESSAGES.SERVER.NOT_FOUND); } - const sshOptions = this.buildSshOptions(server, credential); - let client = this.sshManager.getConnection(serverId); - if (!client) { - client = await this.sshManager.connect(sshOptions); + const command = buildHostContainerActionCommand(action, containerId); + this.logger.log( + `[CONTAINER_ACTION] host fallback executing '${command}' on serverId=${serverId}`, + ); + let result: ExecuteResult; + + if (server.serverType === ServerType.LOCAL) { + const host = new LocalAgentHostAdapter(); + result = await host.executeCommand( + command, + ServerConnectionsService.CONTAINER_ACTION_TIMEOUT_MS, + ); + } else { + const credential = await this.credentialRepository.findOne({ + where: { + serverId, + status: EntityStatus.ACTIVE, + deletedAt: IsNull(), + }, + order: { createdAt: "DESC" }, + }); + + if (!credential) { + throw new BadRequestException( + ERROR_MESSAGES.SERVER.CREDENTIALS_NOT_FOUND, + ); + } + + const sshOptions = this.buildSshOptions(server, credential); + let client = this.sshManager.getConnection(serverId); + if (!client) { + client = await this.sshManager.connect(sshOptions); + } + + const host = new SshAgentHostAdapter(client, this.executor); + result = await host.executeCommand( + command, + ServerConnectionsService.CONTAINER_ACTION_TIMEOUT_MS, + ); } - const host = new SshAgentHostAdapter(client, this.executor); - result = await host.executeCommand( - command, - ServerConnectionsService.CONTAINER_ACTION_TIMEOUT_MS, + const success = Boolean(result.success); + const detail = + result.stderr?.trim() || + result.stdout?.trim() || + `docker ${action} failed (exit ${result.exitCode ?? "unknown"})`; + + return { + requestId: "host-fallback", + containerId: containerId.trim(), + action, + success, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + exitCode: result.exitCode ?? (success ? 0 : 1), + error: success ? undefined : detail, + }; + } catch (error) { + this.logger.error( + `Container action on host failed for server '${serverId}': ${toErrorMessage(error)}`, ); + throw error; } - - const success = Boolean(result.success); - const detail = - result.stderr?.trim() || - result.stdout?.trim() || - `docker ${action} failed (exit ${result.exitCode ?? "unknown"})`; - - return { - requestId: "host-fallback", - containerId: containerId.trim(), - action, - success, - stdout: result.stdout ?? "", - stderr: result.stderr ?? "", - exitCode: result.exitCode ?? (success ? 0 : 1), - error: success ? undefined : detail, - }; } /** @@ -600,79 +641,87 @@ export class ServerConnectionsService { userId: string, serverId: string, ): Promise { - await this.getOwnedServer(userId, serverId); + try { + await this.getOwnedServer(userId, serverId); - let resources: ServerResourcesMetricsPayload | null = null; - let socketError: string | null = null; + let resources: ServerResourcesMetricsPayload | null = null; + let socketError: string | null = null; - if (this.deploymentGateway.isAgentConnectedForServer(serverId)) { - const agentVersion = - this.deploymentGateway.getAgentVersion(serverId) ?? "unknown"; - const supportsResources = this.deploymentGateway.agentSupports( - serverId, - DeploymentEvents.SERVER_GET_RESOURCES, - ); + if (this.deploymentGateway.isAgentConnectedForServer(serverId)) { + const agentVersion = + this.deploymentGateway.getAgentVersion(serverId) ?? "unknown"; + const supportsResources = this.deploymentGateway.agentSupports( + serverId, + DeploymentEvents.SERVER_GET_RESOURCES, + ); - this.logger.log( - `[SERVER_RESOURCES] serverId=${serverId} agentVersion=${agentVersion} supportsServerResources=${supportsResources}`, - ); + this.logger.log( + `[SERVER_RESOURCES] serverId=${serverId} agentVersion=${agentVersion} supportsServerResources=${supportsResources}`, + ); + + if (!supportsResources) { + socketError = `Connected agent (version ${agentVersion}) does not support server resource collection`; + this.logger.warn( + `[SERVER_RESOURCES] skipping socket for server '${serverId}': ${socketError}`, + ); + } else { + try { + resources = await this.deploymentGateway.requestServerResources( + serverId, + SERVER_CONNECTIONS.SOCKET_RESOURCES_ATTEMPT_MS, + ); + this.logger.log( + `[SERVER_RESOURCES] agent returned metrics for server '${serverId}'`, + ); + } catch (error) { + socketError = + error instanceof Error ? error.message : String(error); + this.logger.warn( + `[SERVER_RESOURCES] agent socket failed for server '${serverId}': ${socketError}`, + ); + } + } + } else { + socketError = `No connected agent for server '${serverId}'`; + this.logger.warn( + `[SERVER_RESOURCES] no connected agent for server '${serverId}'`, + ); + } - if (!supportsResources) { - socketError = `Connected agent (version ${agentVersion}) does not support server resource collection`; + if (!resources) { this.logger.warn( - `[SERVER_RESOURCES] skipping socket for server '${serverId}': ${socketError}`, + `[SERVER_RESOURCES] using host fallback for server '${serverId}'` + + (socketError ? `: ${socketError}` : ""), ); - } else { try { - resources = await this.deploymentGateway.requestServerResources( - serverId, - SERVER_CONNECTIONS.SOCKET_RESOURCES_ATTEMPT_MS, - ); - this.logger.log( - `[SERVER_RESOURCES] agent returned metrics for server '${serverId}'`, - ); + resources = await this.collectResourcesOnHost(serverId); } catch (error) { - socketError = error instanceof Error ? error.message : String(error); - this.logger.warn( - `[SERVER_RESOURCES] agent socket failed for server '${serverId}': ${socketError}`, + const hostMessage = + error instanceof Error ? error.message : String(error); + const detail = socketError + ? `Agent: ${socketError}. Host: ${hostMessage}` + : hostMessage; + throw new BadRequestException( + `Failed to collect server resources: ${detail}`, ); } } - } else { - socketError = `No connected agent for server '${serverId}'`; - this.logger.warn( - `[SERVER_RESOURCES] no connected agent for server '${serverId}'`, - ); - } - if (!resources) { - this.logger.warn( - `[SERVER_RESOURCES] using host fallback for server '${serverId}'` + - (socketError ? `: ${socketError}` : ""), + return { + serverId, + timestamp: new Date().toISOString(), + cpu: resources.cpu, + memory: resources.memory, + disk: resources.disk, + network: resources.network, + system: resources.system, + }; + } catch (error) { + this.logger.error( + `Get server resources failed for server '${serverId}': ${toErrorMessage(error)}`, ); - try { - resources = await this.collectResourcesOnHost(serverId); - } catch (error) { - const hostMessage = - error instanceof Error ? error.message : String(error); - const detail = socketError - ? `Agent: ${socketError}. Host: ${hostMessage}` - : hostMessage; - throw new BadRequestException( - `Failed to collect server resources: ${detail}`, - ); - } + throw error; } - - return { - serverId, - timestamp: new Date().toISOString(), - cpu: resources.cpu, - memory: resources.memory, - disk: resources.disk, - network: resources.network, - system: resources.system, - }; } /** @@ -681,62 +730,73 @@ export class ServerConnectionsService { async collectResourcesOnHost( serverId: string, ): Promise { - const server = await this.serverRepository.findOne({ - where: { id: serverId, status: EntityStatus.ACTIVE, deletedAt: IsNull() }, - }); - - if (!server) { - throw new NotFoundException(ERROR_MESSAGES.SERVER.NOT_FOUND); - } - - let result: ExecuteResult; - - if (server.serverType === ServerType.LOCAL) { - const host = new LocalAgentHostAdapter(); - result = await host.executeCommand( - `bash -lc ${JSON.stringify(HOST_RESOURCES_SHELL_COMMAND)}`, - HOST_RESOURCES_COMMAND_TIMEOUT_MS, - ); - } else { - const credential = await this.credentialRepository.findOne({ + try { + const server = await this.serverRepository.findOne({ where: { - serverId, + id: serverId, status: EntityStatus.ACTIVE, deletedAt: IsNull(), }, - order: { createdAt: "DESC" }, }); - if (!credential) { - throw new BadRequestException( - ERROR_MESSAGES.SERVER.CREDENTIALS_NOT_FOUND, - ); + if (!server) { + throw new NotFoundException(ERROR_MESSAGES.SERVER.NOT_FOUND); } - const sshOptions = this.buildSshOptions(server, credential); - let client = this.sshManager.getConnection(serverId); - if (!client) { - client = await this.sshManager.connect(sshOptions); + let result: ExecuteResult; + + if (server.serverType === ServerType.LOCAL) { + const host = new LocalAgentHostAdapter(); + result = await host.executeCommand( + `bash -lc ${JSON.stringify(HOST_RESOURCES_SHELL_COMMAND)}`, + HOST_RESOURCES_COMMAND_TIMEOUT_MS, + ); + } else { + const credential = await this.credentialRepository.findOne({ + where: { + serverId, + status: EntityStatus.ACTIVE, + deletedAt: IsNull(), + }, + order: { createdAt: "DESC" }, + }); + + if (!credential) { + throw new BadRequestException( + ERROR_MESSAGES.SERVER.CREDENTIALS_NOT_FOUND, + ); + } + + const sshOptions = this.buildSshOptions(server, credential); + let client = this.sshManager.getConnection(serverId); + if (!client) { + client = await this.sshManager.connect(sshOptions); + } + + const host = new SshAgentHostAdapter(client, this.executor); + result = await host.executeCommand( + `bash -lc ${JSON.stringify(HOST_RESOURCES_SHELL_COMMAND)}`, + HOST_RESOURCES_COMMAND_TIMEOUT_MS, + ); } - const host = new SshAgentHostAdapter(client, this.executor); - result = await host.executeCommand( - `bash -lc ${JSON.stringify(HOST_RESOURCES_SHELL_COMMAND)}`, - HOST_RESOURCES_COMMAND_TIMEOUT_MS, - ); - } + if (!result.success) { + const detail = + result.stderr?.trim() || + result.stdout?.trim() || + `Host resource collection failed (exit ${result.exitCode ?? "unknown"})`; + throw new BadRequestException( + `Failed to collect server resources on host: ${detail}`, + ); + } - if (!result.success) { - const detail = - result.stderr?.trim() || - result.stdout?.trim() || - `Host resource collection failed (exit ${result.exitCode ?? "unknown"})`; - throw new BadRequestException( - `Failed to collect server resources on host: ${detail}`, + return parseHostResourcesOutput(result.stdout); + } catch (error) { + this.logger.error( + `Collect resources on host failed for server '${serverId}': ${toErrorMessage(error)}`, ); + throw error; } - - return parseHostResourcesOutput(result.stdout); } private buildSshOptions( @@ -1343,7 +1403,9 @@ export class ServerConnectionsService { try { await queryRunner.rollbackTransaction(); } catch (rollbackErr) { - console.warn("rollback failed:", (rollbackErr as Error).message); + this.logger.warn( + `rollback failed: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`, + ); } logs.push(SERVER_ONBOARD_LOGS.TRANSACTION_ROLLED_BACK); @@ -1385,43 +1447,50 @@ export class ServerConnectionsService { userId: string, id: string, ): Promise> { - const serverOptions = await this.getServerConnectionOptions(userId, id); + try { + const serverOptions = await this.getServerConnectionOptions(userId, id); + + if (this.sshManager.getConnection(id)) { + throw new OperationFailedException( + ERROR_MESSAGES.SERVER.ALREADY_CONNECTED, + ERROR_MESSAGES.SERVER.ALREADY_CONNECTED, + HttpStatus.CONFLICT, + { errorCode: ServerErrorCode.ALREADY_CONNECTED }, + ); + } - if (this.sshManager.getConnection(id)) { - throw new OperationFailedException( - ERROR_MESSAGES.SERVER.ALREADY_CONNECTED, - ERROR_MESSAGES.SERVER.ALREADY_CONNECTED, - HttpStatus.CONFLICT, - { errorCode: ServerErrorCode.ALREADY_CONNECTED }, - ); - } + try { + await this.sshManager.connect(serverOptions); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } - try { - await this.sshManager.connect(serverOptions); - } catch (error) { - if (error instanceof HttpException) { - throw error; + throw new OperationFailedException( + ERROR_MESSAGES.SERVER.CONNECTION_FAILED, + error instanceof Error + ? error.message + : ERROR_MESSAGES.SERVER.CONNECTION_FAILED, + HttpStatus.BAD_REQUEST, + { errorCode: ServerErrorCode.CONNECTION_FAILED }, + ); } - throw new OperationFailedException( - ERROR_MESSAGES.SERVER.CONNECTION_FAILED, - error instanceof Error - ? error.message - : ERROR_MESSAGES.SERVER.CONNECTION_FAILED, - HttpStatus.BAD_REQUEST, - { errorCode: ServerErrorCode.CONNECTION_FAILED }, + await this.serverRepository.update( + { id }, + { lastConnectedAt: dayjs().unix() }, ); - } - - await this.serverRepository.update( - { id }, - { lastConnectedAt: dayjs().unix() }, - ); - return { - message: SUCCESS_MESSAGES.SERVER.CONNECTED, - data: { connected: true }, - }; + return { + message: SUCCESS_MESSAGES.SERVER.CONNECTED, + data: { connected: true }, + }; + } catch (error) { + this.logger.error( + `Connect server '${id}' failed: ${toErrorMessage(error)}`, + ); + throw error; + } } /** @@ -1433,13 +1502,20 @@ export class ServerConnectionsService { userId: string, id: string, ): Promise> { - await this.getServerConnectionOptions(userId, id); - this.sshManager.disconnect(id); + try { + await this.getServerConnectionOptions(userId, id); + this.sshManager.disconnect(id); - return { - message: SUCCESS_MESSAGES.SERVER.DISCONNECTED, - data: { connected: false }, - }; + return { + message: SUCCESS_MESSAGES.SERVER.DISCONNECTED, + data: { connected: false }, + }; + } catch (error) { + this.logger.error( + `Disconnect server '${id}' failed: ${toErrorMessage(error)}`, + ); + throw error; + } } /** @@ -1498,111 +1574,116 @@ export class ServerConnectionsService { userId: string, query: ListServersQueryDto, ): Promise>> { - const page = query.page ?? DEFAULT_LIST_PAGE; - const limit = query.limit ?? DEFAULT_LIST_LIMIT; - const skip = (page - 1) * limit; - const sortBy = query.sortBy ?? DEFAULT_LIST_SORT_BY; - const sortOrder = ( - query.sortOrder ?? DEFAULT_LIST_SORT_ORDER - ).toUpperCase() as "ASC" | "DESC"; + try { + const page = query.page ?? DEFAULT_LIST_PAGE; + const limit = query.limit ?? DEFAULT_LIST_LIMIT; + const skip = (page - 1) * limit; + const sortBy = query.sortBy ?? DEFAULT_LIST_SORT_BY; + const sortOrder = ( + query.sortOrder ?? DEFAULT_LIST_SORT_ORDER + ).toUpperCase() as "ASC" | "DESC"; - const connectedIds = this.sshManager.getConnectedServerIds(); + const connectedIds = this.sshManager.getConnectedServerIds(); - const where: FindOptionsWhere = { - userId, - deletedAt: IsNull(), - status: query.status ?? EntityStatus.ACTIVE, - }; + const where: FindOptionsWhere = { + userId, + deletedAt: IsNull(), + status: query.status ?? EntityStatus.ACTIVE, + }; - if (query.provider) { - where.provider = query.provider; - } + if (query.provider) { + where.provider = query.provider; + } - if (query.serverType) { - where.serverType = query.serverType; - } + if (query.serverType) { + where.serverType = query.serverType; + } - if (query.connected === true) { - if (connectedIds.length === 0) { - return { - message: SUCCESS_MESSAGES.SERVER.LIST, - data: { - data: [], - pagination: { - page, - limit, - total: 0, - totalPages: 0, + if (query.connected === true) { + if (connectedIds.length === 0) { + return { + message: SUCCESS_MESSAGES.SERVER.LIST, + data: { + data: [], + pagination: { + page, + limit, + total: 0, + totalPages: 0, + }, }, - }, - }; - } + }; + } - where.id = In(connectedIds); - } + where.id = In(connectedIds); + } - if (query.connected === false && connectedIds.length > 0) { - where.id = Not(In(connectedIds)); - } + if (query.connected === false && connectedIds.length > 0) { + where.id = Not(In(connectedIds)); + } - let searchWhere: FindOptionsWhere[] | undefined; + let searchWhere: FindOptionsWhere[] | undefined; - if (query.search?.trim()) { - const searchTerm = query.search.trim(); - const search = ILike(`%${searchTerm}%`); + if (query.search?.trim()) { + const searchTerm = query.search.trim(); + const search = ILike(`%${searchTerm}%`); - searchWhere = [ - { - ...where, - name: search, - }, - { - ...where, - host: search, - }, - { - ...where, - username: search, - }, - ]; + searchWhere = [ + { + ...where, + name: search, + }, + { + ...where, + host: search, + }, + { + ...where, + username: search, + }, + ]; - const searchValue = String(query.search); + const searchValue = String(query.search); - if (isUUID(searchValue)) { - searchWhere.push({ - ...where, - id: searchValue, - }); + if (isUUID(searchValue)) { + searchWhere.push({ + ...where, + id: searchValue, + }); + } } - } - const [servers, total] = await this.serverRepository.findAndCount({ - where: searchWhere ?? where, - order: { - [sortBy]: sortOrder, - }, - skip, - take: limit, - }); + const [servers, total] = await this.serverRepository.findAndCount({ + where: searchWhere ?? where, + order: { + [sortBy]: sortOrder, + }, + skip, + take: limit, + }); - const totalPages = total === 0 ? 0 : Math.ceil(total / limit); + const totalPages = total === 0 ? 0 : Math.ceil(total / limit); - return { - message: SUCCESS_MESSAGES.SERVER.LIST, - data: { - data: servers.map((server) => - toServerResponseDto(server, this.sshManager, (id) => - this.deploymentGateway.isAgentConnectedForServer(id), + return { + message: SUCCESS_MESSAGES.SERVER.LIST, + data: { + data: servers.map((server) => + toServerResponseDto(server, this.sshManager, (id) => + this.deploymentGateway.isAgentConnectedForServer(id), + ), ), - ), - pagination: { - page, - limit, - total, - totalPages, + pagination: { + page, + limit, + total, + totalPages, + }, }, - }, - }; + }; + } catch (error) { + this.logger.error(`List servers failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -1612,14 +1693,19 @@ export class ServerConnectionsService { userId: string, id: string, ): Promise> { - const server = await this.getOwnedServer(userId, id); + try { + const server = await this.getOwnedServer(userId, id); - return { - message: SUCCESS_MESSAGES.SERVER.FETCHED, - data: toServerResponseDto(server, this.sshManager, (id) => - this.deploymentGateway.isAgentConnectedForServer(id), - ), - }; + return { + message: SUCCESS_MESSAGES.SERVER.FETCHED, + data: toServerResponseDto(server, this.sshManager, (id) => + this.deploymentGateway.isAgentConnectedForServer(id), + ), + }; + } catch (error) { + this.logger.error(`Get server '${id}' failed: ${toErrorMessage(error)}`); + throw error; + } } /** @@ -1630,101 +1716,26 @@ export class ServerConnectionsService { id: string, input: UpdateServerDto, ): Promise> { - const server = await this.getOwnedServer(userId, id); - - await this.serverRepository.update({ id: server.id }, { name: input.name }); - - return { - message: SUCCESS_MESSAGES.SERVER.UPDATED, - data: toServerResponseDto(server, this.sshManager, (serverId) => - this.deploymentGateway.isAgentConnectedForServer(serverId), - ), - }; - } - - /** - * Test connection - * @param id - * @returns - */ - async test(id: string): Promise { - const server = await this.serverRepository.findOne({ - where: { id, status: EntityStatus.ACTIVE, deletedAt: IsNull() }, - }); - if (!server) { - return { success: false, message: ERROR_MESSAGES.SERVER.NOT_FOUND }; - } - const creds = await this.credentialRepository.find({ - where: { serverId: id }, - order: { createdAt: "DESC" }, - }); - const credential = creds[0]; - if (!credential) { - return { - success: false, - message: ERROR_MESSAGES.SERVER.CREDENTIALS_NOT_FOUND, - }; - } + try { + const server = await this.getOwnedServer(userId, id); - return this.health.testConnection({ - serverId: id, - host: server.host, - port: server.port, - username: server.username, - authType: credential.authType, - encryptedPassword: credential.encryptedPassword ?? null, - encryptedPrivateKey: credential.encryptedPrivateKey ?? null, - privateKeyPassphrase: credential.privateKeyPassphrase ?? null, - }); - } + await this.serverRepository.update( + { id: server.id }, + { name: input.name }, + ); - /** - * execute commands - * @param id - * @param body - * @returns - */ - async execute( - id: string, - body: ExecuteCommandDto, - ): Promise { - const server = await this.serverRepository.findOne({ - where: { id, status: EntityStatus.ACTIVE, deletedAt: IsNull() }, - }); - if (!server) { - return { success: false, message: ERROR_MESSAGES.SERVER.NOT_FOUND }; - } - const creds = await this.credentialRepository.find({ - where: { serverId: id }, - order: { createdAt: "DESC" }, - }); - const credential = creds[0]; - if (!credential) { return { - success: false, - message: ERROR_MESSAGES.SERVER.CREDENTIALS_NOT_FOUND, + message: SUCCESS_MESSAGES.SERVER.UPDATED, + data: toServerResponseDto(server, this.sshManager, (serverId) => + this.deploymentGateway.isAgentConnectedForServer(serverId), + ), }; + } catch (error) { + this.logger.error( + `Update server '${id}' failed: ${toErrorMessage(error)}`, + ); + throw error; } - - const options = { - serverId: id, - host: server.host, - port: server.port, - username: server.username, - authType: credential.authType, - encryptedPassword: credential.encryptedPassword ?? null, - encryptedPrivateKey: credential.encryptedPrivateKey ?? null, - privateKeyPassphrase: credential.privateKeyPassphrase ?? null, - }; - - await this.health.testConnection(options); - - const result = await this.executor.executeCommand( - id, - body.command, - body.timeout, - ); - return result; } /** @@ -1733,6 +1744,11 @@ export class ServerConnectionsService { * @returns */ async findOne(options: FindOneOptions) { - return await this.serverRepository.findOne(options); + try { + return await this.serverRepository.findOne(options); + } catch (error) { + this.logger.error(`Find server failed: ${toErrorMessage(error)}`); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/service-template/controllers/public-service-template.controller.ts b/apps/control-panel-app/src/modules/service-template/controllers/public-service-template.controller.ts index 4f846d9..9651a15 100644 --- a/apps/control-panel-app/src/modules/service-template/controllers/public-service-template.controller.ts +++ b/apps/control-panel-app/src/modules/service-template/controllers/public-service-template.controller.ts @@ -2,12 +2,14 @@ import { Controller, Get, Header, + Logger, Param, Query, UseGuards, } from "@nestjs/common"; import { KubearaPublicOriginGuard } from "@control-panel/common/guards/kubeara-public-origin.guard"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import type { PublicTemplateDetailsDto, @@ -18,6 +20,8 @@ import { ServiceTemplateService } from "../services/service-template.service"; @UseGuards(KubearaPublicOriginGuard) @Controller("public/templates") export class PublicServiceTemplateController { + private readonly logger = new Logger(PublicServiceTemplateController.name); + constructor( private readonly serviceTemplateService: ServiceTemplateService, ) {} @@ -27,16 +31,30 @@ export class PublicServiceTemplateController { */ @Get() @Header("Cache-Control", "public, max-age=300") - listTemplates( + async listTemplates( @Query("category") category?: string, ): Promise { - return this.serviceTemplateService.listPublicTemplates(category); + try { + return await this.serviceTemplateService.listPublicTemplates(category); + } catch (error) { + this.logger.error( + `List public templates failed: ${toErrorMessage(error)}`, + ); + throw error; + } } @Get("categories") @Header("Cache-Control", "public, max-age=300") - listCategories(): Promise { - return this.serviceTemplateService.listUniqueCategories(); + async listCategories(): Promise { + try { + return await this.serviceTemplateService.listUniqueCategories(); + } catch (error) { + this.logger.error( + `List public template categories failed: ${toErrorMessage(error)}`, + ); + throw error; + } } /** @@ -44,7 +62,14 @@ export class PublicServiceTemplateController { */ @Get(":slug") @Header("Cache-Control", "public, max-age=300") - getTemplate(@Param("slug") slug: string): Promise { - return this.serviceTemplateService.getPublicTemplateDetails(slug); + async getTemplate( + @Param("slug") slug: string, + ): Promise { + try { + return await this.serviceTemplateService.getPublicTemplateDetails(slug); + } catch (error) { + this.logger.error(`Get public template failed: ${toErrorMessage(error)}`); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/service-template/controllers/service-template.controller.ts b/apps/control-panel-app/src/modules/service-template/controllers/service-template.controller.ts index adfd027..0aaca9c 100644 --- a/apps/control-panel-app/src/modules/service-template/controllers/service-template.controller.ts +++ b/apps/control-panel-app/src/modules/service-template/controllers/service-template.controller.ts @@ -2,6 +2,7 @@ import { BadRequestException, Controller, Get, + Logger, Param, Query, Res, @@ -9,6 +10,7 @@ import { import type { Response } from "express"; import { PaginatedResponse } from "@shared/common"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { ServiceResponse } from "@control-panel/common/interfaces/success-response.interface"; import { ListTemplatesQueryDto } from "../dto/list-templates-query.dto"; @@ -17,6 +19,8 @@ import { ServiceTemplateService } from "../services/service-template.service"; @Controller("templates") export class ServiceTemplateController { + private readonly logger = new Logger(ServiceTemplateController.name); + constructor( private readonly serviceTemplateService: ServiceTemplateService, ) {} @@ -25,18 +29,30 @@ export class ServiceTemplateController { * Lists templates with pagination. */ @Get() - listTemplates( + async listTemplates( @Query() query: ListTemplatesQueryDto, ): Promise>> { - return this.serviceTemplateService.listTemplatesPaginated(query); + try { + return await this.serviceTemplateService.listTemplatesPaginated(query); + } catch (error) { + this.logger.error(`List templates failed: ${toErrorMessage(error)}`); + throw error; + } } /** * Lists unique template categories. */ @Get("categories") - listCategories(): Promise> { - return this.serviceTemplateService.listTemplateCategories(); + async listCategories(): Promise> { + try { + return await this.serviceTemplateService.listTemplateCategories(); + } catch (error) { + this.logger.error( + `List template categories failed: ${toErrorMessage(error)}`, + ); + throw error; + } } /** @@ -48,31 +64,36 @@ export class ServiceTemplateController { @Query("format") format = "details", @Res({ passthrough: true }) res: Response, ) { - const normalized = (format || "details").toLowerCase(); + try { + const normalized = (format || "details").toLowerCase(); - if (normalized === "details") { - return this.serviceTemplateService.getTemplateDetails(slug); - } + if (normalized === "details") { + return await this.serviceTemplateService.getTemplateDetails(slug); + } - if (normalized === "yml" || normalized === "yaml") { - const tpl = await this.serviceTemplateService.getTemplate(slug, format); - res.setHeader("Content-Type", "application/x-yaml"); - res.setHeader( - "Content-Disposition", - `attachment; filename="${slug}.yml"`, - ); - res.send((tpl as { compose: string }).compose); - return; - } + if (normalized === "yml" || normalized === "yaml") { + const tpl = await this.serviceTemplateService.getTemplate(slug, format); + res.setHeader("Content-Type", "application/x-yaml"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${slug}.yml"`, + ); + res.send((tpl as { compose: string }).compose); + return; + } - if (normalized === "json" || normalized === "base64") { - const tpl = await this.serviceTemplateService.getTemplate(slug, format); - res.json(tpl); - return; - } + if (normalized === "json" || normalized === "base64") { + const tpl = await this.serviceTemplateService.getTemplate(slug, format); + res.json(tpl); + return; + } - throw new BadRequestException( - `Unsupported format '${format}'. Supported formats: details, yml, yaml, json, base64.`, - ); + throw new BadRequestException( + `Unsupported format '${format}'. Supported formats: details, yml, yaml, json, base64.`, + ); + } catch (error) { + this.logger.error(`Get template failed: ${toErrorMessage(error)}`); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/service-template/index.ts b/apps/control-panel-app/src/modules/service-template/index.ts deleted file mode 100644 index 4ac930d..0000000 --- a/apps/control-panel-app/src/modules/service-template/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./service-template.module"; -export * from "./services/service-template.service"; -export * from "./entities/service-template.entity"; diff --git a/apps/control-panel-app/src/modules/terminal/constants/terminal.constants.ts b/apps/control-panel-app/src/modules/terminal/constants/terminal.constants.ts index 0d743eb..07a783b 100644 --- a/apps/control-panel-app/src/modules/terminal/constants/terminal.constants.ts +++ b/apps/control-panel-app/src/modules/terminal/constants/terminal.constants.ts @@ -1,2 +1,12 @@ -export const DEFAULT_TERMINAL_COLS = 80; -export const DEFAULT_TERMINAL_ROWS = 24; +export { + DEFAULT_TERMINAL_COLS, + DEFAULT_TERMINAL_ROWS, + TERMINAL_TERM_TYPE, + MIN_TERMINAL_COLS, + MAX_TERMINAL_COLS, + MIN_TERMINAL_ROWS, + MAX_TERMINAL_ROWS, + SSH_TERMINAL_CONNECTION_ID_PREFIX, + SSH_TERMINAL_WINDOW_PIXELS, + TERMINAL_OUTPUT_ENCODING, +} from "@shared/common"; diff --git a/apps/control-panel-app/src/modules/terminal/ssh-terminal.service.ts b/apps/control-panel-app/src/modules/terminal/ssh-terminal.service.ts index 79ec4d2..3bb0609 100644 --- a/apps/control-panel-app/src/modules/terminal/ssh-terminal.service.ts +++ b/apps/control-panel-app/src/modules/terminal/ssh-terminal.service.ts @@ -10,6 +10,12 @@ import { InjectRepository } from "@nestjs/typeorm"; import { IsNull, Repository } from "typeorm"; import { randomUUID } from "node:crypto"; import { SshConnectionManager, SshConnectionOptions } from "@shared/ssh"; +import { + SSH_TERMINAL_CONNECTION_ID_PREFIX, + SSH_TERMINAL_WINDOW_PIXELS, + TERMINAL_OUTPUT_ENCODING, + TERMINAL_TERM_TYPE, +} from "@shared/common"; import { DeploymentGateway } from "@control-panel/websocket/websocket.gateway"; import { ServerEntity } from "@control-panel/modules/server-connections/entities/server.entity"; import { ServerSshCredentialEntity } from "@control-panel/modules/server-connections/entities/server-ssh-credential.entity"; @@ -34,131 +40,180 @@ export class SshTerminalService { private readonly deploymentGateway: DeploymentGateway, ) {} + /** + * Creates an SSH fallback terminal session for the given server. + */ async createSession( serverId: string, userId: string, cols: number, rows: number, ): Promise { - const server = await this.serverRepository.findOne({ - where: { - id: serverId, - userId, - status: EntityStatus.ACTIVE, - deletedAt: IsNull(), - }, - }); - - if (!server) { - throw new NotFoundException(ERROR_MESSAGES.SERVER.NOT_FOUND); - } - - if (server.serverType === ServerType.LOCAL) { - throw new BadRequestException( - "SSH terminal fallback is not available for local servers without an agent", - ); - } + try { + const server = await this.serverRepository.findOne({ + where: { + id: serverId, + userId, + status: EntityStatus.ACTIVE, + deletedAt: IsNull(), + }, + }); - const credential = await this.credentialRepository.findOne({ - where: { - serverId, - status: EntityStatus.ACTIVE, - deletedAt: IsNull(), - }, - order: { createdAt: "DESC" }, - }); - - if (!credential) { - throw new BadRequestException( - ERROR_MESSAGES.SERVER.CREDENTIALS_NOT_FOUND, - ); - } + if (!server) { + throw new NotFoundException(ERROR_MESSAGES.SERVER.NOT_FOUND); + } - const sessionId = randomUUID(); - const connectionId = `terminal-${sessionId}`; - const sshOptions = this.buildSshOptions(server, credential); - - const client = await this.sshManager.connect({ - ...sshOptions, - serverId: connectionId, - }); - - return new Promise((resolve, reject) => { - client.shell({ term: "xterm-256color", cols, rows }, (error, stream) => { - if (error || !stream) { - this.sshManager.disconnect(connectionId); - reject( - new BadRequestException( - `SSH shell failed: ${error?.message ?? "unknown error"}`, - ), - ); - return; - } + if (server.serverType === ServerType.LOCAL) { + throw new BadRequestException( + ERROR_MESSAGES.TERMINAL.SSH_LOCAL_UNAVAILABLE, + ); + } - this.sessions.set(sessionId, { - sessionId, + const credential = await this.credentialRepository.findOne({ + where: { serverId, - connectionId, - stream, - }); - - stream.on("data", (data: Buffer) => { - this.deploymentGateway.broadcastTerminalOutput( - sessionId, - data.toString("utf8"), - ); - }); + status: EntityStatus.ACTIVE, + deletedAt: IsNull(), + }, + order: { createdAt: "DESC" }, + }); - stream.stderr.on("data", (data: Buffer) => { - this.deploymentGateway.broadcastTerminalOutput( - sessionId, - data.toString("utf8"), - ); - }); + if (!credential) { + throw new BadRequestException( + ERROR_MESSAGES.SERVER.CREDENTIALS_NOT_FOUND, + ); + } - stream.on("close", () => { - this.cleanupSession(sessionId, { notifyClients: true }); - }); + const sessionId = randomUUID(); + const connectionId = `${SSH_TERMINAL_CONNECTION_ID_PREFIX}${sessionId}`; + const sshOptions = this.buildSshOptions(server, credential); - this.deploymentGateway.registerTerminalSession( - sessionId, - serverId, - userId, - TerminalTransport.SSH, - ); + const client = await this.sshManager.connect({ + ...sshOptions, + serverId: connectionId, + }); - this.logger.log( - `[TERMINAL] SSH fallback session created sessionId=${sessionId} serverId=${serverId}`, - ); + return new Promise((resolve, reject) => { + try { + client.shell( + { term: TERMINAL_TERM_TYPE, cols, rows }, + (error, stream) => { + if (error || !stream) { + this.sshManager.disconnect(connectionId); + reject( + new BadRequestException( + `${ERROR_MESSAGES.TERMINAL.SSH_SHELL_FAILED}: ${error?.message ?? ERROR_MESSAGES.TERMINAL.UNKNOWN_ERROR}`, + ), + ); + return; + } + + this.sessions.set(sessionId, { + sessionId, + serverId, + connectionId, + stream, + }); + + stream.on("data", (data: Buffer) => { + this.deploymentGateway.broadcastTerminalOutput( + sessionId, + data.toString(TERMINAL_OUTPUT_ENCODING), + ); + }); + + stream.stderr.on("data", (data: Buffer) => { + this.deploymentGateway.broadcastTerminalOutput( + sessionId, + data.toString(TERMINAL_OUTPUT_ENCODING), + ); + }); - resolve(sessionId); + stream.on("close", () => { + this.cleanupSession(sessionId, { notifyClients: true }); + }); + + this.deploymentGateway.registerTerminalSession( + sessionId, + serverId, + userId, + TerminalTransport.SSH, + ); + + this.logger.log( + `[TERMINAL] SSH fallback session created sessionId=${sessionId} serverId=${serverId}`, + ); + + resolve(sessionId); + }, + ); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } }); - }); + } catch (error) { + this.logger.error( + `Failed to create SSH terminal session for server '${serverId}': ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } + /** + * Writes input to an SSH terminal session. + */ writeInput(sessionId: string, data: string): void { - const session = this.sessions.get(sessionId); - if (!session) { - return; - } + try { + const session = this.sessions.get(sessionId); + if (!session) { + return; + } - session.stream.write(data); + session.stream.write(data); + } catch (error) { + this.logger.error( + `Failed to write SSH terminal input sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Resizes an SSH terminal session. + */ resize(sessionId: string, cols: number, rows: number): void { - const session = this.sessions.get(sessionId); - if (!session) { - return; - } + try { + const session = this.sessions.get(sessionId); + if (!session) { + return; + } - session.stream.setWindow(rows, cols, 0, 0); + session.stream.setWindow( + rows, + cols, + SSH_TERMINAL_WINDOW_PIXELS.HEIGHT, + SSH_TERMINAL_WINDOW_PIXELS.WIDTH, + ); + } catch (error) { + this.logger.error( + `Failed to resize SSH terminal sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Closes an SSH terminal session. + */ closeSession( sessionId: string, options: { notifyClients?: boolean } = {}, ): void { - this.cleanupSession(sessionId, options); + try { + this.cleanupSession(sessionId, options); + } catch (error) { + this.logger.error( + `Failed to close SSH terminal sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } private cleanupSession( diff --git a/apps/control-panel-app/src/modules/terminal/terminal.controller.ts b/apps/control-panel-app/src/modules/terminal/terminal.controller.ts index 4f28767..ee5ebbb 100644 --- a/apps/control-panel-app/src/modules/terminal/terminal.controller.ts +++ b/apps/control-panel-app/src/modules/terminal/terminal.controller.ts @@ -3,6 +3,7 @@ import { Controller, HttpCode, HttpStatus, + Logger, Param, Post, Req, @@ -23,6 +24,8 @@ import { @UseGuards(AccessTokenGuard) @Controller("servers/:serverId/terminal") export class TerminalController { + private readonly logger = new Logger(TerminalController.name); + constructor(private readonly terminalService: TerminalService) {} /** @@ -31,12 +34,23 @@ export class TerminalController { @Post("connect") @HttpCode(HttpStatus.OK) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) - connect( + async connect( @Req() req: AuthenticatedRequest, @Param("serverId") serverId: string, @Body() body: TerminalConnectDto, ): Promise> { - return this.terminalService.connectTerminal(req.user.id, serverId, body); + try { + return await this.terminalService.connectTerminal( + req.user.id, + serverId, + body, + ); + } catch (error) { + this.logger.error( + `Terminal connect failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } /** @@ -45,11 +59,22 @@ export class TerminalController { @Post("disconnect") @HttpCode(HttpStatus.OK) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) - disconnect( + async disconnect( @Req() req: AuthenticatedRequest, @Param("serverId") serverId: string, @Body() body: TerminalDisconnectDto, ): Promise> { - return this.terminalService.disconnectTerminal(req.user.id, serverId, body); + try { + return await this.terminalService.disconnectTerminal( + req.user.id, + serverId, + body, + ); + } catch (error) { + this.logger.error( + `Terminal disconnect failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/terminal/terminal.service.ts b/apps/control-panel-app/src/modules/terminal/terminal.service.ts index c7be42f..c55ae9d 100644 --- a/apps/control-panel-app/src/modules/terminal/terminal.service.ts +++ b/apps/control-panel-app/src/modules/terminal/terminal.service.ts @@ -47,53 +47,61 @@ export class TerminalService { serverId: string, body: TerminalConnectDto, ): Promise> { - await this.assertActiveServerForUser(serverId, userId); - - const cols = body.cols ?? DEFAULT_TERMINAL_COLS; - const rows = body.rows ?? DEFAULT_TERMINAL_ROWS; - - const agentSessionId = await this.tryAgentTerminalConnect( - serverId, - userId, - cols, - rows, - ); + try { + await this.assertActiveServerForUser(serverId, userId); - if (agentSessionId) { - return { - message: SUCCESS_MESSAGES.TERMINAL.CONNECTED, - data: { - sessionId: agentSessionId, - serverId, - transport: TerminalTransport.AGENT, - }, - }; - } + const cols = body.cols ?? DEFAULT_TERMINAL_COLS; + const rows = body.rows ?? DEFAULT_TERMINAL_ROWS; - try { - const sessionId = await this.sshTerminalService.createSession( + const agentSessionId = await this.tryAgentTerminalConnect( serverId, userId, cols, rows, ); - return { - message: SUCCESS_MESSAGES.TERMINAL.SSH_CONNECTED, - data: { - sessionId, + if (agentSessionId) { + return { + message: SUCCESS_MESSAGES.TERMINAL.CONNECTED, + data: { + sessionId: agentSessionId, + serverId, + transport: TerminalTransport.AGENT, + }, + }; + } + + try { + const sessionId = await this.sshTerminalService.createSession( serverId, - transport: TerminalTransport.SSH, - }, - }; + userId, + cols, + rows, + ); + + return { + message: SUCCESS_MESSAGES.TERMINAL.SSH_CONNECTED, + data: { + sessionId, + serverId, + transport: TerminalTransport.SSH, + }, + }; + } catch (error) { + const sshDetail = + error instanceof Error ? error.message : String(error); + this.logger.warn( + `[TERMINAL] SSH fallback failed for server '${serverId}': ${sshDetail}`, + ); + throw new BadRequestException( + `${ERROR_MESSAGES.TERMINAL.CONNECT_FAILED}: ${sshDetail}`, + ); + } } catch (error) { - const sshDetail = error instanceof Error ? error.message : String(error); - this.logger.warn( - `[TERMINAL] SSH fallback failed for server '${serverId}': ${sshDetail}`, - ); - throw new BadRequestException( - `${ERROR_MESSAGES.TERMINAL.CONNECT_FAILED}: ${sshDetail}`, + this.logger.error( + `Failed to connect terminal for server '${serverId}': ${error instanceof Error ? error.message : String(error)}`, ); + throw error; } } @@ -105,34 +113,51 @@ export class TerminalService { serverId: string, body: TerminalDisconnectDto, ): Promise> { - await this.assertActiveServerForUser(serverId, userId); + try { + await this.assertActiveServerForUser(serverId, userId); - const sessionId = body.sessionId.trim(); - const session = this.deploymentGateway.getTerminalSession(sessionId); + const sessionId = body.sessionId.trim(); + const session = this.deploymentGateway.getTerminalSession(sessionId); - if ( - !session || - session.serverId !== serverId || - session.userId !== userId - ) { - throw new NotFoundException(ERROR_MESSAGES.TERMINAL.SESSION_NOT_FOUND); - } + if ( + session && + (session.serverId !== serverId || session.userId !== userId) + ) { + throw new NotFoundException(ERROR_MESSAGES.TERMINAL.SESSION_NOT_FOUND); + } - try { - this.deploymentGateway.closeTerminalSession(sessionId, { - notifyAgent: session.transport === TerminalTransport.AGENT, - }); + if (!session) { + this.deploymentGateway.notifyAgentTerminalDisconnect( + serverId, + sessionId, + ); + return { + message: SUCCESS_MESSAGES.TERMINAL.DISCONNECTED, + data: { disconnected: true }, + }; + } + + try { + this.deploymentGateway.closeTerminalSession(sessionId, { + notifyAgent: session.transport === TerminalTransport.AGENT, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new BadRequestException( + `${ERROR_MESSAGES.TERMINAL.DISCONNECT_FAILED}: ${detail}`, + ); + } + + return { + message: SUCCESS_MESSAGES.TERMINAL.DISCONNECTED, + data: { disconnected: true }, + }; } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new BadRequestException( - `${ERROR_MESSAGES.TERMINAL.DISCONNECT_FAILED}: ${detail}`, + this.logger.error( + `Failed to disconnect terminal for server '${serverId}': ${error instanceof Error ? error.message : String(error)}`, ); + throw error; } - - return { - message: SUCCESS_MESSAGES.TERMINAL.DISCONNECTED, - data: { disconnected: true }, - }; } /** @@ -144,28 +169,28 @@ export class TerminalService { cols: number, rows: number, ): Promise { - if (!this.deploymentGateway.isAgentConnectedForServer(serverId)) { - this.logger.warn( - `[TERMINAL] no connected agent for server '${serverId}', trying SSH fallback`, + try { + if (!this.deploymentGateway.isAgentConnectedForServer(serverId)) { + this.logger.warn( + `[TERMINAL] no connected agent for server '${serverId}', trying SSH fallback`, + ); + return null; + } + + const agentVersion = + this.deploymentGateway.getAgentVersion(serverId) ?? "unknown"; + const supportsTerminal = this.deploymentGateway.agentSupports( + serverId, + DeploymentEvents.TERMINAL_CONNECT, ); - return null; - } - - const agentVersion = - this.deploymentGateway.getAgentVersion(serverId) ?? "unknown"; - const supportsTerminal = this.deploymentGateway.agentSupports( - serverId, - DeploymentEvents.TERMINAL_CONNECT, - ); - if (!supportsTerminal) { - this.logger.warn( - `[TERMINAL] agent (version ${agentVersion}) does not support terminal for server '${serverId}', trying SSH fallback`, - ); - return null; - } + if (!supportsTerminal) { + this.logger.warn( + `[TERMINAL] agent (version ${agentVersion}) does not support terminal for server '${serverId}', trying SSH fallback`, + ); + return null; + } - try { return await this.deploymentGateway.requestTerminalConnect( serverId, userId, @@ -188,19 +213,26 @@ export class TerminalService { serverId: string, userId: string, ): Promise { - const server = await this.serverRepository.findOne({ - where: { - id: serverId, - userId, - status: EntityStatus.ACTIVE, - deletedAt: IsNull(), - }, - }); + try { + const server = await this.serverRepository.findOne({ + where: { + id: serverId, + userId, + status: EntityStatus.ACTIVE, + deletedAt: IsNull(), + }, + }); - if (!server) { - throw new NotFoundException(ERROR_MESSAGES.SERVER.NOT_FOUND); - } + if (!server) { + throw new NotFoundException(ERROR_MESSAGES.SERVER.NOT_FOUND); + } - return server; + return server; + } catch (error) { + this.logger.error( + `Failed to assert active server '${serverId}': ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } } } diff --git a/apps/control-panel-app/src/modules/users/users.controller.ts b/apps/control-panel-app/src/modules/users/users.controller.ts deleted file mode 100644 index 8ab7fdd..0000000 --- a/apps/control-panel-app/src/modules/users/users.controller.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Controller } from "@nestjs/common"; - -@Controller("users") -export class UsersController {} diff --git a/apps/control-panel-app/src/modules/users/users.module.ts b/apps/control-panel-app/src/modules/users/users.module.ts index 5e54b6a..bd0db8d 100644 --- a/apps/control-panel-app/src/modules/users/users.module.ts +++ b/apps/control-panel-app/src/modules/users/users.module.ts @@ -1,13 +1,11 @@ import { Module } from "@nestjs/common"; import { UsersService } from "./users.service"; -import { UsersController } from "./users.controller"; import { TypeOrmModule } from "@nestjs/typeorm"; import { UserEntity } from "./entities/users.entity"; @Module({ imports: [TypeOrmModule.forFeature([UserEntity])], providers: [UsersService], - controllers: [UsersController], exports: [UsersService], }) export class UsersModule {} diff --git a/apps/control-panel-app/src/modules/users/users.service.ts b/apps/control-panel-app/src/modules/users/users.service.ts index 67a5045..ad313f7 100644 --- a/apps/control-panel-app/src/modules/users/users.service.ts +++ b/apps/control-panel-app/src/modules/users/users.service.ts @@ -1,10 +1,13 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; import { UserEntity } from "./entities/users.entity"; import { FindOneOptions, Repository } from "typeorm"; import { InjectRepository } from "@nestjs/typeorm"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; @Injectable() export class UsersService { + private readonly logger = new Logger(UsersService.name); + constructor( @InjectRepository(UserEntity) private readonly userRepository: Repository, @@ -13,7 +16,12 @@ export class UsersService { async findOne( findOneOptions: FindOneOptions, ): Promise { - const user = await this.userRepository.findOne(findOneOptions); - return user; + try { + const user = await this.userRepository.findOne(findOneOptions); + return user; + } catch (error) { + this.logger.error(`Find user failed: ${toErrorMessage(error)}`); + throw error; + } } } diff --git a/apps/control-panel-app/src/websocket/constants/error-messages.constants.ts b/apps/control-panel-app/src/websocket/constants/error-messages.constants.ts new file mode 100644 index 0000000..abe24f7 --- /dev/null +++ b/apps/control-panel-app/src/websocket/constants/error-messages.constants.ts @@ -0,0 +1,70 @@ +export const WEBSOCKET_ERROR_MESSAGES = { + NO_CONNECTED_AGENT: (serverId: string) => + `No connected agent for server '${serverId}'`, + + NO_CONNECTED_AGENT_FOR_DEPLOYMENT: (serverId: string, deploymentId: string) => + `No connected agent for server '${serverId}' (deployment ${deploymentId})`, + + NO_CONNECTED_AGENT_FOR_TEMPLATE: (serverId: string, templateName: string) => + `No connected agent for server '${serverId}' (template ${templateName})`, + + AGENT_NOT_CONNECTED: (serverId: string, templateName: string) => + `Agent for server '${serverId}' is disconnected (template ${templateName})`, + + AGENT_DOES_NOT_SUPPORT_REMOVAL: (serverId: string) => + `Connected agent for server '${serverId}' does not support agent removal`, + + MISSING_REQUEST_ID_FOR_DEPLOYMENT_VALIDATION: + "Missing requestId for deployment validation", + + AGENT_RETURNED_NO_SERVER_RESOURCES: + "Agent returned no server resource metrics", + + AGENT_RETURNED_NO_TERMINAL_SESSION_ID: + "Agent returned no terminal session id", + + AGENT_RETURNED_NO_CONTAINER_LOGS_SESSION_ID: + "Agent returned no container logs session id", + + AGENT_REMOVAL_FAILED: "Agent removal failed", + + DEPLOYMENT_REMOVAL_FAILED: "Deployment removal failed", + + AGENT_DISCONNECTED: { + GENERIC: "Agent disconnected", + CONTAINER_DISCOVERY: "Agent disconnected during container discovery", + SERVER_RESOURCES: "Agent disconnected during server resource collection", + DEPLOYMENT_VALIDATION: "Agent disconnected during deployment validation", + CONTAINER_ACTION: "Agent disconnected during container action", + DEPLOYMENT_REMOVAL: "Agent disconnected during deployment removal", + AGENT_REMOVAL: "Agent disconnected during agent removal", + TERMINAL_CONNECT: "Agent disconnected during terminal connect", + CONTAINER_LOGS_START: "Agent disconnected during container logs start", + }, + + TIMEOUT: { + DEPLOYMENT_REMOVE: (timeoutSec: number, serverId: string) => + `Deployment remove timed out after ${timeoutSec}s for server '${serverId}'`, + + AGENT_REMOVE: (timeoutSec: number, serverId: string) => + `Agent removal timed out after ${timeoutSec}s for server '${serverId}'`, + + SERVER_RESOURCES: (timeoutSec: number, serverId: string) => + `Server resource collection timed out after ${timeoutSec}s for server '${serverId}'`, + + DEPLOYMENT_VALIDATE: (timeoutSec: number, serverId: string) => + `Deployment validation timed out after ${timeoutSec}s for server '${serverId}'`, + + CONTAINER_ACTION: (timeoutSec: number, serverId: string) => + `Container action timed out after ${timeoutSec}s for server '${serverId}'`, + + TERMINAL_CONNECT: (timeoutSec: number, serverId: string) => + `Terminal connect timed out after ${timeoutSec}s for server '${serverId}'`, + + CONTAINER_LOGS_START: (timeoutSec: number, serverId: string) => + `Container logs start timed out after ${timeoutSec}s for server '${serverId}'`, + + CONTAINER_DISCOVER: (timeoutSec: number, serverId: string) => + `Container discovery timed out after ${timeoutSec}s for server '${serverId}'`, + }, +} as const; diff --git a/apps/control-panel-app/src/websocket/constants/index.ts b/apps/control-panel-app/src/websocket/constants/index.ts index 01b41ec..456dfcc 100644 --- a/apps/control-panel-app/src/websocket/constants/index.ts +++ b/apps/control-panel-app/src/websocket/constants/index.ts @@ -1,3 +1,11 @@ +export const DEPLOYMENTS_SOCKET_NAMESPACE = "deployments"; + +export const SOCKET_ROOM_PREFIX = { + DEPLOYMENT: "deployment", + TERMINAL: "terminal", + CONTAINER_LOGS: "container-logs", +} as const; + export const SERVER_ID_HEADER = "x-kubeara-server-id"; export const CONTAINER_DISCOVER_TIMEOUT_MS = 15_000; export const DEPLOYMENT_VALIDATE_TIMEOUT_MS = 30_000; diff --git a/apps/control-panel-app/src/websocket/deployment-stream-buffer.service.ts b/apps/control-panel-app/src/websocket/deployment-stream-buffer.service.ts index c898830..c6b3229 100644 --- a/apps/control-panel-app/src/websocket/deployment-stream-buffer.service.ts +++ b/apps/control-panel-app/src/websocket/deployment-stream-buffer.service.ts @@ -1,5 +1,6 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; import type { DeploymentLogStreamPayload } from "@shared/socket-events"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; const MAX_LINES_PER_DEPLOYMENT = 5000; @@ -9,38 +10,60 @@ const MAX_LINES_PER_DEPLOYMENT = 5000; */ @Injectable() export class DeploymentStreamBufferService { + private readonly logger = new Logger(DeploymentStreamBufferService.name); private readonly buffers = new Map(); append(payload: DeploymentLogStreamPayload): void { - const deploymentId = payload.deploymentId?.trim(); - if (!deploymentId) { - return; - } + try { + const deploymentId = payload.deploymentId?.trim(); + if (!deploymentId) { + return; + } - let buffer = this.buffers.get(deploymentId); - if (!buffer) { - buffer = []; - this.buffers.set(deploymentId, buffer); - } + let buffer = this.buffers.get(deploymentId); + if (!buffer) { + buffer = []; + this.buffers.set(deploymentId, buffer); + } - buffer.push(payload); - if (buffer.length > MAX_LINES_PER_DEPLOYMENT) { - buffer.splice(0, buffer.length - MAX_LINES_PER_DEPLOYMENT); + buffer.push(payload); + if (buffer.length > MAX_LINES_PER_DEPLOYMENT) { + buffer.splice(0, buffer.length - MAX_LINES_PER_DEPLOYMENT); + } + } catch (error) { + this.logger.error( + `Append deployment stream buffer failed: ${toErrorMessage(error)}`, + ); + throw error; } } get(deploymentId: string): DeploymentLogStreamPayload[] { - const id = deploymentId.trim(); - if (!id) { - return []; + try { + const id = deploymentId.trim(); + if (!id) { + return []; + } + return [...(this.buffers.get(id) ?? [])]; + } catch (error) { + this.logger.error( + `Get deployment stream buffer failed: ${toErrorMessage(error)}`, + ); + throw error; } - return [...(this.buffers.get(id) ?? [])]; } clear(deploymentId: string): void { - const id = deploymentId.trim(); - if (id) { - this.buffers.delete(id); + try { + const id = deploymentId.trim(); + if (id) { + this.buffers.delete(id); + } + } catch (error) { + this.logger.error( + `Clear deployment stream buffer failed: ${toErrorMessage(error)}`, + ); + throw error; } } } diff --git a/apps/control-panel-app/src/websocket/websocket.gateway.ts b/apps/control-panel-app/src/websocket/websocket.gateway.ts index 5a4ca05..f90ec4a 100644 --- a/apps/control-panel-app/src/websocket/websocket.gateway.ts +++ b/apps/control-panel-app/src/websocket/websocket.gateway.ts @@ -67,6 +67,8 @@ import type { ContainerLogsSessionRecord, } from "./interfaces"; import { + DEPLOYMENTS_SOCKET_NAMESPACE, + SOCKET_ROOM_PREFIX, SERVER_ID_HEADER, CONTAINER_ACTION_TIMEOUT_MS, CONTAINER_DISCOVER_TIMEOUT_MS, @@ -78,22 +80,32 @@ import { TERMINAL_CONNECT_TIMEOUT_MS, STREAM_DEBUG, } from "./constants"; +import { WEBSOCKET_ERROR_MESSAGES } from "./constants/error-messages.constants"; +/** + * Builds the Socket.io room name for a deployment log stream. + */ function deploymentRoom(deploymentId: string): string { - return `deployment:${deploymentId}`; + return `${SOCKET_ROOM_PREFIX.DEPLOYMENT}:${deploymentId}`; } +/** + * Builds the Socket.io room name for a terminal session. + */ function terminalRoom(sessionId: string): string { - return `terminal:${sessionId}`; + return `${SOCKET_ROOM_PREFIX.TERMINAL}:${sessionId}`; } +/** + * Builds the Socket.io room name for a container logs session. + */ function containerLogsRoom(sessionId: string): string { - return `container-logs:${sessionId}`; + return `${SOCKET_ROOM_PREFIX.CONTAINER_LOGS}:${sessionId}`; } @Injectable() @WebSocketGateway({ - namespace: "deployments", + namespace: DEPLOYMENTS_SOCKET_NAMESPACE, cors: { origin: "*" }, }) export class DeploymentGateway @@ -160,11 +172,32 @@ export class DeploymentGateway private readonly agentCapabilitiesByServerId = new Map>(); private readonly agentVersionsByServerId = new Map(); + /** + * Initializes the WebSocket gateway after the namespace server is ready. + */ afterInit(): void { - this.logStreamDiagnostics("afterInit"); - this.logger.log("[stream] WebSocket Gateway initialized"); + try { + this.logStreamDiagnostics("afterInit"); + } catch (error) { + this.logger.error( + `Failed to initialize WebSocket gateway: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Logs outbound socket event emissions for observability. + */ + private logEmitEvent(event: string, details: string): void { + this.logger.log(`[emit] event=${event} ${details}`); } + /** + * Handles the connection of a client to the deployment gateway. + * @param client - The socket client. + * @returns A promise that resolves when the connection is handled. + * @throws An error if the connection cannot be handled. + */ async handleConnection(client: Socket): Promise { try { const socketId = client.id; @@ -187,9 +220,6 @@ export class DeploymentGateway if (serverId) { const previous = this.agentsByServerId.get(serverId); if (previous && previous.id !== socketId) { - this.logger.warn( - `Replacing prior agent socket for serverId=${serverId} (old=${previous.id}, new=${socketId})`, - ); this.unregisterServerBinding(previous.id); previous.disconnect(true); } @@ -201,12 +231,6 @@ export class DeploymentGateway this.attachAgentInboundHandlers(client); - this.logger.log( - `Agent connected: ${socketId} (agents=${this.connectedAgents.size})` + - (serverId ? ` serverId=${serverId}` : " (unbound)") + - (publicIp ? ` publicIp=${publicIp}` : ""), - ); - client.on( DeploymentEvents.DEPLOYMENT_LOG, (payload: DeploymentLogPayload) => { @@ -219,28 +243,35 @@ export class DeploymentGateway void this.processDeploymentStatus(client, payload); }, ); - } else { - this.logger.log( - `Console client connected: ${socketId} (agents=${this.connectedAgents.size})`, - ); } const ns = this.getNamespaceServer(); if (isAgent) { - ns?.emit(DeploymentEvents.AGENT_CONNECTED, { + const payload = { agentId: socketId, serverId: serverId ?? undefined, timestamp: new Date().toISOString(), totalAgents: this.connectedAgents.size, - }); + }; + this.logEmitEvent( + DeploymentEvents.AGENT_CONNECTED, + `agentSocket=${socketId}${serverId ? ` serverId=${serverId}` : ""} totalAgents=${this.connectedAgents.size}`, + ); + ns?.emit(DeploymentEvents.AGENT_CONNECTED, payload); } } catch (error) { this.logger.error( `Failed to handle connection: ${error instanceof Error ? error.message : String(error)}`, ); + client.disconnect(true); } } + /** + * Handles the disconnection of a client from the deployment gateway. + * @param client - The socket client. + * @returns A promise that resolves when the disconnection is handled. + */ handleDisconnect(client: Socket): void { try { const socketId = client.id; @@ -251,59 +282,57 @@ export class DeploymentGateway this.agentPublicIps.delete(socketId); this.unregisterServerBinding(socketId); - this.logger.log( - `${wasAgent ? "Agent" : "Client"} disconnected: ${socketId} (agents=${this.connectedAgents.size})`, - ); - if (wasAgent) { if (serverId) { this.clearAgentMetadataForServer(serverId); this.rejectPendingDiscoveryForServer( serverId, - "Agent disconnected during container discovery", + WEBSOCKET_ERROR_MESSAGES.AGENT_DISCONNECTED.CONTAINER_DISCOVERY, ); this.rejectPendingResourcesForServer( serverId, - "Agent disconnected during server resource collection", + WEBSOCKET_ERROR_MESSAGES.AGENT_DISCONNECTED.SERVER_RESOURCES, ); this.rejectPendingDeploymentValidatesForServer( serverId, - "Agent disconnected during deployment validation", + WEBSOCKET_ERROR_MESSAGES.AGENT_DISCONNECTED.DEPLOYMENT_VALIDATION, ); this.rejectPendingContainerActionsForServer( serverId, - "Agent disconnected during container action", + WEBSOCKET_ERROR_MESSAGES.AGENT_DISCONNECTED.CONTAINER_ACTION, ); this.rejectPendingDeploymentRemovesForServer( serverId, - "Agent disconnected during deployment removal", + WEBSOCKET_ERROR_MESSAGES.AGENT_DISCONNECTED.DEPLOYMENT_REMOVAL, ); this.rejectPendingAgentRemovesForServer( serverId, - "Agent disconnected during agent removal", + WEBSOCKET_ERROR_MESSAGES.AGENT_DISCONNECTED.AGENT_REMOVAL, ); this.rejectPendingTerminalConnectsForServer( serverId, - "Agent disconnected during terminal connect", + WEBSOCKET_ERROR_MESSAGES.AGENT_DISCONNECTED.TERMINAL_CONNECT, ); this.rejectPendingContainerLogsStartsForServer( serverId, - "Agent disconnected during container logs start", - ); - this.closeTerminalSessionsForServer(serverId, "Agent disconnected"); - this.closeContainerLogsSessionsForServer( - serverId, - "Agent disconnected", + WEBSOCKET_ERROR_MESSAGES.AGENT_DISCONNECTED.CONTAINER_LOGS_START, ); + this.closeTerminalSessionsForServer(serverId); + this.closeContainerLogsSessionsForServer(serverId); } const ns = this.getNamespaceServer(); - ns?.emit(DeploymentEvents.AGENT_DISCONNECTED, { + const payload = { agentId: socketId, serverId: serverId ?? undefined, timestamp: new Date().toISOString(), totalAgents: this.connectedAgents.size, - }); + }; + this.logEmitEvent( + DeploymentEvents.AGENT_DISCONNECTED, + `agentSocket=${socketId}${serverId ? ` serverId=${serverId}` : ""} totalAgents=${this.connectedAgents.size}`, + ); + ns?.emit(DeploymentEvents.AGENT_DISCONNECTED, payload); } } catch (error) { this.logger.error( @@ -312,30 +341,72 @@ export class DeploymentGateway } } + /** + * Handles the deployment log event from an agent. + * @param client - The socket client. + * @param payload - The deployment log payload. + * @returns A promise that resolves when the deployment log is handled. + */ @SubscribeMessage(DeploymentEvents.DEPLOYMENT_LOG) handleDeploymentLog( @ConnectedSocket() client: Socket, @MessageBody() payload: DeploymentLogPayload, ): void { - this.processAgentLog(client, payload); + try { + this.processAgentLog(client, payload); + } catch (error) { + this.logger.error( + `Failed to handle deployment log: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Handles the deployment status event from an agent. + * @param client - The socket client. + * @param payload - The deployment status payload. + * @returns A promise that resolves when the deployment status is handled. + */ @SubscribeMessage(DeploymentEvents.DEPLOYMENT_STATUS) - handleDeploymentStatus( + async handleDeploymentStatus( @ConnectedSocket() client: Socket, @MessageBody() payload: DeploymentStatusPayload, ): Promise { - return this.processDeploymentStatus(client, payload); + try { + await this.processDeploymentStatus(client, payload); + } catch (error) { + this.logger.error( + `Failed to handle deployment status: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Handles the agent hello event from an agent. + * @param client - The socket client. + * @param payload - The agent hello payload. + * @returns A promise that resolves when the agent hello is handled. + */ @SubscribeMessage(DeploymentEvents.AGENT_HELLO) handleAgentHello( @ConnectedSocket() client: Socket, @MessageBody() payload: AgentHelloPayload, ): void { - this.processAgentHello(client, payload); + try { + this.processAgentHello(client, payload); + } catch (error) { + this.logger.error( + `Failed to handle agent hello: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Handles the server get resources result event from an agent. + * @param client - The socket client. + * @param payload - The server get resources result payload. + * @returns A promise that resolves when the server get resources result is handled. + */ @SubscribeMessage(DeploymentEvents.SERVER_GET_RESOURCES_RESULT) handleServerGetResourcesResult( @ConnectedSocket() client: Socket, @@ -344,25 +415,16 @@ export class DeploymentGateway try { const requestId = payload?.requestId?.trim(); if (!requestId) { - this.logger.warn( - `Ignoring server get-resources result without requestId from ${client.id}`, - ); return; } const pending = this.pendingServerResources.get(requestId); if (!pending) { - this.logger.warn( - `No pending server get-resources for requestId=${requestId}`, - ); return; } const serverId = this.serverIdBySocketId.get(client.id); if (serverId && serverId !== pending.serverId) { - this.logger.warn( - `Server get-resources result server mismatch requestId=${requestId} expected=${pending.serverId} got=${serverId}`, - ); return; } @@ -375,7 +437,11 @@ export class DeploymentGateway } if (!payload.resources) { - pending.reject(new Error("Agent returned no server resource metrics")); + pending.reject( + new Error( + WEBSOCKET_ERROR_MESSAGES.AGENT_RETURNED_NO_SERVER_RESOURCES, + ), + ); return; } @@ -387,6 +453,12 @@ export class DeploymentGateway } } + /** + * Handles the deployment validate result event from an agent. + * @param client - The socket client. + * @param payload - The deployment validate result payload. + * @returns A promise that resolves when the deployment validate result is handled. + */ @SubscribeMessage(DeploymentEvents.DEPLOYMENT_VALIDATE_RESULT) handleDeploymentValidateResult( @ConnectedSocket() client: Socket, @@ -395,25 +467,16 @@ export class DeploymentGateway try { const requestId = payload?.requestId?.trim(); if (!requestId) { - this.logger.warn( - `Ignoring deployment validate result without requestId from ${client.id}`, - ); return; } const pending = this.pendingDeploymentValidations.get(requestId); if (!pending) { - this.logger.warn( - `No pending deployment validation for requestId=${requestId}`, - ); return; } const serverId = this.serverIdBySocketId.get(client.id); if (serverId && serverId !== pending.serverId) { - this.logger.warn( - `Deployment validate result server mismatch requestId=${requestId} expected=${pending.serverId} got=${serverId}`, - ); return; } @@ -427,6 +490,12 @@ export class DeploymentGateway } } + /** + * Handles the agent remove result event from an agent. + * @param client - The socket client. + * @param payload - The agent remove result payload. + * @returns A promise that resolves when the agent remove result is handled. + */ @SubscribeMessage(DeploymentEvents.AGENT_REMOVE_RESULT) handleAgentRemoveResult( @ConnectedSocket() client: Socket, @@ -435,23 +504,16 @@ export class DeploymentGateway try { const requestId = payload?.requestId?.trim(); if (!requestId) { - this.logger.warn( - `Ignoring agent remove result without requestId from ${client.id}`, - ); return; } const pending = this.pendingAgentRemoves.get(requestId); if (!pending) { - this.logger.warn(`No pending agent remove for requestId=${requestId}`); return; } const serverId = this.serverIdBySocketId.get(client.id); if (serverId && serverId !== pending.serverId) { - this.logger.warn( - `Agent remove result server mismatch requestId=${requestId} expected=${pending.serverId} got=${serverId}`, - ); return; } @@ -460,7 +522,10 @@ export class DeploymentGateway if (!payload.success) { pending.reject( - new Error(payload.error?.trim() || "Agent removal failed"), + new Error( + payload.error?.trim() || + WEBSOCKET_ERROR_MESSAGES.AGENT_REMOVAL_FAILED, + ), ); return; } @@ -473,6 +538,12 @@ export class DeploymentGateway } } + /** + * Handles the container action result event from an agent. + * @param client - The socket client. + * @param payload - The container action result payload. + * @returns A promise that resolves when the container action result is handled. + */ @SubscribeMessage(DeploymentEvents.CONTAINER_ACTION_RESULT) handleContainerActionResult( @ConnectedSocket() client: Socket, @@ -481,29 +552,16 @@ export class DeploymentGateway try { const requestId = payload?.requestId?.trim(); if (!requestId) { - this.logger.warn( - `Ignoring container action result without requestId from ${client.id}`, - ); return; } - this.logger.log( - `[CONTAINER_ACTION] result received from agentSocket=${client.id} requestId=${requestId} action=${payload?.action ?? "unknown"} success=${payload?.success ?? false}`, - ); - const pending = this.pendingContainerActions.get(requestId); if (!pending) { - this.logger.warn( - `No pending container action for requestId=${requestId}`, - ); return; } const serverId = this.serverIdBySocketId.get(client.id); if (serverId && serverId !== pending.serverId) { - this.logger.warn( - `Container action result server mismatch requestId=${requestId} expected=${pending.serverId} got=${serverId}`, - ); return; } @@ -517,6 +575,12 @@ export class DeploymentGateway } } + /** + * Handles the container discover result event from an agent. + * @param client - The socket client. + * @param payload - The container discover result payload. + * @returns A promise that resolves when the container discover result is handled. + */ @SubscribeMessage(DeploymentEvents.CONTAINER_DISCOVER_RESULT) handleContainerDiscoverResult( @ConnectedSocket() client: Socket, @@ -525,29 +589,16 @@ export class DeploymentGateway try { const requestId = payload?.requestId?.trim(); if (!requestId) { - this.logger.warn( - `Ignoring container discover result without requestId from ${client.id}`, - ); return; } - this.logger.log( - `[CONTAINER_DISCOVER] result received from agentSocket=${client.id} requestId=${requestId} count=${payload?.containers?.length ?? 0}${payload?.error ? ` error=${payload.error}` : ""}`, - ); - const pending = this.pendingContainerDiscovery.get(requestId); if (!pending) { - this.logger.warn( - `No pending container discovery for requestId=${requestId}`, - ); return; } const serverId = this.serverIdBySocketId.get(client.id); if (serverId && serverId !== pending.serverId) { - this.logger.warn( - `Container discover result server mismatch requestId=${requestId} expected=${pending.serverId} got=${serverId}`, - ); return; } @@ -567,174 +618,280 @@ export class DeploymentGateway } } + /** + * Handles the logs subscribe event from a client. + * @param client - The socket client. + * @param body - The logs subscribe payload. + * @returns A promise that resolves when the logs subscribe is handled. + */ @SubscribeMessage(DeploymentEvents.LOGS_SUBSCRIBE) async handleLogsSubscribe( @ConnectedSocket() client: Socket, @MessageBody() body: LogsSubscribePayload, ): Promise { - const deploymentId = body?.deploymentId?.trim(); - if (!deploymentId) { - this.logger.warn( - `[stream] logs:subscribe rejected for ${client.id}: missing deploymentId`, - ); - return; - } + try { + const deploymentId = body?.deploymentId?.trim(); + if (!deploymentId) { + return; + } - const room = deploymentRoom(deploymentId); + const room = deploymentRoom(deploymentId); - try { - await client.join(room); + try { + await client.join(room); + } catch (error) { + this.logger.error( + `Failed to join logs room client=${client.id} room=${room}: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + + this.logStreamDiagnostics("logs:subscribe", { + socketId: client.id, + deploymentId, + room, + }); + + this.replayBufferedLogsToClient(client, deploymentId); } catch (error) { this.logger.error( - `[stream] logs:subscribe join failed client=${client.id} room=${room}: ${error instanceof Error ? error.message : String(error)}`, + `Failed to handle logs subscribe: ${error instanceof Error ? error.message : String(error)}`, ); - return; } - - this.logger.log( - `[stream] client joined room client=${client.id} deploymentId=${deploymentId} room=${room}`, - ); - - this.logStreamDiagnostics("logs:subscribe", { - socketId: client.id, - deploymentId, - room, - }); - - this.replayBufferedLogsToClient(client, deploymentId); } + /** + * Handles the terminal subscribe event from a client. + * @param client - The socket client. + * @param body - The terminal subscribe payload. + * @returns A promise that resolves when the terminal subscribe is handled. + */ @SubscribeMessage(DeploymentEvents.TERMINAL_SUBSCRIBE) async handleTerminalSubscribe( @ConnectedSocket() client: Socket, @MessageBody() body: TerminalSubscribePayload, ): Promise { - const sessionId = body?.sessionId?.trim(); - if (!sessionId || !this.terminalSessionsById.has(sessionId)) { - this.logger.warn( - `[TERMINAL] subscribe rejected for ${client.id}: unknown sessionId=${sessionId ?? "missing"}`, - ); - return; - } - - const room = terminalRoom(sessionId); try { - await client.join(room); - this.logger.log( - `[TERMINAL] client joined room client=${client.id} sessionId=${sessionId}`, - ); + const sessionId = body?.sessionId?.trim(); + if (!sessionId || !this.terminalSessionsById.has(sessionId)) { + return; + } + + const room = terminalRoom(sessionId); + try { + await client.join(room); + } catch (error) { + this.logger.error( + `Failed to join terminal room client=${client.id} room=${room}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } catch (error) { this.logger.error( - `[TERMINAL] subscribe join failed client=${client.id} room=${room}: ${error instanceof Error ? error.message : String(error)}`, + `Failed to handle terminal subscribe: ${error instanceof Error ? error.message : String(error)}`, ); } } + /** + * Handles the container logs subscribe event from a client. + * @param client - The socket client. + * @param body - The container logs subscribe payload. + * @returns A promise that resolves when the container logs subscribe is handled. + */ @SubscribeMessage(DeploymentEvents.CONTAINER_LOGS_SUBSCRIBE) async handleContainerLogsSubscribe( @ConnectedSocket() client: Socket, @MessageBody() body: ContainerLogsSubscribePayload, ): Promise { - const sessionId = body?.sessionId?.trim(); - if (!sessionId || !this.containerLogsSessionsById.has(sessionId)) { - this.logger.warn( - `[CONTAINER_LOGS] subscribe rejected for ${client.id}: unknown sessionId=${sessionId ?? "missing"}`, - ); - return; - } - - const room = containerLogsRoom(sessionId); try { - await client.join(room); - this.logger.log( - `[CONTAINER_LOGS] client joined room client=${client.id} sessionId=${sessionId}`, - ); + const sessionId = body?.sessionId?.trim(); + if (!sessionId || !this.containerLogsSessionsById.has(sessionId)) { + return; + } + + const room = containerLogsRoom(sessionId); + try { + await client.join(room); + } catch (error) { + this.logger.error( + `Failed to join container logs room client=${client.id} room=${room}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } catch (error) { this.logger.error( - `[CONTAINER_LOGS] subscribe join failed client=${client.id} room=${room}: ${error instanceof Error ? error.message : String(error)}`, + `Failed to handle container logs subscribe: ${error instanceof Error ? error.message : String(error)}`, ); } } + /** + * Handles the container logs stop event from a client. + * @param payload - The container logs stop payload. + * @returns A promise that resolves when the container logs stop is handled. + */ @SubscribeMessage(DeploymentEvents.CONTAINER_LOGS_STOP) handleContainerLogsStopFromConsole( @MessageBody() payload: ContainerLogsStopPayload, ): void { - const sessionId = payload?.sessionId?.trim(); - if (!sessionId) { - return; - } + try { + const sessionId = payload?.sessionId?.trim(); + if (!sessionId) { + return; + } - this.closeContainerLogsSession(sessionId, { notifyAgent: true }); + this.closeContainerLogsSession(sessionId, { notifyAgent: true }); + } catch (error) { + this.logger.error( + `Failed to handle container logs stop: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Handles the terminal input event from a client. + * @param client - The socket client. + * @param payload - The terminal input payload. + * @returns A promise that resolves when the terminal input is handled. + */ @SubscribeMessage(DeploymentEvents.TERMINAL_INPUT) handleTerminalInput( @ConnectedSocket() client: Socket, @MessageBody() payload: TerminalInputPayload, ): void { - this.forwardTerminalEventToAgent( - client, - DeploymentEvents.TERMINAL_INPUT, - payload, - ); + try { + this.forwardTerminalEventToAgent( + client, + DeploymentEvents.TERMINAL_INPUT, + payload, + ); + } catch (error) { + this.logger.error( + `Failed to handle terminal input: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Handles the terminal resize event from a client. + * @param client - The socket client. + * @param payload - The terminal resize payload. + * @returns A promise that resolves when the terminal resize is handled. + */ @SubscribeMessage(DeploymentEvents.TERMINAL_RESIZE) handleTerminalResize( @ConnectedSocket() client: Socket, @MessageBody() payload: TerminalResizePayload, ): void { - this.forwardTerminalEventToAgent( - client, - DeploymentEvents.TERMINAL_RESIZE, - payload, - ); + try { + this.forwardTerminalEventToAgent( + client, + DeploymentEvents.TERMINAL_RESIZE, + payload, + ); + } catch (error) { + this.logger.error( + `Failed to handle terminal resize: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Handles the terminal disconnect event from a client. + * @param client - The socket client. + * @param payload - The terminal disconnect payload. + * @returns A promise that resolves when the terminal disconnect is handled. + */ @SubscribeMessage(DeploymentEvents.TERMINAL_DISCONNECT) handleTerminalDisconnect( @ConnectedSocket() client: Socket, @MessageBody() payload: TerminalDisconnectPayload, ): void { - const sessionId = payload?.sessionId?.trim(); - if (!sessionId) { - return; - } + try { + const sessionId = payload?.sessionId?.trim(); + if (!sessionId) { + return; + } - this.closeTerminalSession(sessionId, { notifyAgent: true }); + this.closeTerminalSession(sessionId, { notifyAgent: true }); + } catch (error) { + this.logger.error( + `Failed to handle terminal disconnect: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Handles the terminal connect result event from a client. + * @param client - The socket client. + * @param payload - The terminal connect result payload. + * @returns A promise that resolves when the terminal connect result is handled. + */ @SubscribeMessage(DeploymentEvents.TERMINAL_CONNECT_RESULT) handleTerminalConnectResult( @ConnectedSocket() client: Socket, @MessageBody() payload: TerminalConnectResponsePayload, ): void { - this.processTerminalConnectResult(client, payload); + try { + this.processTerminalConnectResult(client, payload); + } catch (error) { + this.logger.error( + `Failed to handle terminal connect result: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Handles the terminal output event from a client. + * @param client - The socket client. + * @param payload - The terminal output payload. + * @returns A promise that resolves when the terminal output is handled. + */ @SubscribeMessage(DeploymentEvents.TERMINAL_OUTPUT) handleTerminalOutput( @ConnectedSocket() client: Socket, @MessageBody() payload: TerminalOutputPayload, ): void { - this.relayTerminalOutput(client, payload); + try { + this.relayTerminalOutput(client, payload); + } catch (error) { + this.logger.error( + `Failed to handle terminal output: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Checks if the client is likely an agent client. + * @param client - The socket client. + * @returns A boolean indicating if the client is likely an agent client. + */ private isLikelyAgentClient(client: Socket): boolean { - const headerIp = client.handshake.headers["x-agent-public-ip"]; - const queryIp = client.handshake.query.publicIp; - const hasPublicIp = Boolean( - (Array.isArray(headerIp) ? headerIp[0] : headerIp) ?? - (Array.isArray(queryIp) ? queryIp[0] : queryIp), - ); - const hasServerHeader = Boolean( - client.handshake.headers[SERVER_ID_HEADER] ?? - client.handshake.query.serverId, - ); - return hasPublicIp || hasServerHeader; + try { + const headerIp = client.handshake.headers["x-agent-public-ip"]; + const queryIp = client.handshake.query.publicIp; + const hasPublicIp = Boolean( + (Array.isArray(headerIp) ? headerIp[0] : headerIp) ?? + (Array.isArray(queryIp) ? queryIp[0] : queryIp), + ); + const hasServerHeader = Boolean( + client.handshake.headers[SERVER_ID_HEADER] ?? + client.handshake.query.serverId, + ); + return hasPublicIp || hasServerHeader; + } catch (error) { + this.logger.error( + `Failed to detect agent client: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } } + /** + * Processes an agent log. + * @param client - The socket client. + * @param payload - The agent log payload. + * @returns A promise that resolves when the agent log is handled. + */ private processAgentLog(client: Socket, payload: DeploymentLogPayload): void { try { if (!payload?.message) { @@ -742,16 +899,9 @@ export class DeploymentGateway } if (!payload.deploymentId) { - this.logger.warn( - `[stream] agent log missing deploymentId from agent=${client.id}`, - ); return; } - this.logger.log( - `[stream] agent log deploymentId=${payload.deploymentId} source=${payload.source ?? "deployment"} bytes=${payload.message.length}`, - ); - const serverId = this.serverIdBySocketId.get(client.id); const isContainer = payload.source === "container"; const containerName = @@ -777,15 +927,18 @@ export class DeploymentGateway } } + /** + * Processes a deployment status event. + * @param client - The socket client. + * @param payload - The deployment status payload. + * @returns A promise that resolves when the deployment status is handled. + */ private async processDeploymentStatus( client: Socket, payload: DeploymentStatusPayload, ): Promise { try { if (!payload?.deploymentId) { - this.logger.warn( - `Ignoring deployment status without deploymentId from ${client.id}`, - ); return; } @@ -819,7 +972,7 @@ export class DeploymentGateway new Error( payload.error?.trim() || payload.message?.trim() || - "Deployment removal failed", + WEBSOCKET_ERROR_MESSAGES.DEPLOYMENT_REMOVAL_FAILED, ), ); } @@ -841,7 +994,7 @@ export class DeploymentGateway ); } } catch (error) { - this.logger.warn( + this.logger.error( `Could not persist deployment status for ${payload.deploymentId}: ${error instanceof Error ? error.message : String(error)}`, ); } @@ -866,6 +1019,10 @@ export class DeploymentGateway event: DeploymentEvents.DEPLOYMENT_STATUS, }); + this.logEmitEvent( + DeploymentEvents.DEPLOYMENT_STATUS, + `deploymentId=${payload.deploymentId} serverId=${serverId ?? "n/a"} status=${payload.status} room=${room}`, + ); ns.emit(DeploymentEvents.DEPLOYMENT_STATUS, enriched); ns.to(room).emit(DeploymentEvents.DEPLOYMENT_STATUS, enriched); } catch (error) { @@ -875,28 +1032,37 @@ export class DeploymentGateway } } /** - * Broadcasts the server operation updated event to the websocket. + * Broadcasts a server operation update to all connected clients. */ broadcastServerOperationUpdated( payload: ServerOperationUpdatedPayload, ): void { - const ns = this.getNamespaceServer(); - if (!ns) { - return; - } - - const enriched: ServerOperationUpdatedPayload = { - ...payload, - timestamp: payload.timestamp ?? new Date().toISOString(), - }; + try { + const ns = this.getNamespaceServer(); + if (!ns) { + return; + } - this.logger.log( - `[SERVER_OPERATION] broadcast serverId=${enriched.serverId} status=${String(enriched.operationStatus)} deleted=${Boolean(enriched.deleted)}`, - ); + const enriched: ServerOperationUpdatedPayload = { + ...payload, + timestamp: payload.timestamp ?? new Date().toISOString(), + }; - ns.emit(DeploymentEvents.SERVER_OPERATION_UPDATED, enriched); + this.logEmitEvent( + DeploymentEvents.SERVER_OPERATION_UPDATED, + `serverId=${enriched.serverId} status=${String(enriched.operationStatus)} deleted=${Boolean(enriched.deleted)}`, + ); + ns.emit(DeploymentEvents.SERVER_OPERATION_UPDATED, enriched); + } catch (error) { + this.logger.error( + `Failed to broadcast server operation update: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Broadcasts a deployment log line to subscribed console clients. + */ broadcastDeploymentLog( payload: DeploymentLogPayload & { serverId: string; @@ -904,28 +1070,33 @@ export class DeploymentGateway phase?: DeploymentLogStreamPayload["phase"]; }, ): void { - const message = payload.message?.trim(); - if (!message) { - return; - } - - const phase = payload.phase ?? "install"; + try { + const message = payload.message?.trim(); + if (!message) { + return; + } - this.logger.log( - `[stream] broadcast ${phase} log deploymentId=${payload.deploymentId} serverId=${payload.serverId} bytes=${message.length}`, - ); + const phase = payload.phase ?? "install"; - this.emitStreamPayload({ - deploymentId: payload.deploymentId, - serverId: payload.serverId, - phase, - source: phase === "install" ? "install" : "deployment", - stream: payload.type, - timestamp: payload.timestamp ?? new Date().toISOString(), - message, - }); + this.emitStreamPayload({ + deploymentId: payload.deploymentId, + serverId: payload.serverId, + phase, + source: phase === "install" ? "install" : "deployment", + stream: payload.type, + timestamp: payload.timestamp ?? new Date().toISOString(), + message, + }); + } catch (error) { + this.logger.error( + `Failed to broadcast deployment log: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Normalizes and emits a deployment log stream payload to clients. + */ private emitStreamPayload( input: Omit & { stream?: DeploymentLogStreamType; @@ -933,15 +1104,11 @@ export class DeploymentGateway ): void { const deploymentId = input.deploymentId?.trim(); if (!deploymentId) { - this.logger.warn("[stream] emit skipped: missing deploymentId"); return; } const ns = this.getNamespaceServer(); if (!ns) { - this.logger.error( - "[stream] emit skipped: namespace server is not initialized", - ); return; } @@ -971,8 +1138,10 @@ export class DeploymentGateway this.streamBuffer.append(normalized); - // Namespace broadcast: required for local deployOnLocal (console + agent on - // localhost). Room join + replay below covers late logs:subscribe. + this.logEmitEvent( + DeploymentEvents.DEPLOYMENT_STREAM, + `deploymentId=${deploymentId} room=${room} bytes=${normalized.message.length}`, + ); ns.emit(DeploymentEvents.DEPLOYMENT_STREAM, normalized); } catch (error) { this.logger.error( @@ -981,54 +1150,85 @@ export class DeploymentGateway } } + /** + * Replays buffered deployment logs to a client that just subscribed. + */ private replayBufferedLogsToClient( client: Socket, deploymentId: string, ): void { - const buffered = this.streamBuffer.get(deploymentId); - if (buffered.length === 0) { - return; - } - - this.logger.log( - `[stream] replay ${buffered.length} buffered line(s) to client=${client.id} deploymentId=${deploymentId}`, - ); + try { + const buffered = this.streamBuffer.get(deploymentId); + if (buffered.length === 0) { + return; + } - for (const entry of buffered) { - client.emit(DeploymentEvents.DEPLOYMENT_STREAM, entry); + for (const entry of buffered) { + client.emit(DeploymentEvents.DEPLOYMENT_STREAM, entry); + } + } catch (error) { + this.logger.error( + `Failed to replay buffered logs: ${error instanceof Error ? error.message : String(error)}`, + ); } } + /** + * Returns the deployments namespace server instance. + */ private getNamespaceServer(): Server | null { - return this.server ?? null; + try { + return this.server ?? null; + } catch (error) { + this.logger.error( + `Failed to get namespace server: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } } + /** + * Writes optional stream diagnostics when STREAM_DEBUG is enabled. + */ private logStreamDiagnostics( context: string, extra?: Record, - ) { - if (!STREAM_DEBUG) { - return; - } + ): void { + try { + if (!STREAM_DEBUG) { + return; + } - const ns = this.server; + const ns = this.server; - this.logger.debug( - `[stream][diag] ${context} serverDefined=${Boolean(ns)} trackedAgents=${this.connectedAgents.size} ${extra ? JSON.stringify(extra) : ""}`, - ); + this.logger.debug( + `[stream][diag] ${context} serverDefined=${Boolean(ns)} trackedAgents=${this.connectedAgents.size} ${extra ? JSON.stringify(extra) : ""}`, + ); + } catch (error) { + this.logger.error( + `Failed to write stream diagnostics: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Emits a deployment removal request to the connected agent. + */ emitRemove(message: SocketRemoveMessage, serverId: string): void { try { const client = this.agentsByServerId.get(serverId); if (!client) { throw new Error( - `No connected agent for server '${serverId}' (deployment ${message.payload.deploymentId})`, + WEBSOCKET_ERROR_MESSAGES.NO_CONNECTED_AGENT_FOR_DEPLOYMENT( + serverId, + message.payload.deploymentId, + ), ); } - this.logger.log( - `Emitting remove to serverId=${serverId} for deployment: ${message.payload.deploymentId}`, + this.logEmitEvent( + DeploymentEvents.REMOVE, + `serverId=${serverId} deploymentId=${message.payload.deploymentId}`, ); client.emit(DeploymentEvents.REMOVE, message); } catch (error) { @@ -1052,7 +1252,9 @@ export class DeploymentGateway try { const client = this.agentsByServerId.get(serverId); if (!client?.connected) { - reject(new Error(`No connected agent for server '${serverId}'`)); + reject( + new Error(WEBSOCKET_ERROR_MESSAGES.NO_CONNECTED_AGENT(serverId)), + ); return; } @@ -1060,7 +1262,10 @@ export class DeploymentGateway this.pendingDeploymentRemoves.delete(deploymentId); reject( new Error( - `Deployment remove timed out after ${timeoutMs / 1000}s for server '${serverId}'`, + WEBSOCKET_ERROR_MESSAGES.TIMEOUT.DEPLOYMENT_REMOVE( + timeoutMs / 1000, + serverId, + ), ), ); }, timeoutMs); @@ -1080,8 +1285,9 @@ export class DeploymentGateway payload: { deploymentId, templateSlug }, }; - this.logger.log( - `[DEPLOY_REMOVE] requesting removal deploymentId=${deploymentId} serverId=${serverId}`, + this.logEmitEvent( + DeploymentEvents.REMOVE, + `deploymentId=${deploymentId} serverId=${serverId}`, ); client.emit(DeploymentEvents.REMOVE, message); } catch (error) { @@ -1102,14 +1308,16 @@ export class DeploymentGateway try { const client = this.agentsByServerId.get(serverId); if (!client?.connected) { - reject(new Error(`No connected agent for server '${serverId}'`)); + reject( + new Error(WEBSOCKET_ERROR_MESSAGES.NO_CONNECTED_AGENT(serverId)), + ); return; } if (!this.agentSupports(serverId, DeploymentEvents.AGENT_REMOVE)) { reject( new Error( - `Connected agent for server '${serverId}' does not support agent removal`, + WEBSOCKET_ERROR_MESSAGES.AGENT_DOES_NOT_SUPPORT_REMOVAL(serverId), ), ); return; @@ -1126,7 +1334,10 @@ export class DeploymentGateway this.pendingAgentRemoves.delete(requestId); reject( new Error( - `Agent removal timed out after ${timeoutMs / 1000}s for server '${serverId}'`, + WEBSOCKET_ERROR_MESSAGES.TIMEOUT.AGENT_REMOVE( + timeoutMs / 1000, + serverId, + ), ), ); }, timeoutMs); @@ -1138,8 +1349,9 @@ export class DeploymentGateway timer, }); - this.logger.log( - `[AGENT_REMOVE] requesting removal serverId=${serverId} requestId=${requestId}`, + this.logEmitEvent( + DeploymentEvents.AGENT_REMOVE, + `serverId=${serverId} requestId=${requestId}`, ); client.emit(DeploymentEvents.AGENT_REMOVE, payload); } catch (error) { @@ -1148,23 +1360,33 @@ export class DeploymentGateway }); } + /** + * Emits a deployment request to the connected agent. + */ emitDeploy(message: SocketDeployMessage, serverId: string): void { try { const client = this.agentsByServerId.get(serverId); if (!client) { throw new Error( - `No connected agent for server '${serverId}' (template ${message.payload.name})`, + WEBSOCKET_ERROR_MESSAGES.NO_CONNECTED_AGENT_FOR_TEMPLATE( + serverId, + message.payload.name, + ), ); } if (!client.connected) { throw new Error( - `Agent for server '${serverId}' is disconnected (template ${message.payload.name})`, + WEBSOCKET_ERROR_MESSAGES.AGENT_NOT_CONNECTED( + serverId, + message.payload.name, + ), ); } - this.logger.log( - `[DEPLOY_TRACE] emitting deploy deploymentId=${message.payload.deploymentId ?? "n/a"} serverId=${serverId} template=${message.payload.name} agentSocket=${client.id}`, + this.logEmitEvent( + DeploymentEvents.DEPLOY, + `deploymentId=${message.payload.deploymentId ?? "n/a"} serverId=${serverId} template=${message.payload.name} agentSocket=${client.id}`, ); client.emit(DeploymentEvents.DEPLOY, message); } catch (error) { @@ -1186,7 +1408,9 @@ export class DeploymentGateway try { const client = this.agentsByServerId.get(serverId); if (!client?.connected) { - reject(new Error(`No connected agent for server '${serverId}'`)); + reject( + new Error(WEBSOCKET_ERROR_MESSAGES.NO_CONNECTED_AGENT(serverId)), + ); return; } @@ -1197,7 +1421,10 @@ export class DeploymentGateway this.pendingServerResources.delete(requestId); reject( new Error( - `Server resource collection timed out after ${timeoutMs / 1000}s for server '${serverId}'`, + WEBSOCKET_ERROR_MESSAGES.TIMEOUT.SERVER_RESOURCES( + timeoutMs / 1000, + serverId, + ), ), ); }, timeoutMs); @@ -1209,8 +1436,9 @@ export class DeploymentGateway timer, }); - this.logger.log( - `[SERVER_RESOURCES] emitting event=${DeploymentEvents.SERVER_GET_RESOURCES} to agentSocket=${client.id} serverId=${serverId} requestId=${requestId} connected=${client.connected}`, + this.logEmitEvent( + DeploymentEvents.SERVER_GET_RESOURCES, + `agentSocket=${client.id} serverId=${serverId} requestId=${requestId}`, ); client.emit(DeploymentEvents.SERVER_GET_RESOURCES, payload); @@ -1232,13 +1460,19 @@ export class DeploymentGateway try { const client = this.agentsByServerId.get(serverId); if (!client?.connected) { - reject(new Error(`No connected agent for server '${serverId}'`)); + reject( + new Error(WEBSOCKET_ERROR_MESSAGES.NO_CONNECTED_AGENT(serverId)), + ); return; } const requestId = payload.requestId?.trim(); if (!requestId) { - reject(new Error("Missing requestId for deployment validation")); + reject( + new Error( + WEBSOCKET_ERROR_MESSAGES.MISSING_REQUEST_ID_FOR_DEPLOYMENT_VALIDATION, + ), + ); return; } @@ -1246,7 +1480,10 @@ export class DeploymentGateway this.pendingDeploymentValidations.delete(requestId); reject( new Error( - `Deployment validation timed out after ${timeoutMs / 1000}s for server '${serverId}'`, + WEBSOCKET_ERROR_MESSAGES.TIMEOUT.DEPLOYMENT_VALIDATE( + timeoutMs / 1000, + serverId, + ), ), ); }, timeoutMs); @@ -1258,8 +1495,9 @@ export class DeploymentGateway timer, }); - this.logger.log( - `[DEPLOYMENT_VALIDATE] emitting event=${DeploymentEvents.DEPLOYMENT_VALIDATE} to agentSocket=${client.id} serverId=${serverId} requestId=${requestId} template=${payload.templateSlug}`, + this.logEmitEvent( + DeploymentEvents.DEPLOYMENT_VALIDATE, + `agentSocket=${client.id} serverId=${serverId} requestId=${requestId} template=${payload.templateSlug}`, ); client.emit(DeploymentEvents.DEPLOYMENT_VALIDATE, payload); @@ -1282,7 +1520,9 @@ export class DeploymentGateway try { const client = this.agentsByServerId.get(serverId); if (!client?.connected) { - reject(new Error(`No connected agent for server '${serverId}'`)); + reject( + new Error(WEBSOCKET_ERROR_MESSAGES.NO_CONNECTED_AGENT(serverId)), + ); return; } @@ -1297,7 +1537,10 @@ export class DeploymentGateway this.pendingContainerActions.delete(requestId); reject( new Error( - `Container action timed out after ${timeoutMs / 1000}s for server '${serverId}'`, + WEBSOCKET_ERROR_MESSAGES.TIMEOUT.CONTAINER_ACTION( + timeoutMs / 1000, + serverId, + ), ), ); }, timeoutMs); @@ -1309,8 +1552,9 @@ export class DeploymentGateway timer, }); - this.logger.log( - `[CONTAINER_ACTION] emitting event=${DeploymentEvents.CONTAINER_ACTION} to agentSocket=${client.id} serverId=${serverId} action=${action} containerId=${containerId} requestId=${requestId} connected=${client.connected}`, + this.logEmitEvent( + DeploymentEvents.CONTAINER_ACTION, + `agentSocket=${client.id} serverId=${serverId} action=${action} containerId=${containerId} requestId=${requestId}`, ); client.emit(DeploymentEvents.CONTAINER_ACTION, payload); @@ -1334,7 +1578,9 @@ export class DeploymentGateway try { const client = this.agentsByServerId.get(serverId); if (!client?.connected) { - reject(new Error(`No connected agent for server '${serverId}'`)); + reject( + new Error(WEBSOCKET_ERROR_MESSAGES.NO_CONNECTED_AGENT(serverId)), + ); return; } @@ -1349,7 +1595,10 @@ export class DeploymentGateway this.pendingTerminalConnects.delete(requestId); reject( new Error( - `Terminal connect timed out after ${timeoutMs / 1000}s for server '${serverId}'`, + WEBSOCKET_ERROR_MESSAGES.TIMEOUT.TERMINAL_CONNECT( + timeoutMs / 1000, + serverId, + ), ), ); }, timeoutMs); @@ -1362,8 +1611,9 @@ export class DeploymentGateway timer, }); - this.logger.log( - `[TERMINAL] emitting event=${DeploymentEvents.TERMINAL_CONNECT} to agentSocket=${client.id} serverId=${serverId} requestId=${requestId}`, + this.logEmitEvent( + DeploymentEvents.TERMINAL_CONNECT, + `agentSocket=${client.id} serverId=${serverId} requestId=${requestId}`, ); client.emit(DeploymentEvents.TERMINAL_CONNECT, payload); @@ -1382,33 +1632,62 @@ export class DeploymentGateway userId: string, transport: TerminalTransport = TerminalTransport.AGENT, ): void { - this.terminalSessionsById.set(sessionId, { - sessionId, - serverId, - userId, - transport, - }); + try { + this.terminalSessionsById.set(sessionId, { + sessionId, + serverId, + userId, + transport, + }); + } catch (error) { + this.logger.error( + `Failed to register terminal session: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Broadcasts terminal output to subscribed console clients. + */ broadcastTerminalOutput(sessionId: string, data: string): void { - const session = this.terminalSessionsById.get(sessionId); - if (!session) { - return; - } + try { + const session = this.terminalSessionsById.get(sessionId); + if (!session) { + return; + } - const ns = this.getNamespaceServer(); - if (!ns) { - return; - } + const ns = this.getNamespaceServer(); + if (!ns) { + return; + } - ns.to(terminalRoom(sessionId)).emit(DeploymentEvents.TERMINAL_OUTPUT, { - sessionId, - data, - }); + this.logEmitEvent( + DeploymentEvents.TERMINAL_OUTPUT, + `sessionId=${sessionId}`, + ); + ns.to(terminalRoom(sessionId)).emit(DeploymentEvents.TERMINAL_OUTPUT, { + sessionId, + data, + }); + } catch (error) { + this.logger.error( + `Failed to broadcast terminal output: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Returns a tracked terminal session by id. + */ getTerminalSession(sessionId: string): TerminalSessionRecord | undefined { - return this.terminalSessionsById.get(sessionId); + try { + return this.terminalSessionsById.get(sessionId); + } catch (error) { + this.logger.error( + `Failed to get terminal session: ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } } /** @@ -1424,7 +1703,9 @@ export class DeploymentGateway try { const client = this.agentsByServerId.get(serverId); if (!client?.connected) { - reject(new Error(`No connected agent for server '${serverId}'`)); + reject( + new Error(WEBSOCKET_ERROR_MESSAGES.NO_CONNECTED_AGENT(serverId)), + ); return; } @@ -1441,7 +1722,10 @@ export class DeploymentGateway this.containerLogsSessionsById.delete(sessionId); reject( new Error( - `Container logs start timed out after ${timeoutMs / 1000}s for server '${serverId}'`, + WEBSOCKET_ERROR_MESSAGES.TIMEOUT.CONTAINER_LOGS_START( + timeoutMs / 1000, + serverId, + ), ), ); }, timeoutMs); @@ -1468,8 +1752,9 @@ export class DeploymentGateway containerId, ); - this.logger.log( - `[CONTAINER_LOGS] emitting event=${DeploymentEvents.CONTAINER_LOGS_START} to agentSocket=${client.id} serverId=${serverId} containerId=${containerId} sessionId=${sessionId} requestId=${requestId}`, + this.logEmitEvent( + DeploymentEvents.CONTAINER_LOGS_START, + `agentSocket=${client.id} serverId=${serverId} containerId=${containerId} sessionId=${sessionId} requestId=${requestId}`, ); client.emit(DeploymentEvents.CONTAINER_LOGS_START, payload); @@ -1479,24 +1764,43 @@ export class DeploymentGateway }); } + /** + * Registers a container logs session in the gateway registry. + */ registerContainerLogsSession( sessionId: string, serverId: string, userId: string, containerId: string, ): void { - this.containerLogsSessionsById.set(sessionId, { - sessionId, - serverId, - userId, - containerId, - }); + try { + this.containerLogsSessionsById.set(sessionId, { + sessionId, + serverId, + userId, + containerId, + }); + } catch (error) { + this.logger.error( + `Failed to register container logs session: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + /** + * Returns a tracked container logs session by id. + */ getContainerLogsSession( sessionId: string, ): ContainerLogsSessionRecord | undefined { - return this.containerLogsSessionsById.get(sessionId); + try { + return this.containerLogsSessionsById.get(sessionId); + } catch (error) { + this.logger.error( + `Failed to get container logs session: ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } } /** @@ -1506,31 +1810,73 @@ export class DeploymentGateway sessionId: string, options: { notifyAgent?: boolean } = {}, ): void { - const session = this.containerLogsSessionsById.get(sessionId); - if (!session) { - return; - } + try { + const session = this.containerLogsSessionsById.get(sessionId); + if (!session) { + return; + } - this.containerLogsSessionsById.delete(sessionId); + this.containerLogsSessionsById.delete(sessionId); - if (options.notifyAgent !== false) { - const agent = this.agentsByServerId.get(session.serverId); - if (agent?.connected) { - const payload: ContainerLogsStopPayload = { sessionId }; - agent.emit(DeploymentEvents.CONTAINER_LOGS_STOP, payload); + if (options.notifyAgent !== false) { + const agent = this.agentsByServerId.get(session.serverId); + if (agent?.connected) { + const payload: ContainerLogsStopPayload = { sessionId }; + this.logEmitEvent( + DeploymentEvents.CONTAINER_LOGS_STOP, + `sessionId=${sessionId} target=agent serverId=${session.serverId}`, + ); + agent.emit(DeploymentEvents.CONTAINER_LOGS_STOP, payload); + } } + + const ns = this.getNamespaceServer(); + const payload: ContainerLogsStopPayload = { sessionId }; + this.logEmitEvent( + DeploymentEvents.CONTAINER_LOGS_STOP, + `sessionId=${sessionId} target=console`, + ); + ns?.to(containerLogsRoom(sessionId)).emit( + DeploymentEvents.CONTAINER_LOGS_STOP, + payload, + ); + } catch (error) { + this.logger.error( + `Failed to close container logs session: ${error instanceof Error ? error.message : String(error)}`, + ); } + } - const ns = this.getNamespaceServer(); - const payload: ContainerLogsStopPayload = { sessionId }; - ns?.to(containerLogsRoom(sessionId)).emit( - DeploymentEvents.CONTAINER_LOGS_STOP, - payload, - ); + /** + * Notifies an agent about a container logs session stop. + * @param serverId - The server ID. + * @param sessionId - The session ID. + */ + notifyAgentContainerLogsStop(serverId: string, sessionId: string): void { + try { + const agent = this.agentsByServerId.get(serverId); + if (!agent?.connected) { + return; + } + + const payload: ContainerLogsStopPayload = { sessionId }; + this.logEmitEvent( + DeploymentEvents.CONTAINER_LOGS_STOP, + `sessionId=${sessionId} target=agent serverId=${serverId}`, + ); + agent.emit(DeploymentEvents.CONTAINER_LOGS_STOP, payload); + } catch (error) { + this.logger.error( + `Failed to notify agent container logs stop: ${error instanceof Error ? error.message : String(error)}`, + ); + } } /** * Closes a terminal session. + * @param sessionId - The session ID. + * @param options - The options for closing the terminal session. + * @returns A promise that resolves when the terminal session is closed. */ closeTerminalSession( sessionId: string, @@ -1539,33 +1885,72 @@ export class DeploymentGateway skipTransportClose?: boolean; } = {}, ): void { - const session = this.terminalSessionsById.get(sessionId); - if (!session) { - return; - } - - this.terminalSessionsById.delete(sessionId); + try { + const session = this.terminalSessionsById.get(sessionId); + if (!session) { + return; + } - if (!options.skipTransportClose) { - if (session.transport === TerminalTransport.SSH) { - this.sshTerminalService.closeSession(sessionId, { - notifyClients: false, - }); - } else if (options.notifyAgent !== false) { - const agent = this.agentsByServerId.get(session.serverId); - if (agent?.connected) { - const payload: TerminalDisconnectPayload = { sessionId }; - agent.emit(DeploymentEvents.TERMINAL_DISCONNECT, payload); + this.terminalSessionsById.delete(sessionId); + + if (!options.skipTransportClose) { + if (session.transport === TerminalTransport.SSH) { + this.sshTerminalService.closeSession(sessionId, { + notifyClients: false, + }); + } else if (options.notifyAgent !== false) { + const agent = this.agentsByServerId.get(session.serverId); + if (agent?.connected) { + const payload: TerminalDisconnectPayload = { sessionId }; + this.logEmitEvent( + DeploymentEvents.TERMINAL_DISCONNECT, + `sessionId=${sessionId} target=agent serverId=${session.serverId}`, + ); + agent.emit(DeploymentEvents.TERMINAL_DISCONNECT, payload); + } } } + + const ns = this.getNamespaceServer(); + const payload: TerminalDisconnectPayload = { sessionId }; + this.logEmitEvent( + DeploymentEvents.TERMINAL_DISCONNECT, + `sessionId=${sessionId} target=console`, + ); + ns?.to(terminalRoom(sessionId)).emit( + DeploymentEvents.TERMINAL_DISCONNECT, + payload, + ); + } catch (error) { + this.logger.error( + `Failed to close terminal session: ${error instanceof Error ? error.message : String(error)}`, + ); } + } - const ns = this.getNamespaceServer(); - const payload: TerminalDisconnectPayload = { sessionId }; - ns?.to(terminalRoom(sessionId)).emit( - DeploymentEvents.TERMINAL_DISCONNECT, - payload, - ); + /** + * Notifies an agent about a terminal session disconnect. + * @param serverId - The server ID. + * @param sessionId - The session ID. + */ + notifyAgentTerminalDisconnect(serverId: string, sessionId: string): void { + try { + const agent = this.agentsByServerId.get(serverId); + if (!agent?.connected) { + return; + } + + const payload: TerminalDisconnectPayload = { sessionId }; + this.logEmitEvent( + DeploymentEvents.TERMINAL_DISCONNECT, + `sessionId=${sessionId} target=agent serverId=${serverId}`, + ); + agent.emit(DeploymentEvents.TERMINAL_DISCONNECT, payload); + } catch (error) { + this.logger.error( + `Failed to notify agent terminal disconnect: ${error instanceof Error ? error.message : String(error)}`, + ); + } } /** @@ -1579,7 +1964,9 @@ export class DeploymentGateway try { const client = this.agentsByServerId.get(serverId); if (!client?.connected) { - reject(new Error(`No connected agent for server '${serverId}'`)); + reject( + new Error(WEBSOCKET_ERROR_MESSAGES.NO_CONNECTED_AGENT(serverId)), + ); return; } @@ -1590,7 +1977,10 @@ export class DeploymentGateway this.pendingContainerDiscovery.delete(requestId); reject( new Error( - `Container discovery timed out after ${timeoutMs / 1000}s for server '${serverId}'`, + WEBSOCKET_ERROR_MESSAGES.TIMEOUT.CONTAINER_DISCOVER( + timeoutMs / 1000, + serverId, + ), ), ); }, timeoutMs); @@ -1602,8 +1992,9 @@ export class DeploymentGateway timer, }); - this.logger.log( - `[CONTAINER_DISCOVER] emitting event=${DeploymentEvents.CONTAINER_DISCOVER} to agentSocket=${client.id} serverId=${serverId} requestId=${requestId} connected=${client.connected}`, + this.logEmitEvent( + DeploymentEvents.CONTAINER_DISCOVER, + `agentSocket=${client.id} serverId=${serverId} requestId=${requestId}`, ); client.emit(DeploymentEvents.CONTAINER_DISCOVER, payload); @@ -1613,81 +2004,90 @@ export class DeploymentGateway }); } + /** + * Attaches inbound event handlers to an agent socket connection. + */ private attachAgentInboundHandlers(client: Socket): void { - client.removeAllListeners(DeploymentEvents.AGENT_HELLO); - client.removeAllListeners(DeploymentEvents.CONTAINER_ACTION_RESULT); - client.removeAllListeners(DeploymentEvents.CONTAINER_DISCOVER_RESULT); - client.removeAllListeners(DeploymentEvents.SERVER_GET_RESOURCES_RESULT); - client.removeAllListeners(DeploymentEvents.TERMINAL_CONNECT_RESULT); - client.removeAllListeners(DeploymentEvents.TERMINAL_OUTPUT); - client.removeAllListeners(DeploymentEvents.TERMINAL_DISCONNECT); - client.removeAllListeners(DeploymentEvents.CONTAINER_LOGS_START_RESULT); - client.removeAllListeners(DeploymentEvents.CONTAINER_LOGS_DATA); - client.removeAllListeners(DeploymentEvents.CONTAINER_LOGS_ERROR); - client.removeAllListeners(DeploymentEvents.CONTAINER_LOGS_STOP); - client.on(DeploymentEvents.AGENT_HELLO, (payload: AgentHelloPayload) => { - this.processAgentHello(client, payload); - }); - client.on( - DeploymentEvents.CONTAINER_ACTION_RESULT, - (payload: ContainerActionResponsePayload) => { - this.handleContainerActionResult(client, payload); - }, - ); - client.on( - DeploymentEvents.CONTAINER_DISCOVER_RESULT, - (payload: ContainerDiscoverResponsePayload) => { - this.handleContainerDiscoverResult(client, payload); - }, - ); - client.on( - DeploymentEvents.SERVER_GET_RESOURCES_RESULT, - (payload: ServerGetResourcesResponsePayload) => { - this.handleServerGetResourcesResult(client, payload); - }, - ); - client.on( - DeploymentEvents.TERMINAL_CONNECT_RESULT, - (payload: TerminalConnectResponsePayload) => { - this.processTerminalConnectResult(client, payload); - }, - ); - client.on( - DeploymentEvents.TERMINAL_OUTPUT, - (payload: TerminalOutputPayload) => { - this.relayTerminalOutput(client, payload); - }, - ); - client.on( - DeploymentEvents.TERMINAL_DISCONNECT, - (payload: TerminalDisconnectPayload) => { - this.processAgentTerminalDisconnect(client, payload); - }, - ); - client.on( - DeploymentEvents.CONTAINER_LOGS_START_RESULT, - (payload: ContainerLogsStartResponsePayload) => { - this.processContainerLogsStartResult(client, payload); - }, - ); - client.on( - DeploymentEvents.CONTAINER_LOGS_DATA, - (payload: ContainerLogsDataPayload) => { - this.relayContainerLogsData(client, payload); - }, - ); - client.on( - DeploymentEvents.CONTAINER_LOGS_ERROR, - (payload: ContainerLogsErrorPayload) => { - this.relayContainerLogsError(client, payload); - }, - ); - client.on( - DeploymentEvents.CONTAINER_LOGS_STOP, - (payload: ContainerLogsStopPayload) => { - this.processAgentContainerLogsStop(client, payload); - }, - ); + try { + client.removeAllListeners(DeploymentEvents.AGENT_HELLO); + client.removeAllListeners(DeploymentEvents.CONTAINER_ACTION_RESULT); + client.removeAllListeners(DeploymentEvents.CONTAINER_DISCOVER_RESULT); + client.removeAllListeners(DeploymentEvents.SERVER_GET_RESOURCES_RESULT); + client.removeAllListeners(DeploymentEvents.TERMINAL_CONNECT_RESULT); + client.removeAllListeners(DeploymentEvents.TERMINAL_OUTPUT); + client.removeAllListeners(DeploymentEvents.TERMINAL_DISCONNECT); + client.removeAllListeners(DeploymentEvents.CONTAINER_LOGS_START_RESULT); + client.removeAllListeners(DeploymentEvents.CONTAINER_LOGS_DATA); + client.removeAllListeners(DeploymentEvents.CONTAINER_LOGS_ERROR); + client.removeAllListeners(DeploymentEvents.CONTAINER_LOGS_STOP); + client.on(DeploymentEvents.AGENT_HELLO, (payload: AgentHelloPayload) => { + this.processAgentHello(client, payload); + }); + client.on( + DeploymentEvents.CONTAINER_ACTION_RESULT, + (payload: ContainerActionResponsePayload) => { + this.handleContainerActionResult(client, payload); + }, + ); + client.on( + DeploymentEvents.CONTAINER_DISCOVER_RESULT, + (payload: ContainerDiscoverResponsePayload) => { + this.handleContainerDiscoverResult(client, payload); + }, + ); + client.on( + DeploymentEvents.SERVER_GET_RESOURCES_RESULT, + (payload: ServerGetResourcesResponsePayload) => { + this.handleServerGetResourcesResult(client, payload); + }, + ); + client.on( + DeploymentEvents.TERMINAL_CONNECT_RESULT, + (payload: TerminalConnectResponsePayload) => { + this.processTerminalConnectResult(client, payload); + }, + ); + client.on( + DeploymentEvents.TERMINAL_OUTPUT, + (payload: TerminalOutputPayload) => { + this.relayTerminalOutput(client, payload); + }, + ); + client.on( + DeploymentEvents.TERMINAL_DISCONNECT, + (payload: TerminalDisconnectPayload) => { + this.processAgentTerminalDisconnect(client, payload); + }, + ); + client.on( + DeploymentEvents.CONTAINER_LOGS_START_RESULT, + (payload: ContainerLogsStartResponsePayload) => { + this.processContainerLogsStartResult(client, payload); + }, + ); + client.on( + DeploymentEvents.CONTAINER_LOGS_DATA, + (payload: ContainerLogsDataPayload) => { + this.relayContainerLogsData(client, payload); + }, + ); + client.on( + DeploymentEvents.CONTAINER_LOGS_ERROR, + (payload: ContainerLogsErrorPayload) => { + this.relayContainerLogsError(client, payload); + }, + ); + client.on( + DeploymentEvents.CONTAINER_LOGS_STOP, + (payload: ContainerLogsStopPayload) => { + this.processAgentContainerLogsStop(client, payload); + }, + ); + } catch (error) { + this.logger.error( + `Failed to attach agent inbound handlers: ${error instanceof Error ? error.message : String(error)}`, + ); + } } /** @@ -1697,21 +2097,27 @@ export class DeploymentGateway client: Socket, payload: TerminalDisconnectPayload, ): void { - const sessionId = payload?.sessionId?.trim(); - if (!sessionId) { - return; - } + try { + const sessionId = payload?.sessionId?.trim(); + if (!sessionId) { + return; + } - const session = this.terminalSessionsById.get(sessionId); - const serverId = this.serverIdBySocketId.get(client.id); - if (session && serverId && serverId !== session.serverId) { - return; - } + const session = this.terminalSessionsById.get(sessionId); + const serverId = this.serverIdBySocketId.get(client.id); + if (session && serverId && serverId !== session.serverId) { + return; + } - this.closeTerminalSession(sessionId, { - notifyAgent: false, - skipTransportClose: true, - }); + this.closeTerminalSession(sessionId, { + notifyAgent: false, + skipTransportClose: true, + }); + } catch (error) { + this.logger.error( + `Failed to process agent terminal disconnect: ${error instanceof Error ? error.message : String(error)}`, + ); + } } /** @@ -1724,25 +2130,16 @@ export class DeploymentGateway try { const requestId = payload?.requestId?.trim(); if (!requestId) { - this.logger.warn( - `Ignoring terminal connect result without requestId from ${client.id}`, - ); return; } const pending = this.pendingTerminalConnects.get(requestId); if (!pending) { - this.logger.warn( - `No pending terminal connect for requestId=${requestId}`, - ); return; } const serverId = this.serverIdBySocketId.get(client.id); if (serverId && serverId !== pending.serverId) { - this.logger.warn( - `Terminal connect result server mismatch requestId=${requestId} expected=${pending.serverId} got=${serverId}`, - ); return; } @@ -1756,7 +2153,11 @@ export class DeploymentGateway const sessionId = payload.sessionId?.trim(); if (!sessionId) { - pending.reject(new Error("Agent returned no terminal session id")); + pending.reject( + new Error( + WEBSOCKET_ERROR_MESSAGES.AGENT_RETURNED_NO_TERMINAL_SESSION_ID, + ), + ); return; } @@ -1799,6 +2200,10 @@ export class DeploymentGateway return; } + this.logEmitEvent( + DeploymentEvents.TERMINAL_OUTPUT, + `sessionId=${sessionId} target=console`, + ); ns.to(terminalRoom(sessionId)).emit(DeploymentEvents.TERMINAL_OUTPUT, { sessionId, data, @@ -1826,9 +2231,6 @@ export class DeploymentGateway const session = this.terminalSessionsById.get(sessionId); if (!session) { - this.logger.warn( - `[TERMINAL] ${event} ignored for unknown sessionId=${sessionId} client=${client.id}`, - ); return; } @@ -1852,6 +2254,7 @@ export class DeploymentGateway } agent.emit(event, payload); + this.logEmitEvent(event, `sessionId=${sessionId} target=agent`); } catch (error) { this.logger.error( `Failed to forward terminal event ${event}: ${error instanceof Error ? error.message : String(error)}`, @@ -1866,32 +2269,38 @@ export class DeploymentGateway serverId: string, reason: string, ): void { - for (const [requestId, pending] of this.pendingTerminalConnects) { - if (pending.serverId !== serverId) { - continue; + try { + for (const [requestId, pending] of this.pendingTerminalConnects) { + if (pending.serverId !== serverId) { + continue; + } + clearTimeout(pending.timer); + this.pendingTerminalConnects.delete(requestId); + pending.reject(new Error(reason)); } - clearTimeout(pending.timer); - this.pendingTerminalConnects.delete(requestId); - pending.reject(new Error(reason)); + } catch (error) { + this.logger.error( + `Failed to reject pending terminal connects: ${error instanceof Error ? error.message : String(error)}`, + ); } } /** * Closes terminal sessions for a server. */ - private closeTerminalSessionsForServer( - serverId: string, - reason: string, - ): void { - for (const [sessionId, session] of this.terminalSessionsById) { - if (session.serverId !== serverId) { - continue; + private closeTerminalSessionsForServer(serverId: string): void { + try { + for (const [sessionId, session] of this.terminalSessionsById) { + if (session.serverId !== serverId) { + continue; + } + this.closeTerminalSession(sessionId, { + notifyAgent: session.transport === TerminalTransport.AGENT, + }); } - this.closeTerminalSession(sessionId, { - notifyAgent: session.transport === TerminalTransport.AGENT, - }); - this.logger.log( - `[TERMINAL] closed sessionId=${sessionId} serverId=${serverId}: ${reason}`, + } catch (error) { + this.logger.error( + `Failed to close terminal sessions for server: ${error instanceof Error ? error.message : String(error)}`, ); } } @@ -1903,31 +2312,37 @@ export class DeploymentGateway serverId: string, reason: string, ): void { - for (const [requestId, pending] of this.pendingContainerLogsStarts) { - if (pending.serverId !== serverId) { - continue; + try { + for (const [requestId, pending] of this.pendingContainerLogsStarts) { + if (pending.serverId !== serverId) { + continue; + } + clearTimeout(pending.timer); + this.pendingContainerLogsStarts.delete(requestId); + this.containerLogsSessionsById.delete(pending.sessionId); + pending.reject(new Error(reason)); } - clearTimeout(pending.timer); - this.pendingContainerLogsStarts.delete(requestId); - this.containerLogsSessionsById.delete(pending.sessionId); - pending.reject(new Error(reason)); + } catch (error) { + this.logger.error( + `Failed to reject pending container logs starts: ${error instanceof Error ? error.message : String(error)}`, + ); } } /** * Closes container logs sessions for a server. */ - private closeContainerLogsSessionsForServer( - serverId: string, - reason: string, - ): void { - for (const [sessionId, session] of this.containerLogsSessionsById) { - if (session.serverId !== serverId) { - continue; + private closeContainerLogsSessionsForServer(serverId: string): void { + try { + for (const [sessionId, session] of this.containerLogsSessionsById) { + if (session.serverId !== serverId) { + continue; + } + this.closeContainerLogsSession(sessionId, { notifyAgent: false }); } - this.closeContainerLogsSession(sessionId, { notifyAgent: false }); - this.logger.log( - `[CONTAINER_LOGS] closed sessionId=${sessionId} serverId=${serverId}: ${reason}`, + } catch (error) { + this.logger.error( + `Failed to close container logs sessions for server: ${error instanceof Error ? error.message : String(error)}`, ); } } @@ -1942,25 +2357,16 @@ export class DeploymentGateway try { const requestId = payload?.requestId?.trim(); if (!requestId) { - this.logger.warn( - `Ignoring container logs start result without requestId from ${client.id}`, - ); return; } const pending = this.pendingContainerLogsStarts.get(requestId); if (!pending) { - this.logger.warn( - `No pending container logs start for requestId=${requestId}`, - ); return; } const serverId = this.serverIdBySocketId.get(client.id); if (serverId && serverId !== pending.serverId) { - this.logger.warn( - `Container logs start result server mismatch requestId=${requestId} expected=${pending.serverId} got=${serverId}`, - ); return; } @@ -1977,7 +2383,9 @@ export class DeploymentGateway if (!sessionId) { this.containerLogsSessionsById.delete(pending.sessionId); pending.reject( - new Error("Agent returned no container logs session id"), + new Error( + WEBSOCKET_ERROR_MESSAGES.AGENT_RETURNED_NO_CONTAINER_LOGS_SESSION_ID, + ), ); return; } @@ -2015,6 +2423,10 @@ export class DeploymentGateway return; } + this.logEmitEvent( + DeploymentEvents.CONTAINER_LOGS_DATA, + `sessionId=${sessionId} target=console`, + ); ns.to(containerLogsRoom(sessionId)).emit( DeploymentEvents.CONTAINER_LOGS_DATA, { sessionId, data }, @@ -2051,6 +2463,10 @@ export class DeploymentGateway return; } + this.logEmitEvent( + DeploymentEvents.CONTAINER_LOGS_ERROR, + `sessionId=${sessionId} target=console`, + ); ns.to(containerLogsRoom(sessionId)).emit( DeploymentEvents.CONTAINER_LOGS_ERROR, { sessionId, error }, @@ -2071,18 +2487,24 @@ export class DeploymentGateway client: Socket, payload: ContainerLogsStopPayload, ): void { - const sessionId = payload?.sessionId?.trim(); - if (!sessionId) { - return; - } + try { + const sessionId = payload?.sessionId?.trim(); + if (!sessionId) { + return; + } - const session = this.containerLogsSessionsById.get(sessionId); - const serverId = this.serverIdBySocketId.get(client.id); - if (session && serverId && serverId !== session.serverId) { - return; - } + const session = this.containerLogsSessionsById.get(sessionId); + const serverId = this.serverIdBySocketId.get(client.id); + if (session && serverId && serverId !== session.serverId) { + return; + } - this.closeContainerLogsSession(sessionId, { notifyAgent: false }); + this.closeContainerLogsSession(sessionId, { notifyAgent: false }); + } catch (error) { + this.logger.error( + `Failed to process agent container logs stop: ${error instanceof Error ? error.message : String(error)}`, + ); + } } /** @@ -2092,13 +2514,19 @@ export class DeploymentGateway serverId: string, reason: string, ): void { - for (const [requestId, pending] of this.pendingContainerDiscovery) { - if (pending.serverId !== serverId) { - continue; + try { + for (const [requestId, pending] of this.pendingContainerDiscovery) { + if (pending.serverId !== serverId) { + continue; + } + clearTimeout(pending.timer); + this.pendingContainerDiscovery.delete(requestId); + pending.reject(new Error(reason)); } - clearTimeout(pending.timer); - this.pendingContainerDiscovery.delete(requestId); - pending.reject(new Error(reason)); + } catch (error) { + this.logger.error( + `Failed to reject pending container discovery: ${error instanceof Error ? error.message : String(error)}`, + ); } } @@ -2109,13 +2537,19 @@ export class DeploymentGateway serverId: string, reason: string, ): void { - for (const [requestId, pending] of this.pendingDeploymentValidations) { - if (pending.serverId !== serverId) { - continue; + try { + for (const [requestId, pending] of this.pendingDeploymentValidations) { + if (pending.serverId !== serverId) { + continue; + } + clearTimeout(pending.timer); + this.pendingDeploymentValidations.delete(requestId); + pending.reject(new Error(reason)); } - clearTimeout(pending.timer); - this.pendingDeploymentValidations.delete(requestId); - pending.reject(new Error(reason)); + } catch (error) { + this.logger.error( + `Failed to reject pending deployment validations: ${error instanceof Error ? error.message : String(error)}`, + ); } } @@ -2126,13 +2560,19 @@ export class DeploymentGateway serverId: string, reason: string, ): void { - for (const [requestId, pending] of this.pendingServerResources) { - if (pending.serverId !== serverId) { - continue; + try { + for (const [requestId, pending] of this.pendingServerResources) { + if (pending.serverId !== serverId) { + continue; + } + clearTimeout(pending.timer); + this.pendingServerResources.delete(requestId); + pending.reject(new Error(reason)); } - clearTimeout(pending.timer); - this.pendingServerResources.delete(requestId); - pending.reject(new Error(reason)); + } catch (error) { + this.logger.error( + `Failed to reject pending server resources: ${error instanceof Error ? error.message : String(error)}`, + ); } } @@ -2143,13 +2583,19 @@ export class DeploymentGateway serverId: string, reason: string, ): void { - for (const [requestId, pending] of this.pendingContainerActions) { - if (pending.serverId !== serverId) { - continue; + try { + for (const [requestId, pending] of this.pendingContainerActions) { + if (pending.serverId !== serverId) { + continue; + } + clearTimeout(pending.timer); + this.pendingContainerActions.delete(requestId); + pending.reject(new Error(reason)); } - clearTimeout(pending.timer); - this.pendingContainerActions.delete(requestId); - pending.reject(new Error(reason)); + } catch (error) { + this.logger.error( + `Failed to reject pending container actions: ${error instanceof Error ? error.message : String(error)}`, + ); } } @@ -2160,27 +2606,42 @@ export class DeploymentGateway serverId: string, reason: string, ): void { - for (const [deploymentId, pending] of this.pendingDeploymentRemoves) { - if (pending.serverId !== serverId) { - continue; + try { + for (const [deploymentId, pending] of this.pendingDeploymentRemoves) { + if (pending.serverId !== serverId) { + continue; + } + clearTimeout(pending.timer); + this.pendingDeploymentRemoves.delete(deploymentId); + pending.reject(new Error(reason)); } - clearTimeout(pending.timer); - this.pendingDeploymentRemoves.delete(deploymentId); - pending.reject(new Error(reason)); + } catch (error) { + this.logger.error( + `Failed to reject pending deployment removes: ${error instanceof Error ? error.message : String(error)}`, + ); } } + /** + * Rejects pending agent removes for a server. + */ private rejectPendingAgentRemovesForServer( serverId: string, reason: string, ): void { - for (const [requestId, pending] of this.pendingAgentRemoves) { - if (pending.serverId !== serverId) { - continue; + try { + for (const [requestId, pending] of this.pendingAgentRemoves) { + if (pending.serverId !== serverId) { + continue; + } + clearTimeout(pending.timer); + this.pendingAgentRemoves.delete(requestId); + pending.reject(new Error(reason)); } - clearTimeout(pending.timer); - this.pendingAgentRemoves.delete(requestId); - pending.reject(new Error(reason)); + } catch (error) { + this.logger.error( + `Failed to reject pending agent removes: ${error instanceof Error ? error.message : String(error)}`, + ); } } @@ -2203,15 +2664,29 @@ export class DeploymentGateway * Checks if an agent supports a capability. */ agentSupports(serverId: string, capability: string): boolean { - const capabilities = this.agentCapabilitiesByServerId.get(serverId); - return Boolean(capabilities?.has(capability)); + try { + const capabilities = this.agentCapabilitiesByServerId.get(serverId); + return Boolean(capabilities?.has(capability)); + } catch (error) { + this.logger.error( + `Failed to check agent capability: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } } /** * Gets the version of an agent for a server. */ getAgentVersion(serverId: string): string | null { - return this.agentVersionsByServerId.get(serverId) ?? null; + try { + return this.agentVersionsByServerId.get(serverId) ?? null; + } catch (error) { + this.logger.error( + `Failed to get agent version: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } } /** @@ -2227,13 +2702,6 @@ export class DeploymentGateway this.agentCapabilitiesByServerId.set(serverId, capabilities); this.agentVersionsByServerId.set(serverId, version); } - - this.logger.log( - `[AgentHello] received from agentSocket=${client.id}` + - (serverId ? ` serverId=${serverId}` : " (unbound)") + - ` version=${version} capabilities=[${[...capabilities].join(", ")}]` + - ` supportsContainerAction=${capabilities.has(DeploymentEvents.CONTAINER_ACTION)}`, - ); } catch (error) { this.logger.error( `Failed to process agent hello: ${error instanceof Error ? error.message : String(error)}`, @@ -2245,8 +2713,14 @@ export class DeploymentGateway * Clears agent metadata for a server. */ private clearAgentMetadataForServer(serverId: string): void { - this.agentCapabilitiesByServerId.delete(serverId); - this.agentVersionsByServerId.delete(serverId); + try { + this.agentCapabilitiesByServerId.delete(serverId); + this.agentVersionsByServerId.delete(serverId); + } catch (error) { + this.logger.error( + `Failed to clear agent metadata: ${error instanceof Error ? error.message : String(error)}`, + ); + } } /** @@ -2267,7 +2741,14 @@ export class DeploymentGateway * Gets the number of connected agents. */ getConnectedAgentsCount(): number { - return this.connectedAgents.size; + try { + return this.connectedAgents.size; + } catch (error) { + this.logger.error( + `Failed to get connected agents count: ${error instanceof Error ? error.message : String(error)}`, + ); + return 0; + } } /** @@ -2319,7 +2800,7 @@ export class DeploymentGateway return raw || null; } catch (error) { - this.logger.warn( + this.logger.error( `Failed to parse server id from handshake: ${error instanceof Error ? error.message : String(error)}`, ); return null; @@ -2340,7 +2821,7 @@ export class DeploymentGateway this.serverIdBySocketId.delete(socketId); } } catch (error) { - this.logger.warn( + this.logger.error( `Failed to unregister server binding for socket ${socketId}: ${error instanceof Error ? error.message : String(error)}`, ); } diff --git a/console-app/src/components/shared/copy-button.css b/console-app/src/components/shared/copy-button.css index da702b5..52f1993 100644 --- a/console-app/src/components/shared/copy-button.css +++ b/console-app/src/components/shared/copy-button.css @@ -3,65 +3,175 @@ display: inline-flex; } -.copy-btn-popover { +.copy-btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #62788e; + cursor: pointer; + opacity: 0.7; + transition: + opacity 0.15s ease, + background 0.15s ease, + color 0.15s ease, + transform 0.15s ease; +} + +.copy-btn:hover { + opacity: 1; + background: rgb(37 99 235 / 8%); + color: var(--primary); + transform: scale(1.05); +} + +.copy-btn:focus-visible { + outline: none; + opacity: 1; + box-shadow: + 0 0 0 2px var(--background), + 0 0 0 4px rgb(37 99 235 / 35%); +} + +.copy-btn.copied { + color: var(--success); + opacity: 1; + background: rgb(22 163 74 / 10%); +} + +.copy-btn-tooltip { position: absolute; - bottom: calc(100% + 6px); + bottom: calc(100% + 0.5rem); left: 50%; - transform: translateX(-50%); - padding: 0.25rem 0.5rem; - border-radius: 4px; - background: #031b4e; - color: #fff; - font-size: 0.75rem; + z-index: 10; + display: inline-flex; + align-items: center; + gap: 0.3125rem; + padding: 0.375rem 0.625rem; + border: 1px solid rgb(15 23 42 / 12%); + border-radius: 8px; + background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%); + color: #f8fafc; + font-size: 0.6875rem; font-weight: 600; line-height: 1.2; + letter-spacing: 0.02em; white-space: nowrap; pointer-events: none; - z-index: 2; - box-shadow: 0 2px 8px rgb(0 0 0 / 15%); + opacity: 0; + visibility: hidden; + transform: translateX(-50%) translateY(4px) scale(0.96); + box-shadow: + 0 10px 28px rgb(15 23 42 / 28%), + 0 0 0 1px rgb(255 255 255 / 6%) inset; + transition: + opacity 0.18s ease, + transform 0.18s ease, + visibility 0.18s ease; } -.copy-btn-popover::after { +.copy-btn-tooltip::after { content: ""; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); - border: 4px solid transparent; - border-top-color: #031b4e; + border: 5px solid transparent; + border-top-color: #0f172a; } -[data-theme="dark"] .copy-btn-popover { - background: #e2e8f0; +.copy-btn-wrap:hover .copy-btn-tooltip:not(.is-visible), +.copy-btn:focus-visible .copy-btn-tooltip:not(.is-visible) { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0) scale(1); +} + +.copy-btn-tooltip.is-visible { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0) scale(1); + animation: copy-btn-tooltip-pop 0.22s cubic-bezier(0.34, 1.4, 0.64, 1); +} + +.copy-btn-tooltip--success { + border-color: rgb(22 163 74 / 35%); + background: linear-gradient(180deg, #14532d 0%, #052e16 100%); + color: #bbf7d0; + box-shadow: + 0 10px 28px rgb(22 163 74 / 22%), + 0 0 0 1px rgb(187 247 208 / 12%) inset; +} + +.copy-btn-tooltip--success::after { + border-top-color: #052e16; +} + +.copy-btn-tooltip--success svg { + color: #4ade80; +} + +[data-theme="dark"] .copy-btn { + color: #94a3b8; +} + +[data-theme="dark"] .copy-btn:hover { + background: rgb(59 130 246 / 14%); + color: var(--primary-hover); +} + +[data-theme="dark"] .copy-btn-tooltip { + border-color: rgb(148 163 184 / 18%); + background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%); color: #0f172a; + box-shadow: + 0 10px 28px rgb(0 0 0 / 45%), + 0 0 0 1px rgb(255 255 255 / 50%) inset; } -[data-theme="dark"] .copy-btn-popover::after { +[data-theme="dark"] .copy-btn-tooltip::after { border-top-color: #e2e8f0; } -.copy-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.75rem; - height: 1.75rem; - padding: 0; - border: none; - border-radius: 4px; - background: transparent; - color: #62788e; - cursor: pointer; - opacity: 0.7; - transition: opacity 0.15s, background 0.15s; +[data-theme="dark"] .copy-btn-tooltip--success { + border-color: rgb(74 222 128 / 30%); + background: linear-gradient(180deg, #166534 0%, #052e16 100%); + color: #dcfce7; + box-shadow: + 0 10px 28px rgb(22 163 74 / 28%), + 0 0 0 1px rgb(187 247 208 / 10%) inset; } -.copy-btn:hover { - opacity: 1; - background: rgb(0 0 0 / 6%); +[data-theme="dark"] .copy-btn-tooltip--success::after { + border-top-color: #052e16; } -.copy-btn.copied { - color: #00b871; - opacity: 1; +@keyframes copy-btn-tooltip-pop { + from { + opacity: 0; + transform: translateX(-50%) translateY(6px) scale(0.9); + } + + to { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .copy-btn, + .copy-btn-tooltip { + transition: none; + animation: none; + } + + .copy-btn:hover { + transform: none; + } } diff --git a/console-app/src/components/shared/copy-button.tsx b/console-app/src/components/shared/copy-button.tsx index 00fe0e8..aeb19a8 100644 --- a/console-app/src/components/shared/copy-button.tsx +++ b/console-app/src/components/shared/copy-button.tsx @@ -22,6 +22,20 @@ function CopyIcon() { ); } +function CheckIcon() { + return ( + + + + ); +} + type CopyButtonProps = { text: string; label?: string; @@ -42,19 +56,27 @@ export function CopyButton({ text, label = "Copy" }: CopyButtonProps) { return (
- {copied ? ( - - Copied - - ) : null}
); diff --git a/console-app/src/features/deployments/hooks/use-container-logs.ts b/console-app/src/features/deployments/hooks/use-container-logs.ts index dc72000..537ab99 100644 --- a/console-app/src/features/deployments/hooks/use-container-logs.ts +++ b/console-app/src/features/deployments/hooks/use-container-logs.ts @@ -7,7 +7,6 @@ import { type ContainerLogsStopPayload, } from "@/constants/deployment-events"; import { - emitContainerLogsStop, getDeploymentSocket, subscribeContainerLogsSession, unsubscribeContainerLogsSession, @@ -69,7 +68,6 @@ export function useContainerLogs( } try { - emitContainerLogsStop(currentSessionId); await stopContainerLogs(serverId, currentSessionId); } catch { // Session may already be closed on the server. @@ -131,7 +129,6 @@ export function useContainerLogs( cancelled = true; const currentSessionId = sessionIdRef.current; if (currentSessionId) { - emitContainerLogsStop(currentSessionId); void stopContainerLogs(serverId, currentSessionId).catch(() => undefined); unsubscribeContainerLogsSession(currentSessionId); } diff --git a/console-app/src/features/servers/hooks/use-server-terminal.ts b/console-app/src/features/servers/hooks/use-server-terminal.ts index 1b0c094..9f75fcf 100644 --- a/console-app/src/features/servers/hooks/use-server-terminal.ts +++ b/console-app/src/features/servers/hooks/use-server-terminal.ts @@ -6,7 +6,6 @@ import { type TerminalOutputPayload, } from "@/constants/deployment-events"; import { - emitTerminalDisconnect, emitTerminalInput, emitTerminalResize, getDeploymentSocket, @@ -126,6 +125,18 @@ export function useServerTerminal( }; }, [sessionId, handleSessionClosed]); + useEffect(() => { + return () => { + const currentSessionId = sessionIdRef.current; + if (currentSessionId) { + void disconnectTerminal(serverId, currentSessionId).catch( + () => undefined, + ); + unsubscribeTerminalSession(currentSessionId); + } + }; + }, [serverId]); + const connect = useCallback( async (dimensions?: { cols: number; rows: number }) => { setStatus("connecting"); @@ -154,7 +165,6 @@ export function useServerTerminal( } try { - emitTerminalDisconnect(currentSessionId); await disconnectTerminal(serverId, currentSessionId); } catch { // Session may already be closed on the server. diff --git a/console-app/src/pages/server-detail-page.tsx b/console-app/src/pages/server-detail-page.tsx index 13da9cd..e42ab25 100644 --- a/console-app/src/pages/server-detail-page.tsx +++ b/console-app/src/pages/server-detail-page.tsx @@ -64,8 +64,8 @@ export function ServerDetailPage() {

{server.name}

- {server.host} + {server.host} {" "} · {server.username}

diff --git a/libs/common/src/compose-parser/parse-compose-resource-limits.util.ts b/libs/common/src/compose-parser/parse-compose-resource-limits.util.ts index ff29a4b..51c7060 100644 --- a/libs/common/src/compose-parser/parse-compose-resource-limits.util.ts +++ b/libs/common/src/compose-parser/parse-compose-resource-limits.util.ts @@ -7,8 +7,8 @@ export interface ComposeResourceRequirements { interface ComposeDeployResources { limits?: { - cpus?: string | number; - memory?: string | number; + cpus: string; + memory: string; }; } @@ -137,6 +137,12 @@ export function sumComposeResourceLimits( export function sumComposeResourceLimitsFromYaml( composeYaml: string, ): ComposeResourceRequirements { - const parsed = yaml.load(composeYaml); - return sumComposeResourceLimits(parsed); + try { + const parsed = yaml.load(composeYaml); + return sumComposeResourceLimits(parsed); + } catch (error) { + throw new Error( + `Failed to parse compose YAML for resource limits: ${error instanceof Error ? error.message : String(error)}`, + ); + } } diff --git a/libs/common/src/constants/app-strings.constants.ts b/libs/common/src/constants/app-strings.constants.ts index 5437293..d9b7124 100644 --- a/libs/common/src/constants/app-strings.constants.ts +++ b/libs/common/src/constants/app-strings.constants.ts @@ -72,4 +72,15 @@ export const SUCCESS_MESSAGES = { export const SOCKET_ERROR_MESSAGES = { MISSING_SOCKET_PAYLOAD: "Missing socket payload", INVALID_SOCKET_PAYLOAD: "Invalid socket payload", + MISSING_REQUEST_ID: "Missing requestId", + MISSING_REQUEST_ID_TEMPLATE_COMPOSE: + "Missing requestId, templateSlug, or compose payload", + MISSING_REQUEST_ID_CONTAINER_ACTION: + "Missing requestId, containerId, or action", + MISSING_REQUEST_ID_CONTAINER_LOGS_START: + "Missing requestId, sessionId, or containerId", + MISSING_DEPLOYMENT_SCHEMA: (templateName: string) => + `Missing deployment schema for template ${templateName}`, + CANNOT_SEND_TERMINAL_CONNECT_RESULT: + "Cannot send terminal connect result: socket disconnected", }; diff --git a/libs/common/src/constants/index.ts b/libs/common/src/constants/index.ts index 1a5c37e..3b8f3fc 100644 --- a/libs/common/src/constants/index.ts +++ b/libs/common/src/constants/index.ts @@ -1,3 +1,4 @@ -export * from "./socket.constants"; export * from "./app-strings.constants"; export * from "./app-config.constants"; +export * from "./terminal.constants"; +export * from "./shell.constants"; diff --git a/libs/common/src/constants/shell.constants.ts b/libs/common/src/constants/shell.constants.ts new file mode 100644 index 0000000..be8a78e --- /dev/null +++ b/libs/common/src/constants/shell.constants.ts @@ -0,0 +1,10 @@ +/** Common shell executable paths on Linux hosts. */ +export const SHELL_PATHS = { + BASH: "/bin/bash", + SH: "/bin/sh", +} as const; + +/** Defaults for child_process exec/spawn on remote and local hosts. */ +export const EXEC_DEFAULTS = { + MAX_BUFFER_BYTES: 16 * 1024 * 1024, +} as const; diff --git a/libs/common/src/constants/socket.constants.ts b/libs/common/src/constants/socket.constants.ts deleted file mode 100644 index 35c756d..0000000 --- a/libs/common/src/constants/socket.constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const SOCKET_EVENTS = { - DEPLOY: "deploy", - REMOVE: "deploy:remove", - DEPLOY_TEMPLATE: "deploy:template", - DEPLOYMENT_STATUS: "deployment:status", - CONTAINER_LOG: "container:logs", - LOGS_SUBSCRIBE: "logs:subscribe", - /** Control panel → console (unified log stream). */ - DEPLOYMENT_STREAM: "deployment:stream", - AGENT_CONNECTED: "agent:connected", - AGENT_DISCONNECTED: "agent:disconnected", -}; diff --git a/libs/common/src/constants/terminal.constants.ts b/libs/common/src/constants/terminal.constants.ts new file mode 100644 index 0000000..02e763b --- /dev/null +++ b/libs/common/src/constants/terminal.constants.ts @@ -0,0 +1,35 @@ +/** Default terminal width in columns (xterm standard). */ +export const DEFAULT_TERMINAL_COLS = 80; + +/** Default terminal height in rows (xterm standard). */ +export const DEFAULT_TERMINAL_ROWS = 24; + +/** TERM value used for PTY and SSH shell sessions. */ +export const TERMINAL_TERM_TYPE = "xterm-256color"; + +/** COLORTERM value for truecolor terminal support. */ +export const TERMINAL_COLOR_TERM = "truecolor"; + +/** Minimum allowed terminal column count. */ +export const MIN_TERMINAL_COLS = 10; + +/** Maximum allowed terminal column count. */ +export const MAX_TERMINAL_COLS = 500; + +/** Minimum allowed terminal row count. */ +export const MIN_TERMINAL_ROWS = 5; + +/** Maximum allowed terminal row count. */ +export const MAX_TERMINAL_ROWS = 200; + +/** Prefix for SSH fallback terminal connection ids. */ +export const SSH_TERMINAL_CONNECTION_ID_PREFIX = "terminal-"; + +/** SSH shell window pixel dimensions (unused by xterm; set to zero). */ +export const SSH_TERMINAL_WINDOW_PIXELS = { + WIDTH: 0, + HEIGHT: 0, +} as const; + +/** Text encoding used when relaying SSH stream output. */ +export const TERMINAL_OUTPUT_ENCODING = "utf8" as const;