feat(deploy): configurable CPU/RAM/disk for app databases
Let users size the database workload from the deploy wizard the same way Redis/RabbitMQ are configured: a resource card (CPU request/limit, memory request/limit, storage) on the Resources step, stored in optionalServiceResources.database. - entity/DTO: add `database` to optionalServiceResources - k8s: resolveDatabaseResources() applies user-selected resources on both the Helm and K8s-API deploy paths (was hardcoded 100m/256Mi→500m/512Mi) - billing: bill database CPU/RAM as a separate line on top of the app's resources; merge it through the upgrade path too - wizard: db resource card on the Resources step, disk moved into the card, cost preview + review summary include the database resources Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,12 @@ import { AppRuntime, DatabaseType, ProductType } from '../../common/enums';
|
|||||||
import { OptionalServiceResourcesDto } from '../../billing/dto/optional-service-resources.dto';
|
import { OptionalServiceResourcesDto } from '../../billing/dto/optional-service-resources.dto';
|
||||||
|
|
||||||
export class OptionalServiceResourcesMapDto {
|
export class OptionalServiceResourcesMapDto {
|
||||||
|
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => OptionalServiceResourcesDto)
|
||||||
|
database?: OptionalServiceResourcesDto;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
|
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
|
|||||||
@@ -82,9 +82,16 @@ export class Application {
|
|||||||
@Column({ type: 'jsonb', nullable: true })
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
logPaths: string[]; // Custom log paths to collect (e.g., ['/app/logs/*.log'])
|
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 })
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
optionalServiceResources?: {
|
optionalServiceResources?: {
|
||||||
|
database?: {
|
||||||
|
cpuRequest?: string;
|
||||||
|
cpuLimit: string;
|
||||||
|
memoryRequest?: string;
|
||||||
|
memoryLimit: string;
|
||||||
|
storageGi: number;
|
||||||
|
};
|
||||||
redis?: {
|
redis?: {
|
||||||
cpuRequest?: string;
|
cpuRequest?: string;
|
||||||
cpuLimit: string;
|
cpuLimit: string;
|
||||||
|
|||||||
@@ -637,6 +637,16 @@ export class BillingService {
|
|||||||
replicas: newResources.replicas ?? base.replicas,
|
replicas: newResources.replicas ?? base.replicas,
|
||||||
...(newResources.dbStorageSize && { dbStorageSize: newResources.dbStorageSize }),
|
...(newResources.dbStorageSize && { dbStorageSize: newResources.dbStorageSize }),
|
||||||
...(newResources.appStorageSize && { appStorageSize: newResources.appStorageSize }),
|
...(newResources.appStorageSize && { appStorageSize: newResources.appStorageSize }),
|
||||||
|
...(newResources.databaseResources && {
|
||||||
|
databaseResources: {
|
||||||
|
...base.databaseResources,
|
||||||
|
...newResources.databaseResources,
|
||||||
|
storageGi:
|
||||||
|
newResources.databaseResources.storageGi ??
|
||||||
|
base.databaseResources?.storageGi ??
|
||||||
|
1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
...(newResources.redisResources && {
|
...(newResources.redisResources && {
|
||||||
redisResources: {
|
redisResources: {
|
||||||
...base.redisResources,
|
...base.redisResources,
|
||||||
@@ -734,7 +744,9 @@ export class BillingService {
|
|||||||
? (app as Application).optionalServiceResources
|
? (app as Application).optionalServiceResources
|
||||||
: undefined;
|
: undefined;
|
||||||
const dtoExtras =
|
const dtoExtras =
|
||||||
'redisResources' in app ? (app as CalculateCostDto) : undefined;
|
'redisResources' in app || 'databaseResources' in app
|
||||||
|
? (app as CalculateCostDto)
|
||||||
|
: undefined;
|
||||||
const productType =
|
const productType =
|
||||||
'productType' in app
|
'productType' in app
|
||||||
? ((app as Application).productType ?? ProductType.APPLICATION)
|
? ((app as Application).productType ?? ProductType.APPLICATION)
|
||||||
@@ -753,6 +765,7 @@ export class BillingService {
|
|||||||
enableRabbitmq: !!app.enableRabbitmq,
|
enableRabbitmq: !!app.enableRabbitmq,
|
||||||
enableElasticsearch: managed ? false : !!app.enableElasticsearch,
|
enableElasticsearch: managed ? false : !!app.enableElasticsearch,
|
||||||
enableCustomDomain: managed ? false : enableCustomDomain,
|
enableCustomDomain: managed ? false : enableCustomDomain,
|
||||||
|
databaseResources: optionalRes?.database ?? dtoExtras?.databaseResources,
|
||||||
redisResources: optionalRes?.redis ?? dtoExtras?.redisResources,
|
redisResources: optionalRes?.redis ?? dtoExtras?.redisResources,
|
||||||
rabbitmqResources: optionalRes?.rabbitmq ?? dtoExtras?.rabbitmqResources,
|
rabbitmqResources: optionalRes?.rabbitmq ?? dtoExtras?.rabbitmqResources,
|
||||||
};
|
};
|
||||||
@@ -919,6 +932,7 @@ export class BillingService {
|
|||||||
enableRabbitmq: config.enableRabbitmq,
|
enableRabbitmq: config.enableRabbitmq,
|
||||||
enableElasticsearch: config.enableElasticsearch,
|
enableElasticsearch: config.enableElasticsearch,
|
||||||
enableCustomDomain: config.enableCustomDomain,
|
enableCustomDomain: config.enableCustomDomain,
|
||||||
|
databaseResources: config.databaseResources,
|
||||||
redisResources: config.redisResources,
|
redisResources: config.redisResources,
|
||||||
rabbitmqResources: config.rabbitmqResources,
|
rabbitmqResources: config.rabbitmqResources,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -144,6 +144,12 @@ export class CalculateCostDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enableElasticsearch?: boolean;
|
enableElasticsearch?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Per-database CPU/RAM/storage limits' })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => OptionalServiceResourcesDto)
|
||||||
|
databaseResources?: OptionalServiceResourcesDto;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
|
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@@ -213,6 +219,12 @@ export class UpgradeResourcesDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
appStorageSize?: string;
|
appStorageSize?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Database resource limits' })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => OptionalServiceResourcesDto)
|
||||||
|
databaseResources?: OptionalServiceResourcesDto;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Managed Redis resource limits' })
|
@ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Managed Redis resource limits' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
|
|||||||
@@ -654,8 +654,14 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
const replicas = dto.replicas ?? 1;
|
const replicas = dto.replicas ?? 1;
|
||||||
const hasDatabase = dto.databaseType !== DatabaseType.NONE && dto.databaseType !== 'none';
|
const hasDatabase = dto.databaseType !== DatabaseType.NONE && dto.databaseType !== 'none';
|
||||||
const cpuQty = this.parseCpuToCores(dto.cpuLimit) * replicas;
|
let cpuQty = this.parseCpuToCores(dto.cpuLimit) * replicas;
|
||||||
const memoryQty = this.parseMemoryToGb(dto.memoryLimit) * 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 =
|
const storageQty =
|
||||||
(dto.dbStorageSize
|
(dto.dbStorageSize
|
||||||
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
|
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ interface ManifestContext {
|
|||||||
dbPassword: string;
|
dbPassword: string;
|
||||||
dbVersion: string;
|
dbVersion: string;
|
||||||
dbStorageSize: string;
|
dbStorageSize: string;
|
||||||
|
dbCpuRequest: string;
|
||||||
|
dbCpuLimit: string;
|
||||||
|
dbMemoryRequest: string;
|
||||||
|
dbMemoryLimit: string;
|
||||||
appStorageSize: string;
|
appStorageSize: string;
|
||||||
enableRedis: boolean;
|
enableRedis: boolean;
|
||||||
redisVersion: string;
|
redisVersion: string;
|
||||||
@@ -126,6 +130,21 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
/**
|
/**
|
||||||
* Build Helm values object from an Application entity and image URI.
|
* 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) {
|
private buildRedisHelmBlock(app: Application) {
|
||||||
const res = app.optionalServiceResources?.redis;
|
const res = app.optionalServiceResources?.redis;
|
||||||
const storageGi = res?.storageGi ?? 1;
|
const storageGi = res?.storageGi ?? 1;
|
||||||
@@ -254,12 +273,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
username: app.dbUsername || 'appuser',
|
username: app.dbUsername || 'appuser',
|
||||||
password: app.dbPassword || this.generatePassword(),
|
password: app.dbPassword || this.generatePassword(),
|
||||||
storageSize: app.dbStorageSize || '1Gi',
|
storageSize: app.dbStorageSize || '1Gi',
|
||||||
resources: {
|
resources: this.resolveDatabaseResources(app),
|
||||||
cpuRequest: app.cpuRequest || '100m',
|
|
||||||
cpuLimit: app.cpuLimit || '500m',
|
|
||||||
memoryRequest: app.memoryRequest || '256Mi',
|
|
||||||
memoryLimit: app.memoryLimit || '512Mi',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
redis: { enabled: false, storageSize: '1Gi', resources: {} },
|
redis: { enabled: false, storageSize: '1Gi', resources: {} },
|
||||||
rabbitmq: { enabled: false, storageSize: '2Gi', resources: {} },
|
rabbitmq: { enabled: false, storageSize: '2Gi', resources: {} },
|
||||||
@@ -342,12 +356,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
username: app.dbUsername || 'appuser',
|
username: app.dbUsername || 'appuser',
|
||||||
password: app.dbPassword || this.generatePassword(),
|
password: app.dbPassword || this.generatePassword(),
|
||||||
storageSize: app.dbStorageSize || '1Gi',
|
storageSize: app.dbStorageSize || '1Gi',
|
||||||
resources: {
|
resources: this.resolveDatabaseResources(app),
|
||||||
cpuRequest: app.cpuRequest || '100m',
|
|
||||||
cpuLimit: app.cpuLimit || '500m',
|
|
||||||
memoryRequest: app.memoryRequest || '256Mi',
|
|
||||||
memoryLimit: app.memoryLimit || '512Mi',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
wordpress: {
|
wordpress: {
|
||||||
enabled: isWordPress,
|
enabled: isWordPress,
|
||||||
@@ -481,6 +490,10 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
dbPassword: app.dbPassword || '',
|
dbPassword: app.dbPassword || '',
|
||||||
dbVersion: app.dbVersion || '',
|
dbVersion: app.dbVersion || '',
|
||||||
dbStorageSize: app.dbStorageSize || '1Gi',
|
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',
|
appStorageSize: app.appStorageSize || '2Gi',
|
||||||
enableRedis: app.enableRedis || false,
|
enableRedis: app.enableRedis || false,
|
||||||
redisVersion: app.redisVersion || '7.2',
|
redisVersion: app.redisVersion || '7.2',
|
||||||
@@ -559,6 +572,10 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
dbPassword: app.dbPassword || this.generatePassword(),
|
dbPassword: app.dbPassword || this.generatePassword(),
|
||||||
dbVersion: app.dbVersion || '',
|
dbVersion: app.dbVersion || '',
|
||||||
dbStorageSize: app.dbStorageSize || '1Gi',
|
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',
|
appStorageSize: app.appStorageSize || '2Gi',
|
||||||
enableRedis: false,
|
enableRedis: false,
|
||||||
redisVersion: app.redisVersion || '7.2',
|
redisVersion: app.redisVersion || '7.2',
|
||||||
@@ -626,6 +643,10 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
dbPassword: app.dbPassword || this.generatePassword(),
|
dbPassword: app.dbPassword || this.generatePassword(),
|
||||||
dbVersion: app.dbVersion || '',
|
dbVersion: app.dbVersion || '',
|
||||||
dbStorageSize: app.dbStorageSize || '1Gi',
|
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',
|
appStorageSize: app.appStorageSize || '2Gi',
|
||||||
enableRedis: app.enableRedis || false,
|
enableRedis: app.enableRedis || false,
|
||||||
redisVersion: app.redisVersion || '7.2',
|
redisVersion: app.redisVersion || '7.2',
|
||||||
@@ -1808,8 +1829,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
env: envVars,
|
env: envVars,
|
||||||
volumeMounts: [{ name: 'db-storage', mountPath: dataPath }],
|
volumeMounts: [{ name: 'db-storage', mountPath: dataPath }],
|
||||||
resources: {
|
resources: {
|
||||||
requests: { cpu: '100m', memory: '256Mi' },
|
requests: { cpu: ctx.dbCpuRequest, memory: ctx.dbMemoryRequest },
|
||||||
limits: { cpu: '500m', memory: '512Mi' },
|
limits: { cpu: ctx.dbCpuLimit, memory: ctx.dbMemoryLimit },
|
||||||
},
|
},
|
||||||
readinessProbe,
|
readinessProbe,
|
||||||
livenessProbe,
|
livenessProbe,
|
||||||
|
|||||||
@@ -36,9 +36,16 @@ function sanitizePayloadForWordPressRuntime(payload: CreateApplicationDto): Crea
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type OptionalServiceKey = 'redis' | 'rabbitmq';
|
type OptionalServiceKey = 'database' | 'redis' | 'rabbitmq';
|
||||||
|
|
||||||
const FALLBACK_OPTIONAL_RESOURCES: Record<OptionalServiceKey, OptionalServiceResourceConfig> = {
|
const FALLBACK_OPTIONAL_RESOURCES: Record<OptionalServiceKey, OptionalServiceResourceConfig> = {
|
||||||
|
database: {
|
||||||
|
cpuRequest: '100m',
|
||||||
|
cpuLimit: '500m',
|
||||||
|
memoryRequest: '256Mi',
|
||||||
|
memoryLimit: '512Mi',
|
||||||
|
storageGi: 1,
|
||||||
|
},
|
||||||
redis: {
|
redis: {
|
||||||
cpuRequest: '50m',
|
cpuRequest: '50m',
|
||||||
cpuLimit: '200m',
|
cpuLimit: '200m',
|
||||||
@@ -313,6 +320,22 @@ export default function DeployPage() {
|
|||||||
return Math.max(1, minGiToFitFileBytes(dbDumpFile.size));
|
return Math.max(1, minGiToFitFileBytes(dbDumpFile.size));
|
||||||
}, [form.databaseType, dbDumpFile]);
|
}, [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<ClusterPublic[]>({
|
const { data: clusters = [] } = useQuery<ClusterPublic[]>({
|
||||||
queryKey: ['clusters-public'],
|
queryKey: ['clusters-public'],
|
||||||
queryFn: () => api.get('/clusters/public').then((r) => r.data),
|
queryFn: () => api.get('/clusters/public').then((r) => r.data),
|
||||||
@@ -373,6 +396,8 @@ export default function DeployPage() {
|
|||||||
enableRedis: form.runtime === 'wordpress' ? false : form.enableRedis,
|
enableRedis: form.runtime === 'wordpress' ? false : form.enableRedis,
|
||||||
enableRabbitmq: form.runtime === 'wordpress' ? false : form.enableRabbitmq,
|
enableRabbitmq: form.runtime === 'wordpress' ? false : form.enableRabbitmq,
|
||||||
enableElasticsearch: form.runtime === 'wordpress' ? false : form.enableElasticsearch,
|
enableElasticsearch: form.runtime === 'wordpress' ? false : form.enableElasticsearch,
|
||||||
|
databaseResources:
|
||||||
|
form.databaseType !== 'none' ? form.optionalServiceResources?.database : undefined,
|
||||||
redisResources:
|
redisResources:
|
||||||
form.runtime !== 'wordpress' && form.enableRedis ? form.optionalServiceResources?.redis : undefined,
|
form.runtime !== 'wordpress' && form.enableRedis ? form.optionalServiceResources?.redis : undefined,
|
||||||
rabbitmqResources:
|
rabbitmqResources:
|
||||||
@@ -410,6 +435,7 @@ export default function DeployPage() {
|
|||||||
setDeployStage('creating');
|
setDeployStage('creating');
|
||||||
const payload = sanitizePayloadForWordPressRuntime({
|
const payload = sanitizePayloadForWordPressRuntime({
|
||||||
...form,
|
...form,
|
||||||
|
optionalServiceResources: dbSyncedOptionalResources(),
|
||||||
...(form.databaseType !== 'none' && form.dbStorageSize
|
...(form.databaseType !== 'none' && form.dbStorageSize
|
||||||
? { dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi` }
|
? { dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi` }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -492,6 +518,7 @@ export default function DeployPage() {
|
|||||||
setDeployStage('creating');
|
setDeployStage('creating');
|
||||||
const payload = sanitizePayloadForWordPressRuntime({
|
const payload = sanitizePayloadForWordPressRuntime({
|
||||||
...form,
|
...form,
|
||||||
|
optionalServiceResources: dbSyncedOptionalResources(),
|
||||||
...(form.databaseType !== 'none' && form.dbStorageSize
|
...(form.databaseType !== 'none' && form.dbStorageSize
|
||||||
? { dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi` }
|
? { dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi` }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -645,7 +672,7 @@ export default function DeployPage() {
|
|||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!validateRestoreStorageOrShowModal()) return;
|
if (!validateRestoreStorageOrShowModal()) return;
|
||||||
const payload = { ...form };
|
const payload = { ...form, optionalServiceResources: dbSyncedOptionalResources() };
|
||||||
// Format dbStorageSize with Gi suffix
|
// Format dbStorageSize with Gi suffix
|
||||||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||||||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
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 === 'mysql') dbVersion = '8.0';
|
||||||
else if (opt.value === 'mariadb') dbVersion = '11.4';
|
else if (opt.value === 'mariadb') dbVersion = '11.4';
|
||||||
else if (opt.value === 'mongodb') dbVersion = '7.0';
|
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 ${
|
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'
|
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() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Database Storage Size */}
|
{/* CPU, RAM and disk for the database are configured on the Resources step. */}
|
||||||
<div className="pt-2">
|
<p className="text-xs text-gray-400 pt-1">
|
||||||
<label className="block text-xs text-gray-500 mb-2">{dw.databaseStorageSize}</label>
|
{dw.databaseResourcesHint}
|
||||||
<div className="flex items-center gap-3">
|
{dbDumpFile
|
||||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
? ` (${dw.databaseStorageSize} ≥ ${minDbGiFromRestoreDump} GB)`
|
||||||
<button
|
: ''}
|
||||||
type="button"
|
</p>
|
||||||
onClick={() => {
|
|
||||||
const current = parseInt(form.dbStorageSize || '1', 10);
|
|
||||||
const min = minDbGiFromRestoreDump;
|
|
||||||
if (current > min) setForm({ ...form, dbStorageSize: String(current - 1) });
|
|
||||||
}}
|
|
||||||
disabled={parseInt(form.dbStorageSize || '1', 10) <= 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 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"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const current = parseInt(form.dbStorageSize || '1', 10);
|
|
||||||
if (current < 100) setForm({ ...form, dbStorageSize: String(current + 1) });
|
|
||||||
}}
|
|
||||||
disabled={parseInt(form.dbStorageSize || '1', 10) >= 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 based on dump size ({(dbDumpFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-gray-400">
|
|
||||||
Minimum {minDbGiFromRestoreDump} GB for this configuration
|
|
||||||
{dbDumpFile ? ' (must fit the uploaded dump)' : ''} • Only expansion is allowed after creation
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2444,6 +2438,48 @@ export default function DeployPage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{form.databaseType !== 'none' && form.optionalServiceResources?.database && (
|
||||||
|
<div className="space-y-4 pt-2 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">{dw.databaseResources}</h3>
|
||||||
|
<WorkloadResourceFields
|
||||||
|
title={
|
||||||
|
form.databaseType === 'postgresql' ? 'PostgreSQL'
|
||||||
|
: form.databaseType === 'mysql' ? 'MySQL'
|
||||||
|
: form.databaseType === 'mariadb' ? 'MariaDB'
|
||||||
|
: 'MongoDB'
|
||||||
|
}
|
||||||
|
accentClass="text-blue-500"
|
||||||
|
borderClass="border-blue-200"
|
||||||
|
bgClass="bg-blue-50/50"
|
||||||
|
config={{
|
||||||
|
...form.optionalServiceResources.database,
|
||||||
|
storageGi: parseInt(form.dbStorageSize || '1', 10) || 1,
|
||||||
|
}}
|
||||||
|
onChange={(patch) => {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{form.runtime !== 'wordpress' && (form.enableRedis || form.enableRabbitmq) && (
|
{form.runtime !== 'wordpress' && (form.enableRedis || form.enableRabbitmq) && (
|
||||||
<div className="space-y-4 pt-2 border-t border-gray-200">
|
<div className="space-y-4 pt-2 border-t border-gray-200">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">{dw.optionalServices}</h3>
|
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">{dw.optionalServices}</h3>
|
||||||
@@ -2529,6 +2565,16 @@ export default function DeployPage() {
|
|||||||
<span className="text-sm text-gray-500">{dw.dbStorage}</span>
|
<span className="text-sm text-gray-500">{dw.dbStorage}</span>
|
||||||
<span className="text-sm font-medium">{form.dbStorageSize || '1'} GB</span>
|
<span className="text-sm font-medium">{form.dbStorageSize || '1'} GB</span>
|
||||||
</div>
|
</div>
|
||||||
|
{form.optionalServiceResources?.database && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">{dw.databaseResources}</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{form.optionalServiceResources.database.cpuRequest} / {form.optionalServiceResources.database.cpuLimit}
|
||||||
|
{' · '}
|
||||||
|
{form.optionalServiceResources.database.memoryRequest} / {form.optionalServiceResources.database.memoryLimit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{dbDumpFile && (
|
{dbDumpFile && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-gray-500">{dw.dbDump}</span>
|
<span className="text-sm text-gray-500">{dw.dbDump}</span>
|
||||||
|
|||||||
@@ -1231,6 +1231,8 @@ const en: Dictionary = {
|
|||||||
databaseCredentials: 'Database Credentials',
|
databaseCredentials: 'Database Credentials',
|
||||||
dbUsername: 'DB Username', dbPassword: 'DB Password', dbStorage: 'DB Storage', dbDump: 'DB Dump',
|
dbUsername: 'DB Username', dbPassword: 'DB Password', dbStorage: 'DB Storage', dbDump: 'DB Dump',
|
||||||
databaseStorageSize: 'Database Storage Size',
|
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',
|
username: 'Username', password: 'Password', autoGenerated: 'Auto-generated',
|
||||||
generatePassword: 'Generate random password',
|
generatePassword: 'Generate random password',
|
||||||
dbCredsNote: 'These credentials are used for internal cluster communication only. The database is not exposed externally.',
|
dbCredsNote: 'These credentials are used for internal cluster communication only. The database is not exposed externally.',
|
||||||
|
|||||||
@@ -1243,6 +1243,8 @@ const fa = {
|
|||||||
databaseCredentials: 'اطلاعات ورود دیتابیس',
|
databaseCredentials: 'اطلاعات ورود دیتابیس',
|
||||||
dbUsername: 'نام کاربری دیتابیس', dbPassword: 'رمز دیتابیس', dbStorage: 'فضای دیتابیس', dbDump: 'dump دیتابیس',
|
dbUsername: 'نام کاربری دیتابیس', dbPassword: 'رمز دیتابیس', dbStorage: 'فضای دیتابیس', dbDump: 'dump دیتابیس',
|
||||||
databaseStorageSize: 'اندازهٔ فضای ذخیرهٔ دیتابیس',
|
databaseStorageSize: 'اندازهٔ فضای ذخیرهٔ دیتابیس',
|
||||||
|
databaseResources: 'منابع دیتابیس',
|
||||||
|
databaseResourcesHint: 'پردازنده، حافظه و دیسک دیتابیس را در مرحلهٔ منابع پیکربندی کن.',
|
||||||
username: 'نام کاربری', password: 'رمز عبور', autoGenerated: 'تولید خودکار',
|
username: 'نام کاربری', password: 'رمز عبور', autoGenerated: 'تولید خودکار',
|
||||||
generatePassword: 'تولید رمز تصادفی',
|
generatePassword: 'تولید رمز تصادفی',
|
||||||
dbCredsNote: 'این اطلاعات فقط برای ارتباط داخلی کلاستر استفاده میشوند. دیتابیس بهصورت خارجی expose نمیشود.',
|
dbCredsNote: 'این اطلاعات فقط برای ارتباط داخلی کلاستر استفاده میشوند. دیتابیس بهصورت خارجی expose نمیشود.',
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import type {
|
|||||||
export type OptionalServiceKey = keyof OptionalServiceResourcesMap;
|
export type OptionalServiceKey = keyof OptionalServiceResourcesMap;
|
||||||
|
|
||||||
const FALLBACK_OPTIONAL_RESOURCES: Record<OptionalServiceKey, OptionalServiceResourceConfig> = {
|
const FALLBACK_OPTIONAL_RESOURCES: Record<OptionalServiceKey, OptionalServiceResourceConfig> = {
|
||||||
|
database: {
|
||||||
|
cpuRequest: '100m',
|
||||||
|
cpuLimit: '500m',
|
||||||
|
memoryRequest: '256Mi',
|
||||||
|
memoryLimit: '512Mi',
|
||||||
|
storageGi: 1,
|
||||||
|
},
|
||||||
redis: {
|
redis: {
|
||||||
cpuRequest: '50m',
|
cpuRequest: '50m',
|
||||||
cpuLimit: '200m',
|
cpuLimit: '200m',
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export interface OptionalServiceResourceConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OptionalServiceResourcesMap {
|
export interface OptionalServiceResourcesMap {
|
||||||
|
database?: OptionalServiceResourceConfig;
|
||||||
redis?: OptionalServiceResourceConfig;
|
redis?: OptionalServiceResourceConfig;
|
||||||
rabbitmq?: OptionalServiceResourceConfig;
|
rabbitmq?: OptionalServiceResourceConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user