diff --git a/backend/src/applications/dto/application.dto.ts b/backend/src/applications/dto/application.dto.ts index 341525c..7c53eec 100644 --- a/backend/src/applications/dto/application.dto.ts +++ b/backend/src/applications/dto/application.dto.ts @@ -18,6 +18,12 @@ import { AppRuntime, DatabaseType, ProductType } from '../../common/enums'; import { OptionalServiceResourcesDto } from '../../billing/dto/optional-service-resources.dto'; export class OptionalServiceResourcesMapDto { + @ApiPropertyOptional({ type: OptionalServiceResourcesDto }) + @IsOptional() + @ValidateNested() + @Type(() => OptionalServiceResourcesDto) + database?: OptionalServiceResourcesDto; + @ApiPropertyOptional({ type: OptionalServiceResourcesDto }) @IsOptional() @ValidateNested() diff --git a/backend/src/applications/entities/application.entity.ts b/backend/src/applications/entities/application.entity.ts index 9bd2d14..0a23825 100644 --- a/backend/src/applications/entities/application.entity.ts +++ b/backend/src/applications/entities/application.entity.ts @@ -82,9 +82,16 @@ export class Application { @Column({ type: 'jsonb', nullable: true }) logPaths: string[]; // Custom log paths to collect (e.g., ['/app/logs/*.log']) - /** User-selected CPU/RAM/storage per optional service (redis, rabbitmq). */ + /** User-selected CPU/RAM/storage per optional service (database, redis, rabbitmq). */ @Column({ type: 'jsonb', nullable: true }) optionalServiceResources?: { + database?: { + cpuRequest?: string; + cpuLimit: string; + memoryRequest?: string; + memoryLimit: string; + storageGi: number; + }; redis?: { cpuRequest?: string; cpuLimit: string; diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index 69ebf44..24275fc 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -637,6 +637,16 @@ export class BillingService { replicas: newResources.replicas ?? base.replicas, ...(newResources.dbStorageSize && { dbStorageSize: newResources.dbStorageSize }), ...(newResources.appStorageSize && { appStorageSize: newResources.appStorageSize }), + ...(newResources.databaseResources && { + databaseResources: { + ...base.databaseResources, + ...newResources.databaseResources, + storageGi: + newResources.databaseResources.storageGi ?? + base.databaseResources?.storageGi ?? + 1, + }, + }), ...(newResources.redisResources && { redisResources: { ...base.redisResources, @@ -734,7 +744,9 @@ export class BillingService { ? (app as Application).optionalServiceResources : undefined; const dtoExtras = - 'redisResources' in app ? (app as CalculateCostDto) : undefined; + 'redisResources' in app || 'databaseResources' in app + ? (app as CalculateCostDto) + : undefined; const productType = 'productType' in app ? ((app as Application).productType ?? ProductType.APPLICATION) @@ -753,6 +765,7 @@ export class BillingService { enableRabbitmq: !!app.enableRabbitmq, enableElasticsearch: managed ? false : !!app.enableElasticsearch, enableCustomDomain: managed ? false : enableCustomDomain, + databaseResources: optionalRes?.database ?? dtoExtras?.databaseResources, redisResources: optionalRes?.redis ?? dtoExtras?.redisResources, rabbitmqResources: optionalRes?.rabbitmq ?? dtoExtras?.rabbitmqResources, }; @@ -919,6 +932,7 @@ export class BillingService { enableRabbitmq: config.enableRabbitmq, enableElasticsearch: config.enableElasticsearch, enableCustomDomain: config.enableCustomDomain, + databaseResources: config.databaseResources, redisResources: config.redisResources, rabbitmqResources: config.rabbitmqResources, }; diff --git a/backend/src/billing/dto/billing.dto.ts b/backend/src/billing/dto/billing.dto.ts index 649111f..27047c3 100644 --- a/backend/src/billing/dto/billing.dto.ts +++ b/backend/src/billing/dto/billing.dto.ts @@ -144,6 +144,12 @@ export class CalculateCostDto { @IsBoolean() enableElasticsearch?: boolean; + @ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Per-database CPU/RAM/storage limits' }) + @IsOptional() + @ValidateNested() + @Type(() => OptionalServiceResourcesDto) + databaseResources?: OptionalServiceResourcesDto; + @ApiPropertyOptional({ type: OptionalServiceResourcesDto }) @IsOptional() @ValidateNested() @@ -213,6 +219,12 @@ export class UpgradeResourcesDto { @IsString() appStorageSize?: string; + @ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Database resource limits' }) + @IsOptional() + @ValidateNested() + @Type(() => OptionalServiceResourcesDto) + databaseResources?: OptionalServiceResourcesDto; + @ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Managed Redis resource limits' }) @IsOptional() @ValidateNested() diff --git a/backend/src/billing/pricing-catalog.service.ts b/backend/src/billing/pricing-catalog.service.ts index afc32df..5d42853 100644 --- a/backend/src/billing/pricing-catalog.service.ts +++ b/backend/src/billing/pricing-catalog.service.ts @@ -654,8 +654,14 @@ export class PricingCatalogService implements OnModuleInit { } 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; + let cpuQty = this.parseCpuToCores(dto.cpuLimit) * replicas; + let memoryQty = this.parseMemoryToGb(dto.memoryLimit) * replicas; + // The database runs as its own single-replica workload; bill its CPU/RAM on + // top of the app's, using the user-selected per-database resources. + if (hasDatabase && dto.databaseResources) { + cpuQty += this.parseCpuToCores(dto.databaseResources.cpuLimit); + memoryQty += this.parseMemoryToGb(dto.databaseResources.memoryLimit); + } const storageQty = (dto.dbStorageSize ? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0 diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index 1e131f4..7e9e471 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -38,6 +38,10 @@ interface ManifestContext { dbPassword: string; dbVersion: string; dbStorageSize: string; + dbCpuRequest: string; + dbCpuLimit: string; + dbMemoryRequest: string; + dbMemoryLimit: string; appStorageSize: string; enableRedis: boolean; redisVersion: string; @@ -126,6 +130,21 @@ export class KubernetesService implements OnModuleInit { /** * Build Helm values object from an Application entity and image URI. */ + /** + * Resolve the database workload's CPU/RAM. Prefers the user-selected + * per-database resources (optionalServiceResources.database); falls back to + * the app's main resources for managed databases / legacy apps. + */ + private resolveDatabaseResources(app: Application) { + const res = app.optionalServiceResources?.database; + return { + cpuRequest: res?.cpuRequest || app.cpuRequest || '100m', + cpuLimit: res?.cpuLimit || app.cpuLimit || '500m', + memoryRequest: res?.memoryRequest || app.memoryRequest || '256Mi', + memoryLimit: res?.memoryLimit || app.memoryLimit || '512Mi', + }; + } + private buildRedisHelmBlock(app: Application) { const res = app.optionalServiceResources?.redis; const storageGi = res?.storageGi ?? 1; @@ -254,12 +273,7 @@ export class KubernetesService implements OnModuleInit { 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', - }, + resources: this.resolveDatabaseResources(app), }, redis: { enabled: false, storageSize: '1Gi', resources: {} }, rabbitmq: { enabled: false, storageSize: '2Gi', resources: {} }, @@ -342,12 +356,7 @@ export class KubernetesService implements OnModuleInit { 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', - }, + resources: this.resolveDatabaseResources(app), }, wordpress: { enabled: isWordPress, @@ -481,6 +490,10 @@ export class KubernetesService implements OnModuleInit { dbPassword: app.dbPassword || '', dbVersion: app.dbVersion || '', dbStorageSize: app.dbStorageSize || '1Gi', + dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest, + dbCpuLimit: this.resolveDatabaseResources(app).cpuLimit, + dbMemoryRequest: this.resolveDatabaseResources(app).memoryRequest, + dbMemoryLimit: this.resolveDatabaseResources(app).memoryLimit, appStorageSize: app.appStorageSize || '2Gi', enableRedis: app.enableRedis || false, redisVersion: app.redisVersion || '7.2', @@ -559,6 +572,10 @@ export class KubernetesService implements OnModuleInit { dbPassword: app.dbPassword || this.generatePassword(), dbVersion: app.dbVersion || '', dbStorageSize: app.dbStorageSize || '1Gi', + dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest, + dbCpuLimit: this.resolveDatabaseResources(app).cpuLimit, + dbMemoryRequest: this.resolveDatabaseResources(app).memoryRequest, + dbMemoryLimit: this.resolveDatabaseResources(app).memoryLimit, appStorageSize: app.appStorageSize || '2Gi', enableRedis: false, redisVersion: app.redisVersion || '7.2', @@ -626,6 +643,10 @@ export class KubernetesService implements OnModuleInit { dbPassword: app.dbPassword || this.generatePassword(), dbVersion: app.dbVersion || '', dbStorageSize: app.dbStorageSize || '1Gi', + dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest, + dbCpuLimit: this.resolveDatabaseResources(app).cpuLimit, + dbMemoryRequest: this.resolveDatabaseResources(app).memoryRequest, + dbMemoryLimit: this.resolveDatabaseResources(app).memoryLimit, appStorageSize: app.appStorageSize || '2Gi', enableRedis: app.enableRedis || false, redisVersion: app.redisVersion || '7.2', @@ -1808,8 +1829,8 @@ export class KubernetesService implements OnModuleInit { env: envVars, volumeMounts: [{ name: 'db-storage', mountPath: dataPath }], resources: { - requests: { cpu: '100m', memory: '256Mi' }, - limits: { cpu: '500m', memory: '512Mi' }, + requests: { cpu: ctx.dbCpuRequest, memory: ctx.dbMemoryRequest }, + limits: { cpu: ctx.dbCpuLimit, memory: ctx.dbMemoryLimit }, }, readinessProbe, livenessProbe, diff --git a/frontend/src/app/[lang]/dashboard/deploy/page.tsx b/frontend/src/app/[lang]/dashboard/deploy/page.tsx index eb5e4c4..005fc34 100644 --- a/frontend/src/app/[lang]/dashboard/deploy/page.tsx +++ b/frontend/src/app/[lang]/dashboard/deploy/page.tsx @@ -36,9 +36,16 @@ function sanitizePayloadForWordPressRuntime(payload: CreateApplicationDto): Crea }; } -type OptionalServiceKey = 'redis' | 'rabbitmq'; +type OptionalServiceKey = 'database' | 'redis' | 'rabbitmq'; const FALLBACK_OPTIONAL_RESOURCES: Record = { + database: { + cpuRequest: '100m', + cpuLimit: '500m', + memoryRequest: '256Mi', + memoryLimit: '512Mi', + storageGi: 1, + }, redis: { cpuRequest: '50m', cpuLimit: '200m', @@ -313,6 +320,22 @@ export default function DeployPage() { return Math.max(1, minGiToFitFileBytes(dbDumpFile.size)); }, [form.databaseType, dbDumpFile]); + /** Keep the database workload's storageGi in lockstep with the dbStorageSize disk field. */ + const dbSyncedOptionalResources = (): OptionalServiceResourcesMap | undefined => { + const osr = form.optionalServiceResources; + if (!osr) return osr; + if (form.databaseType === 'none') { + // Drop any leftover database entry when no database is selected. + const { database: _drop, ...rest } = osr; + return rest; + } + if (!osr.database) return osr; + return { + ...osr, + database: { ...osr.database, storageGi: parseInt(form.dbStorageSize || '1', 10) || 1 }, + }; + }; + const { data: clusters = [] } = useQuery({ queryKey: ['clusters-public'], queryFn: () => api.get('/clusters/public').then((r) => r.data), @@ -373,6 +396,8 @@ export default function DeployPage() { enableRedis: form.runtime === 'wordpress' ? false : form.enableRedis, enableRabbitmq: form.runtime === 'wordpress' ? false : form.enableRabbitmq, enableElasticsearch: form.runtime === 'wordpress' ? false : form.enableElasticsearch, + databaseResources: + form.databaseType !== 'none' ? form.optionalServiceResources?.database : undefined, redisResources: form.runtime !== 'wordpress' && form.enableRedis ? form.optionalServiceResources?.redis : undefined, rabbitmqResources: @@ -410,6 +435,7 @@ export default function DeployPage() { setDeployStage('creating'); const payload = sanitizePayloadForWordPressRuntime({ ...form, + optionalServiceResources: dbSyncedOptionalResources(), ...(form.databaseType !== 'none' && form.dbStorageSize ? { dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi` } : {}), @@ -492,6 +518,7 @@ export default function DeployPage() { setDeployStage('creating'); const payload = sanitizePayloadForWordPressRuntime({ ...form, + optionalServiceResources: dbSyncedOptionalResources(), ...(form.databaseType !== 'none' && form.dbStorageSize ? { dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi` } : {}), @@ -645,7 +672,7 @@ export default function DeployPage() { const handleSubmit = () => { if (!validateRestoreStorageOrShowModal()) return; - const payload = { ...form }; + const payload = { ...form, optionalServiceResources: dbSyncedOptionalResources() }; // Format dbStorageSize with Gi suffix if (payload.databaseType !== 'none' && payload.dbStorageSize) { payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`; @@ -1477,7 +1504,20 @@ export default function DeployPage() { else if (opt.value === 'mysql') dbVersion = '8.0'; else if (opt.value === 'mariadb') dbVersion = '11.4'; else if (opt.value === 'mongodb') dbVersion = '7.0'; - setForm({ ...form, databaseType: opt.value as any, dbVersion }); + const hasDb = opt.value !== 'none'; + setForm({ + ...form, + databaseType: opt.value as any, + dbVersion, + optionalServiceResources: hasDb + ? { + ...form.optionalServiceResources, + database: + form.optionalServiceResources?.database ?? + optionalDefaultsFromCatalog(pricingCatalog, 'database'), + } + : form.optionalServiceResources, + }); }} className={`p-4 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' @@ -1665,59 +1705,13 @@ export default function DeployPage() { - {/* Database Storage Size */} -
- -
-
- - { - const min = minDbGiFromRestoreDump; - const val = Math.max(min, Math.min(100, parseInt(e.target.value, 10) || min)); - setForm({ ...form, dbStorageSize: String(val) }); - }} - className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none" - /> - -
- GB - {dbDumpFile && ( - - Suggested based on dump size ({(dbDumpFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB) - - )} -
-

- Minimum {minDbGiFromRestoreDump} GB for this configuration - {dbDumpFile ? ' (must fit the uploaded dump)' : ''} • Only expansion is allowed after creation -

-
+ {/* CPU, RAM and disk for the database are configured on the Resources step. */} +

+ {dw.databaseResourcesHint} + {dbDumpFile + ? ` (${dw.databaseStorageSize} ≥ ${minDbGiFromRestoreDump} GB)` + : ''} +

)} @@ -2444,6 +2438,48 @@ export default function DeployPage() { + {form.databaseType !== 'none' && form.optionalServiceResources?.database && ( +
+

{dw.databaseResources}

+ { + const { storageGi, ...res } = patch; + setForm((prev) => { + const nextStorage = + storageGi !== undefined + ? Math.max(minDbGiFromRestoreDump, storageGi) + : parseInt(prev.dbStorageSize || '1', 10) || 1; + return { + ...prev, + dbStorageSize: String(nextStorage), + optionalServiceResources: { + ...prev.optionalServiceResources, + database: { + ...prev.optionalServiceResources!.database!, + ...res, + storageGi: nextStorage, + }, + }, + }; + }); + }} + /> +
+ )} + {form.runtime !== 'wordpress' && (form.enableRedis || form.enableRabbitmq) && (

{dw.optionalServices}

@@ -2529,6 +2565,16 @@ export default function DeployPage() { {dw.dbStorage} {form.dbStorageSize || '1'} GB
+ {form.optionalServiceResources?.database && ( +
+ {dw.databaseResources} + + {form.optionalServiceResources.database.cpuRequest} / {form.optionalServiceResources.database.cpuLimit} + {' · '} + {form.optionalServiceResources.database.memoryRequest} / {form.optionalServiceResources.database.memoryLimit} + +
+ )} {dbDumpFile && (
{dw.dbDump} diff --git a/frontend/src/i18n/dictionaries/en.ts b/frontend/src/i18n/dictionaries/en.ts index 14e4a7e..4cebd83 100644 --- a/frontend/src/i18n/dictionaries/en.ts +++ b/frontend/src/i18n/dictionaries/en.ts @@ -1231,6 +1231,8 @@ const en: Dictionary = { databaseCredentials: 'Database Credentials', dbUsername: 'DB Username', dbPassword: 'DB Password', dbStorage: 'DB Storage', dbDump: 'DB Dump', databaseStorageSize: 'Database Storage Size', + databaseResources: 'Database resources', + databaseResourcesHint: 'Configure the database CPU, RAM and disk on the Resources step.', username: 'Username', password: 'Password', autoGenerated: 'Auto-generated', generatePassword: 'Generate random password', dbCredsNote: 'These credentials are used for internal cluster communication only. The database is not exposed externally.', diff --git a/frontend/src/i18n/dictionaries/fa.ts b/frontend/src/i18n/dictionaries/fa.ts index 6192fc2..9af80ee 100644 --- a/frontend/src/i18n/dictionaries/fa.ts +++ b/frontend/src/i18n/dictionaries/fa.ts @@ -1243,6 +1243,8 @@ const fa = { databaseCredentials: 'اطلاعات ورود دیتابیس', dbUsername: 'نام کاربری دیتابیس', dbPassword: 'رمز دیتابیس', dbStorage: 'فضای دیتابیس', dbDump: 'dump دیتابیس', databaseStorageSize: 'اندازهٔ فضای ذخیرهٔ دیتابیس', + databaseResources: 'منابع دیتابیس', + databaseResourcesHint: 'پردازنده، حافظه و دیسک دیتابیس را در مرحلهٔ منابع پیکربندی کن.', username: 'نام کاربری', password: 'رمز عبور', autoGenerated: 'تولید خودکار', generatePassword: 'تولید رمز تصادفی', dbCredsNote: 'این اطلاعات فقط برای ارتباط داخلی کلاستر استفاده می‌شوند. دیتابیس به‌صورت خارجی expose نمی‌شود.', diff --git a/frontend/src/lib/optional-service-defaults.ts b/frontend/src/lib/optional-service-defaults.ts index edbedea..edd599d 100644 --- a/frontend/src/lib/optional-service-defaults.ts +++ b/frontend/src/lib/optional-service-defaults.ts @@ -7,6 +7,13 @@ import type { export type OptionalServiceKey = keyof OptionalServiceResourcesMap; const FALLBACK_OPTIONAL_RESOURCES: Record = { + database: { + cpuRequest: '100m', + cpuLimit: '500m', + memoryRequest: '256Mi', + memoryLimit: '512Mi', + storageGi: 1, + }, redis: { cpuRequest: '50m', cpuLimit: '200m', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2ba34ac..9820b3b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -267,6 +267,7 @@ export interface OptionalServiceResourceConfig { } export interface OptionalServiceResourcesMap { + database?: OptionalServiceResourceConfig; redis?: OptionalServiceResourceConfig; rabbitmq?: OptionalServiceResourceConfig; }