Add automatic cluster pool allocation.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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'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>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-gray-500">Cluster</span>
|
<span className="text-sm text-gray-500">Cluster</span>
|
||||||
<span className="text-sm font-medium">
|
<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}`
|
? `${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)`
|
? `${pools.find((p) => p.id === form.poolId)?.name || 'Pool'} (Load Balanced)`
|
||||||
: 'Default Cluster'}
|
: 'Automatic allocator'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user