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> {
// Only admin/technical users can manually select cluster or pool
// Regular users always get the default cluster assignment
if (userRole !== UserRole.ADMIN && userRole !== UserRole.TECHNICAL) {
// End users and technical staff cannot influence placement; only admins may manually assign.
const isAdmin = userRole === UserRole.ADMIN;
if (!isAdmin) {
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.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 poolId = dto.poolId;
let allocationLogId: string | undefined;
if (!clusterId) {
if (poolId) {
// Mode 2: Pool-based load balancing
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');
}
}
if (isAdmin && clusterId) {
await this.clustersService.findOne(clusterId);
this.logger.log(`Manual cluster assignment for app "${dto.name}" -> cluster ${clusterId}`);
} 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
@@ -109,7 +95,11 @@ export class ApplicationsService {
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[]> {
@@ -182,12 +182,12 @@ export class CreateApplicationDto {
@IsNumber()
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()
@IsString()
clusterId?: string;
@ApiPropertyOptional({ description: 'Cluster pool ID for load-balanced deployment' })
@ApiPropertyOptional({ description: 'Admin-only pool override. Ignored for non-admin users.' })
@IsOptional()
@IsString()
poolId?: string;
@@ -136,10 +136,10 @@ export class Application {
@Column()
userId: string;
@Column({ nullable: true })
@Column({ type: 'uuid', nullable: true })
clusterId: string;
@Column({ nullable: true })
@Column({ type: 'uuid', nullable: true })
poolId: string; // Cluster pool used for load-balanced assignment
@OneToMany(() => Deployment, (deployment: Deployment) => deployment.application)
+61 -51
View File
@@ -24,15 +24,17 @@ import { UserRole } from '../common/enums';
export class ClustersController {
constructor(private readonly clustersService: ClustersService) {}
// ─── Public endpoints (any authenticated user) ────────────────────
// ─── Admin-safe lookup endpoints (no end-user exposure) ───────────
@Get('public')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List available clusters (public info, no kubeconfig)' })
async findAllPublic() {
return this.clustersService.findAllPublic();
}
@Get('pools/public')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List active cluster pools with resolved cluster names' })
async findAllPoolsPublic() {
return this.clustersService.findAllPoolsPublic();
@@ -41,90 +43,98 @@ export class ClustersController {
// ─── Cluster admin endpoints ──────────────────────────────────────
@Post()
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin/Technical)' })
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin)' })
async create(@Body() dto: CreateClusterDto) {
return this.clustersService.create(dto);
}
@Get()
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'List all clusters (Admin/Technical)' })
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all clusters (Admin)' })
async 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 ─────────────────────────────────
@Post('pools')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Create a cluster pool for load balancing (Admin/Technical)' })
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Create a cluster pool for load balancing (Admin)' })
async createPool(@Body() dto: CreateClusterPoolDto) {
return this.clustersService.createPool(dto);
}
@Get('pools')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'List all cluster pools (Admin/Technical)' })
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'List all cluster pools (Admin)' })
async findAllPools() {
return this.clustersService.findAllPools();
}
@Get('pools/:id')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Get cluster pool details (Admin/Technical)' })
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get cluster pool details (Admin)' })
async findOnePool(@Param('id') id: string) {
return this.clustersService.findOnePool(id);
}
@Patch('pools/:id')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Update cluster pool (Admin/Technical)' })
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Update cluster pool (Admin)' })
async updatePool(@Param('id') id: string, @Body() dto: UpdateClusterPoolDto) {
return this.clustersService.updatePool(id, dto);
}
@Delete('pools/:id')
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
@ApiOperation({ summary: 'Delete a cluster pool (Admin/Technical)' })
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Delete a cluster pool (Admin)' })
async deletePool(@Param('id') id: string) {
await this.clustersService.deletePool(id);
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 { Cluster } from './entities/cluster.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';
@Module({
imports: [
TypeOrmModule.forFeature([Cluster, ClusterPool]),
TypeOrmModule.forFeature([Cluster, ClusterPool, ClusterHealth, ClusterAllocationLog]),
forwardRef(() => KubernetesModule),
],
controllers: [ClustersController],
+390 -13
View File
@@ -3,12 +3,16 @@ import { InjectRepository } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { Repository, DataSource, In } from 'typeorm';
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 { ClusterHealth } from './entities/cluster-health.entity';
import { ClusterAllocationLog } from './entities/cluster-allocation-log.entity';
import { CreateClusterDto, UpdateClusterDto } from './dto/cluster.dto';
import { CreateClusterPoolDto, UpdateClusterPoolDto } from './dto/cluster-pool.dto';
import { ClusterStatus } from '../common/enums';
import { ElasticsearchService } from '../kubernetes/elasticsearch.service';
import { CreateApplicationDto } from '../applications/dto/application.dto';
@Injectable()
export class ClustersService {
@@ -21,6 +25,10 @@ export class ClustersService {
private clustersRepository: Repository<Cluster>,
@InjectRepository(ClusterPool)
private poolsRepository: Repository<ClusterPool>,
@InjectRepository(ClusterHealth)
private healthRepository: Repository<ClusterHealth>,
@InjectRepository(ClusterAllocationLog)
private allocationLogsRepository: Repository<ClusterAllocationLog>,
private dataSource: DataSource,
private configService: ConfigService,
@Inject(forwardRef(() => ElasticsearchService))
@@ -34,7 +42,7 @@ export class ClustersService {
async testConnection(kubeconfig: string): Promise<{ connected: boolean; version?: string; error?: string }> {
try {
const kc = new k8s.KubeConfig();
kc.loadFromString(kubeconfig);
kc.loadFromString(this.decryptKubeconfig(kubeconfig));
const versionApi = kc.makeApiClient(k8s.VersionApi);
const result = await versionApi.getCode();
@@ -74,13 +82,18 @@ export class ClustersService {
const cluster = this.clustersRepository.create({
...dto,
kubeconfig: this.encryptKubeconfig(dto.kubeconfig),
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 usableCluster = this.withDecryptedKubeconfig(saved);
this.logger.log(`Cluster "${saved.name}" registered (active) — K8s ${connectionTest.version}`);
// 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}`);
});
@@ -89,12 +102,33 @@ export class ClustersService {
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[]> {
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' },
});
}
@@ -104,7 +138,7 @@ export class ClustersService {
if (!cluster) {
throw new NotFoundException('Cluster not found');
}
return cluster;
return this.withDecryptedKubeconfig(cluster);
}
async getDefault(): Promise<Cluster> {
@@ -126,7 +160,7 @@ export class ClustersService {
if (!cluster) {
throw new NotFoundException('No active cluster available');
}
return cluster;
return this.withDecryptedKubeconfig(cluster);
}
async update(id: string, dto: UpdateClusterDto): Promise<Cluster> {
@@ -141,10 +175,11 @@ export class ClustersService {
);
}
dto.status = ClusterStatus.ACTIVE;
dto.kubeconfig = this.encryptKubeconfig(dto.kubeconfig);
this.logger.log(`Cluster "${cluster.name}" kubeconfig updated — connection verified (K8s ${connectionTest.version})`);
// 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}`);
});
}
@@ -159,7 +194,9 @@ export class ClustersService {
}
}
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);
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.recordHealthSnapshot(cluster, {
status: cluster.healthStatus,
message: cluster.healthMessage,
});
this.logger.log(`Cluster "${cluster.name}" test: ${result.connected ? 'ACTIVE' : 'INACTIVE'}`);
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.
* 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(', ')}`);
}
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);
this.logger.log(`Cluster pool "${saved.name}" created with ${dto.clusterIds.length} clusters (strategy: ${dto.strategy})`);
return saved;
}
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'>[] })[]> {
const pools = await this.poolsRepository.find({
where: { isActive: true },
order: { createdAt: 'DESC' },
order: { isDefault: 'DESC', priority: 'ASC', createdAt: 'DESC' },
});
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);
return this.poolsRepository.save(pool);
}
@@ -443,6 +601,7 @@ export class ClustersService {
totalMemoryAllocatable: string;
podCount: number;
nodeCount: number;
readyNodeCount?: number;
appCount: number;
}> {
const cluster = await this.findOne(id);
@@ -496,7 +655,8 @@ export class ClustersService {
);
const appCount = parseInt(appCountResult[0]?.count || '0', 10);
return {
const readyNodeCount = nodes.filter((node) => node.status === 'Ready').length;
const resources = {
nodes,
totalCpuCapacity: `${totalCpuCap}m`,
totalMemoryCapacity: `${totalMemCap.toFixed(0)}Mi`,
@@ -504,10 +664,33 @@ export class ClustersService {
totalMemoryAllocatable: `${totalMemAlloc.toFixed(0)}Mi`,
podCount,
nodeCount: nodes.length,
readyNodeCount,
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) {
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}`);
}
}
@@ -713,4 +896,198 @@ export class ClustersService {
if (memory.endsWith('Ti')) return parseFloat(memory) * 1024 * 1024;
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';
export class CreateClusterPoolDto {
@@ -11,14 +11,25 @@ export class CreateClusterPoolDto {
@IsString()
description?: string;
@ApiProperty({ example: 'least-apps', enum: ['least-apps', 'round-robin'] })
@IsIn(['least-apps', 'round-robin'])
strategy: 'least-apps' | 'round-robin';
@ApiProperty({ example: 'weighted-resource', enum: ['least-apps', 'round-robin', 'weighted-resource'] })
@IsIn(['least-apps', 'round-robin', 'weighted-resource'])
strategy: 'least-apps' | 'round-robin' | 'weighted-resource';
@ApiProperty({ example: ['uuid-1', 'uuid-2'], description: 'Array of cluster IDs in this pool' })
@IsArray()
@IsString({ each: true })
clusterIds: string[];
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@ApiPropertyOptional({ example: 100 })
@IsOptional()
@IsNumber()
@Min(1)
priority?: number;
}
export class UpdateClusterPoolDto {
@@ -32,10 +43,10 @@ export class UpdateClusterPoolDto {
@IsString()
description?: string;
@ApiPropertyOptional({ enum: ['least-apps', 'round-robin'] })
@ApiPropertyOptional({ enum: ['least-apps', 'round-robin', 'weighted-resource'] })
@IsOptional()
@IsIn(['least-apps', 'round-robin'])
strategy?: 'least-apps' | 'round-robin';
@IsIn(['least-apps', 'round-robin', 'weighted-resource'])
strategy?: 'least-apps' | 'round-robin' | 'weighted-resource';
@ApiPropertyOptional({ description: 'Array of cluster IDs in this pool' })
@IsOptional()
@@ -47,4 +58,15 @@ export class UpdateClusterPoolDto {
@IsOptional()
@IsBoolean()
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 { ClusterStatus } from '../../common/enums';
@@ -25,6 +25,18 @@ export class CreateClusterDto {
@IsString()
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' })
@IsOptional()
@IsString()
@@ -62,6 +74,16 @@ export class UpdateClusterDto {
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
region?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
provider?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEnum(ClusterStatus)
@@ -77,6 +99,18 @@ export class UpdateClusterDto {
@IsBoolean()
isDefault?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(1)
weight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional()
@IsOptional()
@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,
} from 'typeorm';
export type PoolStrategy = 'least-apps' | 'round-robin';
export type PoolStrategy = 'least-apps' | 'round-robin' | 'weighted-resource';
@Entity('cluster_pools')
export class ClusterPool {
@@ -32,6 +32,12 @@ export class ClusterPool {
@Column({ default: true })
isActive: boolean;
@Column({ default: false })
isDefault: boolean;
@Column({ default: 100 })
priority: number;
@CreateDateColumn()
createdAt: Date;
@@ -7,6 +7,8 @@ import {
} from 'typeorm';
import { ClusterStatus } from '../../common/enums';
export type ClusterHealthStatus = 'unknown' | 'healthy' | 'degraded' | 'unhealthy';
@Entity('clusters')
export class Cluster {
@PrimaryGeneratedColumn('uuid')
@@ -30,6 +32,24 @@ export class Cluster {
@Column({ nullable: true })
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 })
provider: string; // e.g., 'aws', 'gcp', 'azure', 'bare-metal'
@@ -227,6 +227,8 @@ export default function AdminClustersPage() {
kubeconfig: '',
region: '',
provider: '',
weight: 1,
tags: '',
isDefault: false,
});
@@ -236,12 +238,16 @@ export default function AdminClustersPage() {
});
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: () => {
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
toast.success('Cluster added & connection verified ✓');
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) => {
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>
</select>
</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>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
@@ -410,10 +435,39 @@ export default function AdminClustersPage() {
}`}>
{cluster.status}
</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>
<p className="text-sm text-gray-500 truncate">
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · {cluster.apiServer}
</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 className="flex items-center gap-2 shrink-0 flex-wrap">
@@ -16,8 +16,10 @@ export default function AdminPoolsPage() {
const [form, setForm] = useState({
name: '',
description: '',
strategy: 'least-apps' as 'least-apps' | 'round-robin',
strategy: 'weighted-resource' as 'least-apps' | 'round-robin' | 'weighted-resource',
clusterIds: [] as string[],
isDefault: false,
priority: 100,
});
const { data: pools = [], isLoading } = useQuery<ClusterPool[]>({
@@ -66,7 +68,7 @@ export default function AdminPoolsPage() {
const resetForm = () => {
setShowForm(false);
setEditingPool(null);
setForm({ name: '', description: '', strategy: 'least-apps', clusterIds: [] });
setForm({ name: '', description: '', strategy: 'weighted-resource', clusterIds: [], isDefault: false, priority: 100 });
};
const startEdit = (pool: ClusterPool) => {
@@ -76,6 +78,8 @@ export default function AdminPoolsPage() {
description: pool.description || '',
strategy: pool.strategy,
clusterIds: pool.clusterIds,
isDefault: pool.isDefault || false,
priority: pool.priority || 100,
});
setShowForm(true);
};
@@ -138,10 +142,21 @@ export default function AdminPoolsPage() {
value={form.strategy}
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="round-robin">Round Robin rotate across clusters evenly</option>
</select>
</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>
@@ -154,6 +169,15 @@ export default function AdminPoolsPage() {
/>
</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 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
@@ -195,7 +219,7 @@ export default function AdminPoolsPage() {
)}
</p>
<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>
</div>
</div>
@@ -206,6 +230,17 @@ export default function AdminPoolsPage() {
}`}>
{cluster.status}
</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>
</button>
);
@@ -278,8 +313,14 @@ export default function AdminPoolsPage() {
{pool.isActive ? 'Active' : 'Inactive'}
</span>
<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>
{pool.isDefault && <span className="badge badge-blue">Default Pool</span>}
<span className="badge badge-gray">Priority {pool.priority || 100}</span>
</div>
{pool.description && (
<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.name}</span>
<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>
</div>
)) : (
@@ -9,6 +9,7 @@ import { useState, useRef, useCallback, useEffect } from 'react';
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 { useConfirm } from '@/components/confirm-modal';
import { useAuthStore } from '@/lib/store';
import { BuildProgressModal } from '@/components/build-progress-modal';
/** Matches backend multipart limit for POST /applications/:id/upload */
@@ -55,6 +56,8 @@ export default function AppDetailPage() {
const queryClient = useQueryClient();
const confirm = useConfirm();
const appId = params.id as string;
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'admin';
const [showLogs, setShowLogs] = useState(false);
const [logTab, setLogTab] = useState<'pod' | 'build'>('pod');
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -143,11 +146,13 @@ export default function AppDetailPage() {
const { data: clusters = [] } = useQuery<ClusterPublic[]>({
queryKey: ['clusters-public'],
queryFn: () => api.get('/clusters/public').then((r) => r.data),
enabled: isAdmin,
});
const { data: pools = [] } = useQuery<ClusterPoolPublic[]>({
queryKey: ['pools-public'],
queryFn: () => api.get('/clusters/pools/public').then((r) => r.data),
enabled: isAdmin,
});
// Fetch DB storage size
@@ -1308,7 +1313,7 @@ export default function AppDetailPage() {
<dt className="text-sm text-gray-500">Port</dt>
<dd className="text-sm font-medium text-gray-900">{app.port}</dd>
</div>
{app.clusterId && (
{isAdmin && app.clusterId && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Cluster</dt>
<dd className="text-sm font-medium text-gray-900">
@@ -1316,7 +1321,7 @@ export default function AppDetailPage() {
</dd>
</div>
)}
{app.poolId && (
{isAdmin && app.poolId && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Pool</dt>
<dd className="text-sm font-medium text-gray-900">
+25 -19
View File
@@ -217,7 +217,7 @@ function minGiToFitFileBytes(bytes: number): number {
export default function DeployPage() {
const router = useRouter();
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 [form, setForm] = useState<CreateApplicationDto>({
name: '',
@@ -388,6 +388,10 @@ export default function DeployPage() {
: {}),
...(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 appId = res.data.id;
@@ -466,6 +470,10 @@ export default function DeployPage() {
: {}),
...(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 appId = res.data.id;
@@ -622,6 +630,10 @@ export default function DeployPage() {
if (enableCustomDomain && customDomainInput.trim()) {
payload.customDomain = customDomainInput.trim();
}
if (!isAdmin) {
delete payload.clusterId;
delete payload.poolId;
}
createMutation.mutate(sanitizePayloadForWordPressRuntime(payload));
};
@@ -2138,8 +2150,8 @@ export default function DeployPage() {
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Resources & Configuration</h2>
{/* Cluster Assignment Mode — Admin only */}
{isAdmin ? (
{/* Cluster Assignment Mode — Super Admin only */}
{isAdmin && (
<div>
<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">
@@ -2267,7 +2279,11 @@ export default function DeployPage() {
)}
<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">
{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 className="text-xs text-gray-400">
{pool.clusters.length} cluster{pool.clusters.length !== 1 ? 's' : ''}:
@@ -2301,18 +2317,6 @@ export default function DeployPage() {
</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">
@@ -2574,16 +2578,18 @@ export default function DeployPage() {
<span className="text-sm font-medium text-green-600">Token provided</span>
</div>
)}
{isAdmin && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">Cluster</span>
<span className="text-sm font-medium">
{isAdmin && clusterMode === 'manual' && form.clusterId
{clusterMode === 'manual' && form.clusterId
? `${clusters.find((c) => c.id === form.clusterId)?.name || form.clusterId}`
: isAdmin && clusterMode === 'pool' && form.poolId
: clusterMode === 'pool' && form.poolId
? `${pools.find((p) => p.id === form.poolId)?.name || 'Pool'} (Load Balanced)`
: 'Default Cluster'}
: 'Automatic allocator'}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-sm text-gray-500">CPU</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[] = [
{ 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/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" /> },
];
+43 -2
View File
@@ -169,9 +169,15 @@ export interface Cluster {
name: string;
description?: string;
status: 'active' | 'inactive' | 'maintenance';
healthStatus?: 'unknown' | 'healthy' | 'degraded' | 'unhealthy';
lastHealthCheckedAt?: string;
healthMessage?: string;
apiServer: string;
region?: string;
provider?: string;
weight: number;
tags: string[];
availableResources?: Record<string, any>;
isDefault: boolean;
defaultCpuLimit: string;
defaultMemoryLimit: string;
@@ -241,15 +247,18 @@ export interface ClusterPublic {
provider?: string;
isDefault: boolean;
status: 'active' | 'inactive' | 'maintenance';
healthStatus?: 'unknown' | 'healthy' | 'degraded' | 'unhealthy';
}
export interface ClusterPoolPublic {
id: string;
name: string;
description?: string;
strategy: 'least-apps' | 'round-robin';
strategy: 'least-apps' | 'round-robin' | 'weighted-resource';
clusterIds: string[];
isActive: boolean;
isDefault: boolean;
priority: number;
clusters: Pick<ClusterPublic, 'id' | 'name' | 'region' | 'provider' | 'status'>[];
}
@@ -257,9 +266,41 @@ export interface ClusterPool {
id: string;
name: string;
description?: string;
strategy: 'least-apps' | 'round-robin';
strategy: 'least-apps' | 'round-robin' | 'weighted-resource';
clusterIds: string[];
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;
}