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 isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||||
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
|
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)
|
// 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 = {};
|
const updateFields: any = {};
|
||||||
if (dto.cpuRequest) updateFields.cpuRequest = dto.cpuRequest;
|
if (workload === 'app') {
|
||||||
if (dto.cpuLimit) updateFields.cpuLimit = dto.cpuLimit;
|
if (dto.cpuRequest) updateFields.cpuRequest = dto.cpuRequest;
|
||||||
if (dto.memoryRequest) updateFields.memoryRequest = dto.memoryRequest;
|
if (dto.cpuLimit) updateFields.cpuLimit = dto.cpuLimit;
|
||||||
if (dto.memoryLimit) updateFields.memoryLimit = dto.memoryLimit;
|
if (dto.memoryRequest) updateFields.memoryRequest = dto.memoryRequest;
|
||||||
if (dto.replicas !== undefined) updateFields.replicas = dto.replicas;
|
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)}`);
|
this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(dto)}`);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Min,
|
Min,
|
||||||
Max,
|
Max,
|
||||||
Matches,
|
Matches,
|
||||||
|
IsIn,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { AppRuntime, DatabaseType } from '../../common/enums';
|
import { AppRuntime, DatabaseType } from '../../common/enums';
|
||||||
@@ -283,6 +284,14 @@ export class CheckDnsDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ScaleResourcesDto {
|
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' })
|
@ApiPropertyOptional({ example: '100m' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@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 { ConfigService } from '@nestjs/config';
|
||||||
import * as k8s from '@kubernetes/client-node';
|
import * as k8s from '@kubernetes/client-node';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@@ -42,6 +42,17 @@ interface ManifestContext {
|
|||||||
logPaths: string[];
|
logPaths: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StorageUsageSlice = {
|
||||||
|
allocatedRaw: string;
|
||||||
|
allocatedGi: number;
|
||||||
|
usedGi: number;
|
||||||
|
availableGi: number;
|
||||||
|
usedPercent: number;
|
||||||
|
allocated: string;
|
||||||
|
used: string;
|
||||||
|
available: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KubernetesService implements OnModuleInit {
|
export class KubernetesService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(KubernetesService.name);
|
private readonly logger = new Logger(KubernetesService.name);
|
||||||
@@ -1452,67 +1463,48 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Map optional workload keys to Deployment + primary container name */
|
||||||
* Get real-time resource usage (CPU/Memory) for an app's pods via metrics-server.
|
workloadDeploymentTarget(
|
||||||
* Also returns the configured requests/limits and pod status.
|
app: Application,
|
||||||
*/
|
workload: 'app' | 'database' | 'redis' | 'rabbitmq',
|
||||||
async getResourceUsage(app: Application): Promise<any> {
|
): { deploymentName: string; containerName: string } | null {
|
||||||
const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId);
|
switch (workload) {
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
case 'app':
|
||||||
|
return { deploymentName: app.name, containerName: app.name };
|
||||||
// Get deployment info for configured resources
|
case 'database':
|
||||||
let deployment: k8s.V1Deployment | null = null;
|
if (!app.databaseType || app.databaseType === DatabaseType.NONE) return null;
|
||||||
try {
|
return { deploymentName: `${app.name}-db`, containerName: `${app.name}-db` };
|
||||||
const depResponse = await appsApi.readNamespacedDeployment(app.name, namespace);
|
case 'redis':
|
||||||
deployment = depResponse.body;
|
if (!app.enableRedis) return null;
|
||||||
} catch {
|
return { deploymentName: `${app.name}-redis`, containerName: 'redis' };
|
||||||
// Deployment may not exist yet
|
case 'rabbitmq':
|
||||||
|
if (!app.enableRabbitmq) return null;
|
||||||
|
return { deploymentName: `${app.name}-rabbitmq`, containerName: 'rabbitmq' };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get pods
|
private async fetchNamespacePodMetrics(kc: k8s.KubeConfig, namespace: string, appLabelValue: string): Promise<any[]> {
|
||||||
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 {
|
try {
|
||||||
const metricsClient = new k8s.CustomObjectsApi(kc.getCurrentCluster()?.server);
|
|
||||||
// Use the kc to make a raw request to metrics API
|
|
||||||
const opts: any = {};
|
const opts: any = {};
|
||||||
await kc.applyToRequest(opts);
|
await kc.applyToRequest(opts);
|
||||||
|
|
||||||
const metricsUrl = `${kc.getCurrentCluster()?.server}/apis/metrics.k8s.io/v1beta1/namespaces/${namespace}/pods`;
|
const metricsUrl = `${kc.getCurrentCluster()?.server}/apis/metrics.k8s.io/v1beta1/namespaces/${namespace}/pods`;
|
||||||
|
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const url = new URL(metricsUrl);
|
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 client = url.protocol === 'https:' ? https : http;
|
||||||
const reqOpts: any = {
|
const reqOpts: any = {
|
||||||
hostname: url.hostname,
|
hostname: url.hostname,
|
||||||
port: url.port,
|
port: url.port,
|
||||||
path: url.pathname + `?labelSelector=app%3D${app.name}`,
|
path: `${url.pathname}?${labelQ}`,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: opts.headers || {},
|
headers: opts.headers || {},
|
||||||
rejectUnauthorized: false,
|
rejectUnauthorized: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply TLS from kubeconfig
|
|
||||||
if (opts.ca) reqOpts.ca = opts.ca;
|
if (opts.ca) reqOpts.ca = opts.ca;
|
||||||
if (opts.cert) reqOpts.cert = opts.cert;
|
if (opts.cert) reqOpts.cert = opts.cert;
|
||||||
if (opts.key) reqOpts.key = opts.key;
|
if (opts.key) reqOpts.key = opts.key;
|
||||||
@@ -1525,6 +1517,11 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
const parsed = JSON.parse(data);
|
const parsed = JSON.parse(data);
|
||||||
const items = (parsed.items || []).map((item: any) => ({
|
const items = (parsed.items || []).map((item: any) => ({
|
||||||
name: item.metadata?.name,
|
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',
|
cpu: item.containers?.[0]?.usage?.cpu || '0',
|
||||||
memory: item.containers?.[0]?.usage?.memory || '0',
|
memory: item.containers?.[0]?.usage?.memory || '0',
|
||||||
}));
|
}));
|
||||||
@@ -1538,41 +1535,208 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
} catch {
|
} 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 podsResponse = await coreApi.listNamespacedPod(
|
||||||
const container = deployment?.spec?.template?.spec?.containers?.[0];
|
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 = {
|
const configured = {
|
||||||
cpuRequest: container?.resources?.requests?.cpu || app.cpuRequest,
|
cpuRequest: container?.resources?.requests?.cpu || opts.fallbackCpuRequest,
|
||||||
cpuLimit: container?.resources?.limits?.cpu || app.cpuLimit,
|
cpuLimit: container?.resources?.limits?.cpu || opts.fallbackCpuLimit,
|
||||||
memoryRequest: container?.resources?.requests?.memory || app.memoryRequest,
|
memoryRequest: container?.resources?.requests?.memory || opts.fallbackMemoryRequest,
|
||||||
memoryLimit: container?.resources?.limits?.memory || app.memoryLimit,
|
memoryLimit: container?.resources?.limits?.memory || opts.fallbackMemoryLimit,
|
||||||
replicas: deployment?.spec?.replicas ?? app.replicas,
|
replicas: deployment.spec?.replicas ?? opts.fallbackReplicas ?? 1,
|
||||||
readyReplicas: deployment?.status?.readyReplicas || 0,
|
readyReplicas: deployment.status?.readyReplicas || 0,
|
||||||
availableReplicas: deployment?.status?.availableReplicas || 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 {
|
return {
|
||||||
|
key: opts.key,
|
||||||
|
title: opts.title,
|
||||||
|
deploymentName: opts.deploymentName,
|
||||||
configured,
|
configured,
|
||||||
pods,
|
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.
|
* 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(
|
async updateResources(
|
||||||
app: Application,
|
app: Application,
|
||||||
resources: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number },
|
resources: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number },
|
||||||
|
workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
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: {} };
|
const patch: any = { spec: {} };
|
||||||
|
|
||||||
if (resources.replicas !== undefined) {
|
if (resources.replicas !== undefined && workload === 'app') {
|
||||||
patch.spec.replicas = resources.replicas;
|
patch.spec.replicas = resources.replicas;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1581,7 +1745,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
spec: {
|
spec: {
|
||||||
containers: [
|
containers: [
|
||||||
{
|
{
|
||||||
name: app.name,
|
name: target.containerName,
|
||||||
resources: {
|
resources: {
|
||||||
requests: {
|
requests: {
|
||||||
...(resources.cpuRequest && { cpu: resources.cpuRequest }),
|
...(resources.cpuRequest && { cpu: resources.cpuRequest }),
|
||||||
@@ -1599,7 +1763,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await appsApi.patchNamespacedDeployment(
|
await appsApi.patchNamespacedDeployment(
|
||||||
app.name,
|
target.deploymentName,
|
||||||
namespace,
|
namespace,
|
||||||
patch,
|
patch,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -1610,7 +1774,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
|
{ 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.
|
* 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<{
|
async getStorageUsage(app: Application): Promise<{
|
||||||
database: { allocated: string; used: string; available: string; usedPercent: number } | null;
|
database: StorageUsageSlice | null;
|
||||||
appStorage: { allocated: string; used: string; available: string; usedPercent: number } | null;
|
appStorage: StorageUsageSlice | null;
|
||||||
|
redisStorage: StorageUsageSlice | null;
|
||||||
|
rabbitmqStorage: StorageUsageSlice | null;
|
||||||
totalAllocatedGb: number;
|
totalAllocatedGb: number;
|
||||||
totalUsedGb: number;
|
totalUsedGb: number;
|
||||||
}> {
|
}> {
|
||||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
const result: {
|
const result = {
|
||||||
database: { allocated: string; used: string; available: string; usedPercent: number } | null;
|
database: null as StorageUsageSlice | null,
|
||||||
appStorage: { allocated: string; used: string; available: string; usedPercent: number } | null;
|
appStorage: null as StorageUsageSlice | null,
|
||||||
totalAllocatedGb: number;
|
redisStorage: null as StorageUsageSlice | null,
|
||||||
totalUsedGb: number;
|
rabbitmqStorage: null as StorageUsageSlice | null,
|
||||||
} = {
|
|
||||||
database: null,
|
|
||||||
appStorage: null,
|
|
||||||
totalAllocatedGb: 0,
|
totalAllocatedGb: 0,
|
||||||
totalUsedGb: 0,
|
totalUsedGb: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to parse size strings to GB
|
|
||||||
const parseToGb = (size: string): number => {
|
const parseToGb = (size: string): number => {
|
||||||
if (!size) return 0;
|
if (!size) return 0;
|
||||||
const match = size.match(/^(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)?$/i);
|
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 => {
|
const formatSize = (gb: number): string => {
|
||||||
if (gb >= 1) return `${gb.toFixed(1)}Gi`;
|
if (gb >= 1) return `${gb.toFixed(1)}Gi`;
|
||||||
const mb = gb * 1024;
|
const mb = gb * 1024;
|
||||||
@@ -2100,7 +2261,21 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
return `${(mb * 1024).toFixed(0)}Ki`;
|
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) {
|
if (app.databaseType && app.databaseType !== DatabaseType.NONE) {
|
||||||
try {
|
try {
|
||||||
const pvcName = `${app.name}-db`;
|
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 allocatedStr = pvc.body.spec?.resources?.requests?.storage || app.dbStorageSize || '1Gi';
|
||||||
const allocatedGb = parseToGb(allocatedStr);
|
const allocatedGb = parseToGb(allocatedStr);
|
||||||
|
|
||||||
// Try to get actual usage from pod exec (du command)
|
|
||||||
let usedGb = 0;
|
let usedGb = 0;
|
||||||
try {
|
try {
|
||||||
usedGb = await this.getPvcUsageFromPod(app, `${app.name}-db`, '/var/lib/postgresql/data', namespace);
|
const dbDep = `${app.name}-db`;
|
||||||
if (usedGb === 0 && app.databaseType === DatabaseType.MYSQL) {
|
const dbContainer = `${app.name}-db`;
|
||||||
usedGb = await this.getPvcUsageFromPod(app, `${app.name}-db`, '/var/lib/mysql', namespace);
|
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 {
|
} catch {
|
||||||
// Estimate ~10% usage if we can't get actual
|
|
||||||
usedGb = allocatedGb * 0.1;
|
usedGb = allocatedGb * 0.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableGb = Math.max(0, allocatedGb - usedGb);
|
result.database = makeSlice(allocatedStr, allocatedGb, usedGb);
|
||||||
const usedPercent = allocatedGb > 0 ? Math.round((usedGb / allocatedGb) * 100) : 0;
|
|
||||||
|
|
||||||
result.database = {
|
|
||||||
allocated: allocatedStr,
|
|
||||||
used: formatSize(usedGb),
|
|
||||||
available: formatSize(availableGb),
|
|
||||||
usedPercent,
|
|
||||||
};
|
|
||||||
result.totalAllocatedGb += allocatedGb;
|
result.totalAllocatedGb += allocatedGb;
|
||||||
result.totalUsedGb += usedGb;
|
result.totalUsedGb += usedGb;
|
||||||
} catch (e: any) {
|
} 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 {
|
||||||
// Try new unified name first, then legacy wp-content name for backward compatibility
|
|
||||||
let pvc;
|
let pvc;
|
||||||
let pvcName = `${app.name}-storage`;
|
let pvcName = `${app.name}-storage`;
|
||||||
try {
|
try {
|
||||||
pvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace);
|
pvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace);
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to legacy WordPress PVC name
|
|
||||||
pvcName = `${app.name}-wp-content`;
|
pvcName = `${app.name}-wp-content`;
|
||||||
pvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace);
|
pvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allocatedStr = pvc.body.spec?.resources?.requests?.storage || app.appStorageSize || '2Gi';
|
const allocatedStr = pvc.body.spec?.resources?.requests?.storage || app.appStorageSize || '2Gi';
|
||||||
const allocatedGb = parseToGb(allocatedStr);
|
const allocatedGb = parseToGb(allocatedStr);
|
||||||
|
|
||||||
// Determine mount path based on runtime
|
|
||||||
const mountPath = this.getStorageMountPath(app.runtime);
|
const mountPath = this.getStorageMountPath(app.runtime);
|
||||||
|
|
||||||
// Try to get actual usage from pod exec
|
|
||||||
let usedGb = 0;
|
let usedGb = 0;
|
||||||
try {
|
try {
|
||||||
usedGb = await this.getPvcUsageFromPod(app, app.name, mountPath, namespace);
|
usedGb = await this.getPvcUsageFromPod(app, app.name, mountPath, namespace, app.name);
|
||||||
} catch {
|
} catch {
|
||||||
usedGb = allocatedGb * 0.1;
|
usedGb = allocatedGb * 0.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableGb = Math.max(0, allocatedGb - usedGb);
|
result.appStorage = makeSlice(allocatedStr, allocatedGb, usedGb);
|
||||||
const usedPercent = allocatedGb > 0 ? Math.round((usedGb / allocatedGb) * 100) : 0;
|
|
||||||
|
|
||||||
result.appStorage = {
|
|
||||||
allocated: allocatedStr,
|
|
||||||
used: formatSize(usedGb),
|
|
||||||
available: formatSize(availableGb),
|
|
||||||
usedPercent,
|
|
||||||
};
|
|
||||||
result.totalAllocatedGb += allocatedGb;
|
result.totalAllocatedGb += allocatedGb;
|
||||||
result.totalUsedGb += usedGb;
|
result.totalUsedGb += usedGb;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.logger.warn(`Failed to get app storage usage for ${app.name}: ${e.message}`);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2185,10 +2381,15 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
* Get PVC usage by executing du command in a pod.
|
* Get PVC usage by executing du command in a pod.
|
||||||
* Returns usage in GB.
|
* 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);
|
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 pods = await coreApi.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `app=${deploymentName}`);
|
||||||
const runningPod = pods.body.items.find(p => p.status?.phase === 'Running');
|
const runningPod = pods.body.items.find(p => p.status?.phase === 'Running');
|
||||||
|
|
||||||
@@ -2206,7 +2407,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
exec.exec(
|
exec.exec(
|
||||||
namespace,
|
namespace,
|
||||||
runningPod.metadata!.name!,
|
runningPod.metadata!.name!,
|
||||||
deploymentName === `${app.name}-db` ? 'db' : app.name,
|
containerName,
|
||||||
['du', '-sb', mountPath],
|
['du', '-sb', mountPath],
|
||||||
stdout,
|
stdout,
|
||||||
null,
|
null,
|
||||||
@@ -2221,7 +2422,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
|
|
||||||
const output = Buffer.concat(chunks).toString().trim();
|
const output = Buffer.concat(chunks).toString().trim();
|
||||||
const bytes = parseInt(output.split(/\s+/)[0], 10) || 0;
|
const bytes = parseInt(output.split(/\s+/)[0], 10) || 0;
|
||||||
return bytes / (1024 * 1024 * 1024); // Convert to GB
|
return bytes / (1024 * 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export default function AppDetailPage() {
|
|||||||
memoryLimit: '',
|
memoryLimit: '',
|
||||||
replicas: 1,
|
replicas: 1,
|
||||||
});
|
});
|
||||||
|
const [scaleWorkload, setScaleWorkload] = useState<'app' | 'database' | 'redis' | 'rabbitmq'>('app');
|
||||||
|
const [showDbDiskExpand, setShowDbDiskExpand] = useState(false);
|
||||||
const [dbStorageSize, setDbStorageSize] = useState('1');
|
const [dbStorageSize, setDbStorageSize] = useState('1');
|
||||||
const [dbStorageLoading, setDbStorageLoading] = useState(false);
|
const [dbStorageLoading, setDbStorageLoading] = useState(false);
|
||||||
const [showSnapshots, setShowSnapshots] = useState(false);
|
const [showSnapshots, setShowSnapshots] = useState(false);
|
||||||
@@ -93,6 +95,13 @@ export default function AppDetailPage() {
|
|||||||
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
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<Deployment[]>({
|
const { data: deployments = [] } = useQuery<Deployment[]>({
|
||||||
queryKey: ['deployments', appId],
|
queryKey: ['deployments', appId],
|
||||||
queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data),
|
queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data),
|
||||||
@@ -146,9 +155,21 @@ export default function AppDetailPage() {
|
|||||||
}, [dbStorageData]);
|
}, [dbStorageData]);
|
||||||
|
|
||||||
// Fetch comprehensive storage usage (allocated/used/available)
|
// Fetch comprehensive storage usage (allocated/used/available)
|
||||||
|
interface StorageUsageSlice {
|
||||||
|
allocatedRaw: string;
|
||||||
|
allocatedGi: number;
|
||||||
|
usedGi: number;
|
||||||
|
availableGi: number;
|
||||||
|
usedPercent: number;
|
||||||
|
}
|
||||||
interface StorageUsageData {
|
interface StorageUsageData {
|
||||||
database: { allocated: number; used: number; available: number } | null;
|
database: StorageUsageSlice | null;
|
||||||
appStorage: { allocated: number; used: number; available: number } | 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<StorageUsageData>({
|
const { data: storageUsage, isLoading: storageUsageLoading } = useQuery<StorageUsageData>({
|
||||||
queryKey: ['storage-usage', appId],
|
queryKey: ['storage-usage', appId],
|
||||||
@@ -191,6 +212,7 @@ export default function AppDetailPage() {
|
|||||||
if (res.data.success) {
|
if (res.data.success) {
|
||||||
toast.success(res.data.message || 'Database storage expanded!');
|
toast.success(res.data.message || 'Database storage expanded!');
|
||||||
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
|
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.data.message || 'Failed to expand storage');
|
toast.error(res.data.message || 'Failed to expand storage');
|
||||||
}
|
}
|
||||||
@@ -445,18 +467,27 @@ export default function AppDetailPage() {
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync form when resource data loads
|
// Sync form when resource data loads (selected workload)
|
||||||
useEffect(() => {
|
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({
|
setResourceForm({
|
||||||
cpuRequest: resourceUsage.configured.cpuRequest,
|
cpuRequest: w.configured.cpuRequest || '',
|
||||||
cpuLimit: resourceUsage.configured.cpuLimit,
|
cpuLimit: w.configured.cpuLimit || '',
|
||||||
memoryRequest: resourceUsage.configured.memoryRequest,
|
memoryRequest: w.configured.memoryRequest || '',
|
||||||
memoryLimit: resourceUsage.configured.memoryLimit,
|
memoryLimit: w.configured.memoryLimit || '',
|
||||||
replicas: resourceUsage.configured.replicas,
|
replicas: w.configured.replicas ?? 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [resourceUsage?.configured]);
|
}, [resourceUsage, scaleWorkload]);
|
||||||
|
|
||||||
// Auto-scroll logs to bottom
|
// Auto-scroll logs to bottom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -544,6 +575,23 @@ export default function AppDetailPage() {
|
|||||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to update resources'),
|
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
|
// Calculate upgrade cost before applying
|
||||||
const calculateUpgradeCostMutation = useMutation({
|
const calculateUpgradeCostMutation = useMutation({
|
||||||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
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'),
|
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 = () => {
|
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) {
|
if (!app?.billingCycle) {
|
||||||
scaleMutation.mutate(resourceForm);
|
scaleMutation.mutate(resourceForm);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Otherwise, calculate cost first
|
|
||||||
calculateUpgradeCostMutation.mutate(resourceForm);
|
calculateUpgradeCostMutation.mutate(resourceForm);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1637,133 +1693,146 @@ export default function AppDetailPage() {
|
|||||||
<div className="text-center py-6 text-gray-400 text-sm">Loading metrics...</div>
|
<div className="text-center py-6 text-gray-400 text-sm">Loading metrics...</div>
|
||||||
) : resourceUsage ? (
|
) : resourceUsage ? (
|
||||||
<>
|
<>
|
||||||
{/* Cluster Status */}
|
{resourceUsage.loggingNote && (
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<p className="text-xs text-gray-600 bg-slate-50 border border-slate-100 rounded-lg px-3 py-2">{resourceUsage.loggingNote}</p>
|
||||||
<div className="bg-blue-50 rounded-xl p-4 text-center">
|
|
||||||
<p className="text-xs text-blue-500 font-medium">Replicas</p>
|
|
||||||
<p className="text-2xl font-bold text-blue-700">
|
|
||||||
{resourceUsage.configured.readyReplicas}/{resourceUsage.configured.replicas}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-blue-400">ready</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-50 rounded-xl p-4 text-center">
|
|
||||||
<p className="text-xs text-green-500 font-medium">Pods</p>
|
|
||||||
<p className="text-2xl font-bold text-green-700">{resourceUsage.pods.length}</p>
|
|
||||||
<p className="text-xs text-green-400">
|
|
||||||
{resourceUsage.pods.filter((p) => p.ready).length} ready
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-50 rounded-xl p-4 text-center">
|
|
||||||
<p className="text-xs text-purple-500 font-medium">Metrics</p>
|
|
||||||
<p className="text-2xl font-bold text-purple-700">
|
|
||||||
{resourceUsage.metrics.length > 0 ? <CheckCircle className="w-6 h-6 mx-auto text-purple-700" /> : <Clock className="w-6 h-6 mx-auto text-purple-400" />}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-purple-400">
|
|
||||||
{resourceUsage.metrics.length > 0 ? 'Available' : 'Waiting...'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Per-Pod Metrics */}
|
|
||||||
{resourceUsage.metrics.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700">Pod Usage</h3>
|
|
||||||
{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 (
|
|
||||||
<div key={metric.name} className="bg-gray-50 rounded-xl p-4 space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-xs font-mono text-gray-600 truncate max-w-[250px]" title={metric.name}>
|
|
||||||
<Circle className="w-2 h-2 inline fill-green-500 text-green-500" /> {metric.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CPU Bar */}
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
|
||||||
<span>CPU</span>
|
|
||||||
<span>
|
|
||||||
{cpuUsed.toFixed(1)}m / {cpuLimit.toFixed(0)}m ({cpuPercent.toFixed(1)}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
|
||||||
<div
|
|
||||||
className={`h-2.5 rounded-full transition-all duration-500 ${
|
|
||||||
cpuPercent > 80 ? 'bg-red-500' : cpuPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${cpuPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Memory Bar */}
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
|
||||||
<span>Memory</span>
|
|
||||||
<span>
|
|
||||||
{memUsed.toFixed(1)}Mi / {memLimit.toFixed(0)}Mi ({memPercent.toFixed(1)}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
|
||||||
<div
|
|
||||||
className={`h-2.5 rounded-full transition-all duration-500 ${
|
|
||||||
memPercent > 80 ? 'bg-red-500' : memPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${memPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pod Status Table */}
|
{(resourceUsage.workloads && resourceUsage.workloads.length > 0
|
||||||
{resourceUsage.pods.length > 0 && (
|
? resourceUsage.workloads
|
||||||
<div>
|
: resourceUsage.configured
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-2">Pod Status</h3>
|
? [
|
||||||
<div className="overflow-x-auto -mx-2 px-2">
|
{
|
||||||
<table className="w-full text-xs">
|
key: 'app' as const,
|
||||||
<thead>
|
title: 'Application',
|
||||||
<tr className="text-left text-gray-500 border-b border-gray-200">
|
deploymentName: app?.name || '',
|
||||||
<th className="pb-2 font-medium">Pod</th>
|
configured: resourceUsage.configured,
|
||||||
<th className="pb-2 font-medium">Status</th>
|
pods: resourceUsage.pods,
|
||||||
<th className="pb-2 font-medium">Ready</th>
|
metrics: resourceUsage.metrics,
|
||||||
<th className="pb-2 font-medium">Restarts</th>
|
sidecars: undefined,
|
||||||
</tr>
|
},
|
||||||
</thead>
|
]
|
||||||
<tbody className="divide-y divide-gray-100">
|
: []
|
||||||
{resourceUsage.pods.map((pod) => (
|
).map((w) => (
|
||||||
<tr key={pod.name} className="text-gray-700">
|
<div key={w.key} className="border border-gray-200 rounded-xl p-4 space-y-4 bg-slate-50/50">
|
||||||
<td className="py-2 font-mono truncate max-w-[200px]" title={pod.name}>
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
{pod.name}
|
<h3 className="text-sm font-semibold text-gray-800">{w.title}</h3>
|
||||||
</td>
|
<span className="text-[11px] text-gray-400 font-mono truncate max-w-[200px]" title={w.deploymentName}>{w.deploymentName}</span>
|
||||||
<td className="py-2">
|
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
pod.status === 'Running' ? 'bg-green-100 text-green-700' :
|
|
||||||
pod.status === 'Pending' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
'bg-red-100 text-red-700'
|
|
||||||
}`}>
|
|
||||||
{pod.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2">{pod.ready ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Clock className="w-4 h-4 text-yellow-500" />}</td>
|
|
||||||
<td className="py-2">{pod.restarts}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3">
|
||||||
|
<p className="text-[10px] text-blue-600 font-medium">Replicas</p>
|
||||||
|
<p className="text-lg font-bold text-blue-800">
|
||||||
|
{w.configured.readyReplicas}/{w.configured.replicas}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-blue-500">ready</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 rounded-lg p-3">
|
||||||
|
<p className="text-[10px] text-green-600 font-medium">Pods</p>
|
||||||
|
<p className="text-lg font-bold text-green-800">{w.pods.length}</p>
|
||||||
|
<p className="text-[10px] text-green-500">{w.pods.filter((p) => p.ready).length} ready</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50 rounded-lg p-3">
|
||||||
|
<p className="text-[10px] text-purple-600 font-medium">Metrics</p>
|
||||||
|
<p className="text-lg font-bold text-purple-800 flex justify-center">
|
||||||
|
{w.metrics.length > 0 ? <CheckCircle className="w-5 h-5 text-purple-700" /> : <Clock className="w-5 h-5 text-purple-400" />}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-purple-500">{w.metrics.length > 0 ? 'Live' : 'Waiting…'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-600 grid sm:grid-cols-2 gap-2 border-t border-gray-200 pt-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">CPU: </span>
|
||||||
|
<span className="font-mono">{w.configured.cpuRequest} → {w.configured.cpuLimit}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Memory: </span>
|
||||||
|
<span className="font-mono">{w.configured.memoryRequest} → {w.configured.memoryLimit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{w.metrics.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-semibold text-gray-600">Live usage</h4>
|
||||||
|
{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 (
|
||||||
|
<div key={metric.name} className="bg-white rounded-lg p-3 space-y-2 border border-gray-100">
|
||||||
|
<p className="text-[11px] font-mono text-gray-600 truncate" title={metric.name}>
|
||||||
|
<Circle className="w-2 h-2 inline fill-green-500 text-green-500" /> {metric.name}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-[11px] text-gray-500 mb-0.5">
|
||||||
|
<span>CPU</span>
|
||||||
|
<span>{cpuUsed.toFixed(1)}m / {cpuLimit.toFixed(0)}m ({cpuPercent.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div className={`h-2 rounded-full ${cpuPercent > 80 ? 'bg-red-500' : cpuPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${cpuPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-[11px] text-gray-500 mb-0.5">
|
||||||
|
<span>Memory</span>
|
||||||
|
<span>{memUsed.toFixed(1)}Mi / {memLimit.toFixed(0)}Mi ({memPercent.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div className={`h-2 rounded-full ${memPercent > 80 ? 'bg-red-500' : memPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${memPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{w.pods.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-gray-600 mb-1">Pods</h4>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 border-b border-gray-200">
|
||||||
|
<th className="pb-1 font-medium">Name</th>
|
||||||
|
<th className="pb-1 font-medium">Status</th>
|
||||||
|
<th className="pb-1 font-medium">Ready</th>
|
||||||
|
<th className="pb-1 font-medium">R</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{w.pods.map((pod) => (
|
||||||
|
<tr key={pod.name} className="text-gray-700">
|
||||||
|
<td className="py-1 font-mono truncate max-w-[140px]" title={pod.name}>{pod.name}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${pod.status === 'Running' ? 'bg-green-100 text-green-700' : pod.status === 'Pending' ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'}`}>{pod.status}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-1">{pod.ready ? <CheckCircle className="w-3.5 h-3.5 text-green-500" /> : <Clock className="w-3.5 h-3.5 text-yellow-500" />}</td>
|
||||||
|
<td className="py-1">{pod.restarts}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{w.sidecars && w.sidecars.length > 0 && (
|
||||||
|
<div className="text-[11px] text-gray-600 border-t border-dashed border-gray-200 pt-2">
|
||||||
|
<span className="font-semibold text-gray-700">Sidecars: </span>
|
||||||
|
{w.sidecars.map((s) => (
|
||||||
|
<span key={s.name} className="mr-3">
|
||||||
|
{s.name} <span className="text-gray-400">(CPU {s.cpuLimit || '—'}, mem {s.memoryLimit || '—'})</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
{/* Storage Usage Section */}
|
{/* Storage Usage Section */}
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
@@ -1778,56 +1847,119 @@ export default function AppDetailPage() {
|
|||||||
{storageUsage.database && (
|
{storageUsage.database && (
|
||||||
<div className="bg-gray-50 rounded-xl p-4">
|
<div className="bg-gray-50 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-medium text-gray-600">Database Storage</span>
|
<span className="text-xs font-medium text-gray-600">Database volume</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
{(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
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
<div
|
<div
|
||||||
className={`h-3 rounded-full transition-all duration-500 ${
|
className={`h-3 rounded-full transition-all duration-500 ${
|
||||||
(storageUsage.database.used / storageUsage.database.allocated) * 100 > 80
|
storageUsage.database.usedPercent > 80
|
||||||
? 'bg-red-500'
|
? 'bg-red-500'
|
||||||
: (storageUsage.database.used / storageUsage.database.allocated) * 100 > 50
|
: storageUsage.database.usedPercent > 50
|
||||||
? 'bg-yellow-500'
|
? 'bg-yellow-500'
|
||||||
: 'bg-blue-500'
|
: 'bg-blue-500'
|
||||||
}`}
|
}`}
|
||||||
style={{ width: `${Math.min((storageUsage.database.used / storageUsage.database.allocated) * 100, 100)}%` }}
|
style={{ width: `${Math.min(storageUsage.database.usedPercent, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||||
<span>Used: {(storageUsage.database.used / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
<span>Used {storageUsage.database.usedGi.toFixed(2)} GiB</span>
|
||||||
<span>Available: {(storageUsage.database.available / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
<span>Free ~{storageUsage.database.availableGi.toFixed(2)} GiB</span>
|
||||||
</div>
|
</div>
|
||||||
|
{app?.databaseType !== 'none' && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||||
|
{showDbDiskExpand ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const current = parseInt(dbStorageSize, 10);
|
||||||
|
const min = parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1;
|
||||||
|
if (current > min + 1) setDbStorageSize(String(current - 1));
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
value={dbStorageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const current = parseInt(dbStorageSize, 10);
|
||||||
|
if (current < 500) setDbStorageSize(String(current + 1));
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600">GiB</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => resizeDbMutation.mutate(`${parseInt(dbStorageSize, 10)}Gi`)}
|
||||||
|
disabled={
|
||||||
|
resizeDbMutation.isPending ||
|
||||||
|
parseInt(dbStorageSize, 10) <= (parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1)
|
||||||
|
}
|
||||||
|
className="btn-primary text-xs px-2 py-1 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{resizeDbMutation.isPending ? 'Expanding…' : 'Expand DB disk'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setShowDbDiskExpand(false)} className="btn-secondary text-xs px-2 py-1">Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDbDiskExpand(true)}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Scale className="w-3 h-3" /> Expand database disk
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-gray-400 mt-1">PVC can only grow. Size from API: {storageUsage.database.allocatedRaw}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* App Storage (WordPress wp-content) */}
|
|
||||||
{storageUsage.appStorage && (
|
{storageUsage.appStorage && (
|
||||||
<div className="bg-gray-50 rounded-xl p-4">
|
<div className="bg-gray-50 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-medium text-gray-600">
|
<span className="text-xs font-medium text-gray-600">
|
||||||
{app?.runtime === 'wordpress' ? 'wp-content Storage' : 'App Storage'}
|
{app?.runtime === 'wordpress' ? 'wp-content volume' : 'Application volume'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
{(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
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
<div
|
<div
|
||||||
className={`h-3 rounded-full transition-all duration-500 ${
|
className={`h-3 rounded-full transition-all duration-500 ${
|
||||||
(storageUsage.appStorage.used / storageUsage.appStorage.allocated) * 100 > 80
|
storageUsage.appStorage.usedPercent > 80
|
||||||
? 'bg-red-500'
|
? 'bg-red-500'
|
||||||
: (storageUsage.appStorage.used / storageUsage.appStorage.allocated) * 100 > 50
|
: storageUsage.appStorage.usedPercent > 50
|
||||||
? 'bg-yellow-500'
|
? 'bg-yellow-500'
|
||||||
: 'bg-green-500'
|
: 'bg-green-500'
|
||||||
}`}
|
}`}
|
||||||
style={{ width: `${Math.min((storageUsage.appStorage.used / storageUsage.appStorage.allocated) * 100, 100)}%` }}
|
style={{ width: `${Math.min(storageUsage.appStorage.usedPercent, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||||
<span>Used: {(storageUsage.appStorage.used / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
<span>Used {storageUsage.appStorage.usedGi.toFixed(2)} GiB</span>
|
||||||
<span>Available: {(storageUsage.appStorage.available / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
<span>Free ~{storageUsage.appStorage.availableGi.toFixed(2)} GiB</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expand App Storage (all app types) */}
|
{/* Expand App Storage (all app types) */}
|
||||||
@@ -1905,7 +2037,43 @@ export default function AppDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!storageUsage.database && !storageUsage.appStorage && (
|
{storageUsage.redisStorage && app?.enableRedis && (
|
||||||
|
<div className="bg-amber-50/80 rounded-xl p-4 border border-amber-100">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-medium text-gray-700">Redis (optional) volume</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{storageUsage.redisStorage.usedGi.toFixed(2)} GiB / {storageUsage.redisStorage.allocatedGi.toFixed(1)} GiB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||||
|
<div
|
||||||
|
className={`h-2.5 rounded-full ${storageUsage.redisStorage.usedPercent > 80 ? 'bg-red-500' : 'bg-amber-500'}`}
|
||||||
|
style={{ width: `${Math.min(storageUsage.redisStorage.usedPercent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-gray-500 mt-1">Allocated {storageUsage.redisStorage.allocatedRaw}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{storageUsage.rabbitmqStorage && app?.enableRabbitmq && (
|
||||||
|
<div className="bg-violet-50/80 rounded-xl p-4 border border-violet-100">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-medium text-gray-700">RabbitMQ (optional) volume</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{storageUsage.rabbitmqStorage.usedGi.toFixed(2)} GiB / {storageUsage.rabbitmqStorage.allocatedGi.toFixed(1)} GiB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||||
|
<div
|
||||||
|
className={`h-2.5 rounded-full ${storageUsage.rabbitmqStorage.usedPercent > 80 ? 'bg-red-500' : 'bg-violet-500'}`}
|
||||||
|
style={{ width: `${Math.min(storageUsage.rabbitmqStorage.usedPercent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-gray-500 mt-1">Allocated {storageUsage.rabbitmqStorage.allocatedRaw}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!storageUsage.database && !storageUsage.appStorage && !storageUsage.redisStorage && !storageUsage.rabbitmqStorage && (
|
||||||
<p className="text-sm text-gray-400 text-center py-4">No storage data available</p>
|
<p className="text-sm text-gray-400 text-center py-4">No storage data available</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1916,7 +2084,23 @@ export default function AppDetailPage() {
|
|||||||
|
|
||||||
{/* Scaling Controls */}
|
{/* Scaling Controls */}
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1"><Settings className="w-4 h-4" /> Scale Resources</h3>
|
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1"><Settings className="w-4 h-4" /> Adjust CPU / memory</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Workload</label>
|
||||||
|
<select
|
||||||
|
value={scaleWorkload}
|
||||||
|
onChange={(e) => setScaleWorkload(e.target.value as typeof scaleWorkload)}
|
||||||
|
className="input-field text-sm max-w-md"
|
||||||
|
>
|
||||||
|
<option value="app">Application</option>
|
||||||
|
{app?.databaseType !== 'none' && <option value="database">Database</option>}
|
||||||
|
{app?.enableRedis && <option value="redis">Redis</option>}
|
||||||
|
{app?.enableRabbitmq && <option value="rabbitmq">RabbitMQ</option>}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">CPU Request</label>
|
<label className="block text-xs text-gray-500 mb-1">CPU Request</label>
|
||||||
@@ -1958,31 +2142,46 @@ export default function AppDetailPage() {
|
|||||||
placeholder="512Mi"
|
placeholder="512Mi"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{scaleWorkload === 'app' && (
|
||||||
<label className="block text-xs text-gray-500 mb-1">Replicas</label>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<label className="block text-xs text-gray-500 mb-1">Replicas</label>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => setResourceForm((f) => ({ ...f, replicas: Math.max(1, f.replicas - 1) }))}
|
<button
|
||||||
className="btn-icon w-9 h-9"
|
type="button"
|
||||||
>
|
onClick={() => setResourceForm((f) => ({ ...f, replicas: Math.max(1, f.replicas - 1) }))}
|
||||||
−
|
className="btn-icon w-9 h-9"
|
||||||
</button>
|
>
|
||||||
<span className="text-lg font-bold text-gray-800 w-8 text-center">{resourceForm.replicas}</span>
|
−
|
||||||
<button
|
</button>
|
||||||
onClick={() => setResourceForm((f) => ({ ...f, replicas: Math.min(10, f.replicas + 1) }))}
|
<span className="text-lg font-bold text-gray-800 w-8 text-center">{resourceForm.replicas}</span>
|
||||||
className="btn-icon w-9 h-9"
|
<button
|
||||||
>
|
type="button"
|
||||||
+
|
onClick={() => setResourceForm((f) => ({ ...f, replicas: Math.min(10, f.replicas + 1) }))}
|
||||||
</button>
|
className="btn-icon w-9 h-9"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleScaleResources}
|
onClick={handleScaleResources}
|
||||||
disabled={scaleMutation.isPending || calculateUpgradeCostMutation.isPending}
|
disabled={
|
||||||
|
scaleMutation.isPending ||
|
||||||
|
calculateUpgradeCostMutation.isPending ||
|
||||||
|
directPatchResourcesMutation.isPending
|
||||||
|
}
|
||||||
className="btn-primary text-sm w-full disabled:opacity-50"
|
className="btn-primary text-sm w-full disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{scaleMutation.isPending || calculateUpgradeCostMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /> Calculating...</> : <><RefreshCw className="w-3 h-3 inline" /> Apply Changes</>}
|
{directPatchResourcesMutation.isPending ? (
|
||||||
|
<><Clock className="w-3 h-3 inline animate-spin" /> Applying…</>
|
||||||
|
) : scaleMutation.isPending || calculateUpgradeCostMutation.isPending ? (
|
||||||
|
<><Clock className="w-3 h-3 inline animate-spin" /> Calculating…</>
|
||||||
|
) : (
|
||||||
|
<><RefreshCw className="w-3 h-3 inline" /> Apply changes</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -185,9 +185,33 @@ export interface PodMetric {
|
|||||||
name: string;
|
name: string;
|
||||||
cpu: string;
|
cpu: string;
|
||||||
memory: 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 {
|
export interface ResourceUsage {
|
||||||
|
/** Per-deployment breakdown (app, DB, optional Redis/RabbitMQ) */
|
||||||
|
workloads?: WorkloadResourceUsage[];
|
||||||
|
/** Note when Elasticsearch logging sidecar is enabled */
|
||||||
|
loggingNote?: string;
|
||||||
configured: {
|
configured: {
|
||||||
cpuRequest: string;
|
cpuRequest: string;
|
||||||
cpuLimit: string;
|
cpuLimit: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user