Add managed databases and services with billing-aligned upgrades.
Introduce product types for managed PostgreSQL, Redis, and RabbitMQ with a dedicated dashboard, Helm-only deploy pipeline, external access, snapshots with progress, and prorated resource or storage upgrades matching application billing rules. PVCs use an expandable StorageClass with automatic migration when legacy disks cannot resize in place. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE service_access_grants
|
||||
ADD COLUMN IF NOT EXISTS persistent BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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;
|
||||
|
||||
@@ -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<Application> {
|
||||
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<Application[]> {
|
||||
return this.appsRepository.find({
|
||||
where: { userId },
|
||||
relations: ['deployments'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
async findAllByUser(
|
||||
userId: string,
|
||||
options?: { productType?: 'application' | 'managed' },
|
||||
): Promise<Application[]> {
|
||||
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<Application[]> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Application> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<typeof this.appToResourceConfig>,
|
||||
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<ResourceCredit | null> {
|
||||
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<typeof this.appToResourceConfig>,
|
||||
): CalculateCostDto {
|
||||
return {
|
||||
productType: config.productType,
|
||||
runtime: config.runtime,
|
||||
databaseType: config.databaseType,
|
||||
cpuLimit: config.cpuLimit,
|
||||
@@ -887,6 +919,7 @@ export class BillingService {
|
||||
patch: Partial<CalculateCostDto> = {},
|
||||
): CalculateCostDto {
|
||||
return {
|
||||
productType: credit.productType ?? ProductType.APPLICATION,
|
||||
runtime: credit.runtime,
|
||||
databaseType: credit.databaseType,
|
||||
cpuLimit: credit.cpuLimit,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<PricingResourceType, number> {
|
||||
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<PricingResourceType, number>();
|
||||
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<PricingResourceType, number> {
|
||||
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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<string, any> }> {
|
||||
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<BuildProgress | null> {
|
||||
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<Deployment> {
|
||||
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.');
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
const storageClass = this.configService.get<string>('platform.storageClass') || '';
|
||||
const createStorageClass = this.configService.get<boolean>('platform.createStorageClass') === true;
|
||||
const storageProvisioner =
|
||||
this.configService.get<string>('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<string, any> {
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
|
||||
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
|
||||
const productType = app.productType;
|
||||
|
||||
const values: Record<string, any> = {
|
||||
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<string, any> {
|
||||
const domain = this.configService.get('platform.domain');
|
||||
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
|
||||
@@ -149,13 +250,15 @@ export class KubernetesService implements OnModuleInit {
|
||||
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
|
||||
|
||||
const values: Record<string, any> = {
|
||||
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<Record<string, any>> {
|
||||
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<Record<string, any>> {
|
||||
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<void> {
|
||||
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<Record<string, any>> {
|
||||
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<Record<string, any>> {
|
||||
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<string, any> = {};
|
||||
|
||||
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<Record<string, any>> {
|
||||
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<string>('platform.storageClass')
|
||||
? { storageClassName: this.configService.get<string>('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<void> {
|
||||
const storageClass = this.configService.get<string>('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<string> {
|
||||
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<void> {
|
||||
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<string | undefined> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<string>('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) {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
|
||||
const snapshotDir = path.join(uploadDir, app.userId, app.id, 'snapshots', snapshotId);
|
||||
fs.mkdirSync(snapshotDir, { recursive: true });
|
||||
|
||||
const updates: Partial<AppSnapshot> = {};
|
||||
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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const logsEndRef = useRef<HTMLPreElement>(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<ServiceAccessTarget>('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<Application>({
|
||||
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<ResourceUsage>({
|
||||
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<ServiceAccessGrant[]>({
|
||||
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 ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RotateCw className="w-3 h-3 inline" /> Restart</>}
|
||||
</button>
|
||||
)}
|
||||
{!isInProgress && hasPaidAccess && (
|
||||
{!isInProgress && hasPaidAccess && isApplicationProduct(app) && (
|
||||
<button onClick={() => redeployMutation.mutate()} disabled={redeployMutation.isPending} className="btn-primary text-sm disabled:opacity-50" title="Rebuild from latest source code">
|
||||
{redeployMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RefreshCw className="w-3 h-3 inline" /> Redeploy</>}
|
||||
</button>
|
||||
@@ -1926,129 +1841,7 @@ export default function AppDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Temporary External Access */}
|
||||
{hasAccessTargets && (
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<ExternalLink className="w-5 h-5" /> Temporary External Access
|
||||
</h2>
|
||||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-lg px-3 py-2 mb-4 flex items-start gap-2">
|
||||
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
Opens a NodePort on the cluster node IP. Anyone who can reach that IP may connect until the timer ends. Use short durations only.
|
||||
</p>
|
||||
|
||||
{!app?.latestImageTag ? (
|
||||
<p className="text-sm text-gray-500">Deploy the application first to enable external access.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-4 flex flex-wrap gap-4 items-end">
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Service</label>
|
||||
<select
|
||||
value={accessTarget}
|
||||
onChange={(e) => setAccessTarget(e.target.value as ServiceAccessTarget)}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{accessTargetOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Duration</label>
|
||||
<div className="flex gap-2">
|
||||
{[30, 60, 240].map((mins) => (
|
||||
<button
|
||||
key={mins}
|
||||
type="button"
|
||||
onClick={() => setAccessDuration(mins)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
accessDuration === mins
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{mins < 60 ? `${mins}m` : `${mins / 60}h`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createAccessMutation.mutate()}
|
||||
disabled={createAccessMutation.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{createAccessMutation.isPending ? 'Opening…' : 'Enable access'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{accessGrants.filter((g) => g.status === 'active').length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No active external access sessions.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{accessGrants
|
||||
.filter((g) => g.status === 'active')
|
||||
.map((grant) => (
|
||||
<div key={grant.id} className="border border-gray-200 rounded-xl p-4 bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-800">{accessTargetLabel(grant.target)}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">ends in {formatAccessCountdown(grant.expiresAt)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => revokeAccessMutation.mutate(grant.id)}
|
||||
disabled={revokeAccessMutation.isPending}
|
||||
className="btn-secondary text-xs text-red-600 border-red-200 hover:bg-red-50"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-sm font-mono">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-gray-500 text-xs font-sans">Endpoint</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-800">{grant.host}:{grant.port}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(`${grant.host}:${grant.port}`, `access-endpoint-${grant.id}`)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
title="Copy"
|
||||
>
|
||||
{copiedField === `access-endpoint-${grant.id}` ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{grant.connection.url && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-gray-500 text-xs font-sans">URL</span>
|
||||
<div className="flex items-center gap-2 max-w-[70%]">
|
||||
<span className="text-gray-800 truncate text-xs" title={grant.connection.url}>
|
||||
{showAccessSecret ? grant.connection.url : '••••••••••••'}
|
||||
</span>
|
||||
<button type="button" onClick={() => setShowAccessSecret(!showAccessSecret)} className="p-1 text-gray-400 hover:text-gray-600">
|
||||
{showAccessSecret ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(grant.connection.url || '', `access-url-${grant.id}`)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{copiedField === `access-url-${grant.id}` ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{app && <ServiceExternalAccessPanel appId={appId} app={app} />}
|
||||
|
||||
{/* Resource Monitoring & Scaling */}
|
||||
<div className="card">
|
||||
@@ -2863,94 +2656,12 @@ export default function AppDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logs — Pod & Build */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"><FileText className="w-5 h-5" /> Logs</h2>
|
||||
<div className="flex items-center space-x-3">
|
||||
{showLogs && logTab === 'pod' && (
|
||||
<span className="text-xs text-gray-400 flex items-center space-x-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<span>Live (every 3s)</span>
|
||||
</span>
|
||||
)}
|
||||
{showLogs && logTab === 'build' && (
|
||||
<span className="text-xs text-gray-400 flex items-center space-x-1">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
<span>Auto-refresh (every 5s)</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
{showLogs ? <><ChevronDown className="w-4 h-4 inline" /> Hide Logs</> : <><FileText className="w-4 h-4 inline" /> Show Logs</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showLogs && (
|
||||
<div className="space-y-3">
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-1 bg-gray-100 rounded-xl p-1">
|
||||
<button
|
||||
onClick={() => setLogTab('pod')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
logTab === 'pod'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Monitor className="w-4 h-4 inline" /> Pod Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLogTab('build')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
logTab === 'build'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Hammer className="w-4 h-4 inline" /> Build Logs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pod logs */}
|
||||
{logTab === 'pod' && (
|
||||
<pre
|
||||
ref={logsEndRef}
|
||||
className="bg-gray-900 text-green-400 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words"
|
||||
>
|
||||
{logsData?.logs || (isRunning ? 'Loading logs...' : isStopped ? 'Application is stopped. Start it to see logs.' : 'Waiting for pod to be ready...')}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Build logs */}
|
||||
{logTab === 'build' && (
|
||||
<div>
|
||||
{buildLogsData?.version && (
|
||||
<div className="flex items-center gap-3 mb-2 text-xs text-gray-500">
|
||||
<span><Pin className="w-3 h-3 inline" /> {buildLogsData.version}</span>
|
||||
<span className={`badge ${statusColors[buildLogsData.status] || 'badge-gray'}`}>
|
||||
{buildLogsData.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<pre className="bg-gray-900 text-blue-300 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words">
|
||||
{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.'
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<WorkloadLogsPanel
|
||||
appId={appId}
|
||||
showBuildLogs={isApplicationProduct(app)}
|
||||
isRunning={isRunning}
|
||||
isStopped={isStopped}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
running: 'badge-green',
|
||||
@@ -51,10 +52,11 @@ export default function AppsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const { data: apps = [], isLoading } = useQuery<Application[]>({
|
||||
queryKey: ['applications'],
|
||||
queryFn: () => api.get('/applications').then((r) => r.data),
|
||||
const { data: appsRaw = [], isLoading } = useQuery<Application[]>({
|
||||
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}`),
|
||||
|
||||
@@ -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: <LayoutDashboard className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/apps', label: 'Applications', icon: <Package className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/services', label: 'Databases & Services', icon: <Database className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/logs', label: 'Logs', icon: <ScrollText className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/deploy', label: 'New Deploy', icon: <Rocket className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/wallet', label: 'Wallet', icon: <Wallet className="w-4 h-4" /> },
|
||||
|
||||
@@ -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<Application[]>({
|
||||
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<Application[]>({
|
||||
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() {
|
||||
<div className="card p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Application</label>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Resource</label>
|
||||
<select
|
||||
value={appId}
|
||||
onChange={(e) => {
|
||||
@@ -172,12 +204,25 @@ function LogsPageContent() {
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
<option value="">All applications</option>
|
||||
{applications.map((app) => (
|
||||
<option key={app.id} value={app.id}>
|
||||
{app.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="">All resources</option>
|
||||
{applications.length > 0 && (
|
||||
<optgroup label="Applications">
|
||||
{applications.map((app) => (
|
||||
<option key={app.id} value={app.id}>
|
||||
{app.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{managedServices.length > 0 && (
|
||||
<optgroup label="Databases & services">
|
||||
{managedServices.map((svc) => (
|
||||
<option key={svc.id} value={svc.id}>
|
||||
{svc.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -190,7 +235,7 @@ function LogsPageContent() {
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{WORKLOADS.map((w) => (
|
||||
{workloadOptions.map((w) => (
|
||||
<option key={w.value || 'all'} value={w.value}>
|
||||
{w.label}
|
||||
</option>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
running: 'bg-emerald-100 text-emerald-700',
|
||||
@@ -31,36 +33,61 @@ const statusIcons: Record<string, ReactNode> = {
|
||||
stopped: <Circle className="w-3 h-3 fill-gray-400 text-gray-400" />,
|
||||
};
|
||||
|
||||
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<Application[]>({
|
||||
queryKey: ['applications'],
|
||||
queryFn: () => api.get('/applications').then((r) => r.data),
|
||||
const { data: appsRaw = [], isLoading: appsLoading } = useQuery<Application[]>({
|
||||
queryKey: ['applications', 'application'],
|
||||
queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: servicesRaw = [], isLoading: servicesLoading } = useQuery<Application[]>({
|
||||
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<ResourceCredit[]>({
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
{/* Welcome */}
|
||||
<div>
|
||||
<h1 className="page-title">
|
||||
Welcome back, {user?.firstName}!
|
||||
</h1>
|
||||
<p className="page-subtitle">
|
||||
Here's an overview of your applications.
|
||||
</p>
|
||||
<h1 className="page-title">Welcome back, {user?.firstName}!</h1>
|
||||
<p className="page-subtitle">Overview of your applications and managed services.</p>
|
||||
</div>
|
||||
|
||||
{resourceCredits.length > 0 && (
|
||||
@@ -70,7 +97,8 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<h2 className="font-semibold text-indigo-900">Prepaid resource credits</h2>
|
||||
<p className="text-sm text-indigo-700 mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,58 +132,59 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Apps</div>
|
||||
<div className="stat-label">Applications</div>
|
||||
<div className="stat-value text-gray-900">{isLoading ? '—' : apps.length}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Running</div>
|
||||
<div className="stat-value text-emerald-600">{isLoading ? '—' : runningApps.length}</div>
|
||||
<div className="stat-label">Apps running</div>
|
||||
<div className="stat-value text-emerald-600">{isLoading ? '—' : runningApps}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Failed</div>
|
||||
<div className="stat-value text-red-600">{isLoading ? '—' : failedApps.length}</div>
|
||||
<div className="stat-label">Managed services</div>
|
||||
<div className="stat-value text-gray-900">{isLoading ? '—' : services.length}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Deploys</div>
|
||||
<div className="stat-value text-primary-600">
|
||||
{isLoading ? '—' : apps.reduce((sum, a) => sum + (a.deployments?.length || 0), 0)}
|
||||
</div>
|
||||
<div className="stat-label">Services running</div>
|
||||
<div className="stat-value text-emerald-600">{isLoading ? '—' : runningServices}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Applications */}
|
||||
{(failedApps > 0 || failedServices > 0) && (
|
||||
<div className="text-sm text-red-600 font-medium">
|
||||
{failedApps > 0 && `${failedApps} application${failedApps !== 1 ? 's' : ''} failed`}
|
||||
{failedApps > 0 && failedServices > 0 && ' · '}
|
||||
{failedServices > 0 && `${failedServices} service${failedServices !== 1 ? 's' : ''} failed`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Applications */}
|
||||
<div>
|
||||
<div className="page-header mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Recent Applications</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Recent applications</h2>
|
||||
<Link href="/dashboard/deploy" className="btn-primary text-sm">
|
||||
<Rocket className="w-4 h-4 mr-1 inline" /> New Application
|
||||
<Rocket className="w-4 h-4 mr-1 inline" /> New application
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
{appsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-40" />
|
||||
<div className="skeleton h-3 w-64" />
|
||||
</div>
|
||||
<div className="skeleton h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : apps.length === 0 ? (
|
||||
<div className="card text-center py-16">
|
||||
<Package className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-600 text-lg font-medium">No applications yet</p>
|
||||
<p className="text-gray-400 mt-1 text-sm">
|
||||
Deploy your first application to get started.
|
||||
</p>
|
||||
<Link href="/dashboard/deploy" className="btn-primary mt-6 inline-flex items-center gap-1">
|
||||
<div className="card text-center py-12">
|
||||
<Package className="w-10 h-10 mx-auto mb-3 text-gray-300" />
|
||||
<p className="text-gray-600 font-medium">No applications yet</p>
|
||||
<Link href="/dashboard/deploy" className="btn-primary mt-4 inline-flex items-center gap-1 text-sm">
|
||||
<Rocket className="w-4 h-4" /> Deploy your first app
|
||||
</Link>
|
||||
</div>
|
||||
@@ -170,14 +199,14 @@ export default function DashboardPage() {
|
||||
href={`/dashboard/apps/${app.id}`}
|
||||
className="card-hover flex items-center justify-between group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-primary-50 flex items-center justify-center shrink-0">
|
||||
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
|
||||
<Hexagon
|
||||
className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-primary-700 transition-colors truncate">
|
||||
{app.name}
|
||||
</h3>
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-primary-700 truncate">{app.name}</h3>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{app.runtime} · {app.replicas} replica{app.replicas > 1 ? 's' : ''}
|
||||
{app.databaseType !== 'none' && ` · ${app.databaseType}`}
|
||||
@@ -186,24 +215,89 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{statusIcons[status]}
|
||||
<span className={`badge ${statusColors[status] || 'badge-gray'}`}>
|
||||
{status}
|
||||
</span>
|
||||
<span className={`badge ${statusColors[status] || 'badge-gray'}`}>{status}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{apps.length > 5 && (
|
||||
<Link
|
||||
href="/dashboard/apps"
|
||||
className="text-center text-sm text-primary-600 hover:text-primary-700 font-medium py-3"
|
||||
>
|
||||
<Link href="/dashboard/apps" className="text-center text-sm text-primary-600 hover:text-primary-700 font-medium py-2">
|
||||
View all {apps.length} applications →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Managed services */}
|
||||
<div>
|
||||
<div className="page-header mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Recent databases & services</h2>
|
||||
<Link href="/dashboard/services/new" className="btn-primary text-sm">
|
||||
<Plus className="w-4 h-4 mr-1 inline" /> New service
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{servicesLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-40" />
|
||||
<div className="skeleton h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : services.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<Database className="w-10 h-10 mx-auto mb-3 text-gray-300" />
|
||||
<p className="text-gray-600 font-medium">No managed services yet</p>
|
||||
<Link href="/dashboard/services/new" className="btn-primary mt-4 inline-flex items-center gap-1 text-sm">
|
||||
<Plus className="w-4 h-4" /> Create database or service
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{services.slice(0, 5).map((svc) => {
|
||||
const latestDeploy = svc.deployments?.[0];
|
||||
const status = latestDeploy?.status || 'pending';
|
||||
return (
|
||||
<Link
|
||||
key={svc.id}
|
||||
href={`/dashboard/services/${svc.id}`}
|
||||
className="card-hover flex items-center justify-between group"
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-blue-50 flex items-center justify-center shrink-0">
|
||||
<Database className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-primary-700 truncate">{svc.name}</h3>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{managedServiceTypeLabel(svc.productType)} · {serviceSubtitle(svc)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{statusIcons[status]}
|
||||
<span className={`badge ${statusColors[status] || 'badge-gray'}`}>{status}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{services.length > 5 && (
|
||||
<Link
|
||||
href="/dashboard/services"
|
||||
className="text-center text-sm text-primary-600 hover:text-primary-700 font-medium py-2"
|
||||
>
|
||||
View all {services.length} services →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string | null>(null);
|
||||
const [showRenewalModal, setShowRenewalModal] = useState(false);
|
||||
const [selectedCycle, setSelectedCycle] = useState<'hourly' | 'monthly' | 'yearly'>('monthly');
|
||||
const [showServiceSecrets, setShowServiceSecrets] = useState(false);
|
||||
|
||||
const { data: app, isLoading } = useQuery<AppWithOptional>({
|
||||
queryKey: ['application', serviceId],
|
||||
queryFn: () => api.get(`/applications/${serviceId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: deployments = [] } = useQuery<Deployment[]>({
|
||||
queryKey: ['deployments', serviceId],
|
||||
queryFn: () => api.get(`/deployments/applications/${serviceId}`).then((r) => r.data),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: serviceCredentials } = useQuery<OptionalServiceCredentials>({
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="skeleton h-8 w-48" />
|
||||
<div className="card skeleton h-40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/services" className="btn-ghost">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="page-title">{app.name}</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{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}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={`badge ${latestStatus === 'running' ? 'badge-green' : 'badge-yellow'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
{!isDeployed && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
onClick={() => deployMutation.mutate()}
|
||||
disabled={deployMutation.isPending || needsRenewal}
|
||||
>
|
||||
<Rocket className="w-4 h-4 inline mr-1" /> Deploy
|
||||
</button>
|
||||
)}
|
||||
{isDeployed && !needsRenewal && !isInProgress && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
onClick={() => redeployMutation.mutate()}
|
||||
disabled={redeployMutation.isPending}
|
||||
title="Re-run Helm install for this service"
|
||||
>
|
||||
{redeployMutation.isPending ? (
|
||||
<Clock className="w-4 h-4 inline animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 inline mr-1" />
|
||||
)}
|
||||
Redeploy
|
||||
</button>
|
||||
)}
|
||||
{isDeployed && isRunning && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary text-sm"
|
||||
onClick={() => restartMutation.mutate()}
|
||||
disabled={restartMutation.isPending || isInProgress}
|
||||
>
|
||||
{restartMutation.isPending ? (
|
||||
<Clock className="w-4 h-4 inline animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="w-4 h-4 inline mr-1" />
|
||||
)}
|
||||
Restart
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger text-sm"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete service?',
|
||||
message: `Delete "${app.name}" permanently?`,
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsRenewal && (
|
||||
<div className="rounded-xl p-4 border-2 bg-amber-50 border-amber-300 flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<AlertTriangle className="w-6 h-6 text-amber-600 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-amber-800">Payment required</h3>
|
||||
<p className="text-sm text-amber-600">Renew to restore this service.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRenewalModal(true)}
|
||||
className="btn-primary bg-amber-600 hover:bg-amber-700"
|
||||
>
|
||||
<CreditCard className="w-4 h-4 inline mr-1" /> Renew
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{app.planExpiresAt && (
|
||||
<div className="card flex items-center gap-3 text-sm text-gray-600">
|
||||
<Clock className="w-4 h-4" />
|
||||
Plan expires: {new Date(app.planExpiresAt).toLocaleString()}
|
||||
{app.billingCycle && ` (${app.billingCycle})`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Configuration</h2>
|
||||
<dl className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Service type</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{managedServiceTypeLabel(app.productType)}</dd>
|
||||
</div>
|
||||
{app.productType === 'managed_database' && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Database engine</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">
|
||||
{app.databaseType}
|
||||
{app.databaseType !== 'none' && app.dbVersion ? ` v${app.dbVersion}` : ''}
|
||||
</dd>
|
||||
</div>
|
||||
{app.databaseType !== 'none' && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Storage</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{app.dbStorageSize || '1Gi'}</dd>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{app.productType === 'managed_redis' && app.redisVersion && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Redis version</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">v{app.redisVersion}</dd>
|
||||
</div>
|
||||
)}
|
||||
{app.productType === 'managed_rabbitmq' && app.rabbitmqVersion && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">RabbitMQ version</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">v{app.rabbitmqVersion}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">CPU</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{cpuDisplay}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Memory</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{memDisplay}</dd>
|
||||
</div>
|
||||
{app.billingCycle && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Billing</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 capitalize">{app.billingCycle}</dd>
|
||||
</div>
|
||||
)}
|
||||
{app.latestImageTag && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Deploy marker</dt>
|
||||
<dd className="text-xs font-mono text-gray-700 truncate max-w-[200px]" title={app.latestImageTag}>
|
||||
{app.latestImageTag}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Deployment History</h2>
|
||||
{deployments.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="w-8 h-8 mx-auto text-gray-300 mb-2" />
|
||||
<p className="text-gray-500 text-sm">No deployments yet</p>
|
||||
<p className="text-gray-400 text-xs mt-1">Click Deploy to provision this service</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-72 overflow-y-auto">
|
||||
{deployments.slice(0, 10).map((d) => (
|
||||
<div key={d.id} className="flex items-center justify-between p-3 bg-gray-50/80 rounded-xl">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{d.version || d.imageTag || 'Provision'}</p>
|
||||
<p className="text-xs text-gray-500">{new Date(d.createdAt).toLocaleString()}</p>
|
||||
{d.errorMessage && (
|
||||
<p className="text-xs text-red-500 mt-1 truncate" title={d.errorMessage}>
|
||||
<XCircle className="w-3 h-3 inline" /> {d.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`badge ${statusColors[d.status] || 'badge-gray'} ml-2 shrink-0`}>
|
||||
{d.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{app.productType === 'managed_database' && app.databaseType !== 'none' && (
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-blue-500" /> Connection
|
||||
<span className="badge badge-blue text-xs">{app.databaseType}</span>
|
||||
</h2>
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Internal cluster</h3>
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={field} className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<div className="flex items-center gap-1 font-mono text-sm">
|
||||
{value}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(value, field)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{copiedField === field ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">Password</span>
|
||||
<div className="flex items-center gap-1 font-mono text-sm">
|
||||
{showDbPassword ? app.dbPassword || '—' : '••••••••'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDbPassword(!showDbPassword)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showDbPassword ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(app.dbPassword || '', 'password')}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{copiedField === 'password' ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 pt-2 border-t border-gray-200">
|
||||
Use external access below for internet-facing connections.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(app.productType === 'managed_redis' || app.productType === 'managed_rabbitmq') && (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5" /> Connection
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowServiceSecrets(!showServiceSecrets)}
|
||||
className="btn-secondary text-xs inline-flex items-center gap-1"
|
||||
>
|
||||
{showServiceSecrets ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
{showServiceSecrets ? 'Hide secrets' : 'Show secrets'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{app.productType === 'managed_redis' && (
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Redis (internal)</h3>
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={field} className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs font-mono text-gray-800 truncate" title={value}>
|
||||
{secret && !showServiceSecrets ? '••••••••••••' : value || '—'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(value || '', field)}
|
||||
disabled={!value}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
{copiedField === field ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{app.productType === 'managed_rabbitmq' && (
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700">RabbitMQ (internal)</h3>
|
||||
{[
|
||||
{
|
||||
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 }) => (
|
||||
<div key={field} className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs font-mono text-gray-800 truncate" title={value}>
|
||||
{secret && !showServiceSecrets ? '••••••••••••' : value || '—'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(value || '', field)}
|
||||
disabled={!value}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
{copiedField === field ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDeployed && (
|
||||
<p className="text-xs text-gray-400 mt-3">Deploy the service to load live credentials.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ServiceExternalAccessPanel appId={serviceId} app={app} />
|
||||
|
||||
<ManagedServiceResourcesPanel
|
||||
serviceId={serviceId}
|
||||
app={app}
|
||||
isDeployed={isDeployed}
|
||||
isStopped={isStopped}
|
||||
needsRenewal={needsRenewal}
|
||||
/>
|
||||
|
||||
{app.productType === 'managed_database' && app.databaseType !== 'none' && (
|
||||
<DatabaseSnapshotsPanel serviceId={serviceId} isDeployed={isDeployed} />
|
||||
)}
|
||||
|
||||
<WorkloadLogsPanel
|
||||
appId={serviceId}
|
||||
showBuildLogs={false}
|
||||
isRunning={isRunning}
|
||||
isStopped={isStopped}
|
||||
emptyPodMessage={
|
||||
isRunning
|
||||
? 'Loading logs...'
|
||||
: isStopped
|
||||
? 'Service is stopped.'
|
||||
: 'Deploy or re-provision the service to see workload logs.'
|
||||
}
|
||||
/>
|
||||
|
||||
{showRenewalModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6">
|
||||
<h2 className="text-xl font-bold mb-2">Renew service</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">"{app.name}"</p>
|
||||
<div className="bg-gray-50 rounded-xl p-3 mb-4 flex justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Wallet className="w-4 h-4" /> Wallet
|
||||
</span>
|
||||
<strong>{walletData?.balance?.toLocaleString() ?? 0} T</strong>
|
||||
</div>
|
||||
<div className="space-y-2 mb-6">
|
||||
{(['hourly', 'monthly', 'yearly'] as const).map((cycle) => (
|
||||
<label
|
||||
key={cycle}
|
||||
className={`flex justify-between p-3 border-2 rounded-xl cursor-pointer ${
|
||||
selectedCycle === cycle ? 'border-primary-500 bg-primary-50' : 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => setSelectedCycle(cycle)}
|
||||
>
|
||||
<span className="capitalize font-medium">{cycle}</span>
|
||||
<span className="font-bold">
|
||||
{renewalCostData?.costs[cycle]?.toLocaleString() ?? '—'} T
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="btn-secondary flex-1" onClick={() => setShowRenewalModal(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary flex-1"
|
||||
disabled={renewMutation.isPending}
|
||||
onClick={() => renewMutation.mutate(selectedCycle)}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 inline mr-1" />
|
||||
Pay from wallet
|
||||
</button>
|
||||
</div>
|
||||
{renewalCost != null && walletData && walletData.balance < renewalCost && (
|
||||
<p className="text-xs text-amber-600 mt-3">
|
||||
Insufficient wallet balance. Top up your wallet or pay via invoice.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ServiceKind | null>(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<File | null>(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<PricingCatalog>({
|
||||
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<DeployCostPreview>({
|
||||
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<Application>('/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<Application>('/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 (
|
||||
<div className="max-w-2xl mx-auto space-y-8 animate-fade-in">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/services" className="btn-ghost">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="page-title">New managed service</h1>
|
||||
<p className="page-subtitle">Database, Redis, or RabbitMQ — billed like applications</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((label, i) => (
|
||||
<div key={label} className="flex items-center flex-1 last:flex-none">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`flex items-center justify-center w-9 h-9 rounded-full text-sm font-bold ${
|
||||
i < step
|
||||
? 'bg-emerald-500 text-white'
|
||||
: i === step
|
||||
? 'bg-primary-600 text-white ring-4 ring-primary-100'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{i < step ? '✓' : i + 1}
|
||||
</div>
|
||||
<span className={`mt-1.5 text-xs font-medium hidden sm:block ${i <= step ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 rounded-full ${i < step ? 'bg-emerald-400' : 'bg-gray-200'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{step === 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
{ 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) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setKind(opt.id);
|
||||
if (opt.id === 'managed_redis' && !form.optionalServiceResources?.redis) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
optionalServiceResources: {
|
||||
redis: optionalDefaultsFromCatalog(pricingCatalog, 'redis'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (opt.id === 'managed_rabbitmq' && !form.optionalServiceResources?.rabbitmq) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
optionalServiceResources: {
|
||||
rabbitmq: optionalDefaultsFromCatalog(pricingCatalog, 'rabbitmq'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className={`p-5 rounded-xl border-2 text-left transition-all ${
|
||||
kind === opt.id ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Database className={`w-8 h-8 mb-2 ${kind === opt.id ? 'text-primary-600' : 'text-gray-400'}`} />
|
||||
<p className="font-semibold text-gray-900">{opt.title}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{opt.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && kind && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Service name</label>
|
||||
<input
|
||||
className="input-field"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
|
||||
placeholder="my-database"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Lowercase letters, numbers, and hyphens only</p>
|
||||
</div>
|
||||
|
||||
{kind === 'managed_database' && (
|
||||
<>
|
||||
<ManagedDatabaseConfig
|
||||
form={dbForm}
|
||||
onChange={(patch) => setForm({ ...form, ...patch })}
|
||||
dbDumpFile={dbDumpFile}
|
||||
onDbDumpFileChange={setDbDumpFile}
|
||||
/>
|
||||
<DatabaseWorkloadResources
|
||||
values={{
|
||||
cpuRequest: form.cpuRequest,
|
||||
cpuLimit: form.cpuLimit,
|
||||
memoryRequest: form.memoryRequest,
|
||||
memoryLimit: form.memoryLimit,
|
||||
}}
|
||||
onChange={(patch) => setForm({ ...form, ...patch })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{kind === 'managed_redis' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Redis version</label>
|
||||
<select
|
||||
className="input-field max-w-xs"
|
||||
value={form.redisVersion}
|
||||
onChange={(e) => setForm({ ...form, redisVersion: e.target.value })}
|
||||
>
|
||||
{['7.2', '7.0', '6.2', '6.0'].map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{form.optionalServiceResources?.redis && (
|
||||
<OptionalServiceResourceFields
|
||||
title="Redis resources"
|
||||
accentClass="text-red-500"
|
||||
borderClass="border-red-400"
|
||||
bgClass="bg-red-50"
|
||||
config={form.optionalServiceResources.redis}
|
||||
onChange={(patch) =>
|
||||
setForm({
|
||||
...form,
|
||||
optionalServiceResources: {
|
||||
...form.optionalServiceResources,
|
||||
redis: { ...form.optionalServiceResources!.redis!, ...patch },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'managed_rabbitmq' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">RabbitMQ version</label>
|
||||
<select
|
||||
className="input-field max-w-xs"
|
||||
value={form.rabbitmqVersion}
|
||||
onChange={(e) => setForm({ ...form, rabbitmqVersion: e.target.value })}
|
||||
>
|
||||
{['3.13', '3.12', '3.11', '3.10'].map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{form.optionalServiceResources?.rabbitmq && (
|
||||
<OptionalServiceResourceFields
|
||||
title="RabbitMQ resources"
|
||||
accentClass="text-orange-500"
|
||||
borderClass="border-orange-400"
|
||||
bgClass="bg-orange-50"
|
||||
config={form.optionalServiceResources.rabbitmq}
|
||||
onChange={(patch) =>
|
||||
setForm({
|
||||
...form,
|
||||
optionalServiceResources: {
|
||||
...form.optionalServiceResources,
|
||||
rabbitmq: { ...form.optionalServiceResources!.rabbitmq!, ...patch },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-5">
|
||||
{costLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
) : costData ? (
|
||||
<>
|
||||
<div className="bg-emerald-50 rounded-xl p-4 border border-emerald-200">
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">Billing cycle</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['hourly', 'monthly', 'yearly'] as const).map((cycle) => (
|
||||
<button
|
||||
key={cycle}
|
||||
type="button"
|
||||
onClick={() => setSelectedCycle(cycle)}
|
||||
className={`py-3 rounded-lg border-2 text-sm font-medium capitalize ${
|
||||
selectedCycle === cycle ? 'border-emerald-500 bg-white' : 'border-transparent bg-white/50'
|
||||
}`}
|
||||
>
|
||||
{cycle}
|
||||
<span className="block text-lg font-bold text-emerald-700 mt-1">
|
||||
{Number(costData[cycle]).toLocaleString('en-US')} T
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{requiresPayment && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Payment method</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('wallet')}
|
||||
className={`p-4 rounded-xl border-2 text-left ${
|
||||
paymentMethod === 'wallet' ? 'border-primary-500 bg-primary-50' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Wallet className="w-5 h-5 text-primary-600" />
|
||||
<p className="font-semibold text-sm mt-2">Wallet</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{Number(walletBalance).toLocaleString('en-US')} T
|
||||
{!hasEnoughBalance && <span className="text-red-500 block">Insufficient</span>}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('gateway')}
|
||||
className={`p-4 rounded-xl border-2 text-left ${
|
||||
paymentMethod === 'gateway' ? 'border-primary-500 bg-primary-50' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-5 h-5 text-emerald-600" />
|
||||
<p className="font-semibold text-sm mt-2">Pay now</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 text-center">Pricing unavailable</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between mt-8 pt-6 border-t border-gray-100">
|
||||
<button type="button" onClick={() => setStep(step - 1)} disabled={step === 0} className="btn-ghost disabled:opacity-0">
|
||||
← Back
|
||||
</button>
|
||||
{step < 2 ? (
|
||||
<button type="button" onClick={() => setStep(step + 1)} disabled={!canNext()} className="btn-primary disabled:opacity-50">
|
||||
Next →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={walletPayMutation.isPending || gatewayPayMutation.isPending || deployStage !== 'idle'}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
onClick={handlePay}
|
||||
>
|
||||
{walletPayMutation.isPending || gatewayPayMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin inline" />
|
||||
) : (
|
||||
'Pay & provision'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deployStage !== 'idle' && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 space-y-4">
|
||||
<h3 className="font-bold text-gray-900">Provisioning service</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
{deployStage === 'creating' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary-600" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
Creating service
|
||||
</div>
|
||||
{dbDumpFile && kind === 'managed_database' && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{deployStage === 'uploading-db' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary-600" />
|
||||
) : ['paying', 'deploying', 'done'].includes(deployStage) ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
|
||||
)}
|
||||
Uploading database dump
|
||||
{deployStage === 'uploading-db' && dbUploadProgress > 0 && (
|
||||
<span className="text-primary-600 font-semibold">{dbUploadProgress}%</span>
|
||||
)}
|
||||
</div>
|
||||
{deployStage === 'uploading-db' && (
|
||||
<div className="ml-6 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary-500 transition-all" style={{ width: `${dbUploadProgress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{deployStage === 'paying' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : ['deploying', 'done'].includes(deployStage) ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
|
||||
)}
|
||||
Payment
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{deployStage === 'deploying' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : deployStage === 'done' ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : deployStage === 'error' ? (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
|
||||
)}
|
||||
Deploying
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RestoreStorageErrorModal
|
||||
open={showRestoreStorageErrorModal}
|
||||
message={restoreStorageErrorMessage}
|
||||
onClose={() => setShowRestoreStorageErrorModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<Application[]>({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div className="skeleton h-8 w-56" />
|
||||
<div className="skeleton h-10 w-40 rounded-xl" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-36" />
|
||||
<div className="skeleton h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Databases & Services</h1>
|
||||
<p className="page-subtitle">
|
||||
Standalone databases, Redis, and RabbitMQ — {services.length} service{services.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/services/new" className="btn-primary">
|
||||
<Plus className="w-4 h-4 mr-1 inline" /> New Service
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{services.length === 0 ? (
|
||||
<div className="card text-center py-16">
|
||||
<Database className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-600 text-lg font-medium">No managed services yet</p>
|
||||
<p className="text-gray-400 mt-1 text-sm">
|
||||
Provision a database, Redis, or RabbitMQ without deploying a full application.
|
||||
</p>
|
||||
<Link href="/dashboard/services/new" className="btn-primary mt-6 inline-flex items-center gap-1">
|
||||
<Plus className="w-4 h-4" /> Create your first service
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{services.map((svc) => {
|
||||
const lifecycle = svc.lifecycleStatus || 'active';
|
||||
const expiry = formatExpiry(svc.planExpiresAt);
|
||||
const latestStatus = svc.deployments?.[0]?.status || 'pending';
|
||||
return (
|
||||
<div
|
||||
key={svc.id}
|
||||
className={`card-hover flex flex-col sm:flex-row sm:items-center gap-4 ${
|
||||
lifecycle === 'suspended' ? 'border-l-4 border-l-amber-400' : ''
|
||||
} ${lifecycle === 'pending_deletion' ? 'border-l-4 border-l-red-400' : ''}`}
|
||||
>
|
||||
<Link href={`/dashboard/services/${svc.id}`} className="flex items-center gap-3 flex-1 min-w-0 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-indigo-50 flex items-center justify-center shrink-0">
|
||||
<Database className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-gray-900 group-hover:text-primary-600 truncate">{svc.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{managedServiceTypeLabel(svc.productType)} · {serviceSubtitle(svc)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<span className={`badge ${latestStatus === 'running' ? 'badge-green' : 'badge-yellow'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||
lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
|
||||
{lifecycleLabels[lifecycle] || lifecycle}
|
||||
</span>
|
||||
<span className={`text-xs ${expiry.urgent ? 'text-red-600 font-semibold' : 'text-gray-500'}`}>
|
||||
{expiry.text}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-sm text-red-600"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete service?',
|
||||
message: `Permanently delete "${svc.name}"? This cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate(svc.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="flex justify-between text-xs text-blue-700">
|
||||
<span>{label || 'Creating database dump…'}</span>
|
||||
<span className="font-semibold tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2.5 overflow-hidden">
|
||||
<div
|
||||
className="h-2.5 rounded-full bg-blue-600 transition-all duration-500 ease-out"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatabaseSnapshotsPanel({
|
||||
serviceId,
|
||||
isDeployed,
|
||||
}: {
|
||||
serviceId: string;
|
||||
isDeployed: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
const prevInProgressRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const { data: snapshots = [], isLoading } = useQuery<AppSnapshot[]>({
|
||||
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 (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<History className="w-5 h-5" /> Snapshots
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewBackup}
|
||||
disabled={!isDeployed || createMutation.isPending || hasInProgress}
|
||||
className="btn-secondary text-sm disabled:opacity-50"
|
||||
title={
|
||||
!isDeployed
|
||||
? 'Deploy the service first'
|
||||
: hasInProgress
|
||||
? 'Wait for the current backup to finish'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{createMutation.isPending || hasInProgress ? (
|
||||
<>
|
||||
<Clock className="w-3 h-3 inline animate-spin" /> Creating…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Camera className="w-3 h-3 inline" /> New Snapshot
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowPanel(!showPanel)} className="btn-secondary text-sm">
|
||||
{showPanel ? (
|
||||
<>
|
||||
<ChevronDown className="w-4 h-4 inline" /> Hide
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<History className="w-4 h-4 inline" /> Show
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPanel && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
<Camera className="w-3 h-3 inline" /> <strong>Database snapshots</strong> 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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">Loading backups…</div>
|
||||
) : snapshots.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Camera className="w-8 h-8 mx-auto text-gray-300 mb-2" />
|
||||
<p className="text-gray-500 text-sm">No backups yet</p>
|
||||
<p className="text-gray-400 text-xs mt-1">Click New backup to create your first database dump.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
||||
{snapshots.map((snap) => (
|
||||
<div
|
||||
key={snap.id}
|
||||
className={`border rounded-xl p-4 ${
|
||||
snap.status === 'completed'
|
||||
? 'border-gray-200 bg-white'
|
||||
: snap.status === 'in_progress'
|
||||
? 'border-blue-200 bg-blue-50'
|
||||
: 'border-red-200 bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{snap.label || 'Database backup'}</p>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
snap.type === 'pre_deploy' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{snap.type === 'pre_deploy' ? 'Auto' : 'Manual'}
|
||||
</span>
|
||||
<span className={`badge ${statusColors[snap.status] || 'badge-gray'} text-xs`}>
|
||||
{snap.status === 'in_progress' ? 'Dumping…' : snap.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{new Date(snap.createdAt).toLocaleString()}</p>
|
||||
|
||||
{snap.status === 'in_progress' && (
|
||||
<BackupProgressBar
|
||||
progress={snap.progress ?? 0}
|
||||
label={
|
||||
(snap.progress ?? 0) < 15
|
||||
? 'Preparing dump…'
|
||||
: (snap.progress ?? 0) < 90
|
||||
? 'Exporting database…'
|
||||
: 'Finalizing…'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{snap.status === 'completed' && snap.dbDumpPath && (
|
||||
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
|
||||
<Database className="w-3 h-3" /> Dump: {formatBytes(snap.dbDumpSize)}
|
||||
</p>
|
||||
)}
|
||||
{snap.errorMessage && (
|
||||
<p className="text-xs text-red-500 mt-1 truncate" title={snap.errorMessage}>
|
||||
<XCircle className="w-3 h-3 inline" /> {snap.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{snap.status === 'completed' && snap.dbDumpPath && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadSnapshotDb(snap.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg"
|
||||
title="Download database dump"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(snap)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg disabled:opacity-50"
|
||||
title="Delete backup"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{snap.status === 'failed' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(snap)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg shrink-0"
|
||||
title="Remove failed backup"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400 text-center">Maximum 10 backups are kept.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<DatabaseWorkloadValues>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-800">Database resources</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">CPU request</label>
|
||||
<select
|
||||
className="input-field text-sm"
|
||||
value={values.cpuRequest}
|
||||
onChange={(e) => onChange({ cpuRequest: e.target.value })}
|
||||
>
|
||||
<option value="50m">50m</option>
|
||||
<option value="100m">100m</option>
|
||||
<option value="250m">250m</option>
|
||||
<option value="500m">500m</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">CPU limit</label>
|
||||
<select
|
||||
className="input-field text-sm"
|
||||
value={values.cpuLimit}
|
||||
onChange={(e) => onChange({ cpuLimit: e.target.value })}
|
||||
>
|
||||
<option value="250m">250m</option>
|
||||
<option value="500m">500m</option>
|
||||
<option value="1">1 core</option>
|
||||
<option value="2">2 cores</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Memory request</label>
|
||||
<select
|
||||
className="input-field text-sm"
|
||||
value={values.memoryRequest}
|
||||
onChange={(e) => onChange({ memoryRequest: e.target.value })}
|
||||
>
|
||||
<option value="64Mi">64 Mi</option>
|
||||
<option value="128Mi">128 Mi</option>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Memory limit</label>
|
||||
<select
|
||||
className="input-field text-sm"
|
||||
value={values.memoryLimit}
|
||||
onChange={(e) => onChange({ memoryLimit: e.target.value })}
|
||||
>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
<option value="1Gi">1 Gi</option>
|
||||
<option value="2Gi">2 Gi</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Application[]>({
|
||||
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<Application[]>({
|
||||
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<Application[]>({
|
||||
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) {
|
||||
|
||||
@@ -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<ManagedDatabaseFormState>) => void;
|
||||
dbDumpFile: File | null;
|
||||
onDbDumpFileChange: (file: File | null) => void;
|
||||
}) {
|
||||
const dbDumpInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Database engine</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{DB_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
databaseType: opt.value,
|
||||
dbVersion: opt.versions[0],
|
||||
})
|
||||
}
|
||||
className={`p-3 rounded-xl border-2 text-center transition-colors ${
|
||||
form.databaseType === opt.value
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Database
|
||||
className={`w-6 h-6 mx-auto ${form.databaseType === opt.value ? 'text-primary-600' : 'text-gray-400'}`}
|
||||
/>
|
||||
<p className="mt-1 font-semibold text-sm text-gray-900">{opt.label}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Version</label>
|
||||
<select
|
||||
className="input-field max-w-xs"
|
||||
value={form.dbVersion}
|
||||
onChange={(e) => onChange({ dbVersion: e.target.value })}
|
||||
>
|
||||
{versionOptions(form.databaseType).map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{versionLabel(form.databaseType, v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-800">Database credentials</h3>
|
||||
<span className="text-xs text-gray-400">(optional — auto-generated if empty)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Username</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="appuser"
|
||||
value={form.dbUsername}
|
||||
onChange={(e) => onChange({ dbUsername: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="input-field pr-20"
|
||||
type={showDbPassword ? 'text' : 'password'}
|
||||
placeholder="Auto-generated"
|
||||
value={form.dbPassword}
|
||||
onChange={(e) => onChange({ dbPassword: e.target.value })}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let pass = '';
|
||||
for (let i = 0; i < 20; i++) pass += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
onChange({ dbPassword: pass });
|
||||
setShowDbPassword(true);
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-blue-500 transition-colors"
|
||||
title="Generate random password"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDbPassword(!showDbPassword)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
{showDbPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Used for internal cluster access. Use temporary or permanent external access below after deploy.
|
||||
</p>
|
||||
|
||||
<div className="pt-2">
|
||||
<label className="block text-xs text-gray-500 mb-2">Optional: upload DB dump to restore at creation</label>
|
||||
<div
|
||||
onDrop={(e) => {
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={dbDumpInputRef}
|
||||
type="file"
|
||||
accept=".sql,.gz,.dump"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) acceptDump(f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{dbDumpFile ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-left">
|
||||
<p className="font-medium text-gray-800">{dbDumpFile.name}</p>
|
||||
<p className="text-xs text-gray-500">{(dbDumpFile.size / (1024 * 1024)).toFixed(2)} MB</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDbDumpFileChange(null);
|
||||
}}
|
||||
className="text-sm text-red-500"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">Upload a SQL dump to be restored after the database is created</p>
|
||||
<p className="text-xs text-gray-400">Optional • Max 500MB • .sql, .gz, .dump</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<label className="block text-xs text-gray-500 mb-2">Database storage size</label>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(form.dbStorageSize || '1', 10);
|
||||
if (current > minDbGiFromRestoreDump) {
|
||||
onChange({ dbStorageSize: String(current - 1) });
|
||||
}
|
||||
}}
|
||||
disabled={dbGi <= minDbGiFromRestoreDump}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={minDbGiFromRestoreDump}
|
||||
max={100}
|
||||
value={form.dbStorageSize || '1'}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(form.dbStorageSize || '1', 10);
|
||||
if (current < 100) onChange({ dbStorageSize: String(current + 1) });
|
||||
}}
|
||||
disabled={dbGi >= 100}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">GB</span>
|
||||
{dbDumpFile && (
|
||||
<span className="text-xs text-blue-500">
|
||||
Suggested from dump ({(dbDumpFile.size / ONE_GIB).toFixed(2)} GiB min fit)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Minimum {minDbGiFromRestoreDump} GB
|
||||
{dbDumpFile ? ' (must fit the uploaded dump)' : ''} • Only expansion allowed after creation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-modal-backdrop"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
/>
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl max-w-md w-full animate-modal-enter">
|
||||
<div className="p-6 pb-0">
|
||||
<div className="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center mb-4">
|
||||
<AlertCircle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2">Storage too small</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{message}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-6">
|
||||
<button type="button" onClick={onClose} className="btn-primary">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const [showResources, setShowResources] = useState(false);
|
||||
const [dbStorageSize, setDbStorageSize] = useState('1');
|
||||
const [isDraggingDb, setIsDraggingDb] = useState(false);
|
||||
const [dbRestoreLogs, setDbRestoreLogs] = useState<string | null>(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<UpgradeCostSummary | null>(null);
|
||||
const [pendingUpgradePayload, setPendingUpgradePayload] = useState<UpgradePayload | null>(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<ResourceUsage>({
|
||||
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<StorageUsageData>({
|
||||
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<Invoice>(`/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 (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5" /> Resources & Scaling
|
||||
</h2>
|
||||
<button type="button" onClick={() => setShowResources(!showResources)} className="btn-secondary text-sm">
|
||||
{showResources ? (
|
||||
<>
|
||||
<ChevronDown className="w-4 h-4 inline" /> Hide
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BarChart3 className="w-4 h-4 inline" /> Monitor
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showResources && (
|
||||
<div className="space-y-6">
|
||||
{!isDeployed ? (
|
||||
<p className="text-sm text-gray-500 text-center py-6">Deploy the service to view metrics and adjust resources.</p>
|
||||
) : resourcesLoading ? (
|
||||
<div className="text-center py-6 text-gray-400 text-sm">Loading metrics…</div>
|
||||
) : metricsWorkload ? (
|
||||
<div className="border border-gray-200 rounded-xl p-4 space-y-4 bg-slate-50/50">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-gray-800">{metricsWorkload.title}</h3>
|
||||
<span className="text-[11px] text-gray-400 font-mono">{metricsWorkload.deploymentName}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="bg-blue-50 rounded-lg p-3">
|
||||
<p className="text-[10px] text-blue-600 font-medium">Replicas</p>
|
||||
<p className="text-lg font-bold text-blue-800">
|
||||
{metricsWorkload.configured.readyReplicas}/{metricsWorkload.configured.replicas}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3">
|
||||
<p className="text-[10px] text-green-600 font-medium">Pods</p>
|
||||
<p className="text-lg font-bold text-green-800">{metricsWorkload.pods.length}</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-lg p-3">
|
||||
<p className="text-[10px] text-purple-600 font-medium">Metrics</p>
|
||||
<p className="text-lg font-bold text-purple-800 flex justify-center">
|
||||
{metricsWorkload.metrics.length > 0 ? (
|
||||
<CheckCircle className="w-5 h-5 text-purple-700" />
|
||||
) : (
|
||||
<Clock className="w-5 h-5 text-purple-400" />
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{metricsWorkload.metrics.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{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 (
|
||||
<div key={metric.name} className="bg-white rounded-lg p-3 border border-gray-100 text-xs">
|
||||
<p className="font-mono text-gray-600 truncate mb-2">{metric.name}</p>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-gray-500">
|
||||
<span>CPU</span>
|
||||
<span>
|
||||
{cpuPercent.toFixed(0)}% ({cpuUsed.toFixed(0)}m / {cpuLimit.toFixed(0)}m)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${cpuPercent > 80 ? 'bg-red-500' : 'bg-green-500'}`}
|
||||
style={{ width: `${cpuPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-500">
|
||||
<span>Memory</span>
|
||||
<span>
|
||||
{memPercent.toFixed(0)}% ({memUsed.toFixed(0)} / {memLimit.toFixed(0)} Mi)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${memPercent > 80 ? 'bg-red-500' : 'bg-green-500'}`}
|
||||
style={{ width: `${memPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 text-center py-4">
|
||||
{isStopped ? 'Service is stopped.' : 'No resource metrics yet.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
|
||||
<Database className="w-4 h-4" /> Storage
|
||||
</h3>
|
||||
{storageUsageLoading ? (
|
||||
<p className="text-sm text-gray-400">Loading storage…</p>
|
||||
) : storageSlice ? (
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-3">
|
||||
<div className="flex justify-between text-xs text-gray-600">
|
||||
<span>Used {storageSlice.usedGi.toFixed(2)} GiB</span>
|
||||
<span>Allocated {storageSlice.allocatedGi.toFixed(1)} GiB</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full ${storageSlice.usedPercent > 80 ? 'bg-red-500' : storageSlice.usedPercent > 50 ? 'bg-yellow-500' : 'bg-blue-500'}`}
|
||||
style={{ width: `${Math.min(storageSlice.usedPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{isDatabase && (
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2 border-t border-gray-200">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden bg-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const c = parseInt(dbStorageSize, 10);
|
||||
if (c > currentDbGi + 1) setDbStorageSize(String(c - 1));
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gray-100 font-bold text-sm"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={dbStorageSize}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const c = parseInt(dbStorageSize, 10);
|
||||
if (c < 100) setDbStorageSize(String(c + 1));
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gray-100 font-bold text-sm"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">GB</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-xs px-3 py-1.5 disabled:opacity-50"
|
||||
disabled={
|
||||
resourcesPending ||
|
||||
!isDeployed ||
|
||||
parseInt(dbStorageSize, 10) <= currentDbGi
|
||||
}
|
||||
onClick={handleExpandStorage}
|
||||
>
|
||||
{resizeDbMutation.isPending || calculateUpgradeCostMutation.isPending
|
||||
? 'Expanding…'
|
||||
: 'Expand'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 w-full">
|
||||
Only expansion is allowed.
|
||||
{app.billingCycle
|
||||
? ' Additional storage is charged for the remaining billing period.'
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">Storage metrics unavailable.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
|
||||
<Settings className="w-4 h-4" /> Adjust CPU / memory
|
||||
</h3>
|
||||
{app.productType === 'managed_database' && (
|
||||
<DatabaseWorkloadResources values={dbResources} onChange={(p) => setDbResources((v) => ({ ...v, ...p }))} />
|
||||
)}
|
||||
{app.productType === 'managed_redis' && (
|
||||
<OptionalServiceResourceFields
|
||||
title="Redis resources"
|
||||
accentClass="text-red-500"
|
||||
borderClass="border-red-200"
|
||||
bgClass="bg-red-50/30"
|
||||
config={redisResources}
|
||||
onChange={(p) => setRedisResources((c) => ({ ...c, ...p }))}
|
||||
/>
|
||||
)}
|
||||
{app.productType === 'managed_rabbitmq' && (
|
||||
<OptionalServiceResourceFields
|
||||
title="RabbitMQ resources"
|
||||
accentClass="text-orange-500"
|
||||
borderClass="border-orange-200"
|
||||
bgClass="bg-orange-50/30"
|
||||
config={rabbitResources}
|
||||
onChange={(p) => setRabbitResources((c) => ({ ...c, ...p }))}
|
||||
/>
|
||||
)}
|
||||
{app.billingCycle && (
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Upgrades that increase cost are charged for the remaining billing period (wallet or invoice), same as
|
||||
applications.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm mt-4 disabled:opacity-50"
|
||||
disabled={resourcesPending || needsRenewal || !isDeployed}
|
||||
onClick={applyResources}
|
||||
>
|
||||
{resourcesPending ? (
|
||||
<>
|
||||
<Clock className="w-3 h-3 inline animate-spin" /> Applying…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-3 h-3 inline" /> Apply changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isDatabase && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
|
||||
<Scale className="w-4 h-4" /> Restore database dump
|
||||
</h3>
|
||||
<div
|
||||
onDrop={(e) => {
|
||||
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' : ''}`}
|
||||
>
|
||||
<input
|
||||
ref={dbFileInputRef}
|
||||
type="file"
|
||||
accept=".sql,.gz,.dump"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleDbFileUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{dbUploadMutation.isPending ? (
|
||||
<p className="text-sm text-gray-700">Restoring database…</p>
|
||||
) : (
|
||||
<>
|
||||
<Database className="w-8 h-8 mx-auto text-gray-400 mb-2" />
|
||||
<p className="text-sm font-medium text-gray-700">Upload SQL dump to restore</p>
|
||||
<p className="text-xs text-gray-500 mt-1">.sql, .gz, or .dump — max 500MB</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{dbRestoreLogs && (
|
||||
<pre className="mt-3 bg-gray-900 text-green-400 p-4 rounded-xl text-xs font-mono max-h-[240px] overflow-auto whitespace-pre-wrap">
|
||||
{dbRestoreLogs}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResourceUpgradeConfirmModal
|
||||
open={showUpgradeConfirm}
|
||||
upgradeCostData={upgradeCostData}
|
||||
walletBalance={walletData?.balance}
|
||||
isPending={scaleMutation.isPending || createUpgradeInvoiceMutation.isPending}
|
||||
onCancel={() => {
|
||||
setShowUpgradeConfirm(false);
|
||||
setUpgradeCostData(null);
|
||||
setPendingUpgradePayload(null);
|
||||
}}
|
||||
onConfirm={confirmUpgrade}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<OptionalServiceResourceConfig>) => void;
|
||||
}) {
|
||||
const storageStr = String(config.storageGi);
|
||||
const setStorage = (gb: number) => onChange({ storageGi: Math.max(0, gb) });
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl p-5 border-2 ${borderClass} ${bgClass}`}>
|
||||
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Server className={`w-5 h-5 ${accentClass}`} />
|
||||
{title}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">CPU limit</label>
|
||||
<select
|
||||
className="input-field text-sm"
|
||||
disabled={readOnly}
|
||||
value={config.cpuLimit}
|
||||
onChange={(e) => onChange({ cpuLimit: e.target.value })}
|
||||
>
|
||||
<option value="200m">200m</option>
|
||||
<option value="500m">500m</option>
|
||||
<option value="1">1 core</option>
|
||||
<option value="2">2 cores</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Memory limit</label>
|
||||
<select
|
||||
className="input-field text-sm"
|
||||
disabled={readOnly}
|
||||
value={config.memoryLimit}
|
||||
onChange={(e) => onChange({ memoryLimit: e.target.value })}
|
||||
>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
<option value="1Gi">1 Gi</option>
|
||||
<option value="2Gi">2 Gi</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs text-gray-500 mb-1">Storage (GB)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => setStorage(parseInt(storageStr, 10) - 1)}
|
||||
className="px-3 py-1.5 bg-gray-100 rounded-lg font-bold text-sm disabled:opacity-30"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
disabled={readOnly}
|
||||
value={storageStr}
|
||||
onChange={(e) => setStorage(parseInt(e.target.value, 10) || 0)}
|
||||
className="w-16 text-center input-field py-1.5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => setStorage(parseInt(storageStr, 10) + 1)}
|
||||
className="px-3 py-1.5 bg-gray-100 rounded-lg font-bold text-sm disabled:opacity-30"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 animate-fade-in">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Confirm Resource Upgrade</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
{needsPay
|
||||
? 'This upgrade requires payment for the remaining billing period.'
|
||||
: 'No additional cost for this change.'}
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Current hourly cost</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{upgradeCostData.currentCost.hourly.toLocaleString()} Toman/hour
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">New hourly cost</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{upgradeCostData.newCost.hourly.toLocaleString()} Toman/hour
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Remaining hours in period</span>
|
||||
<span className="text-sm font-medium text-gray-900">{upgradeCostData.remainingHours} hours</span>
|
||||
</div>
|
||||
<div className="border-t pt-3 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-700">Prorated amount to pay</span>
|
||||
<span className="text-lg font-bold text-primary-600">
|
||||
{upgradeCostData.proratedAmount.toLocaleString()} Toman
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 rounded-xl p-4 mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Wallet className="w-5 h-5 text-blue-500" />
|
||||
<span className="text-sm text-blue-700">Wallet Balance</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-blue-900">
|
||||
{walletBalance?.toLocaleString() ?? 0} Toman
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{walletShort && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-amber-700">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
Wallet is short by{' '}
|
||||
{(upgradeCostData.proratedAmount - (walletBalance ?? 0)).toLocaleString()} Toman. You can pay the
|
||||
delta by gateway on the invoice page.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl font-medium text-gray-700 hover:bg-gray-50 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isPending}
|
||||
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Clock className="w-4 h-4 animate-spin" /> Applying…
|
||||
</>
|
||||
) : needsPay ? (
|
||||
<>
|
||||
<CreditCard className="w-4 h-4" /> Create Invoice & Pay
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" /> Apply Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ServiceAccessTarget>('database');
|
||||
const [accessDuration, setAccessDuration] = useState(60);
|
||||
const [accessPersistent, setAccessPersistent] = useState(false);
|
||||
const [showAccessSecret, setShowAccessSecret] = useState(false);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(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<ServiceAccessGrant[]>({
|
||||
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 (
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<ExternalLink className="w-5 h-5" /> External access
|
||||
</h2>
|
||||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-lg px-3 py-2 mb-4 flex items-start gap-2">
|
||||
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
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.
|
||||
</p>
|
||||
|
||||
{!app.latestImageTag ? (
|
||||
<p className="text-sm text-gray-500">Deploy the service first to enable external access.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-4 space-y-4">
|
||||
<div className="flex flex-wrap gap-4 items-end">
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Service</label>
|
||||
<select
|
||||
value={accessTarget}
|
||||
onChange={(e) => setAccessTarget(e.target.value as ServiceAccessTarget)}
|
||||
className="input-field w-full text-sm"
|
||||
>
|
||||
{accessTargetOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Access mode</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccessPersistent(false)}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border transition-colors ${
|
||||
!accessPersistent
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Temporary
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccessPersistent(true)}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border transition-colors ${
|
||||
accessPersistent
|
||||
? 'bg-amber-600 text-white border-amber-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Always open
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!accessPersistent && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Duration</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[30, 60, 240].map((mins) => (
|
||||
<button
|
||||
key={mins}
|
||||
type="button"
|
||||
onClick={() => setAccessDuration(mins)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
accessDuration === mins
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{mins < 60 ? `${mins}m` : `${mins / 60}h`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{accessPersistent && (
|
||||
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
The port stays exposed until you click Revoke. Only use this when you need a stable external endpoint.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createAccessMutation.mutate()}
|
||||
disabled={createAccessMutation.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{createAccessMutation.isPending ? 'Opening…' : accessPersistent ? 'Open port permanently' : 'Enable access'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{accessGrants.filter((g) => g.status === 'active').length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No active external access sessions.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{accessGrants
|
||||
.filter((g) => g.status === 'active')
|
||||
.map((grant) => (
|
||||
<div key={grant.id} className="border border-gray-200 rounded-xl p-4 bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-800">{accessTargetLabel(grant.target)}</span>
|
||||
{grant.persistent && (
|
||||
<span className="ml-2 text-xs font-medium text-amber-700 bg-amber-50 px-2 py-0.5 rounded-full">
|
||||
Always open
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-2 text-xs text-gray-500">{formatAccessCountdown(grant)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => revokeAccessMutation.mutate(grant.id)}
|
||||
disabled={revokeAccessMutation.isPending}
|
||||
className="btn-secondary text-xs text-red-600 border-red-200 hover:bg-red-50"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-sm font-mono">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-gray-500 text-xs font-sans">Endpoint</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-800">
|
||||
{grant.host}:{grant.port}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(`${grant.host}:${grant.port}`, `access-endpoint-${grant.id}`)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{copiedField === `access-endpoint-${grant.id}` ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{grant.connection.url && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-gray-500 text-xs font-sans">URL</span>
|
||||
<div className="flex items-center gap-2 max-w-[70%]">
|
||||
<span className="text-gray-800 truncate text-xs" title={grant.connection.url}>
|
||||
{showAccessSecret ? grant.connection.url : '••••••••••••'}
|
||||
</span>
|
||||
<button type="button" onClick={() => setShowAccessSecret(!showAccessSecret)} className="p-1 text-gray-400 hover:text-gray-600">
|
||||
{showAccessSecret ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(grant.connection.url || '', `access-url-${grant.id}`)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{copiedField === `access-url-${grant.id}` ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<HTMLPreElement>(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 (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" /> Logs
|
||||
</h2>
|
||||
<div className="flex items-center space-x-3">
|
||||
{showLogs && logTab === 'pod' && (
|
||||
<span className="text-xs text-gray-400 flex items-center space-x-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<span>Live (every 3s)</span>
|
||||
</span>
|
||||
)}
|
||||
{showBuildLogs && showLogs && logTab === 'build' && (
|
||||
<span className="text-xs text-gray-400 flex items-center space-x-1">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
<span>Auto-refresh (every 5s)</span>
|
||||
</span>
|
||||
)}
|
||||
<button type="button" onClick={() => setShowLogs(!showLogs)} className="btn-secondary text-sm">
|
||||
{showLogs ? (
|
||||
<>
|
||||
<ChevronDown className="w-4 h-4 inline" /> Hide logs
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-4 h-4 inline" /> Show logs
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLogs && (
|
||||
<div className="space-y-3">
|
||||
{showBuildLogs ? (
|
||||
<div className="flex gap-1 bg-gray-100 rounded-xl p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogTab('pod')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
logTab === 'pod' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Monitor className="w-4 h-4 inline" /> Pod logs
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogTab('build')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
logTab === 'build' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Hammer className="w-4 h-4 inline" /> Build logs
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">Workload pod output (no image build for this service).</p>
|
||||
)}
|
||||
|
||||
{logTab === 'pod' && (
|
||||
<pre
|
||||
ref={logsEndRef}
|
||||
className="bg-gray-900 text-green-400 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words"
|
||||
>
|
||||
{logsData?.logs || podPlaceholder}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{showBuildLogs && logTab === 'build' && (
|
||||
<div>
|
||||
{buildLogsData?.version && (
|
||||
<div className="flex items-center gap-3 mb-2 text-xs text-gray-500">
|
||||
<span>
|
||||
<Pin className="w-3 h-3 inline" /> {buildLogsData.version}
|
||||
</span>
|
||||
<span className={`badge ${statusColors[buildLogsData.status] || 'badge-gray'}`}>
|
||||
{buildLogsData.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<pre className="bg-gray-900 text-blue-300 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words">
|
||||
{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.')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
OptionalServiceResourceConfig,
|
||||
OptionalServiceResourcesMap,
|
||||
PricingCatalog,
|
||||
} from '@/types';
|
||||
|
||||
export type OptionalServiceKey = keyof OptionalServiceResourcesMap;
|
||||
|
||||
const FALLBACK_OPTIONAL_RESOURCES: Record<OptionalServiceKey, OptionalServiceResourceConfig> = {
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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<Application, 'productType'>): boolean {
|
||||
const t = app.productType;
|
||||
return !!t && MANAGED_PRODUCT_TYPES.includes(t);
|
||||
}
|
||||
|
||||
export function isApplicationProduct(app: Pick<Application, 'productType'>): boolean {
|
||||
return !isManagedProduct(app);
|
||||
}
|
||||
|
||||
export function filterApplications(apps: Application[]): Application[] {
|
||||
return apps.filter(isApplicationProduct);
|
||||
}
|
||||
|
||||
export function filterManagedServices(apps: Application[]): Application[] {
|
||||
return apps.filter(isManagedProduct);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user