From dc9830383b8a152ece4a5236c8d9695fd584d28d Mon Sep 17 00:00:00 2001 From: keyhan Date: Mon, 25 May 2026 21:41:00 +0330 Subject: [PATCH] Improve logging recovery, resource scaling, and app deploy logging. Auto-reconnect Elasticsearch port-forward after cluster or API restarts, poll log status in the UI, and apply storage changes through billing upgrade for all workloads. Add Redis/RabbitMQ PVC resize, Helm ES credentials for Fluent Bit, and fix deploy progress overlay behavior. Co-authored-by: Cursor --- backend/.env.example | 19 +- .../helm/cloudhost-app/templates/_helpers.tpl | 18 + .../cloudhost-app/templates/deployment.yaml | 5 + .../elasticsearch-credentials-secret.yaml | 14 + backend/helm/cloudhost-app/values.yaml | 3 + backend/helm/cloudhost-logging/values.yaml | 2 + .../applications/applications.controller.ts | 74 +++ backend/src/billing/billing.controller.ts | 101 ++-- backend/src/config/configuration.ts | 12 + .../src/kubernetes/elasticsearch.service.ts | 350 ++++++++++- backend/src/kubernetes/helm.service.ts | 17 +- backend/src/kubernetes/kubernetes.service.ts | 153 ++++- frontend/src/app/dashboard/apps/[id]/page.tsx | 561 +++++++++--------- frontend/src/app/dashboard/logs/page.tsx | 46 +- frontend/src/components/deleting-overlay.tsx | 4 +- .../deployment-progress-manager.tsx | 30 +- .../managed-service-resources-panel.tsx | 72 +-- frontend/src/types/index.ts | 1 + 18 files changed, 1068 insertions(+), 414 deletions(-) create mode 100644 backend/helm/cloudhost-app/templates/elasticsearch-credentials-secret.yaml diff --git a/backend/.env.example b/backend/.env.example index fef2306..18c9050 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,11 +19,26 @@ JWT_REFRESH_EXPIRES_IN=7d REDIS_HOST=localhost REDIS_PORT=6379 -# Container Registry -REGISTRY_URL=registry.example.com +# Container Registry (same Docker Registry v2, two hostnames) +# Internal — Kaniko/build pods push here (ClusterIP, HTTP, fast) +REGISTRY_URL=registry.cloudhost-builds.svc.cluster.local:5000 +# External — kubelet pulls app images; also use for manual "docker push" (Ingress or NodePort) +REGISTRY_PULL_URL=repo.3fase.ir +# REGISTRY_PULL_URL=10.0.0.50:30500 REGISTRY_USERNAME=admin REGISTRY_PASSWORD=registry_secret +# Central logging (Elasticsearch + Kibana) +# In-cluster backend: leave ELASTICSEARCH_HOST unset (uses elasticsearch.logging.svc.cluster.local). +# Local backend (npm run dev): API auto-runs kubectl port-forward when host is loopback +# ELASTICSEARCH_HOST=127.0.0.1 +# ELASTICSEARCH_PORT=9200 +# ELASTICSEARCH_AUTO_PORT_FORWARD=false +# ELASTIC_PASSWORD=CloudHost2024!Secure +# KIBANA_SYSTEM_PASSWORD=Kibana2024!System +# LOGGING_ELASTICSEARCH_IMAGE=localhost:30500/elasticsearch:8.12.0 +# LOGGING_KIBANA_IMAGE=localhost:30500/kibana:8.12.0 + # Build BUILD_NAMESPACE=cloudhost-builds BUILD_SERVICE_ACCOUNT=kaniko-builder diff --git a/backend/helm/cloudhost-app/templates/_helpers.tpl b/backend/helm/cloudhost-app/templates/_helpers.tpl index bbc0a57..bd9d650 100644 --- a/backend/helm/cloudhost-app/templates/_helpers.tpl +++ b/backend/helm/cloudhost-app/templates/_helpers.tpl @@ -130,3 +130,21 @@ Default log paths based on runtime {{- else }}/var/log/app/*.log {{- end }} {{- end }} + +{{/* +Shell start command for stdout capture (must match CloudHost-generated images). +*/}} +{{- define "cloudhost-app.runtimeStartCommand" -}} +{{- if eq .Values.app.runtime "nodejs" -}} +if [ -f /app/.mode ] && [ "$(cat /app/.mode)" = "standalone" ] && [ -f server.js ]; then node server.js; else npm start; fi +{{- else if eq .Values.app.runtime "go" -}} +./main +{{- else if eq .Values.app.runtime "dotnet" -}} +DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' ! -name '*.runtimeconfig.dll' | head -1) && dotnet "$DLL" +{{- else -}} +{{- end }} +{{- end }} + +{{- define "cloudhost-app.loggingWrapEnabled" -}} +{{- and .Values.elasticsearch.enabled (include "cloudhost-app.runtimeStartCommand" .) -}} +{{- end }} diff --git a/backend/helm/cloudhost-app/templates/deployment.yaml b/backend/helm/cloudhost-app/templates/deployment.yaml index 17c15ca..ce04231 100644 --- a/backend/helm/cloudhost-app/templates/deployment.yaml +++ b/backend/helm/cloudhost-app/templates/deployment.yaml @@ -27,6 +27,11 @@ spec: - name: {{ $name }} image: {{ .Values.app.image | quote }} imagePullPolicy: Always + {{- if include "cloudhost-app.loggingWrapEnabled" . }} + command: ["sh", "-c"] + args: + - mkdir -p /var/log/app && ({{ include "cloudhost-app.runtimeStartCommand" . | trim }}) >> /var/log/app/app.log 2>&1 + {{- end }} ports: - containerPort: {{ .Values.app.port }} {{- if and .Values.envVars (gt (len .Values.envVars) 0) }} diff --git a/backend/helm/cloudhost-app/templates/elasticsearch-credentials-secret.yaml b/backend/helm/cloudhost-app/templates/elasticsearch-credentials-secret.yaml new file mode 100644 index 0000000..8d12452 --- /dev/null +++ b/backend/helm/cloudhost-app/templates/elasticsearch-credentials-secret.yaml @@ -0,0 +1,14 @@ +{{- if .Values.elasticsearch.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: elasticsearch-credentials + namespace: {{ include "cloudhost-app.namespace" . }} + labels: + {{- include "cloudhost-app.labels" . | nindent 4 }} +type: Opaque +stringData: + ELASTIC_PASSWORD: {{ .Values.elasticsearch.elasticPassword | default "CloudHost2024!Secure" | quote }} + FLUENTBIT_PASSWORD: {{ .Values.elasticsearch.fluentbitPassword | default "FluentBit2024!Writer" | quote }} + KIBANA_SYSTEM_PASSWORD: {{ .Values.elasticsearch.kibanaPassword | default "Kibana2024!System" | quote }} +{{- end }} diff --git a/backend/helm/cloudhost-app/values.yaml b/backend/helm/cloudhost-app/values.yaml index ddc7e37..8f84652 100644 --- a/backend/helm/cloudhost-app/values.yaml +++ b/backend/helm/cloudhost-app/values.yaml @@ -87,6 +87,9 @@ elasticsearch: logPaths: [] ownerId: "" applicationId: "" + elasticPassword: "" + fluentbitPassword: "" + kibanaPassword: "" # ── Change metadata ───────────────────────────────────── changeCause: "" diff --git a/backend/helm/cloudhost-logging/values.yaml b/backend/helm/cloudhost-logging/values.yaml index a691d13..9b93d3c 100644 --- a/backend/helm/cloudhost-logging/values.yaml +++ b/backend/helm/cloudhost-logging/values.yaml @@ -8,6 +8,8 @@ clusterName: cloudhost-logs storage: 50Gi +# Official Elastic images; require docker.elastic.co DNS + outbound HTTPS from nodes. +# If pull fails with "lookup docker.elastic.co: Try again", mirror to your registry and override here. images: elasticsearch: docker.elastic.co/elasticsearch/elasticsearch:8.12.0 kibana: docker.elastic.co/kibana/kibana:8.12.0 diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index 33b5c2d..5585429 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -199,6 +199,80 @@ export class ApplicationsController { return result; } + @Patch(':id/redis-storage') + @ApiOperation({ summary: 'Resize (expand) Redis PVC storage' }) + async resizeRedisStorage( + @Param('id') id: string, + @Request() req: any, + @Body() body: { size: string }, + ) { + const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; + const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); + + if (!app.enableRedis) { + throw new BadRequestException('Redis is not enabled for this application'); + } + if (!body.size || !/^\d+Gi$/.test(body.size)) { + throw new BadRequestException('Size must be in format like "1Gi", "5Gi", "10Gi"'); + } + + const result = await this.kubernetesService.resizeRedisStoragePvc(app, body.size); + if (result.success) { + const prev = app.optionalServiceResources?.redis; + const storageGi = parseInt(body.size.replace('Gi', ''), 10) || 1; + await this.applicationsService.update(id, app.userId, { + optionalServiceResources: { + ...app.optionalServiceResources, + redis: { + cpuRequest: prev?.cpuRequest, + cpuLimit: prev?.cpuLimit ?? '200m', + memoryRequest: prev?.memoryRequest, + memoryLimit: prev?.memoryLimit ?? '256Mi', + storageGi, + }, + }, + } as any); + } + return result; + } + + @Patch(':id/rabbitmq-storage') + @ApiOperation({ summary: 'Resize (expand) RabbitMQ PVC storage' }) + async resizeRabbitmqStorage( + @Param('id') id: string, + @Request() req: any, + @Body() body: { size: string }, + ) { + const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; + const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); + + if (!app.enableRabbitmq) { + throw new BadRequestException('RabbitMQ is not enabled for this application'); + } + if (!body.size || !/^\d+Gi$/.test(body.size)) { + throw new BadRequestException('Size must be in format like "1Gi", "5Gi", "10Gi"'); + } + + const result = await this.kubernetesService.resizeRabbitmqStoragePvc(app, body.size); + if (result.success) { + const prev = app.optionalServiceResources?.rabbitmq; + const storageGi = parseInt(body.size.replace('Gi', ''), 10) || 2; + await this.applicationsService.update(id, app.userId, { + optionalServiceResources: { + ...app.optionalServiceResources, + rabbitmq: { + cpuRequest: prev?.cpuRequest, + cpuLimit: prev?.cpuLimit ?? '500m', + memoryRequest: prev?.memoryRequest, + memoryLimit: prev?.memoryLimit ?? '512Mi', + storageGi, + }, + }, + } as any); + } + return result; + } + @Get() @ApiOperation({ summary: 'List my applications or managed services' }) async findAll( diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index 1a2e177..495656f 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -830,7 +830,7 @@ export class BillingController { private buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial { const pt = app.productType ?? ProductType.APPLICATION; - if (pt === ProductType.MANAGED_REDIS && dto.redisResources) { + if (dto.redisResources) { return { optionalServiceResources: { ...app.optionalServiceResources, @@ -844,7 +844,7 @@ export class BillingController { }; } - if (pt === ProductType.MANAGED_RABBITMQ && dto.rabbitmqResources) { + if (dto.rabbitmqResources) { return { optionalServiceResources: { ...app.optionalServiceResources, @@ -860,6 +860,10 @@ export class BillingController { }; } + if (pt === ProductType.MANAGED_REDIS || pt === ProductType.MANAGED_RABBITMQ) { + return {}; + } + return { cpuRequest: dto.cpuRequest || app.cpuRequest, cpuLimit: dto.cpuLimit || app.cpuLimit, @@ -899,46 +903,39 @@ export class BillingController { } if (pt === ProductType.MANAGED_REDIS) { - const res = app.optionalServiceResources?.redis; - if (res) { - await this.kubernetesService.updateResources( - app, - { - cpuRequest: res.cpuRequest, - cpuLimit: res.cpuLimit, - memoryRequest: res.memoryRequest, - memoryLimit: res.memoryLimit, - }, - 'redis', - ); - } + await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis'); return; } if (pt === ProductType.MANAGED_RABBITMQ) { - const res = app.optionalServiceResources?.rabbitmq; - if (res) { - await this.kubernetesService.updateResources( - app, - { - cpuRequest: res.cpuRequest, - cpuLimit: res.cpuLimit, - memoryRequest: res.memoryRequest, - memoryLimit: res.memoryLimit, - }, - 'rabbitmq', - ); - } + await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq'); return; } - await this.kubernetesService.updateResources(app, { - cpuRequest: dto.cpuRequest, - cpuLimit: dto.cpuLimit, - memoryRequest: dto.memoryRequest, - memoryLimit: dto.memoryLimit, - replicas: dto.replicas, - }); + if (dto.redisResources && app.enableRedis) { + await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis'); + } + + if (dto.rabbitmqResources && app.enableRabbitmq) { + await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq'); + } + + const touchesAppWorkload = + dto.cpuRequest !== undefined || + dto.cpuLimit !== undefined || + dto.memoryRequest !== undefined || + dto.memoryLimit !== undefined || + dto.replicas !== undefined; + + if (touchesAppWorkload) { + await this.kubernetesService.updateResources(app, { + cpuRequest: dto.cpuRequest, + cpuLimit: dto.cpuLimit, + memoryRequest: dto.memoryRequest, + memoryLimit: dto.memoryLimit, + replicas: dto.replicas, + }); + } if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) { await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize); @@ -957,6 +954,40 @@ export class BillingController { } } + private async applyOptionalServiceUpgrade( + app: Application, + dto: UpgradeResourcesDto, + previous: Application, + service: 'redis' | 'rabbitmq', + ): Promise { + const res = app.optionalServiceResources?.[service]; + const dtoRes = service === 'redis' ? dto.redisResources : dto.rabbitmqResources; + if (res) { + await this.kubernetesService.updateResources( + app, + { + cpuRequest: res.cpuRequest, + cpuLimit: res.cpuLimit, + memoryRequest: res.memoryRequest, + memoryLimit: res.memoryLimit, + }, + service, + ); + } + const prevGi = + previous.optionalServiceResources?.[service]?.storageGi ?? (service === 'redis' ? 1 : 2); + const nextGi = dtoRes?.storageGi; + if (nextGi != null && nextGi > prevGi) { + const resize = + service === 'redis' + ? await this.kubernetesService.resizeRedisStoragePvc(app, `${nextGi}Gi`) + : await this.kubernetesService.resizeRabbitmqStoragePvc(app, `${nextGi}Gi`); + if (!resize.success) { + throw new BadRequestException(resize.message); + } + } + } + private async getAppWithAccess(user: any, applicationId: string) { const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES; diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index 34b12e9..9c0c904 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -35,9 +35,21 @@ export default () => ({ }, elasticsearch: { + /** API host for log search. Use cluster DNS in-cluster; 127.0.0.1 + port-forward when backend runs locally. */ + host: process.env.ELASTICSEARCH_HOST || 'elasticsearch.logging.svc.cluster.local', + port: parseInt(process.env.ELASTICSEARCH_PORT || '9200', 10), + /** In development with loopback host, start kubectl port-forward on API boot (set false to manage manually). */ + autoPortForward: process.env.ELASTICSEARCH_AUTO_PORT_FORWARD ?? 'true', password: process.env.ELASTIC_PASSWORD || 'CloudHost2024!Secure', fluentbitPassword: process.env.FLUENTBIT_PASSWORD || 'FluentBit2024!Writer', kibanaPassword: process.env.KIBANA_SYSTEM_PASSWORD || 'Kibana2024!System', + /** Override when cluster nodes cannot reach docker.elastic.co (mirror to local registry). */ + images: { + elasticsearch: + process.env.LOGGING_ELASTICSEARCH_IMAGE || + 'docker.elastic.co/elasticsearch/elasticsearch:8.12.0', + kibana: process.env.LOGGING_KIBANA_IMAGE || 'docker.elastic.co/kibana/kibana:8.12.0', + }, }, platform: { diff --git a/backend/src/kubernetes/elasticsearch.service.ts b/backend/src/kubernetes/elasticsearch.service.ts index efd64f8..8c01519 100644 --- a/backend/src/kubernetes/elasticsearch.service.ts +++ b/backend/src/kubernetes/elasticsearch.service.ts @@ -1,7 +1,16 @@ -import { Injectable, Logger, ServiceUnavailableException, Inject, forwardRef } from '@nestjs/common'; +import { + Injectable, + Logger, + ServiceUnavailableException, + Inject, + forwardRef, + OnModuleInit, + OnModuleDestroy, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as k8s from '@kubernetes/client-node'; import * as crypto from 'crypto'; +import { ChildProcess, spawn } from 'child_process'; import { ClustersService } from '../clusters/clusters.service'; import { HelmService, LOGGING_HELM_NAMESPACE, LOGGING_HELM_RELEASE } from './helm.service'; @@ -56,12 +65,18 @@ export interface LogStatsResult { * that all user apps can send logs to via Fluent Bit sidecars. */ @Injectable() -export class ElasticsearchService { +export class ElasticsearchService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(ElasticsearchService.name); private readonly ES_NAMESPACE = 'logging'; private readonly ES_NAME = 'elasticsearch'; private readonly KIBANA_NAME = 'kibana'; - + private portForwardChild: ChildProcess | null = null; + private portForwardStartedByUs = false; + private ensureInFlight: Promise | null = null; + private reconnectTimer: ReturnType | null = null; + private healthCheckTimer: ReturnType | null = null; + private reconnectAttempt = 0; + // Default credentials - should be overridden via env in production private readonly ELASTIC_PASSWORD: string; private readonly FLUENTBIT_PASSWORD: string; @@ -78,6 +93,254 @@ export class ElasticsearchService { this.KIBANA_SYSTEM_PASSWORD = this.configService.get('elasticsearch.kibanaPassword') || 'Kibana2024!System'; } + async onModuleInit(): Promise { + await this.ensureLocalElasticsearchAccess({ waitForCluster: true }); + if (this.shouldAutoPortForward()) { + this.healthCheckTimer = setInterval(() => { + void this.periodicElasticsearchHealthCheck(); + }, 30_000); + } + } + + onModuleDestroy(): void { + if (this.healthCheckTimer) { + clearInterval(this.healthCheckTimer); + this.healthCheckTimer = null; + } + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.stopDevPortForward(); + } + + private isLoopbackHost(host: string): boolean { + return host === '127.0.0.1' || host === 'localhost' || host === '::1'; + } + + private shouldAutoPortForward(): boolean { + if (this.configService.get('elasticsearch.autoPortForward') === 'false') { + return false; + } + if (process.env.ELASTICSEARCH_AUTO_PORT_FORWARD === 'false') { + return false; + } + const nodeEnv = process.env.NODE_ENV || 'development'; + if (nodeEnv === 'production') { + return false; + } + const host = this.configService.get('elasticsearch.host') || ''; + return this.isLoopbackHost(host); + } + + private stopDevPortForward(): void { + if (!this.portForwardChild) { + return; + } + const startedByUs = this.portForwardStartedByUs; + const child = this.portForwardChild; + this.portForwardChild = null; + this.portForwardStartedByUs = false; + child.kill('SIGTERM'); + if (startedByUs) { + this.logger.log('Stopped Elasticsearch kubectl port-forward'); + } + } + + private schedulePortForwardReconnect(reason: string): void { + if (!this.shouldAutoPortForward()) { + return; + } + if (this.reconnectTimer) { + return; + } + const delay = Math.min(60_000, 2_000 * Math.pow(2, this.reconnectAttempt)); + this.reconnectAttempt += 1; + this.logger.warn( + `Elasticsearch port-forward lost (${reason}). Reconnecting in ${Math.round(delay / 1000)}s…`, + ); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + void this.ensureLocalElasticsearchAccess().then((ok) => { + if (ok) { + this.reconnectAttempt = 0; + } + }); + }, delay); + } + + private async periodicElasticsearchHealthCheck(): Promise { + if (!this.shouldAutoPortForward()) { + return; + } + const deployed = await this.isDeployed(); + if (!deployed) { + return; + } + if (await this.probeElasticsearch()) { + this.reconnectAttempt = 0; + return; + } + this.logger.debug('Elasticsearch health check failed; restoring tunnel…'); + await this.ensureLocalElasticsearchAccess(); + } + + private async waitForLoggingStack(maxWaitMs = 120_000): Promise { + const started = Date.now(); + while (Date.now() - started < maxWaitMs) { + if (await this.isDeployed()) { + return true; + } + await new Promise((r) => setTimeout(r, 5_000)); + } + return false; + } + + private async probeElasticsearch(timeoutMs = 3000): Promise { + try { + const conn = this.getConnectionInfo(); + const auth = Buffer.from(`${conn.username}:${conn.password}`).toString('base64'); + const response = await fetch(`http://${conn.host}:${conn.port}/_cluster/health`, { + headers: { Authorization: `Basic ${auth}` }, + signal: AbortSignal.timeout(timeoutMs), + }); + return response.ok; + } catch { + return false; + } + } + + private async waitForElasticsearch(maxWaitMs = 15_000): Promise { + const started = Date.now(); + while (Date.now() - started < maxWaitMs) { + if (await this.probeElasticsearch(2000)) { + return true; + } + await new Promise((r) => setTimeout(r, 400)); + } + return false; + } + + private startDevPortForward(localPort: number): void { + if (this.portForwardChild) { + return; + } + const args = [ + 'port-forward', + '-n', + this.ES_NAMESPACE, + `svc/${this.ES_NAME}`, + `${localPort}:9200`, + ]; + this.logger.log(`Starting kubectl ${args.join(' ')} (local log search)`); + const child = spawn('kubectl', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + this.portForwardChild = child; + this.portForwardStartedByUs = true; + child.on('exit', (code, signal) => { + const wasOurs = this.portForwardChild === child; + if (wasOurs) { + this.portForwardChild = null; + this.portForwardStartedByUs = false; + } + if (wasOurs) { + const reason = + code !== 0 && code !== null + ? `exit code ${code}` + : signal + ? `signal ${signal}` + : 'connection closed'; + this.schedulePortForwardReconnect(reason); + } + }); + child.stderr?.on('data', (chunk: Buffer) => { + const line = chunk.toString().trim(); + if (line && !line.includes('Handling connection')) { + this.logger.debug(`kubectl port-forward: ${line}`); + } + }); + } + + /** + * When the API runs on the host with ELASTICSEARCH_HOST=127.0.0.1, open a tunnel to the cluster. + * Safe to call repeatedly (e.g. after cluster/API restart or port-forward drop). + */ + private async ensureLocalElasticsearchAccess(options?: { + waitForCluster?: boolean; + }): Promise { + if (this.ensureInFlight) { + await this.ensureInFlight; + return this.probeElasticsearch(); + } + + this.ensureInFlight = this.ensureLocalElasticsearchAccessImpl(options); + try { + await this.ensureInFlight; + return this.probeElasticsearch(); + } finally { + this.ensureInFlight = null; + } + } + + private async ensureLocalElasticsearchAccessImpl(options?: { + waitForCluster?: boolean; + }): Promise { + if (!this.shouldAutoPortForward()) { + return; + } + + if (await this.probeElasticsearch()) { + this.reconnectAttempt = 0; + return; + } + + let deployed = await this.isDeployed(); + if (!deployed && options?.waitForCluster) { + this.logger.log('Waiting for logging stack after cluster reconnect…'); + deployed = await this.waitForLoggingStack(); + } + if (!deployed) { + return; + } + + const port = this.configService.get('elasticsearch.port') || 9200; + + // Stale tunnel after sleep/reboot: port may be bound but ES unreachable + if (this.portForwardChild) { + this.stopDevPortForward(); + await new Promise((r) => setTimeout(r, 300)); + } + + this.startDevPortForward(port); + const ready = await this.waitForElasticsearch(90_000); + if (ready) { + this.reconnectAttempt = 0; + this.logger.log(`Elasticsearch reachable at 127.0.0.1:${port}`); + } else { + this.stopDevPortForward(); + this.logger.warn( + `Could not reach Elasticsearch on 127.0.0.1:${port}. Will retry. Manual: kubectl port-forward -n ${this.ES_NAMESPACE} svc/${this.ES_NAME} ${port}:9200`, + ); + this.schedulePortForwardReconnect('probe timeout'); + } + } + + private localElasticsearchHint(): string { + const conn = this.getConnectionInfo(); + if (this.isLoopbackHost(conn.host)) { + return ( + `Ensure port ${conn.port} is forwarded to the cluster (the API auto-starts kubectl port-forward in development). ` + + `Manual: kubectl port-forward -n ${this.ES_NAMESPACE} svc/${this.ES_NAME} ${conn.port}:9200` + ); + } + if (conn.host.includes('svc.cluster.local') || conn.host.includes('.cluster.')) { + return ( + 'Run the API inside the cluster, or set ELASTICSEARCH_HOST=127.0.0.1 and keep port-forward running: ' + + `kubectl port-forward -n ${this.ES_NAMESPACE} svc/${this.ES_NAME} ${conn.port}:9200` + ); + } + return `Ensure Elasticsearch is listening on ${conn.host}:${conn.port}.`; + } + private async getK8sClients(clusterId?: string) { const cluster = clusterId ? await this.clustersService.findOne(clusterId) @@ -178,6 +441,10 @@ export class ElasticsearchService { elasticPassword: this.ELASTIC_PASSWORD, fluentbitPassword: this.FLUENTBIT_PASSWORD, kibanaSystemPassword: this.KIBANA_SYSTEM_PASSWORD, + images: { + elasticsearch: this.configService.get('elasticsearch.images.elasticsearch'), + kibana: this.configService.get('elasticsearch.images.kibana'), + }, }); this.logger.log( @@ -214,8 +481,10 @@ export class ElasticsearchService { */ getConnectionInfo(): { host: string; port: number; username: string; password: string } { return { - host: `${this.ES_NAME}.${this.ES_NAMESPACE}.svc.cluster.local`, - port: 9200, + host: + this.configService.get('elasticsearch.host') || + `${this.ES_NAME}.${this.ES_NAMESPACE}.svc.cluster.local`, + port: this.configService.get('elasticsearch.port') || 9200, username: 'elastic', password: this.ELASTIC_PASSWORD, }; @@ -363,6 +632,18 @@ export class ElasticsearchService { return `logs-user-${userId.split('-')[0]}-*`; } + private elasticsearchFetch(url: string, auth: string, body: unknown): Promise { + return fetch(url, { + method: body === undefined ? 'GET' : 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${auth}`, + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(15_000), + }); + } + private async esRequest(path: string, body: unknown, clusterId?: string): Promise { const deployed = await this.isDeployed(clusterId); if (!deployed) { @@ -375,14 +656,23 @@ export class ElasticsearchService { const url = `http://${conn.host}:${conn.port}${path}`; const auth = Buffer.from(`${conn.username}:${conn.password}`).toString('base64'); - const response = await fetch(url, { - method: body === undefined ? 'GET' : 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Basic ${auth}`, - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); + let response: Response | undefined; + try { + response = await this.elasticsearchFetch(url, auth, body); + } catch (err: any) { + if (this.shouldAutoPortForward()) { + await this.ensureLocalElasticsearchAccess(); + try { + response = await this.elasticsearchFetch(url, auth, body); + } catch { + // retry failed + } + } + if (!response) { + this.logger.warn(`Elasticsearch unreachable at ${conn.host}:${conn.port}: ${err?.message || err}`); + throw new ServiceUnavailableException(`Cannot reach Elasticsearch. ${this.localElasticsearchHint()}`); + } + } if (!response.ok) { const text = await response.text(); @@ -518,8 +808,36 @@ export class ElasticsearchService { return (result.hits?.hits || []).map((h: any) => this.normalizeHit(h)); } - async getLoggingStatus(clusterId?: string): Promise<{ available: boolean; deployed: boolean }> { - const deployed = await this.isDeployed(clusterId); - return { available: deployed, deployed }; + async getLoggingStatus( + clusterId?: string, + ): Promise<{ available: boolean; deployed: boolean; recovering?: boolean; message?: string }> { + let deployed = await this.isDeployed(clusterId); + if (!deployed && this.shouldAutoPortForward()) { + deployed = await this.waitForLoggingStack(8_000); + } + if (!deployed) { + return { + available: false, + deployed: false, + message: 'Central logging is not deployed. Ask an administrator to deploy Elasticsearch.', + }; + } + + if (this.shouldAutoPortForward() && !(await this.probeElasticsearch())) { + void this.ensureLocalElasticsearchAccess(); + } + + if (await this.probeElasticsearch()) { + return { available: true, deployed: true }; + } + + return { + available: false, + deployed: true, + recovering: this.shouldAutoPortForward(), + message: this.shouldAutoPortForward() + ? 'Reconnecting to Elasticsearch after cluster or API restart. This usually takes under a minute.' + : `Elasticsearch is running in the cluster, but this backend cannot reach it. ${this.localElasticsearchHint()}`, + }; } } diff --git a/backend/src/kubernetes/helm.service.ts b/backend/src/kubernetes/helm.service.ts index 6ad75ba..f87eaf7 100644 --- a/backend/src/kubernetes/helm.service.ts +++ b/backend/src/kubernetes/helm.service.ts @@ -100,7 +100,12 @@ export class HelmService { */ async installLoggingStack( kubeconfig: string, - values: { elasticPassword: string; fluentbitPassword: string; kibanaSystemPassword: string }, + values: { + elasticPassword: string; + fluentbitPassword: string; + kibanaSystemPassword: string; + images?: { elasticsearch?: string; kibana?: string }; + }, ): Promise<{ stdout: string; stderr: string }> { return this.installOrUpgradeFromChart( 'cloudhost-logging', @@ -110,6 +115,16 @@ export class HelmService { elasticPassword: values.elasticPassword, fluentbitPassword: values.fluentbitPassword, kibanaSystemPassword: values.kibanaSystemPassword, + ...(values.images?.elasticsearch || values.images?.kibana + ? { + images: { + ...(values.images.elasticsearch + ? { elasticsearch: values.images.elasticsearch } + : {}), + ...(values.images.kibana ? { kibana: values.images.kibana } : {}), + }, + } + : {}), }, kubeconfig, { wait: true, timeout: '10m' }, diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index a145258..97177d7 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -303,6 +303,9 @@ export class KubernetesService implements OnModuleInit { logPaths: app.logPaths || [], ownerId: app.userId, applicationId: app.id, + elasticPassword: this.configService.get('elasticsearch.password') || 'CloudHost2024!Secure', + fluentbitPassword: this.configService.get('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer', + kibanaPassword: this.configService.get('elasticsearch.kibanaPassword') || 'Kibana2024!System', }, changeCause: `Deploy ${imageUri} at ${new Date().toISOString()}`, }; @@ -602,8 +605,9 @@ export class KubernetesService implements OnModuleInit { manifests.rabbitmq = true; } - // 3.7 Create Fluent Bit ConfigMap if Elasticsearch is enabled + // 3.7 Logging: credentials secret + Fluent Bit config if (context.enableElasticsearch) { + await this.ensureElasticsearchCredentialsSecret(coreApi, context.namespace); await this.createFluentBitConfigMap(coreApi, context); manifests.fluentBitConfig = true; } @@ -874,6 +878,7 @@ export class KubernetesService implements OnModuleInit { // Add log volume mount if Elasticsearch is enabled if (ctx.enableElasticsearch) { appContainer.volumeMounts.push({ name: 'app-logs', mountPath: '/var/log/app' }); + this.applyLoggingCommandWrapper(appContainer, ctx.runtime); } containers.push(appContainer); @@ -943,21 +948,97 @@ export class KubernetesService implements OnModuleInit { return ['/var/www/html/storage/logs/*.log', '/var/log/app/*.log']; case AppRuntime.PHP: return ['/var/www/html/storage/logs/*.log', '/var/log/php/*.log', '/var/log/app/*.log']; - case AppRuntime.DJANGO: - return ['/app/logs/*.log', '/var/log/app/*.log']; - case AppRuntime.PYTHON: - return ['/app/logs/*.log', '/var/log/app/*.log']; - case AppRuntime.NODEJS: - return ['/app/logs/*.log', '/var/log/app/*.log']; - case AppRuntime.GO: - return ['/app/logs/*.log', '/var/log/app/*.log']; - case AppRuntime.DOTNET: - return ['/app/logs/*.log', '/var/log/app/*.log']; default: + // Node/Go/Python/.NET log to stdout — captured into /var/log/app/app.log at runtime return ['/var/log/app/*.log']; } } + /** + * Redirect stdout/stderr into the shared log volume so Fluent Bit can tail them. + */ + private applyLoggingCommandWrapper(container: any, runtime: string): void { + const startCmd = this.getRuntimeStartCommand(runtime); + if (!startCmd) return; + container.command = ['sh', '-c']; + container.args = [`mkdir -p /var/log/app && (${startCmd}) >> /var/log/app/app.log 2>&1`]; + } + + /** Shell command that mirrors CloudHost-generated image ENTRYPOINT/CMD per runtime. */ + private getRuntimeStartCommand(runtime: string): string | null { + switch (runtime) { + case AppRuntime.NODEJS: + return ( + 'if [ -f /app/.mode ] && [ "$(cat /app/.mode)" = "standalone" ] && [ -f server.js ]; ' + + 'then node server.js; else npm start; fi' + ); + case AppRuntime.GO: + return './main'; + case AppRuntime.PYTHON: + return ( + 'if [ -f main.py ]; then ' + + 'if grep -qi fastapi main.py; then exec uvicorn main:app --host 0.0.0.0 --port ${PORT:-3000}; ' + + 'elif grep -qi flask main.py; then exec gunicorn -w 4 -b 0.0.0.0:${PORT:-3000} main:app; ' + + 'else exec python main.py; fi; ' + + 'elif [ -f app.py ]; then ' + + 'if grep -qi fastapi app.py; then exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-3000}; ' + + 'elif grep -qi flask app.py; then exec gunicorn -w 4 -b 0.0.0.0:${PORT:-3000} app:app; ' + + 'else exec python app.py; fi; ' + + 'else exec gunicorn -w 4 -b 0.0.0.0:${PORT:-3000} app:app; fi' + ); + case AppRuntime.DJANGO: + return 'python manage.py runserver 0.0.0.0:${PORT:-8000}'; + case AppRuntime.DOTNET: + return ( + 'DLL=$(find . -maxdepth 1 -name "*.dll" ! -name "*.deps.dll" ! -name "*.runtimeconfig.dll" | head -1) ' + + '&& dotnet "$DLL"' + ); + case AppRuntime.WORDPRESS: + case AppRuntime.LARAVEL: + case AppRuntime.PHP: + return null; + default: + return null; + } + } + + /** Replicate logging credentials into the app namespace for Fluent Bit sidecars. */ + private async ensureElasticsearchCredentialsSecret( + coreApi: k8s.CoreV1Api, + namespace: string, + ): Promise { + const name = 'elasticsearch-credentials'; + const stringData = { + ELASTIC_PASSWORD: this.configService.get('elasticsearch.password') || 'CloudHost2024!Secure', + FLUENTBIT_PASSWORD: this.configService.get('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer', + KIBANA_SYSTEM_PASSWORD: this.configService.get('elasticsearch.kibanaPassword') || 'Kibana2024!System', + }; + + try { + await coreApi.readNamespacedSecret(name, namespace); + await coreApi.replaceNamespacedSecret(name, namespace, { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name, namespace }, + type: 'Opaque', + stringData, + }); + } catch (err: any) { + if (err.statusCode === 404 || err.body?.code === 404) { + await coreApi.createNamespacedSecret(namespace, { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name, namespace }, + type: 'Opaque', + stringData, + }); + this.logger.log(`Created ${name} secret in ${namespace}`); + } else { + throw err; + } + } + } + /** * Build Fluent Bit configuration for log collection */ @@ -3755,6 +3836,56 @@ export class KubernetesService implements OnModuleInit { return bytes / (1024 * 1024 * 1024); } + /** + * Expand a named PVC (Redis, RabbitMQ, or other optional service volumes). + */ + async resizeNamedPvc( + app: Application, + pvcName: string, + newSize: string, + label: string, + ): Promise<{ success: boolean; message: string }> { + const { coreApi } = await this.getK8sClient(app.clusterId); + const namespace = `user-${app.userId.split('-')[0]}`; + + try { + const pvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace); + const currentSize = pvc.body.spec?.resources?.requests?.storage || '1Gi'; + const parseGi = (s: string) => parseInt(String(s).replace(/Gi/i, ''), 10) || 0; + + if (parseGi(newSize) <= parseGi(currentSize)) { + return { + success: false, + message: `New size (${newSize}) must be larger than current size (${currentSize})`, + }; + } + + await this.patchPvcStorageSize(coreApi, pvcName, namespace, newSize); + this.logger.log(`Expanded ${pvcName} from ${currentSize} to ${newSize}`); + return { success: true, message: `${label} storage expanded from ${currentSize} to ${newSize}` }; + } catch (e: any) { + this.logger.error(`Failed to resize ${pvcName}: ${e.message}`); + return { + success: false, + message: e.body?.message || e.message || `Failed to resize ${label} storage`, + }; + } + } + + async resizeRedisStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> { + if (!app.enableRedis) { + return { success: false, message: 'Redis is not enabled for this application' }; + } + return this.resizeNamedPvc(app, `${app.name}-redis-data`, newSize, 'Redis'); + } + + async resizeRabbitmqStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> { + if (!app.enableRabbitmq) { + return { success: false, message: 'RabbitMQ is not enabled for this application' }; + } + return this.resizeNamedPvc(app, `${app.name}-rabbitmq-data`, newSize, 'RabbitMQ'); + } + /** * Resize app storage PVC (all app types). */ diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index 4f25b29..72ca3a5 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -19,6 +19,30 @@ import { useAuthStore } from '@/lib/store'; /** Matches backend multipart limit for POST /applications/:id/upload */ const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3; +type UpgradePayload = { + cpuRequest?: string; + cpuLimit?: string; + memoryRequest?: string; + memoryLimit?: string; + replicas?: number; + dbStorageSize?: string; + appStorageSize?: string; + redisResources?: { + cpuRequest?: string; + cpuLimit?: string; + memoryRequest?: string; + memoryLimit?: string; + storageGi?: number; + }; + rabbitmqResources?: { + cpuRequest?: string; + cpuLimit?: string; + memoryRequest?: string; + memoryLimit?: string; + storageGi?: number; + }; +}; + const statusColors: Record = { running: 'badge-green', pending: 'badge-yellow', @@ -80,8 +104,9 @@ export default function AppDetailPage() { }); const [scaleWorkload, setScaleWorkload] = useState<'app' | 'database' | 'redis' | 'rabbitmq'>('app'); const [resourceFormDirty, setResourceFormDirty] = useState(false); - const [showDbDiskExpand, setShowDbDiskExpand] = useState(false); const [dbStorageSize, setDbStorageSize] = useState('1'); + const [redisStorageSize, setRedisStorageSize] = useState('1'); + const [rabbitmqStorageSize, setRabbitmqStorageSize] = useState('2'); const [dbStorageLoading, setDbStorageLoading] = useState(false); const [showSnapshots, setShowSnapshots] = useState(false); const [downloadingArtifact, setDownloadingArtifact] = useState<'source' | 'wp-content' | 'database' | null>(null); @@ -95,6 +120,7 @@ export default function AppDetailPage() { currentCost: { hourly: number }; newCost: { hourly: number }; } | null>(null); + const [pendingUpgradePayload, setPendingUpgradePayload] = useState(null); // ── Custom Domain ────────────────────────────────── const [showDomainSetup, setShowDomainSetup] = useState(false); @@ -192,8 +218,6 @@ export default function AppDetailPage() { // App storage expansion state const [appStorageSize, setAppStorageSize] = useState('2'); - const [showAppStorageExpand, setShowAppStorageExpand] = useState(false); - useEffect(() => { if (app?.appStorageSize) { const sizeNum = parseInt(app.appStorageSize.replace('Gi', ''), 10) || 2; @@ -201,38 +225,17 @@ export default function AppDetailPage() { } }, [app?.appStorageSize]); - const resizeAppStorageMutation = useMutation({ - mutationFn: (size: string) => api.patch(`/applications/${appId}/app-storage`, { size }), - onSuccess: (res) => { - if (res.data.success) { - toast.success(res.data.message || 'App storage expanded!'); - queryClient.invalidateQueries({ queryKey: ['application', appId] }); - queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] }); - setShowAppStorageExpand(false); - } else { - toast.error(res.data.message || 'Failed to expand storage'); - } - }, - onError: (err: any) => { - toast.error(err.response?.data?.message || 'Failed to resize app storage'); - }, - }); + useEffect(() => { + if (storageUsage?.redisStorage) { + setRedisStorageSize(String(Math.max(1, Math.round(storageUsage.redisStorage.allocatedGi)))); + } + }, [storageUsage?.redisStorage?.allocatedGi]); - const resizeDbMutation = useMutation({ - mutationFn: (size: string) => api.patch(`/applications/${appId}/db-storage`, { size }), - onSuccess: (res) => { - if (res.data.success) { - toast.success(res.data.message || 'Database storage expanded!'); - queryClient.invalidateQueries({ queryKey: ['db-storage', appId] }); - queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] }); - } else { - toast.error(res.data.message || 'Failed to expand storage'); - } - }, - onError: (err: any) => { - toast.error(err.response?.data?.message || 'Failed to resize database storage'); - }, - }); + useEffect(() => { + if (storageUsage?.rabbitmqStorage) { + setRabbitmqStorageSize(String(Math.max(2, Math.round(storageUsage.rabbitmqStorage.allocatedGi)))); + } + }, [storageUsage?.rabbitmqStorage?.allocatedGi]); // ─── Billing & Renewal ────────────────────────────── const { data: walletData } = useQuery<{ balance: number }>({ @@ -599,15 +602,17 @@ export default function AppDetailPage() { }); const scaleMutation = useMutation({ - mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) => - api.post(`/billing/applications/${appId}/upgrade`, data), + mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${appId}/upgrade`, data), onSuccess: (res) => { setResourceFormDirty(false); invalidateAll(); queryClient.invalidateQueries({ queryKey: ['resources', appId] }); + queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] }); + queryClient.invalidateQueries({ queryKey: ['db-storage', appId] }); queryClient.invalidateQueries({ queryKey: ['wallet'] }); setShowUpgradeConfirm(false); setUpgradeCostData(null); + setPendingUpgradePayload(null); const paidAmount = res.data.paidAmount || 0; if (paidAmount > 0) { toast.success(`Resources upgraded! Paid ${paidAmount.toLocaleString()} Toman`); @@ -638,8 +643,7 @@ export default function AppDetailPage() { // Calculate upgrade cost before applying const calculateUpgradeCostMutation = useMutation({ - mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) => - api.post(`/billing/applications/${appId}/upgrade/calculate`, data), + mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${appId}/upgrade/calculate`, data), onSuccess: (res) => { setUpgradeCostData(res.data); setShowUpgradeConfirm(true); @@ -648,35 +652,201 @@ export default function AppDetailPage() { }); const createUpgradeInvoiceMutation = useMutation({ - mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) => + mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${appId}/upgrade/invoice`, data).then((r) => r.data), onSuccess: (invoice) => { toast.success('Invoice created. Choose how you want to pay.'); queryClient.invalidateQueries({ queryKey: ['invoices'] }); setShowUpgradeConfirm(false); setUpgradeCostData(null); + setPendingUpgradePayload(null); router.push(`/dashboard/invoices?invoice=${invoice.id}`); }, onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create upgrade invoice'), }); - // Handler: app uses billing upgrade path when subscribed; other workloads patch directly. + const buildWorkloadUpgradePayload = useCallback((): UpgradePayload => { + if (!app) return {}; + switch (scaleWorkload) { + case 'app': { + const payload: UpgradePayload = { + cpuRequest: resourceForm.cpuRequest || undefined, + cpuLimit: resourceForm.cpuLimit || undefined, + memoryRequest: resourceForm.memoryRequest || undefined, + memoryLimit: resourceForm.memoryLimit || undefined, + replicas: resourceForm.replicas, + }; + const minAppGi = parseInt((app.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2; + const newAppGi = parseInt(appStorageSize, 10); + if (newAppGi > minAppGi) payload.appStorageSize = `${newAppGi}Gi`; + return payload; + } + case 'database': { + const minDbGi = + parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1; + const newDbGi = parseInt(dbStorageSize, 10); + if (newDbGi > minDbGi) return { dbStorageSize: `${newDbGi}Gi` }; + return {}; + } + case 'redis': { + const prev = app.optionalServiceResources?.redis; + const minGi = Math.max(1, Math.round(storageUsage?.redisStorage?.allocatedGi ?? prev?.storageGi ?? 1)); + const newGi = parseInt(redisStorageSize, 10); + return { + redisResources: { + cpuRequest: resourceForm.cpuRequest || prev?.cpuRequest, + cpuLimit: resourceForm.cpuLimit || prev?.cpuLimit, + memoryRequest: resourceForm.memoryRequest || prev?.memoryRequest, + memoryLimit: resourceForm.memoryLimit || prev?.memoryLimit, + storageGi: newGi > minGi ? newGi : (prev?.storageGi ?? minGi), + }, + }; + } + case 'rabbitmq': { + const prev = app.optionalServiceResources?.rabbitmq; + const minGi = Math.max(2, Math.round(storageUsage?.rabbitmqStorage?.allocatedGi ?? prev?.storageGi ?? 2)); + const newGi = parseInt(rabbitmqStorageSize, 10); + return { + rabbitmqResources: { + cpuRequest: resourceForm.cpuRequest || prev?.cpuRequest, + cpuLimit: resourceForm.cpuLimit || prev?.cpuLimit, + memoryRequest: resourceForm.memoryRequest || prev?.memoryRequest, + memoryLimit: resourceForm.memoryLimit || prev?.memoryLimit, + storageGi: newGi > minGi ? newGi : (prev?.storageGi ?? minGi), + }, + }; + } + default: + return {}; + } + }, [ + app, + scaleWorkload, + resourceForm, + appStorageSize, + dbStorageSize, + redisStorageSize, + rabbitmqStorageSize, + dbStorageData?.currentSize, + storageUsage?.redisStorage?.allocatedGi, + storageUsage?.rabbitmqStorage?.allocatedGi, + ]); + + const patchDatabaseCpuIfNeeded = () => { + if (scaleWorkload !== 'database') return; + directPatchResourcesMutation.mutate({ + workload: 'database', + cpuRequest: resourceForm.cpuRequest || undefined, + cpuLimit: resourceForm.cpuLimit || undefined, + memoryRequest: resourceForm.memoryRequest || undefined, + memoryLimit: resourceForm.memoryLimit || undefined, + }); + }; + const handleScaleResources = () => { - if (scaleWorkload !== 'app') { - directPatchResourcesMutation.mutate({ - workload: scaleWorkload, - cpuRequest: resourceForm.cpuRequest || undefined, - cpuLimit: resourceForm.cpuLimit || undefined, - memoryRequest: resourceForm.memoryRequest || undefined, - memoryLimit: resourceForm.memoryLimit || undefined, + if (!app) return; + if (app.lifecycleStatus && app.lifecycleStatus !== 'active') { + if (scaleWorkload === 'database') { + patchDatabaseCpuIfNeeded(); + return; + } + toast.warn('Renew the application before changing resources'); + return; + } + + const payload = buildWorkloadUpgradePayload(); + const hasBillingPayload = Object.keys(payload).length > 0; + const needsDbCpuPatch = scaleWorkload === 'database'; + + if (!hasBillingPayload && !needsDbCpuPatch) { + toast.warn('No changes to apply'); + return; + } + + if (!app.billingCycle) { + if (hasBillingPayload) { + scaleMutation.mutate(payload, { + onSuccess: () => patchDatabaseCpuIfNeeded(), + }); + } else { + patchDatabaseCpuIfNeeded(); + } + return; + } + + if (!hasBillingPayload) { + patchDatabaseCpuIfNeeded(); + return; + } + + setPendingUpgradePayload(payload); + calculateUpgradeCostMutation.mutate(payload); + }; + + const confirmUpgradeApply = () => { + if (!pendingUpgradePayload || !upgradeCostData) return; + if (upgradeCostData.proratedAmount > 0) { + createUpgradeInvoiceMutation.mutate(pendingUpgradePayload); + } else { + scaleMutation.mutate(pendingUpgradePayload, { + onSuccess: () => patchDatabaseCpuIfNeeded(), }); - return; } - if (!app?.billingCycle) { - scaleMutation.mutate(resourceForm); - return; + }; + + const workloadStorageConfig = (): { + label: string; + value: string; + setValue: (v: string) => void; + minGi: number; + maxGi: number; + currentGi: number; + } | null => { + if (!app) return null; + switch (scaleWorkload) { + case 'app': + if (!storageUsage?.appStorage) return null; + return { + label: 'Application volume', + value: appStorageSize, + setValue: setAppStorageSize, + minGi: parseInt((app.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2, + maxGi: 100, + currentGi: storageUsage.appStorage.allocatedGi, + }; + case 'database': + if (app.databaseType === 'none' || !storageUsage?.database) return null; + return { + label: 'Database volume', + value: dbStorageSize, + setValue: setDbStorageSize, + minGi: parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1, + maxGi: 500, + currentGi: storageUsage.database.allocatedGi, + }; + case 'redis': + if (!app.enableRedis || !storageUsage?.redisStorage) return null; + return { + label: 'Redis volume', + value: redisStorageSize, + setValue: setRedisStorageSize, + minGi: Math.max(1, Math.round(storageUsage.redisStorage.allocatedGi)), + maxGi: 100, + currentGi: storageUsage.redisStorage.allocatedGi, + }; + case 'rabbitmq': + if (!app.enableRabbitmq || !storageUsage?.rabbitmqStorage) return null; + return { + label: 'RabbitMQ volume', + value: rabbitmqStorageSize, + setValue: setRabbitmqStorageSize, + minGi: Math.max(2, Math.round(storageUsage.rabbitmqStorage.allocatedGi)), + maxGi: 100, + currentGi: storageUsage.rabbitmqStorage.allocatedGi, + }; + default: + return null; } - calculateUpgradeCostMutation.mutate(resourceForm); }; const previewMutation = useMutation({ @@ -1187,19 +1357,14 @@ export default function AppDetailPage() { onClick={() => { setShowUpgradeConfirm(false); setUpgradeCostData(null); + setPendingUpgradePayload(null); }} className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl font-medium text-gray-700 hover:bg-gray-50 transition-all" > Cancel - { - const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1)); - setDbStorageSize(String(val)); - }} - className="w-14 text-center py-1.5 border-x border-gray-300 text-sm font-semibold focus:outline-none" - /> - - - GB - - -

Only expansion is allowed (shrinking is not possible)

- - - - {/* DB Dump Upload */}

Restore Database Dump

Used {storageUsage.database.usedGi.toFixed(2)} GiB Free ~{storageUsage.database.availableGi.toFixed(2)} GiB
- {app?.databaseType !== 'none' && ( -
- {showDbDiskExpand ? ( -
-
- - { - const val = Math.max(1, Math.min(500, parseInt(e.target.value, 10) || 1)); - setDbStorageSize(String(val)); - }} - className="w-12 text-center py-1 border-x border-gray-300 text-xs font-semibold focus:outline-none" - /> - -
- GiB - - -
- ) : ( - - )} -

PVC can only grow. Size from API: {storageUsage.database.allocatedRaw}

-
- )} +

Expand disk in Adjust CPU / memory & storage below.

)} @@ -2148,79 +2185,7 @@ export default function AppDetailPage() { Used {storageUsage.appStorage.usedGi.toFixed(2)} GiB Free ~{storageUsage.appStorage.availableGi.toFixed(2)} GiB - - {/* Expand App Storage (all app types) */} -
- {showAppStorageExpand ? ( -
-
- - { - const val = Math.max(2, Math.min(100, parseInt(e.target.value, 10) || 2)); - setAppStorageSize(String(val)); - }} - className="w-12 text-center py-1 border-x border-gray-300 text-xs font-semibold focus:outline-none" - /> - -
- GB - - -
- ) : ( - - )} -

Only expansion is allowed

-
+

Expand disk in Adjust CPU / memory & storage below.

)} @@ -2238,7 +2203,9 @@ export default function AppDetailPage() { style={{ width: `${Math.min(storageUsage.redisStorage.usedPercent, 100)}%` }} /> -

Allocated {storageUsage.redisStorage.allocatedRaw}

+

+ Allocated {storageUsage.redisStorage.allocatedRaw} — expand in Adjust CPU / memory & storage. +

)} @@ -2256,7 +2223,9 @@ export default function AppDetailPage() { style={{ width: `${Math.min(storageUsage.rabbitmqStorage.usedPercent, 100)}%` }} /> -

Allocated {storageUsage.rabbitmqStorage.allocatedRaw}

+

+ Allocated {storageUsage.rabbitmqStorage.allocatedRaw} — expand in Adjust CPU / memory & storage. +

)} @@ -2271,9 +2240,12 @@ export default function AppDetailPage() { {/* Scaling Controls */}
-

Adjust CPU / memory

+

+ Adjust CPU / memory & storage +

- Pick which component to update. The main application may use billing if your plan charges for upgrades; database and optional services apply directly in the cluster. + Pick which component to update. CPU and memory apply per workload; storage can only grow (expand). The main + application may use billing for paid upgrades; database and optional services apply directly in the cluster.

@@ -2351,6 +2323,65 @@ export default function AppDetailPage() {
)} + + + {(() => { + const storageCfg = workloadStorageConfig(); + if (!storageCfg) return null; + return ( +
+ +

+ Current: {storageCfg.currentGi.toFixed(1)} GiB allocated (expand only, no shrink). Applied with + Apply changes. +

+
+
+ + { + const val = Math.max( + storageCfg.minGi + 1, + Math.min(storageCfg.maxGi, parseInt(e.target.value, 10) || storageCfg.minGi + 1), + ); + storageCfg.setValue(String(val)); + }} + className="w-14 text-center py-1.5 border-x border-gray-300 text-xs font-semibold focus:outline-none" + /> + +
+ GiB +
+
+ ); + })()} + +
+
GB -

- Only expansion is allowed. + Only expansion is allowed. Use Apply changes below. {app.billingCycle ? ' Additional storage is charged for the remaining billing period.' : ''} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8cca2a6..2ba34ac 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -58,6 +58,7 @@ export interface Application { enableElasticsearch?: boolean; elasticsearchVersion?: string; logPaths?: string[]; + optionalServiceResources?: OptionalServiceResourcesMap; // Billing & Lifecycle planId?: string; billingCycle?: BillingCycle;