From 695e05f948cdad398a2dcd9f8cf3a0e925ab065b Mon Sep 17 00:00:00 2001 From: keyhan Date: Sat, 23 May 2026 19:00:09 +0330 Subject: [PATCH] Add managed databases and services with billing-aligned upgrades. Introduce product types for managed PostgreSQL, Redis, and RabbitMQ with a dedicated dashboard, Helm-only deploy pipeline, external access, snapshots with progress, and prorated resource or storage upgrades matching application billing rules. PVCs use an expandable StorageClass with automatic migration when legacy disks cannot resize in place. Co-authored-by: Cursor --- backend/.env.example | 4 + .../templates/app-storage-pvc.yaml | 5 + .../templates/db-deployment.yaml | 5 +- .../helm/cloudhost-app/templates/db-pvc.yaml | 3 + .../cloudhost-app/templates/deployment.yaml | 2 + .../templates/rabbitmq-deployment.yaml | 3 + .../templates/redis-deployment.yaml | 3 + .../helm/cloudhost-app/templates/service.yaml | 2 + .../cloudhost-app/templates/storageclass.yaml | 12 + backend/helm/cloudhost-app/values.yaml | 7 + .../015_application_product_type.sql | 9 + .../016_service_access_persistent.sql | 2 + backend/migrations/017_snapshot_progress.sql | 2 + backend/src/access/access.service.ts | 41 +- backend/src/access/dto/service-access.dto.ts | 18 +- .../entities/service-access-grant.entity.ts | 4 + .../applications/applications.controller.ts | 26 +- .../src/applications/applications.service.ts | 79 +- .../src/applications/dto/application.dto.ts | 16 +- .../entities/application.entity.ts | 12 +- .../src/applications/managed-service.util.ts | 72 ++ backend/src/billing/billing.controller.ts | 200 +++-- backend/src/billing/billing.service.ts | 95 ++- backend/src/billing/dto/billing.dto.ts | 26 +- .../entities/resource-credit.entity.ts | 5 +- .../billing/pricing-catalog.service.spec.ts | 89 +++ .../src/billing/pricing-catalog.service.ts | 65 +- backend/src/common/enums.ts | 19 + backend/src/config/configuration.ts | 5 + .../src/deployments/deployments.service.ts | 215 +++++- backend/src/kubernetes/kubernetes.service.ts | 614 +++++++++++++-- .../src/snapshots/entities/snapshot.entity.ts | 4 + backend/src/snapshots/snapshots.service.ts | 114 ++- frontend/src/app/dashboard/apps/[id]/page.tsx | 337 +-------- frontend/src/app/dashboard/apps/page.tsx | 8 +- frontend/src/app/dashboard/layout.tsx | 2 + frontend/src/app/dashboard/logs/page.tsx | 67 +- frontend/src/app/dashboard/page.tsx | 200 +++-- .../src/app/dashboard/services/[id]/page.tsx | 701 +++++++++++++++++ .../src/app/dashboard/services/new/page.tsx | 640 ++++++++++++++++ frontend/src/app/dashboard/services/page.tsx | 185 +++++ .../components/database-snapshots-panel.tsx | 314 ++++++++ .../database-workload-resources.tsx | 81 ++ .../deployment-progress-manager.tsx | 33 +- .../components/managed-database-config.tsx | 336 +++++++++ .../managed-service-resources-panel.tsx | 707 ++++++++++++++++++ .../optional-service-resource-fields.tsx | 95 +++ .../resource-upgrade-confirm-modal.tsx | 121 +++ .../service-external-access-panel.tsx | 290 +++++++ .../src/components/workload-logs-panel.tsx | 165 ++++ frontend/src/lib/format-utils.ts | 20 + frontend/src/lib/optional-service-defaults.ts | 53 ++ frontend/src/lib/product-type.ts | 24 + frontend/src/lib/storage-utils.ts | 6 + frontend/src/types/index.ts | 12 +- 55 files changed, 5575 insertions(+), 600 deletions(-) create mode 100644 backend/helm/cloudhost-app/templates/storageclass.yaml create mode 100644 backend/migrations/015_application_product_type.sql create mode 100644 backend/migrations/016_service_access_persistent.sql create mode 100644 backend/migrations/017_snapshot_progress.sql create mode 100644 backend/src/applications/managed-service.util.ts create mode 100644 frontend/src/app/dashboard/services/[id]/page.tsx create mode 100644 frontend/src/app/dashboard/services/new/page.tsx create mode 100644 frontend/src/app/dashboard/services/page.tsx create mode 100644 frontend/src/components/database-snapshots-panel.tsx create mode 100644 frontend/src/components/database-workload-resources.tsx create mode 100644 frontend/src/components/managed-database-config.tsx create mode 100644 frontend/src/components/managed-service-resources-panel.tsx create mode 100644 frontend/src/components/optional-service-resource-fields.tsx create mode 100644 frontend/src/components/resource-upgrade-confirm-modal.tsx create mode 100644 frontend/src/components/service-external-access-panel.tsx create mode 100644 frontend/src/components/workload-logs-panel.tsx create mode 100644 frontend/src/lib/format-utils.ts create mode 100644 frontend/src/lib/optional-service-defaults.ts create mode 100644 frontend/src/lib/product-type.ts create mode 100644 frontend/src/lib/storage-utils.ts diff --git a/backend/.env.example b/backend/.env.example index 00454a2..fef2306 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -31,3 +31,7 @@ BUILD_SERVICE_ACCOUNT=kaniko-builder # Platform PLATFORM_DOMAIN=apps.cloudhost.local UPLOAD_DIR=./uploads +# PVC resize: use a dynamic StorageClass with allowVolumeExpansion (k3s: rancher.io/local-path) +PLATFORM_STORAGE_CLASS=cloudhost-expandable +PLATFORM_CREATE_STORAGE_CLASS=true +PLATFORM_STORAGE_PROVISIONER=rancher.io/local-path diff --git a/backend/helm/cloudhost-app/templates/app-storage-pvc.yaml b/backend/helm/cloudhost-app/templates/app-storage-pvc.yaml index 391da25..8883ae5 100644 --- a/backend/helm/cloudhost-app/templates/app-storage-pvc.yaml +++ b/backend/helm/cloudhost-app/templates/app-storage-pvc.yaml @@ -1,3 +1,4 @@ +{{- if .Values.app.enabled }} {{- $name := include "cloudhost-app.name" . -}} {{- $ns := include "cloudhost-app.namespace" . -}} apiVersion: v1 @@ -12,6 +13,10 @@ metadata: spec: accessModes: - ReadWriteOnce + {{- if .Values.global.storageClass }} + storageClassName: {{ .Values.global.storageClass | quote }} + {{- end }} resources: requests: storage: {{ .Values.app.storageSize | default "2Gi" | quote }} +{{- end }} diff --git a/backend/helm/cloudhost-app/templates/db-deployment.yaml b/backend/helm/cloudhost-app/templates/db-deployment.yaml index 17bd709..1d3cd05 100644 --- a/backend/helm/cloudhost-app/templates/db-deployment.yaml +++ b/backend/helm/cloudhost-app/templates/db-deployment.yaml @@ -2,6 +2,7 @@ {{- $name := include "cloudhost-app.name" . -}} {{- $ns := include "cloudhost-app.namespace" . -}} {{- $dbName := include "cloudhost-app.dbDeploymentName" . -}} +{{- $dbLogicalName := include "cloudhost-app.dbName" . -}} {{- $dbPort := include "cloudhost-app.dbPort" . -}} {{- $dbData := include "cloudhost-app.dbDataPath" . -}} {{- $dbImg := include "cloudhost-app.dbImage" . -}} @@ -107,7 +108,7 @@ spec: readinessProbe: {{- if eq .Values.database.type "postgresql" }} exec: - command: ["pg_isready", "-U", {{ .Values.database.username | quote }}] + command: ["pg_isready", "-U", {{ .Values.database.username | quote }}, "-d", {{ $dbLogicalName | quote }}] {{- else if eq .Values.database.type "mariadb" }} exec: command: ["healthcheck.sh", "--connect", "--innodb_initialized"] @@ -124,7 +125,7 @@ spec: livenessProbe: {{- if eq .Values.database.type "postgresql" }} exec: - command: ["pg_isready", "-U", {{ .Values.database.username | quote }}] + command: ["pg_isready", "-U", {{ .Values.database.username | quote }}, "-d", {{ $dbLogicalName | quote }}] {{- else if eq .Values.database.type "mariadb" }} exec: command: ["healthcheck.sh", "--connect", "--innodb_initialized"] diff --git a/backend/helm/cloudhost-app/templates/db-pvc.yaml b/backend/helm/cloudhost-app/templates/db-pvc.yaml index 3d8a35f..300bc2b 100644 --- a/backend/helm/cloudhost-app/templates/db-pvc.yaml +++ b/backend/helm/cloudhost-app/templates/db-pvc.yaml @@ -14,6 +14,9 @@ metadata: spec: accessModes: - ReadWriteOnce + {{- if .Values.global.storageClass }} + storageClassName: {{ .Values.global.storageClass | quote }} + {{- end }} resources: requests: storage: {{ .Values.database.storageSize | quote }} diff --git a/backend/helm/cloudhost-app/templates/deployment.yaml b/backend/helm/cloudhost-app/templates/deployment.yaml index ce09c78..17c15ca 100644 --- a/backend/helm/cloudhost-app/templates/deployment.yaml +++ b/backend/helm/cloudhost-app/templates/deployment.yaml @@ -1,3 +1,4 @@ +{{- if .Values.app.enabled }} {{- $name := include "cloudhost-app.name" . -}} {{- $ns := include "cloudhost-app.namespace" . -}} apiVersion: apps/v1 @@ -250,3 +251,4 @@ spec: configMap: name: {{ $name }}-fluent-bit-config {{- end }} +{{- end }} diff --git a/backend/helm/cloudhost-app/templates/rabbitmq-deployment.yaml b/backend/helm/cloudhost-app/templates/rabbitmq-deployment.yaml index 2d15a56..c169171 100644 --- a/backend/helm/cloudhost-app/templates/rabbitmq-deployment.yaml +++ b/backend/helm/cloudhost-app/templates/rabbitmq-deployment.yaml @@ -31,6 +31,9 @@ metadata: spec: accessModes: - ReadWriteOnce + {{- if .Values.global.storageClass }} + storageClassName: {{ .Values.global.storageClass | quote }} + {{- end }} resources: requests: storage: {{ .Values.rabbitmq.storageSize | default "2Gi" | quote }} diff --git a/backend/helm/cloudhost-app/templates/redis-deployment.yaml b/backend/helm/cloudhost-app/templates/redis-deployment.yaml index 7f12014..a835acf 100644 --- a/backend/helm/cloudhost-app/templates/redis-deployment.yaml +++ b/backend/helm/cloudhost-app/templates/redis-deployment.yaml @@ -30,6 +30,9 @@ metadata: spec: accessModes: - ReadWriteOnce + {{- if .Values.global.storageClass }} + storageClassName: {{ .Values.global.storageClass | quote }} + {{- end }} resources: requests: storage: {{ .Values.redis.storageSize | default "1Gi" | quote }} diff --git a/backend/helm/cloudhost-app/templates/service.yaml b/backend/helm/cloudhost-app/templates/service.yaml index 56c53fb..36fe652 100644 --- a/backend/helm/cloudhost-app/templates/service.yaml +++ b/backend/helm/cloudhost-app/templates/service.yaml @@ -1,3 +1,4 @@ +{{- if .Values.app.enabled }} {{- $name := include "cloudhost-app.name" . -}} {{- $ns := include "cloudhost-app.namespace" . -}} apiVersion: v1 @@ -15,3 +16,4 @@ spec: - port: 80 targetPort: {{ .Values.app.port }} protocol: TCP +{{- end }} diff --git a/backend/helm/cloudhost-app/templates/storageclass.yaml b/backend/helm/cloudhost-app/templates/storageclass.yaml new file mode 100644 index 0000000..01e167c --- /dev/null +++ b/backend/helm/cloudhost-app/templates/storageclass.yaml @@ -0,0 +1,12 @@ +{{- if and .Values.global.createStorageClass .Values.global.storageClass }} +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: {{ .Values.global.storageClass | quote }} + labels: + {{- include "cloudhost-app.labels" . | nindent 4 }} +provisioner: {{ .Values.global.storageProvisioner | quote }} +allowVolumeExpansion: true +reclaimPolicy: Delete +volumeBindingMode: WaitForFirstConsumer +{{- end }} diff --git a/backend/helm/cloudhost-app/values.yaml b/backend/helm/cloudhost-app/values.yaml index 66a7749..ddc7e37 100644 --- a/backend/helm/cloudhost-app/values.yaml +++ b/backend/helm/cloudhost-app/values.yaml @@ -3,8 +3,15 @@ # These are overridden per-app at install/upgrade time. # ──────────────────────────────────────────────────────────── +# ── Cluster storage (PVC resize) ───────────────────────── +global: + storageClass: "" + createStorageClass: false + storageProvisioner: "rancher.io/local-path" + # ── Application ────────────────────────────────────────── app: + enabled: true # false for managed database/redis/rabbitmq-only stacks name: my-app namespace: user-default runtime: nodejs # nodejs | laravel | wordpress | go | php | python | django | dotnet diff --git a/backend/migrations/015_application_product_type.sql b/backend/migrations/015_application_product_type.sql new file mode 100644 index 0000000..a59885d --- /dev/null +++ b/backend/migrations/015_application_product_type.sql @@ -0,0 +1,9 @@ +-- Standalone managed services (database / redis / rabbitmq) vs full applications +ALTER TABLE applications + ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application'; + +CREATE INDEX IF NOT EXISTS idx_applications_user_product_type + ON applications (user_id, product_type); + +ALTER TABLE resource_credits + ADD COLUMN IF NOT EXISTS product_type VARCHAR(32) NOT NULL DEFAULT 'application'; diff --git a/backend/migrations/016_service_access_persistent.sql b/backend/migrations/016_service_access_persistent.sql new file mode 100644 index 0000000..8847cb7 --- /dev/null +++ b/backend/migrations/016_service_access_persistent.sql @@ -0,0 +1,2 @@ +ALTER TABLE service_access_grants + ADD COLUMN IF NOT EXISTS persistent BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/migrations/017_snapshot_progress.sql b/backend/migrations/017_snapshot_progress.sql new file mode 100644 index 0000000..1508197 --- /dev/null +++ b/backend/migrations/017_snapshot_progress.sql @@ -0,0 +1,2 @@ +-- Snapshot creation progress (0–100) for UI feedback during DB dumps +ALTER TABLE snapshots ADD COLUMN IF NOT EXISTS progress INT NOT NULL DEFAULT 0; diff --git a/backend/src/access/access.service.ts b/backend/src/access/access.service.ts index 1091d34..0c71983 100644 --- a/backend/src/access/access.service.ts +++ b/backend/src/access/access.service.ts @@ -20,6 +20,7 @@ import { ServiceAccessGrantStatus, DatabaseType, AppLifecycleStatus, + ProductType, } from '../common/enums'; import { CreateServiceAccessDto } from './dto/service-access.dto'; @@ -29,6 +30,7 @@ export interface RevokeAccessJobData { const MIN_DURATION_MINUTES = 15; const DEFAULT_MAX_DURATION_MINUTES = 240; +const PERSISTENT_ACCESS_EXPIRES_AT = new Date('2099-12-31T23:59:59.000Z'); @Injectable() export class AccessService implements OnModuleInit { @@ -54,6 +56,7 @@ export class AccessService implements OnModuleInit { }, }); for (const grant of expired) { + if (grant.persistent) continue; try { await this.revokeGrant(grant.id, grant.userId, true); } catch (e: any) { @@ -96,16 +99,23 @@ export class AccessService implements OnModuleInit { private validateTargetEnabled(app: Application, target: ServiceAccessTarget): void { switch (target) { case ServiceAccessTarget.DATABASE: - if (!app.databaseType || app.databaseType === DatabaseType.NONE) { + if ( + (!app.databaseType || app.databaseType === DatabaseType.NONE) && + app.productType !== ProductType.MANAGED_DATABASE + ) { throw new BadRequestException('Application has no database'); } break; case ServiceAccessTarget.REDIS: - if (!app.enableRedis) throw new BadRequestException('Redis is not enabled'); + if (!app.enableRedis && app.productType !== ProductType.MANAGED_REDIS) { + throw new BadRequestException('Redis is not enabled'); + } break; case ServiceAccessTarget.RABBITMQ_AMQP: case ServiceAccessTarget.RABBITMQ_MANAGEMENT: - if (!app.enableRabbitmq) throw new BadRequestException('RabbitMQ is not enabled'); + if (!app.enableRabbitmq && app.productType !== ProductType.MANAGED_RABBITMQ) { + throw new BadRequestException('RabbitMQ is not enabled'); + } break; } } @@ -175,9 +185,15 @@ export class AccessService implements OnModuleInit { await this.validateApplicationForAccess(app); this.validateTargetEnabled(app, dto.target); - const maxDuration = await this.getMaxDurationMinutes(); - if (dto.durationMinutes > maxDuration) { - throw new BadRequestException(`Duration cannot exceed ${maxDuration} minutes`); + const persistent = !!dto.persistent; + if (!persistent) { + if (!dto.durationMinutes) { + throw new BadRequestException('durationMinutes is required for temporary access'); + } + const maxDuration = await this.getMaxDurationMinutes(); + if (dto.durationMinutes > maxDuration) { + throw new BadRequestException(`Duration cannot exceed ${maxDuration} minutes`); + } } const existing = await this.grantsRepo.findOne({ @@ -195,7 +211,9 @@ export class AccessService implements OnModuleInit { const grantId = uuidv4(); const namespace = this.kubernetesService.getUserNamespace(app.userId); - const expiresAt = new Date(Date.now() + dto.durationMinutes * 60 * 1000); + const expiresAt = persistent + ? PERSISTENT_ACCESS_EXPIRES_AT + : new Date(Date.now() + (dto.durationMinutes as number) * 60 * 1000); let k8sResult: { host: string; nodePort: number; k8sServiceName: string; targetPort: number }; try { @@ -216,13 +234,16 @@ export class AccessService implements OnModuleInit { host: k8sResult.host, k8sServiceName: k8sResult.k8sServiceName, status: ServiceAccessGrantStatus.ACTIVE, + persistent, expiresAt, }); await this.grantsRepo.save(grant); - const delayMs = Math.max(0, expiresAt.getTime() - Date.now()); - await this.revokeQueue.add('revoke', { grantId }, { delay: delayMs, jobId: grantId }); + if (!persistent) { + const delayMs = Math.max(0, expiresAt.getTime() - Date.now()); + await this.revokeQueue.add('revoke', { grantId }, { delay: delayMs, jobId: grantId }); + } const creds = await this.kubernetesService.readAccessCredentials(app, dto.target); const connection = this.buildConnection( @@ -240,6 +261,7 @@ export class AccessService implements OnModuleInit { port: grant.nodePort, targetPort: grant.targetPort, expiresAt: grant.expiresAt, + persistent: grant.persistent, status: grant.status, connection, }; @@ -279,6 +301,7 @@ export class AccessService implements OnModuleInit { port: grant.nodePort, targetPort: grant.targetPort, expiresAt: grant.expiresAt, + persistent: grant.persistent, status: grant.status, connection, }; diff --git a/backend/src/access/dto/service-access.dto.ts b/backend/src/access/dto/service-access.dto.ts index 3735bf9..ad38d10 100644 --- a/backend/src/access/dto/service-access.dto.ts +++ b/backend/src/access/dto/service-access.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsInt, Min, Max } from 'class-validator'; +import { IsEnum, IsInt, Min, Max, IsBoolean, IsOptional, ValidateIf } from 'class-validator'; import { ServiceAccessTarget } from '../../common/enums'; export class CreateServiceAccessDto { @@ -7,11 +7,20 @@ export class CreateServiceAccessDto { @IsEnum(ServiceAccessTarget) target: ServiceAccessTarget; - @ApiProperty({ example: 60, description: 'Access duration in minutes (min 15)' }) + @ApiPropertyOptional({ + example: false, + description: 'Keep NodePort open until manually revoked (ignores durationMinutes)', + }) + @IsOptional() + @IsBoolean() + persistent?: boolean; + + @ApiPropertyOptional({ example: 60, description: 'Access duration in minutes (min 15), required when not persistent' }) + @ValidateIf((o) => !o.persistent) @IsInt() @Min(15) @Max(1440) - durationMinutes: number; + durationMinutes?: number; } export class ServiceAccessConnectionDto { @@ -53,6 +62,9 @@ export class ServiceAccessGrantResponseDto { @ApiProperty() expiresAt: Date; + @ApiPropertyOptional() + persistent?: boolean; + @ApiProperty() status: string; diff --git a/backend/src/access/entities/service-access-grant.entity.ts b/backend/src/access/entities/service-access-grant.entity.ts index 6eb5ad2..d09e91b 100644 --- a/backend/src/access/entities/service-access-grant.entity.ts +++ b/backend/src/access/entities/service-access-grant.entity.ts @@ -50,6 +50,10 @@ export class ServiceAccessGrant { @Column({ type: 'enum', enum: ServiceAccessGrantStatus, default: ServiceAccessGrantStatus.ACTIVE }) status: ServiceAccessGrantStatus; + /** When true, access stays open until manually revoked (no auto-expiry job). */ + @Column({ default: false }) + persistent: boolean; + @Column({ type: 'timestamptz' }) expiresAt: Date; diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index 6edb716..33b5c2d 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -200,9 +200,12 @@ export class ApplicationsController { } @Get() - @ApiOperation({ summary: 'List my applications' }) - async findAll(@Request() req: any) { - return this.applicationsService.findAllByUser(req.user.id); + @ApiOperation({ summary: 'List my applications or managed services' }) + async findAll( + @Request() req: any, + @Query('productType') productType?: 'application' | 'managed', + ) { + return this.applicationsService.findAllByUser(req.user.id, { productType }); } @Get('all') @@ -299,19 +302,30 @@ export class ApplicationsController { // Update in K8s (live) await this.kubernetesService.updateResources(app, dto, workload); - // Update in DB — only main app resources are stored on the Application entity - const updateFields: any = {}; + const updateFields: Record = {}; if (workload === 'app') { if (dto.cpuRequest) updateFields.cpuRequest = dto.cpuRequest; if (dto.cpuLimit) updateFields.cpuLimit = dto.cpuLimit; if (dto.memoryRequest) updateFields.memoryRequest = dto.memoryRequest; if (dto.memoryLimit) updateFields.memoryLimit = dto.memoryLimit; if (dto.replicas !== undefined) updateFields.replicas = dto.replicas; + } else if (workload === 'redis' || workload === 'rabbitmq') { + const prev = app.optionalServiceResources?.[workload]; + updateFields.optionalServiceResources = { + ...app.optionalServiceResources, + [workload]: { + cpuRequest: dto.cpuRequest ?? prev?.cpuRequest, + cpuLimit: dto.cpuLimit ?? prev?.cpuLimit ?? '200m', + memoryRequest: dto.memoryRequest ?? prev?.memoryRequest, + memoryLimit: dto.memoryLimit ?? prev?.memoryLimit ?? '256Mi', + storageGi: prev?.storageGi ?? (workload === 'redis' ? 1 : 2), + }, + }; } const updated = Object.keys(updateFields).length > 0 - ? await this.applicationsService.update(id, app.userId, updateFields) + ? await this.applicationsService.update(id, app.userId, updateFields as any) : app; this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(dto)}`); return updated; diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index b1d16cf..2887b16 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -8,8 +8,16 @@ import * as crypto from 'crypto'; import { Application } from './entities/application.entity'; import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto'; import { ClustersService } from '../clusters/clusters.service'; -import { UserRole, DatabaseType, CustomDomainStatus, AppRuntime } from '../common/enums'; +import { + UserRole, + DatabaseType, + CustomDomainStatus, + AppRuntime, + ProductType, + isManagedProductType, +} from '../common/enums'; import { ensureAppUrlEnv } from './app-url.util'; +import { normalizeCreateApplicationDto } from './managed-service.util'; @Injectable() export class ApplicationsService { @@ -23,6 +31,9 @@ export class ApplicationsService { ) {} async create(userId: string, dto: CreateApplicationDto, userRole?: string): Promise { + dto = normalizeCreateApplicationDto(dto); + const productType = dto.productType ?? ProductType.APPLICATION; + // End users and technical staff cannot influence placement; only admins may manually assign. const isAdmin = userRole === UserRole.ADMIN; if (!isAdmin) { @@ -63,9 +74,12 @@ export class ApplicationsService { this.logger.log(`Generated DB credentials for app "${dto.name}" — user: ${dbUsername}`); } - const customDomain = dto.customDomain?.toLowerCase().trim() || undefined; + const customDomain = isManagedProductType(productType) + ? undefined + : dto.customDomain?.toLowerCase().trim() || undefined; - const defaultPort = [AppRuntime.WORDPRESS, AppRuntime.PHP, AppRuntime.LARAVEL].includes(dto.runtime) + const runtime = dto.runtime ?? AppRuntime.NODEJS; + const defaultPort = [AppRuntime.WORDPRESS, AppRuntime.PHP, AppRuntime.LARAVEL].includes(runtime) ? 80 : 3000; @@ -74,26 +88,31 @@ export class ApplicationsService { const app = this.appsRepository.create({ ...dto, + productType, + runtime, userId, clusterId, poolId, dbUsername, dbPassword, + replicas: isManagedProductType(productType) ? 0 : (dto.replicas ?? 1), port: dto.port ?? defaultPort, subdomain, customDomain: customDomain || undefined, customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE, - envVars: ensureAppUrlEnv( - { - name: dto.name, - runtime: dto.runtime, - subdomain, - customDomain: customDomain || undefined, - customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE, - envVars: dto.envVars ?? {}, - }, - platformDomain, - ), + envVars: isManagedProductType(productType) + ? (dto.envVars ?? {}) + : ensureAppUrlEnv( + { + name: dto.name, + runtime, + subdomain, + customDomain: customDomain || undefined, + customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE, + envVars: dto.envVars ?? {}, + }, + platformDomain, + ), }); const saved = await this.appsRepository.save(app); if (allocationLogId) { @@ -102,12 +121,32 @@ export class ApplicationsService { return saved; } - async findAllByUser(userId: string): Promise { - return this.appsRepository.find({ - where: { userId }, - relations: ['deployments'], - order: { createdAt: 'DESC' }, - }); + async findAllByUser( + userId: string, + options?: { productType?: 'application' | 'managed' }, + ): Promise { + const qb = this.appsRepository + .createQueryBuilder('app') + .leftJoinAndSelect('app.deployments', 'deployments') + .where('app.userId = :userId', { userId }) + .orderBy('app.createdAt', 'DESC'); + + if (options?.productType === 'application') { + qb.andWhere( + '(app.productType = :applicationType OR app.productType IS NULL)', + { applicationType: ProductType.APPLICATION }, + ); + } else if (options?.productType === 'managed') { + qb.andWhere('app.productType IN (:...managedTypes)', { + managedTypes: [ + ProductType.MANAGED_DATABASE, + ProductType.MANAGED_REDIS, + ProductType.MANAGED_RABBITMQ, + ], + }); + } + + return qb.getMany(); } async findAll(search?: string): Promise { diff --git a/backend/src/applications/dto/application.dto.ts b/backend/src/applications/dto/application.dto.ts index 37b96df..341525c 100644 --- a/backend/src/applications/dto/application.dto.ts +++ b/backend/src/applications/dto/application.dto.ts @@ -14,7 +14,7 @@ import { } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { AppRuntime, DatabaseType } from '../../common/enums'; +import { AppRuntime, DatabaseType, ProductType } from '../../common/enums'; import { OptionalServiceResourcesDto } from '../../billing/dto/optional-service-resources.dto'; export class OptionalServiceResourcesMapDto { @@ -44,9 +44,19 @@ export class CreateApplicationDto { @IsString() description?: string; - @ApiProperty({ enum: AppRuntime, example: AppRuntime.NODEJS }) + @ApiPropertyOptional({ + enum: ProductType, + default: ProductType.APPLICATION, + description: 'application (default) | managed_database | managed_redis | managed_rabbitmq', + }) + @IsOptional() + @IsEnum(ProductType) + productType?: ProductType; + + @ApiPropertyOptional({ enum: AppRuntime, example: AppRuntime.NODEJS }) + @IsOptional() @IsEnum(AppRuntime) - runtime: AppRuntime; + runtime?: AppRuntime; @ApiProperty({ enum: DatabaseType, example: DatabaseType.POSTGRESQL }) @IsEnum(DatabaseType) diff --git a/backend/src/applications/entities/application.entity.ts b/backend/src/applications/entities/application.entity.ts index 0bf42b0..9bd2d14 100644 --- a/backend/src/applications/entities/application.entity.ts +++ b/backend/src/applications/entities/application.entity.ts @@ -8,7 +8,14 @@ import { OneToMany, JoinColumn, } from 'typeorm'; -import { AppRuntime, DatabaseType, BillingCycle, AppLifecycleStatus, CustomDomainStatus } from '../../common/enums'; +import { + AppRuntime, + DatabaseType, + BillingCycle, + AppLifecycleStatus, + CustomDomainStatus, + ProductType, +} from '../../common/enums'; import { User } from '../../users/entities/user.entity'; import { Deployment } from '../../deployments/entities/deployment.entity'; @@ -20,6 +27,9 @@ export class Application { @Column() name: string; + @Column({ type: 'varchar', default: ProductType.APPLICATION }) + productType: ProductType; + @Column({ nullable: true }) description: string; diff --git a/backend/src/applications/managed-service.util.ts b/backend/src/applications/managed-service.util.ts new file mode 100644 index 0000000..94ed561 --- /dev/null +++ b/backend/src/applications/managed-service.util.ts @@ -0,0 +1,72 @@ +import { BadRequestException } from '@nestjs/common'; +import { CreateApplicationDto } from './dto/application.dto'; +import { + AppRuntime, + DatabaseType, + ProductType, + isManagedProductType, +} from '../common/enums'; + +const DB_CPU_LIMIT = '500m'; +const DB_MEMORY_LIMIT = '512Mi'; + +/** Normalize and validate create payload for managed vs full applications. */ +export function normalizeCreateApplicationDto(dto: CreateApplicationDto): CreateApplicationDto { + const productType = dto.productType ?? ProductType.APPLICATION; + + if (!isManagedProductType(productType)) { + if (!dto.runtime) { + throw new BadRequestException('runtime is required for applications'); + } + return { ...dto, productType: ProductType.APPLICATION }; + } + + if (dto.enableElasticsearch) { + throw new BadRequestException('Elasticsearch is not available for managed services'); + } + if (dto.customDomain) { + throw new BadRequestException('Custom domains are not available for managed services'); + } + + const normalized: CreateApplicationDto = { + ...dto, + productType, + runtime: dto.runtime ?? AppRuntime.NODEJS, + replicas: 0, + cpuLimit: dto.cpuLimit ?? '100m', + memoryLimit: dto.memoryLimit ?? '128Mi', + enableElasticsearch: false, + customDomain: undefined, + }; + + switch (productType) { + case ProductType.MANAGED_DATABASE: + if (!dto.databaseType || dto.databaseType === DatabaseType.NONE) { + throw new BadRequestException('databaseType is required for managed database services'); + } + normalized.databaseType = dto.databaseType; + normalized.enableRedis = false; + normalized.enableRabbitmq = false; + normalized.cpuRequest = dto.cpuRequest ?? '100m'; + normalized.cpuLimit = dto.cpuLimit ?? DB_CPU_LIMIT; + normalized.memoryRequest = dto.memoryRequest ?? '256Mi'; + normalized.memoryLimit = dto.memoryLimit ?? DB_MEMORY_LIMIT; + break; + case ProductType.MANAGED_REDIS: + normalized.databaseType = DatabaseType.NONE; + normalized.enableRedis = true; + normalized.enableRabbitmq = false; + if (!dto.redisVersion) normalized.redisVersion = '7.2'; + break; + case ProductType.MANAGED_RABBITMQ: + normalized.databaseType = DatabaseType.NONE; + normalized.enableRedis = false; + normalized.enableRabbitmq = true; + if (!dto.rabbitmqVersion) normalized.rabbitmqVersion = '3.13'; + break; + default: + break; + } + + return normalized; +} diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index 4c66e01..1a2e177 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -37,7 +37,17 @@ import { import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; -import { UserRole, BillingCycle, AppLifecycleStatus, InvoiceReason, InvoiceStatus, PaymentMethod } from '../common/enums'; +import { + UserRole, + BillingCycle, + AppLifecycleStatus, + InvoiceReason, + InvoiceStatus, + PaymentMethod, + ProductType, + DatabaseType, +} from '../common/enums'; +import { Application } from '../applications/entities/application.entity'; @ApiTags('Billing') @ApiBearerAuth() @@ -722,33 +732,15 @@ export class BillingController { paidInvoice = paid.invoice; } - // Apply the resource changes - const updatedApp = await this.applicationsService.update(app.id, app.userId, { - cpuRequest: dto.cpuRequest || app.cpuRequest, - cpuLimit: dto.cpuLimit || app.cpuLimit, - memoryRequest: dto.memoryRequest || app.memoryRequest, - memoryLimit: dto.memoryLimit || app.memoryLimit, - replicas: dto.replicas ?? app.replicas, - dbStorageSize: dto.dbStorageSize || app.dbStorageSize, - appStorageSize: dto.appStorageSize || app.appStorageSize, - }); + const updatedApp = await this.applicationsService.update( + app.id, + app.userId, + this.buildUpgradeEntityPatch(app, dto), + ); - // Update Kubernetes resources try { - await this.kubernetesService.updateResources(updatedApp, { - cpuRequest: dto.cpuRequest, - cpuLimit: dto.cpuLimit, - memoryRequest: dto.memoryRequest, - memoryLimit: dto.memoryLimit, - replicas: dto.replicas, - }); - - // Resize app storage PVC if changed - if (dto.appStorageSize && dto.appStorageSize !== app.appStorageSize) { - await this.kubernetesService.resizeAppStoragePvc(updatedApp, dto.appStorageSize); - } + await this.applyUpgradeToKubernetes(updatedApp, dto, app); } catch (e: any) { - // Log error but don't fail - DB is updated, K8s will sync on next deploy console.warn(`K8s resource update failed for ${app.name}: ${e.message}`); } @@ -803,29 +795,15 @@ export class BillingController { if (action === 'upgrade') { const app = await this.applicationsService.findOne(invoice.applicationId); - const resources = invoice.metadata?.resources || {}; - const updatedApp = await this.applicationsService.update(app.id, app.userId, { - cpuRequest: resources.cpuRequest || app.cpuRequest, - cpuLimit: resources.cpuLimit || app.cpuLimit, - memoryRequest: resources.memoryRequest || app.memoryRequest, - memoryLimit: resources.memoryLimit || app.memoryLimit, - replicas: resources.replicas ?? app.replicas, - dbStorageSize: resources.dbStorageSize || app.dbStorageSize, - appStorageSize: resources.appStorageSize || app.appStorageSize, - }); + const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto; + const updatedApp = await this.applicationsService.update( + app.id, + app.userId, + this.buildUpgradeEntityPatch(app, resources), + ); try { - await this.kubernetesService.updateResources(updatedApp, { - cpuRequest: resources.cpuRequest, - cpuLimit: resources.cpuLimit, - memoryRequest: resources.memoryRequest, - memoryLimit: resources.memoryLimit, - replicas: resources.replicas, - }); - - if (resources.appStorageSize && resources.appStorageSize !== app.appStorageSize) { - await this.kubernetesService.resizeAppStoragePvc(updatedApp, resources.appStorageSize); - } + await this.applyUpgradeToKubernetes(updatedApp, resources, app); } catch (e: any) { console.warn(`K8s resource update failed for ${app.name}: ${e.message}`); } @@ -849,6 +827,136 @@ export class BillingController { return null; } + private buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial { + const pt = app.productType ?? ProductType.APPLICATION; + + if (pt === ProductType.MANAGED_REDIS && dto.redisResources) { + return { + optionalServiceResources: { + ...app.optionalServiceResources, + redis: { + ...app.optionalServiceResources?.redis, + ...dto.redisResources, + storageGi: + dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1, + }, + }, + }; + } + + if (pt === ProductType.MANAGED_RABBITMQ && dto.rabbitmqResources) { + return { + optionalServiceResources: { + ...app.optionalServiceResources, + rabbitmq: { + ...app.optionalServiceResources?.rabbitmq, + ...dto.rabbitmqResources, + storageGi: + dto.rabbitmqResources.storageGi ?? + app.optionalServiceResources?.rabbitmq?.storageGi ?? + 2, + }, + }, + }; + } + + return { + cpuRequest: dto.cpuRequest || app.cpuRequest, + cpuLimit: dto.cpuLimit || app.cpuLimit, + memoryRequest: dto.memoryRequest || app.memoryRequest, + memoryLimit: dto.memoryLimit || app.memoryLimit, + replicas: dto.replicas ?? app.replicas, + dbStorageSize: dto.dbStorageSize || app.dbStorageSize, + appStorageSize: dto.appStorageSize || app.appStorageSize, + }; + } + + private async applyUpgradeToKubernetes( + app: Application, + dto: UpgradeResourcesDto, + previous: Application, + ): Promise { + const pt = app.productType ?? ProductType.APPLICATION; + + if (pt === ProductType.MANAGED_DATABASE) { + await this.kubernetesService.updateResources( + app, + { + cpuRequest: dto.cpuRequest, + cpuLimit: dto.cpuLimit, + memoryRequest: dto.memoryRequest, + memoryLimit: dto.memoryLimit, + }, + 'database', + ); + if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) { + const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize); + if (!resize.success) { + throw new BadRequestException(resize.message); + } + } + return; + } + + 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', + ); + } + 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', + ); + } + return; + } + + 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); + } + + if ( + dto.dbStorageSize && + dto.dbStorageSize !== previous.dbStorageSize && + previous.databaseType && + previous.databaseType !== DatabaseType.NONE + ) { + const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize); + 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/billing/billing.service.ts b/backend/src/billing/billing.service.ts index 23d1fd3..4bed463 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -13,6 +13,8 @@ import { InvoiceStatus, PaymentMethod, UserRole, + ProductType, + isManagedProductType, } from '../common/enums'; import { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto'; import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto'; @@ -560,6 +562,7 @@ export class BillingService { * Used by lifecycle service for auto-renew. */ async calculateCostForApp(app: { + productType?: ProductType; runtime: string; databaseType: string; cpuLimit: string; @@ -572,20 +575,9 @@ export class BillingService { enableElasticsearch?: boolean; customDomain?: string; customDomainStatus?: string; + optionalServiceResources?: Application['optionalServiceResources']; }): Promise<{ hourly: number; monthly: number; yearly: number }> { - const result = await this.calculateCost({ - runtime: app.runtime, - databaseType: app.databaseType, - cpuLimit: app.cpuLimit, - memoryLimit: app.memoryLimit, - replicas: app.replicas, - dbStorageSize: app.dbStorageSize, - appStorageSize: app.appStorageSize, - enableRedis: app.enableRedis, - enableRabbitmq: app.enableRabbitmq, - enableElasticsearch: app.enableElasticsearch, - enableCustomDomain: !!app.customDomain && app.customDomainStatus === 'verified', - }); + const result = await this.calculateCost(this.toCalculateDto(this.appToResourceConfig(app as Application))); return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly }; } @@ -635,19 +627,43 @@ export class BillingService { remainingHours: number; billingCycle: BillingCycle | null; }> { - // Current cost const currentCost = await this.calculateCostForApp(app); - // New cost with upgraded resources - const newCost = await this.calculateCost({ - runtime: app.runtime, - databaseType: app.databaseType, - cpuLimit: newResources.cpuLimit || app.cpuLimit, - memoryLimit: newResources.memoryLimit || app.memoryLimit, - replicas: newResources.replicas ?? app.replicas, - dbStorageSize: newResources.dbStorageSize || app.dbStorageSize, - appStorageSize: newResources.appStorageSize || app.appStorageSize, - }); + const base = this.appToResourceConfig(app); + const merged = { + ...base, + ...(newResources.cpuLimit && { cpuLimit: newResources.cpuLimit }), + ...(newResources.memoryLimit && { memoryLimit: newResources.memoryLimit }), + replicas: newResources.replicas ?? base.replicas, + ...(newResources.dbStorageSize && { dbStorageSize: newResources.dbStorageSize }), + ...(newResources.appStorageSize && { appStorageSize: newResources.appStorageSize }), + ...(newResources.redisResources && { + redisResources: { + ...base.redisResources, + ...newResources.redisResources, + storageGi: + newResources.redisResources.storageGi ?? + base.redisResources?.storageGi ?? + 1, + }, + }), + ...(newResources.rabbitmqResources && { + rabbitmqResources: { + ...base.rabbitmqResources, + ...newResources.rabbitmqResources, + storageGi: + newResources.rabbitmqResources.storageGi ?? + base.rabbitmqResources?.storageGi ?? + 2, + }, + }), + }; + const newCostResult = await this.calculateCost(this.toCalculateDto(merged)); + const newCost = { + hourly: newCostResult.hourly, + monthly: newCostResult.monthly, + yearly: newCostResult.yearly, + }; // Difference const difference = { @@ -719,18 +735,24 @@ export class BillingService { : undefined; const dtoExtras = 'redisResources' in app ? (app as CalculateCostDto) : undefined; + const productType = + 'productType' in app + ? ((app as Application).productType ?? ProductType.APPLICATION) + : ((app as CalculateCostDto).productType ?? ProductType.APPLICATION); + const managed = isManagedProductType(productType); return { + productType, runtime: app.runtime, databaseType: app.databaseType, cpuLimit: app.cpuLimit, memoryLimit: app.memoryLimit, - replicas: app.replicas || 1, + replicas: managed ? 0 : (app.replicas ?? 1), dbStorageSize: app.dbStorageSize, appStorageSize: app.appStorageSize, enableRedis: !!app.enableRedis, enableRabbitmq: !!app.enableRabbitmq, - enableElasticsearch: !!app.enableElasticsearch, - enableCustomDomain, + enableElasticsearch: managed ? false : !!app.enableElasticsearch, + enableCustomDomain: managed ? false : enableCustomDomain, redisResources: optionalRes?.redis ?? dtoExtras?.redisResources, rabbitmqResources: optionalRes?.rabbitmq ?? dtoExtras?.rabbitmqResources, }; @@ -744,6 +766,7 @@ export class BillingService { const credit = this.creditRepo.create({ userId: app.userId, sourceAppName: app.name, + productType: app.productType ?? ProductType.APPLICATION, runtime: app.runtime, databaseType: app.databaseType, cpuLimit: app.cpuLimit, @@ -781,6 +804,7 @@ export class BillingService { return { id: credit.id, sourceAppName: credit.sourceAppName, + productType: credit.productType ?? ProductType.APPLICATION, runtime: credit.runtime, databaseType: credit.databaseType, cpuLimit: credit.cpuLimit, @@ -803,6 +827,9 @@ export class BillingService { config: ReturnType, credit: ResourceCredit, ): boolean { + const configProduct = config.productType ?? ProductType.APPLICATION; + const creditProduct = credit.productType ?? ProductType.APPLICATION; + if (configProduct !== creditProduct) return false; if (config.runtime !== credit.runtime) return false; if ( credit.databaseType !== DatabaseType.NONE && @@ -842,12 +869,16 @@ export class BillingService { ): Promise { const credits = await this.getActiveCredits(userId); return ( - credits.find( - (c) => + credits.find((c) => { + const creditProduct = c.productType ?? ProductType.APPLICATION; + const configProduct = config.productType ?? ProductType.APPLICATION; + if (creditProduct !== configProduct) return false; + return ( c.runtime === config.runtime && (c.databaseType === DatabaseType.NONE || - c.databaseType === config.databaseType), - ) ?? null + c.databaseType === config.databaseType) + ); + }) ?? null ); } @@ -865,6 +896,7 @@ export class BillingService { config: ReturnType, ): CalculateCostDto { return { + productType: config.productType, runtime: config.runtime, databaseType: config.databaseType, cpuLimit: config.cpuLimit, @@ -887,6 +919,7 @@ export class BillingService { patch: Partial = {}, ): CalculateCostDto { return { + productType: credit.productType ?? ProductType.APPLICATION, runtime: credit.runtime, databaseType: credit.databaseType, cpuLimit: credit.cpuLimit, diff --git a/backend/src/billing/dto/billing.dto.ts b/backend/src/billing/dto/billing.dto.ts index 23b35cb..649111f 100644 --- a/backend/src/billing/dto/billing.dto.ts +++ b/backend/src/billing/dto/billing.dto.ts @@ -1,7 +1,7 @@ import { IsString, IsEnum, IsOptional, IsBoolean, IsNumber, IsArray, ValidateNested, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { BillingCycle, PricingResourceType, AppRuntime, InvoiceStatus } from '../../common/enums'; +import { BillingCycle, PricingResourceType, AppRuntime, InvoiceStatus, ProductType } from '../../common/enums'; import { OptionalServiceResourcesDto } from './optional-service-resources.dto'; export class CreatePricingRuleDto { @@ -92,6 +92,11 @@ export class ChargeWalletDto { } export class CalculateCostDto { + @ApiPropertyOptional({ enum: ProductType, default: ProductType.APPLICATION }) + @IsOptional() + @IsEnum(ProductType) + productType?: ProductType; + @ApiProperty({ example: 'nodejs' }) @IsString() runtime: string; @@ -108,10 +113,11 @@ export class CalculateCostDto { @IsString() memoryLimit: string; - @ApiProperty({ example: 1, description: 'Number of replicas' }) + @ApiPropertyOptional({ example: 1, description: 'Number of replicas (0 for managed services)' }) + @IsOptional() @IsNumber() - @Min(1) - replicas: number; + @Min(0) + replicas?: number; @ApiProperty({ example: '1Gi', description: 'Database storage size' }) @IsOptional() @@ -206,6 +212,18 @@ export class UpgradeResourcesDto { @IsOptional() @IsString() appStorageSize?: string; + + @ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Managed Redis resource limits' }) + @IsOptional() + @ValidateNested() + @Type(() => OptionalServiceResourcesDto) + redisResources?: OptionalServiceResourcesDto; + + @ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Managed RabbitMQ resource limits' }) + @IsOptional() + @ValidateNested() + @Type(() => OptionalServiceResourcesDto) + rabbitmqResources?: OptionalServiceResourcesDto; } export class CalculateUpgradeCostDto extends UpgradeResourcesDto {} diff --git a/backend/src/billing/entities/resource-credit.entity.ts b/backend/src/billing/entities/resource-credit.entity.ts index 3363dc0..204dd17 100644 --- a/backend/src/billing/entities/resource-credit.entity.ts +++ b/backend/src/billing/entities/resource-credit.entity.ts @@ -7,7 +7,7 @@ import { JoinColumn, } from 'typeorm'; import { User } from '../../users/entities/user.entity'; -import { AppRuntime, DatabaseType, BillingCycle } from '../../common/enums'; +import { AppRuntime, DatabaseType, BillingCycle, ProductType } from '../../common/enums'; /** Prepaid resources returned to the user when they delete an app before plan expiry. */ @Entity('resource_credits') @@ -25,6 +25,9 @@ export class ResourceCredit { @Column({ nullable: true }) sourceAppName: string; + @Column({ type: 'varchar', default: ProductType.APPLICATION }) + productType: ProductType; + @Column({ type: 'enum', enum: AppRuntime }) runtime: AppRuntime; diff --git a/backend/src/billing/pricing-catalog.service.spec.ts b/backend/src/billing/pricing-catalog.service.spec.ts index 5cefd47..7b74641 100644 --- a/backend/src/billing/pricing-catalog.service.spec.ts +++ b/backend/src/billing/pricing-catalog.service.spec.ts @@ -11,6 +11,7 @@ import { DatabaseType, OptionalService, PricingResourceType, + ProductType, } from '../common/enums'; import { OPTIONAL_SERVICE_DEPLOY_SPECS } from './pricing-catalog.constants'; import { CalculateCostDto } from './dto/billing.dto'; @@ -207,4 +208,92 @@ describe('PricingCatalogService', () => { expect(service.amountForCycleFromLine(line, BillingCycle.MONTHLY)).toBe(2); expect(service.amountForCycleFromLine(line, BillingCycle.YEARLY)).toBe(3); }); + + it('managed_database bills database addon and resources without app base fee', () => { + const rates = [ + { + runtime: AppRuntime.NODEJS, + resourceType: PricingResourceType.BASE_FEE, + hourlyPrice: 999, + monthlyPrice: 999, + yearlyPrice: 999, + isActive: true, + }, + { + runtime: AppRuntime.NODEJS, + resourceType: PricingResourceType.DATABASE_ADDON, + hourlyPrice: 0, + monthlyPrice: 500, + yearlyPrice: 0, + isActive: true, + }, + { + runtime: AppRuntime.NODEJS, + resourceType: PricingResourceType.STORAGE_PER_GB, + hourlyPrice: 0, + monthlyPrice: 100, + yearlyPrice: 0, + isActive: true, + }, + ] as PricingRate[]; + + const result = service.computeTotalsWithRates( + { + ...baseDto(), + productType: ProductType.MANAGED_DATABASE, + databaseType: DatabaseType.POSTGRESQL, + replicas: 0, + dbStorageSize: '2Gi', + cpuLimit: '500m', + memoryLimit: '512Mi', + }, + rates, + emptyOptional(), + ); + expect(result.monthly).toBe(700); + expect(result.breakdown.some((l) => l.label.includes('Base fee'))).toBe(false); + }); + + it('managed_redis bills only optional redis lines', () => { + const profile = { + service: OptionalService.REDIS, + cpuLimit: OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit, + memoryLimit: '256Mi', + storageGi: 0, + } as OptionalServiceProfile; + + const rates = [ + { + service: OptionalService.REDIS, + resourceType: PricingResourceType.BASE_FEE, + hourlyPrice: 0, + monthlyPrice: 250, + yearlyPrice: 0, + isActive: true, + }, + ] as OptionalServiceRate[]; + + const appRates = [ + { + runtime: AppRuntime.NODEJS, + resourceType: PricingResourceType.BASE_FEE, + hourlyPrice: 999, + monthlyPrice: 999, + yearlyPrice: 999, + isActive: true, + }, + ] as PricingRate[]; + + const result = service.computeTotalsWithRates( + { + ...baseDto(), + productType: ProductType.MANAGED_REDIS, + replicas: 0, + enableRedis: true, + }, + appRates, + { profiles: [profile], rates, customDomain: null }, + ); + expect(result.monthly).toBe(250); + }); }); diff --git a/backend/src/billing/pricing-catalog.service.ts b/backend/src/billing/pricing-catalog.service.ts index 688a221..afc32df 100644 --- a/backend/src/billing/pricing-catalog.service.ts +++ b/backend/src/billing/pricing-catalog.service.ts @@ -11,6 +11,7 @@ import { DatabaseType, OptionalService, PricingResourceType, + ProductType, } from '../common/enums'; import { CalculateCostDto } from './dto/billing.dto'; import { @@ -329,6 +330,19 @@ export class PricingCatalogService implements OnModuleInit { rates: PricingRate[], optional: OptionalBillingContext, ): CostBreakdownLine[] { + const productType = dto.productType ?? ProductType.APPLICATION; + + if ( + productType === ProductType.MANAGED_REDIS || + productType === ProductType.MANAGED_RABBITMQ + ) { + return this.buildOptionalServiceLines(dto, optional); + } + + if (productType === ProductType.MANAGED_DATABASE) { + return this.buildManagedDatabaseLines(dto, rates); + } + const lines: CostBreakdownLine[] = []; const quantities = this.getQuantities(dto); @@ -351,6 +365,52 @@ export class PricingCatalogService implements OnModuleInit { return lines; } + private buildManagedDatabaseLines( + dto: CalculateCostDto, + rates: PricingRate[], + ): CostBreakdownLine[] { + const lines: CostBreakdownLine[] = []; + const quantities = this.getManagedDatabaseQuantities(dto); + const allowed = new Set([ + PricingResourceType.DATABASE_ADDON, + PricingResourceType.CPU_PER_CORE, + PricingResourceType.MEMORY_PER_GB, + PricingResourceType.STORAGE_PER_GB, + ]); + + for (const rate of rates) { + if (!allowed.has(rate.resourceType)) continue; + const qty = quantities.get(rate.resourceType) ?? 0; + if (qty <= 0) continue; + const line = this.lineFromPrices( + RESOURCE_LABELS[rate.resourceType], + qty, + Number(rate.hourlyPrice), + Number(rate.monthlyPrice), + Number(rate.yearlyPrice), + rate.resourceType, + dto, + ); + if (line) lines.push(line); + } + return lines; + } + + private getManagedDatabaseQuantities(dto: CalculateCostDto): Map { + const cpuQty = this.parseCpuToCores(dto.cpuLimit || '500m'); + const memoryQty = this.parseMemoryToGb(dto.memoryLimit || '512Mi'); + const storageQty = dto.dbStorageSize + ? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0 + : 1; + + const map = new Map(); + map.set(PricingResourceType.DATABASE_ADDON, 1); + map.set(PricingResourceType.CPU_PER_CORE, cpuQty); + map.set(PricingResourceType.MEMORY_PER_GB, memoryQty); + map.set(PricingResourceType.STORAGE_PER_GB, storageQty); + return map; + } + private buildOptionalServiceLines( dto: CalculateCostDto, optional: OptionalBillingContext, @@ -589,7 +649,10 @@ export class PricingCatalogService implements OnModuleInit { } getQuantities(dto: CalculateCostDto): Map { - const replicas = dto.replicas || 1; + if ((dto.productType ?? ProductType.APPLICATION) === ProductType.MANAGED_DATABASE) { + return this.getManagedDatabaseQuantities(dto); + } + const replicas = dto.replicas ?? 1; const hasDatabase = dto.databaseType !== DatabaseType.NONE && dto.databaseType !== 'none'; const cpuQty = this.parseCpuToCores(dto.cpuLimit) * replicas; const memoryQty = this.parseMemoryToGb(dto.memoryLimit) * replicas; diff --git a/backend/src/common/enums.ts b/backend/src/common/enums.ts index fb1e211..53c5836 100644 --- a/backend/src/common/enums.ts +++ b/backend/src/common/enums.ts @@ -44,6 +44,25 @@ export enum DatabaseType { NONE = 'none', } +/** Standalone managed offerings vs full application deploy. */ +export enum ProductType { + APPLICATION = 'application', + MANAGED_DATABASE = 'managed_database', + MANAGED_REDIS = 'managed_redis', + MANAGED_RABBITMQ = 'managed_rabbitmq', +} + +export function isManagedProductType(productType?: ProductType | string): boolean { + return ( + productType === ProductType.MANAGED_DATABASE || + productType === ProductType.MANAGED_REDIS || + productType === ProductType.MANAGED_RABBITMQ + ); +} + +/** Stored in latestImageTag after a managed service is provisioned via Helm (no app image build). */ +export const MANAGED_DEPLOY_MARKER = 'helm-managed'; + // Optional services that can be attached to an application export enum OptionalService { REDIS = 'redis', diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index 6f4a73d..34b12e9 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -43,6 +43,11 @@ export default () => ({ platform: { domain: process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local', uploadDir: process.env.UPLOAD_DIR || './uploads', + /** StorageClass for new PVCs; must support allowVolumeExpansion for disk resize */ + storageClass: process.env.PLATFORM_STORAGE_CLASS || 'cloudhost-expandable', + /** Install cloudhost-expandable StorageClass via Helm on each app deploy */ + createStorageClass: process.env.PLATFORM_CREATE_STORAGE_CLASS !== 'false', + storageProvisioner: process.env.PLATFORM_STORAGE_PROVISIONER || 'rancher.io/local-path', }, // Lifecycle defaults (can be overridden via PlatformSettings entity by admin) diff --git a/backend/src/deployments/deployments.service.ts b/backend/src/deployments/deployments.service.ts index ad9f0f1..cf13c01 100644 --- a/backend/src/deployments/deployments.service.ts +++ b/backend/src/deployments/deployments.service.ts @@ -6,7 +6,12 @@ import { Deployment } from './entities/deployment.entity'; import { ApplicationsService } from '../applications/applications.service'; import { KubernetesService } from '../kubernetes/kubernetes.service'; import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service'; -import { AppLifecycleStatus, DeploymentStatus } from '../common/enums'; +import { + AppLifecycleStatus, + DeploymentStatus, + isManagedProductType, + MANAGED_DEPLOY_MARKER, +} from '../common/enums'; import { ClustersService } from '../clusters/clusters.service'; @Injectable() @@ -36,22 +41,111 @@ export class DeploymentsService { }); const saved = await this.deploymentsRepository.save(deployment); - // Trigger async build & deploy pipeline - this.executePipeline(saved.id, app).catch((error) => { + // Trigger async pipeline (Helm-only for managed services, build+deploy for apps) + const run = isManagedProductType(app.productType) + ? this.executeManagedPipeline(saved.id, app) + : this.executePipeline(saved.id, app); + run.catch((error) => { this.logger.error(`Pipeline failed for deployment ${saved.id}:`, error); }); return saved; } + /** Provision managed database/redis/rabbitmq via Helm only — no image build. */ + private async executeManagedPipeline(deploymentId: string, app: any): Promise { + try { + await this.updateStatus(deploymentId, DeploymentStatus.DEPLOYING); + this.buildService.setProgress(deploymentId, { + phase: 'deploying', + percent: 10, + message: 'Provisioning service via Helm...', + }); + + const hasDbDump = app.dbDumpPath && fs.existsSync(app.dbDumpPath); + const { app: deployedApp, k8sResources } = await this.deployManagedWithClusterFallback( + deploymentId, + app, + hasDbDump, + ); + app = deployedApp; + + if (hasDbDump) { + this.logger.log(`Restoring DB dump for ${app.name} from ${app.dbDumpPath}`); + try { + await this.kubernetesService.waitForDatabaseReady(app, 120_000); + const freshApp = await this.applicationsService.findOne(app.id); + const result = await this.kubernetesService.restoreDatabaseDump(freshApp, freshApp.dbDumpPath!); + if (result.success) { + this.logger.log(`DB dump restored successfully for ${app.name}`); + } else { + this.logger.warn(`DB dump restore failed for ${app.name}: ${result.logs}`); + } + } catch (e: any) { + this.logger.warn(`DB dump restore error for ${app.name}: ${e.message}`); + } + } + + this.buildService.setProgress(deploymentId, { + phase: 'deploying', + percent: 96, + message: 'Waiting for service pods to become ready...', + }); + await this.kubernetesService.waitForApplicationReady( + app, + 600_000, + () => this.isDeploymentCancelled(deploymentId), + ); + + if (await this.isDeploymentCancelled(deploymentId)) { + return; + } + + await this.applicationsService.updateImageTag(app.id, MANAGED_DEPLOY_MARKER); + + this.buildService.setProgress(deploymentId, { + phase: 'done', + percent: 100, + message: 'Service provisioned', + }); + await this.deploymentsRepository.update(deploymentId, { + status: DeploymentStatus.RUNNING, + k8sResources, + finishedAt: new Date(), + }); + } catch (error: any) { + if (this.isCancellationError(error) || (await this.isDeploymentCancelled(deploymentId))) { + this.logger.log(`Deployment ${deploymentId} cancelled by user`); + await this.deploymentsRepository.update(deploymentId, { + status: DeploymentStatus.CANCELLED, + errorMessage: 'Cancelled by user', + finishedAt: new Date(), + }); + return; + } + this.logger.error(`Managed deployment ${deploymentId} failed:`, error); + this.buildService.setProgress(deploymentId, { + phase: 'failed', + percent: 0, + message: error.message || 'Provisioning failed', + }); + await this.deploymentsRepository.update(deploymentId, { + status: DeploymentStatus.FAILED, + errorMessage: error.message, + finishedAt: new Date(), + }); + } + } + private async executePipeline(deploymentId: string, app: any): Promise { try { // Step 1: Build image await this.updateStatus(deploymentId, DeploymentStatus.BUILDING); - const { imageUri, buildLog } = await this.buildService.buildImage(app, deploymentId); + const buildResult = await this.buildService.buildImage(app, deploymentId); + const imageUri = buildResult.imageUri; // Save build log - await this.deploymentsRepository.update(deploymentId, { buildLog }); + await this.deploymentsRepository.update(deploymentId, { buildLog: buildResult.buildLog }); // Step 2: Update app with new image tag await this.applicationsService.updateImageTag(app.id, imageUri); @@ -149,6 +243,74 @@ export class DeploymentsService { } } + private async deployManagedWithClusterFallback( + deploymentId: string, + app: any, + hasDbDump: boolean, + ): Promise<{ app: any; k8sResources: Record }> { + const failedClusterIds: string[] = []; + let currentApp = app; + let lastError: any; + const maxAttempts = Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (await this.isDeploymentCancelled(deploymentId)) { + throw new Error('Deployment cancelled by user'); + } + + try { + this.buildService.setProgress(deploymentId, { + phase: 'deploying', + percent: Math.min(20 + attempt * 5, 90), + message: + attempt === 1 + ? 'Installing Helm release...' + : `Retrying Helm install on fallback cluster (${attempt}/${maxAttempts})...`, + }); + + const k8sResources = await this.kubernetesService.deployManagedService(currentApp); + return { app: currentApp, k8sResources }; + } catch (error: any) { + lastError = error; + failedClusterIds.push(currentApp.clusterId); + const failureMessage = error?.message || 'Helm provisioning failed on selected cluster'; + await this.clustersService.markAllocationFailure(currentApp.id, currentApp.clusterId, failureMessage); + + if (attempt >= maxAttempts) { + break; + } + + try { + const fallback = await this.clustersService.chooseFallbackClusterForApplication( + currentApp, + failedClusterIds, + failureMessage, + ); + const updatedApp = await this.applicationsService.updateClusterAssignment( + currentApp.id, + fallback.cluster.id, + fallback.pool?.id, + ); + await this.clustersService.attachAllocationToApplication(fallback.allocationLogId, currentApp.id); + this.logger.warn( + `Managed deployment ${deploymentId} falling back from cluster ${currentApp.clusterId || 'none'} to ${fallback.cluster.id}`, + ); + currentApp = { + ...currentApp, + ...updatedApp, + clusterId: fallback.cluster.id, + poolId: fallback.pool?.id || currentApp.poolId, + }; + } catch (fallbackError: any) { + lastError = fallbackError; + break; + } + } + } + + throw lastError || new Error('Managed service provisioning failed'); + } + private async deployWithClusterFallback( deploymentId: string, app: any, @@ -174,7 +336,10 @@ export class DeploymentsService { : `Retrying deployment on fallback cluster (${attempt}/${maxAttempts})...`, }); - const deployApp = hasDbDump ? { ...currentApp, replicas: 0 } : currentApp; + const deployApp = + hasDbDump && !isManagedProductType(currentApp.productType) + ? { ...currentApp, replicas: 0 } + : currentApp; const k8sResources = await this.kubernetesService.deployApplication(deployApp, imageUri); return { app: currentApp, k8sResources }; } catch (error: any) { @@ -231,6 +396,12 @@ export class DeploymentsService { String(error?.message || '').toLowerCase().includes('cancelled'); } + /** Managed DB/Redis/RabbitMQ or rows already provisioned via Helm without an app image build. */ + private isManagedOrHelmOnlyApp(app: { productType?: string; latestImageTag?: string }): boolean { + if (isManagedProductType(app.productType)) return true; + return app.latestImageTag === MANAGED_DEPLOY_MARKER; + } + private ensureRedeployAllowed(app: any): void { if (!app.billingCycle) return; @@ -267,8 +438,7 @@ export class DeploymentsService { } async getBuildLogs(applicationId: string, userId: string): Promise<{ buildLog: string | null; status: string; version: string | null; createdAt: Date }> { - // Verify user access - await this.applicationsService.findOne(applicationId, userId); + const app = await this.applicationsService.findOne(applicationId, userId); const latest = await this.deploymentsRepository.findOne({ where: { applicationId }, @@ -279,6 +449,15 @@ export class DeploymentsService { return { buildLog: null, status: 'no_deployment', version: null, createdAt: new Date() }; } + if (this.isManagedOrHelmOnlyApp(app)) { + return { + buildLog: null, + status: latest.status, + version: latest.version, + createdAt: latest.createdAt, + }; + } + return { buildLog: latest.buildLog || null, status: latest.status, @@ -288,7 +467,8 @@ export class DeploymentsService { } async getBuildProgress(applicationId: string, userId: string): Promise { - await this.applicationsService.findOne(applicationId, userId); + const app = await this.applicationsService.findOne(applicationId, userId); + const managed = this.isManagedOrHelmOnlyApp(app); const latest = await this.deploymentsRepository.findOne({ where: { applicationId }, @@ -311,10 +491,18 @@ export class DeploymentsService { return { phase: 'cancelled', percent: 0, message: latest.errorMessage || 'Cancelled by user' }; } if (latest.status === DeploymentStatus.BUILDING) { - return { phase: 'building', percent: 0, message: 'Building...' }; + return { + phase: managed ? 'deploying' : 'building', + percent: 0, + message: managed ? 'Provisioning...' : 'Building...', + }; } if (latest.status === DeploymentStatus.DEPLOYING) { - return { phase: 'deploying', percent: 90, message: 'Deploying...' }; + return { + phase: 'deploying', + percent: 90, + message: managed ? 'Provisioning via Helm...' : 'Deploying...', + }; } return null; } @@ -412,6 +600,11 @@ export class DeploymentsService { async redeployApplication(applicationId: string, userId: string): Promise { const app = await this.applicationsService.findOne(applicationId, userId); + if (this.isManagedOrHelmOnlyApp(app)) { + this.logger.log(`Re-provisioning ${app.name} via Helm (no build)`); + return this.triggerDeployment(applicationId, userId); + } + if (!app.codePath && !app.gitUrl) { throw new NotFoundException('No source code available. Upload code or set a git URL first.'); } diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index 2a399a7..a145258 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -9,7 +9,14 @@ import { PassThrough } from 'stream'; import { ClustersService } from '../clusters/clusters.service'; import { Application } from '../applications/entities/application.entity'; import { ensureAppUrlEnv } from '../applications/app-url.util'; -import { AppRuntime, DatabaseType, CustomDomainStatus, ServiceAccessTarget } from '../common/enums'; +import { + AppRuntime, + DatabaseType, + CustomDomainStatus, + ServiceAccessTarget, + ProductType, + isManagedProductType, +} from '../common/enums'; import { HelmService } from './helm.service'; import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util'; @@ -111,6 +118,7 @@ export class KubernetesService implements OnModuleInit { const storageGi = res?.storageGi ?? 1; return { enabled: app.enableRedis || false, + version: app.redisVersion || '7.2', storageSize: `${storageGi}Gi`, resources: { cpuRequest: res?.cpuRequest || '50m', @@ -126,6 +134,7 @@ export class KubernetesService implements OnModuleInit { const storageGi = res?.storageGi ?? 2; return { enabled: app.enableRabbitmq || false, + version: app.rabbitmqVersion || '3.13', storageSize: `${storageGi}Gi`, resources: { cpuRequest: res?.cpuRequest || '100m', @@ -141,6 +150,98 @@ export class KubernetesService implements OnModuleInit { return ensureAppUrlEnv(app, platformDomain); } + private helmGlobalStorageValues(): Record { + const storageClass = this.configService.get('platform.storageClass') || ''; + const createStorageClass = this.configService.get('platform.createStorageClass') === true; + const storageProvisioner = + this.configService.get('platform.storageProvisioner') || 'rancher.io/local-path'; + if (!storageClass) { + return { storageClass: '', createStorageClass: false, storageProvisioner }; + } + return { storageClass, createStorageClass, storageProvisioner }; + } + + /** Helm values for managed_database / managed_redis / managed_rabbitmq (no app workload). */ + private buildManagedHelmValues(app: Application): Record { + const namespace = `user-${app.userId.split('-')[0]}`; + const pullRegistryUrl = this.configService.get('registry.pullUrl') || 'localhost:30500'; + const isPostgres = app.databaseType === DatabaseType.POSTGRESQL; + const productType = app.productType; + + const values: Record = { + global: this.helmGlobalStorageValues(), + app: { + enabled: false, + name: app.name, + namespace, + runtime: app.runtime, + image: '', + port: app.port || 3000, + replicas: 0, + storageSize: app.appStorageSize || '2Gi', + }, + resources: { + cpuRequest: app.cpuRequest, + cpuLimit: app.cpuLimit, + memoryRequest: app.memoryRequest, + memoryLimit: app.memoryLimit, + }, + envVars: {}, + ingress: { + enabled: false, + subdomain: app.subdomain || app.name, + domain: this.configService.get('platform.domain'), + clusterIssuer: 'letsencrypt-prod', + customDomain: '', + }, + registry: { url: pullRegistryUrl }, + database: { + enabled: false, + type: app.databaseType, + version: app.dbVersion || (isPostgres ? '16' : '8.0'), + username: app.dbUsername || 'appuser', + password: app.dbPassword || this.generatePassword(), + storageSize: app.dbStorageSize || '1Gi', + resources: { + cpuRequest: app.cpuRequest || '100m', + cpuLimit: app.cpuLimit || '500m', + memoryRequest: app.memoryRequest || '256Mi', + memoryLimit: app.memoryLimit || '512Mi', + }, + }, + redis: { enabled: false, storageSize: '1Gi', resources: {} }, + rabbitmq: { enabled: false, storageSize: '2Gi', resources: {} }, + wordpress: { enabled: false }, + elasticsearch: { + enabled: false, + logPaths: [], + ownerId: app.userId, + applicationId: app.id, + }, + changeCause: `Helm provision ${app.name} (${productType}) at ${new Date().toISOString()}`, + }; + + switch (productType) { + case ProductType.MANAGED_DATABASE: + values.database.enabled = true; + values.redis.enabled = false; + values.rabbitmq.enabled = false; + break; + case ProductType.MANAGED_REDIS: + values.redis = this.buildRedisHelmBlock(app); + values.redis.enabled = true; + break; + case ProductType.MANAGED_RABBITMQ: + values.rabbitmq = this.buildRabbitmqHelmBlock(app); + values.rabbitmq.enabled = true; + break; + default: + break; + } + + return values; + } + private buildHelmValues(app: Application, imageUri: string): Record { const domain = this.configService.get('platform.domain'); const pullRegistryUrl = this.configService.get('registry.pullUrl') || 'localhost:30500'; @@ -149,13 +250,15 @@ export class KubernetesService implements OnModuleInit { const isPostgres = app.databaseType === DatabaseType.POSTGRESQL; const values: Record = { + global: this.helmGlobalStorageValues(), app: { + enabled: true, name: app.name, namespace: `user-${app.userId.split('-')[0]}`, runtime: app.runtime, image: imageUri, port: app.port, - replicas: app.replicas, + replicas: app.replicas || 1, storageSize: app.appStorageSize || '2Gi', }, resources: { @@ -170,9 +273,8 @@ export class KubernetesService implements OnModuleInit { subdomain: app.subdomain || app.name, domain: domain, clusterIssuer: 'letsencrypt-prod', - customDomain: (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED) - ? app.customDomain - : '', + customDomain: + app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : '', }, registry: { url: pullRegistryUrl, @@ -185,10 +287,10 @@ export class KubernetesService implements OnModuleInit { password: app.dbPassword || this.generatePassword(), storageSize: app.dbStorageSize || '1Gi', resources: { - cpuRequest: '100m', - cpuLimit: '500m', - memoryRequest: '256Mi', - memoryLimit: '512Mi', + cpuRequest: app.cpuRequest || '100m', + cpuLimit: app.cpuLimit || '500m', + memoryRequest: app.memoryRequest || '256Mi', + memoryLimit: app.memoryLimit || '512Mi', }, }, wordpress: { @@ -208,7 +310,25 @@ export class KubernetesService implements OnModuleInit { return values; } + /** Install or upgrade only the workload for a managed service (database, Redis, or RabbitMQ). */ + async deployManagedService(app: Application): Promise> { + if (!isManagedProductType(app.productType)) { + throw new BadRequestException('deployManagedService requires a managed product type'); + } + try { + return await this.deployManagedViaHelm(app); + } catch (helmError: any) { + this.logger.warn( + `Helm provision failed for managed ${app.name}, falling back to direct K8s API: ${helmError.message}`, + ); + return await this.deployManagedViaK8sApi(app); + } + } + async deployApplication(app: Application, imageUri: string): Promise> { + if (isManagedProductType(app.productType)) { + return this.deployManagedService(app); + } // Try Helm first, fall back to direct K8s API if Helm is unavailable try { return await this.deployViaHelm(app, imageUri); @@ -227,8 +347,9 @@ export class KubernetesService implements OnModuleInit { ): Promise { const { coreApi, appsApi } = await this.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; + const managed = isManagedProductType(app.productType); const workloads = [ - { name: app.name, replicas: app.replicas || 1 }, + ...(!managed ? [{ name: app.name, replicas: app.replicas || 1 }] : []), ...(app.databaseType !== DatabaseType.NONE ? [{ name: `${app.name}-db`, replicas: 1 }] : []), ...(app.enableRedis ? [{ name: `${app.name}-redis`, replicas: 1 }] : []), ...(app.enableRabbitmq ? [{ name: `${app.name}-rabbitmq`, replicas: 1 }] : []), @@ -337,9 +458,90 @@ export class KubernetesService implements OnModuleInit { return { helm: { release: releaseName, namespace, stdout: result.stdout }, values }; } + private async deployManagedViaHelm(app: Application): Promise> { + const kubeconfig = await this.getKubeconfig(app.clusterId); + const values = this.buildManagedHelmValues(app); + const namespace = values.app.namespace; + const releaseName = app.name; + + const result = await this.helmService.installOrUpgrade( + releaseName, + namespace, + values, + kubeconfig, + ); + + this.logger.log(`Successfully provisioned managed service ${app.name} in ${namespace} via Helm`); + return { helm: { release: releaseName, namespace, stdout: result.stdout }, values }; + } + // ── Direct K8s API deployment (fallback) ────────────────────────── + private async deployManagedViaK8sApi(app: Application): Promise> { + const { coreApi, appsApi } = await this.getK8sClient(app.clusterId); + const namespace = `user-${app.userId.split('-')[0]}`; + const context: ManifestContext = { + appName: app.name, + namespace, + image: '', + port: app.port || 3000, + replicas: 0, + cpuRequest: app.cpuRequest, + cpuLimit: app.cpuLimit, + memoryRequest: app.memoryRequest, + memoryLimit: app.memoryLimit, + envVars: {}, + runtime: app.runtime, + databaseType: app.databaseType, + domain: this.configService.get('platform.domain') || 'apps.cloudhost.ir', + subdomain: app.subdomain || app.name, + dbUsername: app.dbUsername || 'appuser', + dbPassword: app.dbPassword || this.generatePassword(), + dbVersion: app.dbVersion || '', + dbStorageSize: app.dbStorageSize || '1Gi', + appStorageSize: app.appStorageSize || '2Gi', + enableRedis: false, + redisVersion: app.redisVersion || '7.2', + enableRabbitmq: false, + rabbitmqVersion: app.rabbitmqVersion || '3.13', + enableElasticsearch: false, + elasticsearchVersion: app.elasticsearchVersion || '8.12', + logPaths: [], + ownerId: app.userId, + applicationId: app.id, + }; + + const manifests: Record = {}; + + await this.ensureNamespace(coreApi, namespace); + + switch (app.productType) { + case ProductType.MANAGED_DATABASE: + context.databaseType = app.databaseType; + manifests.database = await this.deployDatabase(coreApi, appsApi, context); + break; + case ProductType.MANAGED_REDIS: + context.enableRedis = true; + await this.deployRedis(coreApi, appsApi, context); + manifests.redis = true; + break; + case ProductType.MANAGED_RABBITMQ: + context.enableRabbitmq = true; + await this.deployRabbitmq(coreApi, appsApi, context); + manifests.rabbitmq = true; + break; + default: + throw new BadRequestException(`Unsupported managed product type: ${app.productType}`); + } + + this.logger.log(`Provisioned managed service ${app.name} in ${namespace} via K8s API`); + return manifests; + } + private async deployViaK8sApi(app: Application, imageUri: string): Promise> { + if (isManagedProductType(app.productType)) { + return this.deployManagedViaK8sApi(app); + } const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId); const domain = this.configService.get('platform.domain'); @@ -600,6 +802,9 @@ export class KubernetesService implements OnModuleInit { spec: { accessModes: ['ReadWriteOnce'], resources: { requests: { storage: ctx.appStorageSize || '2Gi' } }, + ...(this.configService.get('platform.storageClass') + ? { storageClassName: this.configService.get('platform.storageClass') } + : {}), }, }; @@ -1098,20 +1303,23 @@ export class KubernetesService implements OnModuleInit { let livenessProbe: any; switch (dbType) { - case DatabaseType.POSTGRESQL: + case DatabaseType.POSTGRESQL: { const pgVer = ctx.dbVersion || '16'; + const pgDatabase = ctx.appName.replace(/-/g, '_'); image = `postgres:${pgVer}-alpine`; port = 5432; dataPath = '/var/lib/postgresql/data'; envVars = [ { name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' }, - { name: 'POSTGRES_DB', value: ctx.appName.replace(/-/g, '_') }, + { name: 'POSTGRES_DB', value: pgDatabase }, { name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'username' } } }, { name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } }, ]; - readinessProbe = { exec: { command: ['pg_isready', '-U', ctx.dbUsername] }, initialDelaySeconds: 10, periodSeconds: 5, failureThreshold: 6 }; - livenessProbe = { exec: { command: ['pg_isready', '-U', ctx.dbUsername] }, initialDelaySeconds: 30, periodSeconds: 10, failureThreshold: 5 }; + const pgReady = ['pg_isready', '-U', ctx.dbUsername, '-d', pgDatabase]; + readinessProbe = { exec: { command: pgReady }, initialDelaySeconds: 10, periodSeconds: 5, failureThreshold: 6 }; + livenessProbe = { exec: { command: pgReady }, initialDelaySeconds: 30, periodSeconds: 10, failureThreshold: 5 }; break; + } case DatabaseType.MYSQL: const mysqlVer = ctx.dbVersion || '8.0'; @@ -1255,6 +1463,7 @@ export class KubernetesService implements OnModuleInit { name: string, size: string, ): Promise { + const storageClass = this.configService.get('platform.storageClass'); const pvc: k8s.V1PersistentVolumeClaim = { apiVersion: 'v1', kind: 'PersistentVolumeClaim', @@ -1262,6 +1471,7 @@ export class KubernetesService implements OnModuleInit { spec: { accessModes: ['ReadWriteOnce'], resources: { requests: { storage: size } }, + ...(storageClass ? { storageClassName: storageClass } : {}), }, }; @@ -1520,9 +1730,26 @@ export class KubernetesService implements OnModuleInit { this.logger.log(`RabbitMQ deployed for ${ctx.appName}`); } + private primaryWorkloadLabel(app: Application): string { + if (isManagedProductType(app.productType)) { + switch (app.productType) { + case ProductType.MANAGED_DATABASE: + return `${app.name}-db`; + case ProductType.MANAGED_REDIS: + return `${app.name}-redis`; + case ProductType.MANAGED_RABBITMQ: + return `${app.name}-rabbitmq`; + default: + break; + } + } + return app.name; + } + async getPodLogs(app: Application): Promise { const { coreApi } = await this.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; + const podLabel = this.primaryWorkloadLabel(app); const pods = await coreApi.listNamespacedPod( namespace, @@ -1530,7 +1757,7 @@ export class KubernetesService implements OnModuleInit { undefined, undefined, undefined, - `app=${app.name}`, + `app=${podLabel}`, ); if (pods.body.items.length === 0) { @@ -1575,9 +1802,12 @@ export class KubernetesService implements OnModuleInit { /** All K8s Deployments that belong to an application stack (default replica targets). */ private getApplicationWorkloadDeployments(app: Application): { name: string; runningReplicas: number }[] { - const workloads: { name: string; runningReplicas: number }[] = [ - { name: app.name, runningReplicas: app.replicas || 1 }, - ]; + const managed = isManagedProductType(app.productType); + const workloads: { name: string; runningReplicas: number }[] = []; + + if (!managed) { + workloads.push({ name: app.name, runningReplicas: app.replicas || 1 }); + } if (app.databaseType && app.databaseType !== DatabaseType.NONE) { workloads.push({ name: `${app.name}-db`, runningReplicas: 1 }); @@ -1705,9 +1935,12 @@ export class KubernetesService implements OnModuleInit { async restartDeployment(app: Application): Promise { const { appsApi } = await this.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; + const deploymentName = isManagedProductType(app.productType) + ? this.primaryWorkloadLabel(app) + : app.name; await appsApi.patchNamespacedDeployment( - app.name, + deploymentName, namespace, { spec: { @@ -2965,50 +3198,331 @@ export class KubernetesService implements OnModuleInit { return { success: true, logs }; } + private isPvcResizeForbiddenError(err: unknown): boolean { + const msg = + (err as { body?: { message?: string }; message?: string })?.body?.message || + (err as Error)?.message || + ''; + return /forbidden|resize|storageclass|dynamically provisioned/i.test(msg); + } + + private async resolvePvcStorageClassName( + coreApi: k8s.CoreV1Api, + pvc: k8s.V1PersistentVolumeClaim, + ): Promise { + let scName = pvc.spec?.storageClassName; + if (scName) return scName; + const volumeName = pvc.spec?.volumeName; + if (!volumeName) return undefined; + try { + const pv = await coreApi.readPersistentVolume(volumeName); + scName = pv.body.spec?.storageClassName; + if (pv.body.spec?.hostPath || pv.body.spec?.nfs || pv.body.spec?.local) { + return undefined; + } + return scName; + } catch { + return undefined; + } + } + + private async ensureStorageClassAllowsExpansion( + kc: k8s.KubeConfig, + storageClassName: string, + ): Promise<{ ok: boolean; message?: string }> { + const storageApi = kc.makeApiClient(k8s.StorageV1Api); + try { + const sc = await storageApi.readStorageClass(storageClassName); + if (sc.body.allowVolumeExpansion) { + return { ok: true }; + } + await storageApi.patchStorageClass( + storageClassName, + { allowVolumeExpansion: true }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } }, + ); + this.logger.log(`Enabled allowVolumeExpansion on StorageClass ${storageClassName}`); + return { ok: true }; + } catch (e: any) { + return { + ok: false, + message: `StorageClass "${storageClassName}" does not support expansion: ${e.message}`, + }; + } + } + + private async patchPvcStorageSize( + coreApi: k8s.CoreV1Api, + pvcName: string, + namespace: string, + newSize: string, + ): Promise { + await coreApi.patchNamespacedPersistentVolumeClaim( + pvcName, + namespace, + [{ op: 'replace', path: '/spec/resources/requests/storage', value: newSize }], + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/json-patch+json' } }, + ); + } + + /** + * Migrate DB PVC to a resize-capable StorageClass (one-time copy). + * Used when legacy PVCs were created without storageClassName. + */ + private async migrateDatabasePvcToResizableStorage( + app: Application, + newSize: string, + storageClassName: string, + ): Promise<{ success: boolean; message: string }> { + const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId); + const batchApi = kc.makeApiClient(k8s.BatchV1Api); + const namespace = `user-${app.userId.split('-')[0]}`; + const oldPvcName = `${app.name}-db`; + const newPvcName = `${app.name}-db-resizable`; + const deploymentName = `${app.name}-db`; + + try { + try { + await coreApi.readNamespacedPersistentVolumeClaim(newPvcName, namespace); + } catch { + await coreApi.createNamespacedPersistentVolumeClaim(namespace, { + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { name: newPvcName, namespace }, + spec: { + accessModes: ['ReadWriteOnce'], + storageClassName, + resources: { requests: { storage: newSize } }, + }, + }); + } + + await appsApi.patchNamespacedDeployment( + deploymentName, + namespace, + { spec: { replicas: 0 } }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } }, + ); + await this.waitForDeploymentReplicas(appsApi, namespace, deploymentName, 0, 120_000); + + const jobName = `${app.name}-pvc-migrate-${Date.now()}`; + await batchApi.createNamespacedJob(namespace, { + apiVersion: 'batch/v1', + kind: 'Job', + metadata: { name: jobName, namespace }, + spec: { + ttlSecondsAfterFinished: 300, + backoffLimit: 1, + template: { + spec: { + restartPolicy: 'Never', + containers: [ + { + name: 'copy', + image: 'busybox:1.36', + command: [ + 'sh', + '-c', + 'set -e; mkdir -p /dest; if [ -d /src ] && [ "$(ls -A /src 2>/dev/null)" ]; then cp -a /src/. /dest/; fi; touch /dest/.cloudhost-migrated', + ], + volumeMounts: [ + { name: 'src', mountPath: '/src', readOnly: true }, + { name: 'dest', mountPath: '/dest' }, + ], + }, + ], + volumes: [ + { name: 'src', persistentVolumeClaim: { claimName: oldPvcName } }, + { name: 'dest', persistentVolumeClaim: { claimName: newPvcName } }, + ], + }, + }, + }, + }); + + const jobOk = await this.waitForJobComplete(batchApi, coreApi, namespace, jobName, 600_000); + if (!jobOk) { + return { + success: false, + message: 'Storage migration job failed or timed out. Database was scaled down; check cluster jobs.', + }; + } + + const depRes = await appsApi.readNamespacedDeployment(deploymentName, namespace); + const existingVolumes = depRes.body.spec?.template?.spec?.volumes || []; + const updatedVolumes = existingVolumes.map((vol) => { + if (vol.name === 'db-storage' && vol.persistentVolumeClaim) { + return { ...vol, persistentVolumeClaim: { claimName: newPvcName } }; + } + return vol; + }); + if (!updatedVolumes.some((v) => v.name === 'db-storage')) { + updatedVolumes.push({ + name: 'db-storage', + persistentVolumeClaim: { claimName: newPvcName }, + }); + } + + await appsApi.patchNamespacedDeployment( + deploymentName, + namespace, + { + spec: { + replicas: 1, + template: { spec: { volumes: updatedVolumes } }, + }, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } }, + ); + + try { + await coreApi.deleteNamespacedPersistentVolumeClaim(oldPvcName, namespace); + } catch { + this.logger.warn(`Could not delete old PVC ${oldPvcName} after migration`); + } + + this.logger.log(`Migrated database PVC ${oldPvcName} → ${newPvcName} (${newSize})`); + return { + success: true, + message: `Database storage migrated to expandable disk and set to ${newSize}. A brief restart was required.`, + }; + } catch (e: any) { + this.logger.error(`PVC migration failed for ${app.name}: ${e.message}`); + try { + await appsApi.patchNamespacedDeployment( + deploymentName, + namespace, + { spec: { replicas: 1 } }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } }, + ); + } catch {} + return { + success: false, + message: e.body?.message || e.message || 'Failed to migrate database storage', + }; + } + } + + private async waitForDeploymentReplicas( + appsApi: k8s.AppsV1Api, + namespace: string, + name: string, + target: number, + timeoutMs: number, + ): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const dep = await appsApi.readNamespacedDeployment(name, namespace); + const ready = dep.body.status?.readyReplicas ?? 0; + const replicas = dep.body.spec?.replicas ?? 0; + if (target === 0 && replicas === 0) return true; + if (target > 0 && ready >= target && replicas >= target) return true; + } catch {} + await new Promise((r) => setTimeout(r, 3000)); + } + return false; + } + + private async waitForJobComplete( + batchApi: k8s.BatchV1Api, + coreApi: k8s.CoreV1Api, + namespace: string, + jobName: string, + timeoutMs: number, + ): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const job = await batchApi.readNamespacedJob(jobName, namespace); + const succeeded = job.body.status?.succeeded ?? 0; + const failed = job.body.status?.failed ?? 0; + if (succeeded > 0) return true; + if (failed > 0) return false; + } catch {} + await new Promise((r) => setTimeout(r, 3000)); + } + return false; + } + /** * Resize (expand) the database PVC for an application. * K8s only supports PVC expansion, not shrinking. */ async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> { - const { coreApi } = await this.getK8sClient(app.clusterId); + const { coreApi, kc } = await this.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; const pvcName = `${app.name}-db`; try { - // Read current PVC to check current size const currentPvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace); const currentSize = currentPvc.body.spec?.resources?.requests?.storage || '1Gi'; - const currentGi = parseInt(currentSize.replace('Gi', ''), 10) || 1; - const newGi = parseInt(newSize.replace('Gi', ''), 10) || 1; + const currentGi = parseInt(String(currentSize).replace(/Gi/i, ''), 10) || 1; + const newGi = parseInt(String(newSize).replace(/Gi/i, ''), 10) || 1; if (newGi <= currentGi) { return { success: false, message: `New size (${newSize}) must be larger than current size (${currentSize})` }; } - // Patch PVC to expand - const patch = [ - { - op: 'replace', - path: '/spec/resources/requests/storage', - value: newSize, - }, - ]; + const scName = await this.resolvePvcStorageClassName(coreApi, currentPvc.body); + if (scName) { + const scCheck = await this.ensureStorageClassAllowsExpansion(kc, scName); + if (!scCheck.ok) { + return { success: false, message: scCheck.message || 'StorageClass does not allow expansion' }; + } + } - await coreApi.patchNamespacedPersistentVolumeClaim( - pvcName, - namespace, - patch, - undefined, - undefined, - undefined, - undefined, - undefined, - { headers: { 'Content-Type': 'application/json-patch+json' } }, - ); - - this.logger.log(`Resized PVC ${pvcName} from ${currentSize} to ${newSize}`); - return { success: true, message: `Database storage expanded from ${currentSize} to ${newSize}` }; + try { + await this.patchPvcStorageSize(coreApi, pvcName, namespace, newSize); + this.logger.log(`Resized PVC ${pvcName} from ${currentSize} to ${newSize}`); + return { success: true, message: `Database storage expanded from ${currentSize} to ${newSize}` }; + } catch (patchErr: any) { + if (!this.isPvcResizeForbiddenError(patchErr)) { + throw patchErr; + } + const targetSc = this.configService.get('platform.storageClass'); + if (!targetSc) { + return { + success: false, + message: + 'This disk cannot be expanded in place. Set PLATFORM_STORAGE_CLASS (e.g. cloudhost-expandable) and redeploy, or contact support.', + }; + } + if (pvcName.endsWith('-resizable')) { + return { + success: false, + message: patchErr.body?.message || patchErr.message || 'Failed to resize database storage', + }; + } + this.logger.warn(`In-place resize failed for ${pvcName}, migrating to StorageClass ${targetSc}`); + return this.migrateDatabasePvcToResizableStorage(app, newSize, targetSc); + } } catch (e: any) { this.logger.error(`Failed to resize PVC ${pvcName}: ${e.message}`); return { success: false, message: e.body?.message || e.message || 'Failed to resize database storage' }; @@ -3297,7 +3811,10 @@ export class KubernetesService implements OnModuleInit { * * Strategy: Run dump command, then sleep for 60s to allow exec retrieval. */ - async exportDatabaseDump(app: Application): Promise<{ data: Buffer | null; logs: string }> { + async exportDatabaseDump( + app: Application, + onProgress?: (percent: number) => void, + ): Promise<{ data: Buffer | null; logs: string }> { const { coreApi, kc } = await this.getK8sClient(app.clusterId); const batchApi = kc.makeApiClient(k8s.BatchV1Api); const namespace = `user-${app.userId.split('-')[0]}`; @@ -3358,6 +3875,9 @@ export class KubernetesService implements OnModuleInit { while (Date.now() - start < timeout) { await new Promise((r) => setTimeout(r, 3000)); + const elapsed = Date.now() - start; + const waitPct = Math.min(75, Math.round((elapsed / timeout) * 75)); + onProgress?.(10 + waitPct); try { const pods = await coreApi.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `job-name=${jobName}`); if (pods.body.items.length > 0) { @@ -3370,6 +3890,7 @@ export class KubernetesService implements OnModuleInit { const logRes = await coreApi.readNamespacedPodLog(podName, namespace, 'dump', false, undefined, undefined, undefined, undefined, undefined, 50); if (logRes.body?.includes('DUMP_DONE')) { dumpDone = true; + onProgress?.(88); break; } } catch {} @@ -3418,6 +3939,7 @@ export class KubernetesService implements OnModuleInit { if (chunks.length > 0) { dumpBuffer = Buffer.concat(chunks); + onProgress?.(95); this.logger.log(`DB dump retrieved: ${dumpBuffer.length} bytes`); } } catch (e: any) { diff --git a/backend/src/snapshots/entities/snapshot.entity.ts b/backend/src/snapshots/entities/snapshot.entity.ts index d206f4f..1f9d52c 100644 --- a/backend/src/snapshots/entities/snapshot.entity.ts +++ b/backend/src/snapshots/entities/snapshot.entity.ts @@ -63,6 +63,10 @@ export class AppSnapshot { @Column({ nullable: true }) errorMessage: string; + /** 0–100 while status is in_progress */ + @Column({ type: 'int', default: 0 }) + progress: number; + // ─── Relations ──────────────────────────────────── @ManyToOne(() => Application, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'applicationId' }) diff --git a/backend/src/snapshots/snapshots.service.ts b/backend/src/snapshots/snapshots.service.ts index 479d772..c6dc77c 100644 --- a/backend/src/snapshots/snapshots.service.ts +++ b/backend/src/snapshots/snapshots.service.ts @@ -1,4 +1,12 @@ -import { Injectable, Logger, NotFoundException, BadRequestException, Inject, forwardRef } from '@nestjs/common'; +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, + Inject, + forwardRef, + OnModuleInit, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ConfigService } from '@nestjs/config'; @@ -7,12 +15,12 @@ import * as path from 'path'; import { AppSnapshot, SnapshotType, SnapshotStatus } from './entities/snapshot.entity'; import { ApplicationsService } from '../applications/applications.service'; import { KubernetesService } from '../kubernetes/kubernetes.service'; -import { AppRuntime, DatabaseType } from '../common/enums'; +import { AppRuntime, DatabaseType, ProductType } from '../common/enums'; const MAX_SNAPSHOTS = 10; @Injectable() -export class SnapshotsService { +export class SnapshotsService implements OnModuleInit { private readonly logger = new Logger(SnapshotsService.name); constructor( @@ -24,6 +32,16 @@ export class SnapshotsService { private configService: ConfigService, ) {} + async onModuleInit(): Promise { + try { + await this.snapshotsRepo.query( + `ALTER TABLE snapshots ADD COLUMN IF NOT EXISTS progress INT NOT NULL DEFAULT 0`, + ); + } catch (e: any) { + this.logger.warn(`Could not ensure snapshots.progress column: ${e.message}`); + } + } + /** * Create a snapshot of the current state of an application. * Captures: source code zip, wp-content (for WordPress), and DB dump. @@ -41,6 +59,7 @@ export class SnapshotsService { createdBy: userId, type, status: SnapshotStatus.IN_PROGRESS, + progress: 0, label: label || `Snapshot ${new Date().toLocaleString()}`, imageTag: app.latestImageTag || undefined, hasDatabase: app.databaseType !== DatabaseType.NONE, @@ -80,46 +99,71 @@ export class SnapshotsService { return saved; } + private async setSnapshotProgress(snapshotId: string, progress: number): Promise { + try { + await this.snapshotsRepo.update(snapshotId, { + progress: Math.min(100, Math.max(0, progress)), + }); + } catch (e: any) { + this.logger.debug(`Snapshot progress update skipped: ${e.message}`); + } + } + private async captureSnapshot(snapshotId: string, app: any): Promise { const uploadDir = this.configService.get('platform.uploadDir') || './uploads'; const snapshotDir = path.join(uploadDir, app.userId, app.id, 'snapshots', snapshotId); fs.mkdirSync(snapshotDir, { recursive: true }); const updates: Partial = {}; + const managedDbOnly = app.productType === ProductType.MANAGED_DATABASE; try { - // 1. Copy current source code zip - if (app.codePath && fs.existsSync(app.codePath)) { - const destPath = path.join(snapshotDir, 'source.zip'); - fs.copyFileSync(app.codePath, destPath); - updates.appArchivePath = destPath; - updates.appArchiveSize = fs.statSync(destPath).size; - this.logger.log(`Snapshot ${snapshotId}: copied source code (${(updates.appArchiveSize / 1024).toFixed(1)} KB)`); - } + await this.setSnapshotProgress(snapshotId, 5); - // 2. Archive wp-content for WordPress apps - if (app.runtime === AppRuntime.WORDPRESS) { - try { - const { data, logs } = await this.kubernetesService.archiveWpContent(app); - if (data && data.length > 0) { - const wpPath = path.join(snapshotDir, 'wp-content.tar.gz'); - fs.writeFileSync(wpPath, data); - updates.wpContentArchivePath = wpPath; - updates.wpContentSize = data.length; - this.logger.log(`Snapshot ${snapshotId}: archived wp-content (${(data.length / 1024).toFixed(1)} KB)`); - } else { - this.logger.warn(`Snapshot ${snapshotId}: wp-content archive empty — ${logs}`); + if (!managedDbOnly) { + // 1. Copy current source code zip + if (app.codePath && fs.existsSync(app.codePath)) { + await this.setSnapshotProgress(snapshotId, 12); + const destPath = path.join(snapshotDir, 'source.zip'); + fs.copyFileSync(app.codePath, destPath); + updates.appArchivePath = destPath; + updates.appArchiveSize = fs.statSync(destPath).size; + this.logger.log(`Snapshot ${snapshotId}: copied source code (${(updates.appArchiveSize / 1024).toFixed(1)} KB)`); + } + + // 2. Archive wp-content for WordPress apps + if (app.runtime === AppRuntime.WORDPRESS) { + await this.setSnapshotProgress(snapshotId, 18); + try { + const { data, logs } = await this.kubernetesService.archiveWpContent(app); + if (data && data.length > 0) { + const wpPath = path.join(snapshotDir, 'wp-content.tar.gz'); + fs.writeFileSync(wpPath, data); + updates.wpContentArchivePath = wpPath; + updates.wpContentSize = data.length; + this.logger.log(`Snapshot ${snapshotId}: archived wp-content (${(data.length / 1024).toFixed(1)} KB)`); + } else { + this.logger.warn(`Snapshot ${snapshotId}: wp-content archive empty — ${logs}`); + } + } catch (e: any) { + this.logger.warn(`Snapshot ${snapshotId}: wp-content archive failed — ${e.message}`); } - } catch (e: any) { - this.logger.warn(`Snapshot ${snapshotId}: wp-content archive failed — ${e.message}`); } } // 3. Dump database if (app.databaseType !== DatabaseType.NONE) { + await this.setSnapshotProgress(snapshotId, managedDbOnly ? 10 : 25); try { - const { data, logs } = await this.kubernetesService.exportDatabaseDump(app); + const mapDumpProgress = (dumpPct: number) => { + const base = managedDbOnly ? 10 : 25; + const end = managedDbOnly ? 95 : 90; + const t = Math.min(1, Math.max(0, (dumpPct - 10) / 85)); + void this.setSnapshotProgress(snapshotId, base + Math.round(t * (end - base))); + }; + const { data, logs } = await this.kubernetesService.exportDatabaseDump(app, mapDumpProgress); if (data && data.length > 0) { + await this.setSnapshotProgress(snapshotId, 92); const dbPath = path.join(snapshotDir, 'database.sql'); fs.writeFileSync(dbPath, data); updates.dbDumpPath = dbPath; @@ -127,16 +171,34 @@ export class SnapshotsService { this.logger.log(`Snapshot ${snapshotId}: dumped database (${(data.length / 1024).toFixed(1)} KB)`); } else { this.logger.warn(`Snapshot ${snapshotId}: DB dump empty — ${logs}`); + if (managedDbOnly) { + updates.status = SnapshotStatus.FAILED; + updates.errorMessage = logs || 'Database dump produced no data'; + updates.progress = 0; + await this.snapshotsRepo.update(snapshotId, updates); + await this.pruneSnapshots(app.id); + return; + } } } catch (e: any) { this.logger.warn(`Snapshot ${snapshotId}: DB dump failed — ${e.message}`); + if (managedDbOnly) { + updates.status = SnapshotStatus.FAILED; + updates.errorMessage = e.message; + updates.progress = 0; + await this.snapshotsRepo.update(snapshotId, updates); + await this.pruneSnapshots(app.id); + return; + } } } updates.status = SnapshotStatus.COMPLETED; + updates.progress = 100; } catch (error: any) { updates.status = SnapshotStatus.FAILED; updates.errorMessage = error.message; + updates.progress = 0; this.logger.error(`Snapshot ${snapshotId} failed: ${error.message}`); } diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index f58f8fb..b0a46e6 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -4,10 +4,13 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useRouter } from 'next/navigation'; import api from '@/lib/api'; import { toast } from 'react-toastify'; -import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget, OptionalServiceCredentials, Invoice } from '@/types'; +import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, OptionalServiceCredentials, Invoice } from '@/types'; import { useState, useRef, useCallback, useEffect } from 'react'; import NextLink from 'next/link'; -import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink, ScrollText } from 'lucide-react'; +import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ScrollText } from 'lucide-react'; +import { ServiceExternalAccessPanel } from '@/components/service-external-access-panel'; +import { WorkloadLogsPanel } from '@/components/workload-logs-panel'; +import { isApplicationProduct, isManagedProduct } from '@/lib/product-type'; import { useConfirm } from '@/components/confirm-modal'; import { useAuthStore } from '@/lib/store'; @@ -57,10 +60,7 @@ export default function AppDetailPage() { const appId = params.id as string; const user = useAuthStore((s) => s.user); const isAdmin = user?.role === 'admin'; - const [showLogs, setShowLogs] = useState(false); - const [logTab, setLogTab] = useState<'pod' | 'build'>('pod'); const fileInputRef = useRef(null); - const logsEndRef = useRef(null); const [uploadProgress, setUploadProgress] = useState(0); const [isDragging, setIsDragging] = useState(false); const [showResources, setShowResources] = useState(false); @@ -98,17 +98,19 @@ export default function AppDetailPage() { const [showDomainSetup, setShowDomainSetup] = useState(false); const [customDomainInput, setCustomDomainInput] = useState(''); - const [accessTarget, setAccessTarget] = useState('database'); - const [accessDuration, setAccessDuration] = useState(60); - const [showAccessSecret, setShowAccessSecret] = useState(false); const [showServiceSecrets, setShowServiceSecrets] = useState(false); - const [accessNow, setAccessNow] = useState(() => Date.now()); const { data: app, isLoading } = useQuery({ queryKey: ['application', appId], queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data), }); + useEffect(() => { + if (app && isManagedProduct(app)) { + router.replace(`/dashboard/services/${appId}`); + } + }, [app, appId, router]); + useEffect(() => { if (!app) return; if (scaleWorkload === 'database' && app.databaseType === 'none') setScaleWorkload('app'); @@ -128,20 +130,6 @@ export default function AppDetailPage() { enabled: !!app?.latestImageTag && (!!app?.enableRedis || !!app?.enableRabbitmq), }); - const { data: logsData } = useQuery<{ logs: string }>({ - queryKey: ['logs', appId], - queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data), - enabled: showLogs && logTab === 'pod', - refetchInterval: showLogs && logTab === 'pod' ? 3000 : false, - }); - - const { data: buildLogsData } = useQuery<{ buildLog: string | null; status: string; version: string | null }>({ - queryKey: ['build-logs', appId], - queryFn: () => api.get(`/deployments/applications/${appId}/build-logs`).then((r) => r.data), - enabled: showLogs && logTab === 'build', - refetchInterval: showLogs && logTab === 'build' ? 5000 : false, - }); - const { data: resourceUsage, isLoading: resourcesLoading } = useQuery({ queryKey: ['resources', appId], queryFn: () => api.get(`/applications/${appId}/resources`).then((r) => r.data), @@ -537,12 +525,6 @@ export default function AppDetailPage() { }, [resourceUsage, scaleWorkload, resourceFormDirty]); // Auto-scroll logs to bottom - useEffect(() => { - if (logsEndRef.current) { - logsEndRef.current.scrollTop = logsEndRef.current.scrollHeight; - } - }, [logsData]); - const invalidateAll = () => { queryClient.invalidateQueries({ queryKey: ['deployments', appId] }); queryClient.invalidateQueries({ queryKey: ['application', appId] }); @@ -589,7 +571,7 @@ export default function AppDetailPage() { mutationFn: () => api.post(`/deployments/applications/${appId}/redeploy`), onSuccess: () => { invalidateAll(); - toast.success('Redeploy triggered — building new version from latest source'); + toast.success('Redeploy started'); }, onError: () => toast.error('Failed to trigger redeploy'), }); @@ -700,77 +682,6 @@ export default function AppDetailPage() { onError: () => toast.error('Failed to get preview URL. Make sure the app is deployed.'), }); - const accessTargetOptions: { value: ServiceAccessTarget; label: string }[] = []; - if (app?.databaseType && app.databaseType !== 'none') { - accessTargetOptions.push({ value: 'database', label: 'Database' }); - } - if (app?.enableRedis) accessTargetOptions.push({ value: 'redis', label: 'Redis' }); - if (app?.enableRabbitmq) { - accessTargetOptions.push({ value: 'rabbitmq_amqp', label: 'RabbitMQ (AMQP)' }); - accessTargetOptions.push({ value: 'rabbitmq_management', label: 'RabbitMQ Management UI' }); - } - const hasAccessTargets = accessTargetOptions.length > 0; - - useEffect(() => { - if (!hasAccessTargets) return; - if (!accessTargetOptions.some((o) => o.value === accessTarget)) { - setAccessTarget(accessTargetOptions[0].value); - } - }, [app?.databaseType, app?.enableRedis, app?.enableRabbitmq]); - - const { data: accessGrants = [], refetch: refetchAccessGrants } = useQuery({ - queryKey: ['access-grants', appId], - queryFn: () => api.get(`/applications/${appId}/access`).then((r) => r.data), - enabled: hasAccessTargets && !!app?.latestImageTag, - refetchInterval: 30000, - }); - - useEffect(() => { - if (!accessGrants.some((g) => g.status === 'active')) return; - const t = setInterval(() => setAccessNow(Date.now()), 1000); - return () => clearInterval(t); - }, [accessGrants]); - - const createAccessMutation = useMutation({ - mutationFn: () => - api.post(`/applications/${appId}/access`, { - target: accessTarget, - durationMinutes: accessDuration, - }).then((r) => r.data), - onSuccess: () => { - refetchAccessGrants(); - toast.success('Temporary external access enabled'); - }, - onError: (err: any) => { - toast.error(err.response?.data?.message || 'Failed to enable access'); - }, - }); - - const revokeAccessMutation = useMutation({ - mutationFn: (grantId: string) => - api.delete(`/applications/${appId}/access/${grantId}`).then((r) => r.data), - onSuccess: () => { - refetchAccessGrants(); - toast.success('Access revoked'); - }, - onError: () => toast.error('Failed to revoke access'), - }); - - const accessTargetLabel = (target: ServiceAccessTarget) => - accessTargetOptions.find((o) => o.value === target)?.label || target; - - const formatAccessCountdown = (expiresAt: string) => { - const ms = new Date(expiresAt).getTime() - accessNow; - if (ms <= 0) return 'Expired'; - const totalSec = Math.floor(ms / 1000); - const h = Math.floor(totalSec / 3600); - const m = Math.floor((totalSec % 3600) / 60); - const s = totalSec % 60; - if (h > 0) return `${h}h ${m}m ${s}s`; - if (m > 0) return `${m}m ${s}s`; - return `${s}s`; - }; - const uploadMutation = useMutation({ mutationFn: (file: File) => { const formData = new FormData(); @@ -901,6 +812,10 @@ export default function AppDetailPage() { ); } + if (isManagedProduct(app)) { + return null; + } + const latestStatus = deployments[0]?.status || 'pending'; const hasDeployments = deployments.length > 0; const isStopped = latestStatus === 'stopped'; @@ -970,7 +885,7 @@ export default function AppDetailPage() { {restartMutation.isPending ? <> : <> Restart} )} - {!isInProgress && hasPaidAccess && ( + {!isInProgress && hasPaidAccess && isApplicationProduct(app) && ( @@ -1926,129 +1841,7 @@ export default function AppDetailPage() { )} - {/* Temporary External Access */} - {hasAccessTargets && ( -
-

- Temporary External Access -

-

- - Opens a NodePort on the cluster node IP. Anyone who can reach that IP may connect until the timer ends. Use short durations only. -

- - {!app?.latestImageTag ? ( -

Deploy the application first to enable external access.

- ) : ( - <> -
-
- - -
-
- -
- {[30, 60, 240].map((mins) => ( - - ))} -
-
- -
- - {accessGrants.filter((g) => g.status === 'active').length === 0 ? ( -

No active external access sessions.

- ) : ( -
- {accessGrants - .filter((g) => g.status === 'active') - .map((grant) => ( -
-
-
- {accessTargetLabel(grant.target)} - ends in {formatAccessCountdown(grant.expiresAt)} -
- -
-
-
- Endpoint -
- {grant.host}:{grant.port} - -
-
- {grant.connection.url && ( -
- URL -
- - {showAccessSecret ? grant.connection.url : '••••••••••••'} - - - -
-
- )} -
-
- ))} -
- )} - - )} -
- )} + {app && } {/* Resource Monitoring & Scaling */}
@@ -2863,94 +2656,12 @@ export default function AppDetailPage() { )}
- {/* Logs — Pod & Build */} -
-
-

Logs

-
- {showLogs && logTab === 'pod' && ( - - - Live (every 3s) - - )} - {showLogs && logTab === 'build' && ( - - - Auto-refresh (every 5s) - - )} - -
-
- {showLogs && ( -
- {/* Tab switcher */} -
- - -
- - {/* Pod logs */} - {logTab === 'pod' && ( -
-                {logsData?.logs || (isRunning ? 'Loading logs...' : isStopped ? 'Application is stopped. Start it to see logs.' : 'Waiting for pod to be ready...')}
-              
- )} - - {/* Build logs */} - {logTab === 'build' && ( -
- {buildLogsData?.version && ( -
- {buildLogsData.version} - - {buildLogsData.status} - -
- )} -
-                  {buildLogsData?.buildLog || (
-                    buildLogsData?.status === 'building'
-                      ? 'Build in progress... Logs will appear when complete.'
-                      : buildLogsData?.status === 'pending'
-                        ? 'Build is pending...'
-                        : buildLogsData?.status === 'no_deployment'
-                          ? 'No deployments yet. Deploy your app to see build logs.'
-                          : 'No build logs available for this deployment.'
-                  )}
-                
-
- )} -
- )} -
+ ); } diff --git a/frontend/src/app/dashboard/apps/page.tsx b/frontend/src/app/dashboard/apps/page.tsx index 61630ab..de47ab1 100644 --- a/frontend/src/app/dashboard/apps/page.tsx +++ b/frontend/src/app/dashboard/apps/page.tsx @@ -7,6 +7,7 @@ import { toast } from 'react-toastify'; import type { Application } from '@/types'; import { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; +import { filterApplications } from '@/lib/product-type'; const statusColors: Record = { running: 'badge-green', @@ -51,10 +52,11 @@ export default function AppsPage() { const queryClient = useQueryClient(); const confirm = useConfirm(); - const { data: apps = [], isLoading } = useQuery({ - queryKey: ['applications'], - queryFn: () => api.get('/applications').then((r) => r.data), + const { data: appsRaw = [], isLoading } = useQuery({ + queryKey: ['applications', 'application'], + queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data), }); + const apps = filterApplications(appsRaw); const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete(`/applications/${id}`), diff --git a/frontend/src/app/dashboard/layout.tsx b/frontend/src/app/dashboard/layout.tsx index 9f64ad9..38ad25d 100644 --- a/frontend/src/app/dashboard/layout.tsx +++ b/frontend/src/app/dashboard/layout.tsx @@ -28,6 +28,7 @@ import { CreditCard, ScrollText, FileText, + Database, } from 'lucide-react'; type NavItem = { href: string; label: string; icon: ReactNode }; @@ -35,6 +36,7 @@ type NavItem = { href: string; label: string; icon: ReactNode }; const userNavItems: NavItem[] = [ { href: '/dashboard', label: 'Dashboard', icon: }, { href: '/dashboard/apps', label: 'Applications', icon: }, + { href: '/dashboard/services', label: 'Databases & Services', icon: }, { href: '/dashboard/logs', label: 'Logs', icon: }, { href: '/dashboard/deploy', label: 'New Deploy', icon: }, { href: '/dashboard/wallet', label: 'Wallet', icon: }, diff --git a/frontend/src/app/dashboard/logs/page.tsx b/frontend/src/app/dashboard/logs/page.tsx index f47ae30..fd0e944 100644 --- a/frontend/src/app/dashboard/logs/page.tsx +++ b/frontend/src/app/dashboard/logs/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, Suspense } from 'react'; +import { useState, useEffect, useMemo, Suspense } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useSearchParams } from 'next/navigation'; import api from '@/lib/api'; @@ -73,10 +73,42 @@ function LogsPageContent() { }); const { data: applications = [] } = useQuery({ - queryKey: ['applications'], - queryFn: () => api.get('/applications').then((r) => r.data), + queryKey: ['applications', 'application'], + queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data), }); + const { data: managedServices = [] } = useQuery({ + queryKey: ['applications', 'managed'], + queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data), + }); + + const selectedManaged = useMemo( + () => managedServices.find((s) => s.id === appId), + [managedServices, appId], + ); + + const workloadOptions = useMemo(() => { + if (!appId || !selectedManaged) { + return WORKLOADS; + } + const opts: { value: string; label: string }[] = [{ value: '', label: 'All sources' }]; + if (selectedManaged.productType === 'managed_database') { + opts.push({ value: 'database', label: 'Database' }); + } else if (selectedManaged.productType === 'managed_redis') { + opts.push({ value: 'redis', label: 'Redis' }); + } else if (selectedManaged.productType === 'managed_rabbitmq') { + opts.push({ value: 'rabbitmq', label: 'RabbitMQ' }); + } + return opts; + }, [appId, selectedManaged]); + + useEffect(() => { + if (!selectedManaged) return; + if (workload === 'app') { + setWorkload(''); + } + }, [selectedManaged, workload]); + const buildTimeRange = () => { const now = new Date(); const from = new Date(); @@ -163,7 +195,7 @@ function LogsPageContent() {
- +
@@ -190,7 +235,7 @@ function LogsPageContent() { }} className="input w-full text-sm" > - {WORKLOADS.map((w) => ( + {workloadOptions.map((w) => ( diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 180616f..20111b0 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -6,8 +6,10 @@ import type { ReactNode } from 'react'; import api from '@/lib/api'; import { useAuthStore } from '@/lib/store'; import type { Application } from '@/types'; -import { Rocket, Package, Circle, Hexagon, Wallet, Clock } from 'lucide-react'; +import { Rocket, Package, Circle, Hexagon, Wallet, Clock, Database, Plus } from 'lucide-react'; import type { ResourceCredit } from '@/types'; +import { managedServiceTypeLabel } from '@/lib/optional-service-defaults'; +import { filterApplications, filterManagedServices } from '@/lib/product-type'; const statusColors: Record = { running: 'bg-emerald-100 text-emerald-700', @@ -31,36 +33,61 @@ const statusIcons: Record = { stopped: , }; +function countRunning(apps: Application[]) { + return apps.filter((a) => a.deployments?.some((d) => d.status === 'running')).length; +} + +function countFailed(apps: Application[]) { + return apps.filter((a) => + a.deployments?.some((d) => d.status === 'failed' || d.status === 'build_failed'), + ).length; +} + +function serviceSubtitle(app: Application): string { + if (app.productType === 'managed_database') { + return `${app.databaseType}${app.dbVersion ? ` v${app.dbVersion}` : ''}`; + } + if (app.productType === 'managed_redis') { + return `Redis${app.redisVersion ? ` v${app.redisVersion}` : ''}`; + } + if (app.productType === 'managed_rabbitmq') { + return `RabbitMQ${app.rabbitmqVersion ? ` v${app.rabbitmqVersion}` : ''}`; + } + return '—'; +} + export default function DashboardPage() { const user = useAuthStore((s) => s.user); - const { data: apps = [], isLoading } = useQuery({ - queryKey: ['applications'], - queryFn: () => api.get('/applications').then((r) => r.data), + const { data: appsRaw = [], isLoading: appsLoading } = useQuery({ + queryKey: ['applications', 'application'], + queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data), }); + const { data: servicesRaw = [], isLoading: servicesLoading } = useQuery({ + queryKey: ['applications', 'managed'], + queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data), + }); + + const apps = filterApplications(appsRaw); + const services = filterManagedServices(servicesRaw); + const isLoading = appsLoading || servicesLoading; + const { data: resourceCredits = [] } = useQuery({ queryKey: ['resource-credits'], queryFn: () => api.get('/billing/resource-credits').then((r) => r.data), }); - const runningApps = apps.filter( - (a) => a.deployments?.some((d) => d.status === 'running'), - ); - const failedApps = apps.filter( - (a) => a.deployments?.some((d) => d.status === 'failed' || d.status === 'build_failed'), - ); + const runningApps = countRunning(apps); + const failedApps = countFailed(apps); + const runningServices = countRunning(services); + const failedServices = countFailed(services); return (
- {/* Welcome */}
-

- Welcome back, {user?.firstName}! -

-

- Here's an overview of your applications. -

+

Welcome back, {user?.firstName}!

+

Overview of your applications and managed services.

{resourceCredits.length > 0 && ( @@ -70,7 +97,8 @@ export default function DashboardPage() {

Prepaid resource credits

- If you delete an app before your plan ends, you can deploy a new app with the same resources at no extra charge until the credit expires. + If you delete an app before your plan ends, you can deploy a new app with the same resources at no + extra charge until the credit expires.

@@ -104,58 +132,59 @@ export default function DashboardPage() {
)} - {/* Stats */}
-
Total Apps
+
Applications
{isLoading ? '—' : apps.length}
-
Running
-
{isLoading ? '—' : runningApps.length}
+
Apps running
+
{isLoading ? '—' : runningApps}
-
Failed
-
{isLoading ? '—' : failedApps.length}
+
Managed services
+
{isLoading ? '—' : services.length}
-
Total Deploys
-
- {isLoading ? '—' : apps.reduce((sum, a) => sum + (a.deployments?.length || 0), 0)} -
+
Services running
+
{isLoading ? '—' : runningServices}
- {/* Recent Applications */} + {(failedApps > 0 || failedServices > 0) && ( +
+ {failedApps > 0 && `${failedApps} application${failedApps !== 1 ? 's' : ''} failed`} + {failedApps > 0 && failedServices > 0 && ' · '} + {failedServices > 0 && `${failedServices} service${failedServices !== 1 ? 's' : ''} failed`} +
+ )} + + {/* Applications */}
-

Recent Applications

+

Recent applications

- New Application + New application
- {isLoading ? ( + {appsLoading ? (
- {[1, 2, 3].map((i) => ( + {[1, 2].map((i) => (
-
))}
) : apps.length === 0 ? ( -
- -

No applications yet

-

- Deploy your first application to get started. -

- +
+ +

No applications yet

+ Deploy your first app
@@ -170,14 +199,14 @@ export default function DashboardPage() { href={`/dashboard/apps/${app.id}`} className="card-hover flex items-center justify-between group" > -
+
- +
-

- {app.name} -

+

{app.name}

{app.runtime} · {app.replicas} replica{app.replicas > 1 ? 's' : ''} {app.databaseType !== 'none' && ` · ${app.databaseType}`} @@ -186,24 +215,89 @@ export default function DashboardPage() {

{statusIcons[status]} - - {status} - + {status}
); })} {apps.length > 5 && ( - + View all {apps.length} applications → )}
)}
+ + {/* Managed services */} +
+
+

Recent databases & services

+ + New service + +
+ + {servicesLoading ? ( +
+ {[1, 2].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : services.length === 0 ? ( +
+ +

No managed services yet

+ + Create database or service + +
+ ) : ( +
+ {services.slice(0, 5).map((svc) => { + const latestDeploy = svc.deployments?.[0]; + const status = latestDeploy?.status || 'pending'; + return ( + +
+
+ +
+
+

{svc.name}

+

+ {managedServiceTypeLabel(svc.productType)} · {serviceSubtitle(svc)} +

+
+
+
+ {statusIcons[status]} + {status} +
+ + ); + })} + {services.length > 5 && ( + + View all {services.length} services → + + )} +
+ )} +
); } diff --git a/frontend/src/app/dashboard/services/[id]/page.tsx b/frontend/src/app/dashboard/services/[id]/page.tsx new file mode 100644 index 0000000..770a74e --- /dev/null +++ b/frontend/src/app/dashboard/services/[id]/page.tsx @@ -0,0 +1,701 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '@/lib/api'; +import { toast } from 'react-toastify'; +import type { + Application, + Deployment, + OptionalServiceCredentials, + OptionalServiceResourcesMap, +} from '@/types'; +import { managedServiceTypeLabel } from '@/lib/optional-service-defaults'; +import { + ArrowLeft, + Database, + AlertTriangle, + Wallet, + CreditCard, + Copy, + Check, + Eye, + EyeOff, + Rocket, + RefreshCw, + Clock, + KeyRound, + RotateCw, + Package, + XCircle, +} from 'lucide-react'; +import { useConfirm } from '@/components/confirm-modal'; +import { ServiceExternalAccessPanel } from '@/components/service-external-access-panel'; +import { WorkloadLogsPanel } from '@/components/workload-logs-panel'; +import { ManagedServiceResourcesPanel } from '@/components/managed-service-resources-panel'; +import { DatabaseSnapshotsPanel } from '@/components/database-snapshots-panel'; + +const statusColors: Record = { + running: 'badge-green', + pending: 'badge-yellow', + building: 'badge-blue', + deploying: 'badge-blue', + failed: 'badge-red', + build_failed: 'badge-red', + cancelled: 'badge-gray', + stopped: 'badge-gray', +}; + +function dbPort(databaseType: string): string { + if (databaseType === 'postgresql') return '5432'; + if (databaseType === 'mongodb') return '27017'; + return '3306'; +} + +type AppWithOptional = Application & { optionalServiceResources?: OptionalServiceResourcesMap }; + +export default function ManagedServiceDetailPage() { + const params = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const confirm = useConfirm(); + const serviceId = params.id as string; + + const [showDbPassword, setShowDbPassword] = useState(false); + const [copiedField, setCopiedField] = useState(null); + const [showRenewalModal, setShowRenewalModal] = useState(false); + const [selectedCycle, setSelectedCycle] = useState<'hourly' | 'monthly' | 'yearly'>('monthly'); + const [showServiceSecrets, setShowServiceSecrets] = useState(false); + + const { data: app, isLoading } = useQuery({ + queryKey: ['application', serviceId], + queryFn: () => api.get(`/applications/${serviceId}`).then((r) => r.data), + }); + + const { data: deployments = [] } = useQuery({ + queryKey: ['deployments', serviceId], + queryFn: () => api.get(`/deployments/applications/${serviceId}`).then((r) => r.data), + refetchInterval: 5000, + }); + + const { data: serviceCredentials } = useQuery({ + queryKey: ['service-credentials', serviceId], + queryFn: () => api.get(`/applications/${serviceId}/service-credentials`).then((r) => r.data), + enabled: + !!app && + (app.productType === 'managed_redis' || + app.productType === 'managed_rabbitmq' || + !!app.enableRedis || + !!app.enableRabbitmq), + }); + + const { data: walletData } = useQuery<{ balance: number }>({ + queryKey: ['wallet'], + queryFn: () => api.get('/billing/wallet').then((r) => r.data), + }); + + const { data: renewalCostData } = useQuery<{ + costs: { hourly: number; monthly: number; yearly: number }; + }>({ + queryKey: ['renewal-cost', serviceId], + queryFn: () => api.get(`/billing/applications/${serviceId}/renewal-cost`).then((r) => r.data), + enabled: + showRenewalModal || + app?.lifecycleStatus === 'suspended' || + app?.lifecycleStatus === 'pending_deletion', + }); + + const needsRenewal = + app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion'; + + const deployMutation = useMutation({ + mutationFn: () => api.post(`/deployments/applications/${serviceId}/deploy`), + onSuccess: () => { + toast.success('Provisioning started'); + queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] }); + queryClient.invalidateQueries({ queryKey: ['application', serviceId] }); + }, + onError: () => toast.error('Failed to start provisioning'), + }); + + const redeployMutation = useMutation({ + mutationFn: () => api.post(`/deployments/applications/${serviceId}/redeploy`), + onSuccess: () => { + toast.success('Re-provisioning started'); + queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] }); + queryClient.invalidateQueries({ queryKey: ['application', serviceId] }); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Failed to re-provision service'); + }, + }); + + const restartMutation = useMutation({ + mutationFn: () => api.post(`/deployments/applications/${serviceId}/restart`), + onSuccess: () => toast.success('Service restarted'), + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Failed to restart service'); + }, + }); + + const renewMutation = useMutation({ + mutationFn: (cycle: string) => api.post(`/billing/applications/${serviceId}/renew`, { cycle }), + onSuccess: () => { + toast.success('Service renewed'); + queryClient.invalidateQueries({ queryKey: ['application', serviceId] }); + setShowRenewalModal(false); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Renewal failed'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: () => api.delete(`/applications/${serviceId}`), + onSuccess: (res) => { + queryClient.invalidateQueries({ queryKey: ['applications', 'managed'] }); + queryClient.invalidateQueries({ queryKey: ['resource-credits'] }); + if (res.data?.resourceCredit) { + toast.success('Service deleted. Prepaid resources are on your dashboard.'); + } else { + toast.success('Service deleted'); + } + router.push('/dashboard/services'); + }, + onError: () => toast.error('Failed to delete'), + }); + + const copyToClipboard = useCallback((text: string, field: string) => { + navigator.clipboard.writeText(text); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 2000); + }, []); + + useEffect(() => { + if (app && (app.productType === 'application' || !app.productType)) { + router.replace(`/dashboard/apps/${serviceId}`); + } + }, [app, router, serviceId]); + + if (isLoading || !app) { + return ( +
+
+
+
+ ); + } + + if (app.productType === 'application' || !app.productType) { + return null; + } + + const latestStatus = deployments[0]?.status || 'pending'; + const isDeployed = !!app.latestImageTag; + const isInProgress = latestStatus === 'building' || latestStatus === 'deploying' || latestStatus === 'pending'; + const isRunning = latestStatus === 'running'; + const isStopped = latestStatus === 'stopped'; + const renewalCost = + selectedCycle === 'hourly' + ? renewalCostData?.costs.hourly + : selectedCycle === 'yearly' + ? renewalCostData?.costs.yearly + : renewalCostData?.costs.monthly; + + const optionalRes = + app.productType === 'managed_redis' + ? app.optionalServiceResources?.redis + : app.productType === 'managed_rabbitmq' + ? app.optionalServiceResources?.rabbitmq + : null; + + const cpuDisplay = + app.productType === 'managed_database' + ? `${app.cpuRequest || '100m'} / ${app.cpuLimit || '500m'}` + : optionalRes + ? `${optionalRes.cpuRequest} / ${optionalRes.cpuLimit}` + : '—'; + + const memDisplay = + app.productType === 'managed_database' + ? `${app.memoryRequest || '128Mi'} / ${app.memoryLimit || '512Mi'}` + : optionalRes + ? `${optionalRes.memoryRequest} / ${optionalRes.memoryLimit}` + : '—'; + + return ( +
+
+
+ + + +
+

{app.name}

+

+ {managedServiceTypeLabel(app.productType)} + {app.dbVersion ? ` · v${app.dbVersion}` : ''} + {app.redisVersion && app.productType === 'managed_redis' ? ` · v${app.redisVersion}` : ''} + {app.rabbitmqVersion && app.productType === 'managed_rabbitmq' + ? ` · v${app.rabbitmqVersion}` + : ''} +

+
+
+
+ + {latestStatus} + + {!isDeployed && ( + + )} + {isDeployed && !needsRenewal && !isInProgress && ( + + )} + {isDeployed && isRunning && ( + + )} + +
+
+ + {needsRenewal && ( +
+
+ +
+

Payment required

+

Renew to restore this service.

+
+
+ +
+ )} + + {app.planExpiresAt && ( +
+ + Plan expires: {new Date(app.planExpiresAt).toLocaleString()} + {app.billingCycle && ` (${app.billingCycle})`} +
+ )} + +
+
+

Configuration

+
+
+
Service type
+
{managedServiceTypeLabel(app.productType)}
+
+ {app.productType === 'managed_database' && ( + <> +
+
Database engine
+
+ {app.databaseType} + {app.databaseType !== 'none' && app.dbVersion ? ` v${app.dbVersion}` : ''} +
+
+ {app.databaseType !== 'none' && ( +
+
Storage
+
{app.dbStorageSize || '1Gi'}
+
+ )} + + )} + {app.productType === 'managed_redis' && app.redisVersion && ( +
+
Redis version
+
v{app.redisVersion}
+
+ )} + {app.productType === 'managed_rabbitmq' && app.rabbitmqVersion && ( +
+
RabbitMQ version
+
v{app.rabbitmqVersion}
+
+ )} +
+
CPU
+
{cpuDisplay}
+
+
+
Memory
+
{memDisplay}
+
+ {app.billingCycle && ( +
+
Billing
+
{app.billingCycle}
+
+ )} + {app.latestImageTag && ( +
+
Deploy marker
+
+ {app.latestImageTag} +
+
+ )} +
+
+ +
+

Deployment History

+ {deployments.length === 0 ? ( +
+ +

No deployments yet

+

Click Deploy to provision this service

+
+ ) : ( +
+ {deployments.slice(0, 10).map((d) => ( +
+
+

{d.version || d.imageTag || 'Provision'}

+

{new Date(d.createdAt).toLocaleString()}

+ {d.errorMessage && ( +

+ {d.errorMessage} +

+ )} +
+ + {d.status} + +
+ ))} +
+ )} +
+
+ + {app.productType === 'managed_database' && app.databaseType !== 'none' && ( +
+

+ Connection + {app.databaseType} +

+
+

Internal cluster

+ {[ + { label: 'Host', value: `${app.name}-db`, field: 'host' }, + { label: 'Port', value: dbPort(app.databaseType), field: 'port' }, + { label: 'Database', value: app.name.replace(/-/g, '_'), field: 'database' }, + { label: 'Username', value: app.dbUsername || 'appuser', field: 'username' }, + ].map(({ label, value, field }) => ( +
+ {label} +
+ {value} + +
+
+ ))} +
+ Password +
+ {showDbPassword ? app.dbPassword || '—' : '••••••••'} + + +
+
+

+ Use external access below for internet-facing connections. +

+
+
+ )} + + {(app.productType === 'managed_redis' || app.productType === 'managed_rabbitmq') && ( +
+
+

+ Connection +

+ +
+ + {app.productType === 'managed_redis' && ( +
+

Redis (internal)

+ {[ + { label: 'Host', value: serviceCredentials?.redis?.host || `${app.name}-redis`, field: 'redis-host' }, + { label: 'Port', value: String(serviceCredentials?.redis?.port || 6379), field: 'redis-port' }, + { + label: 'Password', + value: serviceCredentials?.redis?.password || '', + field: 'redis-password', + secret: true, + }, + { label: 'URL', value: serviceCredentials?.redis?.url || '', field: 'redis-url', secret: true }, + ].map(({ label, value, field, secret }) => ( +
+ {label} +
+ + {secret && !showServiceSecrets ? '••••••••••••' : value || '—'} + + +
+
+ ))} +
+ )} + + {app.productType === 'managed_rabbitmq' && ( +
+

RabbitMQ (internal)

+ {[ + { + label: 'Host', + value: serviceCredentials?.rabbitmq?.host || `${app.name}-rabbitmq`, + field: 'rabbit-host', + }, + { + label: 'AMQP Port', + value: String(serviceCredentials?.rabbitmq?.amqpPort || 5672), + field: 'rabbit-amqp-port', + }, + { + label: 'Management Port', + value: String(serviceCredentials?.rabbitmq?.managementPort || 15672), + field: 'rabbit-mgmt-port', + }, + { + label: 'Username', + value: serviceCredentials?.rabbitmq?.username || 'appuser', + field: 'rabbit-user', + }, + { + label: 'Password', + value: serviceCredentials?.rabbitmq?.password || '', + field: 'rabbit-password', + secret: true, + }, + { + label: 'AMQP URL', + value: serviceCredentials?.rabbitmq?.amqpUrl || '', + field: 'rabbit-amqp-url', + secret: true, + }, + { + label: 'Management URL', + value: serviceCredentials?.rabbitmq?.managementUrl || '', + field: 'rabbit-mgmt-url', + }, + ].map(({ label, value, field, secret }) => ( +
+ {label} +
+ + {secret && !showServiceSecrets ? '••••••••••••' : value || '—'} + + +
+
+ ))} +
+ )} + + {!isDeployed && ( +

Deploy the service to load live credentials.

+ )} +
+ )} + + + + + + {app.productType === 'managed_database' && app.databaseType !== 'none' && ( + + )} + + + + {showRenewalModal && ( +
+
+

Renew service

+

"{app.name}"

+
+ + Wallet + + {walletData?.balance?.toLocaleString() ?? 0} T +
+
+ {(['hourly', 'monthly', 'yearly'] as const).map((cycle) => ( + + ))} +
+
+ + +
+ {renewalCost != null && walletData && walletData.balance < renewalCost && ( +

+ Insufficient wallet balance. Top up your wallet or pay via invoice. +

+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/app/dashboard/services/new/page.tsx b/frontend/src/app/dashboard/services/new/page.tsx new file mode 100644 index 0000000..9bd4687 --- /dev/null +++ b/frontend/src/app/dashboard/services/new/page.tsx @@ -0,0 +1,640 @@ +'use client'; + +import { useRef, useState, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import api from '@/lib/api'; +import { toast } from 'react-toastify'; +import type { + Application, + CreateApplicationDto, + DeployCostPreview, + OptionalServiceResourcesMap, + PricingCatalog, + ProductType, +} from '@/types'; +import { optionalDefaultsFromCatalog } from '@/lib/optional-service-defaults'; +import { + ManagedDatabaseConfig, + validateDbDumpStorage, + RestoreStorageErrorModal, + type ManagedDatabaseFormState, +} from '@/components/managed-database-config'; +import { DatabaseWorkloadResources } from '@/components/database-workload-resources'; +import { OptionalServiceResourceFields } from '@/components/optional-service-resource-fields'; +import { + Database, + ArrowLeft, + Wallet, + CreditCard, + Loader2, + CheckCircle, + XCircle, +} from 'lucide-react'; + +type ServiceKind = 'managed_database' | 'managed_redis' | 'managed_rabbitmq'; + +const steps = ['Service type', 'Configuration', 'Review & pay']; + +export default function NewManagedServicePage() { + const router = useRouter(); + const [step, setStep] = useState(0); + const [kind, setKind] = useState(null); + const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet'); + const [selectedCycle, setSelectedCycle] = useState<'hourly' | 'monthly' | 'yearly'>('monthly'); + const [deployStage, setDeployStage] = useState< + 'idle' | 'creating' | 'uploading-db' | 'paying' | 'deploying' | 'done' | 'error' + >('idle'); + const [dbUploadProgress, setDbUploadProgress] = useState(0); + const [dbDumpFile, setDbDumpFile] = useState(null); + const [showRestoreStorageErrorModal, setShowRestoreStorageErrorModal] = useState(false); + const [restoreStorageErrorMessage, setRestoreStorageErrorMessage] = useState(''); + + const [form, setForm] = useState({ + name: '', + description: '', + cpuRequest: '100m', + cpuLimit: '500m', + memoryRequest: '256Mi', + memoryLimit: '512Mi', + databaseType: 'postgresql' as ManagedDatabaseFormState['databaseType'], + dbVersion: '16', + dbUsername: '', + dbPassword: '', + dbStorageSize: '1', + redisVersion: '7.2', + rabbitmqVersion: '3.13', + optionalServiceResources: {} as OptionalServiceResourcesMap, + }); + + const dbForm: ManagedDatabaseFormState = { + databaseType: form.databaseType, + dbVersion: form.dbVersion, + dbUsername: form.dbUsername, + dbPassword: form.dbPassword, + dbStorageSize: form.dbStorageSize, + }; + + const { data: pricingCatalog } = useQuery({ + queryKey: ['pricing-catalog'], + queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data), + enabled: step >= 1, + }); + + const deployCostPayload = useMemo(() => { + if (!kind) return null; + const base = { + productType: kind as ProductType, + runtime: 'nodejs', + cpuLimit: form.cpuLimit, + memoryLimit: form.memoryLimit, + replicas: 0, + databaseType: 'none' as const, + enableRedis: false, + enableRabbitmq: false, + enableElasticsearch: false, + cycle: selectedCycle, + }; + if (kind === 'managed_database') { + return { + ...base, + databaseType: form.databaseType, + dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi`, + }; + } + if (kind === 'managed_redis') { + return { + ...base, + cpuLimit: '100m', + memoryLimit: '128Mi', + enableRedis: true, + redisResources: form.optionalServiceResources?.redis, + }; + } + return { + ...base, + cpuLimit: '100m', + memoryLimit: '128Mi', + enableRabbitmq: true, + rabbitmqResources: form.optionalServiceResources?.rabbitmq, + }; + }, [kind, form, selectedCycle]); + + const { data: costData, isLoading: costLoading } = useQuery({ + queryKey: ['deploy-cost', deployCostPayload], + queryFn: () => api.post('/billing/calculate-deploy', deployCostPayload).then((r) => r.data), + enabled: step === 2 && !!deployCostPayload, + }); + + const { data: walletData } = useQuery<{ balance: number }>({ + queryKey: ['wallet-balance'], + queryFn: () => api.get('/billing/wallet').then((r) => r.data), + enabled: step === 2, + }); + + const payAmount = costData?.amountDue ?? 0; + const walletBalance = walletData?.balance ?? 0; + const hasEnoughBalance = payAmount === 0 || walletBalance >= payAmount; + const requiresPayment = (costData?.monthly ?? 0) > 0 && payAmount > 0; + + const buildCreatePayload = (): CreateApplicationDto => { + const productType = kind as ProductType; + const payload: CreateApplicationDto = { + name: form.name, + description: form.description || undefined, + productType, + runtime: 'nodejs', + databaseType: 'none', + cpuRequest: form.cpuRequest, + cpuLimit: form.cpuLimit, + memoryRequest: form.memoryRequest, + memoryLimit: form.memoryLimit, + }; + if (kind === 'managed_database') { + payload.databaseType = form.databaseType; + payload.dbVersion = form.dbVersion; + payload.dbUsername = form.dbUsername || undefined; + payload.dbPassword = form.dbPassword || undefined; + payload.dbStorageSize = `${parseInt(form.dbStorageSize, 10) || 1}Gi`; + } + if (kind === 'managed_redis') { + payload.enableRedis = true; + payload.redisVersion = form.redisVersion; + payload.optionalServiceResources = form.optionalServiceResources; + } + if (kind === 'managed_rabbitmq') { + payload.enableRabbitmq = true; + payload.rabbitmqVersion = form.rabbitmqVersion; + payload.optionalServiceResources = form.optionalServiceResources; + } + return payload; + }; + + const finishDeploy = async (appId: string) => { + setDeployStage('deploying'); + await api.post(`/deployments/applications/${appId}/deploy`); + setDeployStage('done'); + toast.success('Service provisioned successfully'); + router.push(`/dashboard/services/${appId}`); + }; + + const uploadDbDump = async (appId: string) => { + if (!dbDumpFile) return; + setDeployStage('uploading-db'); + setDbUploadProgress(0); + const fd = new FormData(); + fd.append('file', dbDumpFile); + await api.post(`/applications/${appId}/db-upload`, fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (e) => { + if (e.total) setDbUploadProgress(Math.round((e.loaded * 100) / e.total)); + }, + }); + }; + + const walletPayMutation = useMutation({ + mutationFn: async () => { + setDeployStage('creating'); + const res = await api.post('/applications', buildCreatePayload()); + const appId = res.data.id; + if (kind === 'managed_database' && dbDumpFile) { + await uploadDbDump(appId); + } + setDeployStage('paying'); + await api.post(`/billing/wallet/pay/${appId}`, { cycle: selectedCycle }); + return appId; + }, + onSuccess: (appId) => + finishDeploy(appId).catch(() => { + setDeployStage('error'); + toast.error('Payment succeeded but deployment failed'); + }), + onError: (err: unknown) => { + setDeployStage('error'); + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Payment or provisioning failed'); + setTimeout(() => setDeployStage('idle'), 2000); + }, + }); + + const gatewayPayMutation = useMutation({ + mutationFn: async () => { + setDeployStage('paying'); + if (payAmount > 0) { + const { data: gw } = await api.post('/billing/gateway/initiate', { + amount: payAmount, + description: `Service: ${form.name} (${selectedCycle})`, + callbackUrl: `${window.location.origin}/dashboard/services/new`, + }); + await api.post('/billing/gateway/verify', { + trackingCode: gw.trackingCode, + amount: payAmount, + }); + } + setDeployStage('creating'); + const res = await api.post('/applications', buildCreatePayload()); + const appId = res.data.id; + if (kind === 'managed_database' && dbDumpFile) { + await uploadDbDump(appId); + } + await api.post(`/billing/wallet/pay/${appId}`, { cycle: selectedCycle }); + return appId; + }, + onSuccess: (appId) => + finishDeploy(appId).catch(() => { + setDeployStage('error'); + toast.error('Payment succeeded but deployment failed'); + }), + onError: (err: unknown) => { + setDeployStage('error'); + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Payment failed'); + setTimeout(() => setDeployStage('idle'), 2000); + }, + }); + + const validateBeforePay = () => { + if (kind === 'managed_database' && dbDumpFile) { + const err = validateDbDumpStorage(dbDumpFile, parseInt(form.dbStorageSize, 10) || 1); + if (err) { + setRestoreStorageErrorMessage(err); + setShowRestoreStorageErrorModal(true); + return false; + } + } + return true; + }; + + const handlePay = () => { + if (!validateBeforePay()) return; + if (payAmount === 0) walletPayMutation.mutate(); + else if (paymentMethod === 'wallet') { + if (!hasEnoughBalance) { + toast.error('Insufficient wallet balance'); + return; + } + walletPayMutation.mutate(); + } else { + gatewayPayMutation.mutate(); + } + }; + + const canNext = () => { + if (step === 0) return !!kind; + if (step === 1) return /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(form.name); + return true; + }; + + return ( +
+
+ + + +
+

New managed service

+

Database, Redis, or RabbitMQ — billed like applications

+
+
+ +
+ {steps.map((label, i) => ( +
+
+
+ {i < step ? '✓' : i + 1} +
+ +
+ {i < steps.length - 1 && ( +
+ )} +
+ ))} +
+ +
+ {step === 0 && ( +
+ {( + [ + { id: 'managed_database' as const, title: 'Database', desc: 'PostgreSQL, MySQL, MariaDB, MongoDB' }, + { id: 'managed_redis' as const, title: 'Redis', desc: 'In-memory cache & store' }, + { id: 'managed_rabbitmq' as const, title: 'RabbitMQ', desc: 'Message broker' }, + ] as const + ).map((opt) => ( + + ))} +
+ )} + + {step === 1 && kind && ( +
+
+ + setForm({ ...form, name: e.target.value.toLowerCase() })} + placeholder="my-database" + /> +

Lowercase letters, numbers, and hyphens only

+
+ + {kind === 'managed_database' && ( + <> + setForm({ ...form, ...patch })} + dbDumpFile={dbDumpFile} + onDbDumpFileChange={setDbDumpFile} + /> + setForm({ ...form, ...patch })} + /> + + )} + + {kind === 'managed_redis' && ( +
+
+ + +
+ {form.optionalServiceResources?.redis && ( + + setForm({ + ...form, + optionalServiceResources: { + ...form.optionalServiceResources, + redis: { ...form.optionalServiceResources!.redis!, ...patch }, + }, + }) + } + /> + )} +
+ )} + + {kind === 'managed_rabbitmq' && ( +
+
+ + +
+ {form.optionalServiceResources?.rabbitmq && ( + + setForm({ + ...form, + optionalServiceResources: { + ...form.optionalServiceResources, + rabbitmq: { ...form.optionalServiceResources!.rabbitmq!, ...patch }, + }, + }) + } + /> + )} +
+ )} +
+ )} + + {step === 2 && ( +
+ {costLoading ? ( +
+ +
+ ) : costData ? ( + <> +
+

Billing cycle

+
+ {(['hourly', 'monthly', 'yearly'] as const).map((cycle) => ( + + ))} +
+
+ {requiresPayment && ( +
+

Payment method

+
+ + +
+
+ )} + + ) : ( +

Pricing unavailable

+ )} +
+ )} + +
+ + {step < 2 ? ( + + ) : ( + + )} +
+
+ + {deployStage !== 'idle' && ( +
+
+

Provisioning service

+
+
+ {deployStage === 'creating' ? ( + + ) : ( + + )} + Creating service +
+ {dbDumpFile && kind === 'managed_database' && ( +
+
+ {deployStage === 'uploading-db' ? ( + + ) : ['paying', 'deploying', 'done'].includes(deployStage) ? ( + + ) : ( +
+ )} + Uploading database dump + {deployStage === 'uploading-db' && dbUploadProgress > 0 && ( + {dbUploadProgress}% + )} +
+ {deployStage === 'uploading-db' && ( +
+
+
+ )} +
+ )} +
+ {deployStage === 'paying' ? ( + + ) : ['deploying', 'done'].includes(deployStage) ? ( + + ) : ( +
+ )} + Payment +
+
+ {deployStage === 'deploying' ? ( + + ) : deployStage === 'done' ? ( + + ) : deployStage === 'error' ? ( + + ) : ( +
+ )} + Deploying +
+
+
+
+ )} + + setShowRestoreStorageErrorModal(false)} + /> +
+ ); +} diff --git a/frontend/src/app/dashboard/services/page.tsx b/frontend/src/app/dashboard/services/page.tsx new file mode 100644 index 0000000..b043679 --- /dev/null +++ b/frontend/src/app/dashboard/services/page.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import api from '@/lib/api'; +import { toast } from 'react-toastify'; +import type { Application } from '@/types'; +import { managedServiceTypeLabel } from '@/lib/optional-service-defaults'; +import { Database, Plus, AlertTriangle, Clock } from 'lucide-react'; +import { useConfirm } from '@/components/confirm-modal'; +import { filterManagedServices } from '@/lib/product-type'; + +const lifecycleColors: Record = { + active: 'text-green-600 bg-green-50', + suspended: 'text-amber-700 bg-amber-50', + pending_deletion: 'text-red-700 bg-red-50', + deleted: 'text-gray-500 bg-gray-100', +}; + +const lifecycleLabels: Record = { + active: 'Active', + suspended: 'Suspended — Unpaid', + pending_deletion: 'Pending Deletion', + deleted: 'Deleted', +}; + +function serviceSubtitle(app: Application): string { + if (app.productType === 'managed_database') { + return `${app.databaseType}${app.dbVersion ? ` v${app.dbVersion}` : ''}`; + } + if (app.productType === 'managed_redis') { + return `Redis${app.redisVersion ? ` v${app.redisVersion}` : ''}`; + } + if (app.productType === 'managed_rabbitmq') { + return `RabbitMQ${app.rabbitmqVersion ? ` v${app.rabbitmqVersion}` : ''}`; + } + return app.databaseType !== 'none' ? app.databaseType : '—'; +} + +function formatExpiry(expiresAt?: string): { text: string; urgent: boolean } { + if (!expiresAt) return { text: '—', urgent: false }; + const diff = new Date(expiresAt).getTime() - Date.now(); + if (diff <= 0) return { text: 'Expired', urgent: true }; + const hours = Math.floor(diff / 3600000); + const days = Math.floor(hours / 24); + if (days > 0) return { text: `${days}d ${hours % 24}h remaining`, urgent: days < 3 }; + if (hours > 0) return { text: `${hours}h remaining`, urgent: hours < 6 }; + const mins = Math.floor(diff / 60000); + return { text: `${mins}m remaining`, urgent: true }; +} + +export default function ServicesPage() { + const queryClient = useQueryClient(); + const confirm = useConfirm(); + + const { data: servicesRaw = [], isLoading } = useQuery({ + queryKey: ['applications', 'managed'], + queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data), + }); + const services = filterManagedServices(servicesRaw); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/applications/${id}`), + onSuccess: (res) => { + queryClient.invalidateQueries({ queryKey: ['applications', 'managed'] }); + queryClient.invalidateQueries({ queryKey: ['resource-credits'] }); + if (res.data?.resourceCredit) { + toast.success('Service deleted. Prepaid resources are on your dashboard.'); + } else { + toast.success('Service deleted'); + } + }, + onError: () => toast.error('Failed to delete service'), + }); + + if (isLoading) { + return ( +
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ); + } + + return ( +
+
+
+

Databases & Services

+

+ Standalone databases, Redis, and RabbitMQ — {services.length} service{services.length !== 1 ? 's' : ''} +

+
+ + New Service + +
+ + {services.length === 0 ? ( +
+ +

No managed services yet

+

+ Provision a database, Redis, or RabbitMQ without deploying a full application. +

+ + Create your first service + +
+ ) : ( +
+ {services.map((svc) => { + const lifecycle = svc.lifecycleStatus || 'active'; + const expiry = formatExpiry(svc.planExpiresAt); + const latestStatus = svc.deployments?.[0]?.status || 'pending'; + return ( +
+ +
+ +
+
+

{svc.name}

+

+ {managedServiceTypeLabel(svc.productType)} · {serviceSubtitle(svc)} +

+
+ +
+ + {latestStatus} + + + {lifecycle === 'suspended' && } + {lifecycleLabels[lifecycle] || lifecycle} + + + {expiry.text} + + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/database-snapshots-panel.tsx b/frontend/src/components/database-snapshots-panel.tsx new file mode 100644 index 0000000..037d06b --- /dev/null +++ b/frontend/src/components/database-snapshots-panel.tsx @@ -0,0 +1,314 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '@/lib/api'; +import { toast } from 'react-toastify'; +import type { AppSnapshot } from '@/types'; +import { formatBytes } from '@/lib/format-utils'; +import { + Camera, + ChevronDown, + Clock, + Database, + Download, + History, + Trash2, + XCircle, +} from 'lucide-react'; +import { useConfirm } from '@/components/confirm-modal'; + +const statusColors: Record = { + running: 'badge-green', + pending: 'badge-yellow', + building: 'badge-blue', + deploying: 'badge-blue', + failed: 'badge-red', + build_failed: 'badge-red', + cancelled: 'badge-gray', + stopped: 'badge-gray', +}; + +function BackupProgressBar({ progress, label }: { progress: number; label?: string }) { + const pct = Math.min(100, Math.max(0, progress)); + return ( +
+
+ {label || 'Creating database dump…'} + {pct}% +
+
+
+
+
+ ); +} + +export function DatabaseSnapshotsPanel({ + serviceId, + isDeployed, +}: { + serviceId: string; + isDeployed: boolean; +}) { + const queryClient = useQueryClient(); + const confirm = useConfirm(); + const [showPanel, setShowPanel] = useState(false); + const prevInProgressRef = useRef>(new Set()); + + const { data: snapshots = [], isLoading } = useQuery({ + queryKey: ['snapshots', serviceId], + queryFn: () => api.get(`/snapshots/applications/${serviceId}`).then((r) => r.data), + enabled: showPanel, + refetchInterval: (query) => { + if (!showPanel) return false; + const list = query.state.data; + const hasInProgress = list?.some((s) => s.status === 'in_progress'); + return hasInProgress ? 2000 : 10000; + }, + }); + + const hasInProgress = snapshots.some((s) => s.status === 'in_progress'); + + useEffect(() => { + const inProgressIds = new Set(snapshots.filter((s) => s.status === 'in_progress').map((s) => s.id)); + for (const snap of snapshots) { + if ( + prevInProgressRef.current.has(snap.id) && + !inProgressIds.has(snap.id) && + snap.status === 'completed' && + snap.dbDumpPath + ) { + toast.success('Backup ready — you can download the dump now'); + } + if (prevInProgressRef.current.has(snap.id) && snap.status === 'failed') { + toast.error(snap.errorMessage || 'Backup failed'); + } + } + prevInProgressRef.current = inProgressIds; + }, [snapshots]); + + const createMutation = useMutation({ + mutationFn: () => + api.post( + `/snapshots/applications/${serviceId}?label=${encodeURIComponent('Database backup')}`, + ), + onSuccess: () => { + setShowPanel(true); + queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] }); + toast.info('Backup started — dump in progress'); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string | string[] } } })?.response?.data + ?.message; + const text = Array.isArray(msg) ? msg.join(', ') : msg; + toast.error(text || 'Failed to create backup'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/snapshots/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] }); + toast.success('Backup deleted'); + }, + onError: () => toast.error('Failed to delete backup'), + }); + + const downloadSnapshotDb = (snapshotId: string) => { + const url = `${api.defaults.baseURL}/snapshots/${snapshotId}/download/database`; + const token = localStorage.getItem('accessToken'); + fetch(url, { headers: { Authorization: `Bearer ${token}` } }) + .then((r) => { + if (!r.ok) throw new Error('download failed'); + return r.blob(); + }) + .then((blob) => { + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `database-backup-${snapshotId.slice(0, 8)}.sql`; + link.click(); + URL.revokeObjectURL(link.href); + toast.success('Download started'); + }) + .catch(() => toast.error('Failed to download database dump')); + }; + + const handleDelete = async (snap: AppSnapshot) => { + const ok = await confirm({ + title: 'Delete backup?', + message: `Delete "${snap.label || 'backup'}"? The dump file will be permanently removed.`, + confirmText: 'Delete', + variant: 'danger', + }); + if (ok) deleteMutation.mutate(snap.id); + }; + + const handleNewBackup = () => { + setShowPanel(true); + createMutation.mutate(); + }; + + return ( +
+
+

+ Snapshots +

+
+ + +
+
+ + {showPanel && ( +
+
+

+ Database snapshots store a SQL dump you can + download later. When progress reaches 100%, use the download button. Up to 10 snapshots are kept; + oldest are removed automatically. +

+
+ + {isLoading ? ( +
Loading backups…
+ ) : snapshots.length === 0 ? ( +
+ +

No backups yet

+

Click New backup to create your first database dump.

+
+ ) : ( +
+ {snapshots.map((snap) => ( +
+
+
+
+

{snap.label || 'Database backup'}

+ + {snap.type === 'pre_deploy' ? 'Auto' : 'Manual'} + + + {snap.status === 'in_progress' ? 'Dumping…' : snap.status} + +
+

{new Date(snap.createdAt).toLocaleString()}

+ + {snap.status === 'in_progress' && ( + + )} + + {snap.status === 'completed' && snap.dbDumpPath && ( +

+ Dump: {formatBytes(snap.dbDumpSize)} +

+ )} + {snap.errorMessage && ( +

+ {snap.errorMessage} +

+ )} +
+ {snap.status === 'completed' && snap.dbDumpPath && ( +
+ + +
+ )} + {snap.status === 'failed' && ( + + )} +
+
+ ))} +
+ )} + +

Maximum 10 backups are kept.

+
+ )} +
+ ); +} diff --git a/frontend/src/components/database-workload-resources.tsx b/frontend/src/components/database-workload-resources.tsx new file mode 100644 index 0000000..bdf432c --- /dev/null +++ b/frontend/src/components/database-workload-resources.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Database } from 'lucide-react'; + +export interface DatabaseWorkloadValues { + cpuRequest: string; + cpuLimit: string; + memoryRequest: string; + memoryLimit: string; +} + +export function DatabaseWorkloadResources({ + values, + onChange, +}: { + values: DatabaseWorkloadValues; + onChange: (patch: Partial) => void; +}) { + return ( +
+
+ +

Database resources

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/deployment-progress-manager.tsx b/frontend/src/components/deployment-progress-manager.tsx index 11ca898..144fc26 100644 --- a/frontend/src/components/deployment-progress-manager.tsx +++ b/frontend/src/components/deployment-progress-manager.tsx @@ -8,6 +8,7 @@ import type { Application } from '@/types'; import { useAuthStore } from '@/lib/store'; import { useDeployProgressStore } from '@/lib/deploy-progress-store'; import { getAppsInProgress, isActiveBuildProgress } from '@/lib/deployment-progress'; +import { filterApplications, filterManagedServices } from '@/lib/product-type'; import { BuildProgressModal, type BuildProgress } from '@/components/build-progress-modal'; import { DeploymentProgressBar } from '@/components/deployment-progress-bar'; @@ -16,17 +17,29 @@ export function DeploymentProgressManager() { const pathname = usePathname(); const { minimized, focusedAppId, minimize, expand } = useDeployProgressStore(); - const { data: apps = [] } = useQuery({ - queryKey: ['applications'], - queryFn: () => api.get('/applications').then((r) => r.data), + const refetchWhileDeploying = (list: Application[] | undefined) => + getAppsInProgress(list ?? []).length > 0 ? 3000 : false; + + const { data: appsList = [] } = useQuery({ + queryKey: ['applications', 'application'], + queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data), enabled: isAuthenticated, - refetchInterval: (query) => { - const list = query.state.data ?? []; - return getAppsInProgress(list).length > 0 ? 3000 : false; - }, + refetchInterval: (query) => refetchWhileDeploying(query.state.data), }); - const deployingApps = useMemo(() => getAppsInProgress(apps), [apps]); + const { data: servicesList = [] } = useQuery({ + queryKey: ['applications', 'managed'], + queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data), + enabled: isAuthenticated, + refetchInterval: (query) => refetchWhileDeploying(query.state.data), + }); + + const allResources = useMemo( + () => [...filterApplications(appsList), ...filterManagedServices(servicesList)], + [appsList, servicesList], + ); + + const deployingApps = useMemo(() => getAppsInProgress(allResources), [allResources]); const progressQueries = useQueries({ queries: deployingApps.map((app) => ({ @@ -48,7 +61,9 @@ export function DeploymentProgressManager() { .filter(({ progress }) => isActiveBuildProgress(progress)); }, [deployingApps, progressQueries]); - const routeAppId = pathname.match(/\/dashboard\/apps\/([^/]+)/)?.[1]; + const routeAppId = + pathname.match(/\/dashboard\/apps\/([^/]+)/)?.[1] ?? + pathname.match(/\/dashboard\/services\/([^/]+)/)?.[1]; useEffect(() => { if (activeItems.length === 0) { diff --git a/frontend/src/components/managed-database-config.tsx b/frontend/src/components/managed-database-config.tsx new file mode 100644 index 0000000..64c48f7 --- /dev/null +++ b/frontend/src/components/managed-database-config.tsx @@ -0,0 +1,336 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-react'; +import { toast } from 'react-toastify'; +import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils'; + +export type DatabaseEngine = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb'; + +export interface ManagedDatabaseFormState { + databaseType: DatabaseEngine; + dbVersion: string; + dbUsername: string; + dbPassword: string; + dbStorageSize: string; +} + +const DB_OPTIONS: { value: DatabaseEngine; label: string; versions: string[] }[] = [ + { value: 'postgresql', label: 'PostgreSQL', versions: ['17', '16', '15', '14'] }, + { value: 'mysql', label: 'MySQL', versions: ['9.0', '8.4', '8.0', '5.7'] }, + { value: 'mariadb', label: 'MariaDB', versions: ['11.4', '11.3', '10.11', '10.6'] }, + { value: 'mongodb', label: 'MongoDB', versions: ['7.0', '6.0', '5.0'] }, +]; + +function versionOptions(databaseType: DatabaseEngine) { + const opt = DB_OPTIONS.find((o) => o.value === databaseType); + return opt?.versions ?? ['16']; +} + +function versionLabel(databaseType: DatabaseEngine, v: string) { + if (databaseType === 'postgresql') return `PostgreSQL ${v}`; + if (databaseType === 'mysql') return `MySQL ${v}`; + if (databaseType === 'mariadb') return `MariaDB ${v}`; + return `MongoDB ${v}`; +} + +export function ManagedDatabaseConfig({ + form, + onChange, + dbDumpFile, + onDbDumpFileChange, +}: { + form: ManagedDatabaseFormState; + onChange: (patch: Partial) => void; + dbDumpFile: File | null; + onDbDumpFileChange: (file: File | null) => void; +}) { + const dbDumpInputRef = useRef(null); + const [showDbPassword, setShowDbPassword] = useState(false); + const [isDragging, setIsDragging] = useState(false); + + const minDbGiFromRestoreDump = dbDumpFile ? minGiToFitFileBytes(dbDumpFile.size) : 1; + const dbGi = parseInt(form.dbStorageSize || '1', 10) || 1; + + const acceptDump = (f: File) => { + if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) { + toast.error('Allowed: .sql, .gz, .dump'); + return; + } + if (f.size > 500 * 1024 * 1024) { + toast.error('Max 500MB'); + return; + } + onDbDumpFileChange(f); + const suggested = Math.max(minGiToFitFileBytes(f.size), Math.ceil((f.size / ONE_GIB) * 3)); + const cur = parseInt(form.dbStorageSize || '1', 10) || 1; + onChange({ dbStorageSize: String(Math.max(cur, suggested)) }); + }; + + return ( +
+
+ +
+ {DB_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+ + +
+ +
+
+ +

Database credentials

+ (optional — auto-generated if empty) +
+
+
+ + onChange({ dbUsername: e.target.value })} + /> +
+
+ +
+ onChange({ dbPassword: e.target.value })} + /> +
+ + +
+
+
+
+

+ Used for internal cluster access. Use temporary or permanent external access below after deploy. +

+ +
+ +
{ + e.preventDefault(); + setIsDragging(false); + const f = e.dataTransfer.files[0]; + if (f) acceptDump(f); + }} + onDragOver={(e) => { + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onClick={() => dbDumpInputRef.current?.click()} + className={`border-2 border-dashed rounded-xl p-3 text-center cursor-pointer transition-colors ${ + dbDumpFile + ? 'border-blue-400 bg-blue-50' + : isDragging + ? 'border-blue-400 bg-blue-50' + : 'border-gray-300 hover:border-blue-400 hover:bg-gray-50' + }`} + > + { + const f = e.target.files?.[0]; + if (f) acceptDump(f); + e.target.value = ''; + }} + /> + {dbDumpFile ? ( +
+
+

{dbDumpFile.name}

+

{(dbDumpFile.size / (1024 * 1024)).toFixed(2)} MB

+
+ +
+ ) : ( +
+

Upload a SQL dump to be restored after the database is created

+

Optional • Max 500MB • .sql, .gz, .dump

+
+ )} +
+
+ +
+ +
+
+ + { + const val = Math.max( + minDbGiFromRestoreDump, + Math.min(100, parseInt(e.target.value, 10) || minDbGiFromRestoreDump), + ); + onChange({ dbStorageSize: String(val) }); + }} + className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none" + /> + +
+ GB + {dbDumpFile && ( + + Suggested from dump ({(dbDumpFile.size / ONE_GIB).toFixed(2)} GiB min fit) + + )} +
+

+ Minimum {minDbGiFromRestoreDump} GB + {dbDumpFile ? ' (must fit the uploaded dump)' : ''} • Only expansion allowed after creation +

+
+
+
+ ); +} + +export function validateDbDumpStorage( + dbDumpFile: File | null, + dbStorageSizeGi: number, +): string | null { + if (!dbDumpFile) return null; + const need = minGiToFitFileBytes(dbDumpFile.size); + if (dbStorageSizeGi < need) { + return `Your database dump is about ${(dbDumpFile.size / ONE_GIB).toFixed(2)} GiB. Database storage must be at least ${need} GiB (you selected ${dbStorageSizeGi} GiB). Increase database storage, then try again.`; + } + return null; +} + +export function RestoreStorageErrorModal({ + open, + message, + onClose, +}: { + open: boolean; + message: string; + onClose: () => void; +}) { + if (!open) return null; + return ( +
+
+
+
+
+ +
+

Storage too small

+

{message}

+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/managed-service-resources-panel.tsx b/frontend/src/components/managed-service-resources-panel.tsx new file mode 100644 index 0000000..46958ce --- /dev/null +++ b/frontend/src/components/managed-service-resources-panel.tsx @@ -0,0 +1,707 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '@/lib/api'; +import { toast } from 'react-toastify'; +import type { Application, Invoice, OptionalServiceResourcesMap, ResourceUsage } from '@/types'; +import { + ResourceUpgradeConfirmModal, + type UpgradeCostSummary, +} from '@/components/resource-upgrade-confirm-modal'; +import { optionalDefaultsFromCatalog } from '@/lib/optional-service-defaults'; +import { parseCpuToMillicores, parseMemoryToMi } from '@/lib/format-utils'; +import { DatabaseWorkloadResources } from '@/components/database-workload-resources'; +import { OptionalServiceResourceFields } from '@/components/optional-service-resource-fields'; +import { + BarChart3, + CheckCircle, + ChevronDown, + Clock, + Database, + RefreshCw, + Scale, + Settings, +} from 'lucide-react'; + +type AppWithOptional = Application & { optionalServiceResources?: OptionalServiceResourcesMap }; + +interface StorageUsageSlice { + allocatedRaw: string; + allocatedGi: number; + usedGi: number; + availableGi: number; + usedPercent: number; +} + +interface StorageUsageData { + database: StorageUsageSlice | null; + redisStorage?: StorageUsageSlice | null; + rabbitmqStorage?: StorageUsageSlice | null; +} + +function workloadKey(app: Application): 'database' | 'redis' | 'rabbitmq' { + if (app.productType === 'managed_redis') return 'redis'; + if (app.productType === 'managed_rabbitmq') return 'rabbitmq'; + return 'database'; +} + +function workloadTitle(app: Application): string { + if (app.productType === 'managed_redis') return 'Redis'; + if (app.productType === 'managed_rabbitmq') return 'RabbitMQ'; + return 'Database'; +} + +type UpgradePayload = { + cpuRequest?: string; + cpuLimit?: string; + memoryRequest?: string; + memoryLimit?: string; + dbStorageSize?: string; + redisResources?: { + cpuRequest?: string; + cpuLimit: string; + memoryRequest?: string; + memoryLimit: string; + storageGi: number; + }; + rabbitmqResources?: { + cpuRequest?: string; + cpuLimit: string; + memoryRequest?: string; + memoryLimit: string; + storageGi: number; + }; +}; + +export function ManagedServiceResourcesPanel({ + serviceId, + app, + isDeployed, + isStopped, + needsRenewal = false, +}: { + serviceId: string; + app: AppWithOptional; + isDeployed: boolean; + isStopped: boolean; + needsRenewal?: boolean; +}) { + const router = useRouter(); + const queryClient = useQueryClient(); + const dbFileInputRef = useRef(null); + const [showResources, setShowResources] = useState(false); + const [dbStorageSize, setDbStorageSize] = useState('1'); + const [isDraggingDb, setIsDraggingDb] = useState(false); + const [dbRestoreLogs, setDbRestoreLogs] = useState(null); + + const [dbResources, setDbResources] = useState({ + cpuRequest: '100m', + cpuLimit: '500m', + memoryRequest: '128Mi', + memoryLimit: '512Mi', + }); + const [redisResources, setRedisResources] = useState(optionalDefaultsFromCatalog(undefined, 'redis')); + const [rabbitResources, setRabbitResources] = useState(optionalDefaultsFromCatalog(undefined, 'rabbitmq')); + const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false); + const [upgradeCostData, setUpgradeCostData] = useState(null); + const [pendingUpgradePayload, setPendingUpgradePayload] = useState(null); + + const isDatabase = app.productType === 'managed_database'; + + const { data: walletData } = useQuery<{ balance: number }>({ + queryKey: ['wallet'], + queryFn: () => api.get('/billing/wallet').then((r) => r.data), + }); + + const { data: dbStorageData } = useQuery<{ currentSize: string }>({ + queryKey: ['db-storage', serviceId], + queryFn: () => api.get(`/applications/${serviceId}/db-storage`).then((r) => r.data), + enabled: isDatabase && app.databaseType !== 'none', + }); + + const { data: resourceUsage, isLoading: resourcesLoading } = useQuery({ + queryKey: ['resources', serviceId], + queryFn: () => api.get(`/applications/${serviceId}/resources`).then((r) => r.data), + enabled: showResources && isDeployed, + refetchInterval: showResources && isDeployed ? 5000 : false, + }); + + const { data: storageUsage, isLoading: storageUsageLoading } = useQuery({ + queryKey: ['storage-usage', serviceId], + queryFn: () => api.get(`/applications/${serviceId}/storage`).then((r) => r.data), + enabled: showResources && isDeployed, + refetchInterval: showResources && isDeployed ? 15000 : false, + }); + + useEffect(() => { + if (dbStorageData?.currentSize) { + const n = parseInt(dbStorageData.currentSize.replace('Gi', ''), 10) || 1; + setDbStorageSize(String(n)); + } + }, [dbStorageData]); + + useEffect(() => { + setDbResources({ + cpuRequest: app.cpuRequest || '100m', + cpuLimit: app.cpuLimit || '500m', + memoryRequest: app.memoryRequest || '128Mi', + memoryLimit: app.memoryLimit || '512Mi', + }); + if (app.optionalServiceResources?.redis) setRedisResources(app.optionalServiceResources.redis); + if (app.optionalServiceResources?.rabbitmq) setRabbitResources(app.optionalServiceResources.rabbitmq); + }, [app]); + + const directPatchResourcesMutation = useMutation({ + mutationFn: (data: { + workload: 'database' | 'redis' | 'rabbitmq'; + cpuRequest?: string; + cpuLimit?: string; + memoryRequest?: string; + memoryLimit?: string; + }) => api.patch(`/applications/${serviceId}/resources`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['application', serviceId] }); + queryClient.invalidateQueries({ queryKey: ['resources', serviceId] }); + toast.success('Resources updated'); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Failed to update resources'); + }, + }); + + const scaleMutation = useMutation({ + mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${serviceId}/upgrade`, data), + onSuccess: (res) => { + queryClient.invalidateQueries({ queryKey: ['application', serviceId] }); + queryClient.invalidateQueries({ queryKey: ['resources', serviceId] }); + queryClient.invalidateQueries({ queryKey: ['db-storage', serviceId] }); + queryClient.invalidateQueries({ queryKey: ['storage-usage', serviceId] }); + 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`); + } else { + toast.success('Resources updated successfully'); + } + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Failed to upgrade resources'); + }, + }); + + const calculateUpgradeCostMutation = useMutation({ + mutationFn: (data: UpgradePayload) => + api.post(`/billing/applications/${serviceId}/upgrade/calculate`, data), + onSuccess: (res) => { + setUpgradeCostData(res.data); + setShowUpgradeConfirm(true); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Failed to calculate upgrade cost'); + }, + }); + + const createUpgradeInvoiceMutation = useMutation({ + mutationFn: (data: UpgradePayload) => + api.post(`/billing/applications/${serviceId}/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: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Failed to create upgrade invoice'); + }, + }); + + const resizeDbMutation = useMutation({ + mutationFn: (size: string) => 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(); + formData.append('file', file); + return api.post(`/applications/${serviceId}/db-upload`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, + onSuccess: (res) => { + const data = res.data; + setDbRestoreLogs(data.logs || null); + if (data.success) toast.success('Database restored successfully'); + else toast.error(data.message || 'Database restore failed'); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Failed to upload database dump'); + setDbRestoreLogs(null); + }, + }); + + const handleDbFileUpload = useCallback( + (file: File) => { + if (!file.name.endsWith('.sql') && !file.name.endsWith('.gz') && !file.name.endsWith('.dump')) { + toast.error('Please upload a .sql, .dump, or .gz file'); + return; + } + if (file.size > 500 * 1024 * 1024) { + toast.error('File size must be less than 500MB'); + return; + } + setDbRestoreLogs(null); + dbUploadMutation.mutate(file); + }, + [dbUploadMutation], + ); + + const buildUpgradePayload = useCallback((): UpgradePayload => { + if (app.productType === 'managed_database') { + return { ...dbResources }; + } + if (app.productType === 'managed_redis') { + return { + redisResources: { + cpuRequest: redisResources.cpuRequest, + cpuLimit: redisResources.cpuLimit, + memoryRequest: redisResources.memoryRequest, + memoryLimit: redisResources.memoryLimit, + storageGi: redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1, + }, + }; + } + return { + rabbitmqResources: { + cpuRequest: rabbitResources.cpuRequest, + cpuLimit: rabbitResources.cpuLimit, + memoryRequest: rabbitResources.memoryRequest, + memoryLimit: rabbitResources.memoryLimit, + storageGi: rabbitResources.storageGi ?? app.optionalServiceResources?.rabbitmq?.storageGi ?? 2, + }, + }; + }, [app, dbResources, redisResources, rabbitResources]); + + const applyResources = () => { + if (needsRenewal) { + toast.warn('Renew the service before changing resources'); + return; + } + + const payload = buildUpgradePayload(); + const w = workloadKey(app); + + if (!app.billingCycle) { + if (w === 'database') { + directPatchResourcesMutation.mutate({ workload: 'database', ...dbResources }); + } else if (w === 'redis') { + directPatchResourcesMutation.mutate({ + workload: 'redis', + cpuRequest: redisResources.cpuRequest, + cpuLimit: redisResources.cpuLimit, + memoryRequest: redisResources.memoryRequest, + memoryLimit: redisResources.memoryLimit, + }); + } else { + directPatchResourcesMutation.mutate({ + workload: 'rabbitmq', + cpuRequest: rabbitResources.cpuRequest, + cpuLimit: rabbitResources.cpuLimit, + memoryRequest: rabbitResources.memoryRequest, + memoryLimit: rabbitResources.memoryLimit, + }); + } + return; + } + + setPendingUpgradePayload(payload); + calculateUpgradeCostMutation.mutate(payload); + }; + + const confirmUpgrade = () => { + if (!pendingUpgradePayload || !upgradeCostData) return; + if (upgradeCostData.proratedAmount > 0) { + createUpgradeInvoiceMutation.mutate(pendingUpgradePayload); + } else { + scaleMutation.mutate(pendingUpgradePayload); + } + }; + + 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 + ? { + key: workloadKey(app), + title: workloadTitle(app), + deploymentName: `${app.name}-${workloadKey(app) === 'database' ? 'db' : workloadKey(app)}`, + configured: resourceUsage.configured, + pods: resourceUsage.pods, + metrics: resourceUsage.metrics, + } + : null); + + const storageSlice = + isDatabase + ? storageUsage?.database + : app.productType === 'managed_redis' + ? storageUsage?.redisStorage + : storageUsage?.rabbitmqStorage; + + return ( +
+
+

+ Resources & Scaling +

+ +
+ + {showResources && ( +
+ {!isDeployed ? ( +

Deploy the service to view metrics and adjust resources.

+ ) : resourcesLoading ? ( +
Loading metrics…
+ ) : metricsWorkload ? ( +
+
+

{metricsWorkload.title}

+ {metricsWorkload.deploymentName} +
+
+
+

Replicas

+

+ {metricsWorkload.configured.readyReplicas}/{metricsWorkload.configured.replicas} +

+
+
+

Pods

+

{metricsWorkload.pods.length}

+
+
+

Metrics

+

+ {metricsWorkload.metrics.length > 0 ? ( + + ) : ( + + )} +

+
+
+ {metricsWorkload.metrics.length > 0 && ( +
+ {metricsWorkload.metrics.map((metric) => { + const cpuUsed = parseCpuToMillicores(metric.cpu); + const cpuLimit = parseCpuToMillicores(metricsWorkload.configured.cpuLimit); + const cpuPercent = cpuLimit > 0 ? Math.min((cpuUsed / cpuLimit) * 100, 100) : 0; + const memUsed = parseMemoryToMi(metric.memory); + const memLimit = parseMemoryToMi(metricsWorkload.configured.memoryLimit); + const memPercent = memLimit > 0 ? Math.min((memUsed / memLimit) * 100, 100) : 0; + return ( +
+

{metric.name}

+
+
+ CPU + + {cpuPercent.toFixed(0)}% ({cpuUsed.toFixed(0)}m / {cpuLimit.toFixed(0)}m) + +
+
+
80 ? 'bg-red-500' : 'bg-green-500'}`} + style={{ width: `${cpuPercent}%` }} + /> +
+
+ Memory + + {memPercent.toFixed(0)}% ({memUsed.toFixed(0)} / {memLimit.toFixed(0)} Mi) + +
+
+
80 ? 'bg-red-500' : 'bg-green-500'}`} + style={{ width: `${memPercent}%` }} + /> +
+
+
+ ); + })} +
+ )} +
+ ) : ( +

+ {isStopped ? 'Service is stopped.' : 'No resource metrics yet.'} +

+ )} + +
+

+ Storage +

+ {storageUsageLoading ? ( +

Loading storage…

+ ) : storageSlice ? ( +
+
+ Used {storageSlice.usedGi.toFixed(2)} GiB + Allocated {storageSlice.allocatedGi.toFixed(1)} GiB +
+
+
80 ? 'bg-red-500' : storageSlice.usedPercent > 50 ? 'bg-yellow-500' : 'bg-blue-500'}`} + style={{ width: `${Math.min(storageSlice.usedPercent, 100)}%` }} + /> +
+ {isDatabase && ( +
+
+ + + setDbStorageSize(String(Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1)))) + } + className="w-14 text-center py-1.5 border-x border-gray-300 text-sm font-semibold" + /> + +
+ GB + +

+ Only expansion is allowed. + {app.billingCycle + ? ' Additional storage is charged for the remaining billing period.' + : ''} +

+
+ )} +
+ ) : ( +

Storage metrics unavailable.

+ )} +
+ +
+

+ Adjust CPU / memory +

+ {app.productType === 'managed_database' && ( + setDbResources((v) => ({ ...v, ...p }))} /> + )} + {app.productType === 'managed_redis' && ( + setRedisResources((c) => ({ ...c, ...p }))} + /> + )} + {app.productType === 'managed_rabbitmq' && ( + setRabbitResources((c) => ({ ...c, ...p }))} + /> + )} + {app.billingCycle && ( +

+ Upgrades that increase cost are charged for the remaining billing period (wallet or invoice), same as + applications. +

+ )} + +
+ + {isDatabase && ( +
+

+ Restore database dump +

+
{ + e.preventDefault(); + setIsDraggingDb(false); + const file = e.dataTransfer.files[0]; + if (file) handleDbFileUpload(file); + }} + onDragOver={(e) => { + e.preventDefault(); + setIsDraggingDb(true); + }} + onDragLeave={() => setIsDraggingDb(false)} + onClick={() => dbFileInputRef.current?.click()} + className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${ + isDraggingDb ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-blue-400 hover:bg-gray-50' + } ${dbUploadMutation.isPending ? 'pointer-events-none opacity-60' : ''}`} + > + { + const file = e.target.files?.[0]; + if (file) handleDbFileUpload(file); + e.target.value = ''; + }} + /> + {dbUploadMutation.isPending ? ( +

Restoring database…

+ ) : ( + <> + +

Upload SQL dump to restore

+

.sql, .gz, or .dump — max 500MB

+ + )} +
+ {dbRestoreLogs && ( +
+                  {dbRestoreLogs}
+                
+ )} +
+ )} +
+ )} + + { + setShowUpgradeConfirm(false); + setUpgradeCostData(null); + setPendingUpgradePayload(null); + }} + onConfirm={confirmUpgrade} + /> +
+ ); +} diff --git a/frontend/src/components/optional-service-resource-fields.tsx b/frontend/src/components/optional-service-resource-fields.tsx new file mode 100644 index 0000000..f45807d --- /dev/null +++ b/frontend/src/components/optional-service-resource-fields.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { Server } from 'lucide-react'; +import type { OptionalServiceResourceConfig } from '@/types'; + +export function OptionalServiceResourceFields({ + title, + accentClass, + borderClass, + bgClass, + config, + readOnly, + onChange, +}: { + title: string; + accentClass: string; + borderClass: string; + bgClass: string; + config: OptionalServiceResourceConfig; + readOnly?: boolean; + onChange: (patch: Partial) => void; +}) { + const storageStr = String(config.storageGi); + const setStorage = (gb: number) => onChange({ storageGi: Math.max(0, gb) }); + + return ( +
+

+ + {title} +

+
+
+ + +
+
+ + +
+
+ +
+ + setStorage(parseInt(e.target.value, 10) || 0)} + className="w-16 text-center input-field py-1.5" + /> + + GB +
+
+
+
+ ); +} diff --git a/frontend/src/components/resource-upgrade-confirm-modal.tsx b/frontend/src/components/resource-upgrade-confirm-modal.tsx new file mode 100644 index 0000000..d7c8738 --- /dev/null +++ b/frontend/src/components/resource-upgrade-confirm-modal.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { AlertTriangle, CheckCircle, Clock, CreditCard, Wallet } from 'lucide-react'; + +export type UpgradeCostSummary = { + proratedAmount: number; + remainingHours: number; + currentCost: { hourly: number; monthly: number; yearly: number }; + newCost: { hourly: number; monthly: number; yearly: number }; +}; + +export function ResourceUpgradeConfirmModal({ + open, + upgradeCostData, + walletBalance, + isPending, + onCancel, + onConfirm, +}: { + open: boolean; + upgradeCostData: UpgradeCostSummary | null; + walletBalance?: number; + isPending: boolean; + onCancel: () => void; + onConfirm: () => void; +}) { + if (!open || !upgradeCostData) return null; + + const needsPay = upgradeCostData.proratedAmount > 0; + const walletShort = + needsPay && walletBalance != null && upgradeCostData.proratedAmount > walletBalance; + + return ( +
+
+

Confirm Resource Upgrade

+

+ {needsPay + ? 'This upgrade requires payment for the remaining billing period.' + : 'No additional cost for this change.'} +

+ +
+
+ Current hourly cost + + {upgradeCostData.currentCost.hourly.toLocaleString()} Toman/hour + +
+
+ New hourly cost + + {upgradeCostData.newCost.hourly.toLocaleString()} Toman/hour + +
+
+ Remaining hours in period + {upgradeCostData.remainingHours} hours +
+
+ Prorated amount to pay + + {upgradeCostData.proratedAmount.toLocaleString()} Toman + +
+
+ +
+
+ + Wallet Balance +
+ + {walletBalance?.toLocaleString() ?? 0} Toman + +
+ + {walletShort && ( +
+

+ + Wallet is short by{' '} + {(upgradeCostData.proratedAmount - (walletBalance ?? 0)).toLocaleString()} Toman. You can pay the + delta by gateway on the invoice page. +

+
+ )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/service-external-access-panel.tsx b/frontend/src/components/service-external-access-panel.tsx new file mode 100644 index 0000000..8b6fe8c --- /dev/null +++ b/frontend/src/components/service-external-access-panel.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import api from '@/lib/api'; +import { toast } from 'react-toastify'; +import type { Application, ServiceAccessGrant, ServiceAccessTarget } from '@/types'; +import { ExternalLink, ShieldAlert, Copy, Check, Eye, EyeOff } from 'lucide-react'; + +export function ServiceExternalAccessPanel({ + appId, + app, +}: { + appId: string; + app: Application; +}) { + const queryClient = useQueryClient(); + const [accessTarget, setAccessTarget] = useState('database'); + const [accessDuration, setAccessDuration] = useState(60); + const [accessPersistent, setAccessPersistent] = useState(false); + const [showAccessSecret, setShowAccessSecret] = useState(false); + const [copiedField, setCopiedField] = useState(null); + const [accessNow, setAccessNow] = useState(() => Date.now()); + + const accessTargetOptions: { value: ServiceAccessTarget; label: string }[] = []; + const hasDb = + (app.databaseType && app.databaseType !== 'none') || app.productType === 'managed_database'; + if (hasDb) accessTargetOptions.push({ value: 'database', label: 'Database' }); + if (app.enableRedis || app.productType === 'managed_redis') { + accessTargetOptions.push({ value: 'redis', label: 'Redis' }); + } + if (app.enableRabbitmq || app.productType === 'managed_rabbitmq') { + accessTargetOptions.push({ value: 'rabbitmq_amqp', label: 'RabbitMQ (AMQP)' }); + accessTargetOptions.push({ value: 'rabbitmq_management', label: 'RabbitMQ Management UI' }); + } + const hasAccessTargets = accessTargetOptions.length > 0; + + useEffect(() => { + if (!hasAccessTargets) return; + if (!accessTargetOptions.some((o) => o.value === accessTarget)) { + setAccessTarget(accessTargetOptions[0].value); + } + }, [app.databaseType, app.enableRedis, app.enableRabbitmq, app.productType]); + + const { data: accessGrants = [], refetch: refetchAccessGrants } = useQuery({ + queryKey: ['access-grants', appId], + queryFn: () => api.get(`/applications/${appId}/access`).then((r) => r.data), + enabled: hasAccessTargets && !!app.latestImageTag, + refetchInterval: 30000, + }); + + useEffect(() => { + if (!accessGrants.some((g) => g.status === 'active' && !g.persistent)) return; + const t = setInterval(() => setAccessNow(Date.now()), 1000); + return () => clearInterval(t); + }, [accessGrants]); + + const createAccessMutation = useMutation({ + mutationFn: () => + api + .post(`/applications/${appId}/access`, { + target: accessTarget, + persistent: accessPersistent, + ...(accessPersistent ? {} : { durationMinutes: accessDuration }), + }) + .then((r) => r.data), + onSuccess: () => { + refetchAccessGrants(); + queryClient.invalidateQueries({ queryKey: ['access-grants', appId] }); + toast.success(accessPersistent ? 'Permanent external access enabled' : 'Temporary external access enabled'); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Failed to enable access'); + }, + }); + + const revokeAccessMutation = useMutation({ + mutationFn: (grantId: string) => api.delete(`/applications/${appId}/access/${grantId}`), + onSuccess: () => { + refetchAccessGrants(); + queryClient.invalidateQueries({ queryKey: ['access-grants', appId] }); + toast.success('Access revoked'); + }, + onError: () => toast.error('Failed to revoke access'), + }); + + const copyToClipboard = (text: string, field: string) => { + navigator.clipboard.writeText(text); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 2000); + }; + + const accessTargetLabel = (target: ServiceAccessTarget) => + accessTargetOptions.find((o) => o.value === target)?.label || target; + + const formatAccessCountdown = (grant: ServiceAccessGrant) => { + if (grant.persistent) return 'Permanent (until revoked)'; + const ms = new Date(grant.expiresAt).getTime() - accessNow; + if (ms <= 0) return 'Expired'; + const totalSec = Math.floor(ms / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + if (h > 0) return `${h}h ${m}m ${s}s`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; + }; + + if (!hasAccessTargets) return null; + + return ( +
+

+ External access +

+

+ + Opens a NodePort on the cluster node IP. Anyone who can reach that IP may connect until the session ends or you + revoke it. Use short durations for temporary access; permanent keeps the port open until revoked. +

+ + {!app.latestImageTag ? ( +

Deploy the service first to enable external access.

+ ) : ( + <> +
+
+
+ + +
+
+ +
+ + +
+
+
+ + {!accessPersistent && ( +
+ +
+ {[30, 60, 240].map((mins) => ( + + ))} +
+
+ )} + + {accessPersistent && ( +

+ The port stays exposed until you click Revoke. Only use this when you need a stable external endpoint. +

+ )} + + +
+ + {accessGrants.filter((g) => g.status === 'active').length === 0 ? ( +

No active external access sessions.

+ ) : ( +
+ {accessGrants + .filter((g) => g.status === 'active') + .map((grant) => ( +
+
+
+ {accessTargetLabel(grant.target)} + {grant.persistent && ( + + Always open + + )} + {formatAccessCountdown(grant)} +
+ +
+
+
+ Endpoint +
+ + {grant.host}:{grant.port} + + +
+
+ {grant.connection.url && ( +
+ URL +
+ + {showAccessSecret ? grant.connection.url : '••••••••••••'} + + + +
+
+ )} +
+
+ ))} +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/workload-logs-panel.tsx b/frontend/src/components/workload-logs-panel.tsx new file mode 100644 index 0000000..ded6001 --- /dev/null +++ b/frontend/src/components/workload-logs-panel.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import api from '@/lib/api'; +import { FileText, ChevronDown, Monitor, Hammer, Pin } from 'lucide-react'; + +const statusColors: Record = { + running: 'badge-green', + pending: 'badge-yellow', + building: 'badge-blue', + deploying: 'badge-blue', + failed: 'badge-red', + build_failed: 'badge-red', + cancelled: 'badge-gray', + stopped: 'badge-gray', +}; + +export function WorkloadLogsPanel({ + appId, + showBuildLogs = true, + isRunning = false, + isStopped = false, + emptyPodMessage, +}: { + appId: string; + showBuildLogs?: boolean; + isRunning?: boolean; + isStopped?: boolean; + emptyPodMessage?: string; +}) { + const [showLogs, setShowLogs] = useState(false); + const [logTab, setLogTab] = useState<'pod' | 'build'>('pod'); + const logsEndRef = useRef(null); + + useEffect(() => { + if (!showBuildLogs && logTab === 'build') { + setLogTab('pod'); + } + }, [showBuildLogs, logTab]); + + const { data: logsData } = useQuery<{ logs: string }>({ + queryKey: ['logs', appId], + queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data), + enabled: showLogs && logTab === 'pod', + refetchInterval: showLogs && logTab === 'pod' ? 3000 : false, + }); + + const { data: buildLogsData } = useQuery<{ + buildLog: string | null; + status: string; + version: string | null; + }>({ + queryKey: ['build-logs', appId], + queryFn: () => api.get(`/deployments/applications/${appId}/build-logs`).then((r) => r.data), + enabled: showBuildLogs && showLogs && logTab === 'build', + refetchInterval: showBuildLogs && showLogs && logTab === 'build' ? 5000 : false, + }); + + const podPlaceholder = + emptyPodMessage || + (isRunning + ? 'Loading logs...' + : isStopped + ? 'Service is stopped. Start it to see logs.' + : 'Waiting for workload pods to be ready...'); + + return ( +
+
+

+ Logs +

+
+ {showLogs && logTab === 'pod' && ( + + + Live (every 3s) + + )} + {showBuildLogs && showLogs && logTab === 'build' && ( + + + Auto-refresh (every 5s) + + )} + +
+
+ + {showLogs && ( +
+ {showBuildLogs ? ( +
+ + +
+ ) : ( +

Workload pod output (no image build for this service).

+ )} + + {logTab === 'pod' && ( +
+              {logsData?.logs || podPlaceholder}
+            
+ )} + + {showBuildLogs && logTab === 'build' && ( +
+ {buildLogsData?.version && ( +
+ + {buildLogsData.version} + + + {buildLogsData.status} + +
+ )} +
+                {buildLogsData?.buildLog ||
+                  (buildLogsData?.status === 'building'
+                    ? 'Build in progress... Logs will appear when complete.'
+                    : buildLogsData?.status === 'pending'
+                      ? 'Build is pending...'
+                      : buildLogsData?.status === 'no_deployment'
+                        ? 'No deployments yet. Deploy your app to see build logs.'
+                        : 'No build logs available for this deployment.')}
+              
+
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/lib/format-utils.ts b/frontend/src/lib/format-utils.ts new file mode 100644 index 0000000..73bd5ef --- /dev/null +++ b/frontend/src/lib/format-utils.ts @@ -0,0 +1,20 @@ +export function formatBytes(bytes?: number): string { + if (!bytes) return '—'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function parseCpuToMillicores(cpu: string): number { + if (!cpu) return 0; + if (cpu.endsWith('m')) return parseFloat(cpu); + return parseFloat(cpu) * 1000; +} + +export function parseMemoryToMi(mem: string): number { + if (!mem) return 0; + if (mem.endsWith('Gi')) return parseFloat(mem) * 1024; + if (mem.endsWith('Mi')) return parseFloat(mem); + if (mem.endsWith('Ki')) return parseFloat(mem) / 1024; + return parseFloat(mem); +} diff --git a/frontend/src/lib/optional-service-defaults.ts b/frontend/src/lib/optional-service-defaults.ts new file mode 100644 index 0000000..edbedea --- /dev/null +++ b/frontend/src/lib/optional-service-defaults.ts @@ -0,0 +1,53 @@ +import type { + OptionalServiceResourceConfig, + OptionalServiceResourcesMap, + PricingCatalog, +} from '@/types'; + +export type OptionalServiceKey = keyof OptionalServiceResourcesMap; + +const FALLBACK_OPTIONAL_RESOURCES: Record = { + redis: { + cpuRequest: '50m', + cpuLimit: '200m', + memoryRequest: '64Mi', + memoryLimit: '256Mi', + storageGi: 1, + }, + rabbitmq: { + cpuRequest: '100m', + cpuLimit: '500m', + memoryRequest: '256Mi', + memoryLimit: '512Mi', + storageGi: 2, + }, +}; + +export function optionalDefaultsFromCatalog( + catalog: PricingCatalog | undefined, + service: OptionalServiceKey, +): OptionalServiceResourceConfig { + const profile = catalog?.optionalServices?.[service]?.profile; + const fallback = FALLBACK_OPTIONAL_RESOURCES[service]; + if (!profile) return { ...fallback }; + return { + cpuRequest: profile.cpuRequest || fallback.cpuRequest, + cpuLimit: profile.cpuLimit || fallback.cpuLimit, + memoryRequest: profile.memoryRequest || fallback.memoryRequest, + memoryLimit: profile.memoryLimit || fallback.memoryLimit, + storageGi: profile.storageGi ?? fallback.storageGi, + }; +} + +export function managedServiceTypeLabel(productType?: string): string { + switch (productType) { + case 'managed_database': + return 'Managed Database'; + case 'managed_redis': + return 'Managed Redis'; + case 'managed_rabbitmq': + return 'Managed RabbitMQ'; + default: + return 'Service'; + } +} diff --git a/frontend/src/lib/product-type.ts b/frontend/src/lib/product-type.ts new file mode 100644 index 0000000..926ddd7 --- /dev/null +++ b/frontend/src/lib/product-type.ts @@ -0,0 +1,24 @@ +import type { Application, ProductType } from '@/types'; + +const MANAGED_PRODUCT_TYPES: ProductType[] = [ + 'managed_database', + 'managed_redis', + 'managed_rabbitmq', +]; + +export function isManagedProduct(app: Pick): boolean { + const t = app.productType; + return !!t && MANAGED_PRODUCT_TYPES.includes(t); +} + +export function isApplicationProduct(app: Pick): boolean { + return !isManagedProduct(app); +} + +export function filterApplications(apps: Application[]): Application[] { + return apps.filter(isApplicationProduct); +} + +export function filterManagedServices(apps: Application[]): Application[] { + return apps.filter(isManagedProduct); +} diff --git a/frontend/src/lib/storage-utils.ts b/frontend/src/lib/storage-utils.ts new file mode 100644 index 0000000..bc6e1db --- /dev/null +++ b/frontend/src/lib/storage-utils.ts @@ -0,0 +1,6 @@ +/** Kubernetes-style Gi (1024³ bytes), aligned with wizard “GB” fields */ +export const ONE_GIB = 1024 ** 3; + +export function minGiToFitFileBytes(bytes: number): number { + return Math.max(1, Math.ceil(bytes / ONE_GIB)); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a2e9376..e33c268 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -9,9 +9,16 @@ export interface User { createdAt: string; } +export type ProductType = + | 'application' + | 'managed_database' + | 'managed_redis' + | 'managed_rabbitmq'; + export interface Application { id: string; name: string; + productType?: ProductType; description?: string; runtime: 'nodejs' | 'laravel' | 'wordpress' | 'go' | 'php' | 'python' | 'django' | 'dotnet'; databaseType: 'mysql' | 'postgresql' | 'mongodb' | 'mariadb' | 'none'; @@ -114,6 +121,7 @@ export interface ServiceAccessGrant { port: number; targetPort: number; expiresAt: string; + persistent?: boolean; status: 'active' | 'expired' | 'revoked'; connection: ServiceAccessConnection; } @@ -211,8 +219,9 @@ export interface AuthResponse { export interface CreateApplicationDto { name: string; + productType?: ProductType; description?: string; - runtime: 'nodejs' | 'laravel' | 'wordpress' | 'go' | 'php' | 'python' | 'django' | 'dotnet'; + runtime?: 'nodejs' | 'laravel' | 'wordpress' | 'go' | 'php' | 'python' | 'django' | 'dotnet'; databaseType: 'mysql' | 'postgresql' | 'mongodb' | 'mariadb' | 'none'; runtimeVersion?: string; phpVersion?: string; @@ -660,6 +669,7 @@ export interface AppSnapshot { dbDumpSize?: number; wpContentSize?: number; errorMessage?: string; + progress?: number; applicationId: string; createdBy: string; createdAt: string;