feat: add resource monitoring and live scaling
- Backend: getResourceUsage() method in KubernetesService fetches real-time CPU/Memory metrics from K8s metrics-server API per pod - Backend: updateResources() method patches live K8s deployments with new CPU/Memory requests+limits and replica count - Backend: GET /applications/:id/resources endpoint for monitoring - Backend: PATCH /applications/:id/resources endpoint for scaling - Backend: ScaleResourcesDto with validation for resource fields - Frontend: Resource monitoring card with per-pod CPU/Memory progress bars (color-coded: green < 50%, yellow < 80%, red > 80%) - Frontend: Pod status table showing phase, readiness, restarts - Frontend: Scaling controls for CPU request/limit, memory request/limit, and replicas with +/- buttons - Frontend: Auto-refresh metrics every 5 seconds when monitoring is open - Frontend: parseCpuToMillicores/parseMemoryToMi helpers for metric parsing
This commit is contained in:
@@ -18,7 +18,7 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||||
import { ApplicationsService } from './applications.service';
|
import { ApplicationsService } from './applications.service';
|
||||||
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
|
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto } from './dto/application.dto';
|
||||||
import { RolesGuard } from '../common/guards/roles.guard';
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
import { Roles } from '../common/decorators/roles.decorator';
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
import { UserRole } from '../common/enums';
|
import { UserRole } from '../common/enums';
|
||||||
@@ -87,6 +87,41 @@ export class ApplicationsController {
|
|||||||
return this.applicationsService.update(id, req.user.id, dto);
|
return this.applicationsService.update(id, req.user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/resources')
|
||||||
|
@ApiOperation({ summary: 'Get real-time resource usage for an application' })
|
||||||
|
async getResources(@Param('id') id: string, @Request() req: any) {
|
||||||
|
const app = await this.applicationsService.findOne(
|
||||||
|
id,
|
||||||
|
req.user.role === UserRole.ADMIN ? undefined : req.user.id,
|
||||||
|
);
|
||||||
|
return this.kubernetesService.getResourceUsage(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/resources')
|
||||||
|
@ApiOperation({ summary: 'Update application resources (CPU/Memory/Replicas)' })
|
||||||
|
async updateResources(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Request() req: any,
|
||||||
|
@Body() dto: ScaleResourcesDto,
|
||||||
|
) {
|
||||||
|
const app = await this.applicationsService.findOne(id, req.user.id);
|
||||||
|
|
||||||
|
// Update in K8s (live)
|
||||||
|
await this.kubernetesService.updateResources(app, dto);
|
||||||
|
|
||||||
|
// Update in DB
|
||||||
|
const updateFields: any = {};
|
||||||
|
if (dto.cpuRequest) updateFields.cpuRequest = dto.cpuRequest;
|
||||||
|
if (dto.cpuLimit) updateFields.cpuLimit = dto.cpuLimit;
|
||||||
|
if (dto.memoryRequest) updateFields.memoryRequest = dto.memoryRequest;
|
||||||
|
if (dto.memoryLimit) updateFields.memoryLimit = dto.memoryLimit;
|
||||||
|
if (dto.replicas !== undefined) updateFields.replicas = dto.replicas;
|
||||||
|
|
||||||
|
const updated = await this.applicationsService.update(id, req.user.id, updateFields);
|
||||||
|
this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(dto)}`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: 'Delete an application and all its resources' })
|
@ApiOperation({ summary: 'Delete an application and all its resources' })
|
||||||
async delete(@Param('id') id: string, @Request() req: any) {
|
async delete(@Param('id') id: string, @Request() req: any) {
|
||||||
|
|||||||
@@ -118,3 +118,32 @@ export class UpdateApplicationDto {
|
|||||||
@Max(10)
|
@Max(10)
|
||||||
replicas?: number;
|
replicas?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ScaleResourcesDto {
|
||||||
|
@ApiPropertyOptional({ example: '100m' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cpuRequest?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '500m' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cpuLimit?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '128Mi' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
memoryRequest?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '512Mi' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
memoryLimit?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 2, minimum: 1, maximum: 10 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(10)
|
||||||
|
replicas?: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
coreApi: k8s.CoreV1Api;
|
coreApi: k8s.CoreV1Api;
|
||||||
appsApi: k8s.AppsV1Api;
|
appsApi: k8s.AppsV1Api;
|
||||||
networkingApi: k8s.NetworkingV1Api;
|
networkingApi: k8s.NetworkingV1Api;
|
||||||
|
kc: k8s.KubeConfig;
|
||||||
}> {
|
}> {
|
||||||
const cluster = clusterId
|
const cluster = clusterId
|
||||||
? await this.clustersService.findOne(clusterId)
|
? await this.clustersService.findOne(clusterId)
|
||||||
@@ -69,6 +70,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
coreApi: kc.makeApiClient(k8s.CoreV1Api),
|
coreApi: kc.makeApiClient(k8s.CoreV1Api),
|
||||||
appsApi: kc.makeApiClient(k8s.AppsV1Api),
|
appsApi: kc.makeApiClient(k8s.AppsV1Api),
|
||||||
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
|
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
|
||||||
|
kc,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,6 +525,167 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get real-time resource usage (CPU/Memory) for an app's pods via metrics-server.
|
||||||
|
* Also returns the configured requests/limits and pod status.
|
||||||
|
*/
|
||||||
|
async getResourceUsage(app: Application): Promise<any> {
|
||||||
|
const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId);
|
||||||
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
|
// Get deployment info for configured resources
|
||||||
|
let deployment: k8s.V1Deployment | null = null;
|
||||||
|
try {
|
||||||
|
const depResponse = await appsApi.readNamespacedDeployment(app.name, namespace);
|
||||||
|
deployment = depResponse.body;
|
||||||
|
} catch {
|
||||||
|
// Deployment may not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pods
|
||||||
|
const podsResponse = await coreApi.listNamespacedPod(
|
||||||
|
namespace,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
`app=${app.name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pods = podsResponse.body.items.map((pod) => ({
|
||||||
|
name: pod.metadata?.name,
|
||||||
|
status: pod.status?.phase,
|
||||||
|
ready: pod.status?.conditions?.find((c) => c.type === 'Ready')?.status === 'True',
|
||||||
|
restarts: pod.status?.containerStatuses?.[0]?.restartCount || 0,
|
||||||
|
startedAt: pod.status?.startTime,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Try to get metrics from metrics-server via custom API
|
||||||
|
let podMetrics: any[] = [];
|
||||||
|
try {
|
||||||
|
const metricsClient = new k8s.CustomObjectsApi(kc.getCurrentCluster()?.server);
|
||||||
|
// Use the kc to make a raw request to metrics API
|
||||||
|
const opts: any = {};
|
||||||
|
await kc.applyToRequest(opts);
|
||||||
|
|
||||||
|
const metricsUrl = `${kc.getCurrentCluster()?.server}/apis/metrics.k8s.io/v1beta1/namespaces/${namespace}/pods`;
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
const url = new URL(metricsUrl);
|
||||||
|
|
||||||
|
podMetrics = await new Promise((resolve) => {
|
||||||
|
const client = url.protocol === 'https:' ? https : http;
|
||||||
|
const reqOpts: any = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port,
|
||||||
|
path: url.pathname + `?labelSelector=app%3D${app.name}`,
|
||||||
|
method: 'GET',
|
||||||
|
headers: opts.headers || {},
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply TLS from kubeconfig
|
||||||
|
if (opts.ca) reqOpts.ca = opts.ca;
|
||||||
|
if (opts.cert) reqOpts.cert = opts.cert;
|
||||||
|
if (opts.key) reqOpts.key = opts.key;
|
||||||
|
|
||||||
|
const req = client.request(reqOpts, (res: any) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk: string) => (data += chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const items = (parsed.items || []).map((item: any) => ({
|
||||||
|
name: item.metadata?.name,
|
||||||
|
cpu: item.containers?.[0]?.usage?.cpu || '0',
|
||||||
|
memory: item.containers?.[0]?.usage?.memory || '0',
|
||||||
|
}));
|
||||||
|
resolve(items);
|
||||||
|
} catch {
|
||||||
|
resolve([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', () => resolve([]));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`Metrics not available for ${app.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get configured resources from deployment
|
||||||
|
const container = deployment?.spec?.template?.spec?.containers?.[0];
|
||||||
|
const configured = {
|
||||||
|
cpuRequest: container?.resources?.requests?.cpu || app.cpuRequest,
|
||||||
|
cpuLimit: container?.resources?.limits?.cpu || app.cpuLimit,
|
||||||
|
memoryRequest: container?.resources?.requests?.memory || app.memoryRequest,
|
||||||
|
memoryLimit: container?.resources?.limits?.memory || app.memoryLimit,
|
||||||
|
replicas: deployment?.spec?.replicas ?? app.replicas,
|
||||||
|
readyReplicas: deployment?.status?.readyReplicas || 0,
|
||||||
|
availableReplicas: deployment?.status?.availableReplicas || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured,
|
||||||
|
pods,
|
||||||
|
metrics: podMetrics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update resource limits/requests and replicas on a live K8s deployment.
|
||||||
|
*/
|
||||||
|
async updateResources(
|
||||||
|
app: Application,
|
||||||
|
resources: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
|
const patch: any = { spec: {} };
|
||||||
|
|
||||||
|
if (resources.replicas !== undefined) {
|
||||||
|
patch.spec.replicas = resources.replicas;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resources.cpuRequest || resources.cpuLimit || resources.memoryRequest || resources.memoryLimit) {
|
||||||
|
patch.spec.template = {
|
||||||
|
spec: {
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: app.name,
|
||||||
|
resources: {
|
||||||
|
requests: {
|
||||||
|
...(resources.cpuRequest && { cpu: resources.cpuRequest }),
|
||||||
|
...(resources.memoryRequest && { memory: resources.memoryRequest }),
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
...(resources.cpuLimit && { cpu: resources.cpuLimit }),
|
||||||
|
...(resources.memoryLimit && { memory: resources.memoryLimit }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await appsApi.patchNamespacedDeployment(
|
||||||
|
app.name,
|
||||||
|
namespace,
|
||||||
|
patch,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(resources)}`);
|
||||||
|
}
|
||||||
|
|
||||||
async deleteApplication(app: Application): Promise<void> {
|
async deleteApplication(app: Application): Promise<void> {
|
||||||
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import type { Application, Deployment } from '@/types';
|
import type { Application, Deployment, ResourceUsage } from '@/types';
|
||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
@@ -17,6 +17,30 @@ const statusColors: Record<string, string> = {
|
|||||||
stopped: 'bg-gray-100 text-gray-700',
|
stopped: 'bg-gray-100 text-gray-700',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse CPU value to millicores (e.g. "100m" → 100, "1" → 1000, "250n" → 0.00025)
|
||||||
|
*/
|
||||||
|
function parseCpuToMillicores(cpu: string): number {
|
||||||
|
if (!cpu || cpu === '0') return 0;
|
||||||
|
if (cpu.endsWith('n')) return parseFloat(cpu) / 1_000_000;
|
||||||
|
if (cpu.endsWith('u')) return parseFloat(cpu) / 1_000;
|
||||||
|
if (cpu.endsWith('m')) return parseFloat(cpu);
|
||||||
|
return parseFloat(cpu) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse memory value to MiB (e.g. "128Mi" → 128, "1Gi" → 1024, "131072Ki" → 128)
|
||||||
|
*/
|
||||||
|
function parseMemoryToMi(memory: string): number {
|
||||||
|
if (!memory || memory === '0') return 0;
|
||||||
|
if (memory.endsWith('Ki')) return parseFloat(memory) / 1024;
|
||||||
|
if (memory.endsWith('Mi')) return parseFloat(memory);
|
||||||
|
if (memory.endsWith('Gi')) return parseFloat(memory) * 1024;
|
||||||
|
if (memory.endsWith('Ti')) return parseFloat(memory) * 1024 * 1024;
|
||||||
|
// raw bytes
|
||||||
|
return parseFloat(memory) / (1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppDetailPage() {
|
export default function AppDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -27,6 +51,14 @@ export default function AppDetailPage() {
|
|||||||
const logsEndRef = useRef<HTMLPreElement>(null);
|
const logsEndRef = useRef<HTMLPreElement>(null);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [showResources, setShowResources] = useState(false);
|
||||||
|
const [resourceForm, setResourceForm] = useState({
|
||||||
|
cpuRequest: '',
|
||||||
|
cpuLimit: '',
|
||||||
|
memoryRequest: '',
|
||||||
|
memoryLimit: '',
|
||||||
|
replicas: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: app, isLoading } = useQuery<Application>({
|
const { data: app, isLoading } = useQuery<Application>({
|
||||||
queryKey: ['application', appId],
|
queryKey: ['application', appId],
|
||||||
@@ -46,6 +78,26 @@ export default function AppDetailPage() {
|
|||||||
refetchInterval: showLogs ? 3000 : false,
|
refetchInterval: showLogs ? 3000 : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: resourceUsage, isLoading: resourcesLoading } = useQuery<ResourceUsage>({
|
||||||
|
queryKey: ['resources', appId],
|
||||||
|
queryFn: () => api.get(`/applications/${appId}/resources`).then((r) => r.data),
|
||||||
|
enabled: showResources,
|
||||||
|
refetchInterval: showResources ? 5000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync form when resource data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (resourceUsage?.configured) {
|
||||||
|
setResourceForm({
|
||||||
|
cpuRequest: resourceUsage.configured.cpuRequest,
|
||||||
|
cpuLimit: resourceUsage.configured.cpuLimit,
|
||||||
|
memoryRequest: resourceUsage.configured.memoryRequest,
|
||||||
|
memoryLimit: resourceUsage.configured.memoryLimit,
|
||||||
|
replicas: resourceUsage.configured.replicas,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [resourceUsage?.configured]);
|
||||||
|
|
||||||
// Auto-scroll logs to bottom
|
// Auto-scroll logs to bottom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (logsEndRef.current) {
|
if (logsEndRef.current) {
|
||||||
@@ -113,6 +165,17 @@ export default function AppDetailPage() {
|
|||||||
onError: () => toast.error('Failed to delete application'),
|
onError: () => toast.error('Failed to delete application'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const scaleMutation = useMutation({
|
||||||
|
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
||||||
|
api.patch(`/applications/${appId}/resources`, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
|
||||||
|
toast.success('Resources updated successfully!');
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to update resources'),
|
||||||
|
});
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: (file: File) => {
|
mutationFn: (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -424,6 +487,238 @@ export default function AppDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Resource Monitoring & Scaling */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">📊 Resources & Scaling</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResources(!showResources)}
|
||||||
|
className="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
{showResources ? '🔽 Hide' : '📊 Monitor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showResources && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Live Metrics */}
|
||||||
|
{resourcesLoading ? (
|
||||||
|
<div className="text-center py-6 text-gray-400 text-sm">Loading metrics...</div>
|
||||||
|
) : resourceUsage ? (
|
||||||
|
<>
|
||||||
|
{/* Cluster Status */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<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 ? '✅' : '⏳'}
|
||||||
|
</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}>
|
||||||
|
🟢 {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.pods.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-2">Pod Status</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 border-b">
|
||||||
|
<th className="pb-2 font-medium">Pod</th>
|
||||||
|
<th className="pb-2 font-medium">Status</th>
|
||||||
|
<th className="pb-2 font-medium">Ready</th>
|
||||||
|
<th className="pb-2 font-medium">Restarts</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{resourceUsage.pods.map((pod) => (
|
||||||
|
<tr key={pod.name} className="text-gray-700">
|
||||||
|
<td className="py-2 font-mono truncate max-w-[200px]" title={pod.name}>
|
||||||
|
{pod.name}
|
||||||
|
</td>
|
||||||
|
<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 ? '✅' : '⏳'}</td>
|
||||||
|
<td className="py-2">{pod.restarts}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scaling Controls */}
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">⚙️ Scale Resources</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">CPU Request</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={resourceForm.cpuRequest}
|
||||||
|
onChange={(e) => setResourceForm((f) => ({ ...f, cpuRequest: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
placeholder="100m"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">CPU Limit</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={resourceForm.cpuLimit}
|
||||||
|
onChange={(e) => setResourceForm((f) => ({ ...f, cpuLimit: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
placeholder="500m"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Memory Request</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={resourceForm.memoryRequest}
|
||||||
|
onChange={(e) => setResourceForm((f) => ({ ...f, memoryRequest: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
placeholder="128Mi"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Memory Limit</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={resourceForm.memoryLimit}
|
||||||
|
onChange={(e) => setResourceForm((f) => ({ ...f, memoryLimit: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
placeholder="512Mi"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Replicas</label>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setResourceForm((f) => ({ ...f, replicas: Math.max(1, f.replicas - 1) }))}
|
||||||
|
className="w-8 h-8 flex items-center justify-center bg-gray-100 rounded-lg hover:bg-gray-200 text-sm font-bold"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="text-lg font-bold text-gray-800 w-8 text-center">{resourceForm.replicas}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setResourceForm((f) => ({ ...f, replicas: Math.min(10, f.replicas + 1) }))}
|
||||||
|
className="w-8 h-8 flex items-center justify-center bg-gray-100 rounded-lg hover:bg-gray-200 text-sm font-bold"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
onClick={() => scaleMutation.mutate(resourceForm)}
|
||||||
|
disabled={scaleMutation.isPending}
|
||||||
|
className="btn-primary text-sm w-full disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{scaleMutation.isPending ? '⏳ Applying...' : '🔄 Apply Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
{isStopped ? 'Application is stopped. Start it to see resources.' : 'No resource data available yet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Pod Logs */}
|
{/* Pod Logs */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
@@ -92,3 +92,31 @@ export interface CreateApplicationDto {
|
|||||||
replicas?: number;
|
replicas?: number;
|
||||||
port?: number;
|
port?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PodInfo {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
ready: boolean;
|
||||||
|
restarts: number;
|
||||||
|
startedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodMetric {
|
||||||
|
name: string;
|
||||||
|
cpu: string;
|
||||||
|
memory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceUsage {
|
||||||
|
configured: {
|
||||||
|
cpuRequest: string;
|
||||||
|
cpuLimit: string;
|
||||||
|
memoryRequest: string;
|
||||||
|
memoryLimit: string;
|
||||||
|
replicas: number;
|
||||||
|
readyReplicas: number;
|
||||||
|
availableReplicas: number;
|
||||||
|
};
|
||||||
|
pods: PodInfo[];
|
||||||
|
metrics: PodMetric[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user