Add automatic cluster pool allocation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-18 23:08:06 +03:30
parent 97e4c865b6
commit 72a1519ea0
19 changed files with 983 additions and 151 deletions
@@ -0,0 +1,110 @@
-- Cluster pool allocation, health snapshots, and audit logs.
ALTER TABLE clusters
ADD COLUMN IF NOT EXISTS weight INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS tags JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS "healthStatus" VARCHAR NOT NULL DEFAULT 'unknown',
ADD COLUMN IF NOT EXISTS "lastHealthCheckedAt" TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS "healthMessage" VARCHAR,
ADD COLUMN IF NOT EXISTS "availableResources" JSONB;
ALTER TABLE cluster_pools
ADD COLUMN IF NOT EXISTS "isDefault" BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 100;
CREATE INDEX IF NOT EXISTS idx_clusters_status_health
ON clusters(status, "healthStatus");
CREATE INDEX IF NOT EXISTS idx_clusters_tags
ON clusters USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_cluster_pools_default_priority
ON cluster_pools("isDefault", priority)
WHERE "isActive" = TRUE;
CREATE TABLE IF NOT EXISTS cluster_health (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"clusterId" UUID NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
status VARCHAR NOT NULL DEFAULT 'unknown',
"readyNodes" INTEGER NOT NULL DEFAULT 0,
"nodeCount" INTEGER NOT NULL DEFAULT 0,
"cpuAllocatable" VARCHAR,
"memoryAllocatable" VARCHAR,
"podCount" INTEGER NOT NULL DEFAULT 0,
"appCount" INTEGER NOT NULL DEFAULT 0,
message VARCHAR,
resources JSONB,
"checkedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cluster_health_cluster_checked
ON cluster_health("clusterId", "checkedAt" DESC);
CREATE TABLE IF NOT EXISTS cluster_allocation_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"applicationId" UUID REFERENCES applications(id) ON DELETE SET NULL,
"userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
"poolId" UUID REFERENCES cluster_pools(id) ON DELETE SET NULL,
"selectedClusterId" UUID REFERENCES clusters(id) ON DELETE SET NULL,
strategy VARCHAR NOT NULL DEFAULT 'weighted-resource',
"estimatedRequest" JSONB,
"candidateScores" JSONB,
"rejectionReasons" JSONB,
status VARCHAR NOT NULL DEFAULT 'success',
message VARCHAR,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cluster_allocation_logs_user_created
ON cluster_allocation_logs("userId", "createdAt" DESC);
CREATE INDEX IF NOT EXISTS idx_cluster_allocation_logs_app
ON cluster_allocation_logs("applicationId");
CREATE INDEX IF NOT EXISTS idx_cluster_allocation_logs_cluster_created
ON cluster_allocation_logs("selectedClusterId", "createdAt" DESC);
ALTER TABLE applications
ADD COLUMN IF NOT EXISTS "clusterId" UUID,
ADD COLUMN IF NOT EXISTS "poolId" UUID;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'applications' AND column_name = 'clusterId' AND data_type <> 'uuid'
) THEN
ALTER TABLE applications
ALTER COLUMN "clusterId" TYPE UUID USING NULLIF("clusterId", '')::uuid;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'applications' AND column_name = 'poolId' AND data_type <> 'uuid'
) THEN
ALTER TABLE applications
ALTER COLUMN "poolId" TYPE UUID USING NULLIF("poolId", '')::uuid;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_applications_cluster'
) THEN
ALTER TABLE applications
ADD CONSTRAINT fk_applications_cluster FOREIGN KEY ("clusterId")
REFERENCES clusters(id) ON DELETE SET NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_applications_pool'
) THEN
ALTER TABLE applications
ADD CONSTRAINT fk_applications_pool FOREIGN KEY ("poolId")
REFERENCES cluster_pools(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_applications_cluster_id ON applications("clusterId");
CREATE INDEX IF NOT EXISTS idx_applications_pool_id ON applications("poolId");
@@ -23,49 +23,35 @@ export class ApplicationsService {
) {} ) {}
async create(userId: string, dto: CreateApplicationDto, userRole?: string): Promise<Application> { async create(userId: string, dto: CreateApplicationDto, userRole?: string): Promise<Application> {
// Only admin/technical users can manually select cluster or pool // End users and technical staff cannot influence placement; only admins may manually assign.
// Regular users always get the default cluster assignment const isAdmin = userRole === UserRole.ADMIN;
if (userRole !== UserRole.ADMIN && userRole !== UserRole.TECHNICAL) { if (!isAdmin) {
if (dto.clusterId || dto.poolId) { if (dto.clusterId || dto.poolId) {
this.logger.warn(`Non-admin user ${userId} attempted manual cluster/pool selection ignoring`); this.logger.warn(`Non-admin user ${userId} attempted manual cluster/pool selection - ignoring`);
} }
dto.clusterId = undefined; dto.clusterId = undefined;
dto.poolId = undefined; dto.poolId = undefined;
} }
// Cluster assignment: 3 modes
// 1. Manual: specific clusterId provided
// 2. Pool-based LB: poolId provided → pick from pool using pool's strategy
// 3. Default fallback: no clusterId/poolId → use default cluster
let clusterId = dto.clusterId; let clusterId = dto.clusterId;
let poolId = dto.poolId; let poolId = dto.poolId;
let allocationLogId: string | undefined;
if (!clusterId) { if (isAdmin && clusterId) {
if (poolId) { await this.clustersService.findOne(clusterId);
// Mode 2: Pool-based load balancing this.logger.log(`Manual cluster assignment for app "${dto.name}" -> cluster ${clusterId}`);
try {
const optimal = await this.clustersService.getOptimalClusterFromPool(poolId);
clusterId = optimal.id;
this.logger.log(`Pool-assigned cluster "${optimal.name}" to app "${dto.name}" (pool LB)`);
} catch (err: any) {
this.logger.warn(`Pool assignment failed for app "${dto.name}": ${err.message}`);
poolId = undefined; // Clear invalid pool
}
}
// Mode 3: Default fallback (no manual cluster, no pool, or pool failed)
if (!clusterId) {
try {
const defaultCluster = await this.clustersService.getDefault();
clusterId = defaultCluster.id;
this.logger.log(`Default-assigned cluster "${defaultCluster.name}" to app "${dto.name}"`);
} catch {
this.logger.warn('No cluster available — app will be created without cluster assignment');
}
}
} else { } else {
this.logger.log(`Manual cluster assignment for app "${dto.name}" → cluster ${clusterId}`); const allocationDto = { ...dto };
if (poolId) {
allocationDto.poolId = poolId;
}
const allocation = await this.clustersService.selectClusterForApplication(allocationDto, userId);
clusterId = allocation.cluster.id;
poolId = allocation.pool?.id || (isAdmin ? poolId : undefined);
allocationLogId = allocation.allocationLogId;
if (!clusterId) {
throw new BadRequestException('No eligible cluster available for this application');
}
} }
// Generate database credentials if a database is requested // Generate database credentials if a database is requested
@@ -109,7 +95,11 @@ export class ApplicationsService {
platformDomain, platformDomain,
), ),
}); });
return this.appsRepository.save(app); const saved = await this.appsRepository.save(app);
if (allocationLogId) {
await this.clustersService.attachAllocationToApplication(allocationLogId, saved.id);
}
return saved;
} }
async findAllByUser(userId: string): Promise<Application[]> { async findAllByUser(userId: string): Promise<Application[]> {
@@ -182,12 +182,12 @@ export class CreateApplicationDto {
@IsNumber() @IsNumber()
port?: number; port?: number;
@ApiPropertyOptional({ description: 'Cluster ID to deploy to (auto-assigns default if empty)' }) @ApiPropertyOptional({ description: 'Admin-only manual cluster override. Ignored for non-admin users.' })
@IsOptional() @IsOptional()
@IsString() @IsString()
clusterId?: string; clusterId?: string;
@ApiPropertyOptional({ description: 'Cluster pool ID for load-balanced deployment' }) @ApiPropertyOptional({ description: 'Admin-only pool override. Ignored for non-admin users.' })
@IsOptional() @IsOptional()
@IsString() @IsString()
poolId?: string; poolId?: string;
@@ -136,10 +136,10 @@ export class Application {
@Column() @Column()
userId: string; userId: string;
@Column({ nullable: true }) @Column({ type: 'uuid', nullable: true })
clusterId: string; clusterId: string;
@Column({ nullable: true }) @Column({ type: 'uuid', nullable: true })
poolId: string; // Cluster pool used for load-balanced assignment poolId: string; // Cluster pool used for load-balanced assignment
@OneToMany(() => Deployment, (deployment: Deployment) => deployment.application) @OneToMany(() => Deployment, (deployment: Deployment) => deployment.application)
+61 -51
View File
@@ -24,15 +24,17 @@ import { UserRole } from '../common/enums';
export class ClustersController { export class ClustersController {
constructor(private readonly clustersService: ClustersService) {} constructor(private readonly clustersService: ClustersService) {}
// ─── Public endpoints (any authenticated user) ──────────────────── // ─── Admin-safe lookup endpoints (no end-user exposure) ───────────
@Get('public') @Get('public')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List available clusters (public info, no kubeconfig)' }) @ApiOperation({ summary: 'List available clusters (public info, no kubeconfig)' })
async findAllPublic() { async findAllPublic() {
return this.clustersService.findAllPublic(); return this.clustersService.findAllPublic();
} }
@Get('pools/public') @Get('pools/public')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List active cluster pools with resolved cluster names' }) @ApiOperation({ summary: 'List active cluster pools with resolved cluster names' })
async findAllPoolsPublic() { async findAllPoolsPublic() {
return this.clustersService.findAllPoolsPublic(); return this.clustersService.findAllPoolsPublic();
@@ -41,90 +43,98 @@ export class ClustersController {
// ─── Cluster admin endpoints ────────────────────────────────────── // ─── Cluster admin endpoints ──────────────────────────────────────
@Post() @Post()
@Roles(UserRole.ADMIN, UserRole.TECHNICAL) @Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin/Technical)' }) @ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin)' })
async create(@Body() dto: CreateClusterDto) { async create(@Body() dto: CreateClusterDto) {
return this.clustersService.create(dto); return this.clustersService.create(dto);
} }
@Get() @Get()
@Roles(UserRole.ADMIN, UserRole.TECHNICAL) @Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all clusters (Admin/Technical)' }) @ApiOperation({ summary: 'List all clusters (Admin)' })
async findAll() { async findAll() {
return this.clustersService.findAll(); return this.clustersService.findAll();
} }
@Get(':id/resources')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Get cluster resource usage — nodes, CPU, memory, pods (Admin/Technical)' })
async getClusterResources(@Param('id') id: string) {
return this.clustersService.getClusterResources(id);
}
@Get(':id')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Get cluster details (Admin/Technical)' })
async findOne(@Param('id') id: string) {
return this.clustersService.findOne(id);
}
@Patch(':id')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Update cluster configuration (Admin/Technical)' })
async update(@Param('id') id: string, @Body() dto: UpdateClusterDto) {
return this.clustersService.update(id, dto);
}
@Post(':id/test')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Test connectivity to a registered cluster (Admin/Technical)' })
async testConnection(@Param('id') id: string) {
return this.clustersService.testClusterById(id);
}
@Delete(':id')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Remove a cluster (Admin/Technical)' })
async delete(@Param('id') id: string) {
await this.clustersService.delete(id);
return { message: 'Cluster deleted' };
}
// ─── Cluster Pool admin endpoints ───────────────────────────────── // ─── Cluster Pool admin endpoints ─────────────────────────────────
@Post('pools') @Post('pools')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL) @Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Create a cluster pool for load balancing (Admin/Technical)' }) @ApiOperation({ summary: 'Create a cluster pool for load balancing (Admin)' })
async createPool(@Body() dto: CreateClusterPoolDto) { async createPool(@Body() dto: CreateClusterPoolDto) {
return this.clustersService.createPool(dto); return this.clustersService.createPool(dto);
} }
@Get('pools') @Get('pools')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL) @Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all cluster pools (Admin/Technical)' }) @ApiOperation({ summary: 'List all cluster pools (Admin)' })
async findAllPools() { async findAllPools() {
return this.clustersService.findAllPools(); return this.clustersService.findAllPools();
} }
@Get('pools/:id') @Get('pools/:id')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL) @Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get cluster pool details (Admin/Technical)' }) @ApiOperation({ summary: 'Get cluster pool details (Admin)' })
async findOnePool(@Param('id') id: string) { async findOnePool(@Param('id') id: string) {
return this.clustersService.findOnePool(id); return this.clustersService.findOnePool(id);
} }
@Patch('pools/:id') @Patch('pools/:id')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL) @Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Update cluster pool (Admin/Technical)' }) @ApiOperation({ summary: 'Update cluster pool (Admin)' })
async updatePool(@Param('id') id: string, @Body() dto: UpdateClusterPoolDto) { async updatePool(@Param('id') id: string, @Body() dto: UpdateClusterPoolDto) {
return this.clustersService.updatePool(id, dto); return this.clustersService.updatePool(id, dto);
} }
@Delete('pools/:id') @Delete('pools/:id')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL) @Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Delete a cluster pool (Admin/Technical)' }) @ApiOperation({ summary: 'Delete a cluster pool (Admin)' })
async deletePool(@Param('id') id: string) { async deletePool(@Param('id') id: string) {
await this.clustersService.deletePool(id); await this.clustersService.deletePool(id);
return { message: 'Cluster pool deleted' }; return { message: 'Cluster pool deleted' };
} }
@Get('allocation-logs')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List recent cluster allocation decisions (Admin)' })
async listAllocationLogs() {
return this.clustersService.listAllocationLogs();
}
@Get(':id/resources')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get cluster resource usage - nodes, CPU, memory, pods (Admin)' })
async getClusterResources(@Param('id') id: string) {
return this.clustersService.getClusterResources(id);
}
@Get(':id')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get cluster details (Admin)' })
async findOne(@Param('id') id: string) {
const { kubeconfig, ...cluster } = await this.clustersService.findOne(id);
return cluster;
}
@Patch(':id')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Update cluster configuration (Admin)' })
async update(@Param('id') id: string, @Body() dto: UpdateClusterDto) {
return this.clustersService.update(id, dto);
}
@Post(':id/test')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Test connectivity to a registered cluster (Admin)' })
async testConnection(@Param('id') id: string) {
return this.clustersService.testClusterById(id);
}
@Delete(':id')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Remove a cluster (Admin)' })
async delete(@Param('id') id: string) {
await this.clustersService.delete(id);
return { message: 'Cluster deleted' };
}
} }
+3 -1
View File
@@ -4,11 +4,13 @@ import { ClustersService } from './clusters.service';
import { ClustersController } from './clusters.controller'; import { ClustersController } from './clusters.controller';
import { Cluster } from './entities/cluster.entity'; import { Cluster } from './entities/cluster.entity';
import { ClusterPool } from './entities/cluster-pool.entity'; import { ClusterPool } from './entities/cluster-pool.entity';
import { ClusterHealth } from './entities/cluster-health.entity';
import { ClusterAllocationLog } from './entities/cluster-allocation-log.entity';
import { KubernetesModule } from '../kubernetes/kubernetes.module'; import { KubernetesModule } from '../kubernetes/kubernetes.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Cluster, ClusterPool]), TypeOrmModule.forFeature([Cluster, ClusterPool, ClusterHealth, ClusterAllocationLog]),
forwardRef(() => KubernetesModule), forwardRef(() => KubernetesModule),
], ],
controllers: [ClustersController], controllers: [ClustersController],
+390 -13
View File
@@ -3,12 +3,16 @@ import { InjectRepository } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Repository, DataSource, In } from 'typeorm'; import { Repository, DataSource, In } from 'typeorm';
import * as k8s from '@kubernetes/client-node'; import * as k8s from '@kubernetes/client-node';
import { Cluster } from './entities/cluster.entity'; import * as crypto from 'crypto';
import { Cluster, ClusterHealthStatus } from './entities/cluster.entity';
import { ClusterPool } from './entities/cluster-pool.entity'; import { ClusterPool } from './entities/cluster-pool.entity';
import { ClusterHealth } from './entities/cluster-health.entity';
import { ClusterAllocationLog } from './entities/cluster-allocation-log.entity';
import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto'; import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
import { CreateClusterPoolDto, UpdateClusterPoolDto } from './dto/cluster-pool.dto'; import { CreateClusterPoolDto, UpdateClusterPoolDto } from './dto/cluster-pool.dto';
import { ClusterStatus } from '../common/enums'; import { ClusterStatus } from '../common/enums';
import { ElasticsearchService } from '../kubernetes/elasticsearch.service'; import { ElasticsearchService } from '../kubernetes/elasticsearch.service';
import { CreateApplicationDto } from '../applications/dto/application.dto';
@Injectable() @Injectable()
export class ClustersService { export class ClustersService {
@@ -21,6 +25,10 @@ export class ClustersService {
private clustersRepository: Repository<Cluster>, private clustersRepository: Repository<Cluster>,
@InjectRepository(ClusterPool) @InjectRepository(ClusterPool)
private poolsRepository: Repository<ClusterPool>, private poolsRepository: Repository<ClusterPool>,
@InjectRepository(ClusterHealth)
private healthRepository: Repository<ClusterHealth>,
@InjectRepository(ClusterAllocationLog)
private allocationLogsRepository: Repository<ClusterAllocationLog>,
private dataSource: DataSource, private dataSource: DataSource,
private configService: ConfigService, private configService: ConfigService,
@Inject(forwardRef(() => ElasticsearchService)) @Inject(forwardRef(() => ElasticsearchService))
@@ -34,7 +42,7 @@ export class ClustersService {
async testConnection(kubeconfig: string): Promise<{ connected: boolean; version?: string; error?: string }> { async testConnection(kubeconfig: string): Promise<{ connected: boolean; version?: string; error?: string }> {
try { try {
const kc = new k8s.KubeConfig(); const kc = new k8s.KubeConfig();
kc.loadFromString(kubeconfig); kc.loadFromString(this.decryptKubeconfig(kubeconfig));
const versionApi = kc.makeApiClient(k8s.VersionApi); const versionApi = kc.makeApiClient(k8s.VersionApi);
const result = await versionApi.getCode(); const result = await versionApi.getCode();
@@ -74,13 +82,18 @@ export class ClustersService {
const cluster = this.clustersRepository.create({ const cluster = this.clustersRepository.create({
...dto, ...dto,
kubeconfig: this.encryptKubeconfig(dto.kubeconfig),
status: ClusterStatus.ACTIVE, // Connection verified — mark active status: ClusterStatus.ACTIVE, // Connection verified — mark active
healthStatus: 'healthy',
lastHealthCheckedAt: new Date(),
healthMessage: connectionTest.version ? `Kubernetes ${connectionTest.version}` : 'Connection verified',
}); });
const saved = await this.clustersRepository.save(cluster); const saved = await this.clustersRepository.save(cluster);
const usableCluster = this.withDecryptedKubeconfig(saved);
this.logger.log(`Cluster "${saved.name}" registered (active) — K8s ${connectionTest.version}`); this.logger.log(`Cluster "${saved.name}" registered (active) — K8s ${connectionTest.version}`);
// Bootstrap the cluster with build infrastructure (namespace, registry, SA, etc.) // Bootstrap the cluster with build infrastructure (namespace, registry, SA, etc.)
this.bootstrapCluster(saved.kubeconfig).catch((err) => { this.bootstrapCluster(usableCluster.kubeconfig).catch((err) => {
this.logger.error(`Failed to bootstrap cluster "${saved.name}": ${err.message}`); this.logger.error(`Failed to bootstrap cluster "${saved.name}": ${err.message}`);
}); });
@@ -89,12 +102,33 @@ export class ClustersService {
this.logger.error(`Failed to deploy central logging on "${saved.name}": ${err.message}`); this.logger.error(`Failed to deploy central logging on "${saved.name}": ${err.message}`);
}); });
return saved; this.recordHealthSnapshot(saved, {
status: 'healthy',
message: connectionTest.version ? `Kubernetes ${connectionTest.version}` : 'Connection verified',
}).catch((err) => this.logger.warn(`Failed to record cluster health: ${err.message}`));
return usableCluster;
} }
async findAll(): Promise<Cluster[]> { async findAll(): Promise<Cluster[]> {
return this.clustersRepository.find({ return this.clustersRepository.find({
select: ['id', 'name', 'description', 'status', 'apiServer', 'region', 'provider', 'isDefault', 'createdAt'], select: [
'id',
'name',
'description',
'status',
'apiServer',
'region',
'provider',
'isDefault',
'weight',
'tags',
'healthStatus',
'lastHealthCheckedAt',
'healthMessage',
'availableResources',
'createdAt',
],
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
}); });
} }
@@ -104,7 +138,7 @@ export class ClustersService {
if (!cluster) { if (!cluster) {
throw new NotFoundException('Cluster not found'); throw new NotFoundException('Cluster not found');
} }
return cluster; return this.withDecryptedKubeconfig(cluster);
} }
async getDefault(): Promise<Cluster> { async getDefault(): Promise<Cluster> {
@@ -126,7 +160,7 @@ export class ClustersService {
if (!cluster) { if (!cluster) {
throw new NotFoundException('No active cluster available'); throw new NotFoundException('No active cluster available');
} }
return cluster; return this.withDecryptedKubeconfig(cluster);
} }
async update(id: string, dto: UpdateClusterDto): Promise<Cluster> { async update(id: string, dto: UpdateClusterDto): Promise<Cluster> {
@@ -141,10 +175,11 @@ export class ClustersService {
); );
} }
dto.status = ClusterStatus.ACTIVE; dto.status = ClusterStatus.ACTIVE;
dto.kubeconfig = this.encryptKubeconfig(dto.kubeconfig);
this.logger.log(`Cluster "${cluster.name}" kubeconfig updated — connection verified (K8s ${connectionTest.version})`); this.logger.log(`Cluster "${cluster.name}" kubeconfig updated — connection verified (K8s ${connectionTest.version})`);
// Re-bootstrap build infrastructure on the new/updated cluster // Re-bootstrap build infrastructure on the new/updated cluster
this.bootstrapCluster(dto.kubeconfig).catch((err: any) => { this.bootstrapCluster(this.decryptKubeconfig(dto.kubeconfig)).catch((err: any) => {
this.logger.error(`Failed to bootstrap cluster "${cluster.name}": ${err.message}`); this.logger.error(`Failed to bootstrap cluster "${cluster.name}": ${err.message}`);
}); });
} }
@@ -159,7 +194,9 @@ export class ClustersService {
} }
} }
Object.assign(cluster, dto); Object.assign(cluster, dto);
return this.clustersRepository.save(cluster); cluster.kubeconfig = this.encryptKubeconfig(cluster.kubeconfig);
const saved = await this.clustersRepository.save(cluster);
return this.withDecryptedKubeconfig(saved);
} }
/** /**
@@ -171,7 +208,17 @@ export class ClustersService {
const result = await this.testConnection(cluster.kubeconfig); const result = await this.testConnection(cluster.kubeconfig);
cluster.status = result.connected ? ClusterStatus.ACTIVE : ClusterStatus.INACTIVE; cluster.status = result.connected ? ClusterStatus.ACTIVE : ClusterStatus.INACTIVE;
cluster.healthStatus = result.connected ? 'healthy' : 'unhealthy';
cluster.lastHealthCheckedAt = new Date();
cluster.healthMessage = result.connected
? (result.version ? `Kubernetes ${result.version}` : 'Connection verified')
: result.error || 'Connection failed';
cluster.kubeconfig = this.encryptKubeconfig(cluster.kubeconfig);
await this.clustersRepository.save(cluster); await this.clustersRepository.save(cluster);
await this.recordHealthSnapshot(cluster, {
status: cluster.healthStatus,
message: cluster.healthMessage,
});
this.logger.log(`Cluster "${cluster.name}" test: ${result.connected ? 'ACTIVE' : 'INACTIVE'}`); this.logger.log(`Cluster "${cluster.name}" test: ${result.connected ? 'ACTIVE' : 'INACTIVE'}`);
return result; return result;
@@ -188,6 +235,106 @@ export class ClustersService {
}); });
} }
async selectClusterForApplication(
dto: CreateApplicationDto,
userId: string,
): Promise<{ cluster: Cluster; pool?: ClusterPool; allocationLogId: string }> {
const estimatedRequest = this.estimateApplicationRequest(dto);
let pool = dto.poolId
? await this.poolsRepository.findOne({ where: { id: dto.poolId, isActive: true } })
: null;
if (dto.poolId && !pool) {
throw new BadRequestException('Selected cluster pool is not active or does not exist');
}
if (!pool) {
pool = await this.poolsRepository.findOne({
where: { isActive: true, isDefault: true },
order: { priority: 'ASC', createdAt: 'ASC' },
}) || await this.poolsRepository.findOne({
where: { isActive: true },
order: { priority: 'ASC', createdAt: 'ASC' },
});
}
const candidateQuery = this.clustersRepository
.createQueryBuilder('cluster')
.where('cluster.status = :status', { status: ClusterStatus.ACTIVE });
if (pool?.clusterIds?.length) {
candidateQuery.andWhere('cluster.id IN (:...clusterIds)', { clusterIds: pool.clusterIds });
}
const candidates = await candidateQuery.getMany();
const appCounts = await this.getAppCounts(candidates.map((cluster) => cluster.id));
const candidateScores: Record<string, any>[] = [];
const rejectionReasons: Record<string, any>[] = [];
for (const cluster of candidates) {
const rejection = this.getClusterRejectionReason(cluster, estimatedRequest);
if (rejection) {
rejectionReasons.push({ clusterId: cluster.id, clusterName: cluster.name, reason: rejection });
continue;
}
const score = this.scoreCluster(cluster, estimatedRequest, appCounts.get(cluster.id) || 0, pool?.strategy || 'weighted-resource');
candidateScores.push({
clusterId: cluster.id,
clusterName: cluster.name,
score,
weight: cluster.weight || 1,
appCount: appCounts.get(cluster.id) || 0,
healthStatus: cluster.healthStatus,
availableResources: cluster.availableResources || null,
});
}
if (candidateScores.length === 0) {
const log = await this.allocationLogsRepository.save(this.allocationLogsRepository.create({
userId,
poolId: pool?.id,
selectedClusterId: null,
strategy: pool?.strategy || 'weighted-resource',
estimatedRequest,
candidateScores,
rejectionReasons,
status: 'failed',
message: 'No active healthy cluster has enough estimated capacity',
}));
throw new BadRequestException(
`No active healthy cluster has enough capacity for this application (allocation log: ${log.id})`,
);
}
candidateScores.sort((a, b) => b.score - a.score);
const selected = await this.findOne(candidateScores[0].clusterId);
const log = await this.allocationLogsRepository.save(this.allocationLogsRepository.create({
userId,
poolId: pool?.id,
selectedClusterId: selected.id,
strategy: pool?.strategy || 'weighted-resource',
estimatedRequest,
candidateScores,
rejectionReasons,
status: 'success',
message: `Selected ${selected.name}`,
}));
this.logger.log(`Allocator selected cluster "${selected.name}" for user ${userId} (score ${candidateScores[0].score.toFixed(2)})`);
return { cluster: selected, pool: pool || undefined, allocationLogId: log.id };
}
async attachAllocationToApplication(allocationLogId: string, applicationId: string): Promise<void> {
await this.allocationLogsRepository.update({ id: allocationLogId }, { applicationId });
}
async listAllocationLogs(limit = 100): Promise<ClusterAllocationLog[]> {
return this.allocationLogsRepository.find({
relations: ['selectedCluster', 'pool'],
order: { createdAt: 'DESC' },
take: limit,
});
}
/** /**
* Get the optimal cluster using load-balancing strategy. * Get the optimal cluster using load-balancing strategy.
* Strategy: 'least-apps' — picks the active cluster with fewest deployed applications. * Strategy: 'least-apps' — picks the active cluster with fewest deployed applications.
@@ -308,14 +455,21 @@ export class ClustersService {
throw new BadRequestException(`Clusters not found: ${missingIds.join(', ')}`); throw new BadRequestException(`Clusters not found: ${missingIds.join(', ')}`);
} }
const pool = this.poolsRepository.create(dto); if (dto.isDefault === true) {
await this.poolsRepository.update({ isDefault: true }, { isDefault: false });
}
const pool = this.poolsRepository.create({
...dto,
strategy: dto.strategy || 'weighted-resource',
});
const saved = await this.poolsRepository.save(pool); const saved = await this.poolsRepository.save(pool);
this.logger.log(`Cluster pool "${saved.name}" created with ${dto.clusterIds.length} clusters (strategy: ${dto.strategy})`); this.logger.log(`Cluster pool "${saved.name}" created with ${dto.clusterIds.length} clusters (strategy: ${dto.strategy})`);
return saved; return saved;
} }
async findAllPools(): Promise<ClusterPool[]> { async findAllPools(): Promise<ClusterPool[]> {
return this.poolsRepository.find({ order: { createdAt: 'DESC' } }); return this.poolsRepository.find({ order: { isDefault: 'DESC', priority: 'ASC', createdAt: 'DESC' } });
} }
/** /**
@@ -324,7 +478,7 @@ export class ClustersService {
async findAllPoolsPublic(): Promise<(ClusterPool & { clusters: Pick<Cluster, 'id' | 'name' | 'region' | 'provider' | 'status'>[] })[]> { async findAllPoolsPublic(): Promise<(ClusterPool & { clusters: Pick<Cluster, 'id' | 'name' | 'region' | 'provider' | 'status'>[] })[]> {
const pools = await this.poolsRepository.find({ const pools = await this.poolsRepository.find({
where: { isActive: true }, where: { isActive: true },
order: { createdAt: 'DESC' }, order: { isDefault: 'DESC', priority: 'ASC', createdAt: 'DESC' },
}); });
const allClusterIds = [...new Set(pools.flatMap((p) => p.clusterIds))]; const allClusterIds = [...new Set(pools.flatMap((p) => p.clusterIds))];
@@ -367,6 +521,10 @@ export class ClustersService {
} }
} }
if (dto.isDefault === true) {
await this.poolsRepository.update({ isDefault: true }, { isDefault: false });
}
Object.assign(pool, dto); Object.assign(pool, dto);
return this.poolsRepository.save(pool); return this.poolsRepository.save(pool);
} }
@@ -443,6 +601,7 @@ export class ClustersService {
totalMemoryAllocatable: string; totalMemoryAllocatable: string;
podCount: number; podCount: number;
nodeCount: number; nodeCount: number;
readyNodeCount?: number;
appCount: number; appCount: number;
}> { }> {
const cluster = await this.findOne(id); const cluster = await this.findOne(id);
@@ -496,7 +655,8 @@ export class ClustersService {
); );
const appCount = parseInt(appCountResult[0]?.count || '0', 10); const appCount = parseInt(appCountResult[0]?.count || '0', 10);
return { const readyNodeCount = nodes.filter((node) => node.status === 'Ready').length;
const resources = {
nodes, nodes,
totalCpuCapacity: `${totalCpuCap}m`, totalCpuCapacity: `${totalCpuCap}m`,
totalMemoryCapacity: `${totalMemCap.toFixed(0)}Mi`, totalMemoryCapacity: `${totalMemCap.toFixed(0)}Mi`,
@@ -504,10 +664,33 @@ export class ClustersService {
totalMemoryAllocatable: `${totalMemAlloc.toFixed(0)}Mi`, totalMemoryAllocatable: `${totalMemAlloc.toFixed(0)}Mi`,
podCount, podCount,
nodeCount: nodes.length, nodeCount: nodes.length,
readyNodeCount,
appCount, appCount,
}; };
const healthStatus: ClusterHealthStatus = readyNodeCount === nodes.length && nodes.length > 0
? 'healthy'
: readyNodeCount > 0
? 'degraded'
: 'unhealthy';
const healthMessage = `${readyNodeCount}/${nodes.length} nodes ready`;
await this.clustersRepository.update(id, {
healthStatus,
healthMessage,
lastHealthCheckedAt: new Date(),
availableResources: resources as any,
});
await this.recordHealthSnapshot(cluster, { status: healthStatus, message: healthMessage, resources });
return resources;
} catch (err: any) { } catch (err: any) {
this.logger.error(`Failed to get cluster resources for "${cluster.name}": ${err.message}`); this.logger.error(`Failed to get cluster resources for "${cluster.name}": ${err.message}`);
await this.clustersRepository.update(id, {
healthStatus: 'unhealthy',
healthMessage: err.message,
lastHealthCheckedAt: new Date(),
});
await this.recordHealthSnapshot(cluster, { status: 'unhealthy', message: err.message });
throw new BadRequestException(`Cannot fetch resources: ${err.message}`); throw new BadRequestException(`Cannot fetch resources: ${err.message}`);
} }
} }
@@ -713,4 +896,198 @@ export class ClustersService {
if (memory.endsWith('Ti')) return parseFloat(memory) * 1024 * 1024; if (memory.endsWith('Ti')) return parseFloat(memory) * 1024 * 1024;
return parseFloat(memory) / (1024 * 1024); // bytes return parseFloat(memory) / (1024 * 1024); // bytes
} }
private estimateApplicationRequest(dto: CreateApplicationDto): Record<string, any> {
const replicas = Math.max(dto.replicas || 1, 1);
let cpuMillicores = this.parseCpuToMillicores(dto.cpuRequest || '100m') * replicas;
let memoryMi = this.parseMemoryToMi(dto.memoryRequest || '128Mi') * replicas;
let storageMi = this.parseStorageToMi(dto.dbStorageSize || '1Gi') + this.parseStorageToMi(dto.appStorageSize || '2Gi');
if (dto.databaseType && dto.databaseType !== 'none') {
cpuMillicores += 100;
memoryMi += 256;
}
for (const service of ['redis', 'rabbitmq'] as const) {
const enabled = service === 'redis' ? dto.enableRedis : dto.enableRabbitmq;
const custom = dto.optionalServiceResources?.[service];
if (!enabled && !custom) continue;
cpuMillicores += this.parseCpuToMillicores(custom?.cpuRequest || custom?.cpuLimit || '100m');
memoryMi += this.parseMemoryToMi(custom?.memoryRequest || custom?.memoryLimit || '128Mi');
storageMi += (custom?.storageGi || 1) * 1024;
}
return {
cpuMillicores,
memoryMi,
storageMi,
replicas,
podEstimate: replicas + (dto.databaseType && dto.databaseType !== 'none' ? 1 : 0) + (dto.enableRedis ? 1 : 0) + (dto.enableRabbitmq ? 1 : 0),
};
}
private getClusterRejectionReason(cluster: Cluster, estimatedRequest: Record<string, any>): string | null {
if (cluster.status !== ClusterStatus.ACTIVE) {
return `status=${cluster.status}`;
}
if (cluster.healthStatus && !['healthy', 'unknown'].includes(cluster.healthStatus)) {
return `health=${cluster.healthStatus}`;
}
const resources = cluster.availableResources || {};
const cpuFree = this.parseCpuToMillicores(resources.totalCpuAllocatable || resources.cpuAllocatable || '0');
const memoryFree = this.parseMemoryToMi(resources.totalMemoryAllocatable || resources.memoryAllocatable || '0');
const podCount = Number(resources.podCount || 0);
const nodeCount = Number(resources.nodeCount || 0);
const podCapacity = Number(resources.podCapacity || nodeCount * 110 || 0);
if (cpuFree > 0 && cpuFree < estimatedRequest.cpuMillicores) {
return `insufficient cpu (${cpuFree}m < ${estimatedRequest.cpuMillicores}m)`;
}
if (memoryFree > 0 && memoryFree < estimatedRequest.memoryMi) {
return `insufficient memory (${memoryFree}Mi < ${estimatedRequest.memoryMi}Mi)`;
}
if (podCapacity > 0 && podCount + estimatedRequest.podEstimate > podCapacity) {
return `pod pressure (${podCount}/${podCapacity})`;
}
return null;
}
private scoreCluster(
cluster: Cluster,
estimatedRequest: Record<string, any>,
appCount: number,
strategy: string,
): number {
if (strategy === 'round-robin') {
const idx = this.poolRoundRobinIndices.get('allocator') || 0;
this.poolRoundRobinIndices.set('allocator', idx + 1);
return 1000 - idx;
}
const resources = cluster.availableResources || {};
const cpuFree = this.parseCpuToMillicores(resources.totalCpuAllocatable || resources.cpuAllocatable || '0');
const memoryFree = this.parseMemoryToMi(resources.totalMemoryAllocatable || resources.memoryAllocatable || '0');
const storageFree = this.parseStorageToMi(resources.storageAllocatable || resources.storageFree || '0');
const podCount = Number(resources.podCount || 0);
const nodeCount = Number(resources.nodeCount || 0);
const podCapacity = Number(resources.podCapacity || nodeCount * 110 || 0);
const capacityScore =
this.ratioScore(cpuFree, estimatedRequest.cpuMillicores) * 0.35 +
this.ratioScore(memoryFree, estimatedRequest.memoryMi) * 0.35 +
this.ratioScore(storageFree, estimatedRequest.storageMi) * 0.15 +
(podCapacity > 0 ? Math.max(0, 1 - podCount / podCapacity) : 0.7) * 0.15;
const appPenalty = Math.min(appCount, 100) * 0.75;
if (strategy === 'least-apps') {
return 1000 - appPenalty + capacityScore * 100;
}
return (cluster.weight || 1) * 100 + capacityScore * 100 - appPenalty;
}
private ratioScore(available: number, required: number): number {
if (!available) return 0.7;
if (!required) return 1;
return Math.min(available / required, 10) / 10;
}
private async getAppCounts(clusterIds: string[]): Promise<Map<string, number>> {
if (clusterIds.length === 0) {
return new Map();
}
const rows: { clusterId: string; count: string }[] = await this.dataSource.query(`
SELECT "clusterId", COUNT(*) as count
FROM applications
WHERE "clusterId" = ANY($1)
GROUP BY "clusterId"
`, [clusterIds]);
return new Map(rows.map((row) => [row.clusterId, parseInt(row.count, 10)]));
}
private parseStorageToMi(storage: string): number {
if (!storage) return 0;
if (storage.endsWith('Ki')) return parseFloat(storage) / 1024;
if (storage.endsWith('Mi')) return parseFloat(storage);
if (storage.endsWith('Gi')) return parseFloat(storage) * 1024;
if (storage.endsWith('Ti')) return parseFloat(storage) * 1024 * 1024;
return parseFloat(storage) || 0;
}
private async recordHealthSnapshot(
cluster: Cluster,
snapshot: {
status: ClusterHealthStatus;
message?: string;
resources?: Record<string, any>;
},
): Promise<void> {
const resources = snapshot.resources || cluster.availableResources || {};
await this.healthRepository.save(this.healthRepository.create({
clusterId: cluster.id,
status: snapshot.status,
readyNodes: resources.readyNodeCount || resources.nodeCount || 0,
nodeCount: resources.nodeCount || 0,
cpuAllocatable: resources.totalCpuAllocatable || resources.cpuAllocatable || null,
memoryAllocatable: resources.totalMemoryAllocatable || resources.memoryAllocatable || null,
podCount: resources.podCount || 0,
appCount: resources.appCount || 0,
message: snapshot.message,
resources,
}));
}
private encryptKubeconfig(kubeconfig: string): string {
if (!kubeconfig || kubeconfig.startsWith('enc:v1:')) {
return kubeconfig;
}
const key = this.getKubeconfigEncryptionKey();
if (!key) {
return kubeconfig;
}
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(kubeconfig, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `enc:v1:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
}
private decryptKubeconfig(kubeconfig: string): string {
if (!kubeconfig?.startsWith('enc:v1:')) {
return kubeconfig;
}
const key = this.getKubeconfigEncryptionKey();
if (!key) {
throw new BadRequestException('Kubeconfig is encrypted but CLUSTER_KUBECONFIG_KEY is not configured');
}
const [, , ivRaw, tagRaw, encryptedRaw] = kubeconfig.split(':');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivRaw, 'base64'));
decipher.setAuthTag(Buffer.from(tagRaw, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(encryptedRaw, 'base64')),
decipher.final(),
]).toString('utf8');
}
private withDecryptedKubeconfig(cluster: Cluster): Cluster {
return {
...cluster,
kubeconfig: this.decryptKubeconfig(cluster.kubeconfig),
};
}
private getKubeconfigEncryptionKey(): Buffer | null {
const secret = this.configService.get<string>('CLUSTER_KUBECONFIG_KEY') || this.configService.get<string>('cluster.kubeconfigKey');
if (!secret) {
return null;
}
if (secret.length === 64 && /^[0-9a-f]+$/i.test(secret)) {
return Buffer.from(secret, 'hex');
}
return crypto.createHash('sha256').update(secret).digest();
}
} }
+29 -7
View File
@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsBoolean, IsArray, IsIn } from 'class-validator'; import { IsString, IsOptional, IsBoolean, IsArray, IsIn, IsNumber, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateClusterPoolDto { export class CreateClusterPoolDto {
@@ -11,14 +11,25 @@ export class CreateClusterPoolDto {
@IsString() @IsString()
description?: string; description?: string;
@ApiProperty({ example: 'least-apps', enum: ['least-apps', 'round-robin'] }) @ApiProperty({ example: 'weighted-resource', enum: ['least-apps', 'round-robin', 'weighted-resource'] })
@IsIn(['least-apps', 'round-robin']) @IsIn(['least-apps', 'round-robin', 'weighted-resource'])
strategy: 'least-apps' | 'round-robin'; strategy: 'least-apps' | 'round-robin' | 'weighted-resource';
@ApiProperty({ example: ['uuid-1', 'uuid-2'], description: 'Array of cluster IDs in this pool' }) @ApiProperty({ example: ['uuid-1', 'uuid-2'], description: 'Array of cluster IDs in this pool' })
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
clusterIds: string[]; clusterIds: string[];
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@ApiPropertyOptional({ example: 100 })
@IsOptional()
@IsNumber()
@Min(1)
priority?: number;
} }
export class UpdateClusterPoolDto { export class UpdateClusterPoolDto {
@@ -32,10 +43,10 @@ export class UpdateClusterPoolDto {
@IsString() @IsString()
description?: string; description?: string;
@ApiPropertyOptional({ enum: ['least-apps', 'round-robin'] }) @ApiPropertyOptional({ enum: ['least-apps', 'round-robin', 'weighted-resource'] })
@IsOptional() @IsOptional()
@IsIn(['least-apps', 'round-robin']) @IsIn(['least-apps', 'round-robin', 'weighted-resource'])
strategy?: 'least-apps' | 'round-robin'; strategy?: 'least-apps' | 'round-robin' | 'weighted-resource';
@ApiPropertyOptional({ description: 'Array of cluster IDs in this pool' }) @ApiPropertyOptional({ description: 'Array of cluster IDs in this pool' })
@IsOptional() @IsOptional()
@@ -47,4 +58,15 @@ export class UpdateClusterPoolDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
isActive?: boolean; isActive?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(1)
priority?: number;
} }
+35 -1
View File
@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsBoolean, IsNumber, IsEnum } from 'class-validator'; import { IsString, IsOptional, IsBoolean, IsNumber, IsEnum, IsArray, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ClusterStatus } from '../../common/enums'; import { ClusterStatus } from '../../common/enums';
@@ -25,6 +25,18 @@ export class CreateClusterDto {
@IsString() @IsString()
region?: string; region?: string;
@ApiPropertyOptional({ example: 10, description: 'Relative placement weight; higher means more preferred' })
@IsOptional()
@IsNumber()
@Min(1)
weight?: number;
@ApiPropertyOptional({ example: ['ssd', 'iran', 'production'] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional({ example: 'aws' }) @ApiPropertyOptional({ example: 'aws' })
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -62,6 +74,16 @@ export class UpdateClusterDto {
@IsString() @IsString()
description?: string; description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
region?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
provider?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsEnum(ClusterStatus) @IsEnum(ClusterStatus)
@@ -77,6 +99,18 @@ export class UpdateClusterDto {
@IsBoolean() @IsBoolean()
isDefault?: boolean; isDefault?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(1)
weight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -0,0 +1,64 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { Cluster } from './cluster.entity';
import { ClusterPool, PoolStrategy } from './cluster-pool.entity';
import { Application } from '../../applications/entities/application.entity';
export type ClusterAllocationStatus = 'success' | 'failed';
@Entity('cluster_allocation_logs')
export class ClusterAllocationLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true })
applicationId?: string | null;
@ManyToOne(() => Application, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'applicationId' })
application: Application;
@Column()
userId: string;
@Column({ nullable: true })
poolId?: string | null;
@ManyToOne(() => ClusterPool, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'poolId' })
pool: ClusterPool;
@Column({ nullable: true })
selectedClusterId?: string | null;
@ManyToOne(() => Cluster, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'selectedClusterId' })
selectedCluster: Cluster;
@Column({ default: 'weighted-resource' })
strategy: PoolStrategy;
@Column({ type: 'jsonb', nullable: true })
estimatedRequest: Record<string, any>;
@Column({ type: 'jsonb', nullable: true })
candidateScores: Record<string, any>[];
@Column({ type: 'jsonb', nullable: true })
rejectionReasons: Record<string, any>[];
@Column({ default: 'success' })
status: ClusterAllocationStatus;
@Column({ nullable: true })
message: string;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,52 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { Cluster, ClusterHealthStatus } from './cluster.entity';
@Entity('cluster_health')
export class ClusterHealth {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
clusterId: string;
@ManyToOne(() => Cluster, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'clusterId' })
cluster: Cluster;
@Column({ default: 'unknown' })
status: ClusterHealthStatus;
@Column({ default: 0 })
readyNodes: number;
@Column({ default: 0 })
nodeCount: number;
@Column({ nullable: true })
cpuAllocatable: string;
@Column({ nullable: true })
memoryAllocatable: string;
@Column({ default: 0 })
podCount: number;
@Column({ default: 0 })
appCount: number;
@Column({ nullable: true })
message: string;
@Column({ type: 'jsonb', nullable: true })
resources: Record<string, any>;
@CreateDateColumn()
checkedAt: Date;
}
@@ -6,7 +6,7 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
export type PoolStrategy = 'least-apps' | 'round-robin'; export type PoolStrategy = 'least-apps' | 'round-robin' | 'weighted-resource';
@Entity('cluster_pools') @Entity('cluster_pools')
export class ClusterPool { export class ClusterPool {
@@ -32,6 +32,12 @@ export class ClusterPool {
@Column({ default: true }) @Column({ default: true })
isActive: boolean; isActive: boolean;
@Column({ default: false })
isDefault: boolean;
@Column({ default: 100 })
priority: number;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
@@ -7,6 +7,8 @@ import {
} from 'typeorm'; } from 'typeorm';
import { ClusterStatus } from '../../common/enums'; import { ClusterStatus } from '../../common/enums';
export type ClusterHealthStatus = 'unknown' | 'healthy' | 'degraded' | 'unhealthy';
@Entity('clusters') @Entity('clusters')
export class Cluster { export class Cluster {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@@ -30,6 +32,24 @@ export class Cluster {
@Column({ nullable: true }) @Column({ nullable: true })
region: string; region: string;
@Column({ default: 1 })
weight: number;
@Column({ type: 'jsonb', default: [] })
tags: string[];
@Column({ default: 'unknown' })
healthStatus: ClusterHealthStatus;
@Column({ type: 'timestamptz', nullable: true })
lastHealthCheckedAt: Date;
@Column({ nullable: true })
healthMessage: string;
@Column({ type: 'jsonb', nullable: true })
availableResources: Record<string, any>;
@Column({ nullable: true }) @Column({ nullable: true })
provider: string; // e.g., 'aws', 'gcp', 'azure', 'bare-metal' provider: string; // e.g., 'aws', 'gcp', 'azure', 'bare-metal'
@@ -227,6 +227,8 @@ export default function AdminClustersPage() {
kubeconfig: '', kubeconfig: '',
region: '', region: '',
provider: '', provider: '',
weight: 1,
tags: '',
isDefault: false, isDefault: false,
}); });
@@ -236,12 +238,16 @@ export default function AdminClustersPage() {
}); });
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: typeof form) => api.post('/clusters', data), mutationFn: (data: typeof form) => api.post('/clusters', {
...data,
tags: data.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
weight: Number(data.weight) || 1,
}),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] }); queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
toast.success('Cluster added & connection verified ✓'); toast.success('Cluster added & connection verified ✓');
setShowForm(false); setShowForm(false);
setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', isDefault: false }); setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', weight: 1, tags: '', isDefault: false });
}, },
onError: (err: any) => { onError: (err: any) => {
const message = err?.response?.data?.message || 'Failed to add cluster'; const message = err?.response?.data?.message || 'Failed to add cluster';
@@ -333,6 +339,25 @@ export default function AdminClustersPage() {
<option value="bare-metal">Bare Metal</option> <option value="bare-metal">Bare Metal</option>
</select> </select>
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Weight</label>
<input
type="number"
min={1}
className="input-field"
value={form.weight}
onChange={(e) => setForm({ ...form, weight: Number(e.target.value) || 1 })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tags</label>
<input
className="input-field"
placeholder="ssd, production, iran"
value={form.tags}
onChange={(e) => setForm({ ...form, tags: e.target.value })}
/>
</div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label> <label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
@@ -410,10 +435,39 @@ export default function AdminClustersPage() {
}`}> }`}>
{cluster.status} {cluster.status}
</span> </span>
<span className={`badge ${
cluster.healthStatus === 'healthy' ? 'badge-green'
: cluster.healthStatus === 'degraded' ? 'badge-yellow'
: cluster.healthStatus === 'unhealthy' ? 'badge-red'
: 'badge-gray'
}`}>
health: {cluster.healthStatus || 'unknown'}
</span>
<span className="badge badge-gray">weight {cluster.weight || 1}</span>
</div> </div>
<p className="text-sm text-gray-500 truncate"> <p className="text-sm text-gray-500 truncate">
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · {cluster.apiServer} {cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · {cluster.apiServer}
</p> </p>
{cluster.healthMessage && (
<p className="text-xs text-gray-400 mt-1">
{cluster.healthMessage}
{cluster.lastHealthCheckedAt ? ` · ${new Date(cluster.lastHealthCheckedAt).toLocaleString()}` : ''}
</p>
)}
{cluster.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{cluster.tags.map((tag) => (
<span key={tag} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 text-xs">
{tag}
</span>
))}
</div>
)}
{cluster.availableResources && (
<p className="text-xs text-gray-500 mt-2">
CPU {cluster.availableResources.totalCpuAllocatable || 'n/a'} · Memory {cluster.availableResources.totalMemoryAllocatable || 'n/a'} · Pods {cluster.availableResources.podCount ?? 'n/a'} · Apps {cluster.availableResources.appCount ?? 'n/a'}
</p>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 shrink-0 flex-wrap"> <div className="flex items-center gap-2 shrink-0 flex-wrap">
@@ -16,8 +16,10 @@ export default function AdminPoolsPage() {
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '', name: '',
description: '', description: '',
strategy: 'least-apps' as 'least-apps' | 'round-robin', strategy: 'weighted-resource' as 'least-apps' | 'round-robin' | 'weighted-resource',
clusterIds: [] as string[], clusterIds: [] as string[],
isDefault: false,
priority: 100,
}); });
const { data: pools = [], isLoading } = useQuery<ClusterPool[]>({ const { data: pools = [], isLoading } = useQuery<ClusterPool[]>({
@@ -66,7 +68,7 @@ export default function AdminPoolsPage() {
const resetForm = () => { const resetForm = () => {
setShowForm(false); setShowForm(false);
setEditingPool(null); setEditingPool(null);
setForm({ name: '', description: '', strategy: 'least-apps', clusterIds: [] }); setForm({ name: '', description: '', strategy: 'weighted-resource', clusterIds: [], isDefault: false, priority: 100 });
}; };
const startEdit = (pool: ClusterPool) => { const startEdit = (pool: ClusterPool) => {
@@ -76,6 +78,8 @@ export default function AdminPoolsPage() {
description: pool.description || '', description: pool.description || '',
strategy: pool.strategy, strategy: pool.strategy,
clusterIds: pool.clusterIds, clusterIds: pool.clusterIds,
isDefault: pool.isDefault || false,
priority: pool.priority || 100,
}); });
setShowForm(true); setShowForm(true);
}; };
@@ -138,10 +142,21 @@ export default function AdminPoolsPage() {
value={form.strategy} value={form.strategy}
onChange={(e) => setForm({ ...form, strategy: e.target.value as any })} onChange={(e) => setForm({ ...form, strategy: e.target.value as any })}
> >
<option value="weighted-resource">Weighted Resource prefer healthy capacity and higher weights</option>
<option value="least-apps">Least Apps deploy to cluster with fewest apps</option> <option value="least-apps">Least Apps deploy to cluster with fewest apps</option>
<option value="round-robin">Round Robin rotate across clusters evenly</option> <option value="round-robin">Round Robin rotate across clusters evenly</option>
</select> </select>
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<input
type="number"
min={1}
className="input-field"
value={form.priority}
onChange={(e) => setForm({ ...form, priority: Number(e.target.value) || 100 })}
/>
</div>
</div> </div>
<div> <div>
@@ -154,6 +169,15 @@ export default function AdminPoolsPage() {
/> />
</div> </div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={form.isDefault}
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
/>
Use as default allocator pool
</label>
{/* Cluster selection */} {/* Cluster selection */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
@@ -195,7 +219,7 @@ export default function AdminPoolsPage() {
)} )}
</p> </p>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} {cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · weight {cluster.weight || 1}
</p> </p>
</div> </div>
</div> </div>
@@ -206,6 +230,17 @@ export default function AdminPoolsPage() {
}`}> }`}>
{cluster.status} {cluster.status}
</span> </span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
cluster.healthStatus === 'healthy'
? 'bg-green-100 text-green-700'
: cluster.healthStatus === 'degraded'
? 'bg-yellow-100 text-yellow-700'
: cluster.healthStatus === 'unhealthy'
? 'bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-600'
}`}>
{cluster.healthStatus || 'unknown'}
</span>
</div> </div>
</button> </button>
); );
@@ -278,8 +313,14 @@ export default function AdminPoolsPage() {
{pool.isActive ? 'Active' : 'Inactive'} {pool.isActive ? 'Active' : 'Inactive'}
</span> </span>
<span className="badge badge-purple flex items-center gap-1"> <span className="badge badge-purple flex items-center gap-1">
{pool.strategy === 'least-apps' ? <><BarChart3 className="w-3 h-3" /> Least Apps</> : <><RotateCw className="w-3 h-3" /> Round Robin</>} {pool.strategy === 'weighted-resource'
? <><BarChart3 className="w-3 h-3" /> Weighted Resource</>
: pool.strategy === 'least-apps'
? <><BarChart3 className="w-3 h-3" /> Least Apps</>
: <><RotateCw className="w-3 h-3" /> Round Robin</>}
</span> </span>
{pool.isDefault && <span className="badge badge-blue">Default Pool</span>}
<span className="badge badge-gray">Priority {pool.priority || 100}</span>
</div> </div>
{pool.description && ( {pool.description && (
<p className="text-sm text-gray-500 mb-3">{pool.description}</p> <p className="text-sm text-gray-500 mb-3">{pool.description}</p>
@@ -299,7 +340,7 @@ export default function AdminPoolsPage() {
<span>{cluster.status === 'active' ? <CheckCircle className="w-3 h-3" /> : <XCircle className="w-3 h-3" />}</span> <span>{cluster.status === 'active' ? <CheckCircle className="w-3 h-3" /> : <XCircle className="w-3 h-3" />}</span>
<span>{cluster.name}</span> <span>{cluster.name}</span>
<span className="text-gray-400"> <span className="text-gray-400">
({cluster.provider || 'N/A'} · {cluster.region || 'N/A'}) ({cluster.provider || 'N/A'} · {cluster.region || 'N/A'} · weight {cluster.weight || 1} · {cluster.healthStatus || 'unknown'})
</span> </span>
</div> </div>
)) : ( )) : (
@@ -9,6 +9,7 @@ import { useState, useRef, useCallback, useEffect } from 'react';
import NextLink from 'next/link'; import NextLink from 'next/link';
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, ShieldAlert, ExternalLink, ScrollText } 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, ShieldAlert, ExternalLink, ScrollText } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal'; import { useConfirm } from '@/components/confirm-modal';
import { useAuthStore } from '@/lib/store';
import { BuildProgressModal } from '@/components/build-progress-modal'; import { BuildProgressModal } from '@/components/build-progress-modal';
/** Matches backend multipart limit for POST /applications/:id/upload */ /** Matches backend multipart limit for POST /applications/:id/upload */
@@ -55,6 +56,8 @@ export default function AppDetailPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
const appId = params.id as string; const appId = params.id as string;
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'admin';
const [showLogs, setShowLogs] = useState(false); const [showLogs, setShowLogs] = useState(false);
const [logTab, setLogTab] = useState<'pod' | 'build'>('pod'); const [logTab, setLogTab] = useState<'pod' | 'build'>('pod');
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -143,11 +146,13 @@ export default function AppDetailPage() {
const { data: clusters = [] } = useQuery<ClusterPublic[]>({ const { data: clusters = [] } = useQuery<ClusterPublic[]>({
queryKey: ['clusters-public'], queryKey: ['clusters-public'],
queryFn: () => api.get('/clusters/public').then((r) => r.data), queryFn: () => api.get('/clusters/public').then((r) => r.data),
enabled: isAdmin,
}); });
const { data: pools = [] } = useQuery<ClusterPoolPublic[]>({ const { data: pools = [] } = useQuery<ClusterPoolPublic[]>({
queryKey: ['pools-public'], queryKey: ['pools-public'],
queryFn: () => api.get('/clusters/pools/public').then((r) => r.data), queryFn: () => api.get('/clusters/pools/public').then((r) => r.data),
enabled: isAdmin,
}); });
// Fetch DB storage size // Fetch DB storage size
@@ -1308,7 +1313,7 @@ export default function AppDetailPage() {
<dt className="text-sm text-gray-500">Port</dt> <dt className="text-sm text-gray-500">Port</dt>
<dd className="text-sm font-medium text-gray-900">{app.port}</dd> <dd className="text-sm font-medium text-gray-900">{app.port}</dd>
</div> </div>
{app.clusterId && ( {isAdmin && app.clusterId && (
<div className="flex justify-between"> <div className="flex justify-between">
<dt className="text-sm text-gray-500">Cluster</dt> <dt className="text-sm text-gray-500">Cluster</dt>
<dd className="text-sm font-medium text-gray-900"> <dd className="text-sm font-medium text-gray-900">
@@ -1316,7 +1321,7 @@ export default function AppDetailPage() {
</dd> </dd>
</div> </div>
)} )}
{app.poolId && ( {isAdmin && app.poolId && (
<div className="flex justify-between"> <div className="flex justify-between">
<dt className="text-sm text-gray-500">Pool</dt> <dt className="text-sm text-gray-500">Pool</dt>
<dd className="text-sm font-medium text-gray-900"> <dd className="text-sm font-medium text-gray-900">
+32 -26
View File
@@ -217,7 +217,7 @@ function minGiToFitFileBytes(bytes: number): number {
export default function DeployPage() { export default function DeployPage() {
const router = useRouter(); const router = useRouter();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'admin' || user?.role === 'technical'; const isAdmin = user?.role === 'admin';
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [form, setForm] = useState<CreateApplicationDto>({ const [form, setForm] = useState<CreateApplicationDto>({
name: '', name: '',
@@ -388,6 +388,10 @@ export default function DeployPage() {
: {}), : {}),
...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}), ...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}),
}); });
if (!isAdmin) {
delete payload.clusterId;
delete payload.poolId;
}
const res = await api.post('/applications', payload); const res = await api.post('/applications', payload);
const appId = res.data.id; const appId = res.data.id;
@@ -466,6 +470,10 @@ export default function DeployPage() {
: {}), : {}),
...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}), ...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}),
}); });
if (!isAdmin) {
delete payload.clusterId;
delete payload.poolId;
}
const res = await api.post('/applications', payload); const res = await api.post('/applications', payload);
const appId = res.data.id; const appId = res.data.id;
@@ -622,6 +630,10 @@ export default function DeployPage() {
if (enableCustomDomain && customDomainInput.trim()) { if (enableCustomDomain && customDomainInput.trim()) {
payload.customDomain = customDomainInput.trim(); payload.customDomain = customDomainInput.trim();
} }
if (!isAdmin) {
delete payload.clusterId;
delete payload.poolId;
}
createMutation.mutate(sanitizePayloadForWordPressRuntime(payload)); createMutation.mutate(sanitizePayloadForWordPressRuntime(payload));
}; };
@@ -2138,8 +2150,8 @@ export default function DeployPage() {
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Resources & Configuration</h2> <h2 className="text-lg font-semibold text-gray-900">Resources & Configuration</h2>
{/* Cluster Assignment Mode — Admin only */} {/* Cluster Assignment Mode — Super Admin only */}
{isAdmin ? ( {isAdmin && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">Cluster Assignment</label> <label className="block text-sm font-medium text-gray-700 mb-2">Cluster Assignment</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
@@ -2267,7 +2279,11 @@ export default function DeployPage() {
)} )}
<div className="flex items-center space-x-2 mt-1"> <div className="flex items-center space-x-2 mt-1">
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded flex items-center gap-1"> <span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded flex items-center gap-1">
{pool.strategy === 'least-apps' ? <><BarChart3 className="w-3 h-3" /> Least Apps</> : <><RotateCw className="w-3 h-3" /> Round Robin</>} {pool.strategy === 'weighted-resource'
? <><BarChart3 className="w-3 h-3" /> Weighted Resource</>
: pool.strategy === 'least-apps'
? <><BarChart3 className="w-3 h-3" /> Least Apps</>
: <><RotateCw className="w-3 h-3" /> Round Robin</>}
</span> </span>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{pool.clusters.length} cluster{pool.clusters.length !== 1 ? 's' : ''}: {pool.clusters.length} cluster{pool.clusters.length !== 1 ? 's' : ''}:
@@ -2301,18 +2317,6 @@ export default function DeployPage() {
</div> </div>
)} )}
</div> </div>
) : (
<div className="p-3 bg-gray-50 rounded-xl border border-gray-200">
<div className="flex items-center space-x-3">
<Home className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-700">Cluster Assignment</p>
<p className="text-xs text-gray-500">
Your app will be automatically deployed to the platform&apos;s default cluster
</p>
</div>
</div>
</div>
)} )}
<div className="rounded-xl border border-primary-100 bg-primary-50/40 p-4 space-y-4"> <div className="rounded-xl border border-primary-100 bg-primary-50/40 p-4 space-y-4">
@@ -2574,16 +2578,18 @@ export default function DeployPage() {
<span className="text-sm font-medium text-green-600">Token provided</span> <span className="text-sm font-medium text-green-600">Token provided</span>
</div> </div>
)} )}
<div className="flex justify-between"> {isAdmin && (
<span className="text-sm text-gray-500">Cluster</span> <div className="flex justify-between">
<span className="text-sm font-medium"> <span className="text-sm text-gray-500">Cluster</span>
{isAdmin && clusterMode === 'manual' && form.clusterId <span className="text-sm font-medium">
? `${clusters.find((c) => c.id === form.clusterId)?.name || form.clusterId}` {clusterMode === 'manual' && form.clusterId
: isAdmin && clusterMode === 'pool' && form.poolId ? `${clusters.find((c) => c.id === form.clusterId)?.name || form.clusterId}`
? `${pools.find((p) => p.id === form.poolId)?.name || 'Pool'} (Load Balanced)` : clusterMode === 'pool' && form.poolId
: 'Default Cluster'} ? `${pools.find((p) => p.id === form.poolId)?.name || 'Pool'} (Load Balanced)`
</span> : 'Automatic allocator'}
</div> </span>
</div>
)}
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-gray-500">CPU</span> <span className="text-sm text-gray-500">CPU</span>
<span className="text-sm font-medium">{form.cpuRequest} / {form.cpuLimit}</span> <span className="text-sm font-medium">{form.cpuRequest} / {form.cpuLimit}</span>
-2
View File
@@ -53,8 +53,6 @@ const adminNavItems: NavItem[] = [
const technicalNavItems: NavItem[] = [ const technicalNavItems: NavItem[] = [
{ href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> }, { href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> },
{ href: '/dashboard/admin/apps', label: 'All Applications', icon: <Boxes className="w-4 h-4" /> }, { href: '/dashboard/admin/apps', label: 'All Applications', icon: <Boxes className="w-4 h-4" /> },
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: <Server className="w-4 h-4" /> },
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: <Scale className="w-4 h-4" /> },
{ href: '/dashboard/staff/tickets', label: 'Technical Tickets', icon: <Wrench className="w-4 h-4" /> }, { href: '/dashboard/staff/tickets', label: 'Technical Tickets', icon: <Wrench className="w-4 h-4" /> },
]; ];
+43 -2
View File
@@ -169,9 +169,15 @@ export interface Cluster {
name: string; name: string;
description?: string; description?: string;
status: 'active' | 'inactive' | 'maintenance'; status: 'active' | 'inactive' | 'maintenance';
healthStatus?: 'unknown' | 'healthy' | 'degraded' | 'unhealthy';
lastHealthCheckedAt?: string;
healthMessage?: string;
apiServer: string; apiServer: string;
region?: string; region?: string;
provider?: string; provider?: string;
weight: number;
tags: string[];
availableResources?: Record<string, any>;
isDefault: boolean; isDefault: boolean;
defaultCpuLimit: string; defaultCpuLimit: string;
defaultMemoryLimit: string; defaultMemoryLimit: string;
@@ -241,15 +247,18 @@ export interface ClusterPublic {
provider?: string; provider?: string;
isDefault: boolean; isDefault: boolean;
status: 'active' | 'inactive' | 'maintenance'; status: 'active' | 'inactive' | 'maintenance';
healthStatus?: 'unknown' | 'healthy' | 'degraded' | 'unhealthy';
} }
export interface ClusterPoolPublic { export interface ClusterPoolPublic {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
strategy: 'least-apps' | 'round-robin'; strategy: 'least-apps' | 'round-robin' | 'weighted-resource';
clusterIds: string[]; clusterIds: string[];
isActive: boolean; isActive: boolean;
isDefault: boolean;
priority: number;
clusters: Pick<ClusterPublic, 'id' | 'name' | 'region' | 'provider' | 'status'>[]; clusters: Pick<ClusterPublic, 'id' | 'name' | 'region' | 'provider' | 'status'>[];
} }
@@ -257,9 +266,41 @@ export interface ClusterPool {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
strategy: 'least-apps' | 'round-robin'; strategy: 'least-apps' | 'round-robin' | 'weighted-resource';
clusterIds: string[]; clusterIds: string[];
isActive: boolean; isActive: boolean;
isDefault: boolean;
priority: number;
createdAt: string;
}
export interface ClusterHealth {
id: string;
clusterId: string;
status: 'unknown' | 'healthy' | 'degraded' | 'unhealthy';
readyNodes: number;
nodeCount: number;
cpuAllocatable?: string;
memoryAllocatable?: string;
podCount: number;
appCount: number;
message?: string;
resources?: Record<string, any>;
checkedAt: string;
}
export interface ClusterAllocationLog {
id: string;
applicationId?: string;
userId: string;
poolId?: string;
selectedClusterId?: string;
strategy: 'least-apps' | 'round-robin' | 'weighted-resource';
estimatedRequest?: Record<string, any>;
candidateScores?: Record<string, any>[];
rejectionReasons?: Record<string, any>[];
status: 'success' | 'failed';
message?: string;
createdAt: string; createdAt: string;
} }