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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<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
|
||||
/** 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<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);
|
||||
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<any | null> {
|
||||
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<any> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
private async getPvcUsageFromPod(
|
||||
app: Application,
|
||||
deploymentName: string,
|
||||
mountPath: string,
|
||||
namespace: string,
|
||||
containerName: string,
|
||||
): Promise<number> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user