From 51e56c6996b5dd028aa87a201057d3af85a39d46 Mon Sep 17 00:00:00 2001 From: keyhan Date: Sun, 5 Apr 2026 16:03:01 +0330 Subject: [PATCH] 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 --- .../applications/applications.controller.ts | 37 ++- .../src/applications/dto/application.dto.ts | 29 ++ backend/src/kubernetes/kubernetes.service.ts | 163 ++++++++++ frontend/src/app/dashboard/apps/[id]/page.tsx | 297 +++++++++++++++++- frontend/src/types/index.ts | 28 ++ 5 files changed, 552 insertions(+), 2 deletions(-) diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index 8991379..0caf5ff 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -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) { diff --git a/backend/src/applications/dto/application.dto.ts b/backend/src/applications/dto/application.dto.ts index e77b650..b27fe2c 100644 --- a/backend/src/applications/dto/application.dto.ts +++ b/backend/src/applications/dto/application.dto.ts @@ -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; +} diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index 83badab..2d5f00e 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -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 { + 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 { + 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 { const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index 7f255e7..e9b3d6a 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useRouter } from 'next/navigation'; import api from '@/lib/api'; import toast from 'react-hot-toast'; -import type { Application, Deployment } from '@/types'; +import type { Application, Deployment, ResourceUsage } from '@/types'; import { useState, useRef, useCallback, useEffect } from 'react'; const statusColors: Record = { @@ -17,6 +17,30 @@ const statusColors: Record = { stopped: 'bg-gray-100 text-gray-700', }; +/** + * Parse CPU value to millicores (e.g. "100m" → 100, "1" → 1000, "250n" → 0.00025) + */ +function parseCpuToMillicores(cpu: string): number { + if (!cpu || cpu === '0') return 0; + if (cpu.endsWith('n')) return parseFloat(cpu) / 1_000_000; + if (cpu.endsWith('u')) return parseFloat(cpu) / 1_000; + if (cpu.endsWith('m')) return parseFloat(cpu); + return parseFloat(cpu) * 1000; +} + +/** + * Parse memory value to MiB (e.g. "128Mi" → 128, "1Gi" → 1024, "131072Ki" → 128) + */ +function parseMemoryToMi(memory: string): number { + if (!memory || memory === '0') return 0; + if (memory.endsWith('Ki')) return parseFloat(memory) / 1024; + if (memory.endsWith('Mi')) return parseFloat(memory); + if (memory.endsWith('Gi')) return parseFloat(memory) * 1024; + if (memory.endsWith('Ti')) return parseFloat(memory) * 1024 * 1024; + // raw bytes + return parseFloat(memory) / (1024 * 1024); +} + export default function AppDetailPage() { const params = useParams(); const router = useRouter(); @@ -27,6 +51,14 @@ export default function AppDetailPage() { const logsEndRef = useRef(null); const [uploadProgress, setUploadProgress] = useState(0); const [isDragging, setIsDragging] = useState(false); + const [showResources, setShowResources] = useState(false); + const [resourceForm, setResourceForm] = useState({ + cpuRequest: '', + cpuLimit: '', + memoryRequest: '', + memoryLimit: '', + replicas: 1, + }); const { data: app, isLoading } = useQuery({ queryKey: ['application', appId], @@ -46,6 +78,26 @@ export default function AppDetailPage() { refetchInterval: showLogs ? 3000 : false, }); + const { data: resourceUsage, isLoading: resourcesLoading } = useQuery({ + queryKey: ['resources', appId], + queryFn: () => api.get(`/applications/${appId}/resources`).then((r) => r.data), + enabled: showResources, + refetchInterval: showResources ? 5000 : false, + }); + + // Sync form when resource data loads + useEffect(() => { + if (resourceUsage?.configured) { + setResourceForm({ + cpuRequest: resourceUsage.configured.cpuRequest, + cpuLimit: resourceUsage.configured.cpuLimit, + memoryRequest: resourceUsage.configured.memoryRequest, + memoryLimit: resourceUsage.configured.memoryLimit, + replicas: resourceUsage.configured.replicas, + }); + } + }, [resourceUsage?.configured]); + // Auto-scroll logs to bottom useEffect(() => { if (logsEndRef.current) { @@ -113,6 +165,17 @@ export default function AppDetailPage() { onError: () => toast.error('Failed to delete application'), }); + const scaleMutation = useMutation({ + mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) => + api.patch(`/applications/${appId}/resources`, data), + onSuccess: () => { + invalidateAll(); + queryClient.invalidateQueries({ queryKey: ['resources', appId] }); + toast.success('Resources updated successfully!'); + }, + onError: () => toast.error('Failed to update resources'), + }); + const uploadMutation = useMutation({ mutationFn: (file: File) => { const formData = new FormData(); @@ -424,6 +487,238 @@ export default function AppDetailPage() { + {/* Resource Monitoring & Scaling */} +
+
+

📊 Resources & Scaling

+ +
+ + {showResources && ( +
+ {/* Live Metrics */} + {resourcesLoading ? ( +
Loading metrics...
+ ) : resourceUsage ? ( + <> + {/* Cluster Status */} +
+
+

Replicas

+

+ {resourceUsage.configured.readyReplicas}/{resourceUsage.configured.replicas} +

+

ready

+
+
+

Pods

+

{resourceUsage.pods.length}

+

+ {resourceUsage.pods.filter((p) => p.ready).length} ready +

+
+
+

Metrics

+

+ {resourceUsage.metrics.length > 0 ? '✅' : '⏳'} +

+

+ {resourceUsage.metrics.length > 0 ? 'Available' : 'Waiting...'} +

+
+
+ + {/* Per-Pod Metrics */} + {resourceUsage.metrics.length > 0 && ( +
+

Pod Usage

+ {resourceUsage.metrics.map((metric) => { + const cpuUsed = parseCpuToMillicores(metric.cpu); + const cpuLimit = parseCpuToMillicores(resourceUsage.configured.cpuLimit); + const cpuPercent = cpuLimit > 0 ? Math.min((cpuUsed / cpuLimit) * 100, 100) : 0; + + const memUsed = parseMemoryToMi(metric.memory); + const memLimit = parseMemoryToMi(resourceUsage.configured.memoryLimit); + const memPercent = memLimit > 0 ? Math.min((memUsed / memLimit) * 100, 100) : 0; + + return ( +
+
+

+ 🟢 {metric.name} +

+
+ + {/* CPU Bar */} +
+
+ CPU + + {cpuUsed.toFixed(1)}m / {cpuLimit.toFixed(0)}m ({cpuPercent.toFixed(1)}%) + +
+
+
80 ? 'bg-red-500' : cpuPercent > 50 ? 'bg-yellow-500' : 'bg-green-500' + }`} + style={{ width: `${cpuPercent}%` }} + /> +
+
+ + {/* Memory Bar */} +
+
+ Memory + + {memUsed.toFixed(1)}Mi / {memLimit.toFixed(0)}Mi ({memPercent.toFixed(1)}%) + +
+
+
80 ? 'bg-red-500' : memPercent > 50 ? 'bg-yellow-500' : 'bg-green-500' + }`} + style={{ width: `${memPercent}%` }} + /> +
+
+
+ ); + })} +
+ )} + + {/* Pod Status Table */} + {resourceUsage.pods.length > 0 && ( +
+

Pod Status

+
+ + + + + + + + + + + {resourceUsage.pods.map((pod) => ( + + + + + + + ))} + +
PodStatusReadyRestarts
+ {pod.name} + + + {pod.status} + + {pod.ready ? '✅' : '⏳'}{pod.restarts}
+
+
+ )} + + {/* Scaling Controls */} +
+

⚙️ Scale Resources

+
+
+ + setResourceForm((f) => ({ ...f, cpuRequest: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent" + placeholder="100m" + /> +
+
+ + setResourceForm((f) => ({ ...f, cpuLimit: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent" + placeholder="500m" + /> +
+
+ + setResourceForm((f) => ({ ...f, memoryRequest: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent" + placeholder="128Mi" + /> +
+
+ + setResourceForm((f) => ({ ...f, memoryLimit: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent" + placeholder="512Mi" + /> +
+
+ +
+ + {resourceForm.replicas} + +
+
+
+ +
+
+
+ + ) : ( +
+

+ {isStopped ? 'Application is stopped. Start it to see resources.' : 'No resource data available yet.'} +

+
+ )} +
+ )} +
+ {/* Pod Logs */}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a99dd1d..7570624 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -92,3 +92,31 @@ export interface CreateApplicationDto { replicas?: number; port?: number; } + +export interface PodInfo { + name: string; + status: string; + ready: boolean; + restarts: number; + startedAt?: string; +} + +export interface PodMetric { + name: string; + cpu: string; + memory: string; +} + +export interface ResourceUsage { + configured: { + cpuRequest: string; + cpuLimit: string; + memoryRequest: string; + memoryLimit: string; + replicas: number; + readyReplicas: number; + availableReplicas: number; + }; + pods: PodInfo[]; + metrics: PodMetric[]; +}