feat: add custom domain support with SSL, DNS verification, and billing
Users can assign a custom domain to their app with automatic SSL via cert-manager. Includes DNS verification flow (CNAME check), Persian instructions, admin-configurable pricing via PlatformSetting, and integration into the deploy wizard cost calculation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,8 +24,23 @@ spec:
|
|||||||
name: {{ $name }}
|
name: {{ $name }}
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
|
{{- if .Values.ingress.customDomain }}
|
||||||
|
- host: {{ .Values.ingress.customDomain }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ $name }}
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
{{- end }}
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- {{ $host }}
|
- {{ $host }}
|
||||||
|
{{- if .Values.ingress.customDomain }}
|
||||||
|
- {{ .Values.ingress.customDomain }}
|
||||||
|
{{- end }}
|
||||||
secretName: {{ $name }}-tls
|
secretName: {{ $name }}-tls
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ ingress:
|
|||||||
subdomain: "" # <subdomain>.<domain>
|
subdomain: "" # <subdomain>.<domain>
|
||||||
domain: "apps.cloudhost.ir"
|
domain: "apps.cloudhost.ir"
|
||||||
clusterIssuer: letsencrypt-prod
|
clusterIssuer: letsencrypt-prod
|
||||||
|
customDomain: "" # Optional custom domain (e.g. "www.example.com")
|
||||||
|
|
||||||
# ── Database (MySQL or PostgreSQL) ───────────────────────
|
# ── Database (MySQL or PostgreSQL) ───────────────────────
|
||||||
database:
|
database:
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||||
import { ApplicationsService } from './applications.service';
|
import { ApplicationsService } from './applications.service';
|
||||||
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto } from './dto/application.dto';
|
import { DomainService } from './domain.service';
|
||||||
|
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto } from './dto/application.dto';
|
||||||
import { RolesGuard } from '../common/guards/roles.guard';
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
import { Roles } from '../common/decorators/roles.decorator';
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
import { UserRole, DatabaseType } from '../common/enums';
|
import { UserRole, DatabaseType } from '../common/enums';
|
||||||
@@ -36,6 +37,7 @@ export class ApplicationsController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly applicationsService: ApplicationsService,
|
private readonly applicationsService: ApplicationsService,
|
||||||
|
private readonly domainService: DomainService,
|
||||||
private readonly kubernetesService: KubernetesService,
|
private readonly kubernetesService: KubernetesService,
|
||||||
@Inject(forwardRef(() => DeploymentsService))
|
@Inject(forwardRef(() => DeploymentsService))
|
||||||
private readonly deploymentsService: DeploymentsService,
|
private readonly deploymentsService: DeploymentsService,
|
||||||
@@ -263,6 +265,69 @@ export class ApplicationsController {
|
|||||||
return this.kubernetesService.getPreviewInfo(app);
|
return this.kubernetesService.getPreviewInfo(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Custom Domain ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get(':id/domain')
|
||||||
|
@ApiOperation({ summary: 'Get custom domain info and DNS setup instructions' })
|
||||||
|
async getDomainInfo(@Param('id') id: string, @Request() req: any) {
|
||||||
|
const userId = (req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL)
|
||||||
|
? (await this.applicationsService.findOne(id)).userId
|
||||||
|
: req.user.id;
|
||||||
|
return this.domainService.getDomainInfo(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/domain')
|
||||||
|
@ApiOperation({ summary: 'Set a custom domain for the application' })
|
||||||
|
async setCustomDomain(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Request() req: any,
|
||||||
|
@Body() dto: SetCustomDomainDto,
|
||||||
|
) {
|
||||||
|
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||||
|
const userId = isStaff
|
||||||
|
? (await this.applicationsService.findOne(id)).userId
|
||||||
|
: req.user.id;
|
||||||
|
return this.domainService.setCustomDomain(id, userId, dto.domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/domain/verify')
|
||||||
|
@ApiOperation({ summary: 'Verify DNS records for the custom domain' })
|
||||||
|
async verifyDomainDns(@Param('id') id: string, @Request() req: any) {
|
||||||
|
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||||
|
const userId = isStaff
|
||||||
|
? (await this.applicationsService.findOne(id)).userId
|
||||||
|
: req.user.id;
|
||||||
|
const result = await this.domainService.verifyDns(id, userId);
|
||||||
|
|
||||||
|
if (result.verified && result.application) {
|
||||||
|
try {
|
||||||
|
await this.kubernetesService.updateIngress(result.application);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Failed to update ingress for ${id} after DNS verification: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/domain')
|
||||||
|
@ApiOperation({ summary: 'Remove custom domain from the application' })
|
||||||
|
async removeCustomDomain(@Param('id') id: string, @Request() req: any) {
|
||||||
|
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||||
|
const userId = isStaff
|
||||||
|
? (await this.applicationsService.findOne(id)).userId
|
||||||
|
: req.user.id;
|
||||||
|
const app = await this.domainService.removeCustomDomain(id, userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.kubernetesService.updateIngress(app);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Failed to update ingress for ${id} after domain removal: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: 'Custom domain removed successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: 'Delete an application and all its resources' })
|
@ApiOperation({ summary: 'Delete an application and all its resources' })
|
||||||
async delete(@Param('id') id: string, @Request() req: any) {
|
async delete(@Param('id') id: string, @Request() req: any) {
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ApplicationsService } from './applications.service';
|
import { ApplicationsService } from './applications.service';
|
||||||
|
import { DomainService } from './domain.service';
|
||||||
import { ApplicationsController } from './applications.controller';
|
import { ApplicationsController } from './applications.controller';
|
||||||
import { Application } from './entities/application.entity';
|
import { Application } from './entities/application.entity';
|
||||||
|
import { PlatformSetting } from '../billing/entities/platform-setting.entity';
|
||||||
import { ClustersModule } from '../clusters/clusters.module';
|
import { ClustersModule } from '../clusters/clusters.module';
|
||||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||||
import { DeploymentsModule } from '../deployments/deployments.module';
|
import { DeploymentsModule } from '../deployments/deployments.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Application]),
|
TypeOrmModule.forFeature([Application, PlatformSetting]),
|
||||||
ClustersModule,
|
ClustersModule,
|
||||||
KubernetesModule,
|
KubernetesModule,
|
||||||
forwardRef(() => DeploymentsModule),
|
forwardRef(() => DeploymentsModule),
|
||||||
],
|
],
|
||||||
controllers: [ApplicationsController],
|
controllers: [ApplicationsController],
|
||||||
providers: [ApplicationsService],
|
providers: [ApplicationsService, DomainService],
|
||||||
exports: [ApplicationsService],
|
exports: [ApplicationsService, DomainService],
|
||||||
})
|
})
|
||||||
export class ApplicationsModule {}
|
export class ApplicationsModule {}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import * as crypto from 'crypto';
|
|||||||
import { Application } from './entities/application.entity';
|
import { Application } from './entities/application.entity';
|
||||||
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
|
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
|
||||||
import { ClustersService } from '../clusters/clusters.service';
|
import { ClustersService } from '../clusters/clusters.service';
|
||||||
import { UserRole, DatabaseType } from '../common/enums';
|
import { UserRole, DatabaseType, CustomDomainStatus } from '../common/enums';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApplicationsService {
|
export class ApplicationsService {
|
||||||
@@ -76,6 +76,8 @@ export class ApplicationsService {
|
|||||||
this.logger.log(`Generated DB credentials for app "${dto.name}" — user: ${dbUsername}`);
|
this.logger.log(`Generated DB credentials for app "${dto.name}" — user: ${dbUsername}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customDomain = dto.customDomain?.toLowerCase().trim() || undefined;
|
||||||
|
|
||||||
const app = this.appsRepository.create({
|
const app = this.appsRepository.create({
|
||||||
...dto,
|
...dto,
|
||||||
userId,
|
userId,
|
||||||
@@ -84,6 +86,8 @@ export class ApplicationsService {
|
|||||||
dbUsername,
|
dbUsername,
|
||||||
dbPassword,
|
dbPassword,
|
||||||
subdomain: `${dto.name}-${userId.split('-')[0]}`,
|
subdomain: `${dto.name}-${userId.split('-')[0]}`,
|
||||||
|
customDomain: customDomain || undefined,
|
||||||
|
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
|
||||||
});
|
});
|
||||||
return this.appsRepository.save(app);
|
return this.appsRepository.save(app);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, Not } from 'typeorm';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as dns from 'dns';
|
||||||
|
import { Application } from './entities/application.entity';
|
||||||
|
import { PlatformSetting } from '../billing/entities/platform-setting.entity';
|
||||||
|
import { CustomDomainStatus } from '../common/enums';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DomainService {
|
||||||
|
private readonly logger = new Logger(DomainService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Application)
|
||||||
|
private appRepo: Repository<Application>,
|
||||||
|
@InjectRepository(PlatformSetting)
|
||||||
|
private settingsRepo: Repository<PlatformSetting>,
|
||||||
|
private configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getCustomDomainPrice(): Promise<number> {
|
||||||
|
const setting = await this.settingsRepo.findOne({
|
||||||
|
where: { key: 'custom_domain_monthly_price_toman' },
|
||||||
|
});
|
||||||
|
return setting ? Number(setting.value) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlatformCnameTarget(): Promise<string> {
|
||||||
|
const setting = await this.settingsRepo.findOne({
|
||||||
|
where: { key: 'platform_cname_target' },
|
||||||
|
});
|
||||||
|
return setting?.value || this.configService.get('platform.domain') || 'apps.cloudhost.ir';
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCustomDomain(appId: string, userId: string, domain: string): Promise<Application> {
|
||||||
|
const app = await this.appRepo.findOne({ where: { id: appId, userId } });
|
||||||
|
if (!app) throw new NotFoundException('Application not found');
|
||||||
|
|
||||||
|
const normalizedDomain = domain.toLowerCase().trim();
|
||||||
|
|
||||||
|
const existing = await this.appRepo.findOne({
|
||||||
|
where: {
|
||||||
|
customDomain: normalizedDomain,
|
||||||
|
customDomainStatus: Not(CustomDomainStatus.NONE),
|
||||||
|
id: Not(appId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('This domain is already in use by another application');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.customDomain = normalizedDomain;
|
||||||
|
app.customDomainStatus = CustomDomainStatus.PENDING_DNS;
|
||||||
|
app.customDomainVerifiedAt = undefined;
|
||||||
|
|
||||||
|
const saved = await this.appRepo.save(app);
|
||||||
|
this.logger.log(`Custom domain set for ${app.name}: ${normalizedDomain} (pending DNS)`);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyDns(appId: string, userId: string): Promise<{
|
||||||
|
verified: boolean;
|
||||||
|
message: string;
|
||||||
|
application?: Application;
|
||||||
|
}> {
|
||||||
|
const app = await this.appRepo.findOne({ where: { id: appId, userId } });
|
||||||
|
if (!app) throw new NotFoundException('Application not found');
|
||||||
|
|
||||||
|
if (!app.customDomain || app.customDomainStatus === CustomDomainStatus.NONE) {
|
||||||
|
throw new BadRequestException('No custom domain is set for this application');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cnameTarget = await this.getPlatformCnameTarget();
|
||||||
|
const expectedSubdomain = `${app.subdomain}.${cnameTarget}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = await this.resolveDomain(app.customDomain);
|
||||||
|
|
||||||
|
const isValid = resolved.some(
|
||||||
|
(r) =>
|
||||||
|
r === cnameTarget ||
|
||||||
|
r === expectedSubdomain ||
|
||||||
|
r.endsWith(`.${cnameTarget}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
app.customDomainStatus = CustomDomainStatus.VERIFIED;
|
||||||
|
app.customDomainVerifiedAt = new Date();
|
||||||
|
const saved = await this.appRepo.save(app);
|
||||||
|
this.logger.log(`DNS verified for ${app.name}: ${app.customDomain}`);
|
||||||
|
return {
|
||||||
|
verified: true,
|
||||||
|
message: 'DNS verification successful. Your custom domain is now active.',
|
||||||
|
application: saved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
verified: false,
|
||||||
|
message: `DNS records do not point to ${expectedSubdomain}. Please check your CNAME record and try again.`,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`DNS verification failed for ${app.customDomain}: ${err.message}`);
|
||||||
|
return {
|
||||||
|
verified: false,
|
||||||
|
message: `Could not resolve DNS for ${app.customDomain}. Make sure the CNAME record is set and DNS has propagated (may take up to 48 hours).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeCustomDomain(appId: string, userId: string): Promise<Application> {
|
||||||
|
const app = await this.appRepo.findOne({ where: { id: appId, userId } });
|
||||||
|
if (!app) throw new NotFoundException('Application not found');
|
||||||
|
|
||||||
|
app.customDomain = undefined;
|
||||||
|
app.customDomainStatus = CustomDomainStatus.NONE;
|
||||||
|
app.customDomainVerifiedAt = undefined;
|
||||||
|
|
||||||
|
const saved = await this.appRepo.save(app);
|
||||||
|
this.logger.log(`Custom domain removed for ${app.name}`);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDomainInfo(appId: string, userId: string): Promise<{
|
||||||
|
customDomain?: string;
|
||||||
|
customDomainStatus: CustomDomainStatus;
|
||||||
|
customDomainVerifiedAt?: Date;
|
||||||
|
platformDomain: string;
|
||||||
|
fullPlatformUrl: string;
|
||||||
|
cnameTarget: string;
|
||||||
|
instructions: string[];
|
||||||
|
}> {
|
||||||
|
const app = await this.appRepo.findOne({ where: { id: appId, userId } });
|
||||||
|
if (!app) throw new NotFoundException('Application not found');
|
||||||
|
|
||||||
|
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
|
||||||
|
const cnameTarget = await this.getPlatformCnameTarget();
|
||||||
|
const fullPlatformUrl = `${app.subdomain}.${platformDomain}`;
|
||||||
|
|
||||||
|
const instructions = [
|
||||||
|
`1. وارد پنل مدیریت دامنه خود شوید (مانند Cloudflare، Namecheap، GoDaddy و غیره)`,
|
||||||
|
`2. به بخش مدیریت DNS بروید`,
|
||||||
|
`3. یک رکورد CNAME اضافه کنید:`,
|
||||||
|
` - Name/Host: @ یا www (بسته به دامنهتان)`,
|
||||||
|
` - Type: CNAME`,
|
||||||
|
` - Value/Target: ${fullPlatformUrl}`,
|
||||||
|
`4. اگر از دامنه اصلی (root domain) بدون www استفاده میکنید، برخی ثبتکنندگان از CNAME flattening پشتیبانی میکنند (مانند Cloudflare). در غیر این صورت از www استفاده کنید.`,
|
||||||
|
`5. بین ۵ تا ۳۰ دقیقه صبر کنید تا DNS منتشر شود (تا ۴۸ ساعت ممکن است طول بکشد)`,
|
||||||
|
`6. دکمه "تأیید DNS" را بزنید`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
customDomain: app.customDomain,
|
||||||
|
customDomainStatus: app.customDomainStatus,
|
||||||
|
customDomainVerifiedAt: app.customDomainVerifiedAt,
|
||||||
|
platformDomain,
|
||||||
|
fullPlatformUrl,
|
||||||
|
cnameTarget,
|
||||||
|
instructions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveDomain(domain: string): Promise<string[]> {
|
||||||
|
const resolver = new dns.promises.Resolver();
|
||||||
|
resolver.setServers(['8.8.8.8', '1.1.1.1']);
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cnames = await resolver.resolveCname(domain);
|
||||||
|
results.push(...cnames);
|
||||||
|
} catch {
|
||||||
|
// No CNAME record — try A record
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aRecords = await resolver.resolve4(domain);
|
||||||
|
results.push(...aRecords);
|
||||||
|
} catch {
|
||||||
|
// No A record
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,16 +75,31 @@ export class CreateApplicationDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enableRedis?: boolean;
|
enableRedis?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '7.2', description: 'Redis version (7.2, 7.0, 6.2, 6.0)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
redisVersion?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: false, description: 'Enable RabbitMQ for message queue' })
|
@ApiPropertyOptional({ example: false, description: 'Enable RabbitMQ for message queue' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enableRabbitmq?: boolean;
|
enableRabbitmq?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '3.13', description: 'RabbitMQ version (3.13, 3.12, 3.11, 3.10)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
rabbitmqVersion?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: false, description: 'Enable Elasticsearch for application logging' })
|
@ApiPropertyOptional({ example: false, description: 'Enable Elasticsearch for application logging' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enableElasticsearch?: boolean;
|
enableElasticsearch?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '8.12', description: 'Elasticsearch version (8.12, 8.11, 7.17, 7.10)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
elasticsearchVersion?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: ['/app/logs/*.log'], description: 'Custom log paths to collect' })
|
@ApiPropertyOptional({ example: ['/app/logs/*.log'], description: 'Custom log paths to collect' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@@ -152,6 +167,11 @@ export class CreateApplicationDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
poolId?: string;
|
poolId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'www.example.com', description: 'Custom domain for the application (requires additional fee)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
customDomain?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateApplicationDto {
|
export class UpdateApplicationDto {
|
||||||
@@ -240,6 +260,15 @@ export class UpdateApplicationDto {
|
|||||||
logPaths?: string[];
|
logPaths?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SetCustomDomainDto {
|
||||||
|
@ApiProperty({ example: 'www.example.com', description: 'The custom domain to assign' })
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
|
||||||
|
message: 'Invalid domain format (e.g. example.com or www.example.com)',
|
||||||
|
})
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ScaleResourcesDto {
|
export class ScaleResourcesDto {
|
||||||
@ApiPropertyOptional({ example: '100m' })
|
@ApiPropertyOptional({ example: '100m' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
OneToMany,
|
OneToMany,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { AppRuntime, DatabaseType, BillingCycle, AppLifecycleStatus } from '../../common/enums';
|
import { AppRuntime, DatabaseType, BillingCycle, AppLifecycleStatus, CustomDomainStatus } from '../../common/enums';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { User } from '../../users/entities/user.entity';
|
||||||
import { Deployment } from '../../deployments/entities/deployment.entity';
|
import { Deployment } from '../../deployments/entities/deployment.entity';
|
||||||
|
|
||||||
@@ -54,12 +54,21 @@ export class Application {
|
|||||||
@Column({ default: false })
|
@Column({ default: false })
|
||||||
enableRedis: boolean;
|
enableRedis: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true, default: '7.2' })
|
||||||
|
redisVersion: string;
|
||||||
|
|
||||||
@Column({ default: false })
|
@Column({ default: false })
|
||||||
enableRabbitmq: boolean;
|
enableRabbitmq: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true, default: '3.13' })
|
||||||
|
rabbitmqVersion: string;
|
||||||
|
|
||||||
@Column({ default: false })
|
@Column({ default: false })
|
||||||
enableElasticsearch: boolean; // For application logging
|
enableElasticsearch: boolean; // For application logging
|
||||||
|
|
||||||
|
@Column({ nullable: true, default: '8.12' })
|
||||||
|
elasticsearchVersion: string;
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
logPaths: string[]; // Custom log paths to collect (e.g., ['/app/logs/*.log'])
|
logPaths: string[]; // Custom log paths to collect (e.g., ['/app/logs/*.log'])
|
||||||
|
|
||||||
@@ -124,6 +133,16 @@ export class Application {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
subdomain: string; // <subdomain>.apps.cloudhost.local
|
subdomain: string; // <subdomain>.apps.cloudhost.local
|
||||||
|
|
||||||
|
// ── Custom Domain ─────────────────────────────
|
||||||
|
@Column({ nullable: true })
|
||||||
|
customDomain?: string; // e.g. "www.example.com"
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: CustomDomainStatus, default: CustomDomainStatus.NONE })
|
||||||
|
customDomainStatus: CustomDomainStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
customDomainVerifiedAt?: Date;
|
||||||
|
|
||||||
// ── Billing & Lifecycle ─────────────────────────────
|
// ── Billing & Lifecycle ─────────────────────────────
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
planId: string; // FK to ServicePlan
|
planId: string; // FK to ServicePlan
|
||||||
|
|||||||
@@ -95,6 +95,24 @@ export class BillingController {
|
|||||||
return this.billingService.calculateCost(dto);
|
return this.billingService.calculateCost(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Custom Domain Pricing ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Get('settings/custom-domain-price')
|
||||||
|
@ApiOperation({ summary: 'Get custom domain monthly price' })
|
||||||
|
async getCustomDomainPrice() {
|
||||||
|
return this.billingService.getCustomDomainPrice();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('settings/custom-domain-price')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Set custom domain monthly price (Admin)' })
|
||||||
|
async setCustomDomainPrice(@Body() body: { monthlyPrice: number }) {
|
||||||
|
if (body.monthlyPrice === undefined || body.monthlyPrice < 0) {
|
||||||
|
throw new BadRequestException('monthlyPrice must be a non-negative number');
|
||||||
|
}
|
||||||
|
return this.billingService.setCustomDomainPrice(body.monthlyPrice);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Wallet (User) ───────────────────────────────────────────────
|
// ─── Wallet (User) ───────────────────────────────────────────────
|
||||||
|
|
||||||
@Get('wallet')
|
@Get('wallet')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ServicePlan } from './entities/service-plan.entity';
|
|||||||
import { PricingRule } from './entities/pricing-rule.entity';
|
import { PricingRule } from './entities/pricing-rule.entity';
|
||||||
import { Wallet } from './entities/wallet.entity';
|
import { Wallet } from './entities/wallet.entity';
|
||||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||||
|
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||||
import { TransactionType, BillingCycle, PricingResourceType } from '../common/enums';
|
import { TransactionType, BillingCycle, PricingResourceType } from '../common/enums';
|
||||||
import {
|
import {
|
||||||
CreateServicePlanDto,
|
CreateServicePlanDto,
|
||||||
@@ -23,6 +24,7 @@ export class BillingService {
|
|||||||
@InjectRepository(PricingRule) private ruleRepo: Repository<PricingRule>,
|
@InjectRepository(PricingRule) private ruleRepo: Repository<PricingRule>,
|
||||||
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
||||||
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
||||||
|
@InjectRepository(PlatformSetting) private settingsRepo: Repository<PlatformSetting>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── Service Plans ────────────────────────────────────────────────
|
// ─── Service Plans ────────────────────────────────────────────────
|
||||||
@@ -143,6 +145,7 @@ export class BillingService {
|
|||||||
const hasRedis = dto.enableRedis || false;
|
const hasRedis = dto.enableRedis || false;
|
||||||
const hasRabbitmq = dto.enableRabbitmq || false;
|
const hasRabbitmq = dto.enableRabbitmq || false;
|
||||||
const hasElasticsearch = dto.enableElasticsearch || false;
|
const hasElasticsearch = dto.enableElasticsearch || false;
|
||||||
|
const hasCustomDomain = dto.enableCustomDomain || false;
|
||||||
|
|
||||||
const breakdown: { label: string; hourly: number; monthly: number; yearly: number }[] = [];
|
const breakdown: { label: string; hourly: number; monthly: number; yearly: number }[] = [];
|
||||||
let totalBase = 0;
|
let totalBase = 0;
|
||||||
@@ -184,6 +187,10 @@ export class BillingService {
|
|||||||
cost = hasElasticsearch ? Number(rule.unitPrice) : 0;
|
cost = hasElasticsearch ? Number(rule.unitPrice) : 0;
|
||||||
label = 'Elasticsearch addon';
|
label = 'Elasticsearch addon';
|
||||||
break;
|
break;
|
||||||
|
case PricingResourceType.CUSTOM_DOMAIN_ADDON:
|
||||||
|
cost = hasCustomDomain ? Number(rule.unitPrice) : 0;
|
||||||
|
label = 'Custom domain + SSL';
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cost > 0) {
|
if (cost > 0) {
|
||||||
@@ -222,6 +229,33 @@ export class BillingService {
|
|||||||
return parseFloat(memory) / (1024 * 1024 * 1024);
|
return parseFloat(memory) / (1024 * 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Custom Domain Pricing (PlatformSetting) ────────────────────
|
||||||
|
|
||||||
|
async getCustomDomainPrice(): Promise<{ monthlyPrice: number }> {
|
||||||
|
const setting = await this.settingsRepo.findOne({
|
||||||
|
where: { key: 'custom_domain_monthly_price_toman' },
|
||||||
|
});
|
||||||
|
return { monthlyPrice: setting ? Number(setting.value) : 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCustomDomainPrice(monthlyPrice: number): Promise<{ monthlyPrice: number }> {
|
||||||
|
let setting = await this.settingsRepo.findOne({
|
||||||
|
where: { key: 'custom_domain_monthly_price_toman' },
|
||||||
|
});
|
||||||
|
if (setting) {
|
||||||
|
setting.value = String(monthlyPrice);
|
||||||
|
} else {
|
||||||
|
setting = this.settingsRepo.create({
|
||||||
|
key: 'custom_domain_monthly_price_toman',
|
||||||
|
value: String(monthlyPrice),
|
||||||
|
description: 'Monthly price for custom domain addon (Toman)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.settingsRepo.save(setting);
|
||||||
|
this.logger.log(`Custom domain monthly price updated: ${monthlyPrice} Toman`);
|
||||||
|
return { monthlyPrice };
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Wallet ───────────────────────────────────────────────────────
|
// ─── Wallet ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
async getOrCreateWallet(userId: string): Promise<Wallet> {
|
async getOrCreateWallet(userId: string): Promise<Wallet> {
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ export class CalculateCostDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enableElasticsearch?: boolean;
|
enableElasticsearch?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: false, description: 'Enable custom domain with SSL' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enableCustomDomain?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Renewal & Upgrade DTOs ─────────────────────────────────────────
|
// ─── Renewal & Upgrade DTOs ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export enum PricingResourceType {
|
|||||||
REDIS_ADDON = 'redis_addon', // Price for Redis addon
|
REDIS_ADDON = 'redis_addon', // Price for Redis addon
|
||||||
RABBITMQ_ADDON = 'rabbitmq_addon', // Price for RabbitMQ addon
|
RABBITMQ_ADDON = 'rabbitmq_addon', // Price for RabbitMQ addon
|
||||||
ELASTICSEARCH_ADDON = 'elasticsearch_addon', // Price for Elasticsearch addon
|
ELASTICSEARCH_ADDON = 'elasticsearch_addon', // Price for Elasticsearch addon
|
||||||
|
CUSTOM_DOMAIN_ADDON = 'custom_domain_addon', // Price for custom domain with SSL
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TransactionType {
|
export enum TransactionType {
|
||||||
@@ -98,6 +99,14 @@ export enum TransactionType {
|
|||||||
REFUND = 'refund', // Refund
|
REFUND = 'refund', // Refund
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Custom Domain ────────────────────────────────────
|
||||||
|
|
||||||
|
export enum CustomDomainStatus {
|
||||||
|
NONE = 'none',
|
||||||
|
PENDING_DNS = 'pending_dns',
|
||||||
|
VERIFIED = 'verified',
|
||||||
|
}
|
||||||
|
|
||||||
// ── Application Lifecycle ─────────────────────────────
|
// ── Application Lifecycle ─────────────────────────────
|
||||||
|
|
||||||
export enum AppLifecycleStatus {
|
export enum AppLifecycleStatus {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { promisify } from 'util';
|
|||||||
import { PassThrough } from 'stream';
|
import { PassThrough } from 'stream';
|
||||||
import { ClustersService } from '../clusters/clusters.service';
|
import { ClustersService } from '../clusters/clusters.service';
|
||||||
import { Application } from '../applications/entities/application.entity';
|
import { Application } from '../applications/entities/application.entity';
|
||||||
import { AppRuntime, DatabaseType } from '../common/enums';
|
import { AppRuntime, DatabaseType, CustomDomainStatus } from '../common/enums';
|
||||||
import { HelmService } from './helm.service';
|
import { HelmService } from './helm.service';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -118,6 +118,9 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
subdomain: app.subdomain || app.name,
|
subdomain: app.subdomain || app.name,
|
||||||
domain: domain,
|
domain: domain,
|
||||||
clusterIssuer: 'letsencrypt-prod',
|
clusterIssuer: 'letsencrypt-prod',
|
||||||
|
customDomain: (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED)
|
||||||
|
? app.customDomain
|
||||||
|
: '',
|
||||||
},
|
},
|
||||||
registry: {
|
registry: {
|
||||||
url: pullRegistryUrl,
|
url: pullRegistryUrl,
|
||||||
@@ -182,6 +185,57 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateIngress(app: Application): Promise<void> {
|
||||||
|
const domain = this.configService.get('platform.domain');
|
||||||
|
const subdomain = app.subdomain || app.name;
|
||||||
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
const customDomain = (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED)
|
||||||
|
? app.customDomain : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
||||||
|
const imageUri = app.latestImageTag
|
||||||
|
? `${this.configService.get<string>('registry.pullUrl') || 'localhost:30500'}/${app.name}:${app.latestImageTag}`
|
||||||
|
: '';
|
||||||
|
const values = this.buildHelmValues(app, imageUri);
|
||||||
|
await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig);
|
||||||
|
this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`);
|
||||||
|
} catch (helmError: any) {
|
||||||
|
this.logger.warn(`Helm ingress update failed for ${app.name}, using direct K8s API: ${helmError.message}`);
|
||||||
|
const { networkingApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
const ctx: ManifestContext = {
|
||||||
|
appName: app.name,
|
||||||
|
namespace,
|
||||||
|
image: '',
|
||||||
|
port: app.port,
|
||||||
|
replicas: app.replicas,
|
||||||
|
cpuRequest: app.cpuRequest,
|
||||||
|
cpuLimit: app.cpuLimit,
|
||||||
|
memoryRequest: app.memoryRequest,
|
||||||
|
memoryLimit: app.memoryLimit,
|
||||||
|
envVars: app.envVars || {},
|
||||||
|
runtime: app.runtime,
|
||||||
|
databaseType: app.databaseType,
|
||||||
|
domain,
|
||||||
|
subdomain,
|
||||||
|
dbUsername: app.dbUsername || '',
|
||||||
|
dbPassword: app.dbPassword || '',
|
||||||
|
dbVersion: app.dbVersion || '',
|
||||||
|
dbStorageSize: app.dbStorageSize || '1Gi',
|
||||||
|
appStorageSize: app.appStorageSize || '2Gi',
|
||||||
|
enableRedis: app.enableRedis || false,
|
||||||
|
redisVersion: app.redisVersion || '7.2',
|
||||||
|
enableRabbitmq: app.enableRabbitmq || false,
|
||||||
|
rabbitmqVersion: app.rabbitmqVersion || '3.13',
|
||||||
|
enableElasticsearch: app.enableElasticsearch || false,
|
||||||
|
elasticsearchVersion: app.elasticsearchVersion || '8.12',
|
||||||
|
logPaths: app.logPaths || [],
|
||||||
|
};
|
||||||
|
await this.applyIngress(networkingApi, ctx, customDomain);
|
||||||
|
this.logger.log(`Updated ingress for ${app.name} via K8s API (customDomain: ${customDomain || 'none'})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helm-based deployment ─────────────────────────────────────────
|
// ── Helm-based deployment ─────────────────────────────────────────
|
||||||
|
|
||||||
private async deployViaHelm(app: Application, imageUri: string): Promise<Record<string, any>> {
|
private async deployViaHelm(app: Application, imageUri: string): Promise<Record<string, any>> {
|
||||||
@@ -278,7 +332,9 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
manifests.service = await this.applyService(coreApi, context);
|
manifests.service = await this.applyService(coreApi, context);
|
||||||
|
|
||||||
// 6. Create Ingress
|
// 6. Create Ingress
|
||||||
manifests.ingress = await this.applyIngress(networkingApi, context);
|
const customDomain = (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED)
|
||||||
|
? app.customDomain : undefined;
|
||||||
|
manifests.ingress = await this.applyIngress(networkingApi, context, customDomain);
|
||||||
|
|
||||||
this.logger.log(`Successfully deployed ${app.name} to namespace ${context.namespace} via K8s API`);
|
this.logger.log(`Successfully deployed ${app.name} to namespace ${context.namespace} via K8s API`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -718,21 +774,9 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async applyIngress(networkingApi: k8s.NetworkingV1Api, ctx: ManifestContext): Promise<any> {
|
private async applyIngress(networkingApi: k8s.NetworkingV1Api, ctx: ManifestContext, customDomain?: string): Promise<any> {
|
||||||
const host = `${ctx.subdomain}.${ctx.domain}`;
|
const host = `${ctx.subdomain}.${ctx.domain}`;
|
||||||
const ingress: k8s.V1Ingress = {
|
const rules: k8s.V1IngressRule[] = [
|
||||||
apiVersion: 'networking.k8s.io/v1',
|
|
||||||
kind: 'Ingress',
|
|
||||||
metadata: {
|
|
||||||
name: ctx.appName,
|
|
||||||
namespace: ctx.namespace,
|
|
||||||
annotations: {
|
|
||||||
'cert-manager.io/cluster-issuer': 'letsencrypt-prod',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
spec: {
|
|
||||||
ingressClassName: 'nginx',
|
|
||||||
rules: [
|
|
||||||
{
|
{
|
||||||
host,
|
host,
|
||||||
http: {
|
http: {
|
||||||
@@ -745,8 +789,39 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
const tlsHosts = [host];
|
||||||
|
|
||||||
|
if (customDomain) {
|
||||||
|
rules.push({
|
||||||
|
host: customDomain,
|
||||||
|
http: {
|
||||||
|
paths: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
pathType: 'Prefix',
|
||||||
|
backend: { service: { name: ctx.appName, port: { number: 80 } } },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
tls: [{ hosts: [host], secretName: `${ctx.appName}-tls` }],
|
},
|
||||||
|
});
|
||||||
|
tlsHosts.push(customDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ingress: k8s.V1Ingress = {
|
||||||
|
apiVersion: 'networking.k8s.io/v1',
|
||||||
|
kind: 'Ingress',
|
||||||
|
metadata: {
|
||||||
|
name: ctx.appName,
|
||||||
|
namespace: ctx.namespace,
|
||||||
|
annotations: {
|
||||||
|
'cert-manager.io/cluster-issuer': 'letsencrypt-prod',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
ingressClassName: 'nginx',
|
||||||
|
rules,
|
||||||
|
tls: [{ hosts: tlsHosts, secretName: `${ctx.appName}-tls` }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { UsersService } from './users/users.service';
|
import { UsersService } from './users/users.service';
|
||||||
|
import { PlatformSetting } from './billing/entities/platform-setting.entity';
|
||||||
import { UserRole } from './common/enums';
|
import { UserRole } from './common/enums';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +50,24 @@ async function bootstrap() {
|
|||||||
console.log(` Email: ${email}`);
|
console.log(` Email: ${email}`);
|
||||||
console.log(` Password: ${password}`);
|
console.log(` Password: ${password}`);
|
||||||
|
|
||||||
|
// Seed default platform settings
|
||||||
|
const settingsRepo = app.get<Repository<PlatformSetting>>(getRepositoryToken(PlatformSetting));
|
||||||
|
|
||||||
|
const defaults = [
|
||||||
|
{ key: 'custom_domain_monthly_price_toman', value: '50000', description: 'Monthly price for custom domain addon (Toman)' },
|
||||||
|
{ key: 'platform_cname_target', value: 'apps.cloudhost.ir', description: 'CNAME target shown to users for custom domain setup' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const d of defaults) {
|
||||||
|
const existing = await settingsRepo.findOne({ where: { key: d.key } });
|
||||||
|
if (!existing) {
|
||||||
|
await settingsRepo.save(settingsRepo.create(d));
|
||||||
|
console.log(`✅ Platform setting seeded: ${d.key} = ${d.value}`);
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ Platform setting already exists: ${d.key} = ${existing.value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import type { ServicePlan, BillingCycle, PricingResourceType, LifecycleSettings } from '@/types';
|
import type { ServicePlan, BillingCycle, PricingResourceType, LifecycleSettings } from '@/types';
|
||||||
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp, Shield, Clock } from 'lucide-react';
|
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp, Shield, Clock, Globe } from 'lucide-react';
|
||||||
import { useConfirm } from '@/components/confirm-modal';
|
import { useConfirm } from '@/components/confirm-modal';
|
||||||
|
|
||||||
const runtimeOptions = [
|
const runtimeOptions = [
|
||||||
@@ -34,9 +34,13 @@ const resourceLabels: Record<PricingResourceType, string> = {
|
|||||||
memory_per_gb: 'Memory (per GB)',
|
memory_per_gb: 'Memory (per GB)',
|
||||||
storage_per_gb: 'Storage (per GB)',
|
storage_per_gb: 'Storage (per GB)',
|
||||||
database_addon: 'Database Addon',
|
database_addon: 'Database Addon',
|
||||||
|
redis_addon: 'Redis Addon',
|
||||||
|
rabbitmq_addon: 'RabbitMQ Addon',
|
||||||
|
elasticsearch_addon: 'Elasticsearch Addon',
|
||||||
|
custom_domain_addon: 'Custom Domain + SSL',
|
||||||
};
|
};
|
||||||
|
|
||||||
const allResourceTypes: PricingResourceType[] = ['base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon'];
|
const allResourceTypes: PricingResourceType[] = ['base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon', 'redis_addon', 'rabbitmq_addon', 'elasticsearch_addon', 'custom_domain_addon'];
|
||||||
|
|
||||||
interface RuleForm {
|
interface RuleForm {
|
||||||
resourceType: PricingResourceType;
|
resourceType: PricingResourceType;
|
||||||
@@ -323,12 +327,108 @@ export default function AdminBillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ─── Custom Domain Pricing ───────────────────── */}
|
||||||
|
<CustomDomainPricingSection />
|
||||||
|
|
||||||
{/* ─── Lifecycle Retention Settings ───────────────────── */}
|
{/* ─── Lifecycle Retention Settings ───────────────────── */}
|
||||||
<LifecycleSettingsSection />
|
<LifecycleSettingsSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Custom Domain Pricing Sub-component ──────────────────────────
|
||||||
|
|
||||||
|
function CustomDomainPricingSection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [priceInput, setPriceInput] = useState('');
|
||||||
|
|
||||||
|
const { data: priceData, isLoading } = useQuery<{ monthlyPrice: number }>({
|
||||||
|
queryKey: ['custom-domain-price'],
|
||||||
|
queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (monthlyPrice: number) => api.patch('/billing/settings/custom-domain-price', { monthlyPrice }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
|
||||||
|
toast.success('Custom domain pricing updated');
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to update pricing'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setPriceInput(String(priceData?.monthlyPrice || 0));
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const price = Number(priceInput);
|
||||||
|
if (isNaN(price) || price < 0) {
|
||||||
|
toast.error('Price must be a non-negative number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveMutation.mutate(price);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card mt-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<Globe className="w-5 h-5" /> Custom Domain Pricing
|
||||||
|
</h2>
|
||||||
|
{!editing && (
|
||||||
|
<button onClick={handleEdit} className="btn-secondary text-sm flex items-center gap-1.5">
|
||||||
|
<Edit2 className="w-4 h-4" /> Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-gray-500">Loading...</p>
|
||||||
|
) : editing ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Monthly Price (Toman)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={priceInput}
|
||||||
|
onChange={(e) => setPriceInput(e.target.value)}
|
||||||
|
className="input-field w-full max-w-xs"
|
||||||
|
min="0"
|
||||||
|
placeholder="e.g. 50000"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Set to 0 to make custom domains free. This price is added to the total cost when users enable a custom domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleSave} disabled={saveMutation.isPending} className="btn-primary text-sm">
|
||||||
|
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditing(false)} className="btn-secondary text-sm">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-50 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Monthly price per custom domain</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{(priceData?.monthlyPrice || 0).toLocaleString('en-US')} <span className="text-sm font-normal text-gray-500">Toman</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{priceData?.monthlyPrice === 0 && (
|
||||||
|
<span className="badge badge-green">Free</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Lifecycle Settings Sub-component ─────────────────────────────
|
// ─── Lifecycle Settings Sub-component ─────────────────────────────
|
||||||
|
|
||||||
function LifecycleSettingsSection() {
|
function LifecycleSettingsSection() {
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ export default function AppDetailPage() {
|
|||||||
newCost: { hourly: number };
|
newCost: { hourly: number };
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// ── Custom Domain ──────────────────────────────────
|
||||||
|
const [showDomainSetup, setShowDomainSetup] = useState(false);
|
||||||
|
const [customDomainInput, setCustomDomainInput] = useState('');
|
||||||
|
|
||||||
const { data: app, isLoading } = useQuery<Application>({
|
const { data: app, isLoading } = useQuery<Application>({
|
||||||
queryKey: ['application', appId],
|
queryKey: ['application', appId],
|
||||||
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
||||||
@@ -226,6 +230,60 @@ export default function AppDetailPage() {
|
|||||||
const needsRenewal = app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion';
|
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);
|
const isExpiringSoon = app?.planExpiresAt && new Date(app.planExpiresAt) <= new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// ─── Custom Domain ──────────────────────────────────
|
||||||
|
const { data: domainInfo, refetch: refetchDomainInfo } = useQuery<{
|
||||||
|
customDomain: string | null;
|
||||||
|
customDomainStatus: string;
|
||||||
|
platformDomain: string;
|
||||||
|
fullPlatformUrl: string;
|
||||||
|
cnameTarget: string;
|
||||||
|
instructions: string[];
|
||||||
|
}>({
|
||||||
|
queryKey: ['domain-info', appId],
|
||||||
|
queryFn: () => api.get(`/applications/${appId}/domain`).then((r) => r.data),
|
||||||
|
enabled: showDomainSetup || (!!app && (app.customDomainStatus === 'pending_dns' || app.customDomainStatus === 'verified')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({
|
||||||
|
queryKey: ['custom-domain-price'],
|
||||||
|
queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setDomainMutation = useMutation({
|
||||||
|
mutationFn: (domain: string) => api.post(`/applications/${appId}/domain`, { domain }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('دامنه تنظیم شد. لطفاً رکورد DNS را اضافه کنید.');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
|
refetchDomainInfo();
|
||||||
|
setCustomDomainInput('');
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در تنظیم دامنه'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyDnsMutation = useMutation({
|
||||||
|
mutationFn: () => api.post(`/applications/${appId}/domain/verify`),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
if (res.data.verified) {
|
||||||
|
toast.success('دامنه با موفقیت تأیید شد!');
|
||||||
|
} else {
|
||||||
|
toast.warning(res.data.message || 'DNS هنوز آماده نیست. لطفاً بعداً تلاش کنید.');
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
|
refetchDomainInfo();
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در تأیید DNS'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeDomainMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete(`/applications/${appId}/domain`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('دامنه اختصاصی حذف شد');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
|
refetchDomainInfo();
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در حذف دامنه'),
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Snapshots ──────────────────────────────────────
|
// ─── Snapshots ──────────────────────────────────────
|
||||||
const { data: snapshots = [], isLoading: snapshotsLoading } = useQuery<AppSnapshot[]>({
|
const { data: snapshots = [], isLoading: snapshotsLoading } = useQuery<AppSnapshot[]>({
|
||||||
queryKey: ['snapshots', appId],
|
queryKey: ['snapshots', appId],
|
||||||
@@ -679,7 +737,7 @@ export default function AppDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 truncate">
|
<p className="text-sm text-gray-500 truncate">
|
||||||
{app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} · {app.subdomain}.apps.cloudhost.local
|
{app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} · {app.customDomain && app.customDomainStatus === 'verified' ? app.customDomain : `${app.subdomain}.${domainInfo?.platformDomain || 'apps.cloudhost.ir'}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1206,6 +1264,168 @@ export default function AppDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Domain */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<Globe className="w-5 h-5" /> دامنه
|
||||||
|
</h2>
|
||||||
|
{!showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDomainSetup(true)}
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
>
|
||||||
|
افزودن دامنه اختصاصی
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform domain (always shown) */}
|
||||||
|
<div className="bg-gray-50 rounded-xl p-4 mb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">دامنه پلتفرم</p>
|
||||||
|
<p className="text-sm font-mono font-medium text-gray-800">
|
||||||
|
{app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-green text-xs">فعال</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom domain - verified */}
|
||||||
|
{app.customDomain && app.customDomainStatus === 'verified' && (
|
||||||
|
<div className="bg-emerald-50 rounded-xl p-4 mb-4 border border-emerald-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-emerald-600 mb-1">دامنه اختصاصی</p>
|
||||||
|
<p className="text-sm font-mono font-medium text-emerald-800">{app.customDomain}</p>
|
||||||
|
<p className="text-xs text-emerald-500 mt-1">
|
||||||
|
<CheckCircle className="w-3 h-3 inline" /> SSL فعال — تأیید شده در{' '}
|
||||||
|
{app.customDomainVerifiedAt ? new Date(app.customDomainVerifiedAt).toLocaleString('fa-IR') : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'حذف دامنه اختصاصی',
|
||||||
|
message: `آیا مطمئن هستید که میخواهید دامنه "${app.customDomain}" را حذف کنید؟ وبسایت فقط از طریق دامنه پلتفرم قابل دسترسی خواهد بود.`,
|
||||||
|
confirmText: 'حذف',
|
||||||
|
variant: 'danger',
|
||||||
|
});
|
||||||
|
if (ok) removeDomainMutation.mutate();
|
||||||
|
}}
|
||||||
|
disabled={removeDomainMutation.isPending}
|
||||||
|
className="text-sm px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors"
|
||||||
|
>
|
||||||
|
{removeDomainMutation.isPending ? 'در حال حذف...' : 'حذف دامنه'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom domain - pending DNS */}
|
||||||
|
{app.customDomain && app.customDomainStatus === 'pending_dns' && (
|
||||||
|
<div className="bg-amber-50 rounded-xl p-4 mb-4 border border-amber-200">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-amber-600 mb-1">دامنه اختصاصی — در انتظار تأیید DNS</p>
|
||||||
|
<p className="text-sm font-mono font-medium text-amber-800">{app.customDomain}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => verifyDnsMutation.mutate()}
|
||||||
|
disabled={verifyDnsMutation.isPending}
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
>
|
||||||
|
{verifyDnsMutation.isPending ? 'در حال بررسی...' : 'تأیید DNS'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeDomainMutation.mutate()}
|
||||||
|
disabled={removeDomainMutation.isPending}
|
||||||
|
className="text-sm px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors"
|
||||||
|
>
|
||||||
|
لغو
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DNS Instructions */}
|
||||||
|
{domainInfo?.instructions && (
|
||||||
|
<div className="bg-white rounded-lg p-4 border border-amber-100">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-800 mb-3">راهنمای تنظیم DNS</h4>
|
||||||
|
<div className="space-y-2 text-sm text-gray-600" dir="rtl">
|
||||||
|
{domainInfo.instructions.map((step, i) => (
|
||||||
|
<p key={i} className={step.startsWith(' ') ? 'pr-4 text-xs font-mono bg-gray-50 rounded px-2 py-1' : ''}>
|
||||||
|
{step}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 bg-blue-50 rounded-lg p-3 border border-blue-100">
|
||||||
|
<p className="text-xs text-blue-700 font-medium mb-1">CNAME Target:</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-sm font-mono text-blue-900 bg-blue-100 px-2 py-1 rounded flex-1">
|
||||||
|
{domainInfo.fullPlatformUrl}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(domainInfo.fullPlatformUrl);
|
||||||
|
toast.success('کپی شد!');
|
||||||
|
}}
|
||||||
|
className="text-blue-600 hover:text-blue-800 p-1"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Domain setup form */}
|
||||||
|
{showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && (
|
||||||
|
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-800 mb-3">تنظیم دامنه اختصاصی</h4>
|
||||||
|
{domainPriceData && domainPriceData.monthlyPrice > 0 && (
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3 mb-4 border border-blue-100">
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
<CreditCard className="w-4 h-4 inline ml-1" />
|
||||||
|
هزینه دامنه اختصاصی: <strong>{domainPriceData.monthlyPrice.toLocaleString('fa-IR')} تومان / ماهانه</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-500 mt-1">
|
||||||
|
این هزینه در محاسبه کلی هزینهها در نظر گرفته میشود.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2" dir="ltr">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customDomainInput}
|
||||||
|
onChange={(e) => setCustomDomainInput(e.target.value)}
|
||||||
|
placeholder="example.com or www.example.com"
|
||||||
|
className="input-field flex-1 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (customDomainInput.trim()) setDomainMutation.mutate(customDomainInput.trim());
|
||||||
|
}}
|
||||||
|
disabled={!customDomainInput.trim() || setDomainMutation.isPending}
|
||||||
|
className="btn-primary text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{setDomainMutation.isPending ? 'در حال ثبت...' : 'ثبت دامنه'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowDomainSetup(false); setCustomDomainInput(''); }}
|
||||||
|
className="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Database Info & Dump Upload */}
|
{/* Database Info & Dump Upload */}
|
||||||
{app.databaseType !== 'none' && (
|
{app.databaseType !== 'none' && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import api from '@/lib/api';
|
|||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types';
|
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types';
|
||||||
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2 } from 'lucide-react';
|
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2, Globe } from 'lucide-react';
|
||||||
|
|
||||||
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
|
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
|
||||||
|
|
||||||
@@ -50,8 +50,11 @@ export default function DeployPage() {
|
|||||||
dbStorageSize: '1',
|
dbStorageSize: '1',
|
||||||
appStorageSize: '2',
|
appStorageSize: '2',
|
||||||
enableRedis: false,
|
enableRedis: false,
|
||||||
|
redisVersion: '7.2',
|
||||||
enableRabbitmq: false,
|
enableRabbitmq: false,
|
||||||
|
rabbitmqVersion: '3.13',
|
||||||
enableElasticsearch: false,
|
enableElasticsearch: false,
|
||||||
|
elasticsearchVersion: '8.12',
|
||||||
});
|
});
|
||||||
const [envKey, setEnvKey] = useState('');
|
const [envKey, setEnvKey] = useState('');
|
||||||
const [envVal, setEnvVal] = useState('');
|
const [envVal, setEnvVal] = useState('');
|
||||||
@@ -86,9 +89,19 @@ export default function DeployPage() {
|
|||||||
enabled: isAdmin,
|
enabled: isAdmin,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Custom Domain ──────────────────────────────
|
||||||
|
const [enableCustomDomain, setEnableCustomDomain] = useState(false);
|
||||||
|
const [customDomainInput, setCustomDomainInput] = useState('');
|
||||||
|
|
||||||
|
const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({
|
||||||
|
queryKey: ['custom-domain-price'],
|
||||||
|
queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data),
|
||||||
|
enabled: step >= 2,
|
||||||
|
});
|
||||||
|
|
||||||
// Cost calculation for the review step
|
// Cost calculation for the review step
|
||||||
const { data: costData, isLoading: costLoading } = useQuery<CostBreakdown>({
|
const { data: costData, isLoading: costLoading } = useQuery<CostBreakdown>({
|
||||||
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch],
|
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch, enableCustomDomain],
|
||||||
queryFn: () => api.post('/billing/calculate', {
|
queryFn: () => api.post('/billing/calculate', {
|
||||||
runtime: form.runtime,
|
runtime: form.runtime,
|
||||||
databaseType: form.databaseType,
|
databaseType: form.databaseType,
|
||||||
@@ -100,6 +113,7 @@ export default function DeployPage() {
|
|||||||
enableRedis: form.enableRedis,
|
enableRedis: form.enableRedis,
|
||||||
enableRabbitmq: form.enableRabbitmq,
|
enableRabbitmq: form.enableRabbitmq,
|
||||||
enableElasticsearch: form.enableElasticsearch,
|
enableElasticsearch: form.enableElasticsearch,
|
||||||
|
enableCustomDomain,
|
||||||
}).then((r) => r.data),
|
}).then((r) => r.data),
|
||||||
enabled: step === 3,
|
enabled: step === 3,
|
||||||
});
|
});
|
||||||
@@ -330,6 +344,9 @@ export default function DeployPage() {
|
|||||||
if (payload.appStorageSize) {
|
if (payload.appStorageSize) {
|
||||||
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
|
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
|
||||||
}
|
}
|
||||||
|
if (enableCustomDomain && customDomainInput.trim()) {
|
||||||
|
payload.customDomain = customDomainInput.trim();
|
||||||
|
}
|
||||||
createMutation.mutate(payload);
|
createMutation.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1257,14 +1274,15 @@ export default function DeployPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
{/* Redis */}
|
{/* Redis */}
|
||||||
<button
|
<div className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||||
type="button"
|
|
||||||
onClick={() => setForm({ ...form, enableRedis: !form.enableRedis })}
|
|
||||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
|
||||||
form.enableRedis
|
form.enableRedis
|
||||||
? 'border-red-400 bg-red-50 shadow-sm'
|
? 'border-red-400 bg-red-50 shadow-sm'
|
||||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
}`}
|
}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm({ ...form, enableRedis: !form.enableRedis })}
|
||||||
|
className="w-full text-left"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<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'}`}>
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRedis ? 'bg-red-100' : 'bg-gray-100'}`}>
|
||||||
@@ -1277,22 +1295,38 @@ export default function DeployPage() {
|
|||||||
<p className="text-xs text-gray-500">In-memory cache & store</p>
|
<p className="text-xs text-gray-500">In-memory cache & store</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
{form.enableRedis && (
|
{form.enableRedis && (
|
||||||
<div className="mt-3 pt-3 border-t border-red-200 text-xs text-red-600">
|
<div className="mt-3 pt-3 border-t border-red-200 space-y-2">
|
||||||
<p>REDIS_HOST, REDIS_PASSWORD, REDIS_URL will be available</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-gray-600">Version:</label>
|
||||||
|
<select
|
||||||
|
className="text-xs border border-red-200 rounded px-2 py-1 bg-white"
|
||||||
|
value={form.redisVersion || '7.2'}
|
||||||
|
onChange={(e) => setForm({ ...form, redisVersion: e.target.value })}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<option value="7.2">7.2 (Latest)</option>
|
||||||
|
<option value="7.0">7.0</option>
|
||||||
|
<option value="6.2">6.2 (LTS)</option>
|
||||||
|
<option value="6.0">6.0</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-red-600">REDIS_HOST, REDIS_PASSWORD, REDIS_URL</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
{/* RabbitMQ */}
|
{/* RabbitMQ */}
|
||||||
<button
|
<div className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||||
type="button"
|
|
||||||
onClick={() => setForm({ ...form, enableRabbitmq: !form.enableRabbitmq })}
|
|
||||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
|
||||||
form.enableRabbitmq
|
form.enableRabbitmq
|
||||||
? 'border-orange-400 bg-orange-50 shadow-sm'
|
? 'border-orange-400 bg-orange-50 shadow-sm'
|
||||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
}`}
|
}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm({ ...form, enableRabbitmq: !form.enableRabbitmq })}
|
||||||
|
className="w-full text-left"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<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'}`}>
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRabbitmq ? 'bg-orange-100' : 'bg-gray-100'}`}>
|
||||||
@@ -1305,22 +1339,37 @@ export default function DeployPage() {
|
|||||||
<p className="text-xs text-gray-500">Message broker</p>
|
<p className="text-xs text-gray-500">Message broker</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
{form.enableRabbitmq && (
|
{form.enableRabbitmq && (
|
||||||
<div className="mt-3 pt-3 border-t border-orange-200 text-xs text-orange-600">
|
<div className="mt-3 pt-3 border-t border-orange-200 space-y-2">
|
||||||
<p>RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL will be available</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-gray-600">Version:</label>
|
||||||
|
<select
|
||||||
|
className="text-xs border border-orange-200 rounded px-2 py-1 bg-white"
|
||||||
|
value={form.rabbitmqVersion || '3.13'}
|
||||||
|
onChange={(e) => setForm({ ...form, rabbitmqVersion: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="3.13">3.13 (Latest)</option>
|
||||||
|
<option value="3.12">3.12 (LTS)</option>
|
||||||
|
<option value="3.11">3.11</option>
|
||||||
|
<option value="3.10">3.10</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-orange-600">RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
{/* Elasticsearch */}
|
{/* Elasticsearch */}
|
||||||
<button
|
<div className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||||
type="button"
|
|
||||||
onClick={() => setForm({ ...form, enableElasticsearch: !form.enableElasticsearch })}
|
|
||||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
|
||||||
form.enableElasticsearch
|
form.enableElasticsearch
|
||||||
? 'border-yellow-400 bg-yellow-50 shadow-sm'
|
? 'border-yellow-400 bg-yellow-50 shadow-sm'
|
||||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
}`}
|
}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm({ ...form, enableElasticsearch: !form.enableElasticsearch })}
|
||||||
|
className="w-full text-left"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<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'}`}>
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableElasticsearch ? 'bg-yellow-100' : 'bg-gray-100'}`}>
|
||||||
@@ -1331,19 +1380,96 @@ export default function DeployPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900">Elasticsearch</p>
|
<p className="font-semibold text-gray-900">Elasticsearch</p>
|
||||||
<p className="text-xs text-gray-500">Logging & search</p>
|
<p className="text-xs text-gray-500">Centralized logging</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
{form.enableElasticsearch && (
|
{form.enableElasticsearch && (
|
||||||
<div className="mt-3 pt-3 border-t border-yellow-200 text-xs text-yellow-600">
|
<div className="mt-3 pt-3 border-t border-yellow-200 space-y-2">
|
||||||
<p>Logs collected via Fluent Bit sidecar</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-gray-600">Version:</label>
|
||||||
|
<select
|
||||||
|
className="text-xs border border-yellow-200 rounded px-2 py-1 bg-white"
|
||||||
|
value={form.elasticsearchVersion || '8.12'}
|
||||||
|
onChange={(e) => setForm({ ...form, elasticsearchVersion: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="8.12">8.12 (Latest)</option>
|
||||||
|
<option value="8.11">8.11</option>
|
||||||
|
<option value="7.17">7.17 (LTS)</option>
|
||||||
|
<option value="7.10">7.10</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-yellow-600">Logs collected via Fluent Bit sidecar</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Paths Configuration - shown when Elasticsearch is enabled */}
|
||||||
|
{form.enableElasticsearch && (
|
||||||
|
<div className="mt-4 p-4 bg-yellow-50/50 border border-yellow-200 rounded-xl">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<svg className="w-5 h-5 text-yellow-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-800">Log File Paths</h4>
|
||||||
|
<span className="text-xs text-gray-400">(optional)</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
Specify which log files to collect. Leave empty for default paths based on runtime.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(form.logPaths || []).map((path, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="input-field flex-1 text-sm"
|
||||||
|
value={path}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newPaths = [...(form.logPaths || [])];
|
||||||
|
newPaths[idx] = e.target.value;
|
||||||
|
setForm({ ...form, logPaths: newPaths });
|
||||||
|
}}
|
||||||
|
placeholder="/var/log/app/*.log"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newPaths = (form.logPaths || []).filter((_, i) => i !== idx);
|
||||||
|
setForm({ ...form, logPaths: newPaths });
|
||||||
|
}}
|
||||||
|
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm({ ...form, logPaths: [...(form.logPaths || []), ''] })}
|
||||||
|
className="text-sm text-yellow-600 hover:text-yellow-700 font-medium"
|
||||||
|
>
|
||||||
|
+ Add log path
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 p-3 bg-white/50 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
<strong>Default paths by runtime:</strong><br/>
|
||||||
|
• Node.js/Go/Python: <code className="bg-gray-100 px-1 rounded">/app/logs/*.log</code><br/>
|
||||||
|
• Laravel/PHP: <code className="bg-gray-100 px-1 rounded">/var/www/html/storage/logs/*.log</code><br/>
|
||||||
|
• WordPress: <code className="bg-gray-100 px-1 rounded">/var/www/html/wp-content/debug.log</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
|
{(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
|
||||||
<p className="mt-4 text-xs text-gray-500">
|
<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.
|
{form.enableElasticsearch
|
||||||
|
? 'Logs are sent to a centralized Elasticsearch cluster. View your logs in Kibana dashboard.'
|
||||||
|
: 'Each enabled service adds to the monthly cost. Services are deployed in your namespace.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1803,6 +1929,55 @@ export default function DeployPage() {
|
|||||||
<span className="text-sm font-medium">{Object.keys(form.envVars!).length} defined</span>
|
<span className="text-sm font-medium">{Object.keys(form.envVars!).length} defined</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{enableCustomDomain && customDomainInput && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Custom Domain</span>
|
||||||
|
<span className="text-sm font-medium font-mono">{customDomainInput}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Domain Option */}
|
||||||
|
<div className="bg-white rounded-xl p-5 border border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center">
|
||||||
|
<Globe className="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800">دامنه اختصاصی</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
وبسایت را روی دامنه خود ببینید (با SSL رایگان)
|
||||||
|
{domainPriceData && domainPriceData.monthlyPrice > 0 && (
|
||||||
|
<span className="text-purple-600 font-medium"> — {domainPriceData.monthlyPrice.toLocaleString('fa-IR')} تومان/ماه</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEnableCustomDomain(!enableCustomDomain)}
|
||||||
|
className={`relative w-12 h-6 rounded-full transition-colors ${enableCustomDomain ? 'bg-purple-600' : 'bg-gray-300'}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${enableCustomDomain ? 'translate-x-6' : 'translate-x-0.5'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{enableCustomDomain && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">آدرس دامنه</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
dir="ltr"
|
||||||
|
value={customDomainInput}
|
||||||
|
onChange={(e) => setCustomDomainInput(e.target.value)}
|
||||||
|
placeholder="example.com or www.example.com"
|
||||||
|
className="input-field w-full font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-2" dir="rtl">
|
||||||
|
بعد از دیپلوی، باید رکورد DNS دامنه خود را تنظیم کنید. راهنمای کامل در صفحه جزئیات اپلیکیشن نمایش داده میشود.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cost Breakdown */}
|
{/* Cost Breakdown */}
|
||||||
|
|||||||
@@ -39,11 +39,17 @@ export interface Application {
|
|||||||
poolId?: string;
|
poolId?: string;
|
||||||
latestImageTag?: string;
|
latestImageTag?: string;
|
||||||
subdomain?: string;
|
subdomain?: string;
|
||||||
|
customDomain?: string;
|
||||||
|
customDomainStatus?: 'none' | 'pending_dns' | 'verified';
|
||||||
|
customDomainVerifiedAt?: string;
|
||||||
deployments?: Deployment[];
|
deployments?: Deployment[];
|
||||||
// Optional services
|
// Optional services
|
||||||
enableRedis?: boolean;
|
enableRedis?: boolean;
|
||||||
|
redisVersion?: string;
|
||||||
enableRabbitmq?: boolean;
|
enableRabbitmq?: boolean;
|
||||||
|
rabbitmqVersion?: string;
|
||||||
enableElasticsearch?: boolean;
|
enableElasticsearch?: boolean;
|
||||||
|
elasticsearchVersion?: string;
|
||||||
logPaths?: string[];
|
logPaths?: string[];
|
||||||
// Billing & Lifecycle
|
// Billing & Lifecycle
|
||||||
planId?: string;
|
planId?: string;
|
||||||
@@ -127,10 +133,14 @@ export interface CreateApplicationDto {
|
|||||||
port?: number;
|
port?: number;
|
||||||
clusterId?: string;
|
clusterId?: string;
|
||||||
poolId?: string;
|
poolId?: string;
|
||||||
|
customDomain?: string;
|
||||||
// Optional services
|
// Optional services
|
||||||
enableRedis?: boolean;
|
enableRedis?: boolean;
|
||||||
|
redisVersion?: string;
|
||||||
enableRabbitmq?: boolean;
|
enableRabbitmq?: boolean;
|
||||||
|
rabbitmqVersion?: string;
|
||||||
enableElasticsearch?: boolean;
|
enableElasticsearch?: boolean;
|
||||||
|
elasticsearchVersion?: string;
|
||||||
logPaths?: string[];
|
logPaths?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +273,7 @@ export interface TicketStats {
|
|||||||
// ─── Billing types ──────────────────────────────────
|
// ─── Billing types ──────────────────────────────────
|
||||||
|
|
||||||
export type BillingCycle = 'hourly' | 'monthly' | 'yearly';
|
export type BillingCycle = 'hourly' | 'monthly' | 'yearly';
|
||||||
export type PricingResourceType = 'base_fee' | 'cpu_per_core' | 'memory_per_gb' | 'storage_per_gb' | 'database_addon';
|
export type PricingResourceType = 'base_fee' | 'cpu_per_core' | 'memory_per_gb' | 'storage_per_gb' | 'database_addon' | 'redis_addon' | 'rabbitmq_addon' | 'elasticsearch_addon' | 'custom_domain_addon';
|
||||||
export type TransactionType = 'charge' | 'deduction' | 'refund';
|
export type TransactionType = 'charge' | 'deduction' | 'refund';
|
||||||
|
|
||||||
export interface PricingRule {
|
export interface PricingRule {
|
||||||
|
|||||||
Reference in New Issue
Block a user