feat: add resource monitoring and live scaling
- Backend: getResourceUsage() method in KubernetesService fetches real-time CPU/Memory metrics from K8s metrics-server API per pod - Backend: updateResources() method patches live K8s deployments with new CPU/Memory requests+limits and replica count - Backend: GET /applications/:id/resources endpoint for monitoring - Backend: PATCH /applications/:id/resources endpoint for scaling - Backend: ScaleResourcesDto with validation for resource fields - Frontend: Resource monitoring card with per-pod CPU/Memory progress bars (color-coded: green < 50%, yellow < 80%, red > 80%) - Frontend: Pod status table showing phase, readiness, restarts - Frontend: Scaling controls for CPU request/limit, memory request/limit, and replicas with +/- buttons - Frontend: Auto-refresh metrics every 5 seconds when monitoring is open - Frontend: parseCpuToMillicores/parseMemoryToMi helpers for metric parsing
This commit is contained in:
@@ -18,7 +18,7 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||
import { ApplicationsService } from './applications.service';
|
||||
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
|
||||
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto } from './dto/application.dto';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole } from '../common/enums';
|
||||
@@ -87,6 +87,41 @@ export class ApplicationsController {
|
||||
return this.applicationsService.update(id, req.user.id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/resources')
|
||||
@ApiOperation({ summary: 'Get real-time resource usage for an application' })
|
||||
async getResources(@Param('id') id: string, @Request() req: any) {
|
||||
const app = await this.applicationsService.findOne(
|
||||
id,
|
||||
req.user.role === UserRole.ADMIN ? undefined : req.user.id,
|
||||
);
|
||||
return this.kubernetesService.getResourceUsage(app);
|
||||
}
|
||||
|
||||
@Patch(':id/resources')
|
||||
@ApiOperation({ summary: 'Update application resources (CPU/Memory/Replicas)' })
|
||||
async updateResources(
|
||||
@Param('id') id: string,
|
||||
@Request() req: any,
|
||||
@Body() dto: ScaleResourcesDto,
|
||||
) {
|
||||
const app = await this.applicationsService.findOne(id, req.user.id);
|
||||
|
||||
// Update in K8s (live)
|
||||
await this.kubernetesService.updateResources(app, dto);
|
||||
|
||||
// Update in DB
|
||||
const updateFields: any = {};
|
||||
if (dto.cpuRequest) updateFields.cpuRequest = dto.cpuRequest;
|
||||
if (dto.cpuLimit) updateFields.cpuLimit = dto.cpuLimit;
|
||||
if (dto.memoryRequest) updateFields.memoryRequest = dto.memoryRequest;
|
||||
if (dto.memoryLimit) updateFields.memoryLimit = dto.memoryLimit;
|
||||
if (dto.replicas !== undefined) updateFields.replicas = dto.replicas;
|
||||
|
||||
const updated = await this.applicationsService.update(id, req.user.id, updateFields);
|
||||
this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(dto)}`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete an application and all its resources' })
|
||||
async delete(@Param('id') id: string, @Request() req: any) {
|
||||
|
||||
@@ -118,3 +118,32 @@ export class UpdateApplicationDto {
|
||||
@Max(10)
|
||||
replicas?: number;
|
||||
}
|
||||
|
||||
export class ScaleResourcesDto {
|
||||
@ApiPropertyOptional({ example: '100m' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cpuRequest?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '500m' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cpuLimit?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '128Mi' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memoryRequest?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '512Mi' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memoryLimit?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 2, minimum: 1, maximum: 10 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
replicas?: number;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
coreApi: k8s.CoreV1Api;
|
||||
appsApi: k8s.AppsV1Api;
|
||||
networkingApi: k8s.NetworkingV1Api;
|
||||
kc: k8s.KubeConfig;
|
||||
}> {
|
||||
const cluster = clusterId
|
||||
? await this.clustersService.findOne(clusterId)
|
||||
@@ -69,6 +70,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
coreApi: kc.makeApiClient(k8s.CoreV1Api),
|
||||
appsApi: kc.makeApiClient(k8s.AppsV1Api),
|
||||
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
|
||||
kc,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -523,6 +525,167 @@ export class KubernetesService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time resource usage (CPU/Memory) for an app's pods via metrics-server.
|
||||
* Also returns the configured requests/limits and pod status.
|
||||
*/
|
||||
async getResourceUsage(app: Application): Promise<any> {
|
||||
const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
// Get deployment info for configured resources
|
||||
let deployment: k8s.V1Deployment | null = null;
|
||||
try {
|
||||
const depResponse = await appsApi.readNamespacedDeployment(app.name, namespace);
|
||||
deployment = depResponse.body;
|
||||
} catch {
|
||||
// Deployment may not exist yet
|
||||
}
|
||||
|
||||
// Get pods
|
||||
const podsResponse = await coreApi.listNamespacedPod(
|
||||
namespace,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
`app=${app.name}`,
|
||||
);
|
||||
|
||||
const pods = podsResponse.body.items.map((pod) => ({
|
||||
name: pod.metadata?.name,
|
||||
status: pod.status?.phase,
|
||||
ready: pod.status?.conditions?.find((c) => c.type === 'Ready')?.status === 'True',
|
||||
restarts: pod.status?.containerStatuses?.[0]?.restartCount || 0,
|
||||
startedAt: pod.status?.startTime,
|
||||
}));
|
||||
|
||||
// Try to get metrics from metrics-server via custom API
|
||||
let podMetrics: any[] = [];
|
||||
try {
|
||||
const metricsClient = new k8s.CustomObjectsApi(kc.getCurrentCluster()?.server);
|
||||
// Use the kc to make a raw request to metrics API
|
||||
const opts: any = {};
|
||||
await kc.applyToRequest(opts);
|
||||
|
||||
const metricsUrl = `${kc.getCurrentCluster()?.server}/apis/metrics.k8s.io/v1beta1/namespaces/${namespace}/pods`;
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const url = new URL(metricsUrl);
|
||||
|
||||
podMetrics = await new Promise((resolve) => {
|
||||
const client = url.protocol === 'https:' ? https : http;
|
||||
const reqOpts: any = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname + `?labelSelector=app%3D${app.name}`,
|
||||
method: 'GET',
|
||||
headers: opts.headers || {},
|
||||
rejectUnauthorized: false,
|
||||
};
|
||||
|
||||
// Apply TLS from kubeconfig
|
||||
if (opts.ca) reqOpts.ca = opts.ca;
|
||||
if (opts.cert) reqOpts.cert = opts.cert;
|
||||
if (opts.key) reqOpts.key = opts.key;
|
||||
|
||||
const req = client.request(reqOpts, (res: any) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk: string) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const items = (parsed.items || []).map((item: any) => ({
|
||||
name: item.metadata?.name,
|
||||
cpu: item.containers?.[0]?.usage?.cpu || '0',
|
||||
memory: item.containers?.[0]?.usage?.memory || '0',
|
||||
}));
|
||||
resolve(items);
|
||||
} catch {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', () => resolve([]));
|
||||
req.end();
|
||||
});
|
||||
} catch {
|
||||
this.logger.warn(`Metrics not available for ${app.name}`);
|
||||
}
|
||||
|
||||
// Get configured resources from deployment
|
||||
const container = deployment?.spec?.template?.spec?.containers?.[0];
|
||||
const configured = {
|
||||
cpuRequest: container?.resources?.requests?.cpu || app.cpuRequest,
|
||||
cpuLimit: container?.resources?.limits?.cpu || app.cpuLimit,
|
||||
memoryRequest: container?.resources?.requests?.memory || app.memoryRequest,
|
||||
memoryLimit: container?.resources?.limits?.memory || app.memoryLimit,
|
||||
replicas: deployment?.spec?.replicas ?? app.replicas,
|
||||
readyReplicas: deployment?.status?.readyReplicas || 0,
|
||||
availableReplicas: deployment?.status?.availableReplicas || 0,
|
||||
};
|
||||
|
||||
return {
|
||||
configured,
|
||||
pods,
|
||||
metrics: podMetrics,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update resource limits/requests and replicas on a live K8s deployment.
|
||||
*/
|
||||
async updateResources(
|
||||
app: Application,
|
||||
resources: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number },
|
||||
): Promise<void> {
|
||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
const patch: any = { spec: {} };
|
||||
|
||||
if (resources.replicas !== undefined) {
|
||||
patch.spec.replicas = resources.replicas;
|
||||
}
|
||||
|
||||
if (resources.cpuRequest || resources.cpuLimit || resources.memoryRequest || resources.memoryLimit) {
|
||||
patch.spec.template = {
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: app.name,
|
||||
resources: {
|
||||
requests: {
|
||||
...(resources.cpuRequest && { cpu: resources.cpuRequest }),
|
||||
...(resources.memoryRequest && { memory: resources.memoryRequest }),
|
||||
},
|
||||
limits: {
|
||||
...(resources.cpuLimit && { cpu: resources.cpuLimit }),
|
||||
...(resources.memoryLimit && { memory: resources.memoryLimit }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await appsApi.patchNamespacedDeployment(
|
||||
app.name,
|
||||
namespace,
|
||||
patch,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
|
||||
);
|
||||
|
||||
this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(resources)}`);
|
||||
}
|
||||
|
||||
async deleteApplication(app: Application): Promise<void> {
|
||||
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
Reference in New Issue
Block a user