Add optional service pricing matrix and fix admin catalog save.
Users pick per-service CPU/memory/storage at deploy; admins manage unit rates and deploy defaults. PATCH sends only fields accepted by the pricing-catalog DTO. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
-- Per-addon consumption footprint and optional resource unit prices (0 = use app runtime rates)
|
||||
|
||||
ALTER TABLE addon_rates
|
||||
ADD COLUMN IF NOT EXISTS cpu_limit VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS memory_limit VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS storage_gi DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS log_shipper_cpu_limit VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS log_shipper_memory_limit VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS cpu_hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS cpu_monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS cpu_yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS memory_hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS memory_monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS memory_yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS storage_hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS storage_monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS storage_yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE addon_rates SET cpu_limit = '200m', memory_limit = '256Mi', storage_gi = 1
|
||||
WHERE resource_type = 'redis_addon' AND cpu_limit IS NULL;
|
||||
|
||||
UPDATE addon_rates SET cpu_limit = '500m', memory_limit = '512Mi', storage_gi = 2
|
||||
WHERE resource_type = 'rabbitmq_addon' AND cpu_limit IS NULL;
|
||||
|
||||
UPDATE addon_rates SET cpu_limit = '50m', memory_limit = '64Mi', storage_gi = 0,
|
||||
log_shipper_cpu_limit = '50m', log_shipper_memory_limit = '64Mi'
|
||||
WHERE resource_type = 'elasticsearch_addon' AND cpu_limit IS NULL;
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Optional services: resource profile + pricing matrix (same model as application runtimes)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS optional_service_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
service VARCHAR NOT NULL UNIQUE,
|
||||
cpu_limit VARCHAR NOT NULL DEFAULT '200m',
|
||||
memory_limit VARCHAR NOT NULL DEFAULT '256Mi',
|
||||
storage_gi DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
||||
log_shipper_cpu_limit VARCHAR,
|
||||
log_shipper_memory_limit VARCHAR,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS optional_service_rates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
service VARCHAR NOT NULL,
|
||||
resource_type VARCHAR NOT NULL,
|
||||
hourly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
|
||||
monthly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
|
||||
yearly_price DECIMAL(12, 2) NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (service, resource_type)
|
||||
);
|
||||
|
||||
INSERT INTO optional_service_profiles (service, cpu_limit, memory_limit, storage_gi, log_shipper_cpu_limit, log_shipper_memory_limit)
|
||||
VALUES
|
||||
('redis', '200m', '256Mi', 1, NULL, NULL),
|
||||
('rabbitmq', '500m', '512Mi', 2, NULL, NULL),
|
||||
('elasticsearch', '50m', '64Mi', 0, '50m', '64Mi')
|
||||
ON CONFLICT (service) DO NOTHING;
|
||||
|
||||
UPDATE optional_service_profiles SET
|
||||
log_shipper_cpu_limit = '50m',
|
||||
log_shipper_memory_limit = '64Mi'
|
||||
WHERE service = 'elasticsearch' AND log_shipper_cpu_limit IS NULL;
|
||||
|
||||
INSERT INTO optional_service_rates (service, resource_type, hourly_price, monthly_price, yearly_price)
|
||||
SELECT s.service, t.resource_type, 0, 0, 0
|
||||
FROM (VALUES ('redis'), ('rabbitmq'), ('elasticsearch')) AS s(service)
|
||||
CROSS JOIN (
|
||||
VALUES ('base_fee'), ('cpu_per_core'), ('memory_per_gb'), ('storage_per_gb')
|
||||
) AS t(resource_type)
|
||||
ON CONFLICT (service, resource_type) DO NOTHING;
|
||||
|
||||
-- Migrate legacy per-resource addon prices into optional_service_rates (if columns exist)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'addon_rates' AND column_name = 'cpu_hourly_price'
|
||||
) THEN
|
||||
UPDATE optional_service_rates osr SET
|
||||
hourly_price = COALESCE(ar.cpu_hourly_price, 0),
|
||||
monthly_price = COALESCE(ar.cpu_monthly_price, 0),
|
||||
yearly_price = COALESCE(ar.cpu_yearly_price, 0)
|
||||
FROM addon_rates ar
|
||||
WHERE ar.resource_type = 'redis_addon' AND osr.service = 'redis' AND osr.resource_type = 'cpu_per_core'
|
||||
AND (ar.cpu_hourly_price > 0 OR ar.cpu_monthly_price > 0);
|
||||
|
||||
UPDATE optional_service_rates osr SET
|
||||
hourly_price = COALESCE(ar.memory_hourly_price, 0),
|
||||
monthly_price = COALESCE(ar.memory_monthly_price, 0),
|
||||
yearly_price = COALESCE(ar.memory_yearly_price, 0)
|
||||
FROM addon_rates ar
|
||||
WHERE ar.resource_type = 'redis_addon' AND osr.service = 'redis' AND osr.resource_type = 'memory_per_gb'
|
||||
AND (ar.memory_hourly_price > 0 OR ar.memory_monthly_price > 0);
|
||||
|
||||
UPDATE optional_service_rates osr SET
|
||||
hourly_price = COALESCE(ar.storage_hourly_price, 0),
|
||||
monthly_price = COALESCE(ar.storage_monthly_price, 0),
|
||||
yearly_price = COALESCE(ar.storage_yearly_price, 0)
|
||||
FROM addon_rates ar
|
||||
WHERE ar.resource_type = 'redis_addon' AND osr.service = 'redis' AND osr.resource_type = 'storage_per_gb'
|
||||
AND (ar.storage_hourly_price > 0 OR ar.storage_monthly_price > 0);
|
||||
|
||||
UPDATE optional_service_profiles osp SET
|
||||
cpu_limit = COALESCE(ar.cpu_limit, osp.cpu_limit),
|
||||
memory_limit = COALESCE(ar.memory_limit, osp.memory_limit),
|
||||
storage_gi = COALESCE(ar.storage_gi, osp.storage_gi)
|
||||
FROM addon_rates ar WHERE ar.resource_type = 'redis_addon' AND osp.service = 'redis';
|
||||
|
||||
UPDATE optional_service_profiles osp SET
|
||||
cpu_limit = COALESCE(ar.cpu_limit, osp.cpu_limit),
|
||||
memory_limit = COALESCE(ar.memory_limit, osp.memory_limit),
|
||||
storage_gi = COALESCE(ar.storage_gi, osp.storage_gi)
|
||||
FROM addon_rates ar WHERE ar.resource_type = 'rabbitmq_addon' AND osp.service = 'rabbitmq';
|
||||
|
||||
UPDATE optional_service_profiles osp SET
|
||||
cpu_limit = COALESCE(ar.cpu_limit, osp.cpu_limit),
|
||||
memory_limit = COALESCE(ar.memory_limit, osp.memory_limit),
|
||||
log_shipper_cpu_limit = COALESCE(ar.log_shipper_cpu_limit, osp.log_shipper_cpu_limit),
|
||||
log_shipper_memory_limit = COALESCE(ar.log_shipper_memory_limit, osp.log_shipper_memory_limit)
|
||||
FROM addon_rates ar WHERE ar.resource_type = 'elasticsearch_addon' AND osp.service = 'elasticsearch';
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Per-deploy optional service resource limits (user-chosen, like application CPU/RAM/storage)
|
||||
|
||||
ALTER TABLE applications
|
||||
ADD COLUMN IF NOT EXISTS optional_service_resources JSONB;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Deploy-wizard default requests (TypeORM camelCase column names)
|
||||
|
||||
ALTER TABLE optional_service_profiles
|
||||
ADD COLUMN IF NOT EXISTS "cpuRequest" VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS "memoryRequest" VARCHAR;
|
||||
|
||||
UPDATE optional_service_profiles SET "cpuRequest" = '50m', "memoryRequest" = '64Mi'
|
||||
WHERE service = 'redis' AND "cpuRequest" IS NULL;
|
||||
|
||||
UPDATE optional_service_profiles SET "cpuRequest" = '100m', "memoryRequest" = '256Mi'
|
||||
WHERE service = 'rabbitmq' AND "cpuRequest" IS NULL;
|
||||
|
||||
UPDATE optional_service_profiles SET "cpuRequest" = '50m', "memoryRequest" = '64Mi'
|
||||
WHERE service = 'elasticsearch' AND "cpuRequest" IS NULL;
|
||||
@@ -10,9 +10,26 @@ import {
|
||||
Max,
|
||||
Matches,
|
||||
IsIn,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { AppRuntime, DatabaseType } from '../../common/enums';
|
||||
import { OptionalServiceResourcesDto } from '../../billing/dto/optional-service-resources.dto';
|
||||
|
||||
export class OptionalServiceResourcesMapDto {
|
||||
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionalServiceResourcesDto)
|
||||
redis?: OptionalServiceResourcesDto;
|
||||
|
||||
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionalServiceResourcesDto)
|
||||
rabbitmq?: OptionalServiceResourcesDto;
|
||||
}
|
||||
|
||||
export class CreateApplicationDto {
|
||||
@ApiProperty({ example: 'my-app' })
|
||||
@@ -107,6 +124,12 @@ export class CreateApplicationDto {
|
||||
@IsString({ each: true })
|
||||
logPaths?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: OptionalServiceResourcesMapDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionalServiceResourcesMapDto)
|
||||
optionalServiceResources?: OptionalServiceResourcesMapDto;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://github.com/user/repo.git' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -72,6 +72,25 @@ 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). */
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
optionalServiceResources?: {
|
||||
redis?: {
|
||||
cpuRequest?: string;
|
||||
cpuLimit: string;
|
||||
memoryRequest?: string;
|
||||
memoryLimit: string;
|
||||
storageGi: number;
|
||||
};
|
||||
rabbitmq?: {
|
||||
cpuRequest?: string;
|
||||
cpuLimit: string;
|
||||
memoryRequest?: string;
|
||||
memoryLimit: string;
|
||||
storageGi: number;
|
||||
};
|
||||
};
|
||||
|
||||
@Column({ nullable: true })
|
||||
gitUrl: string;
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { BillingController } from './billing.controller';
|
||||
import { PricingCatalogService } from './pricing-catalog.service';
|
||||
import { PricingRate } from './entities/pricing-rate.entity';
|
||||
import { AddonRate } from './entities/addon-rate.entity';
|
||||
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
||||
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
||||
import { Wallet } from './entities/wallet.entity';
|
||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||
import { ResourceCredit } from './entities/resource-credit.entity';
|
||||
@@ -17,6 +19,8 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
TypeOrmModule.forFeature([
|
||||
PricingRate,
|
||||
AddonRate,
|
||||
OptionalServiceProfile,
|
||||
OptionalServiceRate,
|
||||
Wallet,
|
||||
WalletTransaction,
|
||||
ResourceCredit,
|
||||
|
||||
@@ -312,6 +312,12 @@ export class BillingService {
|
||||
('enableCustomDomain' in app
|
||||
? !!(app as CalculateCostDto).enableCustomDomain
|
||||
: !!(app as Application).customDomain);
|
||||
const optionalRes =
|
||||
'optionalServiceResources' in app
|
||||
? (app as Application).optionalServiceResources
|
||||
: undefined;
|
||||
const dtoExtras =
|
||||
'redisResources' in app ? (app as CalculateCostDto) : undefined;
|
||||
return {
|
||||
runtime: app.runtime,
|
||||
databaseType: app.databaseType,
|
||||
@@ -324,6 +330,8 @@ export class BillingService {
|
||||
enableRabbitmq: !!app.enableRabbitmq,
|
||||
enableElasticsearch: !!app.enableElasticsearch,
|
||||
enableCustomDomain,
|
||||
redisResources: optionalRes?.redis ?? dtoExtras?.redisResources,
|
||||
rabbitmqResources: optionalRes?.rabbitmq ?? dtoExtras?.rabbitmqResources,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -467,6 +475,8 @@ export class BillingService {
|
||||
enableRabbitmq: config.enableRabbitmq,
|
||||
enableElasticsearch: config.enableElasticsearch,
|
||||
enableCustomDomain: config.enableCustomDomain,
|
||||
redisResources: config.redisResources,
|
||||
rabbitmqResources: config.rabbitmqResources,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { IsString, IsEnum, IsOptional, IsBoolean, IsNumber, IsArray, ValidateNes
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { BillingCycle, PricingResourceType, AppRuntime } from '../../common/enums';
|
||||
import { OptionalServiceResourcesDto } from './optional-service-resources.dto';
|
||||
|
||||
export class CreatePricingRuleDto {
|
||||
@ApiProperty({ enum: PricingResourceType })
|
||||
@@ -137,6 +138,18 @@ export class CalculateCostDto {
|
||||
@IsBoolean()
|
||||
enableElasticsearch?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionalServiceResourcesDto)
|
||||
redisResources?: OptionalServiceResourcesDto;
|
||||
|
||||
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionalServiceResourcesDto)
|
||||
rabbitmqResources?: OptionalServiceResourcesDto;
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Enable custom domain with SSL' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
|
||||
/** User-selected limits for an optional service at deploy time. */
|
||||
export class OptionalServiceResourcesDto {
|
||||
@ApiPropertyOptional({ example: '50m' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cpuRequest?: string;
|
||||
|
||||
@ApiProperty({ example: '200m' })
|
||||
@IsString()
|
||||
cpuLimit: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '64Mi' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memoryRequest?: string;
|
||||
|
||||
@ApiProperty({ example: '256Mi' })
|
||||
@IsString()
|
||||
memoryLimit: string;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
storageGi: number;
|
||||
}
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { AppRuntime, PricingResourceType } from '../../common/enums';
|
||||
import { AppRuntime, OptionalService, PricingResourceType } from '../../common/enums';
|
||||
|
||||
export class PricingRateRowDto {
|
||||
@ApiProperty({ enum: PricingResourceType })
|
||||
@@ -38,21 +39,94 @@ export class PricingRateRowDto {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class OptionalServiceProfileDto {
|
||||
@ApiPropertyOptional({ example: '50m' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cpuRequest?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '64Mi' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memoryRequest?: string;
|
||||
|
||||
@ApiProperty({ example: '200m' })
|
||||
@IsString()
|
||||
cpuLimit: string;
|
||||
|
||||
@ApiProperty({ example: '256Mi' })
|
||||
@IsString()
|
||||
memoryLimit: string;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
storageGi: number;
|
||||
|
||||
@ApiPropertyOptional({ example: '50m' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logShipperCpuLimit?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '64Mi' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logShipperMemoryLimit?: string;
|
||||
}
|
||||
|
||||
export class OptionalServiceCatalogEntryDto {
|
||||
@ApiProperty({ enum: OptionalService })
|
||||
@IsEnum(OptionalService)
|
||||
service: OptionalService;
|
||||
|
||||
@ApiProperty({ type: OptionalServiceProfileDto })
|
||||
@ValidateNested()
|
||||
@Type(() => OptionalServiceProfileDto)
|
||||
profile: OptionalServiceProfileDto;
|
||||
|
||||
@ApiProperty({ type: [PricingRateRowDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PricingRateRowDto)
|
||||
rates: PricingRateRowDto[];
|
||||
}
|
||||
|
||||
export class CustomDomainCatalogDto {
|
||||
@ApiProperty({ example: 500 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
hourlyPrice: number;
|
||||
|
||||
@ApiProperty({ example: 50000 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
monthlyPrice: number;
|
||||
|
||||
@ApiProperty({ example: 500000 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
yearlyPrice: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdatePricingCatalogDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Per-runtime pricing rows keyed by runtime',
|
||||
example: {
|
||||
nodejs: [{ resourceType: 'base_fee', hourlyPrice: 0, monthlyPrice: 10000, yearlyPrice: 100000 }],
|
||||
},
|
||||
})
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
runtimes?: Partial<Record<AppRuntime, PricingRateRowDto[]>>;
|
||||
|
||||
@ApiPropertyOptional({ type: [PricingRateRowDto] })
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PricingRateRowDto)
|
||||
addons?: PricingRateRowDto[];
|
||||
@IsObject()
|
||||
optionalServices?: Partial<Record<OptionalService, OptionalServiceCatalogEntryDto>>;
|
||||
|
||||
@ApiPropertyOptional({ type: CustomDomainCatalogDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => CustomDomainCatalogDto)
|
||||
customDomain?: CustomDomainCatalogDto;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from 'typeorm';
|
||||
import { PricingResourceType } from '../../common/enums';
|
||||
|
||||
/** Flat-fee addons only (e.g. custom domain). Optional services use optional_service_* tables. */
|
||||
@Entity('addon_rates')
|
||||
export class AddonRate {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { OptionalService } from '../../common/enums';
|
||||
|
||||
@Entity('optional_service_profiles')
|
||||
export class OptionalServiceProfile {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'enum', enum: OptionalService, unique: true })
|
||||
service: OptionalService;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
cpuRequest?: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
memoryRequest?: string;
|
||||
|
||||
@Column({ type: 'varchar', default: '200m' })
|
||||
cpuLimit: string;
|
||||
|
||||
@Column({ type: 'varchar', default: '256Mi' })
|
||||
memoryLimit: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 1 })
|
||||
storageGi: number;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
logShipperCpuLimit?: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
logShipperMemoryLimit?: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { OptionalService, PricingResourceType } from '../../common/enums';
|
||||
|
||||
@Entity('optional_service_rates')
|
||||
@Unique(['service', 'resourceType'])
|
||||
export class OptionalServiceRate {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'enum', enum: OptionalService })
|
||||
service: OptionalService;
|
||||
|
||||
@Column({ type: 'enum', enum: PricingResourceType })
|
||||
resourceType: PricingResourceType;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
hourlyPrice: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
monthlyPrice: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
yearlyPrice: number;
|
||||
|
||||
@Column({ default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -24,6 +24,14 @@ export const RUNTIME_PRICING_RESOURCES: PricingResourceType[] = [
|
||||
PricingResourceType.DATABASE_ADDON,
|
||||
];
|
||||
|
||||
/** Same billing rows as apps, without database (not applicable to Redis/RabbitMQ/ES). */
|
||||
export const OPTIONAL_SERVICE_BILLING_RESOURCES: PricingResourceType[] = [
|
||||
PricingResourceType.BASE_FEE,
|
||||
PricingResourceType.CPU_PER_CORE,
|
||||
PricingResourceType.MEMORY_PER_GB,
|
||||
PricingResourceType.STORAGE_PER_GB,
|
||||
];
|
||||
|
||||
/** Maps optional service → flat addon row in addon_rates. */
|
||||
export const OPTIONAL_SERVICE_PRICING_TYPE: Record<OptionalService, PricingResourceType> = {
|
||||
[OptionalService.REDIS]: PricingResourceType.REDIS_ADDON,
|
||||
@@ -32,10 +40,11 @@ export const OPTIONAL_SERVICE_PRICING_TYPE: Record<OptionalService, PricingResou
|
||||
};
|
||||
|
||||
export function getBillableAddonResourceTypes(): PricingResourceType[] {
|
||||
return [
|
||||
...Object.values(OPTIONAL_SERVICE_PRICING_TYPE),
|
||||
PricingResourceType.CUSTOM_DOMAIN_ADDON,
|
||||
];
|
||||
return [PricingResourceType.CUSTOM_DOMAIN_ADDON];
|
||||
}
|
||||
|
||||
export function getAllOptionalServices(): OptionalService[] {
|
||||
return Object.values(OptionalService);
|
||||
}
|
||||
|
||||
export const OPTIONAL_SERVICE_LABELS: Record<OptionalService, string> = {
|
||||
@@ -47,11 +56,35 @@ export const OPTIONAL_SERVICE_LABELS: Record<OptionalService, string> = {
|
||||
/** Deploy footprint aligned with helm/cloudhost-app defaults (limits used for billing). */
|
||||
export const OPTIONAL_SERVICE_DEPLOY_SPECS: Record<
|
||||
OptionalService,
|
||||
{ cpuLimit: string; memoryLimit: string; storageGi: number }
|
||||
{
|
||||
cpuRequest: string;
|
||||
memoryRequest: string;
|
||||
cpuLimit: string;
|
||||
memoryLimit: string;
|
||||
storageGi: number;
|
||||
}
|
||||
> = {
|
||||
[OptionalService.REDIS]: { cpuLimit: '200m', memoryLimit: '256Mi', storageGi: 1 },
|
||||
[OptionalService.RABBITMQ]: { cpuLimit: '500m', memoryLimit: '512Mi', storageGi: 2 },
|
||||
[OptionalService.ELASTICSEARCH]: { cpuLimit: '50m', memoryLimit: '64Mi', storageGi: 0 },
|
||||
[OptionalService.REDIS]: {
|
||||
cpuRequest: '50m',
|
||||
memoryRequest: '64Mi',
|
||||
cpuLimit: '200m',
|
||||
memoryLimit: '256Mi',
|
||||
storageGi: 1,
|
||||
},
|
||||
[OptionalService.RABBITMQ]: {
|
||||
cpuRequest: '100m',
|
||||
memoryRequest: '256Mi',
|
||||
cpuLimit: '500m',
|
||||
memoryLimit: '512Mi',
|
||||
storageGi: 2,
|
||||
},
|
||||
[OptionalService.ELASTICSEARCH]: {
|
||||
cpuRequest: '50m',
|
||||
memoryRequest: '64Mi',
|
||||
cpuLimit: '50m',
|
||||
memoryLimit: '64Mi',
|
||||
storageGi: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/** Fluent Bit sidecar per workload when Elasticsearch logging is enabled. */
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { PricingCatalogService } from './pricing-catalog.service';
|
||||
import { PricingRate } from './entities/pricing-rate.entity';
|
||||
import { AddonRate } from './entities/addon-rate.entity';
|
||||
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
||||
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
||||
import {
|
||||
AppRuntime,
|
||||
BillingCycle,
|
||||
@@ -30,6 +32,20 @@ describe('PricingCatalogService', () => {
|
||||
create: jest.fn().mockImplementation((x) => x),
|
||||
};
|
||||
|
||||
const optionalProfileRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
|
||||
create: jest.fn().mockImplementation((x) => x),
|
||||
};
|
||||
|
||||
const optionalRateRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
|
||||
create: jest.fn().mockImplementation((x) => x),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -37,6 +53,8 @@ describe('PricingCatalogService', () => {
|
||||
PricingCatalogService,
|
||||
{ provide: getRepositoryToken(PricingRate), useValue: rateRepo },
|
||||
{ provide: getRepositoryToken(AddonRate), useValue: addonRepo },
|
||||
{ provide: getRepositoryToken(OptionalServiceProfile), useValue: optionalProfileRepo },
|
||||
{ provide: getRepositoryToken(OptionalServiceRate), useValue: optionalRateRepo },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -51,6 +69,8 @@ describe('PricingCatalogService', () => {
|
||||
replicas: 1,
|
||||
});
|
||||
|
||||
const emptyOptional = () => ({ profiles: [], rates: [], customDomain: null });
|
||||
|
||||
it('parses CPU millicores to cores', () => {
|
||||
expect(service.parseCpuToCores('500m')).toBe(0.5);
|
||||
expect(service.parseCpuToCores('2')).toBe(2);
|
||||
@@ -68,31 +88,41 @@ describe('PricingCatalogService', () => {
|
||||
},
|
||||
] as PricingRate[];
|
||||
|
||||
const result = service.computeTotalsWithRates(baseDto(), rates, []);
|
||||
const result = service.computeTotalsWithRates(baseDto(), rates, emptyOptional());
|
||||
expect(result.hourly).toBe(50);
|
||||
expect(result.monthly).toBe(2500);
|
||||
expect(result.yearly).toBe(25000);
|
||||
expect(result.breakdown[0].label).toContain('CPU');
|
||||
});
|
||||
|
||||
it('includes redis addon only when enabled', () => {
|
||||
const addons = [
|
||||
it('includes redis optional service only when enabled', () => {
|
||||
const profile = {
|
||||
service: OptionalService.REDIS,
|
||||
cpuLimit: OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit,
|
||||
memoryLimit: '256Mi',
|
||||
storageGi: 0,
|
||||
} as OptionalServiceProfile;
|
||||
|
||||
const rates = [
|
||||
{
|
||||
resourceType: PricingResourceType.REDIS_ADDON,
|
||||
service: OptionalService.REDIS,
|
||||
resourceType: PricingResourceType.BASE_FEE,
|
||||
hourlyPrice: 10,
|
||||
monthlyPrice: 100,
|
||||
yearlyPrice: 1000,
|
||||
isActive: true,
|
||||
},
|
||||
] as AddonRate[];
|
||||
] as OptionalServiceRate[];
|
||||
|
||||
const without = service.computeTotalsWithRates(baseDto(), [], addons);
|
||||
const optional = { profiles: [profile], rates, customDomain: null };
|
||||
|
||||
const without = service.computeTotalsWithRates(baseDto(), [], optional);
|
||||
expect(without.monthly).toBe(0);
|
||||
|
||||
const withRedis = service.computeTotalsWithRates(
|
||||
{ ...baseDto(), enableRedis: true },
|
||||
[],
|
||||
addons,
|
||||
optional,
|
||||
);
|
||||
expect(withRedis.monthly).toBe(100);
|
||||
expect(withRedis.yearly).toBe(1000);
|
||||
@@ -111,42 +141,41 @@ describe('PricingCatalogService', () => {
|
||||
},
|
||||
] as PricingRate[];
|
||||
|
||||
const result = service.computeTotalsWithRates(baseDto(), rates, []);
|
||||
const result = service.computeTotalsWithRates(baseDto(), rates, emptyOptional());
|
||||
expect(result.yearly).toBe(999);
|
||||
expect(result.monthly).toBe(100);
|
||||
expect(result.yearly).not.toBe(result.monthly * 12);
|
||||
});
|
||||
|
||||
it('bills optional service CPU/RAM/storage with same runtime unit rates', () => {
|
||||
it('bills optional service CPU from profile and service rate matrix', () => {
|
||||
const profile = {
|
||||
service: OptionalService.REDIS,
|
||||
cpuLimit: OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit,
|
||||
memoryLimit: '256Mi',
|
||||
storageGi: 0,
|
||||
} as OptionalServiceProfile;
|
||||
|
||||
const rates = [
|
||||
{
|
||||
runtime: AppRuntime.NODEJS,
|
||||
service: OptionalService.REDIS,
|
||||
resourceType: PricingResourceType.CPU_PER_CORE,
|
||||
hourlyPrice: 1000,
|
||||
monthlyPrice: 0,
|
||||
yearlyPrice: 0,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
runtime: AppRuntime.NODEJS,
|
||||
resourceType: PricingResourceType.MEMORY_PER_GB,
|
||||
hourlyPrice: 0,
|
||||
monthlyPrice: 0,
|
||||
yearlyPrice: 0,
|
||||
isActive: true,
|
||||
},
|
||||
] as PricingRate[];
|
||||
] as OptionalServiceRate[];
|
||||
|
||||
const redisCpu = parseFloat(OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit) / 1000;
|
||||
const optional = { profiles: [profile], rates, customDomain: null };
|
||||
|
||||
const without = service.computeTotalsWithRates(baseDto(), rates, []);
|
||||
const without = service.computeTotalsWithRates(baseDto(), [], optional);
|
||||
const withRedis = service.computeTotalsWithRates(
|
||||
{ ...baseDto(), enableRedis: true },
|
||||
rates,
|
||||
[],
|
||||
optional,
|
||||
);
|
||||
|
||||
expect(withRedis.hourly - without.hourly).toBe(Math.round(redisCpu * 1000));
|
||||
expect(withRedis.hourly - without.hourly).toBe(200);
|
||||
});
|
||||
|
||||
it('amountForCycleFromLine picks the correct column', () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PricingRate } from './entities/pricing-rate.entity';
|
||||
import { AddonRate } from './entities/addon-rate.entity';
|
||||
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
||||
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
||||
import {
|
||||
AppRuntime,
|
||||
BillingCycle,
|
||||
@@ -14,15 +16,21 @@ import { CalculateCostDto } from './dto/billing.dto';
|
||||
import {
|
||||
FLUENT_BIT_SIDECAR,
|
||||
getAllBillingRuntimes,
|
||||
getAllOptionalServices,
|
||||
getBillableAddonResourceTypes,
|
||||
OPTIONAL_SERVICE_BILLING_RESOURCES,
|
||||
OPTIONAL_SERVICE_DEPLOY_SPECS,
|
||||
OPTIONAL_SERVICE_LABELS,
|
||||
OPTIONAL_SERVICE_PRICING_TYPE,
|
||||
RESOURCE_LABELS,
|
||||
RUNTIME_DISPLAY_LABELS,
|
||||
RUNTIME_PRICING_RESOURCES,
|
||||
} from './pricing-catalog.constants';
|
||||
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
|
||||
import {
|
||||
CustomDomainCatalogDto,
|
||||
OptionalServiceCatalogEntryDto,
|
||||
UpdatePricingCatalogDto,
|
||||
} from './dto/pricing-catalog.dto';
|
||||
import { OptionalServiceResourcesDto } from './dto/optional-service-resources.dto';
|
||||
|
||||
export interface CyclePrices {
|
||||
hourly: number;
|
||||
@@ -38,6 +46,30 @@ export interface PricingRateRow {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface OptionalServiceProfileRow {
|
||||
cpuRequest?: string;
|
||||
memoryRequest?: string;
|
||||
cpuLimit: string;
|
||||
memoryLimit: string;
|
||||
storageGi: number;
|
||||
logShipperCpuLimit?: string;
|
||||
logShipperMemoryLimit?: string;
|
||||
}
|
||||
|
||||
export interface OptionalServiceCatalogEntry {
|
||||
service: OptionalService;
|
||||
label: string;
|
||||
profile: OptionalServiceProfileRow;
|
||||
rates: PricingRateRow[];
|
||||
}
|
||||
|
||||
export interface CustomDomainCatalogRow {
|
||||
hourlyPrice: number;
|
||||
monthlyPrice: number;
|
||||
yearlyPrice: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface CatalogRuntimeOption {
|
||||
value: AppRuntime;
|
||||
label: string;
|
||||
@@ -46,12 +78,12 @@ export interface CatalogRuntimeOption {
|
||||
export interface CatalogOptionalServiceOption {
|
||||
value: OptionalService;
|
||||
label: string;
|
||||
resourceType: PricingResourceType;
|
||||
}
|
||||
|
||||
export interface PricingCatalogResponse {
|
||||
runtimes: Record<string, PricingRateRow[]>;
|
||||
addons: PricingRateRow[];
|
||||
optionalServices: Record<string, OptionalServiceCatalogEntry>;
|
||||
customDomain: CustomDomainCatalogRow;
|
||||
runtimeOptions: CatalogRuntimeOption[];
|
||||
optionalServiceOptions: CatalogOptionalServiceOption[];
|
||||
}
|
||||
@@ -63,6 +95,12 @@ export interface CostBreakdownLine {
|
||||
yearly: number;
|
||||
}
|
||||
|
||||
export interface OptionalBillingContext {
|
||||
profiles: OptionalServiceProfile[];
|
||||
rates: OptionalServiceRate[];
|
||||
customDomain: AddonRate | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PricingCatalogService implements OnModuleInit {
|
||||
private readonly logger = new Logger(PricingCatalogService.name);
|
||||
@@ -70,6 +108,10 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
constructor(
|
||||
@InjectRepository(PricingRate) private readonly rateRepo: Repository<PricingRate>,
|
||||
@InjectRepository(AddonRate) private readonly addonRepo: Repository<AddonRate>,
|
||||
@InjectRepository(OptionalServiceProfile)
|
||||
private readonly optionalProfileRepo: Repository<OptionalServiceProfile>,
|
||||
@InjectRepository(OptionalServiceRate)
|
||||
private readonly optionalRateRepo: Repository<OptionalServiceRate>,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
@@ -93,6 +135,45 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const service of getAllOptionalServices()) {
|
||||
let profile = await this.optionalProfileRepo.findOne({ where: { service } });
|
||||
if (!profile) {
|
||||
const spec = OPTIONAL_SERVICE_DEPLOY_SPECS[service];
|
||||
profile = this.optionalProfileRepo.create({
|
||||
service,
|
||||
cpuRequest: spec.cpuRequest,
|
||||
memoryRequest: spec.memoryRequest,
|
||||
cpuLimit: spec.cpuLimit,
|
||||
memoryLimit: spec.memoryLimit,
|
||||
storageGi: spec.storageGi,
|
||||
...(service === OptionalService.ELASTICSEARCH
|
||||
? {
|
||||
logShipperCpuLimit: FLUENT_BIT_SIDECAR.cpuLimit,
|
||||
logShipperMemoryLimit: FLUENT_BIT_SIDECAR.memoryLimit,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
await this.optionalProfileRepo.save(profile);
|
||||
}
|
||||
for (const resourceType of OPTIONAL_SERVICE_BILLING_RESOURCES) {
|
||||
const existing = await this.optionalRateRepo.findOne({
|
||||
where: { service, resourceType },
|
||||
});
|
||||
if (!existing) {
|
||||
await this.optionalRateRepo.save(
|
||||
this.optionalRateRepo.create({
|
||||
service,
|
||||
resourceType,
|
||||
hourlyPrice: 0,
|
||||
monthlyPrice: 0,
|
||||
yearlyPrice: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const resourceType of getBillableAddonResourceTypes()) {
|
||||
const existing = await this.addonRepo.findOne({ where: { resourceType } });
|
||||
if (!existing) {
|
||||
@@ -110,7 +191,11 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
|
||||
async getCatalog(): Promise<PricingCatalogResponse> {
|
||||
const rates = await this.rateRepo.find({ order: { runtime: 'ASC', resourceType: 'ASC' } });
|
||||
const addons = await this.addonRepo.find({ order: { resourceType: 'ASC' } });
|
||||
const profiles = await this.optionalProfileRepo.find();
|
||||
const osRates = await this.optionalRateRepo.find();
|
||||
const customDomainEntity = await this.addonRepo.findOne({
|
||||
where: { resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON },
|
||||
});
|
||||
|
||||
const billingRuntimes = getAllBillingRuntimes();
|
||||
const runtimes: Record<string, PricingRateRow[]> = {};
|
||||
@@ -121,21 +206,26 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
});
|
||||
}
|
||||
|
||||
const addonTypes = getBillableAddonResourceTypes();
|
||||
const optionalServices: Record<string, OptionalServiceCatalogEntry> = {};
|
||||
for (const service of getAllOptionalServices()) {
|
||||
optionalServices[service] = this.toOptionalServiceEntry(
|
||||
service,
|
||||
profiles.find((p) => p.service === service),
|
||||
osRates.filter((r) => r.service === service),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
runtimes,
|
||||
addons: addonTypes.map((resourceType) => {
|
||||
const row = addons.find((a) => a.resourceType === resourceType);
|
||||
return this.toRateRow(resourceType, row);
|
||||
}),
|
||||
optionalServices,
|
||||
customDomain: this.toCustomDomainRow(customDomainEntity),
|
||||
runtimeOptions: billingRuntimes.map((value) => ({
|
||||
value,
|
||||
label: RUNTIME_DISPLAY_LABELS[value] ?? value,
|
||||
})),
|
||||
optionalServiceOptions: Object.values(OptionalService).map((value) => ({
|
||||
optionalServiceOptions: getAllOptionalServices().map((value) => ({
|
||||
value,
|
||||
label: OPTIONAL_SERVICE_LABELS[value] ?? value,
|
||||
resourceType: OPTIONAL_SERVICE_PRICING_TYPE[value],
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -150,22 +240,33 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dto.addons) {
|
||||
for (const row of dto.addons) {
|
||||
await this.upsertAddonRate(row);
|
||||
if (dto.optionalServices) {
|
||||
for (const [serviceKey, entry] of Object.entries(dto.optionalServices)) {
|
||||
if (!entry) continue;
|
||||
const service = serviceKey as OptionalService;
|
||||
if (!getAllOptionalServices().includes(service)) continue;
|
||||
await this.upsertOptionalService(service, entry);
|
||||
}
|
||||
}
|
||||
if (dto.customDomain) {
|
||||
await this.upsertCustomDomain(dto.customDomain);
|
||||
}
|
||||
return this.getCatalog();
|
||||
}
|
||||
|
||||
async getRatesForRuntime(runtime: AppRuntime): Promise<PricingRate[]> {
|
||||
return this.rateRepo.find({
|
||||
where: { runtime, isActive: true },
|
||||
});
|
||||
return this.rateRepo.find({ where: { runtime, isActive: true } });
|
||||
}
|
||||
|
||||
async getAddonRates(): Promise<AddonRate[]> {
|
||||
return this.addonRepo.find({ where: { isActive: true } });
|
||||
async getOptionalBillingContext(): Promise<OptionalBillingContext> {
|
||||
const [profiles, rates, customDomain] = await Promise.all([
|
||||
this.optionalProfileRepo.find(),
|
||||
this.optionalRateRepo.find({ where: { isActive: true } }),
|
||||
this.addonRepo.findOne({
|
||||
where: { resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON, isActive: true },
|
||||
}),
|
||||
]);
|
||||
return { profiles, rates, customDomain };
|
||||
}
|
||||
|
||||
computeTotals(dto: CalculateCostDto): {
|
||||
@@ -174,7 +275,7 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
yearly: number;
|
||||
breakdown: CostBreakdownLine[];
|
||||
} {
|
||||
const lines = this.computeLineItemsSync(dto);
|
||||
const lines = this.buildLines(dto, [], { profiles: [], rates: [], customDomain: null });
|
||||
const hourly = lines.reduce((s, l) => s + l.hourly, 0);
|
||||
const monthly = lines.reduce((s, l) => s + l.monthly, 0);
|
||||
const yearly = lines.reduce((s, l) => s + l.yearly, 0);
|
||||
@@ -189,16 +290,16 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
async computeTotalsFromDb(dto: CalculateCostDto) {
|
||||
const runtime = dto.runtime as AppRuntime;
|
||||
const rates = await this.getRatesForRuntime(runtime);
|
||||
const addons = await this.getAddonRates();
|
||||
return this.computeTotalsWithRates(dto, rates, addons);
|
||||
const optional = await this.getOptionalBillingContext();
|
||||
return this.computeTotalsWithRates(dto, rates, optional);
|
||||
}
|
||||
|
||||
computeTotalsWithRates(
|
||||
dto: CalculateCostDto,
|
||||
rates: PricingRate[],
|
||||
addons: AddonRate[],
|
||||
optional: OptionalBillingContext,
|
||||
) {
|
||||
const lines = this.buildLines(dto, rates, addons);
|
||||
const lines = this.buildLines(dto, rates, optional);
|
||||
const hourly = lines.reduce((s, l) => s + l.hourly, 0);
|
||||
const monthly = lines.reduce((s, l) => s + l.monthly, 0);
|
||||
const yearly = lines.reduce((s, l) => s + l.yearly, 0);
|
||||
@@ -223,14 +324,10 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
private computeLineItemsSync(dto: CalculateCostDto): CostBreakdownLine[] {
|
||||
return this.buildLines(dto, [], []);
|
||||
}
|
||||
|
||||
private buildLines(
|
||||
dto: CalculateCostDto,
|
||||
rates: PricingRate[],
|
||||
addons: AddonRate[],
|
||||
optional: OptionalBillingContext,
|
||||
): CostBreakdownLine[] {
|
||||
const lines: CostBreakdownLine[] = [];
|
||||
const quantities = this.getQuantities(dto);
|
||||
@@ -250,17 +347,93 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
if (line) lines.push(line);
|
||||
}
|
||||
|
||||
for (const addon of addons) {
|
||||
const qty = quantities.get(addon.resourceType) ?? 0;
|
||||
if (qty <= 0) continue;
|
||||
const line = this.lineFromPrices(
|
||||
RESOURCE_LABELS[addon.resourceType],
|
||||
qty,
|
||||
Number(addon.hourlyPrice),
|
||||
Number(addon.monthlyPrice),
|
||||
Number(addon.yearlyPrice),
|
||||
addon.resourceType,
|
||||
lines.push(...this.buildOptionalServiceLines(dto, optional));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private buildOptionalServiceLines(
|
||||
dto: CalculateCostDto,
|
||||
optional: OptionalBillingContext,
|
||||
): CostBreakdownLine[] {
|
||||
const lines: CostBreakdownLine[] = [];
|
||||
const hasDatabase =
|
||||
dto.databaseType !== DatabaseType.NONE && dto.databaseType !== 'none';
|
||||
const logging = !!dto.enableElasticsearch;
|
||||
|
||||
const profileFor = (service: OptionalService) =>
|
||||
optional.profiles.find((p) => p.service === service);
|
||||
const ratesFor = (service: OptionalService) =>
|
||||
optional.rates.filter((r) => r.service === service);
|
||||
|
||||
const esProfile = profileFor(OptionalService.ELASTICSEARCH);
|
||||
const esRates = ratesFor(OptionalService.ELASTICSEARCH);
|
||||
|
||||
const addLogShipper = (workloadLabel: string) => {
|
||||
if (!logging || !esProfile) return;
|
||||
const shipCpu =
|
||||
esProfile.logShipperCpuLimit || FLUENT_BIT_SIDECAR.cpuLimit;
|
||||
const shipMem =
|
||||
esProfile.logShipperMemoryLimit || FLUENT_BIT_SIDECAR.memoryLimit;
|
||||
lines.push(
|
||||
...this.linesForResourceSlice(
|
||||
`${workloadLabel} log shipper`,
|
||||
esRates,
|
||||
this.getLogShipperQuantities(shipCpu, shipMem),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
if (dto.enableRedis) {
|
||||
const profile = this.resolveOptionalServiceProfile(
|
||||
OptionalService.REDIS,
|
||||
dto,
|
||||
profileFor(OptionalService.REDIS),
|
||||
);
|
||||
if (profile) {
|
||||
lines.push(
|
||||
...this.linesForOptionalService(
|
||||
OPTIONAL_SERVICE_LABELS[OptionalService.REDIS],
|
||||
profile,
|
||||
ratesFor(OptionalService.REDIS),
|
||||
),
|
||||
);
|
||||
if (logging) addLogShipper('Redis');
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.enableRabbitmq) {
|
||||
const profile = this.resolveOptionalServiceProfile(
|
||||
OptionalService.RABBITMQ,
|
||||
dto,
|
||||
profileFor(OptionalService.RABBITMQ),
|
||||
);
|
||||
if (profile) {
|
||||
lines.push(
|
||||
...this.linesForOptionalService(
|
||||
OPTIONAL_SERVICE_LABELS[OptionalService.RABBITMQ],
|
||||
profile,
|
||||
ratesFor(OptionalService.RABBITMQ),
|
||||
),
|
||||
);
|
||||
if (logging) addLogShipper('RabbitMQ');
|
||||
}
|
||||
}
|
||||
|
||||
if (logging && esProfile) {
|
||||
addLogShipper('Application');
|
||||
if (hasDatabase) addLogShipper('Database');
|
||||
}
|
||||
|
||||
if (dto.enableCustomDomain && optional.customDomain) {
|
||||
const line = this.lineFromPrices(
|
||||
'Custom domain + SSL',
|
||||
1,
|
||||
Number(optional.customDomain.hourlyPrice),
|
||||
Number(optional.customDomain.monthlyPrice),
|
||||
Number(optional.customDomain.yearlyPrice),
|
||||
PricingResourceType.CUSTOM_DOMAIN_ADDON,
|
||||
dto,
|
||||
true,
|
||||
);
|
||||
if (line) lines.push(line);
|
||||
}
|
||||
@@ -268,6 +441,114 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
return lines;
|
||||
}
|
||||
|
||||
private linesForOptionalService(
|
||||
serviceLabel: string,
|
||||
profile: OptionalServiceProfile,
|
||||
rates: OptionalServiceRate[],
|
||||
): CostBreakdownLine[] {
|
||||
return this.linesForResourceSlice(
|
||||
serviceLabel,
|
||||
rates,
|
||||
this.getOptionalServiceQuantities(profile),
|
||||
);
|
||||
}
|
||||
|
||||
private linesForResourceSlice(
|
||||
prefix: string,
|
||||
rates: OptionalServiceRate[],
|
||||
quantities: Map<PricingResourceType, number>,
|
||||
): CostBreakdownLine[] {
|
||||
const lines: CostBreakdownLine[] = [];
|
||||
for (const rate of rates) {
|
||||
if (!rate.isActive) continue;
|
||||
const qty = quantities.get(rate.resourceType) ?? 0;
|
||||
if (qty <= 0) continue;
|
||||
const resourceLabel = RESOURCE_LABELS[rate.resourceType] ?? rate.resourceType;
|
||||
const line = this.lineFromPrices(
|
||||
`${prefix} — ${resourceLabel}`,
|
||||
qty,
|
||||
Number(rate.hourlyPrice),
|
||||
Number(rate.monthlyPrice),
|
||||
Number(rate.yearlyPrice),
|
||||
rate.resourceType,
|
||||
{} as CalculateCostDto,
|
||||
true,
|
||||
);
|
||||
if (line) lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private resolveOptionalServiceProfile(
|
||||
service: OptionalService,
|
||||
dto: CalculateCostDto,
|
||||
adminProfile?: OptionalServiceProfile | null,
|
||||
): OptionalServiceProfile | null {
|
||||
const spec = OPTIONAL_SERVICE_DEPLOY_SPECS[service];
|
||||
const override = this.getDtoOptionalResources(dto, service);
|
||||
if (!adminProfile && !override) return null;
|
||||
|
||||
const cpuLimit = override?.cpuLimit ?? adminProfile?.cpuLimit ?? spec.cpuLimit;
|
||||
const memoryLimit =
|
||||
override?.memoryLimit ?? adminProfile?.memoryLimit ?? spec.memoryLimit;
|
||||
const storageGi =
|
||||
override?.storageGi ??
|
||||
(adminProfile ? Number(adminProfile.storageGi) : spec.storageGi);
|
||||
|
||||
const resolved: OptionalServiceProfile = {
|
||||
id: adminProfile?.id ?? '',
|
||||
service,
|
||||
cpuLimit,
|
||||
memoryLimit,
|
||||
storageGi,
|
||||
createdAt: adminProfile?.createdAt ?? new Date(),
|
||||
updatedAt: adminProfile?.updatedAt ?? new Date(),
|
||||
};
|
||||
if (service === OptionalService.ELASTICSEARCH) {
|
||||
resolved.logShipperCpuLimit = adminProfile?.logShipperCpuLimit;
|
||||
resolved.logShipperMemoryLimit = adminProfile?.logShipperMemoryLimit;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private getDtoOptionalResources(
|
||||
dto: CalculateCostDto,
|
||||
service: OptionalService,
|
||||
): OptionalServiceResourcesDto | undefined {
|
||||
if (service === OptionalService.REDIS) return dto.redisResources;
|
||||
if (service === OptionalService.RABBITMQ) return dto.rabbitmqResources;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getOptionalServiceQuantities(
|
||||
profile: OptionalServiceProfile,
|
||||
): Map<PricingResourceType, number> {
|
||||
const map = new Map<PricingResourceType, number>();
|
||||
map.set(PricingResourceType.BASE_FEE, 1);
|
||||
map.set(
|
||||
PricingResourceType.CPU_PER_CORE,
|
||||
this.parseCpuToCores(profile.cpuLimit),
|
||||
);
|
||||
map.set(
|
||||
PricingResourceType.MEMORY_PER_GB,
|
||||
this.parseMemoryToGb(profile.memoryLimit),
|
||||
);
|
||||
map.set(PricingResourceType.STORAGE_PER_GB, Number(profile.storageGi) || 0);
|
||||
return map;
|
||||
}
|
||||
|
||||
private getLogShipperQuantities(
|
||||
cpuLimit: string,
|
||||
memoryLimit: string,
|
||||
): Map<PricingResourceType, number> {
|
||||
const map = new Map<PricingResourceType, number>();
|
||||
map.set(PricingResourceType.BASE_FEE, 0);
|
||||
map.set(PricingResourceType.CPU_PER_CORE, this.parseCpuToCores(cpuLimit));
|
||||
map.set(PricingResourceType.MEMORY_PER_GB, this.parseMemoryToGb(memoryLimit));
|
||||
map.set(PricingResourceType.STORAGE_PER_GB, 0);
|
||||
return map;
|
||||
}
|
||||
|
||||
private lineFromPrices(
|
||||
baseLabel: string,
|
||||
quantity: number,
|
||||
@@ -276,13 +557,16 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
yearlyUnit: number,
|
||||
resourceType: PricingResourceType,
|
||||
dto: CalculateCostDto,
|
||||
useFixedLabel = false,
|
||||
): CostBreakdownLine | null {
|
||||
const hourly = Math.round(quantity * hourlyUnit);
|
||||
const monthly = Math.round(quantity * monthlyUnit);
|
||||
const yearly = Math.round(quantity * yearlyUnit);
|
||||
if (hourly <= 0 && monthly <= 0 && yearly <= 0) return null;
|
||||
|
||||
const label = this.describeLine(baseLabel, resourceType, quantity, dto);
|
||||
const label = useFixedLabel
|
||||
? baseLabel
|
||||
: this.describeLine(baseLabel, resourceType, quantity, dto);
|
||||
return { label, hourly, monthly, yearly };
|
||||
}
|
||||
|
||||
@@ -307,10 +591,9 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
getQuantities(dto: CalculateCostDto): Map<PricingResourceType, number> {
|
||||
const replicas = dto.replicas || 1;
|
||||
const hasDatabase = dto.databaseType !== DatabaseType.NONE && dto.databaseType !== 'none';
|
||||
|
||||
let cpuQty = this.parseCpuToCores(dto.cpuLimit) * replicas;
|
||||
let memoryQty = this.parseMemoryToGb(dto.memoryLimit) * replicas;
|
||||
let storageQty =
|
||||
const cpuQty = this.parseCpuToCores(dto.cpuLimit) * replicas;
|
||||
const memoryQty = this.parseMemoryToGb(dto.memoryLimit) * replicas;
|
||||
const storageQty =
|
||||
(dto.dbStorageSize
|
||||
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
|
||||
: 0) +
|
||||
@@ -318,78 +601,15 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
|
||||
: 0);
|
||||
|
||||
const optionalResources = this.optionalServiceResourceTotals(dto, hasDatabase);
|
||||
cpuQty += optionalResources.cpuCores;
|
||||
memoryQty += optionalResources.memoryGb;
|
||||
storageQty += optionalResources.storageGb;
|
||||
|
||||
const map = new Map<PricingResourceType, number>();
|
||||
map.set(PricingResourceType.BASE_FEE, 1);
|
||||
map.set(PricingResourceType.CPU_PER_CORE, cpuQty);
|
||||
map.set(PricingResourceType.MEMORY_PER_GB, memoryQty);
|
||||
map.set(PricingResourceType.STORAGE_PER_GB, storageQty);
|
||||
map.set(PricingResourceType.DATABASE_ADDON, hasDatabase ? 1 : 0);
|
||||
map.set(
|
||||
PricingResourceType.REDIS_ADDON,
|
||||
dto.enableRedis ? 1 : 0,
|
||||
);
|
||||
map.set(
|
||||
PricingResourceType.RABBITMQ_ADDON,
|
||||
dto.enableRabbitmq ? 1 : 0,
|
||||
);
|
||||
map.set(
|
||||
PricingResourceType.ELASTICSEARCH_ADDON,
|
||||
dto.enableElasticsearch ? 1 : 0,
|
||||
);
|
||||
map.set(PricingResourceType.CUSTOM_DOMAIN_ADDON, dto.enableCustomDomain ? 1 : 0);
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional-service pods (and Fluent Bit sidecars when logging is on) bill CPU/RAM/storage
|
||||
* using the same per-runtime unit rates as the main application.
|
||||
*/
|
||||
optionalServiceResourceTotals(
|
||||
dto: CalculateCostDto,
|
||||
hasDatabase: boolean,
|
||||
): { cpuCores: number; memoryGb: number; storageGb: number } {
|
||||
let cpuCores = 0;
|
||||
let memoryGb = 0;
|
||||
let storageGb = 0;
|
||||
const logging = !!dto.enableElasticsearch;
|
||||
const fbCpu = this.parseCpuToCores(FLUENT_BIT_SIDECAR.cpuLimit);
|
||||
const fbMem = this.parseMemoryToGb(FLUENT_BIT_SIDECAR.memoryLimit);
|
||||
|
||||
const addWorkload = (service: OptionalService) => {
|
||||
const spec = OPTIONAL_SERVICE_DEPLOY_SPECS[service];
|
||||
cpuCores += this.parseCpuToCores(spec.cpuLimit);
|
||||
memoryGb += this.parseMemoryToGb(spec.memoryLimit);
|
||||
storageGb += spec.storageGi;
|
||||
};
|
||||
|
||||
if (dto.enableRedis) addWorkload(OptionalService.REDIS);
|
||||
if (dto.enableRabbitmq) addWorkload(OptionalService.RABBITMQ);
|
||||
|
||||
if (logging) {
|
||||
cpuCores += fbCpu;
|
||||
memoryGb += fbMem;
|
||||
if (dto.enableRedis) {
|
||||
cpuCores += fbCpu;
|
||||
memoryGb += fbMem;
|
||||
}
|
||||
if (dto.enableRabbitmq) {
|
||||
cpuCores += fbCpu;
|
||||
memoryGb += fbMem;
|
||||
}
|
||||
if (hasDatabase) {
|
||||
cpuCores += fbCpu;
|
||||
memoryGb += fbMem;
|
||||
}
|
||||
}
|
||||
|
||||
return { cpuCores, memoryGb, storageGb };
|
||||
}
|
||||
|
||||
parseCpuToCores(cpu: string): number {
|
||||
if (!cpu) return 0;
|
||||
if (cpu.endsWith('m')) return parseFloat(cpu) / 1000;
|
||||
@@ -418,12 +638,52 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
await this.rateRepo.save(entity);
|
||||
}
|
||||
|
||||
private async upsertAddonRate(row: PricingRateRow) {
|
||||
private async upsertOptionalService(
|
||||
service: OptionalService,
|
||||
entry: OptionalServiceCatalogEntryDto,
|
||||
) {
|
||||
let profile = await this.optionalProfileRepo.findOne({ where: { service } });
|
||||
if (!profile) {
|
||||
profile = this.optionalProfileRepo.create({ service });
|
||||
}
|
||||
if (entry.profile.cpuRequest !== undefined) profile.cpuRequest = entry.profile.cpuRequest;
|
||||
if (entry.profile.memoryRequest !== undefined) {
|
||||
profile.memoryRequest = entry.profile.memoryRequest;
|
||||
}
|
||||
profile.cpuLimit = entry.profile.cpuLimit;
|
||||
profile.memoryLimit = entry.profile.memoryLimit;
|
||||
profile.storageGi = entry.profile.storageGi;
|
||||
if (entry.profile.logShipperCpuLimit !== undefined) {
|
||||
profile.logShipperCpuLimit = entry.profile.logShipperCpuLimit;
|
||||
}
|
||||
if (entry.profile.logShipperMemoryLimit !== undefined) {
|
||||
profile.logShipperMemoryLimit = entry.profile.logShipperMemoryLimit;
|
||||
}
|
||||
await this.optionalProfileRepo.save(profile);
|
||||
|
||||
for (const row of entry.rates) {
|
||||
let rate = await this.optionalRateRepo.findOne({
|
||||
where: { service, resourceType: row.resourceType },
|
||||
});
|
||||
if (!rate) {
|
||||
rate = this.optionalRateRepo.create({ service, resourceType: row.resourceType });
|
||||
}
|
||||
rate.hourlyPrice = row.hourlyPrice;
|
||||
rate.monthlyPrice = row.monthlyPrice;
|
||||
rate.yearlyPrice = row.yearlyPrice;
|
||||
if (row.isActive !== undefined) rate.isActive = row.isActive;
|
||||
await this.optionalRateRepo.save(rate);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertCustomDomain(row: CustomDomainCatalogDto) {
|
||||
let entity = await this.addonRepo.findOne({
|
||||
where: { resourceType: row.resourceType },
|
||||
where: { resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON },
|
||||
});
|
||||
if (!entity) {
|
||||
entity = this.addonRepo.create({ resourceType: row.resourceType });
|
||||
entity = this.addonRepo.create({
|
||||
resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON,
|
||||
});
|
||||
}
|
||||
entity.hourlyPrice = row.hourlyPrice;
|
||||
entity.monthlyPrice = row.monthlyPrice;
|
||||
@@ -432,9 +692,49 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
await this.addonRepo.save(entity);
|
||||
}
|
||||
|
||||
private toOptionalServiceEntry(
|
||||
service: OptionalService,
|
||||
profile?: OptionalServiceProfile | null,
|
||||
rateEntities: OptionalServiceRate[] = [],
|
||||
): OptionalServiceCatalogEntry {
|
||||
const spec = OPTIONAL_SERVICE_DEPLOY_SPECS[service];
|
||||
return {
|
||||
service,
|
||||
label: OPTIONAL_SERVICE_LABELS[service] ?? service,
|
||||
profile: {
|
||||
cpuRequest: profile?.cpuRequest ?? spec.cpuRequest,
|
||||
memoryRequest: profile?.memoryRequest ?? spec.memoryRequest,
|
||||
cpuLimit: profile?.cpuLimit ?? spec.cpuLimit,
|
||||
memoryLimit: profile?.memoryLimit ?? spec.memoryLimit,
|
||||
storageGi: profile ? Number(profile.storageGi) : spec.storageGi,
|
||||
logShipperCpuLimit:
|
||||
service === OptionalService.ELASTICSEARCH
|
||||
? profile?.logShipperCpuLimit ?? FLUENT_BIT_SIDECAR.cpuLimit
|
||||
: undefined,
|
||||
logShipperMemoryLimit:
|
||||
service === OptionalService.ELASTICSEARCH
|
||||
? profile?.logShipperMemoryLimit ?? FLUENT_BIT_SIDECAR.memoryLimit
|
||||
: undefined,
|
||||
},
|
||||
rates: OPTIONAL_SERVICE_BILLING_RESOURCES.map((resourceType) => {
|
||||
const row = rateEntities.find((r) => r.resourceType === resourceType);
|
||||
return this.toRateRow(resourceType, row);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private toCustomDomainRow(entity?: AddonRate | null): CustomDomainCatalogRow {
|
||||
return {
|
||||
hourlyPrice: entity ? Number(entity.hourlyPrice) : 0,
|
||||
monthlyPrice: entity ? Number(entity.monthlyPrice) : 0,
|
||||
yearlyPrice: entity ? Number(entity.yearlyPrice) : 0,
|
||||
isActive: entity?.isActive ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
private toRateRow(
|
||||
resourceType: PricingResourceType,
|
||||
entity?: PricingRate | AddonRate | null,
|
||||
entity?: PricingRate | OptionalServiceRate | AddonRate | null,
|
||||
): PricingRateRow {
|
||||
return {
|
||||
resourceType,
|
||||
@@ -445,18 +745,33 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy shape for optional-services settings API */
|
||||
/** Legacy API: estimated monthly/hourly/yearly from catalog matrix (base_fee row if set, else full slice). */
|
||||
async getOptionalServicesPricing(): Promise<Record<OptionalService, CyclePrices>> {
|
||||
const addons = await this.addonRepo.find();
|
||||
const catalog = await this.getCatalog();
|
||||
const result = {} as Record<OptionalService, CyclePrices>;
|
||||
for (const service of Object.values(OptionalService)) {
|
||||
const type = OPTIONAL_SERVICE_PRICING_TYPE[service];
|
||||
const row = addons.find((a) => a.resourceType === type);
|
||||
result[service] = {
|
||||
hourly: row ? Number(row.hourlyPrice) : 0,
|
||||
monthly: row ? Number(row.monthlyPrice) : 0,
|
||||
yearly: row ? Number(row.yearlyPrice) : 0,
|
||||
};
|
||||
for (const service of getAllOptionalServices()) {
|
||||
const entry = catalog.optionalServices[service];
|
||||
const profile = await this.optionalProfileRepo.findOne({ where: { service } });
|
||||
const rates = await this.optionalRateRepo.find({ where: { service, isActive: true } });
|
||||
if (profile && rates.length) {
|
||||
const totals = this.computeTotalsWithRates(
|
||||
{ ...this.legacyDtoForService(service), runtime: AppRuntime.NODEJS },
|
||||
[],
|
||||
{ profiles: [profile], rates, customDomain: null },
|
||||
);
|
||||
result[service] = {
|
||||
hourly: totals.hourly,
|
||||
monthly: totals.monthly,
|
||||
yearly: totals.yearly,
|
||||
};
|
||||
} else {
|
||||
const base = entry.rates.find((r) => r.resourceType === PricingResourceType.BASE_FEE);
|
||||
result[service] = {
|
||||
hourly: base?.hourlyPrice ?? 0,
|
||||
monthly: base?.monthlyPrice ?? 0,
|
||||
yearly: base?.yearlyPrice ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -464,20 +779,40 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
async setOptionalServicesPricing(
|
||||
pricing: Partial<Record<OptionalService, CyclePrices>>,
|
||||
): Promise<Record<OptionalService, CyclePrices>> {
|
||||
for (const service of Object.values(OptionalService)) {
|
||||
for (const service of getAllOptionalServices()) {
|
||||
const prices = pricing[service];
|
||||
if (!prices) continue;
|
||||
await this.upsertAddonRate({
|
||||
resourceType: OPTIONAL_SERVICE_PRICING_TYPE[service],
|
||||
hourlyPrice: prices.hourly,
|
||||
monthlyPrice: prices.monthly,
|
||||
yearlyPrice: prices.yearly,
|
||||
isActive: true,
|
||||
let rate = await this.optionalRateRepo.findOne({
|
||||
where: { service, resourceType: PricingResourceType.BASE_FEE },
|
||||
});
|
||||
if (!rate) {
|
||||
rate = this.optionalRateRepo.create({
|
||||
service,
|
||||
resourceType: PricingResourceType.BASE_FEE,
|
||||
});
|
||||
}
|
||||
rate.hourlyPrice = prices.hourly;
|
||||
rate.monthlyPrice = prices.monthly;
|
||||
rate.yearlyPrice = prices.yearly;
|
||||
await this.optionalRateRepo.save(rate);
|
||||
}
|
||||
return this.getOptionalServicesPricing();
|
||||
}
|
||||
|
||||
private legacyDtoForService(service: OptionalService): CalculateCostDto {
|
||||
const dto: CalculateCostDto = {
|
||||
runtime: AppRuntime.NODEJS,
|
||||
databaseType: DatabaseType.NONE,
|
||||
cpuLimit: '0',
|
||||
memoryLimit: '0',
|
||||
replicas: 1,
|
||||
};
|
||||
if (service === OptionalService.REDIS) dto.enableRedis = true;
|
||||
if (service === OptionalService.RABBITMQ) dto.enableRabbitmq = true;
|
||||
if (service === OptionalService.ELASTICSEARCH) dto.enableElasticsearch = true;
|
||||
return dto;
|
||||
}
|
||||
|
||||
async getCustomDomainPrice(): Promise<{ monthlyPrice: number }> {
|
||||
const row = await this.addonRepo.findOne({
|
||||
where: { resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON },
|
||||
@@ -486,12 +821,10 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
}
|
||||
|
||||
async setCustomDomainPrice(monthlyPrice: number): Promise<{ monthlyPrice: number }> {
|
||||
await this.upsertAddonRate({
|
||||
resourceType: PricingResourceType.CUSTOM_DOMAIN_ADDON,
|
||||
await this.upsertCustomDomain({
|
||||
hourlyPrice: 0,
|
||||
monthlyPrice,
|
||||
yearlyPrice: 0,
|
||||
isActive: true,
|
||||
});
|
||||
return { monthlyPrice };
|
||||
}
|
||||
|
||||
@@ -104,6 +104,36 @@ export class KubernetesService implements OnModuleInit {
|
||||
/**
|
||||
* Build Helm values object from an Application entity and image URI.
|
||||
*/
|
||||
private buildRedisHelmBlock(app: Application) {
|
||||
const res = app.optionalServiceResources?.redis;
|
||||
const storageGi = res?.storageGi ?? 1;
|
||||
return {
|
||||
enabled: app.enableRedis || false,
|
||||
storageSize: `${storageGi}Gi`,
|
||||
resources: {
|
||||
cpuRequest: res?.cpuRequest || '50m',
|
||||
cpuLimit: res?.cpuLimit || '200m',
|
||||
memoryRequest: res?.memoryRequest || '64Mi',
|
||||
memoryLimit: res?.memoryLimit || '256Mi',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildRabbitmqHelmBlock(app: Application) {
|
||||
const res = app.optionalServiceResources?.rabbitmq;
|
||||
const storageGi = res?.storageGi ?? 2;
|
||||
return {
|
||||
enabled: app.enableRabbitmq || false,
|
||||
storageSize: `${storageGi}Gi`,
|
||||
resources: {
|
||||
cpuRequest: res?.cpuRequest || '100m',
|
||||
cpuLimit: res?.cpuLimit || '500m',
|
||||
memoryRequest: res?.memoryRequest || '256Mi',
|
||||
memoryLimit: res?.memoryLimit || '512Mi',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private resolveEnvVars(app: Application): Record<string, string> {
|
||||
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
|
||||
return ensureAppUrlEnv(app, platformDomain);
|
||||
@@ -162,26 +192,8 @@ export class KubernetesService implements OnModuleInit {
|
||||
wordpress: {
|
||||
enabled: isWordPress,
|
||||
},
|
||||
redis: {
|
||||
enabled: app.enableRedis || false,
|
||||
storageSize: '1Gi',
|
||||
resources: {
|
||||
cpuRequest: '50m',
|
||||
cpuLimit: '200m',
|
||||
memoryRequest: '64Mi',
|
||||
memoryLimit: '256Mi',
|
||||
},
|
||||
},
|
||||
rabbitmq: {
|
||||
enabled: app.enableRabbitmq || false,
|
||||
storageSize: '2Gi',
|
||||
resources: {
|
||||
cpuRequest: '100m',
|
||||
cpuLimit: '500m',
|
||||
memoryRequest: '256Mi',
|
||||
memoryLimit: '512Mi',
|
||||
},
|
||||
},
|
||||
redis: this.buildRedisHelmBlock(app),
|
||||
rabbitmq: this.buildRabbitmqHelmBlock(app),
|
||||
elasticsearch: {
|
||||
enabled: app.enableElasticsearch || false,
|
||||
logPaths: app.logPaths || [],
|
||||
|
||||
@@ -7,11 +7,13 @@ import { toast } from 'react-toastify';
|
||||
import type {
|
||||
PricingCatalog,
|
||||
PricingRateRow,
|
||||
OptionalServiceProfileRow,
|
||||
CustomDomainCatalogRow,
|
||||
BillingCycle,
|
||||
PricingResourceType,
|
||||
LifecycleSettings,
|
||||
} from '@/types';
|
||||
import { DollarSign, Edit2, Shield, Clock, Layers } from 'lucide-react';
|
||||
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server } from 'lucide-react';
|
||||
|
||||
const resourceLabels: Record<PricingResourceType, string> = {
|
||||
base_fee: 'Base fee',
|
||||
@@ -19,23 +21,62 @@ const resourceLabels: Record<PricingResourceType, string> = {
|
||||
memory_per_gb: 'Memory (per GB)',
|
||||
storage_per_gb: 'Storage (per GB)',
|
||||
database_addon: 'Database addon',
|
||||
redis_addon: 'Redis (flat addon)',
|
||||
rabbitmq_addon: 'RabbitMQ (flat addon)',
|
||||
elasticsearch_addon: 'Elasticsearch (flat addon)',
|
||||
redis_addon: 'Redis',
|
||||
rabbitmq_addon: 'RabbitMQ',
|
||||
elasticsearch_addon: 'Elasticsearch',
|
||||
custom_domain_addon: 'Custom domain + SSL',
|
||||
};
|
||||
|
||||
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
|
||||
|
||||
/** PATCH body must match UpdatePricingCatalogDto (no runtimeOptions / label). */
|
||||
function toPricingCatalogPatch(catalog: PricingCatalog) {
|
||||
const optionalServices: Record<
|
||||
string,
|
||||
{ service: string; profile: OptionalServiceProfileRow; rates: PricingRateRow[] }
|
||||
> = {};
|
||||
for (const [key, entry] of Object.entries(catalog.optionalServices)) {
|
||||
optionalServices[key] = {
|
||||
service: entry.service ?? key,
|
||||
profile: entry.profile,
|
||||
rates: entry.rates,
|
||||
};
|
||||
}
|
||||
return {
|
||||
runtimes: catalog.runtimes,
|
||||
optionalServices,
|
||||
customDomain: catalog.customDomain,
|
||||
};
|
||||
}
|
||||
|
||||
function formatApiError(err: unknown, fallback: string): string {
|
||||
if (!err || typeof err !== 'object' || !('response' in err)) return fallback;
|
||||
const message = (err as { response?: { data?: { message?: string | string[] } } }).response
|
||||
?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(', ');
|
||||
if (typeof message === 'string') return message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function cloneCatalog(catalog: PricingCatalog): PricingCatalog {
|
||||
const runtimes: PricingCatalog['runtimes'] = {};
|
||||
for (const key of Object.keys(catalog.runtimes)) {
|
||||
runtimes[key] = catalog.runtimes[key].map((r) => ({ ...r }));
|
||||
}
|
||||
const optionalServices: PricingCatalog['optionalServices'] = {};
|
||||
for (const key of Object.keys(catalog.optionalServices)) {
|
||||
const entry = catalog.optionalServices[key];
|
||||
optionalServices[key] = {
|
||||
...entry,
|
||||
profile: { ...entry.profile },
|
||||
rates: entry.rates.map((r) => ({ ...r })),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...catalog,
|
||||
runtimes,
|
||||
addons: catalog.addons.map((a) => ({ ...a })),
|
||||
optionalServices,
|
||||
customDomain: { ...catalog.customDomain },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,9 +148,200 @@ function PricingMatrixTable({
|
||||
);
|
||||
}
|
||||
|
||||
function DeployDefaultsFields({
|
||||
service,
|
||||
profile,
|
||||
readOnly,
|
||||
onUpdate,
|
||||
}: {
|
||||
service: string;
|
||||
profile: OptionalServiceProfileRow;
|
||||
readOnly: boolean;
|
||||
onUpdate: (patch: Partial<OptionalServiceProfileRow>) => void;
|
||||
}) {
|
||||
const isLogging = service === 'elasticsearch';
|
||||
|
||||
if (isLogging) {
|
||||
return (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50/40 p-4 space-y-3">
|
||||
<p className="text-xs text-gray-600">
|
||||
Prefilled limits for Fluent Bit log shippers (billed per enabled workload when logging is on).
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Log shipper CPU limit</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.logShipperCpuLimit || '—'}</p>
|
||||
) : (
|
||||
<input
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
placeholder="50m"
|
||||
value={profile.logShipperCpuLimit ?? ''}
|
||||
onChange={(e) => onUpdate({ logShipperCpuLimit: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Log shipper memory limit</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.logShipperMemoryLimit || '—'}</p>
|
||||
) : (
|
||||
<input
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
placeholder="64Mi"
|
||||
value={profile.logShipperMemoryLimit ?? ''}
|
||||
onChange={(e) => onUpdate({ logShipperMemoryLimit: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/60 p-4 space-y-3">
|
||||
<p className="text-xs text-gray-600">
|
||||
Shown when a user enables this service in deploy. They can change CPU, memory, and storage in
|
||||
Resources & Configuration; actual billing uses their choices × unit prices below.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">CPU request</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.cpuRequest || '—'}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={profile.cpuRequest || '50m'}
|
||||
onChange={(e) => onUpdate({ 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="text-xs font-medium text-gray-600">CPU limit</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.cpuLimit}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={profile.cpuLimit}
|
||||
onChange={(e) => onUpdate({ cpuLimit: e.target.value })}
|
||||
>
|
||||
<option value="200m">200m</option>
|
||||
<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="text-xs font-medium text-gray-600">Memory request</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.memoryRequest || '—'}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={profile.memoryRequest || '64Mi'}
|
||||
onChange={(e) => onUpdate({ 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="text-xs font-medium text-gray-600">Memory limit</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.memoryLimit}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={profile.memoryLimit}
|
||||
onChange={(e) => onUpdate({ 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="text-xs font-medium text-gray-600">Storage (Gi)</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.storageGi}</p>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
className="input-field w-full max-w-[140px] text-sm mt-0.5"
|
||||
value={profile.storageGi}
|
||||
onChange={(e) =>
|
||||
onUpdate({ storageGi: e.target.value === '' ? 0 : Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomDomainPricing({
|
||||
customDomain,
|
||||
readOnly,
|
||||
onChange,
|
||||
}: {
|
||||
customDomain: CustomDomainCatalogRow;
|
||||
readOnly: boolean;
|
||||
onChange: (cycle: BillingCycle, value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Custom domain + SSL</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">Flat fee per billing cycle (not resource-based)</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{cycles.map((cycle) => {
|
||||
const field =
|
||||
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
|
||||
return (
|
||||
<div key={cycle}>
|
||||
<label className="text-xs text-gray-500 capitalize">{cycle} (T)</label>
|
||||
{readOnly ? (
|
||||
<p className="font-mono text-sm">{Number(customDomain[field]).toLocaleString('en-US')}</p>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={customDomain[field]}
|
||||
onChange={(e) =>
|
||||
onChange(cycle, e.target.value === '' ? 0 : Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminBillingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeRuntime, setActiveRuntime] = useState<string>('nodejs');
|
||||
const [activeOptionalService, setActiveOptionalService] = useState<string>('redis');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<PricingCatalog | null>(null);
|
||||
|
||||
@@ -126,22 +358,27 @@ export default function AdminBillingPage() {
|
||||
}
|
||||
}, [catalog, activeRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
const options = catalog?.optionalServiceOptions ?? [];
|
||||
if (options.length === 0) return;
|
||||
if (!options.some((o) => o.value === activeOptionalService)) {
|
||||
setActiveOptionalService(options[0].value);
|
||||
}
|
||||
}, [catalog, activeOptionalService]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (body: PricingCatalog) => api.patch('/billing/pricing-catalog', body),
|
||||
mutationFn: (catalog: PricingCatalog) =>
|
||||
api.patch('/billing/pricing-catalog', toPricingCatalogPatch(catalog)),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] });
|
||||
toast.success('Pricing catalog saved');
|
||||
toast.success('Billing plans saved');
|
||||
setEditing(false);
|
||||
setDraft(null);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const message =
|
||||
err && typeof err === 'object' && 'response' in err
|
||||
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
|
||||
: undefined;
|
||||
toast.error(message || 'Failed to save pricing');
|
||||
toast.error(formatApiError(err, 'Failed to save billing plans'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -176,7 +413,23 @@ export default function AdminBillingPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const updateAddonPrice = (
|
||||
const updateOptionalProfile = (patch: Partial<OptionalServiceProfileRow>) => {
|
||||
if (!draft) return;
|
||||
const entry = draft.optionalServices[activeOptionalService];
|
||||
if (!entry) return;
|
||||
setDraft({
|
||||
...draft,
|
||||
optionalServices: {
|
||||
...draft.optionalServices,
|
||||
[activeOptionalService]: {
|
||||
...entry,
|
||||
profile: { ...entry.profile, ...patch },
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateOptionalRate = (
|
||||
resourceType: PricingResourceType,
|
||||
cycle: BillingCycle,
|
||||
value: number,
|
||||
@@ -184,15 +437,33 @@ export default function AdminBillingPage() {
|
||||
if (!draft) return;
|
||||
const field =
|
||||
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
|
||||
const entry = draft.optionalServices[activeOptionalService];
|
||||
if (!entry) return;
|
||||
setDraft({
|
||||
...draft,
|
||||
addons: draft.addons.map((row) =>
|
||||
row.resourceType === resourceType ? { ...row, [field]: value } : row,
|
||||
),
|
||||
optionalServices: {
|
||||
...draft.optionalServices,
|
||||
[activeOptionalService]: {
|
||||
...entry,
|
||||
rates: entry.rates.map((row) =>
|
||||
row.resourceType === resourceType ? { ...row, [field]: value } : row,
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const fillYearlyFromMonthly = (scope: 'runtime' | 'addons') => {
|
||||
const updateCustomDomain = (cycle: BillingCycle, value: number) => {
|
||||
if (!draft) return;
|
||||
const field =
|
||||
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
|
||||
setDraft({
|
||||
...draft,
|
||||
customDomain: { ...draft.customDomain, [field]: value },
|
||||
});
|
||||
};
|
||||
|
||||
const fillYearlyFromMonthly = (scope: 'runtime' | 'optional') => {
|
||||
if (!draft) return;
|
||||
if (scope === 'runtime') {
|
||||
setDraft({
|
||||
@@ -206,12 +477,20 @@ export default function AdminBillingPage() {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const entry = draft.optionalServices[activeOptionalService];
|
||||
if (!entry) return;
|
||||
setDraft({
|
||||
...draft,
|
||||
addons: draft.addons.map((row) => ({
|
||||
...row,
|
||||
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
|
||||
})),
|
||||
optionalServices: {
|
||||
...draft.optionalServices,
|
||||
[activeOptionalService]: {
|
||||
...entry,
|
||||
rates: entry.rates.map((row) => ({
|
||||
...row,
|
||||
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -222,17 +501,21 @@ export default function AdminBillingPage() {
|
||||
};
|
||||
|
||||
const runtimeRows = display?.runtimes[activeRuntime] ?? [];
|
||||
const addonRows = display?.addons ?? [];
|
||||
const optionalServiceTabs = display?.optionalServiceOptions ?? [];
|
||||
const activeOptionalEntry = display?.optionalServices[activeOptionalService];
|
||||
const optionalRateRows = activeOptionalEntry?.rates ?? [];
|
||||
const customDomain = display?.customDomain;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="page-title flex items-center gap-2">
|
||||
<DollarSign className="w-6 h-6" /> Billing & Pricing
|
||||
<DollarSign className="w-6 h-6" /> Billing Plans
|
||||
</h1>
|
||||
<p className="page-subtitle">
|
||||
Usage-based prices per application type (all runtimes from the platform). Optional services also bill CPU, RAM, and disk at the same unit rates as the app, plus any flat addon fee below.
|
||||
Set unit prices per resource. Users choose CPU, memory, and storage when deploying; cost =
|
||||
their usage × these rates.
|
||||
</p>
|
||||
</div>
|
||||
{!editing ? (
|
||||
@@ -241,7 +524,7 @@ export default function AdminBillingPage() {
|
||||
disabled={!catalog}
|
||||
className="btn-primary flex items-center gap-2 shrink-0"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" /> Edit pricing
|
||||
<Edit2 className="w-4 h-4" /> Edit plans
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2 shrink-0">
|
||||
@@ -265,6 +548,27 @@ export default function AdminBillingPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-blue-100 bg-blue-50/50 p-4 flex gap-3 text-sm text-blue-900">
|
||||
<Info className="w-5 h-5 shrink-0 text-blue-600 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">How billing works</p>
|
||||
<ul className="list-disc list-inside text-blue-800/90 space-y-0.5 text-xs sm:text-sm">
|
||||
<li>
|
||||
<strong>Applications</strong> — user picks runtime resources in deploy; you set price per
|
||||
core, GB, base fee, and database addon.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Optional services (Redis, RabbitMQ)</strong> — user enables the service, then sets
|
||||
resources in a separate block; you set the same unit-price rows for that service.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Deploy defaults</strong> — optional prefill only; changing them does not change
|
||||
what existing apps pay unless the user chose those values at deploy.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-400">Loading...</div>
|
||||
) : !display ? (
|
||||
@@ -272,6 +576,14 @@ export default function AdminBillingPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className="card space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Box className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Application runtimes</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Unit pricing per runtime (hourly / monthly / yearly)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
|
||||
{runtimeTabs.map((tab) => (
|
||||
<button
|
||||
@@ -290,9 +602,9 @@ export default function AdminBillingPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{runtimeTabs.find((t) => t.value === activeRuntime)?.label} resources
|
||||
</h2>
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
{runtimeTabs.find((t) => t.value === activeRuntime)?.label} — unit prices
|
||||
</h3>
|
||||
{editing && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -312,28 +624,83 @@ export default function AdminBillingPage() {
|
||||
</div>
|
||||
|
||||
<div className="card space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Layers className="w-5 h-5" /> Platform add-ons
|
||||
</h2>
|
||||
{editing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillYearlyFromMonthly('addons')}
|
||||
className="btn-secondary text-xs"
|
||||
>
|
||||
Fill yearly from monthly ×12
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-start gap-3">
|
||||
<Layers className="w-5 h-5 text-purple-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Optional services</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Unit pricing + deploy wizard defaults per service
|
||||
</p>
|
||||
</div>
|
||||
{editing && activeOptionalEntry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillYearlyFromMonthly('optional')}
|
||||
className="btn-secondary text-xs"
|
||||
>
|
||||
Fill yearly from monthly ×12
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Flat addon fees (optional). Deploy cost also includes each service's CPU, RAM, and disk at the app runtime unit rates above.
|
||||
</p>
|
||||
<PricingMatrixTable
|
||||
rows={addonRows}
|
||||
readOnly={!editing}
|
||||
onChange={updateAddonPrice}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
|
||||
{optionalServiceTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => setActiveOptionalService(tab.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeOptionalService === tab.value
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeOptionalEntry && (
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-gray-500" />
|
||||
Deploy wizard defaults
|
||||
</h3>
|
||||
<DeployDefaultsFields
|
||||
service={activeOptionalService}
|
||||
profile={activeOptionalEntry.profile}
|
||||
readOnly={!editing}
|
||||
onUpdate={updateOptionalProfile}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 pt-4 border-t border-gray-100">
|
||||
<h3 className="text-sm font-semibold text-gray-800">Unit pricing</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Billed from the resources the user selects for this service at deploy (CPU cores ×
|
||||
rate, memory GB × rate, storage GB × rate, plus base fee if used).
|
||||
</p>
|
||||
<PricingMatrixTable
|
||||
rows={optionalRateRows}
|
||||
readOnly={!editing}
|
||||
onChange={updateOptionalRate}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customDomain && (
|
||||
<div className="pt-4 border-t border-gray-100">
|
||||
<CustomDomainPricing
|
||||
customDomain={customDomain}
|
||||
readOnly={!editing}
|
||||
onChange={updateCustomDomain}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -363,11 +730,7 @@ function LifecycleSettingsSection() {
|
||||
setEditing(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const message =
|
||||
err && typeof err === 'object' && 'response' in err
|
||||
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
|
||||
: undefined;
|
||||
toast.error(message || 'Failed to save');
|
||||
toast.error(formatApiError(err, 'Failed to save'));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,10 +7,177 @@ import api from '@/lib/api';
|
||||
import { parseDotenv } from '@/lib/parseDotenv';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, DeployCostPreview, BillingCycle } from '@/types';
|
||||
import type {
|
||||
CreateApplicationDto,
|
||||
ClusterPublic,
|
||||
ClusterPoolPublic,
|
||||
DeployCostPreview,
|
||||
BillingCycle,
|
||||
PricingCatalog,
|
||||
OptionalServiceResourceConfig,
|
||||
OptionalServiceResourcesMap,
|
||||
} from '@/types';
|
||||
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2, Globe, Copy, AlertCircle, ShieldCheck } from 'lucide-react';
|
||||
|
||||
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
|
||||
type OptionalServiceKey = 'redis' | 'rabbitmq';
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function WorkloadResourceFields({
|
||||
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-sm font-medium text-gray-700 mb-1">CPU Request</label>
|
||||
<select
|
||||
className="input-field"
|
||||
disabled={readOnly}
|
||||
value={config.cpuRequest || '50m'}
|
||||
onChange={(e) => onChange({ cpuRequest: e.target.value })}
|
||||
>
|
||||
<option value="50m">50m (0.05 core)</option>
|
||||
<option value="100m">100m (0.1 core)</option>
|
||||
<option value="250m">250m (0.25 core)</option>
|
||||
<option value="500m">500m (0.5 core)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Limit</label>
|
||||
<select
|
||||
className="input-field"
|
||||
disabled={readOnly}
|
||||
value={config.cpuLimit}
|
||||
onChange={(e) => onChange({ cpuLimit: e.target.value })}
|
||||
>
|
||||
<option value="200m">200m (0.2 core)</option>
|
||||
<option value="250m">250m (0.25 core)</option>
|
||||
<option value="500m">500m (0.5 core)</option>
|
||||
<option value="1">1 core</option>
|
||||
<option value="2">2 cores</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Memory Request</label>
|
||||
<select
|
||||
className="input-field"
|
||||
disabled={readOnly}
|
||||
value={config.memoryRequest || '64Mi'}
|
||||
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-sm font-medium text-gray-700 mb-1">Memory Limit</label>
|
||||
<select
|
||||
className="input-field"
|
||||
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>
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Storage</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden bg-white">
|
||||
<button
|
||||
type="button"
|
||||
disabled={readOnly || config.storageGi <= 0}
|
||||
onClick={() => setStorage(config.storageGi - 1)}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold 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 py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={readOnly || config.storageGi >= 100}
|
||||
onClick={() => setStorage(config.storageGi + 1)}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
type DeployStage = 'idle' | 'creating' | 'uploading-source' | 'uploading-db' | 'paying' | 'deploying' | 'done' | 'error';
|
||||
|
||||
@@ -56,6 +223,7 @@ export default function DeployPage() {
|
||||
rabbitmqVersion: '3.13',
|
||||
enableElasticsearch: false,
|
||||
elasticsearchVersion: '8.12',
|
||||
optionalServiceResources: {},
|
||||
});
|
||||
const [envKey, setEnvKey] = useState('');
|
||||
const [envVal, setEnvVal] = useState('');
|
||||
@@ -92,6 +260,12 @@ export default function DeployPage() {
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const { data: pricingCatalog } = useQuery<PricingCatalog>({
|
||||
queryKey: ['pricing-catalog'],
|
||||
queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data),
|
||||
enabled: step >= 1,
|
||||
});
|
||||
|
||||
// ── Custom Domain ──────────────────────────────
|
||||
const [enableCustomDomain, setEnableCustomDomain] = useState(false);
|
||||
const [customDomainInput, setCustomDomainInput] = useState('');
|
||||
@@ -134,6 +308,8 @@ export default function DeployPage() {
|
||||
enableRedis: form.enableRedis,
|
||||
enableRabbitmq: form.enableRabbitmq,
|
||||
enableElasticsearch: form.enableElasticsearch,
|
||||
redisResources: form.enableRedis ? form.optionalServiceResources?.redis : undefined,
|
||||
rabbitmqResources: form.enableRabbitmq ? form.optionalServiceResources?.rabbitmq : undefined,
|
||||
enableCustomDomain,
|
||||
cycle: selectedCycle,
|
||||
};
|
||||
@@ -142,14 +318,14 @@ export default function DeployPage() {
|
||||
const { data: costData, isLoading: costLoading } = useQuery<DeployCostPreview>({
|
||||
queryKey: ['deploy-cost', deployCostPayload],
|
||||
queryFn: () => api.post('/billing/calculate-deploy', deployCostPayload).then((r) => r.data),
|
||||
enabled: step === 3,
|
||||
enabled: step >= 2,
|
||||
});
|
||||
|
||||
// Wallet balance for the review step payment
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
enabled: step === 3,
|
||||
enabled: step >= 2,
|
||||
});
|
||||
|
||||
const payAmount = costData?.amountDue ?? 0;
|
||||
@@ -1380,7 +1556,21 @@ export default function DeployPage() {
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, enableRedis: !form.enableRedis })}
|
||||
onClick={() => {
|
||||
const next = !form.enableRedis;
|
||||
setForm({
|
||||
...form,
|
||||
enableRedis: next,
|
||||
optionalServiceResources: next
|
||||
? {
|
||||
...form.optionalServiceResources,
|
||||
redis:
|
||||
form.optionalServiceResources?.redis ??
|
||||
optionalDefaultsFromCatalog(pricingCatalog, 'redis'),
|
||||
}
|
||||
: form.optionalServiceResources,
|
||||
});
|
||||
}}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1424,7 +1614,21 @@ export default function DeployPage() {
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, enableRabbitmq: !form.enableRabbitmq })}
|
||||
onClick={() => {
|
||||
const next = !form.enableRabbitmq;
|
||||
setForm({
|
||||
...form,
|
||||
enableRabbitmq: next,
|
||||
optionalServiceResources: next
|
||||
? {
|
||||
...form.optionalServiceResources,
|
||||
rabbitmq:
|
||||
form.optionalServiceResources?.rabbitmq ??
|
||||
optionalDefaultsFromCatalog(pricingCatalog, 'rabbitmq'),
|
||||
}
|
||||
: form.optionalServiceResources,
|
||||
});
|
||||
}}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1972,6 +2176,8 @@ export default function DeployPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-primary-100 bg-primary-50/40 p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">Application workload</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Request</label>
|
||||
@@ -2033,6 +2239,54 @@ export default function DeployPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{(form.enableRedis || form.enableRabbitmq) && (
|
||||
<div className="space-y-4 pt-2 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">Optional services</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Configure resources for each enabled service separately from your application.
|
||||
</p>
|
||||
{form.enableRedis && form.optionalServiceResources?.redis && (
|
||||
<WorkloadResourceFields
|
||||
title="Redis"
|
||||
accentClass="text-red-500"
|
||||
borderClass="border-red-200"
|
||||
bgClass="bg-red-50/50"
|
||||
config={form.optionalServiceResources.redis}
|
||||
onChange={(patch) =>
|
||||
setForm({
|
||||
...form,
|
||||
optionalServiceResources: {
|
||||
...form.optionalServiceResources,
|
||||
redis: { ...form.optionalServiceResources!.redis!, ...patch },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{form.enableRabbitmq && form.optionalServiceResources?.rabbitmq && (
|
||||
<WorkloadResourceFields
|
||||
title="RabbitMQ"
|
||||
accentClass="text-orange-500"
|
||||
borderClass="border-orange-200"
|
||||
bgClass="bg-orange-50/50"
|
||||
config={form.optionalServiceResources.rabbitmq}
|
||||
onChange={(patch) =>
|
||||
setForm({
|
||||
...form,
|
||||
optionalServiceResources: {
|
||||
...form.optionalServiceResources,
|
||||
rabbitmq: { ...form.optionalServiceResources!.rabbitmq!, ...patch },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Environment Variables</label>
|
||||
@@ -2205,7 +2459,7 @@ export default function DeployPage() {
|
||||
{(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Optional Services</span>
|
||||
<span className="text-sm font-medium">
|
||||
<span className="text-sm font-medium text-right">
|
||||
{[
|
||||
form.enableRedis && 'Redis',
|
||||
form.enableRabbitmq && 'RabbitMQ',
|
||||
@@ -2214,6 +2468,28 @@ export default function DeployPage() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{form.enableRedis && form.optionalServiceResources?.redis && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Redis resources</span>
|
||||
<span className="text-sm font-medium">
|
||||
{form.optionalServiceResources.redis.cpuRequest} / {form.optionalServiceResources.redis.cpuLimit}
|
||||
{' · '}
|
||||
{form.optionalServiceResources.redis.memoryRequest} / {form.optionalServiceResources.redis.memoryLimit}
|
||||
{' · '}{form.optionalServiceResources.redis.storageGi} GB
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{form.enableRabbitmq && form.optionalServiceResources?.rabbitmq && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">RabbitMQ resources</span>
|
||||
<span className="text-sm font-medium">
|
||||
{form.optionalServiceResources.rabbitmq.cpuRequest} / {form.optionalServiceResources.rabbitmq.cpuLimit}
|
||||
{' · '}
|
||||
{form.optionalServiceResources.rabbitmq.memoryRequest} / {form.optionalServiceResources.rabbitmq.memoryLimit}
|
||||
{' · '}{form.optionalServiceResources.rabbitmq.storageGi} GB
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(form.envVars || {}).length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Env Vars</span>
|
||||
|
||||
@@ -217,6 +217,20 @@ export interface CreateApplicationDto {
|
||||
enableElasticsearch?: boolean;
|
||||
elasticsearchVersion?: string;
|
||||
logPaths?: string[];
|
||||
optionalServiceResources?: OptionalServiceResourcesMap;
|
||||
}
|
||||
|
||||
export interface OptionalServiceResourceConfig {
|
||||
cpuRequest?: string;
|
||||
cpuLimit: string;
|
||||
memoryRequest?: string;
|
||||
memoryLimit: string;
|
||||
storageGi: number;
|
||||
}
|
||||
|
||||
export interface OptionalServiceResourcesMap {
|
||||
redis?: OptionalServiceResourceConfig;
|
||||
rabbitmq?: OptionalServiceResourceConfig;
|
||||
}
|
||||
|
||||
export interface ClusterPublic {
|
||||
@@ -383,6 +397,36 @@ export interface PricingRateRow {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface CyclePrices {
|
||||
hourly: number;
|
||||
monthly: number;
|
||||
yearly: number;
|
||||
}
|
||||
|
||||
export interface OptionalServiceProfileRow {
|
||||
cpuRequest?: string;
|
||||
memoryRequest?: string;
|
||||
cpuLimit: string;
|
||||
memoryLimit: string;
|
||||
storageGi: number;
|
||||
logShipperCpuLimit?: string;
|
||||
logShipperMemoryLimit?: string;
|
||||
}
|
||||
|
||||
export interface OptionalServiceCatalogEntry {
|
||||
service: string;
|
||||
label: string;
|
||||
profile: OptionalServiceProfileRow;
|
||||
rates: PricingRateRow[];
|
||||
}
|
||||
|
||||
export interface CustomDomainCatalogRow {
|
||||
hourlyPrice: number;
|
||||
monthlyPrice: number;
|
||||
yearlyPrice: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface CatalogRuntimeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -391,12 +435,12 @@ export interface CatalogRuntimeOption {
|
||||
export interface CatalogOptionalServiceOption {
|
||||
value: string;
|
||||
label: string;
|
||||
resourceType: PricingResourceType;
|
||||
}
|
||||
|
||||
export interface PricingCatalog {
|
||||
runtimes: Record<string, PricingRateRow[]>;
|
||||
addons: PricingRateRow[];
|
||||
optionalServices: Record<string, OptionalServiceCatalogEntry>;
|
||||
customDomain: CustomDomainCatalogRow;
|
||||
runtimeOptions: CatalogRuntimeOption[];
|
||||
optionalServiceOptions: CatalogOptionalServiceOption[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user