From b24d1505b623f73c555b9e9531ac42d9c84a39d4 Mon Sep 17 00:00:00 2001 From: keyhan Date: Thu, 14 May 2026 16:28:01 +0330 Subject: [PATCH] feat: per-workload resources, storage GiB metrics, optional service disks - Expose DB/Redis/RabbitMQ usage plus sidecars in getResourceUsage - Storage API: GiB fields, Redis/Rabbit PVC usage, fix du/exec container names - PATCH /resources accepts workload; persist entity fields only for app - App detail: workload cards, disk bars, DB expand, scale target select Co-authored-by: Cursor --- .../applications/applications.controller.ts | 26 +- .../src/applications/dto/application.dto.ts | 9 + backend/src/kubernetes/kubernetes.service.ts | 415 +++++++++---- frontend/src/app/dashboard/apps/[id]/page.tsx | 551 ++++++++++++------ frontend/src/types/index.ts | 24 + 5 files changed, 734 insertions(+), 291 deletions(-) diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index 4007cf8..0a34ebf 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -239,18 +239,28 @@ export class ApplicationsController { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); + const workload = dto.workload || 'app'; + if (dto.replicas !== undefined && workload !== 'app') { + throw new BadRequestException('Replicas can only be changed for the main application workload.'); + } + // Update in K8s (live) - await this.kubernetesService.updateResources(app, dto); + await this.kubernetesService.updateResources(app, dto, workload); - // Update in DB + // Update in DB — only main app resources are stored on the Application entity 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; + if (workload === 'app') { + 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, app.userId, updateFields); + const updated = + Object.keys(updateFields).length > 0 + ? await this.applicationsService.update(id, app.userId, updateFields) + : app; this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(dto)}`); return updated; } diff --git a/backend/src/applications/dto/application.dto.ts b/backend/src/applications/dto/application.dto.ts index 1c78fbd..1b85cfd 100644 --- a/backend/src/applications/dto/application.dto.ts +++ b/backend/src/applications/dto/application.dto.ts @@ -9,6 +9,7 @@ import { Min, Max, Matches, + IsIn, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { AppRuntime, DatabaseType } from '../../common/enums'; @@ -283,6 +284,14 @@ export class CheckDnsDto { } export class ScaleResourcesDto { + @ApiPropertyOptional({ + enum: ['app', 'database', 'redis', 'rabbitmq'], + description: 'Which deployment to scale. Default: main application.', + }) + @IsOptional() + @IsIn(['app', 'database', 'redis', 'rabbitmq']) + workload?: 'app' | 'database' | 'redis' | 'rabbitmq'; + @ApiPropertyOptional({ example: '100m' }) @IsOptional() @IsString() diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index 00b4c7e..7f6b372 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit, BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as k8s from '@kubernetes/client-node'; import * as fs from 'fs'; @@ -42,6 +42,17 @@ interface ManifestContext { logPaths: string[]; } +type StorageUsageSlice = { + allocatedRaw: string; + allocatedGi: number; + usedGi: number; + availableGi: number; + usedPercent: number; + allocated: string; + used: string; + available: string; +}; + @Injectable() export class KubernetesService implements OnModuleInit { private readonly logger = new Logger(KubernetesService.name); @@ -1452,67 +1463,48 @@ 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 + /** Map optional workload keys to Deployment + primary container name */ + workloadDeploymentTarget( + app: Application, + workload: 'app' | 'database' | 'redis' | 'rabbitmq', + ): { deploymentName: string; containerName: string } | null { + switch (workload) { + case 'app': + return { deploymentName: app.name, containerName: app.name }; + case 'database': + if (!app.databaseType || app.databaseType === DatabaseType.NONE) return null; + return { deploymentName: `${app.name}-db`, containerName: `${app.name}-db` }; + case 'redis': + if (!app.enableRedis) return null; + return { deploymentName: `${app.name}-redis`, containerName: 'redis' }; + case 'rabbitmq': + if (!app.enableRabbitmq) return null; + return { deploymentName: `${app.name}-rabbitmq`, containerName: 'rabbitmq' }; + default: + return null; } + } - // 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[] = []; + private async fetchNamespacePodMetrics(kc: k8s.KubeConfig, namespace: string, appLabelValue: string): Promise { 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); + const labelQ = `labelSelector=${encodeURIComponent(`app=${appLabelValue}`)}`; - podMetrics = await new Promise((resolve) => { + return 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}`, + path: `${url.pathname}?${labelQ}`, 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; @@ -1525,6 +1517,11 @@ export class KubernetesService implements OnModuleInit { const parsed = JSON.parse(data); const items = (parsed.items || []).map((item: any) => ({ name: item.metadata?.name, + containers: (item.containers || []).map((c: any) => ({ + name: c.name, + cpu: c.usage?.cpu || '0', + memory: c.usage?.memory || '0', + })), cpu: item.containers?.[0]?.usage?.cpu || '0', memory: item.containers?.[0]?.usage?.memory || '0', })); @@ -1538,41 +1535,208 @@ export class KubernetesService implements OnModuleInit { req.end(); }); } catch { - this.logger.warn(`Metrics not available for ${app.name}`); + return []; + } + } + + private async getSingleWorkloadUsage( + app: Application, + namespace: string, + appsApi: k8s.AppsV1Api, + coreApi: k8s.CoreV1Api, + kc: k8s.KubeConfig, + opts: { + key: string; + title: string; + deploymentName: string; + primaryContainerName: string; + fallbackCpuRequest?: string; + fallbackCpuLimit?: string; + fallbackMemoryRequest?: string; + fallbackMemoryLimit?: string; + fallbackReplicas?: number; + }, + ): Promise { + let deployment: k8s.V1Deployment | null = null; + try { + const depResponse = await appsApi.readNamespacedDeployment(opts.deploymentName, namespace); + deployment = depResponse.body; + } catch { + return null; } - // Get configured resources from deployment - const container = deployment?.spec?.template?.spec?.containers?.[0]; + const podsResponse = await coreApi.listNamespacedPod( + namespace, + undefined, + undefined, + undefined, + undefined, + `app=${opts.deploymentName}`, + ); + + 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?.reduce((s, c) => s + (c.restartCount || 0), 0) || 0, + startedAt: pod.status?.startTime, + })); + + const podMetrics = await this.fetchNamespacePodMetrics(kc, namespace, opts.deploymentName); + + const container = + deployment.spec?.template?.spec?.containers?.find((c) => c.name === opts.primaryContainerName) || + deployment.spec?.template?.spec?.containers?.[0]; + + const sidecars = + (deployment.spec?.template?.spec?.containers || []) + .filter((c) => c.name !== opts.primaryContainerName) + .map((c) => ({ + name: c.name, + cpuRequest: c.resources?.requests?.cpu, + cpuLimit: c.resources?.limits?.cpu, + memoryRequest: c.resources?.requests?.memory, + memoryLimit: c.resources?.limits?.memory, + })); + 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, + cpuRequest: container?.resources?.requests?.cpu || opts.fallbackCpuRequest, + cpuLimit: container?.resources?.limits?.cpu || opts.fallbackCpuLimit, + memoryRequest: container?.resources?.requests?.memory || opts.fallbackMemoryRequest, + memoryLimit: container?.resources?.limits?.memory || opts.fallbackMemoryLimit, + replicas: deployment.spec?.replicas ?? opts.fallbackReplicas ?? 1, + readyReplicas: deployment.status?.readyReplicas || 0, + availableReplicas: deployment.status?.availableReplicas || 0, }; + const metrics = podMetrics.map((m: any) => { + const matchC = + m.containers?.find((c: any) => c.name === opts.primaryContainerName) || m.containers?.[0]; + return { + name: m.name, + cpu: matchC?.cpu || m.cpu || '0', + memory: matchC?.memory || m.memory || '0', + containers: m.containers, + }; + }); + return { + key: opts.key, + title: opts.title, + deploymentName: opts.deploymentName, configured, pods, - metrics: podMetrics, + metrics, + sidecars: sidecars.length ? sidecars : undefined, + }; + } + + /** + * Get real-time resource usage (CPU/Memory) for an app's pods via metrics-server. + * Includes application workload, database, and optional Redis / RabbitMQ when enabled. + */ + async getResourceUsage(app: Application): Promise { + const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId); + const namespace = `user-${app.userId.split('-')[0]}`; + + const workloads: any[] = []; + + const appW = await this.getSingleWorkloadUsage(app, namespace, appsApi, coreApi, kc, { + key: 'app', + title: 'Application', + deploymentName: app.name, + primaryContainerName: app.name, + fallbackCpuRequest: app.cpuRequest, + fallbackCpuLimit: app.cpuLimit, + fallbackMemoryRequest: app.memoryRequest, + fallbackMemoryLimit: app.memoryLimit, + fallbackReplicas: app.replicas, + }); + if (appW) workloads.push(appW); + + if (app.databaseType && app.databaseType !== DatabaseType.NONE) { + const dbW = await this.getSingleWorkloadUsage(app, namespace, appsApi, coreApi, kc, { + key: 'database', + title: 'Database', + deploymentName: `${app.name}-db`, + primaryContainerName: `${app.name}-db`, + fallbackCpuRequest: '100m', + fallbackCpuLimit: '500m', + fallbackMemoryRequest: '256Mi', + fallbackMemoryLimit: '512Mi', + fallbackReplicas: 1, + }); + if (dbW) workloads.push(dbW); + } + + if (app.enableRedis) { + const r = await this.getSingleWorkloadUsage(app, namespace, appsApi, coreApi, kc, { + key: 'redis', + title: 'Redis', + deploymentName: `${app.name}-redis`, + primaryContainerName: 'redis', + fallbackCpuRequest: '50m', + fallbackCpuLimit: '200m', + fallbackMemoryRequest: '64Mi', + fallbackMemoryLimit: '256Mi', + fallbackReplicas: 1, + }); + if (r) workloads.push(r); + } + + if (app.enableRabbitmq) { + const mq = await this.getSingleWorkloadUsage(app, namespace, appsApi, coreApi, kc, { + key: 'rabbitmq', + title: 'RabbitMQ', + deploymentName: `${app.name}-rabbitmq`, + primaryContainerName: 'rabbitmq', + fallbackCpuRequest: '100m', + fallbackCpuLimit: '500m', + fallbackMemoryRequest: '256Mi', + fallbackMemoryLimit: '512Mi', + fallbackReplicas: 1, + }); + if (mq) workloads.push(mq); + } + + const primary = workloads.find((w) => w.key === 'app') || workloads[0]; + const loggingNote = app.enableElasticsearch + ? 'Elasticsearch log forwarding is enabled via Fluent Bit sidecar on application pods.' + : undefined; + + return { + workloads, + loggingNote, + configured: primary?.configured, + pods: primary?.pods || [], + metrics: primary?.metrics || [], }; } /** * Update resource limits/requests and replicas on a live K8s deployment. + * @param workload Which deployment to patch (default: main application). Replicas only apply to `app`. */ async updateResources( app: Application, resources: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }, + workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app', ): Promise { const { appsApi } = await this.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; + const target = this.workloadDeploymentTarget(app, workload); + if (!target) { + throw new BadRequestException( + workload === 'database' + ? 'This application does not have a database deployment.' + : `Workload "${workload}" is not enabled for this application.`, + ); + } + const patch: any = { spec: {} }; - if (resources.replicas !== undefined) { + if (resources.replicas !== undefined && workload === 'app') { patch.spec.replicas = resources.replicas; } @@ -1581,7 +1745,7 @@ export class KubernetesService implements OnModuleInit { spec: { containers: [ { - name: app.name, + name: target.containerName, resources: { requests: { ...(resources.cpuRequest && { cpu: resources.cpuRequest }), @@ -1599,7 +1763,7 @@ export class KubernetesService implements OnModuleInit { } await appsApi.patchNamespacedDeployment( - app.name, + target.deploymentName, namespace, patch, undefined, @@ -1610,7 +1774,7 @@ export class KubernetesService implements OnModuleInit { { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } }, ); - this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(resources)}`); + this.logger.log(`Updated resources for ${target.deploymentName} (${workload}): ${JSON.stringify(resources)}`); } /** @@ -2053,30 +2217,28 @@ export class KubernetesService implements OnModuleInit { /** * Get comprehensive storage usage for an application. - * Returns allocated, used, and available storage for database and app (wp-content) PVCs. + * Includes app + DB PVCs and optional Redis/RabbitMQ data volumes when enabled. */ async getStorageUsage(app: Application): Promise<{ - database: { allocated: string; used: string; available: string; usedPercent: number } | null; - appStorage: { allocated: string; used: string; available: string; usedPercent: number } | null; + database: StorageUsageSlice | null; + appStorage: StorageUsageSlice | null; + redisStorage: StorageUsageSlice | null; + rabbitmqStorage: StorageUsageSlice | null; totalAllocatedGb: number; totalUsedGb: number; }> { const { coreApi } = await this.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; - const result: { - database: { allocated: string; used: string; available: string; usedPercent: number } | null; - appStorage: { allocated: string; used: string; available: string; usedPercent: number } | null; - totalAllocatedGb: number; - totalUsedGb: number; - } = { - database: null, - appStorage: null, + const result = { + database: null as StorageUsageSlice | null, + appStorage: null as StorageUsageSlice | null, + redisStorage: null as StorageUsageSlice | null, + rabbitmqStorage: null as StorageUsageSlice | null, totalAllocatedGb: 0, totalUsedGb: 0, }; - // Helper to parse size strings to GB const parseToGb = (size: string): number => { if (!size) return 0; const match = size.match(/^(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)?$/i); @@ -2092,7 +2254,6 @@ export class KubernetesService implements OnModuleInit { } }; - // Helper to format GB to human readable const formatSize = (gb: number): string => { if (gb >= 1) return `${gb.toFixed(1)}Gi`; const mb = gb * 1024; @@ -2100,7 +2261,21 @@ export class KubernetesService implements OnModuleInit { return `${(mb * 1024).toFixed(0)}Ki`; }; - // Get database PVC usage + const makeSlice = (allocatedStr: string, allocatedGb: number, usedGb: number): StorageUsageSlice => { + const availableGb = Math.max(0, allocatedGb - usedGb); + const usedPercent = allocatedGb > 0 ? Math.round((usedGb / allocatedGb) * 100) : 0; + return { + allocatedRaw: allocatedStr, + allocatedGi: allocatedGb, + usedGi: usedGb, + availableGi: availableGb, + usedPercent, + allocated: allocatedStr, + used: formatSize(usedGb), + available: formatSize(availableGb), + }; + }; + if (app.databaseType && app.databaseType !== DatabaseType.NONE) { try { const pvcName = `${app.name}-db`; @@ -2108,27 +2283,22 @@ export class KubernetesService implements OnModuleInit { const allocatedStr = pvc.body.spec?.resources?.requests?.storage || app.dbStorageSize || '1Gi'; const allocatedGb = parseToGb(allocatedStr); - // Try to get actual usage from pod exec (du command) let usedGb = 0; try { - usedGb = await this.getPvcUsageFromPod(app, `${app.name}-db`, '/var/lib/postgresql/data', namespace); - if (usedGb === 0 && app.databaseType === DatabaseType.MYSQL) { - usedGb = await this.getPvcUsageFromPod(app, `${app.name}-db`, '/var/lib/mysql', namespace); + const dbDep = `${app.name}-db`; + const dbContainer = `${app.name}-db`; + if (app.databaseType === DatabaseType.POSTGRESQL) { + usedGb = await this.getPvcUsageFromPod(app, dbDep, '/var/lib/postgresql/data', namespace, dbContainer); + } else if (app.databaseType === DatabaseType.MYSQL || app.databaseType === DatabaseType.MARIADB) { + usedGb = await this.getPvcUsageFromPod(app, dbDep, '/var/lib/mysql', namespace, dbContainer); + } else if (app.databaseType === DatabaseType.MONGODB) { + usedGb = await this.getPvcUsageFromPod(app, dbDep, '/data/db', namespace, dbContainer); } } catch { - // Estimate ~10% usage if we can't get actual usedGb = allocatedGb * 0.1; } - const availableGb = Math.max(0, allocatedGb - usedGb); - const usedPercent = allocatedGb > 0 ? Math.round((usedGb / allocatedGb) * 100) : 0; - - result.database = { - allocated: allocatedStr, - used: formatSize(usedGb), - available: formatSize(availableGb), - usedPercent, - }; + result.database = makeSlice(allocatedStr, allocatedGb, usedGb); result.totalAllocatedGb += allocatedGb; result.totalUsedGb += usedGb; } catch (e: any) { @@ -2136,48 +2306,74 @@ export class KubernetesService implements OnModuleInit { } } - // Get app storage PVC usage (all app types have storage now) try { - // Try new unified name first, then legacy wp-content name for backward compatibility let pvc; let pvcName = `${app.name}-storage`; try { pvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace); } catch { - // Fallback to legacy WordPress PVC name pvcName = `${app.name}-wp-content`; pvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace); } const allocatedStr = pvc.body.spec?.resources?.requests?.storage || app.appStorageSize || '2Gi'; const allocatedGb = parseToGb(allocatedStr); - - // Determine mount path based on runtime const mountPath = this.getStorageMountPath(app.runtime); - // Try to get actual usage from pod exec let usedGb = 0; try { - usedGb = await this.getPvcUsageFromPod(app, app.name, mountPath, namespace); + usedGb = await this.getPvcUsageFromPod(app, app.name, mountPath, namespace, app.name); } catch { usedGb = allocatedGb * 0.1; } - const availableGb = Math.max(0, allocatedGb - usedGb); - const usedPercent = allocatedGb > 0 ? Math.round((usedGb / allocatedGb) * 100) : 0; - - result.appStorage = { - allocated: allocatedStr, - used: formatSize(usedGb), - available: formatSize(availableGb), - usedPercent, - }; + result.appStorage = makeSlice(allocatedStr, allocatedGb, usedGb); result.totalAllocatedGb += allocatedGb; result.totalUsedGb += usedGb; } catch (e: any) { this.logger.warn(`Failed to get app storage usage for ${app.name}: ${e.message}`); } + if (app.enableRedis) { + try { + const redisPvc = `${app.name}-redis-data`; + const pvc = await coreApi.readNamespacedPersistentVolumeClaim(redisPvc, namespace); + const allocatedStr = pvc.body.spec?.resources?.requests?.storage || '1Gi'; + const allocatedGb = parseToGb(allocatedStr); + let usedGb = 0; + try { + usedGb = await this.getPvcUsageFromPod(app, `${app.name}-redis`, '/data', namespace, 'redis'); + } catch { + usedGb = allocatedGb * 0.05; + } + result.redisStorage = makeSlice(allocatedStr, allocatedGb, usedGb); + result.totalAllocatedGb += allocatedGb; + result.totalUsedGb += usedGb; + } catch (e: any) { + this.logger.warn(`Failed to get Redis storage usage for ${app.name}: ${e.message}`); + } + } + + if (app.enableRabbitmq) { + try { + const mqPvc = `${app.name}-rabbitmq-data`; + const pvc = await coreApi.readNamespacedPersistentVolumeClaim(mqPvc, namespace); + const allocatedStr = pvc.body.spec?.resources?.requests?.storage || '1Gi'; + const allocatedGb = parseToGb(allocatedStr); + let usedGb = 0; + try { + usedGb = await this.getPvcUsageFromPod(app, `${app.name}-rabbitmq`, '/var/lib/rabbitmq', namespace, 'rabbitmq'); + } catch { + usedGb = allocatedGb * 0.05; + } + result.rabbitmqStorage = makeSlice(allocatedStr, allocatedGb, usedGb); + result.totalAllocatedGb += allocatedGb; + result.totalUsedGb += usedGb; + } catch (e: any) { + this.logger.warn(`Failed to get RabbitMQ storage usage for ${app.name}: ${e.message}`); + } + } + return result; } @@ -2185,10 +2381,15 @@ export class KubernetesService implements OnModuleInit { * Get PVC usage by executing du command in a pod. * Returns usage in GB. */ - private async getPvcUsageFromPod(app: Application, deploymentName: string, mountPath: string, namespace: string): Promise { + private async getPvcUsageFromPod( + app: Application, + deploymentName: string, + mountPath: string, + namespace: string, + containerName: string, + ): Promise { const { coreApi, kc } = await this.getK8sClient(app.clusterId); - // Find a running pod for this deployment const pods = await coreApi.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `app=${deploymentName}`); const runningPod = pods.body.items.find(p => p.status?.phase === 'Running'); @@ -2206,7 +2407,7 @@ export class KubernetesService implements OnModuleInit { exec.exec( namespace, runningPod.metadata!.name!, - deploymentName === `${app.name}-db` ? 'db' : app.name, + containerName, ['du', '-sb', mountPath], stdout, null, @@ -2221,7 +2422,7 @@ export class KubernetesService implements OnModuleInit { const output = Buffer.concat(chunks).toString().trim(); const bytes = parseInt(output.split(/\s+/)[0], 10) || 0; - return bytes / (1024 * 1024 * 1024); // Convert to GB + return bytes / (1024 * 1024 * 1024); } /** diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index 42f7ba0..271acdd 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -69,6 +69,8 @@ export default function AppDetailPage() { memoryLimit: '', replicas: 1, }); + const [scaleWorkload, setScaleWorkload] = useState<'app' | 'database' | 'redis' | 'rabbitmq'>('app'); + const [showDbDiskExpand, setShowDbDiskExpand] = useState(false); const [dbStorageSize, setDbStorageSize] = useState('1'); const [dbStorageLoading, setDbStorageLoading] = useState(false); const [showSnapshots, setShowSnapshots] = useState(false); @@ -93,6 +95,13 @@ export default function AppDetailPage() { queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data), }); + useEffect(() => { + if (!app) return; + if (scaleWorkload === 'database' && app.databaseType === 'none') setScaleWorkload('app'); + else if (scaleWorkload === 'redis' && !app.enableRedis) setScaleWorkload('app'); + else if (scaleWorkload === 'rabbitmq' && !app.enableRabbitmq) setScaleWorkload('app'); + }, [app, scaleWorkload]); + const { data: deployments = [] } = useQuery({ queryKey: ['deployments', appId], queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data), @@ -146,9 +155,21 @@ export default function AppDetailPage() { }, [dbStorageData]); // Fetch comprehensive storage usage (allocated/used/available) + interface StorageUsageSlice { + allocatedRaw: string; + allocatedGi: number; + usedGi: number; + availableGi: number; + usedPercent: number; + } interface StorageUsageData { - database: { allocated: number; used: number; available: number } | null; - appStorage: { allocated: number; used: number; available: number } | null; + database: StorageUsageSlice | null; + appStorage: StorageUsageSlice | null; + redisStorage?: StorageUsageSlice | null; + rabbitmqStorage?: StorageUsageSlice | null; + totalAllocatedGb?: number; + totalUsedGb?: number; + configured?: { dbStorageSize: string; appStorageSize: string }; } const { data: storageUsage, isLoading: storageUsageLoading } = useQuery({ queryKey: ['storage-usage', appId], @@ -191,6 +212,7 @@ export default function AppDetailPage() { if (res.data.success) { toast.success(res.data.message || 'Database storage expanded!'); queryClient.invalidateQueries({ queryKey: ['db-storage', appId] }); + queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] }); } else { toast.error(res.data.message || 'Failed to expand storage'); } @@ -445,18 +467,27 @@ export default function AppDetailPage() { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; - // Sync form when resource data loads + // Sync form when resource data loads (selected workload) useEffect(() => { - if (resourceUsage?.configured) { + const workloads = resourceUsage?.workloads; + const w = + workloads?.find((x) => x.key === scaleWorkload) || + (scaleWorkload === 'app' && resourceUsage?.configured + ? { + key: 'app' as const, + configured: resourceUsage.configured, + } + : undefined); + if (w?.configured) { setResourceForm({ - cpuRequest: resourceUsage.configured.cpuRequest, - cpuLimit: resourceUsage.configured.cpuLimit, - memoryRequest: resourceUsage.configured.memoryRequest, - memoryLimit: resourceUsage.configured.memoryLimit, - replicas: resourceUsage.configured.replicas, + cpuRequest: w.configured.cpuRequest || '', + cpuLimit: w.configured.cpuLimit || '', + memoryRequest: w.configured.memoryRequest || '', + memoryLimit: w.configured.memoryLimit || '', + replicas: w.configured.replicas ?? 1, }); } - }, [resourceUsage?.configured]); + }, [resourceUsage, scaleWorkload]); // Auto-scroll logs to bottom useEffect(() => { @@ -544,6 +575,23 @@ export default function AppDetailPage() { onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to update resources'), }); + /** CPU/memory for DB / Redis / RabbitMQ — applies directly in Kubernetes (no billing wizard). */ + const directPatchResourcesMutation = useMutation({ + mutationFn: (data: { + workload: 'database' | 'redis' | 'rabbitmq'; + cpuRequest?: string; + cpuLimit?: string; + memoryRequest?: string; + memoryLimit?: string; + }) => api.patch(`/applications/${appId}/resources`, data), + onSuccess: () => { + invalidateAll(); + queryClient.invalidateQueries({ queryKey: ['resources', appId] }); + toast.success('Resources updated'); + }, + onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to update resources'), + }); + // Calculate upgrade cost before applying const calculateUpgradeCostMutation = useMutation({ mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) => @@ -555,14 +603,22 @@ export default function AppDetailPage() { onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to calculate upgrade cost'), }); - // Handler to check upgrade cost before applying + // Handler: app uses billing upgrade path when subscribed; other workloads patch directly. const handleScaleResources = () => { - // If app doesn't have billing cycle (free/unmanaged), apply directly + if (scaleWorkload !== 'app') { + directPatchResourcesMutation.mutate({ + workload: scaleWorkload, + cpuRequest: resourceForm.cpuRequest || undefined, + cpuLimit: resourceForm.cpuLimit || undefined, + memoryRequest: resourceForm.memoryRequest || undefined, + memoryLimit: resourceForm.memoryLimit || undefined, + }); + return; + } if (!app?.billingCycle) { scaleMutation.mutate(resourceForm); return; } - // Otherwise, calculate cost first calculateUpgradeCostMutation.mutate(resourceForm); }; @@ -1637,133 +1693,146 @@ export default function AppDetailPage() {
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}%` }} - /> -
-
-
- ); - })} -
+ {resourceUsage.loggingNote && ( +

{resourceUsage.loggingNote}

)} - {/* Pod Status Table */} - {resourceUsage.pods.length > 0 && ( -
-

Pod Status

-
- - - - - - - - - - - {resourceUsage.pods.map((pod) => ( - - - - - - - ))} - -
PodStatusReadyRestarts
- {pod.name} - - - {pod.status} - - {pod.ready ? : }{pod.restarts}
+ {(resourceUsage.workloads && resourceUsage.workloads.length > 0 + ? resourceUsage.workloads + : resourceUsage.configured + ? [ + { + key: 'app' as const, + title: 'Application', + deploymentName: app?.name || '', + configured: resourceUsage.configured, + pods: resourceUsage.pods, + metrics: resourceUsage.metrics, + sidecars: undefined, + }, + ] + : [] + ).map((w) => ( +
+
+

{w.title}

+ {w.deploymentName}
+ +
+
+

Replicas

+

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

+

ready

+
+
+

Pods

+

{w.pods.length}

+

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

+
+
+

Metrics

+

+ {w.metrics.length > 0 ? : } +

+

{w.metrics.length > 0 ? 'Live' : 'Waiting…'}

+
+
+ +
+
+ CPU: + {w.configured.cpuRequest} → {w.configured.cpuLimit} +
+
+ Memory: + {w.configured.memoryRequest} → {w.configured.memoryLimit} +
+
+ + {w.metrics.length > 0 && ( +
+

Live usage

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

+ {metric.name} +

+
+
+ 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 + {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}%` }} /> +
+
+
+ ); + })} +
+ )} + + {w.pods.length > 0 && ( +
+

Pods

+
+ + + + + + + + + + + {w.pods.map((pod) => ( + + + + + + + ))} + +
NameStatusReadyR
{pod.name} + {pod.status} + {pod.ready ? : }{pod.restarts}
+
+
+ )} + + {w.sidecars && w.sidecars.length > 0 && ( +
+ Sidecars: + {w.sidecars.map((s) => ( + + {s.name} (CPU {s.cpuLimit || '—'}, mem {s.memoryLimit || '—'}) + + ))} +
+ )}
- )} + ))} {/* Storage Usage Section */}
@@ -1778,56 +1847,119 @@ export default function AppDetailPage() { {storageUsage.database && (
- Database Storage + Database volume - {(storageUsage.database.used / (1024 * 1024 * 1024)).toFixed(2)} GB / {(storageUsage.database.allocated / (1024 * 1024 * 1024)).toFixed(1)} GB + {storageUsage.database.usedGi.toFixed(2)} GiB / {storageUsage.database.allocatedGi.toFixed(1)} GiB
80 + storageUsage.database.usedPercent > 80 ? 'bg-red-500' - : (storageUsage.database.used / storageUsage.database.allocated) * 100 > 50 - ? 'bg-yellow-500' - : 'bg-blue-500' + : storageUsage.database.usedPercent > 50 + ? 'bg-yellow-500' + : 'bg-blue-500' }`} - style={{ width: `${Math.min((storageUsage.database.used / storageUsage.database.allocated) * 100, 100)}%` }} + style={{ width: `${Math.min(storageUsage.database.usedPercent, 100)}%` }} />
- Used: {(storageUsage.database.used / (1024 * 1024 * 1024)).toFixed(2)} GB - Available: {(storageUsage.database.available / (1024 * 1024 * 1024)).toFixed(2)} GB + Used {storageUsage.database.usedGi.toFixed(2)} GiB + Free ~{storageUsage.database.availableGi.toFixed(2)} GiB
+ {app?.databaseType !== 'none' && ( +
+ {showDbDiskExpand ? ( +
+
+ + { + const val = Math.max(1, Math.min(500, parseInt(e.target.value, 10) || 1)); + setDbStorageSize(String(val)); + }} + className="w-12 text-center py-1 border-x border-gray-300 text-xs font-semibold focus:outline-none" + /> + +
+ GiB + + +
+ ) : ( + + )} +

PVC can only grow. Size from API: {storageUsage.database.allocatedRaw}

+
+ )}
)} - {/* App Storage (WordPress wp-content) */} {storageUsage.appStorage && (
- {app?.runtime === 'wordpress' ? 'wp-content Storage' : 'App Storage'} + {app?.runtime === 'wordpress' ? 'wp-content volume' : 'Application volume'} - {(storageUsage.appStorage.used / (1024 * 1024 * 1024)).toFixed(2)} GB / {(storageUsage.appStorage.allocated / (1024 * 1024 * 1024)).toFixed(1)} GB + {storageUsage.appStorage.usedGi.toFixed(2)} GiB / {storageUsage.appStorage.allocatedGi.toFixed(1)} GiB
80 + storageUsage.appStorage.usedPercent > 80 ? 'bg-red-500' - : (storageUsage.appStorage.used / storageUsage.appStorage.allocated) * 100 > 50 - ? 'bg-yellow-500' - : 'bg-green-500' + : storageUsage.appStorage.usedPercent > 50 + ? 'bg-yellow-500' + : 'bg-green-500' }`} - style={{ width: `${Math.min((storageUsage.appStorage.used / storageUsage.appStorage.allocated) * 100, 100)}%` }} + style={{ width: `${Math.min(storageUsage.appStorage.usedPercent, 100)}%` }} />
- Used: {(storageUsage.appStorage.used / (1024 * 1024 * 1024)).toFixed(2)} GB - Available: {(storageUsage.appStorage.available / (1024 * 1024 * 1024)).toFixed(2)} GB + Used {storageUsage.appStorage.usedGi.toFixed(2)} GiB + Free ~{storageUsage.appStorage.availableGi.toFixed(2)} GiB
{/* Expand App Storage (all app types) */} @@ -1905,7 +2037,43 @@ export default function AppDetailPage() {
)} - {!storageUsage.database && !storageUsage.appStorage && ( + {storageUsage.redisStorage && app?.enableRedis && ( +
+
+ Redis (optional) volume + + {storageUsage.redisStorage.usedGi.toFixed(2)} GiB / {storageUsage.redisStorage.allocatedGi.toFixed(1)} GiB + +
+
+
80 ? 'bg-red-500' : 'bg-amber-500'}`} + style={{ width: `${Math.min(storageUsage.redisStorage.usedPercent, 100)}%` }} + /> +
+

Allocated {storageUsage.redisStorage.allocatedRaw}

+
+ )} + + {storageUsage.rabbitmqStorage && app?.enableRabbitmq && ( +
+
+ RabbitMQ (optional) volume + + {storageUsage.rabbitmqStorage.usedGi.toFixed(2)} GiB / {storageUsage.rabbitmqStorage.allocatedGi.toFixed(1)} GiB + +
+
+
80 ? 'bg-red-500' : 'bg-violet-500'}`} + style={{ width: `${Math.min(storageUsage.rabbitmqStorage.usedPercent, 100)}%` }} + /> +
+

Allocated {storageUsage.rabbitmqStorage.allocatedRaw}

+
+ )} + + {!storageUsage.database && !storageUsage.appStorage && !storageUsage.redisStorage && !storageUsage.rabbitmqStorage && (

No storage data available

)}
@@ -1916,7 +2084,23 @@ export default function AppDetailPage() { {/* Scaling Controls */}
-

Scale Resources

+

Adjust CPU / memory

+

+ Pick which component to update. The main application may use billing if your plan charges for upgrades; database and optional services apply directly in the cluster. +

+
+ + +
@@ -1958,31 +2142,46 @@ export default function AppDetailPage() { placeholder="512Mi" />
-
- -
- - {resourceForm.replicas} - + {scaleWorkload === 'app' && ( +
+ +
+ + {resourceForm.replicas} + +
-
+ )}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5bb1f09..2865bc2 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -185,9 +185,33 @@ export interface PodMetric { name: string; cpu: string; memory: string; + /** When metrics-server returns multiple containers (e.g. Fluent Bit sidecar) */ + containers?: { name: string; cpu: string; memory: string }[]; +} + +export interface WorkloadResourceUsage { + key: 'app' | 'database' | 'redis' | 'rabbitmq'; + title: string; + deploymentName: string; + configured: { + cpuRequest: string; + cpuLimit: string; + memoryRequest: string; + memoryLimit: string; + replicas: number; + readyReplicas: number; + availableReplicas: number; + }; + pods: PodInfo[]; + metrics: PodMetric[]; + sidecars?: { name: string; cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string }[]; } export interface ResourceUsage { + /** Per-deployment breakdown (app, DB, optional Redis/RabbitMQ) */ + workloads?: WorkloadResourceUsage[]; + /** Note when Elasticsearch logging sidecar is enabled */ + loggingNote?: string; configured: { cpuRequest: string; cpuLimit: string;