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;