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
{
- if (upgradeCostData.proratedAmount > 0) {
- createUpgradeInvoiceMutation.mutate(resourceForm);
- } else {
- scaleMutation.mutate(resourceForm);
- }
- }}
+ onClick={confirmUpgradeApply}
disabled={scaleMutation.isPending || createUpgradeInvoiceMutation.isPending}
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
@@ -1639,71 +1804,6 @@ export default function AppDetailPage() {
- {/* Database Storage Management */}
-
-
Database Storage
-
-
-
- Current Size:
- {dbStorageData?.currentSize || app.dbStorageSize || '1Gi'}
-
-
-
- {
- const current = parseInt(dbStorageSize, 10);
- const min = parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1;
- if (current > min + 1) setDbStorageSize(String(current - 1));
- }}
- className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-sm transition-colors"
- >
- −
-
- {
- 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"
- />
- {
- const current = parseInt(dbStorageSize, 10);
- if (current < 100) setDbStorageSize(String(current + 1));
- }}
- className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-sm transition-colors"
- >
- +
-
-
-
GB
-
{
- const newSize = `${parseInt(dbStorageSize, 10)}Gi`;
- resizeDbMutation.mutate(newSize);
- }}
- disabled={
- resizeDbMutation.isPending ||
- parseInt(dbStorageSize, 10) <= (parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1)
- }
- className="btn-primary text-xs px-3 py-1.5 disabled:opacity-50"
- >
- {resizeDbMutation.isPending ? 'Expanding...' : 'Expand'}
-
-
-
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 current = parseInt(dbStorageSize, 10);
- const min = parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1;
- if (current > min + 1) setDbStorageSize(String(current - 1));
- }}
- className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
- >
- −
-
- {
- 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"
- />
- {
- const current = parseInt(dbStorageSize, 10);
- if (current < 500) setDbStorageSize(String(current + 1));
- }}
- className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
- >
- +
-
-
-
GiB
-
resizeDbMutation.mutate(`${parseInt(dbStorageSize, 10)}Gi`)}
- disabled={
- resizeDbMutation.isPending ||
- parseInt(dbStorageSize, 10) <= (parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1)
- }
- className="btn-primary text-xs px-2 py-1 disabled:opacity-50"
- >
- {resizeDbMutation.isPending ? 'Expanding…' : 'Expand DB disk'}
-
-
setShowDbDiskExpand(false)} className="btn-secondary text-xs px-2 py-1">Cancel
-
- ) : (
-
setShowDbDiskExpand(true)}
- className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
- >
- Expand database disk
-
- )}
-
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 current = parseInt(appStorageSize, 10);
- const min = parseInt((app?.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2;
- if (current > min + 1) setAppStorageSize(String(current - 1));
- }}
- className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs transition-colors"
- >
- −
-
- {
- 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"
- />
- {
- const current = parseInt(appStorageSize, 10);
- if (current < 100) setAppStorageSize(String(current + 1));
- }}
- className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs transition-colors"
- >
- +
-
-
-
GB
-
{
- const newSize = `${parseInt(appStorageSize, 10)}Gi`;
- resizeAppStorageMutation.mutate(newSize);
- }}
- disabled={
- resizeAppStorageMutation.isPending ||
- parseInt(appStorageSize, 10) <= (parseInt((app?.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2)
- }
- className="btn-primary text-xs px-2 py-1 disabled:opacity-50"
- >
- {resizeAppStorageMutation.isPending ? 'Expanding...' : 'Expand'}
-
-
setShowAppStorageExpand(false)}
- className="btn-secondary text-xs px-2 py-1"
- >
- Cancel
-
-
- ) : (
-
setShowAppStorageExpand(true)}
- className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
- >
- Expand Storage
-
- )}
-
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.
Workload
@@ -2351,6 +2323,65 @@ export default function AppDetailPage() {
)}
+
+
+ {(() => {
+ const storageCfg = workloadStorageConfig();
+ if (!storageCfg) return null;
+ return (
+
+
+ Storage — {storageCfg.label}
+
+
+ Current: {storageCfg.currentGi.toFixed(1)} GiB allocated (expand only, no shrink). Applied with
+ Apply changes.
+
+
+
+ {
+ const c = parseInt(storageCfg.value, 10);
+ if (c > storageCfg.minGi + 1) storageCfg.setValue(String(c - 1));
+ }}
+ className="px-2 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
+ >
+ −
+
+ {
+ 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"
+ />
+ {
+ const c = parseInt(storageCfg.value, 10);
+ if (c < storageCfg.maxGi) storageCfg.setValue(String(c + 1));
+ }}
+ className="px-2 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
+ >
+ +
+
+
+
GiB
+
+
+ );
+ })()}
+
+
+
api.get('/logs/status').then((r) => r.data as { available: boolean }),
+ queryFn: () =>
+ api
+ .get('/logs/status')
+ .then((r) =>
+ r.data as {
+ available: boolean;
+ deployed?: boolean;
+ recovering?: boolean;
+ message?: string;
+ },
+ ),
+ refetchInterval: (query) => {
+ const s = query.state.data;
+ if (s?.available) return false;
+ if (s?.deployed === false) return false;
+ return 8_000;
+ },
});
const { data: applications = [] } = useQuery({
@@ -150,7 +166,7 @@ function LogsPageContent() {
params.set('limit', '100');
return api.get(`/logs?${params.toString()}`).then((r) => r.data);
},
- enabled: loggingStatus?.available !== false,
+ enabled: loggingStatus?.available === true,
refetchInterval: autoRefresh ? 5000 : false,
});
@@ -163,20 +179,32 @@ function LogsPageContent() {
params.set('period', timeRange);
return api.get(`/logs/stats?${params.toString()}`).then((r) => r.data);
},
- enabled: loggingStatus?.available !== false,
+ enabled: loggingStatus?.available === true,
});
const totalPages = logsResult ? Math.max(1, Math.ceil(logsResult.total / logsResult.limit)) : 1;
if (loggingStatus && !loggingStatus.available) {
+ const isRecovering = loggingStatus.recovering || loggingStatus.deployed === true;
return (
-
-
Logging not available
-
- Central Elasticsearch is not deployed on the cluster. Enable the logging addon when deploying an app,
- and ask an administrator to deploy the logging stack.
+ {isRecovering ? (
+
+ ) : (
+
+ )}
+
+ {isRecovering ? 'Reconnecting to logging…' : 'Logging not available'}
+
+
+ {loggingStatus.message ||
+ 'Central Elasticsearch is not deployed on the cluster. Enable the logging addon when deploying an app, and ask an administrator to deploy the logging stack.'}
+ {isRecovering && (
+
+ {statusFetching ? 'Checking connection…' : 'Retrying automatically every few seconds.'}
+
+ )}
);
}
diff --git a/frontend/src/components/deleting-overlay.tsx b/frontend/src/components/deleting-overlay.tsx
index 8ab0577..07afe0a 100644
--- a/frontend/src/components/deleting-overlay.tsx
+++ b/frontend/src/components/deleting-overlay.tsx
@@ -21,7 +21,7 @@ export function DeletingTableRowCell({
message?: string;
}) {
return (
-
+
{message}
@@ -34,7 +34,7 @@ export function DeletingTableRowCell({
export function DeletingCardOverlay({ message = 'Deleting…' }: { message?: string }) {
return (
diff --git a/frontend/src/components/deployment-progress-manager.tsx b/frontend/src/components/deployment-progress-manager.tsx
index 144fc26..5f476db 100644
--- a/frontend/src/components/deployment-progress-manager.tsx
+++ b/frontend/src/components/deployment-progress-manager.tsx
@@ -2,7 +2,7 @@
import { useEffect, useMemo } from 'react';
import { usePathname } from 'next/navigation';
-import { useQuery, useQueries } from '@tanstack/react-query';
+import { useQuery, useQueries, keepPreviousData } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Application } from '@/types';
import { useAuthStore } from '@/lib/store';
@@ -49,16 +49,15 @@ export function DeploymentProgressManager() {
.get<{ progress: BuildProgress | null }>(`/deployments/applications/${app.id}/build-progress`)
.then((r) => r.data.progress),
refetchInterval: 1500,
+ placeholderData: keepPreviousData,
})),
});
const activeItems = useMemo(() => {
- return deployingApps
- .map((app, i) => ({
- app,
- progress: progressQueries[i]?.data ?? null,
- }))
- .filter(({ progress }) => isActiveBuildProgress(progress));
+ return deployingApps.map((app, i) => ({
+ app,
+ progress: progressQueries[i]?.data ?? null,
+ }));
}, [deployingApps, progressQueries]);
const routeAppId =
@@ -66,10 +65,10 @@ export function DeploymentProgressManager() {
pathname.match(/\/dashboard\/services\/([^/]+)/)?.[1];
useEffect(() => {
- if (activeItems.length === 0) {
+ if (deployingApps.length === 0) {
useDeployProgressStore.setState({ minimized: false, focusedAppId: null });
}
- }, [activeItems.length]);
+ }, [deployingApps.length]);
useEffect(() => {
if (activeItems.length === 0) return;
@@ -84,12 +83,15 @@ export function DeploymentProgressManager() {
const focusedItem =
activeItems.find((item) => item.app.id === focusedAppId) ?? activeItems[0];
- const focusedProgress = focusedItem?.progress;
+ const focusedProgress =
+ focusedItem?.progress ??
+ ({ phase: 'building', percent: 0, message: 'Loading progress…' } satisfies BuildProgress);
const showModal =
!minimized &&
!!focusedItem &&
- !!focusedProgress &&
- isActiveBuildProgress(focusedProgress);
+ (!focusedItem.progress ||
+ isActiveBuildProgress(focusedItem.progress) ||
+ focusedItem.progress.phase === 'done');
const handleMinimize = () => {
if (focusedItem) minimize(focusedItem.app.id);
@@ -99,7 +101,7 @@ export function DeploymentProgressManager() {
expand(appId);
};
- if (activeItems.length === 0) return null;
+ if (deployingApps.length === 0) return null;
return (
<>
@@ -109,7 +111,7 @@ export function DeploymentProgressManager() {
)}
- {showModal && focusedProgress && (
+ {showModal && (
api.patch(`/applications/${serviceId}/db-storage`, { size }),
- onSuccess: (res) => {
- if (res.data.success) {
- toast.success(res.data.message || 'Storage expanded');
- queryClient.invalidateQueries({ queryKey: ['db-storage', serviceId] });
- queryClient.invalidateQueries({ queryKey: ['storage-usage', serviceId] });
- } else {
- toast.error(res.data.message || 'Failed to expand storage');
- }
- },
- onError: (err: unknown) => {
- const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
- toast.error(msg || 'Failed to resize storage');
- },
- });
-
const dbUploadMutation = useMutation({
mutationFn: (file: File) => {
const formData = new FormData();
@@ -280,9 +263,15 @@ export function ManagedServiceResourcesPanel({
[dbUploadMutation],
);
+ const currentDbGi =
+ parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1;
+
const buildUpgradePayload = useCallback((): UpgradePayload => {
if (app.productType === 'managed_database') {
- return { ...dbResources };
+ const payload: UpgradePayload = { ...dbResources };
+ const newGi = parseInt(dbStorageSize, 10);
+ if (newGi > currentDbGi) payload.dbStorageSize = `${newGi}Gi`;
+ return payload;
}
if (app.productType === 'managed_redis') {
return {
@@ -304,7 +293,7 @@ export function ManagedServiceResourcesPanel({
storageGi: rabbitResources.storageGi ?? app.optionalServiceResources?.rabbitmq?.storageGi ?? 2,
},
};
- }, [app, dbResources, redisResources, rabbitResources]);
+ }, [app, dbResources, redisResources, rabbitResources, dbStorageSize, currentDbGi]);
const applyResources = () => {
if (needsRenewal) {
@@ -316,6 +305,10 @@ export function ManagedServiceResourcesPanel({
const w = workloadKey(app);
if (!app.billingCycle) {
+ if (Object.keys(payload).length > 0 && (payload.dbStorageSize || payload.redisResources || payload.rabbitmqResources)) {
+ scaleMutation.mutate(payload);
+ return;
+ }
if (w === 'database') {
directPatchResourcesMutation.mutate({ workload: 'database', ...dbResources });
} else if (w === 'redis') {
@@ -351,37 +344,12 @@ export function ManagedServiceResourcesPanel({
}
};
- const handleExpandStorage = () => {
- if (needsRenewal) {
- toast.warn('Renew the service before expanding storage');
- return;
- }
- const newGi = parseInt(dbStorageSize, 10);
- if (newGi <= currentDbGi) {
- toast.warn('New size must be larger than current allocation');
- return;
- }
- const newSize = `${newGi}Gi`;
- const payload: UpgradePayload = { dbStorageSize: newSize };
-
- if (!app.billingCycle) {
- resizeDbMutation.mutate(newSize);
- return;
- }
-
- setPendingUpgradePayload(payload);
- calculateUpgradeCostMutation.mutate(payload);
- };
-
const resourcesPending =
directPatchResourcesMutation.isPending ||
scaleMutation.isPending ||
calculateUpgradeCostMutation.isPending ||
createUpgradeInvoiceMutation.isPending;
- const currentDbGi =
- parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1;
-
const metricsWorkload =
resourceUsage?.workloads?.find((w) => w.key === workloadKey(app)) ||
(resourceUsage?.configured
@@ -558,22 +526,8 @@ export function ManagedServiceResourcesPanel({
GB
-
- {resizeDbMutation.isPending || calculateUpgradeCostMutation.isPending
- ? 'Expanding…'
- : 'Expand'}
-
- 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;