add optinal apps
This commit is contained in:
@@ -136,6 +136,50 @@ export class ApplicationsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':id/storage')
|
||||
@ApiOperation({ summary: 'Get comprehensive storage usage (database + app storage)' })
|
||||
async getStorageUsage(@Param('id') id: string, @Request() req: any) {
|
||||
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
|
||||
|
||||
const usage = await this.kubernetesService.getStorageUsage(app);
|
||||
|
||||
return {
|
||||
applicationId: app.id,
|
||||
applicationName: app.name,
|
||||
...usage,
|
||||
// Configured sizes from entity
|
||||
configured: {
|
||||
dbStorageSize: app.dbStorageSize || '1Gi',
|
||||
appStorageSize: app.appStorageSize || '2Gi',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Patch(':id/app-storage')
|
||||
@ApiOperation({ summary: 'Resize (expand) app storage PVC' })
|
||||
async resizeAppStorage(
|
||||
@Param('id') id: string,
|
||||
@Request() req: any,
|
||||
@Body() body: { size: string },
|
||||
) {
|
||||
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
|
||||
|
||||
if (!body.size || !/^\d+Gi$/.test(body.size)) {
|
||||
throw new BadRequestException('Size must be in format like "2Gi", "5Gi", "10Gi"');
|
||||
}
|
||||
|
||||
const result = await this.kubernetesService.resizeAppStoragePvc(app, body.size);
|
||||
|
||||
if (result.success) {
|
||||
// Update the saved size in the DB
|
||||
await this.applicationsService.update(id, app.userId, { appStorageSize: body.size } as any);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List my applications' })
|
||||
async findAll(@Request() req: any) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsBoolean,
|
||||
IsArray,
|
||||
Min,
|
||||
Max,
|
||||
Matches,
|
||||
@@ -62,6 +64,33 @@ export class CreateApplicationDto {
|
||||
@IsString()
|
||||
dbStorageSize?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2Gi', description: 'App PVC storage size for uploads/wp-content (e.g. 2Gi, 5Gi). Default: 2Gi' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
appStorageSize?: string;
|
||||
|
||||
// ── Optional Services ─────────────────────────────
|
||||
@ApiPropertyOptional({ example: false, description: 'Enable Redis for caching' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableRedis?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Enable RabbitMQ for message queue' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableRabbitmq?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Enable Elasticsearch for application logging' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableElasticsearch?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: ['/app/logs/*.log'], description: 'Custom log paths to collect' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
logPaths?: string[];
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://github.com/user/repo.git' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -177,6 +206,38 @@ export class UpdateApplicationDto {
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
replicas?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: '5Gi', description: 'Database PVC storage size' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dbStorageSize?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '5Gi', description: 'App PVC storage size for uploads/wp-content' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
appStorageSize?: string;
|
||||
|
||||
// ── Optional Services ─────────────────────────────
|
||||
@ApiPropertyOptional({ example: false, description: 'Enable Redis' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableRedis?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Enable RabbitMQ' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableRabbitmq?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Enable Elasticsearch logging' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableElasticsearch?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: ['/app/logs/*.log'], description: 'Custom log paths' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
logPaths?: string[];
|
||||
}
|
||||
|
||||
export class ScaleResourcesDto {
|
||||
|
||||
@@ -30,10 +30,10 @@ export class Application {
|
||||
databaseType: DatabaseType;
|
||||
|
||||
@Column({ nullable: true })
|
||||
runtimeVersion: string; // e.g. node: '20', '18', '16' | laravel php: '8.3', '8.2' | wordpress: '6.7', '6.6'
|
||||
runtimeVersion: string; // node: '20','18','16' | laravel/wordpress php: '8.3','8.2' | go: '1.22','1.21' | python/django: '3.12','3.11' | dotnet: '8.0','7.0'
|
||||
|
||||
@Column({ nullable: true })
|
||||
phpVersion: string; // PHP version for Laravel/WordPress (e.g. '8.3', '8.2', '8.1')
|
||||
phpVersion: string; // PHP version for Laravel/WordPress/PHP (e.g. '8.3', '8.2', '8.1')
|
||||
|
||||
@Column({ nullable: true })
|
||||
dbVersion: string; // e.g. postgres: '17', '16', '15' | mysql: '9.0', '8.4', '8.0'
|
||||
@@ -47,6 +47,22 @@ export class Application {
|
||||
@Column({ nullable: true, default: '1Gi' })
|
||||
dbStorageSize: string; // PVC storage size for database (e.g. '1Gi', '5Gi', '10Gi')
|
||||
|
||||
@Column({ nullable: true, default: '2Gi' })
|
||||
appStorageSize: string; // PVC storage size for app files (wp-content, uploads)
|
||||
|
||||
// ── Optional Services ─────────────────────────────
|
||||
@Column({ default: false })
|
||||
enableRedis: boolean;
|
||||
|
||||
@Column({ default: false })
|
||||
enableRabbitmq: boolean;
|
||||
|
||||
@Column({ default: false })
|
||||
enableElasticsearch: boolean; // For application logging
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
logPaths: string[]; // Custom log paths to collect (e.g., ['/app/logs/*.log'])
|
||||
|
||||
@Column({ nullable: true })
|
||||
gitUrl: string;
|
||||
|
||||
|
||||
@@ -12,16 +12,22 @@ import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { BillingService } from './billing.service';
|
||||
import { AppLifecycleService } from '../lifecycle/app-lifecycle.service';
|
||||
import { ApplicationsService } from '../applications/applications.service';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import {
|
||||
CreateServicePlanDto,
|
||||
UpdateServicePlanDto,
|
||||
ChargeWalletDto,
|
||||
CalculateCostDto,
|
||||
RenewApplicationDto,
|
||||
UpgradeResourcesDto,
|
||||
CalculateUpgradeCostDto,
|
||||
} from './dto/billing.dto';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
@@ -36,6 +42,10 @@ export class BillingController {
|
||||
private readonly billingService: BillingService,
|
||||
@Inject(forwardRef(() => AppLifecycleService))
|
||||
private readonly lifecycleService: AppLifecycleService,
|
||||
@Inject(forwardRef(() => ApplicationsService))
|
||||
private readonly applicationsService: ApplicationsService,
|
||||
@Inject(forwardRef(() => KubernetesService))
|
||||
private readonly kubernetesService: KubernetesService,
|
||||
) {}
|
||||
|
||||
// ─── Service Plans (Admin) ────────────────────────────────────────
|
||||
@@ -208,4 +218,267 @@ export class BillingController {
|
||||
) {
|
||||
return this.billingService.adminChargeWallet(userId, dto.amount, dto.description);
|
||||
}
|
||||
|
||||
// ─── Application Renewal ──────────────────────────────────────────
|
||||
|
||||
@Get('applications/:applicationId/renewal-cost')
|
||||
@ApiOperation({ summary: 'Get renewal cost for an application' })
|
||||
async getRenewalCost(
|
||||
@Request() req: any,
|
||||
@Param('applicationId') applicationId: string,
|
||||
) {
|
||||
// User can only view their own app, admin/sales can view any
|
||||
const app = await this.getAppWithAccess(req.user, applicationId);
|
||||
const costs = await this.billingService.calculateRenewalCost(app);
|
||||
return {
|
||||
applicationId: app.id,
|
||||
applicationName: app.name,
|
||||
lifecycleStatus: app.lifecycleStatus,
|
||||
planExpiresAt: app.planExpiresAt,
|
||||
currentCycle: app.billingCycle,
|
||||
costs,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('applications/:applicationId/renew')
|
||||
@ApiOperation({ summary: 'Renew an application (user pays from wallet)' })
|
||||
async renewApplication(
|
||||
@Request() req: any,
|
||||
@Param('applicationId') applicationId: string,
|
||||
@Body() dto: RenewApplicationDto,
|
||||
) {
|
||||
// User can only renew their own app, admin/sales can renew any
|
||||
const app = await this.getAppWithAccess(req.user, applicationId);
|
||||
|
||||
// Calculate cost for the selected cycle
|
||||
const costs = await this.billingService.calculateRenewalCost(app);
|
||||
const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly
|
||||
: dto.cycle === BillingCycle.MONTHLY ? costs.monthly
|
||||
: costs.yearly;
|
||||
|
||||
if (amount <= 0) {
|
||||
throw new BadRequestException('Invalid cost calculation — no pricing rules found');
|
||||
}
|
||||
|
||||
// Deduct from wallet (user's wallet for user, app owner's wallet for admin action)
|
||||
const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES
|
||||
? app.userId
|
||||
: req.user.id;
|
||||
|
||||
const tx = await this.billingService.deductWallet(
|
||||
walletUserId,
|
||||
amount,
|
||||
`Renewal for ${app.name} (${dto.cycle})`,
|
||||
app.id,
|
||||
);
|
||||
|
||||
// Activate the application
|
||||
const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle, app.planId || '');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transaction: tx,
|
||||
application: {
|
||||
id: renewedApp.id,
|
||||
name: renewedApp.name,
|
||||
lifecycleStatus: renewedApp.lifecycleStatus,
|
||||
planExpiresAt: renewedApp.planExpiresAt,
|
||||
billingCycle: renewedApp.billingCycle,
|
||||
},
|
||||
message: `Application "${renewedApp.name}" renewed until ${renewedApp.planExpiresAt?.toISOString()}`,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('admin/applications/:applicationId/renew')
|
||||
@Roles(UserRole.ADMIN, UserRole.SALES)
|
||||
@ApiOperation({ summary: 'Admin/Sales: Renew an application (can bypass wallet if needed)' })
|
||||
async adminRenewApplication(
|
||||
@Request() req: any,
|
||||
@Param('applicationId') applicationId: string,
|
||||
@Body() body: { cycle: string; bypassPayment?: boolean; reason?: string },
|
||||
) {
|
||||
const app = await this.applicationsService.findOne(applicationId);
|
||||
const cycle = body.cycle as BillingCycle;
|
||||
|
||||
if (!Object.values(BillingCycle).includes(cycle)) {
|
||||
throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`);
|
||||
}
|
||||
|
||||
if (body.bypassPayment) {
|
||||
// Direct activation without payment (for special cases, support, etc.)
|
||||
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle, app.planId || '');
|
||||
return {
|
||||
success: true,
|
||||
bypassedPayment: true,
|
||||
reason: body.reason || 'Admin action',
|
||||
application: {
|
||||
id: renewedApp.id,
|
||||
name: renewedApp.name,
|
||||
lifecycleStatus: renewedApp.lifecycleStatus,
|
||||
planExpiresAt: renewedApp.planExpiresAt,
|
||||
},
|
||||
message: `Application "${renewedApp.name}" renewed by admin (payment bypassed)`,
|
||||
};
|
||||
}
|
||||
|
||||
// Normal renewal - deduct from app owner's wallet
|
||||
const costs = await this.billingService.calculateRenewalCost(app);
|
||||
const amount = cycle === BillingCycle.HOURLY ? costs.hourly
|
||||
: cycle === BillingCycle.MONTHLY ? costs.monthly
|
||||
: costs.yearly;
|
||||
|
||||
const tx = await this.billingService.deductWallet(
|
||||
app.userId,
|
||||
amount,
|
||||
`Renewal by ${req.user.role} for ${app.name} (${cycle})`,
|
||||
app.id,
|
||||
);
|
||||
|
||||
const renewedApp = await this.lifecycleService.activateApp(app.id, cycle, app.planId || '');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transaction: tx,
|
||||
application: {
|
||||
id: renewedApp.id,
|
||||
name: renewedApp.name,
|
||||
lifecycleStatus: renewedApp.lifecycleStatus,
|
||||
planExpiresAt: renewedApp.planExpiresAt,
|
||||
},
|
||||
message: `Application "${renewedApp.name}" renewed by ${req.user.role}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Resource Upgrade ─────────────────────────────────────────────
|
||||
|
||||
@Post('applications/:applicationId/upgrade/calculate')
|
||||
@ApiOperation({ summary: 'Calculate cost for resource upgrade' })
|
||||
async calculateUpgradeCost(
|
||||
@Request() req: any,
|
||||
@Param('applicationId') applicationId: string,
|
||||
@Body() dto: CalculateUpgradeCostDto,
|
||||
) {
|
||||
const app = await this.getAppWithAccess(req.user, applicationId);
|
||||
const result = await this.billingService.calculateUpgradeCost(app, dto);
|
||||
|
||||
return {
|
||||
applicationId: app.id,
|
||||
applicationName: app.name,
|
||||
...result,
|
||||
currentResources: {
|
||||
cpuRequest: app.cpuRequest,
|
||||
cpuLimit: app.cpuLimit,
|
||||
memoryRequest: app.memoryRequest,
|
||||
memoryLimit: app.memoryLimit,
|
||||
replicas: app.replicas,
|
||||
dbStorageSize: app.dbStorageSize,
|
||||
appStorageSize: app.appStorageSize,
|
||||
},
|
||||
newResources: {
|
||||
cpuRequest: dto.cpuRequest || app.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit || app.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest || app.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit || app.memoryLimit,
|
||||
replicas: dto.replicas ?? app.replicas,
|
||||
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
|
||||
appStorageSize: dto.appStorageSize || app.appStorageSize,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post('applications/:applicationId/upgrade')
|
||||
@ApiOperation({ summary: 'Upgrade application resources (with payment)' })
|
||||
async upgradeResources(
|
||||
@Request() req: any,
|
||||
@Param('applicationId') applicationId: string,
|
||||
@Body() dto: UpgradeResourcesDto,
|
||||
) {
|
||||
const app = await this.getAppWithAccess(req.user, applicationId);
|
||||
|
||||
// Application must be active to upgrade
|
||||
if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) {
|
||||
throw new BadRequestException(
|
||||
`Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate upgrade cost
|
||||
const costResult = await this.billingService.calculateUpgradeCost(app, dto);
|
||||
|
||||
// If upgrading (positive difference), require payment
|
||||
if (costResult.proratedAmount > 0) {
|
||||
const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES
|
||||
? app.userId
|
||||
: req.user.id;
|
||||
|
||||
await this.billingService.deductWallet(
|
||||
walletUserId,
|
||||
costResult.proratedAmount,
|
||||
`Resource upgrade for ${app.name}: prorated ${costResult.remainingHours}h`,
|
||||
app.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply the resource changes
|
||||
const updatedApp = await this.applicationsService.update(app.id, app.userId, {
|
||||
cpuRequest: dto.cpuRequest || app.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit || app.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest || app.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit || app.memoryLimit,
|
||||
replicas: dto.replicas ?? app.replicas,
|
||||
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
|
||||
appStorageSize: dto.appStorageSize || app.appStorageSize,
|
||||
});
|
||||
|
||||
// Update Kubernetes resources
|
||||
try {
|
||||
await this.kubernetesService.updateResources(updatedApp, {
|
||||
cpuRequest: dto.cpuRequest,
|
||||
cpuLimit: dto.cpuLimit,
|
||||
memoryRequest: dto.memoryRequest,
|
||||
memoryLimit: dto.memoryLimit,
|
||||
replicas: dto.replicas,
|
||||
});
|
||||
|
||||
// Resize app storage PVC if changed
|
||||
if (dto.appStorageSize && dto.appStorageSize !== app.appStorageSize) {
|
||||
await this.kubernetesService.resizeAppStoragePvc(updatedApp, dto.appStorageSize);
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Log error but don't fail - DB is updated, K8s will sync on next deploy
|
||||
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
paidAmount: costResult.proratedAmount,
|
||||
application: {
|
||||
id: updatedApp.id,
|
||||
name: updatedApp.name,
|
||||
cpuRequest: updatedApp.cpuRequest,
|
||||
cpuLimit: updatedApp.cpuLimit,
|
||||
memoryRequest: updatedApp.memoryRequest,
|
||||
memoryLimit: updatedApp.memoryLimit,
|
||||
replicas: updatedApp.replicas,
|
||||
dbStorageSize: updatedApp.dbStorageSize,
|
||||
appStorageSize: updatedApp.appStorageSize,
|
||||
},
|
||||
message: costResult.proratedAmount > 0
|
||||
? `Resources upgraded. Paid ${costResult.proratedAmount} Toman for remaining ${costResult.remainingHours} hours.`
|
||||
: 'Resources updated (downgrade or no cost change).',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helper Methods ───────────────────────────────────────────────
|
||||
|
||||
private async getAppWithAccess(user: any, applicationId: string) {
|
||||
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
|
||||
|
||||
if (isAdminOrSales) {
|
||||
return this.applicationsService.findOne(applicationId);
|
||||
}
|
||||
|
||||
// Regular user - must own the app
|
||||
return this.applicationsService.findOne(applicationId, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@ import { Wallet } from './entities/wallet.entity';
|
||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||
import { LifecycleModule } from '../lifecycle/lifecycle.module';
|
||||
import { ApplicationsModule } from '../applications/applications.module';
|
||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction, PlatformSetting]),
|
||||
forwardRef(() => LifecycleModule),
|
||||
forwardRef(() => ApplicationsModule),
|
||||
forwardRef(() => KubernetesModule),
|
||||
],
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
CreateServicePlanDto,
|
||||
UpdateServicePlanDto,
|
||||
CalculateCostDto,
|
||||
UpgradeResourcesDto,
|
||||
} from './dto/billing.dto';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
@@ -133,9 +135,14 @@ export class BillingService {
|
||||
// Parse resource values
|
||||
const cpuCores = this.parseCpuToCores(dto.cpuLimit);
|
||||
const memoryGb = this.parseMemoryToGb(dto.memoryLimit);
|
||||
const storageGb = dto.dbStorageSize ? parseFloat(dto.dbStorageSize.replace('Gi', '')) || 0 : 0;
|
||||
const dbStorageGb = dto.dbStorageSize ? parseFloat(dto.dbStorageSize.replace('Gi', '')) || 0 : 0;
|
||||
const appStorageGb = dto.appStorageSize ? parseFloat(dto.appStorageSize.replace('Gi', '')) || 0 : 0;
|
||||
const totalStorageGb = dbStorageGb + appStorageGb;
|
||||
const hasDatabase = dto.databaseType !== 'none';
|
||||
const replicas = dto.replicas || 1;
|
||||
const hasRedis = dto.enableRedis || false;
|
||||
const hasRabbitmq = dto.enableRabbitmq || false;
|
||||
const hasElasticsearch = dto.enableElasticsearch || false;
|
||||
|
||||
const breakdown: { label: string; hourly: number; monthly: number; yearly: number }[] = [];
|
||||
let totalBase = 0;
|
||||
@@ -158,13 +165,25 @@ export class BillingService {
|
||||
label = `Memory (${(memoryGb * replicas).toFixed(2)} GB)`;
|
||||
break;
|
||||
case PricingResourceType.STORAGE_PER_GB:
|
||||
cost = storageGb * Number(rule.unitPrice);
|
||||
label = `Storage (${storageGb} GB)`;
|
||||
cost = totalStorageGb * Number(rule.unitPrice);
|
||||
label = `Storage (${totalStorageGb} GB)`;
|
||||
break;
|
||||
case PricingResourceType.DATABASE_ADDON:
|
||||
cost = hasDatabase ? Number(rule.unitPrice) : 0;
|
||||
label = 'Database addon';
|
||||
break;
|
||||
case PricingResourceType.REDIS_ADDON:
|
||||
cost = hasRedis ? Number(rule.unitPrice) : 0;
|
||||
label = 'Redis addon';
|
||||
break;
|
||||
case PricingResourceType.RABBITMQ_ADDON:
|
||||
cost = hasRabbitmq ? Number(rule.unitPrice) : 0;
|
||||
label = 'RabbitMQ addon';
|
||||
break;
|
||||
case PricingResourceType.ELASTICSEARCH_ADDON:
|
||||
cost = hasElasticsearch ? Number(rule.unitPrice) : 0;
|
||||
label = 'Elasticsearch addon';
|
||||
break;
|
||||
}
|
||||
|
||||
if (cost > 0) {
|
||||
@@ -300,6 +319,7 @@ export class BillingService {
|
||||
memoryLimit: string;
|
||||
replicas: number;
|
||||
dbStorageSize?: string;
|
||||
appStorageSize?: string;
|
||||
}): Promise<{ hourly: number; monthly: number; yearly: number }> {
|
||||
const result = await this.calculateCost({
|
||||
runtime: app.runtime,
|
||||
@@ -308,7 +328,100 @@ export class BillingService {
|
||||
memoryLimit: app.memoryLimit,
|
||||
replicas: app.replicas,
|
||||
dbStorageSize: app.dbStorageSize,
|
||||
appStorageSize: app.appStorageSize,
|
||||
});
|
||||
return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly };
|
||||
}
|
||||
|
||||
// ─── Renewal Cost Calculation ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate renewal cost for an application.
|
||||
* Returns cost for each billing cycle based on current app config.
|
||||
*/
|
||||
async calculateRenewalCost(app: Application): Promise<{
|
||||
hourly: number;
|
||||
monthly: number;
|
||||
yearly: number;
|
||||
currentCycle?: BillingCycle;
|
||||
currentCycleCost?: number;
|
||||
}> {
|
||||
const costs = await this.calculateCostForApp(app);
|
||||
|
||||
let currentCycleCost: number | undefined;
|
||||
if (app.billingCycle) {
|
||||
currentCycleCost = app.billingCycle === BillingCycle.HOURLY ? costs.hourly
|
||||
: app.billingCycle === BillingCycle.MONTHLY ? costs.monthly
|
||||
: costs.yearly;
|
||||
}
|
||||
|
||||
return {
|
||||
...costs,
|
||||
currentCycle: app.billingCycle,
|
||||
currentCycleCost,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Resource Upgrade Cost Calculation ────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate the cost difference for a resource upgrade.
|
||||
* Returns the additional cost per billing cycle.
|
||||
*/
|
||||
async calculateUpgradeCost(
|
||||
app: Application,
|
||||
newResources: UpgradeResourcesDto,
|
||||
): Promise<{
|
||||
currentCost: { hourly: number; monthly: number; yearly: number };
|
||||
newCost: { hourly: number; monthly: number; yearly: number };
|
||||
difference: { hourly: number; monthly: number; yearly: number };
|
||||
proratedAmount: number;
|
||||
remainingHours: number;
|
||||
billingCycle: BillingCycle | null;
|
||||
}> {
|
||||
// Current cost
|
||||
const currentCost = await this.calculateCostForApp(app);
|
||||
|
||||
// New cost with upgraded resources
|
||||
const newCost = await this.calculateCost({
|
||||
runtime: app.runtime,
|
||||
databaseType: app.databaseType,
|
||||
cpuLimit: newResources.cpuLimit || app.cpuLimit,
|
||||
memoryLimit: newResources.memoryLimit || app.memoryLimit,
|
||||
replicas: newResources.replicas ?? app.replicas,
|
||||
dbStorageSize: newResources.dbStorageSize || app.dbStorageSize,
|
||||
appStorageSize: newResources.appStorageSize || app.appStorageSize,
|
||||
});
|
||||
|
||||
// Difference
|
||||
const difference = {
|
||||
hourly: newCost.hourly - currentCost.hourly,
|
||||
monthly: newCost.monthly - currentCost.monthly,
|
||||
yearly: newCost.yearly - currentCost.yearly,
|
||||
};
|
||||
|
||||
// Calculate prorated amount based on remaining time in billing period
|
||||
let proratedAmount = 0;
|
||||
let remainingHours = 0;
|
||||
|
||||
if (app.planExpiresAt && app.billingCycle) {
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(app.planExpiresAt);
|
||||
remainingHours = Math.max(0, (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
// Only charge difference if upgrading (not downgrading)
|
||||
if (difference.hourly > 0) {
|
||||
proratedAmount = Math.ceil(difference.hourly * remainingHours);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentCost,
|
||||
newCost,
|
||||
difference,
|
||||
proratedAmount,
|
||||
remainingHours: Math.round(remainingHours),
|
||||
billingCycle: app.billingCycle,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,4 +116,73 @@ export class CalculateCostDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dbStorageSize?: string;
|
||||
|
||||
@ApiProperty({ example: '2Gi', description: 'App storage size for uploads/wp-content' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
appStorageSize?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Enable Redis caching' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableRedis?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Enable RabbitMQ message broker' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableRabbitmq?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Enable Elasticsearch logging' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableElasticsearch?: boolean;
|
||||
}
|
||||
|
||||
// ─── Renewal & Upgrade DTOs ─────────────────────────────────────────
|
||||
|
||||
export class RenewApplicationDto {
|
||||
@ApiProperty({ enum: BillingCycle, example: 'monthly' })
|
||||
@IsEnum(BillingCycle)
|
||||
cycle: BillingCycle;
|
||||
}
|
||||
|
||||
export class UpgradeResourcesDto {
|
||||
@ApiPropertyOptional({ example: '500m', description: 'New CPU request' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cpuRequest?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '1000m', description: 'New CPU limit' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cpuLimit?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '256Mi', description: 'New memory request' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memoryRequest?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '1Gi', description: 'New memory limit' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memoryLimit?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 2, description: 'New replica count' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
replicas?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: '5Gi', description: 'New database storage size' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dbStorageSize?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '5Gi', description: 'New app storage size for uploads/wp-content' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
appStorageSize?: string;
|
||||
}
|
||||
|
||||
export class CalculateUpgradeCostDto extends UpgradeResourcesDto {}
|
||||
|
||||
|
||||
@@ -1,5 +1,298 @@
|
||||
import { AppRuntime } from '../common/enums';
|
||||
|
||||
/**
|
||||
* Tests for build service — Dockerfile generation for all runtimes
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Go Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Go Dockerfile generation', () => {
|
||||
function goDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const goVersion = app.runtimeVersion || '1.22';
|
||||
const port = app.port || 8080;
|
||||
return `FROM golang:${goVersion}-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download || true
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main .
|
||||
|
||||
FROM alpine:3.19
|
||||
WORKDIR /app
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
|
||||
COPY --from=builder /app/main .
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
|
||||
USER appuser
|
||||
ENV PORT=${port}
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD wget --no-verbose --tries=1 --spider http://localhost:${port}/health || exit 1
|
||||
CMD ["./main"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct Go version', () => {
|
||||
const df = goDockerfile({ runtimeVersion: '1.21' });
|
||||
expect(df).toContain('FROM golang:1.21-alpine');
|
||||
});
|
||||
|
||||
it('should default to Go 1.22', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('FROM golang:1.22-alpine');
|
||||
});
|
||||
|
||||
it('should build static binary with CGO_ENABLED=0', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('CGO_ENABLED=0');
|
||||
});
|
||||
|
||||
it('should use multi-stage build for smaller image', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('AS builder');
|
||||
expect(df).toContain('FROM alpine:3.19');
|
||||
});
|
||||
|
||||
it('should include health check', () => {
|
||||
const df = goDockerfile({ port: 8080 });
|
||||
expect(df).toContain('HEALTHCHECK');
|
||||
expect(df).toContain('http://localhost:8080/health');
|
||||
});
|
||||
|
||||
it('should create data directory for persistent storage', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('mkdir -p /app/data');
|
||||
});
|
||||
|
||||
it('should run as non-root user', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('USER appuser');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Python Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Python Dockerfile generation', () => {
|
||||
function pythonDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const pythonVersion = app.runtimeVersion || '3.12';
|
||||
const port = app.port || 8000;
|
||||
return `FROM python:${pythonVersion}-slim AS builder
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y build-essential libpq-dev
|
||||
COPY requirements.txt* ./
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user flask gunicorn
|
||||
|
||||
FROM python:${pythonVersion}-slim
|
||||
WORKDIR /app
|
||||
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
|
||||
COPY --from=builder /root/.local /home/appuser/.local
|
||||
COPY . .
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
|
||||
USER appuser
|
||||
ENV PATH=/home/appuser/.local/bin:$PATH
|
||||
ENV PORT=${port}
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1
|
||||
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:${port}", "app:app"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct Python version', () => {
|
||||
const df = pythonDockerfile({ runtimeVersion: '3.11' });
|
||||
expect(df).toContain('FROM python:3.11-slim');
|
||||
});
|
||||
|
||||
it('should default to Python 3.12', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('FROM python:3.12-slim');
|
||||
});
|
||||
|
||||
it('should use multi-stage build', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('AS builder');
|
||||
});
|
||||
|
||||
it('should install from requirements.txt', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('requirements.txt');
|
||||
});
|
||||
|
||||
it('should include health check', () => {
|
||||
const df = pythonDockerfile({ port: 8000 });
|
||||
expect(df).toContain('HEALTHCHECK');
|
||||
expect(df).toContain('http://localhost:8000/health');
|
||||
});
|
||||
|
||||
it('should run as non-root user', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('USER appuser');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Django Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Django Dockerfile generation', () => {
|
||||
function djangoDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const pythonVersion = app.runtimeVersion || '3.12';
|
||||
const port = app.port || 8000;
|
||||
return `FROM python:${pythonVersion}-slim AS builder
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y build-essential libpq-dev
|
||||
COPY requirements.txt* ./
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user django gunicorn
|
||||
|
||||
FROM python:${pythonVersion}-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /root/.local /home/appuser/.local
|
||||
COPY . .
|
||||
RUN mkdir -p /app/staticfiles /app/media /app/data
|
||||
USER appuser
|
||||
ENV PORT=${port}
|
||||
ENV DJANGO_SETTINGS_MODULE=config.settings
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health/ || exit 1
|
||||
CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:${port}"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct Python version', () => {
|
||||
const df = djangoDockerfile({ runtimeVersion: '3.10' });
|
||||
expect(df).toContain('FROM python:3.10-slim');
|
||||
});
|
||||
|
||||
it('should set DJANGO_SETTINGS_MODULE', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('DJANGO_SETTINGS_MODULE');
|
||||
});
|
||||
|
||||
it('should create staticfiles and media directories', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('/app/staticfiles');
|
||||
expect(df).toContain('/app/media');
|
||||
});
|
||||
|
||||
it('should run migrations on startup', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('migrate');
|
||||
});
|
||||
|
||||
it('should use gunicorn for production', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('gunicorn');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// .NET Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('.NET Dockerfile generation', () => {
|
||||
function dotnetDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const dotnetVersion = app.runtimeVersion || '8.0';
|
||||
const port = app.port || 5000;
|
||||
return `FROM mcr.microsoft.com/dotnet/sdk:${dotnetVersion} AS build
|
||||
WORKDIR /src
|
||||
COPY *.csproj ./
|
||||
RUN dotnet restore || true
|
||||
COPY . .
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:${dotnetVersion}
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
RUN mkdir -p /app/data
|
||||
USER appuser
|
||||
ENV ASPNETCORE_URLS=http://+:${port}
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1
|
||||
CMD ["dotnet", "app.dll"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct .NET version', () => {
|
||||
const df = dotnetDockerfile({ runtimeVersion: '7.0' });
|
||||
expect(df).toContain('dotnet/sdk:7.0');
|
||||
expect(df).toContain('dotnet/aspnet:7.0');
|
||||
});
|
||||
|
||||
it('should default to .NET 8.0', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('dotnet/sdk:8.0');
|
||||
});
|
||||
|
||||
it('should use multi-stage build', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('AS build');
|
||||
expect(df).toContain('dotnet/aspnet');
|
||||
});
|
||||
|
||||
it('should publish in Release mode', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('-c Release');
|
||||
});
|
||||
|
||||
it('should set ASPNETCORE_ENVIRONMENT to Production', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('ASPNETCORE_ENVIRONMENT=Production');
|
||||
});
|
||||
|
||||
it('should configure ASPNETCORE_URLS for correct port', () => {
|
||||
const df = dotnetDockerfile({ port: 8080 });
|
||||
expect(df).toContain('ASPNETCORE_URLS=http://+:8080');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PHP Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('PHP Dockerfile generation', () => {
|
||||
function phpDockerfile(app: { phpVersion?: string; port?: number }): string {
|
||||
const phpVersion = app.phpVersion || '8.3';
|
||||
const port = app.port || 80;
|
||||
return `FROM php:${phpVersion}-fpm-alpine
|
||||
RUN apk add --no-cache nginx supervisor curl
|
||||
RUN docker-php-ext-install pdo pdo_mysql opcache
|
||||
WORKDIR /var/www/html
|
||||
COPY . .
|
||||
RUN mkdir -p /var/www/html/uploads /var/www/html/data
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
EXPOSE ${port}
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct PHP version', () => {
|
||||
const df = phpDockerfile({ phpVersion: '8.2' });
|
||||
expect(df).toContain('FROM php:8.2-fpm-alpine');
|
||||
});
|
||||
|
||||
it('should default to PHP 8.3', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('FROM php:8.3-fpm-alpine');
|
||||
});
|
||||
|
||||
it('should use FPM with nginx via supervisord', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('supervisord');
|
||||
expect(df).toContain('nginx');
|
||||
});
|
||||
|
||||
it('should install common PHP extensions', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('pdo');
|
||||
expect(df).toContain('opcache');
|
||||
});
|
||||
|
||||
it('should create upload and data directories', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('/var/www/html/uploads');
|
||||
expect(df).toContain('/var/www/html/data');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for the WordPress build flow — specifically:
|
||||
* 1. Helper pod PVC race condition (must wait for termination)
|
||||
|
||||
@@ -622,6 +622,16 @@ export class BuildService {
|
||||
return this.laravelDockerfile(app);
|
||||
case AppRuntime.WORDPRESS:
|
||||
return this.wordpressDockerfile(app);
|
||||
case AppRuntime.GO:
|
||||
return this.goDockerfile(app);
|
||||
case AppRuntime.PHP:
|
||||
return this.phpDockerfile(app);
|
||||
case AppRuntime.PYTHON:
|
||||
return this.pythonDockerfile(app);
|
||||
case AppRuntime.DJANGO:
|
||||
return this.djangoDockerfile(app);
|
||||
case AppRuntime.DOTNET:
|
||||
return this.dotnetDockerfile(app);
|
||||
default:
|
||||
throw new Error(`Unsupported runtime: ${runtime}`);
|
||||
}
|
||||
@@ -874,6 +884,286 @@ CMD []` : `CMD ["apache2-foreground"]`}
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Go Dockerfile ─────────────────────────────────────────────────
|
||||
private goDockerfile(app: Application): string {
|
||||
const goVersion = app.runtimeVersion || '1.22';
|
||||
const port = app.port || 8080;
|
||||
return `# --- Build stage ---
|
||||
FROM golang:${goVersion}-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install git for fetching dependencies
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Copy go mod files first for better caching
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download || true
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main .
|
||||
|
||||
# --- Production stage ---
|
||||
FROM alpine:3.19
|
||||
WORKDIR /app
|
||||
|
||||
# Add CA certificates for HTTPS requests
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
|
||||
|
||||
# Copy the binary from builder
|
||||
COPY --from=builder /app/main .
|
||||
COPY --from=builder /app/static ./static 2>/dev/null || true
|
||||
COPY --from=builder /app/templates ./templates 2>/dev/null || true
|
||||
COPY --from=builder /app/public ./public 2>/dev/null || true
|
||||
|
||||
# Create data directory for persistent storage
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
|
||||
|
||||
USER appuser
|
||||
|
||||
ENV PORT=${port}
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:${port}/health || exit 1
|
||||
|
||||
CMD ["./main"]
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── PHP (Plain) Dockerfile ────────────────────────────────────────
|
||||
private phpDockerfile(app: Application): string {
|
||||
const phpVersion = app.phpVersion || '8.3';
|
||||
const port = app.port || 80;
|
||||
return `FROM php:${phpVersion}-fpm-alpine
|
||||
|
||||
RUN apk add --no-cache nginx supervisor curl \\
|
||||
&& docker-php-ext-install pdo pdo_mysql opcache \\
|
||||
&& docker-php-ext-install pdo_pgsql 2>/dev/null || true
|
||||
|
||||
# Install common PHP extensions
|
||||
RUN apk add --no-cache libpng-dev libjpeg-turbo-dev freetype-dev \\
|
||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \\
|
||||
&& docker-php-ext-install gd
|
||||
|
||||
WORKDIR /var/www/html
|
||||
COPY . .
|
||||
|
||||
# Generate nginx config
|
||||
RUN mkdir -p /etc/nginx/http.d && \\
|
||||
echo 'server {' > /etc/nginx/http.d/default.conf && \\
|
||||
echo ' listen ${port};' >> /etc/nginx/http.d/default.conf && \\
|
||||
echo ' root /var/www/html;' >> /etc/nginx/http.d/default.conf && \\
|
||||
echo ' index index.php index.html;' >> /etc/nginx/http.d/default.conf && \\
|
||||
echo ' client_max_body_size 64M;' >> /etc/nginx/http.d/default.conf && \\
|
||||
echo ' location / { try_files \\$uri \\$uri/ /index.php?\\$query_string; }' >> /etc/nginx/http.d/default.conf && \\
|
||||
echo ' location ~ \\.php\\$ {' >> /etc/nginx/http.d/default.conf && \\
|
||||
echo ' fastcgi_pass 127.0.0.1:9000;' >> /etc/nginx/http.d/default.conf && \\
|
||||
echo ' fastcgi_param SCRIPT_FILENAME \\$document_root\\$fastcgi_script_name;' >> /etc/nginx/http.d/default.conf && \\
|
||||
echo ' include fastcgi_params;' >> /etc/nginx/http.d/default.conf && \\
|
||||
echo ' }' >> /etc/nginx/http.d/default.conf && \\
|
||||
echo ' location ~ /\\.ht { deny all; }' >> /etc/nginx/http.d/default.conf && \\
|
||||
echo '}' >> /etc/nginx/http.d/default.conf
|
||||
|
||||
# Generate supervisord config
|
||||
RUN echo '[supervisord]' > /etc/supervisord.conf && \\
|
||||
echo 'nodaemon=true' >> /etc/supervisord.conf && \\
|
||||
echo 'logfile=/dev/stdout' >> /etc/supervisord.conf && \\
|
||||
echo 'logfile_maxbytes=0' >> /etc/supervisord.conf && \\
|
||||
echo '[program:php-fpm]' >> /etc/supervisord.conf && \\
|
||||
echo 'command=php-fpm -F' >> /etc/supervisord.conf && \\
|
||||
echo 'autostart=true' >> /etc/supervisord.conf && \\
|
||||
echo 'autorestart=true' >> /etc/supervisord.conf && \\
|
||||
echo 'stdout_logfile=/dev/stdout' >> /etc/supervisord.conf && \\
|
||||
echo 'stdout_logfile_maxbytes=0' >> /etc/supervisord.conf && \\
|
||||
echo 'stderr_logfile=/dev/stderr' >> /etc/supervisord.conf && \\
|
||||
echo 'stderr_logfile_maxbytes=0' >> /etc/supervisord.conf && \\
|
||||
echo '[program:nginx]' >> /etc/supervisord.conf && \\
|
||||
echo 'command=nginx -g "daemon off;"' >> /etc/supervisord.conf && \\
|
||||
echo 'autostart=true' >> /etc/supervisord.conf && \\
|
||||
echo 'autorestart=true' >> /etc/supervisord.conf && \\
|
||||
echo 'stdout_logfile=/dev/stdout' >> /etc/supervisord.conf && \\
|
||||
echo 'stdout_logfile_maxbytes=0' >> /etc/supervisord.conf && \\
|
||||
echo 'stderr_logfile=/dev/stderr' >> /etc/supervisord.conf && \\
|
||||
echo 'stderr_logfile_maxbytes=0' >> /etc/supervisord.conf
|
||||
|
||||
# Use custom configs if provided
|
||||
RUN [ -f docker/nginx.conf ] && cp docker/nginx.conf /etc/nginx/http.d/default.conf || true
|
||||
RUN [ -f docker/supervisord.conf ] && cp docker/supervisord.conf /etc/supervisord.conf || true
|
||||
|
||||
# Create upload and data directories
|
||||
RUN mkdir -p /var/www/html/uploads /var/www/html/data \\
|
||||
&& chown -R www-data:www-data /var/www/html
|
||||
|
||||
EXPOSE ${port}
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Python Dockerfile ─────────────────────────────────────────────
|
||||
private pythonDockerfile(app: Application): string {
|
||||
const pythonVersion = app.runtimeVersion || '3.12';
|
||||
const port = app.port || 8000;
|
||||
return `# --- Build stage ---
|
||||
FROM python:${pythonVersion}-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||
build-essential libpq-dev \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install dependencies
|
||||
COPY requirements.txt* ./
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\
|
||||
pip install --no-cache-dir --user flask gunicorn
|
||||
|
||||
# --- Production stage ---
|
||||
FROM python:${pythonVersion}-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||
libpq5 curl \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
|
||||
|
||||
# Copy installed packages from builder
|
||||
COPY --from=builder /root/.local /home/appuser/.local
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
|
||||
|
||||
USER appuser
|
||||
ENV PATH=/home/appuser/.local/bin:$PATH
|
||||
ENV PORT=${port}
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
|
||||
CMD curl -f http://localhost:${port}/health || exit 1
|
||||
|
||||
# Auto-detect: Flask, FastAPI, or plain Python
|
||||
CMD ["sh", "-c", "if [ -f main.py ]; then if grep -q 'FastAPI\\|fastapi' main.py; then uvicorn main:app --host 0.0.0.0 --port ${port}; elif grep -q 'Flask\\|flask' main.py; then gunicorn -w 4 -b 0.0.0.0:${port} main:app; else python main.py; fi; elif [ -f app.py ]; then if grep -q 'FastAPI\\|fastapi' app.py; then uvicorn app:app --host 0.0.0.0 --port ${port}; elif grep -q 'Flask\\|flask' app.py; then gunicorn -w 4 -b 0.0.0.0:${port} app:app; else python app.py; fi; else gunicorn -w 4 -b 0.0.0.0:${port} app:app; fi"]
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Django Dockerfile ─────────────────────────────────────────────
|
||||
private djangoDockerfile(app: Application): string {
|
||||
const pythonVersion = app.runtimeVersion || '3.12';
|
||||
const port = app.port || 8000;
|
||||
return `# --- Build stage ---
|
||||
FROM python:${pythonVersion}-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||
build-essential libpq-dev \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install dependencies
|
||||
COPY requirements.txt* ./
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\
|
||||
pip install --no-cache-dir --user django gunicorn psycopg2-binary mysqlclient
|
||||
|
||||
# --- Production stage ---
|
||||
FROM python:${pythonVersion}-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||
libpq5 default-libmysqlclient-dev curl \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
|
||||
|
||||
# Copy installed packages from builder
|
||||
COPY --from=builder /root/.local /home/appuser/.local
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create directories for static files and media
|
||||
RUN mkdir -p /app/staticfiles /app/media /app/data \\
|
||||
&& chown -R appuser:appgroup /app
|
||||
|
||||
USER appuser
|
||||
ENV PATH=/home/appuser/.local/bin:$PATH
|
||||
ENV PORT=${port}
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV DJANGO_SETTINGS_MODULE=config.settings
|
||||
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \\
|
||||
CMD curl -f http://localhost:${port}/health/ || curl -f http://localhost:${port}/ || exit 1
|
||||
|
||||
# Auto-detect project structure and run migrations + collectstatic
|
||||
CMD ["sh", "-c", "\\
|
||||
PROJECT_NAME=$(find . -maxdepth 2 -name 'wsgi.py' | head -1 | cut -d'/' -f2) && \\
|
||||
if [ -z \\\"$PROJECT_NAME\\\" ]; then PROJECT_NAME='config'; fi && \\
|
||||
echo \\\"Django project: $PROJECT_NAME\\\" && \\
|
||||
python manage.py migrate --noinput 2>/dev/null || true && \\
|
||||
python manage.py collectstatic --noinput 2>/dev/null || true && \\
|
||||
gunicorn $PROJECT_NAME.wsgi:application --bind 0.0.0.0:${port} --workers 4 --threads 2 \\
|
||||
"]
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── .NET Dockerfile ───────────────────────────────────────────────
|
||||
private dotnetDockerfile(app: Application): string {
|
||||
const dotnetVersion = app.runtimeVersion || '8.0';
|
||||
const port = app.port || 5000;
|
||||
return `# --- Build stage ---
|
||||
FROM mcr.microsoft.com/dotnet/sdk:${dotnetVersion} AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy csproj and restore dependencies
|
||||
COPY *.csproj ./
|
||||
RUN dotnet restore || true
|
||||
|
||||
# Copy everything else and build
|
||||
COPY . .
|
||||
RUN dotnet publish -c Release -o /app/publish --no-restore 2>/dev/null || \\
|
||||
dotnet publish -c Release -o /app/publish
|
||||
|
||||
# --- Production stage ---
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:${dotnetVersion}
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
|
||||
|
||||
# Copy published app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
|
||||
|
||||
USER appuser
|
||||
|
||||
ENV ASPNETCORE_URLS=http://+:${port}
|
||||
ENV DOTNET_RUNNING_IN_CONTAINER=true
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \\
|
||||
CMD curl -f http://localhost:${port}/health || curl -f http://localhost:${port}/ || exit 1
|
||||
|
||||
# Auto-detect entry point DLL
|
||||
CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' ! -name '*.runtimeconfig.dll' | head -1) && dotnet $DLL"]
|
||||
`;
|
||||
}
|
||||
|
||||
private async waitForJobCompletion(
|
||||
batchApi: k8s.BatchV1Api,
|
||||
coreApi: k8s.CoreV1Api,
|
||||
|
||||
@@ -29,14 +29,28 @@ export enum AppRuntime {
|
||||
NODEJS = 'nodejs',
|
||||
LARAVEL = 'laravel',
|
||||
WORDPRESS = 'wordpress',
|
||||
GO = 'go',
|
||||
PHP = 'php',
|
||||
PYTHON = 'python',
|
||||
DJANGO = 'django',
|
||||
DOTNET = 'dotnet',
|
||||
}
|
||||
|
||||
export enum DatabaseType {
|
||||
MYSQL = 'mysql',
|
||||
POSTGRESQL = 'postgresql',
|
||||
MONGODB = 'mongodb',
|
||||
MARIADB = 'mariadb',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
// Optional services that can be attached to an application
|
||||
export enum OptionalService {
|
||||
REDIS = 'redis',
|
||||
RABBITMQ = 'rabbitmq',
|
||||
ELASTICSEARCH = 'elasticsearch',
|
||||
}
|
||||
|
||||
export enum DeploymentStatus {
|
||||
PENDING = 'pending',
|
||||
BUILDING = 'building',
|
||||
@@ -73,6 +87,9 @@ export enum PricingResourceType {
|
||||
MEMORY_PER_GB = 'memory_per_gb', // Price per GB RAM
|
||||
STORAGE_PER_GB = 'storage_per_gb', // Price per GB disk
|
||||
DATABASE_ADDON = 'database_addon', // Price for database addon
|
||||
REDIS_ADDON = 'redis_addon', // Price for Redis addon
|
||||
RABBITMQ_ADDON = 'rabbitmq_addon', // Price for RabbitMQ addon
|
||||
ELASTICSEARCH_ADDON = 'elasticsearch_addon', // Price for Elasticsearch addon
|
||||
}
|
||||
|
||||
export enum TransactionType {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,13 +70,13 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
const saved = await this.appRepo.save(app);
|
||||
|
||||
// If the app was suspended, scale it back up
|
||||
// If the app was suspended, resume it (scale app + database back up)
|
||||
if (app.latestImageTag) {
|
||||
try {
|
||||
await this.kubernetesService.scaleDeployment(app, app.replicas || 1);
|
||||
this.logger.log(`Reactivated ${app.name} — scaled to ${app.replicas} replicas, expires ${expiresAt.toISOString()}`);
|
||||
await this.kubernetesService.resumeApplication(app);
|
||||
this.logger.log(`Reactivated ${app.name} — resumed with ${app.replicas} replicas, expires ${expiresAt.toISOString()}`);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to scale up ${app.name} on reactivation: ${e.message}`);
|
||||
this.logger.warn(`Failed to resume ${app.name} on reactivation: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,11 +136,11 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy {
|
||||
if (renewed) continue;
|
||||
}
|
||||
|
||||
// Suspend: scale to 0
|
||||
// Suspend: scale app and database to 0
|
||||
try {
|
||||
await this.kubernetesService.scaleDeployment(app, 0);
|
||||
await this.kubernetesService.suspendApplication(app);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to scale down ${app.name}: ${e.message}`);
|
||||
this.logger.warn(`Failed to suspend ${app.name} in K8s: ${e.message}`);
|
||||
}
|
||||
|
||||
app.lifecycleStatus = AppLifecycleStatus.SUSPENDED;
|
||||
|
||||
@@ -6,7 +6,7 @@ import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision } from '@/types';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap } from 'lucide-react';
|
||||
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
@@ -71,7 +71,17 @@ export default function AppDetailPage() {
|
||||
const [dbStorageSize, setDbStorageSize] = useState('1');
|
||||
const [dbStorageLoading, setDbStorageLoading] = useState(false);
|
||||
const [showSnapshots, setShowSnapshots] = useState(false);
|
||||
const [downloadingArtifact, setDownloadingArtifact] = useState<'source' | 'wp-content' | 'database' | null>(null);
|
||||
const [snapshotTab, setSnapshotTab] = useState<'revisions' | 'snapshots'>('revisions');
|
||||
const [showRenewalModal, setShowRenewalModal] = useState(false);
|
||||
const [selectedCycle, setSelectedCycle] = useState<'hourly' | 'monthly' | 'yearly'>('monthly');
|
||||
const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false);
|
||||
const [upgradeCostData, setUpgradeCostData] = useState<{
|
||||
proratedAmount: number;
|
||||
remainingHours: number;
|
||||
currentCost: { hourly: number };
|
||||
newCost: { hourly: number };
|
||||
} | null>(null);
|
||||
|
||||
const { data: app, isLoading } = useQuery<Application>({
|
||||
queryKey: ['application', appId],
|
||||
@@ -130,6 +140,46 @@ export default function AppDetailPage() {
|
||||
}
|
||||
}, [dbStorageData]);
|
||||
|
||||
// Fetch comprehensive storage usage (allocated/used/available)
|
||||
interface StorageUsageData {
|
||||
database: { allocated: number; used: number; available: number } | null;
|
||||
appStorage: { allocated: number; used: number; available: number } | null;
|
||||
}
|
||||
const { data: storageUsage, isLoading: storageUsageLoading } = useQuery<StorageUsageData>({
|
||||
queryKey: ['storage-usage', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}/storage`).then((r) => r.data),
|
||||
enabled: showResources && !!app,
|
||||
refetchInterval: showResources ? 15000 : false,
|
||||
});
|
||||
|
||||
// App storage expansion state
|
||||
const [appStorageSize, setAppStorageSize] = useState('2');
|
||||
const [showAppStorageExpand, setShowAppStorageExpand] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (app?.appStorageSize) {
|
||||
const sizeNum = parseInt(app.appStorageSize.replace('Gi', ''), 10) || 2;
|
||||
setAppStorageSize(String(sizeNum));
|
||||
}
|
||||
}, [app?.appStorageSize]);
|
||||
|
||||
const resizeAppStorageMutation = useMutation({
|
||||
mutationFn: (size: string) => api.patch(`/applications/${appId}/app-storage`, { size }),
|
||||
onSuccess: (res) => {
|
||||
if (res.data.success) {
|
||||
toast.success(res.data.message || 'App storage expanded!');
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
|
||||
setShowAppStorageExpand(false);
|
||||
} else {
|
||||
toast.error(res.data.message || 'Failed to expand storage');
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Failed to resize app storage');
|
||||
},
|
||||
});
|
||||
|
||||
const resizeDbMutation = useMutation({
|
||||
mutationFn: (size: string) => api.patch(`/applications/${appId}/db-storage`, { size }),
|
||||
onSuccess: (res) => {
|
||||
@@ -145,6 +195,37 @@ export default function AppDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Billing & Renewal ──────────────────────────────
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet'],
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: renewalCostData } = useQuery<{
|
||||
costs: { hourly: number; monthly: number; yearly: number; currentCycle?: string };
|
||||
}>({
|
||||
queryKey: ['renewal-cost', appId],
|
||||
queryFn: () => api.get(`/billing/applications/${appId}/renewal-cost`).then((r) => r.data),
|
||||
enabled: showRenewalModal || (app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion'),
|
||||
});
|
||||
|
||||
const renewMutation = useMutation({
|
||||
mutationFn: (cycle: string) => api.post(`/billing/applications/${appId}/renew`, { cycle }),
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.data.message || 'Application renewed successfully!');
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
||||
setShowRenewalModal(false);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Failed to renew application');
|
||||
},
|
||||
});
|
||||
|
||||
// Check if app needs renewal (expired or suspended)
|
||||
const needsRenewal = app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion';
|
||||
const isExpiringSoon = app?.planExpiresAt && new Date(app.planExpiresAt) <= new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
// ─── Snapshots ──────────────────────────────────────
|
||||
const { data: snapshots = [], isLoading: snapshotsLoading } = useQuery<AppSnapshot[]>({
|
||||
queryKey: ['snapshots', appId],
|
||||
@@ -251,10 +332,28 @@ export default function AppDetailPage() {
|
||||
};
|
||||
|
||||
const downloadCurrentArtifact = (artifact: 'source' | 'wp-content' | 'database') => {
|
||||
// Prevent duplicate downloads
|
||||
if (downloadingArtifact) {
|
||||
toast.warn('A download is already in progress. Please wait.');
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloadingArtifact(artifact);
|
||||
const url = `${api.defaults.baseURL}/snapshots/applications/${appId}/current/${artifact}`;
|
||||
const token = localStorage.getItem('accessToken');
|
||||
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
||||
const timeout = 15 * 60 * 1000; // 15 minutes
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
const artifactName = artifact === 'source' ? 'Source Code' : artifact === 'wp-content' ? 'wp-content' : 'Database';
|
||||
toast.info(`Downloading ${artifactName}... This may take up to 15 minutes for large files.`);
|
||||
|
||||
fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((r) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!r.ok) throw new Error('Not found');
|
||||
return r.blob();
|
||||
})
|
||||
@@ -265,8 +364,19 @@ export default function AppDetailPage() {
|
||||
link.download = `current-${artifact}${ext}`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
toast.success(`${artifactName} downloaded successfully!`);
|
||||
})
|
||||
.catch(() => toast.error(`Failed to download current ${artifact}`));
|
||||
.catch((err) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === 'AbortError') {
|
||||
toast.error(`Download timed out after 15 minutes. Try again or check server logs.`);
|
||||
} else {
|
||||
toast.error(`Failed to download current ${artifact}`);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setDownloadingArtifact(null);
|
||||
});
|
||||
};
|
||||
|
||||
const formatBytes = (bytes?: number) => {
|
||||
@@ -358,15 +468,45 @@ export default function AppDetailPage() {
|
||||
|
||||
const scaleMutation = useMutation({
|
||||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
||||
api.patch(`/applications/${appId}/resources`, data),
|
||||
onSuccess: () => {
|
||||
api.post(`/billing/applications/${appId}/upgrade`, data),
|
||||
onSuccess: (res) => {
|
||||
invalidateAll();
|
||||
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
||||
setShowUpgradeConfirm(false);
|
||||
setUpgradeCostData(null);
|
||||
const paidAmount = res.data.paidAmount || 0;
|
||||
if (paidAmount > 0) {
|
||||
toast.success(`Resources upgraded! Paid ${paidAmount.toLocaleString()} Toman`);
|
||||
} else {
|
||||
toast.success('Resources updated successfully!');
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('Failed to update resources'),
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to update resources'),
|
||||
});
|
||||
|
||||
// Calculate upgrade cost before applying
|
||||
const calculateUpgradeCostMutation = useMutation({
|
||||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
||||
api.post(`/billing/applications/${appId}/upgrade/calculate`, data),
|
||||
onSuccess: (res) => {
|
||||
setUpgradeCostData(res.data);
|
||||
setShowUpgradeConfirm(true);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to calculate upgrade cost'),
|
||||
});
|
||||
|
||||
// Handler to check upgrade cost before applying
|
||||
const handleScaleResources = () => {
|
||||
// If app doesn't have billing cycle (free/unmanaged), apply directly
|
||||
if (!app?.billingCycle) {
|
||||
scaleMutation.mutate(resourceForm);
|
||||
return;
|
||||
}
|
||||
// Otherwise, calculate cost first
|
||||
calculateUpgradeCostMutation.mutate(resourceForm);
|
||||
};
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () => api.get(`/applications/${appId}/preview`).then((r) => r.data),
|
||||
onSuccess: (data: { url: string; nodePort: number; host: string; ingressUrl?: string }) => {
|
||||
@@ -583,6 +723,293 @@ export default function AppDetailPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Renewal Banner for Expired/Suspended Apps */}
|
||||
{needsRenewal && (
|
||||
<div className={`rounded-xl p-4 border-2 ${
|
||||
app.lifecycleStatus === 'pending_deletion'
|
||||
? 'bg-red-50 border-red-300'
|
||||
: 'bg-amber-50 border-amber-300'
|
||||
}`}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
app.lifecycleStatus === 'pending_deletion' ? 'bg-red-100' : 'bg-amber-100'
|
||||
}`}>
|
||||
<AlertTriangle className={`w-6 h-6 ${
|
||||
app.lifecycleStatus === 'pending_deletion' ? 'text-red-600' : 'text-amber-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`font-semibold ${
|
||||
app.lifecycleStatus === 'pending_deletion' ? 'text-red-800' : 'text-amber-800'
|
||||
}`}>
|
||||
{app.lifecycleStatus === 'pending_deletion'
|
||||
? 'Application Scheduled for Deletion!'
|
||||
: 'Application Suspended — Payment Required'}
|
||||
</h3>
|
||||
<p className={`text-sm ${
|
||||
app.lifecycleStatus === 'pending_deletion' ? 'text-red-600' : 'text-amber-600'
|
||||
}`}>
|
||||
{app.lifecycleStatus === 'pending_deletion'
|
||||
? `This application will be permanently deleted on ${app.scheduledDeletionAt ? new Date(app.scheduledDeletionAt).toLocaleString() : 'soon'}. Renew now to prevent data loss.`
|
||||
: 'Your plan has expired. Renew to restore service access.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRenewalModal(true)}
|
||||
className={`px-6 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
|
||||
app.lifecycleStatus === 'pending_deletion'
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-amber-600 text-white hover:bg-amber-700'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Renew Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expiring Soon Warning */}
|
||||
{!needsRenewal && isExpiringSoon && app.planExpiresAt && (
|
||||
<div className="rounded-xl p-4 border bg-blue-50 border-blue-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-blue-800">Plan Expiring Soon</h3>
|
||||
<p className="text-sm text-blue-600">
|
||||
Your plan expires on {new Date(app.planExpiresAt).toLocaleString()}. Renew early to avoid service interruption.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRenewalModal(true)}
|
||||
className="px-4 py-2 rounded-lg font-medium bg-blue-600 text-white hover:bg-blue-700 transition-all flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Extend Plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Renewal Modal */}
|
||||
{showRenewalModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 animate-fade-in">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Renew Application</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">Select a billing cycle to renew "{app.name}"</p>
|
||||
|
||||
{/* Wallet Balance */}
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Wallet className="w-5 h-5 text-gray-400" />
|
||||
<span className="text-sm text-gray-600">Wallet Balance</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
{walletData?.balance?.toLocaleString() || 0} Toman
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Billing Cycle Selection */}
|
||||
<div className="space-y-3 mb-6">
|
||||
{renewalCostData?.costs && (
|
||||
<>
|
||||
<label
|
||||
className={`flex items-center justify-between p-4 border-2 rounded-xl cursor-pointer transition-all ${
|
||||
selectedCycle === 'hourly' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => setSelectedCycle('hourly')}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="radio" checked={selectedCycle === 'hourly'} onChange={() => {}} className="w-4 h-4 text-primary-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Hourly</p>
|
||||
<p className="text-xs text-gray-500">Pay as you go, auto-renews</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-bold text-gray-900">{renewalCostData.costs.hourly.toLocaleString()} Toman</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={`flex items-center justify-between p-4 border-2 rounded-xl cursor-pointer transition-all ${
|
||||
selectedCycle === 'monthly' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => setSelectedCycle('monthly')}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="radio" checked={selectedCycle === 'monthly'} onChange={() => {}} className="w-4 h-4 text-primary-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Monthly</p>
|
||||
<p className="text-xs text-gray-500">Best for most users</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-bold text-gray-900">{renewalCostData.costs.monthly.toLocaleString()} Toman</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={`flex items-center justify-between p-4 border-2 rounded-xl cursor-pointer transition-all ${
|
||||
selectedCycle === 'yearly' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => setSelectedCycle('yearly')}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="radio" checked={selectedCycle === 'yearly'} onChange={() => {}} className="w-4 h-4 text-primary-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Yearly</p>
|
||||
<p className="text-xs text-green-600">Save up to 20%</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-bold text-gray-900">{renewalCostData.costs.yearly.toLocaleString()} Toman</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Insufficient Balance Warning */}
|
||||
{renewalCostData?.costs && walletData && (
|
||||
(() => {
|
||||
const cost = selectedCycle === 'hourly' ? renewalCostData.costs.hourly
|
||||
: selectedCycle === 'monthly' ? renewalCostData.costs.monthly
|
||||
: renewalCostData.costs.yearly;
|
||||
if (walletData.balance < cost) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-red-700">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
Insufficient balance. Please charge your wallet first.
|
||||
<a href="/dashboard/wallet" className="underline ml-1 font-medium">Go to Wallet</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowRenewalModal(false)}
|
||||
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl font-medium text-gray-700 hover:bg-gray-50 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => renewMutation.mutate(selectedCycle)}
|
||||
disabled={renewMutation.isPending || !renewalCostData?.costs || (walletData && renewalCostData?.costs && (
|
||||
(selectedCycle === 'hourly' && walletData.balance < renewalCostData.costs.hourly) ||
|
||||
(selectedCycle === 'monthly' && walletData.balance < renewalCostData.costs.monthly) ||
|
||||
(selectedCycle === 'yearly' && walletData.balance < renewalCostData.costs.yearly)
|
||||
))}
|
||||
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{renewMutation.isPending ? (
|
||||
<><Clock className="w-4 h-4 animate-spin" /> Processing...</>
|
||||
) : (
|
||||
<><CreditCard className="w-4 h-4" /> Pay & Renew</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upgrade Confirmation Modal */}
|
||||
{showUpgradeConfirm && upgradeCostData && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 animate-fade-in">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Confirm Resource Upgrade</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
{upgradeCostData.proratedAmount > 0
|
||||
? 'This upgrade requires payment for the remaining billing period.'
|
||||
: 'No additional cost for this change.'}
|
||||
</p>
|
||||
|
||||
{/* Cost Summary */}
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Current hourly cost</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{upgradeCostData.currentCost.hourly.toLocaleString()} Toman/hour
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">New hourly cost</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{upgradeCostData.newCost.hourly.toLocaleString()} Toman/hour
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Remaining hours in period</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{upgradeCostData.remainingHours} hours
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t pt-3 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-700">Prorated amount to pay</span>
|
||||
<span className="text-lg font-bold text-primary-600">
|
||||
{upgradeCostData.proratedAmount.toLocaleString()} Toman
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wallet Balance */}
|
||||
<div className="bg-blue-50 rounded-xl p-4 mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Wallet className="w-5 h-5 text-blue-500" />
|
||||
<span className="text-sm text-blue-700">Wallet Balance</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-blue-900">
|
||||
{walletData?.balance?.toLocaleString() || 0} Toman
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Insufficient Balance Warning */}
|
||||
{walletData && upgradeCostData.proratedAmount > walletData.balance && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-red-700">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
Insufficient balance. Please charge your wallet first.
|
||||
<a href="/dashboard/wallet" className="underline ml-1 font-medium">Go to Wallet</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUpgradeConfirm(false);
|
||||
setUpgradeCostData(null);
|
||||
}}
|
||||
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl font-medium text-gray-700 hover:bg-gray-50 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scaleMutation.mutate(resourceForm)}
|
||||
disabled={scaleMutation.isPending || (walletData && upgradeCostData.proratedAmount > walletData.balance)}
|
||||
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{scaleMutation.isPending ? (
|
||||
<><Clock className="w-4 h-4 animate-spin" /> Applying...</>
|
||||
) : upgradeCostData.proratedAmount > 0 ? (
|
||||
<><CreditCard className="w-4 h-4" /> Pay & Upgrade</>
|
||||
) : (
|
||||
<><CheckCircle className="w-4 h-4" /> Apply Changes</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status & Config */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
@@ -898,7 +1325,7 @@ export default function AppDetailPage() {
|
||||
{resizeDbMutation.isPending ? 'Expanding...' : 'Expand'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">فقط امکان افزایش حجم وجود دارد (کاهش ممکن نیست)</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Only expansion is allowed (shrinking is not possible)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1107,6 +1534,155 @@ export default function AppDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Storage Usage Section */}
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
|
||||
<Database className="w-4 h-4" /> Storage Usage
|
||||
</h3>
|
||||
{storageUsageLoading ? (
|
||||
<div className="text-center py-4 text-gray-400 text-sm">Loading storage metrics...</div>
|
||||
) : storageUsage ? (
|
||||
<div className="space-y-4">
|
||||
{/* Database Storage */}
|
||||
{storageUsage.database && (
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">Database Storage</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{(storageUsage.database.used / (1024 * 1024 * 1024)).toFixed(2)} GB / {(storageUsage.database.allocated / (1024 * 1024 * 1024)).toFixed(1)} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-500 ${
|
||||
(storageUsage.database.used / storageUsage.database.allocated) * 100 > 80
|
||||
? 'bg-red-500'
|
||||
: (storageUsage.database.used / storageUsage.database.allocated) * 100 > 50
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min((storageUsage.database.used / storageUsage.database.allocated) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||
<span>Used: {(storageUsage.database.used / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
||||
<span>Available: {(storageUsage.database.available / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* App Storage (WordPress wp-content) */}
|
||||
{storageUsage.appStorage && (
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
{app?.runtime === 'wordpress' ? 'wp-content Storage' : 'App Storage'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{(storageUsage.appStorage.used / (1024 * 1024 * 1024)).toFixed(2)} GB / {(storageUsage.appStorage.allocated / (1024 * 1024 * 1024)).toFixed(1)} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-500 ${
|
||||
(storageUsage.appStorage.used / storageUsage.appStorage.allocated) * 100 > 80
|
||||
? 'bg-red-500'
|
||||
: (storageUsage.appStorage.used / storageUsage.appStorage.allocated) * 100 > 50
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min((storageUsage.appStorage.used / storageUsage.appStorage.allocated) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||
<span>Used: {(storageUsage.appStorage.used / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
||||
<span>Available: {(storageUsage.appStorage.available / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
||||
</div>
|
||||
|
||||
{/* Expand App Storage (all app types) */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
{showAppStorageExpand ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(appStorageSize, 10);
|
||||
const min = parseInt((app?.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2;
|
||||
if (current > min + 1) setAppStorageSize(String(current - 1));
|
||||
}}
|
||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs transition-colors"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
value={appStorageSize}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(2, Math.min(100, parseInt(e.target.value, 10) || 2));
|
||||
setAppStorageSize(String(val));
|
||||
}}
|
||||
className="w-12 text-center py-1 border-x border-gray-300 text-xs font-semibold focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(appStorageSize, 10);
|
||||
if (current < 100) setAppStorageSize(String(current + 1));
|
||||
}}
|
||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600">GB</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newSize = `${parseInt(appStorageSize, 10)}Gi`;
|
||||
resizeAppStorageMutation.mutate(newSize);
|
||||
}}
|
||||
disabled={
|
||||
resizeAppStorageMutation.isPending ||
|
||||
parseInt(appStorageSize, 10) <= (parseInt((app?.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2)
|
||||
}
|
||||
className="btn-primary text-xs px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
{resizeAppStorageMutation.isPending ? 'Expanding...' : 'Expand'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAppStorageExpand(false)}
|
||||
className="btn-secondary text-xs px-2 py-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAppStorageExpand(true)}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
|
||||
>
|
||||
<Scale className="w-3 h-3" /> Expand Storage
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-1">Only expansion is allowed</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!storageUsage.database && !storageUsage.appStorage && (
|
||||
<p className="text-sm text-gray-400 text-center py-4">No storage data available</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 text-center py-4">Storage metrics unavailable</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scaling Controls */}
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1"><Settings className="w-4 h-4" /> Scale Resources</h3>
|
||||
@@ -1171,11 +1747,11 @@ export default function AppDetailPage() {
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={() => scaleMutation.mutate(resourceForm)}
|
||||
disabled={scaleMutation.isPending}
|
||||
onClick={handleScaleResources}
|
||||
disabled={scaleMutation.isPending || calculateUpgradeCostMutation.isPending}
|
||||
className="btn-primary text-sm w-full disabled:opacity-50"
|
||||
>
|
||||
{scaleMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /> Applying...</> : <><RefreshCw className="w-3 h-3 inline" /> Apply Changes</>}
|
||||
{scaleMutation.isPending || calculateUpgradeCostMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /> Calculating...</> : <><RefreshCw className="w-3 h-3 inline" /> Apply Changes</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1303,30 +1879,45 @@ export default function AppDetailPage() {
|
||||
<h3 className="text-sm font-semibold text-blue-800 mb-3 flex items-center gap-2">
|
||||
<Download className="w-4 h-4" /> Download Current State
|
||||
</h3>
|
||||
<p className="text-xs text-blue-600 mb-3">Download a copy of the current live files without creating a snapshot.</p>
|
||||
<p className="text-xs text-blue-600 mb-3">Download a copy of the current live files without creating a snapshot. Large files may take up to 15 minutes.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{app.codePath && (
|
||||
<button
|
||||
onClick={() => downloadCurrentArtifact('source')}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-white border border-blue-200 text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-1"
|
||||
disabled={!!downloadingArtifact}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-white border border-blue-200 text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Archive className="w-3 h-3" /> Source Code
|
||||
{downloadingArtifact === 'source' ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> Downloading...</>
|
||||
) : (
|
||||
<><Archive className="w-3 h-3" /> Source Code</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{app.runtime === 'wordpress' && (
|
||||
{app.runtime?.toLowerCase() === 'wordpress' && (
|
||||
<button
|
||||
onClick={() => downloadCurrentArtifact('wp-content')}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-white border border-blue-200 text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-1"
|
||||
disabled={!!downloadingArtifact}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-white border border-blue-200 text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Archive className="w-3 h-3" /> wp-content
|
||||
{downloadingArtifact === 'wp-content' ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> Downloading...</>
|
||||
) : (
|
||||
<><Archive className="w-3 h-3" /> wp-content</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{app.databaseType !== 'none' && (
|
||||
{app.databaseType && app.databaseType.toLowerCase() !== 'none' && (
|
||||
<button
|
||||
onClick={() => downloadCurrentArtifact('database')}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-white border border-blue-200 text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-1"
|
||||
disabled={!!downloadingArtifact}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-white border border-blue-200 text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Database className="w-3 h-3" /> Database Dump
|
||||
{downloadingArtifact === 'database' ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> Downloading...</>
|
||||
) : (
|
||||
<><Database className="w-3 h-3" /> Database Dump</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,10 @@ export default function DeployPage() {
|
||||
replicas: 1,
|
||||
port: 3000,
|
||||
dbStorageSize: '1',
|
||||
appStorageSize: '2',
|
||||
enableRedis: false,
|
||||
enableRabbitmq: false,
|
||||
enableElasticsearch: false,
|
||||
});
|
||||
const [envKey, setEnvKey] = useState('');
|
||||
const [envVal, setEnvVal] = useState('');
|
||||
@@ -84,7 +88,7 @@ export default function DeployPage() {
|
||||
|
||||
// Cost calculation for the review step
|
||||
const { data: costData, isLoading: costLoading } = useQuery<CostBreakdown>({
|
||||
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize],
|
||||
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch],
|
||||
queryFn: () => api.post('/billing/calculate', {
|
||||
runtime: form.runtime,
|
||||
databaseType: form.databaseType,
|
||||
@@ -92,6 +96,10 @@ export default function DeployPage() {
|
||||
memoryLimit: form.memoryLimit,
|
||||
replicas: form.replicas,
|
||||
dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}` : undefined,
|
||||
appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}`,
|
||||
enableRedis: form.enableRedis,
|
||||
enableRabbitmq: form.enableRabbitmq,
|
||||
enableElasticsearch: form.enableElasticsearch,
|
||||
}).then((r) => r.data),
|
||||
enabled: step === 3,
|
||||
});
|
||||
@@ -115,6 +123,9 @@ export default function DeployPage() {
|
||||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
||||
}
|
||||
if (payload.appStorageSize) {
|
||||
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
|
||||
}
|
||||
const res = await api.post('/applications', payload);
|
||||
const appId = res.data.id;
|
||||
|
||||
@@ -188,6 +199,9 @@ export default function DeployPage() {
|
||||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
||||
}
|
||||
if (payload.appStorageSize) {
|
||||
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
|
||||
}
|
||||
const res = await api.post('/applications', payload);
|
||||
const appId = res.data.id;
|
||||
|
||||
@@ -312,6 +326,10 @@ export default function DeployPage() {
|
||||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
||||
}
|
||||
// Format appStorageSize with Gi suffix (all app types)
|
||||
if (payload.appStorageSize) {
|
||||
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
|
||||
}
|
||||
createMutation.mutate(payload);
|
||||
};
|
||||
|
||||
@@ -361,6 +379,10 @@ export default function DeployPage() {
|
||||
return;
|
||||
}
|
||||
setWpContentFile(file);
|
||||
// Auto-suggest app storage size based on file size (add 50% buffer, minimum 2GB)
|
||||
const fileSizeGb = file.size / (1024 * 1024 * 1024);
|
||||
const suggestedSize = Math.max(2, Math.ceil(fileSizeGb * 1.5));
|
||||
setForm((prev) => ({ ...prev, appStorageSize: String(suggestedSize) }));
|
||||
toast.success(`WordPress files selected: ${file.name}`);
|
||||
}, []);
|
||||
|
||||
@@ -451,30 +473,40 @@ export default function DeployPage() {
|
||||
{/* Application Runtime */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Application Type</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ value: 'nodejs', label: 'Node.js', icon: <Hexagon className="w-6 h-6 text-green-500" />, desc: 'Express, NestJS, Fastify...' },
|
||||
{ value: 'laravel', label: 'Laravel', icon: <Hexagon className="w-6 h-6 text-orange-500" />, desc: 'PHP, Composer, Artisan' },
|
||||
{ value: 'wordpress', label: 'WordPress', icon: <Hexagon className="w-6 h-6 text-blue-600" />, desc: 'Official image, wp-content' },
|
||||
{ value: 'nodejs', label: 'Node.js', icon: <Hexagon className="w-5 h-5 text-green-500" />, desc: 'Express, NestJS, Fastify' },
|
||||
{ value: 'laravel', label: 'Laravel', icon: <Hexagon className="w-5 h-5 text-orange-500" />, desc: 'PHP, Composer, Artisan' },
|
||||
{ value: 'wordpress', label: 'WordPress', icon: <Hexagon className="w-5 h-5 text-blue-600" />, desc: 'Official image' },
|
||||
{ value: 'go', label: 'Go', icon: <Hexagon className="w-5 h-5 text-cyan-500" />, desc: 'Gin, Echo, Fiber' },
|
||||
{ value: 'python', label: 'Python', icon: <Hexagon className="w-5 h-5 text-yellow-500" />, desc: 'Flask, FastAPI' },
|
||||
{ value: 'django', label: 'Django', icon: <Hexagon className="w-5 h-5 text-green-700" />, desc: 'Python web framework' },
|
||||
{ value: 'php', label: 'PHP', icon: <Hexagon className="w-5 h-5 text-indigo-500" />, desc: 'Plain PHP apps' },
|
||||
{ value: 'dotnet', label: '.NET', icon: <Hexagon className="w-5 h-5 text-purple-500" />, desc: 'ASP.NET Core' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updates: any = { runtime: opt.value as any, phpVersion: '' };
|
||||
const updates: any = { runtime: opt.value as any, phpVersion: '', runtimeVersion: '' };
|
||||
if (opt.value === 'nodejs') { updates.port = 3000; updates.runtimeVersion = '20'; }
|
||||
else if (opt.value === 'laravel') { updates.port = 8000; updates.runtimeVersion = ''; updates.phpVersion = '8.3'; }
|
||||
else if (opt.value === 'laravel') { updates.port = 8000; updates.phpVersion = '8.3'; }
|
||||
else if (opt.value === 'wordpress') { updates.port = 80; updates.databaseType = 'mysql'; updates.runtimeVersion = '6.7'; updates.phpVersion = '8.3'; }
|
||||
else if (opt.value === 'go') { updates.port = 8080; updates.runtimeVersion = '1.22'; }
|
||||
else if (opt.value === 'python') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
|
||||
else if (opt.value === 'django') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
|
||||
else if (opt.value === 'php') { updates.port = 80; updates.phpVersion = '8.3'; }
|
||||
else if (opt.value === 'dotnet') { updates.port = 5000; updates.runtimeVersion = '8.0'; }
|
||||
setForm({ ...form, ...updates });
|
||||
// Reset WordPress-specific state when switching types
|
||||
if (opt.value !== 'wordpress') { setWpMode('fresh'); setWpContentFile(null); }
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-colors ${
|
||||
className={`p-3 rounded-xl border-2 text-left transition-colors ${
|
||||
form.runtime === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{opt.icon}
|
||||
<p className="mt-2 font-semibold text-gray-900">{opt.label}</p>
|
||||
<p className="mt-1.5 font-semibold text-sm text-gray-900">{opt.label}</p>
|
||||
<p className="text-xs text-gray-500">{opt.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
@@ -898,29 +930,97 @@ export default function DeployPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{form.runtime === 'go' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Go Version</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.runtimeVersion || '1.22'}
|
||||
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
|
||||
>
|
||||
<option value="1.23">Go 1.23 (Latest)</option>
|
||||
<option value="1.22">Go 1.22 (Recommended)</option>
|
||||
<option value="1.21">Go 1.21</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{(form.runtime === 'python' || form.runtime === 'django') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Python Version</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.runtimeVersion || '3.12'}
|
||||
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
|
||||
>
|
||||
<option value="3.13">Python 3.13 (Latest)</option>
|
||||
<option value="3.12">Python 3.12 (Recommended)</option>
|
||||
<option value="3.11">Python 3.11</option>
|
||||
<option value="3.10">Python 3.10</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{form.runtime === 'php' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">PHP Version</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.phpVersion || '8.3'}
|
||||
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })}
|
||||
>
|
||||
<option value="8.4">PHP 8.4 (Latest)</option>
|
||||
<option value="8.3">PHP 8.3 (Recommended)</option>
|
||||
<option value="8.2">PHP 8.2</option>
|
||||
<option value="8.1">PHP 8.1</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{form.runtime === 'dotnet' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">.NET Version</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.runtimeVersion || '8.0'}
|
||||
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
|
||||
>
|
||||
<option value="9.0">.NET 9.0 (Latest)</option>
|
||||
<option value="8.0">.NET 8.0 LTS (Recommended)</option>
|
||||
<option value="7.0">.NET 7.0</option>
|
||||
<option value="6.0">.NET 6.0 LTS</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Database
|
||||
{form.runtime === 'wordpress' && (
|
||||
<span className="text-xs text-blue-500 mr-2"> — وردپرس به MySQL نیاز دارد</span>
|
||||
<span className="text-xs text-blue-500 mr-2"> — WordPress requires MySQL/MariaDB</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3 sm:gap-4">
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-3 sm:gap-4">
|
||||
{[
|
||||
{ value: 'none', label: 'None', icon: <XCircle className="w-6 h-6 text-gray-400" /> },
|
||||
{ value: 'postgresql', label: 'PostgreSQL', icon: <svg className="w-6 h-6 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
|
||||
{ value: 'mysql', label: 'MySQL', icon: <svg className="w-6 h-6 text-orange-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
|
||||
{ value: 'mariadb', label: 'MariaDB', icon: <svg className="w-6 h-6 text-teal-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
|
||||
{ value: 'mongodb', label: 'MongoDB', icon: <svg className="w-6 h-6 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L12 22M12 2C7.58 4 5 8 5 12C5 16 7.58 20 12 22M12 2C16.42 4 19 8 19 12C19 16 16.42 20 12 22"/></svg> },
|
||||
].map((opt) => {
|
||||
const isWordPress = form.runtime === 'wordpress';
|
||||
const disabled = isWordPress && opt.value !== 'mysql';
|
||||
const disabled = isWordPress && !['mysql', 'mariadb'].includes(opt.value);
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setForm({ ...form, databaseType: opt.value as any, dbVersion: opt.value === 'postgresql' ? '16' : opt.value === 'mysql' ? '8.0' : '' })}
|
||||
onClick={() => {
|
||||
let dbVersion = '';
|
||||
if (opt.value === 'postgresql') dbVersion = '16';
|
||||
else if (opt.value === 'mysql') dbVersion = '8.0';
|
||||
else if (opt.value === 'mariadb') dbVersion = '11.4';
|
||||
else if (opt.value === 'mongodb') dbVersion = '7.0';
|
||||
setForm({ ...form, databaseType: opt.value as any, dbVersion });
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 text-center transition-colors ${
|
||||
form.databaseType === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
@@ -937,11 +1037,11 @@ export default function DeployPage() {
|
||||
{form.databaseType !== 'none' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{form.databaseType === 'postgresql' ? 'PostgreSQL' : 'MySQL'} Version
|
||||
{form.databaseType === 'postgresql' ? 'PostgreSQL' : form.databaseType === 'mysql' ? 'MySQL' : form.databaseType === 'mariadb' ? 'MariaDB' : 'MongoDB'} Version
|
||||
</label>
|
||||
<select
|
||||
className="input-field max-w-xs"
|
||||
value={form.dbVersion || (form.databaseType === 'postgresql' ? '16' : '8.0')}
|
||||
value={form.dbVersion || (form.databaseType === 'postgresql' ? '16' : form.databaseType === 'mysql' ? '8.0' : form.databaseType === 'mariadb' ? '11.4' : '7.0')}
|
||||
onChange={(e) => setForm({ ...form, dbVersion: e.target.value })}
|
||||
>
|
||||
{form.databaseType === 'postgresql' ? (
|
||||
@@ -951,13 +1051,26 @@ export default function DeployPage() {
|
||||
<option value="15">PostgreSQL 15</option>
|
||||
<option value="14">PostgreSQL 14</option>
|
||||
</>
|
||||
) : (
|
||||
) : form.databaseType === 'mysql' ? (
|
||||
<>
|
||||
<option value="9.0">MySQL 9.0 (Latest)</option>
|
||||
<option value="8.4">MySQL 8.4 (LTS)</option>
|
||||
<option value="8.0">MySQL 8.0</option>
|
||||
<option value="5.7">MySQL 5.7</option>
|
||||
</>
|
||||
) : form.databaseType === 'mariadb' ? (
|
||||
<>
|
||||
<option value="11.4">MariaDB 11.4 (LTS)</option>
|
||||
<option value="11.3">MariaDB 11.3</option>
|
||||
<option value="10.11">MariaDB 10.11 (LTS)</option>
|
||||
<option value="10.6">MariaDB 10.6</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="7.0">MongoDB 7.0 (Latest)</option>
|
||||
<option value="6.0">MongoDB 6.0</option>
|
||||
<option value="5.0">MongoDB 5.0</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -1126,14 +1239,177 @@ export default function DeployPage() {
|
||||
<span className="text-sm font-medium text-gray-700">GB</span>
|
||||
{dbDumpFile && (
|
||||
<span className="text-xs text-blue-500">
|
||||
پیشنهاد بر اساس حجم دامپ ({(dbDumpFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
|
||||
Suggested based on dump size ({(dbDumpFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">حداقل ۱ گیگابایت • بعد از ساخت فقط امکان افزایش حجم وجود دارد</p>
|
||||
<p className="mt-1 text-xs text-gray-400">Minimum 1GB • Only expansion is allowed after creation</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional Services Section */}
|
||||
<div className="bg-purple-50 rounded-xl p-5 border border-purple-200">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Server className="w-5 h-5 text-purple-600" />
|
||||
<h3 className="font-semibold text-gray-900">Optional Services</h3>
|
||||
<span className="text-xs text-gray-400">(Enable additional services for your app)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* Redis */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, enableRedis: !form.enableRedis })}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
form.enableRedis
|
||||
? 'border-red-400 bg-red-50 shadow-sm'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRedis ? 'bg-red-100' : 'bg-gray-100'}`}>
|
||||
<svg className={`w-6 h-6 ${form.enableRedis ? 'text-red-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2ZM2 17L12 22L22 17M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Redis</p>
|
||||
<p className="text-xs text-gray-500">In-memory cache & store</p>
|
||||
</div>
|
||||
</div>
|
||||
{form.enableRedis && (
|
||||
<div className="mt-3 pt-3 border-t border-red-200 text-xs text-red-600">
|
||||
<p>REDIS_HOST, REDIS_PASSWORD, REDIS_URL will be available</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* RabbitMQ */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, enableRabbitmq: !form.enableRabbitmq })}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
form.enableRabbitmq
|
||||
? 'border-orange-400 bg-orange-50 shadow-sm'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRabbitmq ? 'bg-orange-100' : 'bg-gray-100'}`}>
|
||||
<svg className={`w-6 h-6 ${form.enableRabbitmq ? 'text-orange-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 3H3v18h18V3zM8 17H5v-3h3v3zm5-4h-3v-3h3v3zm5 0h-3v-3h3v3zm0-4h-8V6h8v3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">RabbitMQ</p>
|
||||
<p className="text-xs text-gray-500">Message broker</p>
|
||||
</div>
|
||||
</div>
|
||||
{form.enableRabbitmq && (
|
||||
<div className="mt-3 pt-3 border-t border-orange-200 text-xs text-orange-600">
|
||||
<p>RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL will be available</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Elasticsearch */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, enableElasticsearch: !form.enableElasticsearch })}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
form.enableElasticsearch
|
||||
? 'border-yellow-400 bg-yellow-50 shadow-sm'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableElasticsearch ? 'bg-yellow-100' : 'bg-gray-100'}`}>
|
||||
<svg className={`w-6 h-6 ${form.enableElasticsearch ? 'text-yellow-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" stroke="currentColor" strokeWidth="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Elasticsearch</p>
|
||||
<p className="text-xs text-gray-500">Logging & search</p>
|
||||
</div>
|
||||
</div>
|
||||
{form.enableElasticsearch && (
|
||||
<div className="mt-3 pt-3 border-t border-yellow-200 text-xs text-yellow-600">
|
||||
<p>Logs collected via Fluent Bit sidecar</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
|
||||
<p className="mt-4 text-xs text-gray-500">
|
||||
Each enabled service adds to the monthly cost. Services are deployed in your namespace and not shared.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* App Storage Size (all app types) */}
|
||||
<div className="bg-green-50 rounded-xl p-5 border border-green-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FolderUp className="w-5 h-5 text-green-600" />
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{form.runtime === 'wordpress' ? 'Upload Storage (wp-content)'
|
||||
: form.runtime === 'laravel' ? 'Storage Directory'
|
||||
: 'Application Data Storage'}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{form.runtime === 'wordpress'
|
||||
? 'This space is used for uploads, plugins, themes, and other WordPress files.'
|
||||
: form.runtime === 'laravel'
|
||||
? 'This space is used for uploads, logs, cache, and other Laravel storage files.'
|
||||
: 'This space is used for persistent application data, uploads, and files.'}
|
||||
</p>
|
||||
<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"
|
||||
onClick={() => {
|
||||
const current = parseInt(form.appStorageSize || '2', 10);
|
||||
if (current > 1) setForm({ ...form, appStorageSize: String(current - 1) });
|
||||
}}
|
||||
disabled={parseInt(form.appStorageSize || '2', 10) <= 1}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.appStorageSize || '2'}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 2));
|
||||
setForm({ ...form, appStorageSize: String(val) });
|
||||
}}
|
||||
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(form.appStorageSize || '2', 10);
|
||||
if (current < 100) setForm({ ...form, appStorageSize: String(current + 1) });
|
||||
}}
|
||||
disabled={parseInt(form.appStorageSize || '2', 10) >= 100}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">GB</span>
|
||||
{wpContentFile && form.runtime === 'wordpress' && (
|
||||
<span className="text-xs text-green-600">
|
||||
Suggested based on wp-content ({(wpContentFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Minimum 1GB • Recommended: 2GB or more</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1509,6 +1785,18 @@ export default function DeployPage() {
|
||||
<span className="text-sm text-gray-500">Port</span>
|
||||
<span className="text-sm font-medium">{form.port}</span>
|
||||
</div>
|
||||
{(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">
|
||||
{[
|
||||
form.enableRedis && 'Redis',
|
||||
form.enableRabbitmq && 'RabbitMQ',
|
||||
form.enableElasticsearch && 'Elasticsearch',
|
||||
].filter(Boolean).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(form.envVars || {}).length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Env Vars</span>
|
||||
|
||||
@@ -13,14 +13,15 @@ export interface Application {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
runtime: 'nodejs' | 'laravel' | 'wordpress';
|
||||
databaseType: 'mysql' | 'postgresql' | 'none';
|
||||
runtime: 'nodejs' | 'laravel' | 'wordpress' | 'go' | 'php' | 'python' | 'django' | 'dotnet';
|
||||
databaseType: 'mysql' | 'postgresql' | 'mongodb' | 'mariadb' | 'none';
|
||||
runtimeVersion?: string;
|
||||
phpVersion?: string;
|
||||
dbVersion?: string;
|
||||
dbUsername?: string;
|
||||
dbPassword?: string;
|
||||
dbStorageSize?: string;
|
||||
appStorageSize?: string;
|
||||
gitUrl?: string;
|
||||
gitToken?: string;
|
||||
gitBranch?: string;
|
||||
@@ -39,6 +40,11 @@ export interface Application {
|
||||
latestImageTag?: string;
|
||||
subdomain?: string;
|
||||
deployments?: Deployment[];
|
||||
// Optional services
|
||||
enableRedis?: boolean;
|
||||
enableRabbitmq?: boolean;
|
||||
enableElasticsearch?: boolean;
|
||||
logPaths?: string[];
|
||||
// Billing & Lifecycle
|
||||
planId?: string;
|
||||
billingCycle?: BillingCycle;
|
||||
@@ -100,14 +106,15 @@ export interface AuthResponse {
|
||||
export interface CreateApplicationDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
runtime: 'nodejs' | 'laravel' | 'wordpress';
|
||||
databaseType: 'mysql' | 'postgresql' | 'none';
|
||||
runtime: 'nodejs' | 'laravel' | 'wordpress' | 'go' | 'php' | 'python' | 'django' | 'dotnet';
|
||||
databaseType: 'mysql' | 'postgresql' | 'mongodb' | 'mariadb' | 'none';
|
||||
runtimeVersion?: string;
|
||||
phpVersion?: string;
|
||||
dbVersion?: string;
|
||||
dbUsername?: string;
|
||||
dbPassword?: string;
|
||||
dbStorageSize?: string;
|
||||
appStorageSize?: string;
|
||||
gitUrl?: string;
|
||||
gitToken?: string;
|
||||
gitBranch?: string;
|
||||
@@ -120,6 +127,11 @@ export interface CreateApplicationDto {
|
||||
port?: number;
|
||||
clusterId?: string;
|
||||
poolId?: string;
|
||||
// Optional services
|
||||
enableRedis?: boolean;
|
||||
enableRabbitmq?: boolean;
|
||||
enableElasticsearch?: boolean;
|
||||
logPaths?: string[];
|
||||
}
|
||||
|
||||
export interface ClusterPublic {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user