From b191dc9f0364a0a2bc98a91f9be24e8586b89976 Mon Sep 17 00:00:00 2001 From: chyoo Date: Thu, 9 Jul 2026 11:56:05 +0900 Subject: [PATCH] refine dashboard infrastructure and resource status --- src/runtime/runtime.controller.ts | 4 + src/runtime/runtime.service.ts | 134 ++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/src/runtime/runtime.controller.ts b/src/runtime/runtime.controller.ts index d6dae24..8d0e0c1 100644 --- a/src/runtime/runtime.controller.ts +++ b/src/runtime/runtime.controller.ts @@ -23,6 +23,10 @@ export class RuntimeController { return this.runtimeService.listPodMetrics(namespace); } + @Get('metrics/cluster') + getClusterMetrics() { + return this.runtimeService.getClusterMetrics(); + } @Get('workloads/:namespace/:kind/:name/env') getWorkloadEnv( diff --git a/src/runtime/runtime.service.ts b/src/runtime/runtime.service.ts index 6483941..7356e3e 100644 --- a/src/runtime/runtime.service.ts +++ b/src/runtime/runtime.service.ts @@ -72,6 +72,64 @@ export class RuntimeService { return parsePodMetrics(output, Boolean(namespace), namespace); } + async getClusterMetrics() { + const nodes = await this.kubectlJson(['get', 'nodes', '-o', 'json']); + const topOutput = await this.kubectl(['top', 'nodes', '--no-headers']).catch(() => ''); + const usageByNode = parseNodeMetrics(topOutput); + + const nodeMetrics = await Promise.all((nodes.items ?? []).map(async (node: KubectlJson) => { + const name = node.metadata?.name ?? '-'; + const capacity = node.status?.capacity ?? {}; + const usage = usageByNode.get(name); + const storage = await this.getNodeStorageMetric(name).catch(() => null); + + return { + name, + status: node.status?.conditions?.find((condition: KubectlJson) => condition.type === 'Ready')?.status === 'True' ? 'Ready' : 'NotReady', + cpu: { + usage: usage?.cpu ?? null, + capacity: capacity.cpu ?? null, + percent: usage?.cpuMillicores !== undefined ? percentage(usage.cpuMillicores, parseCpuToMillicores(capacity.cpu)) : null, + }, + memory: { + usage: usage?.memory ?? null, + capacity: capacity.memory ?? null, + percent: usage?.memoryBytes !== undefined ? percentage(usage.memoryBytes, parseMemoryToBytes(capacity.memory)) : null, + }, + storage: storage ?? { + usage: null, + capacity: capacity['ephemeral-storage'] ?? null, + percent: null, + }, + }; + })); + + return { + nodes: nodeMetrics, + summary: { + cpuPercent: averagePercent(nodeMetrics.map((node: any) => node.cpu.percent)), + memoryPercent: averagePercent(nodeMetrics.map((node: any) => node.memory.percent)), + storagePercent: averagePercent(nodeMetrics.map((node: any) => node.storage.percent)), + }, + }; + } + + private async getNodeStorageMetric(nodeName: string) { + const stats = await this.kubectlJson(['get', '--raw', `/api/v1/nodes/${nodeName}/proxy/stats/summary`]); + const fsStats = stats.node?.fs; + const usedBytes = Number(fsStats?.usedBytes); + const capacityBytes = Number(fsStats?.capacityBytes); + + if (!Number.isFinite(usedBytes) || !Number.isFinite(capacityBytes) || capacityBytes <= 0) { + return null; + } + + return { + usage: formatBytes(usedBytes), + capacity: formatBytes(capacityBytes), + percent: percentage(usedBytes, capacityBytes), + }; + } async getWorkloadEnv(namespace: string, kind: string, name: string) { const normalizedKind = this.normalizeEnvWorkloadKind(kind); @@ -642,3 +700,79 @@ function parsePodMetrics(output: string, namespacedOutput: boolean, namespace?: }) .filter((metric) => metric.namespace && metric.pod); } + +function parseNodeMetrics(output: string) { + const metrics = new Map(); + + output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .forEach((line) => { + const columns = line.split(/\s+/); + const [node, cpu, memory] = [columns[0], columns[1], columns[3]]; + if (!node) return; + metrics.set(node, { + cpu, + cpuMillicores: parseCpuToMillicores(cpu), + memory, + memoryBytes: parseMemoryToBytes(memory), + }); + }); + + return metrics; +} + +function parseCpuToMillicores(value?: string | null) { + if (!value) return 0; + const normalized = value.trim(); + if (normalized.endsWith('m')) return Number(normalized.slice(0, -1)) || 0; + return (Number(normalized) || 0) * 1000; +} + +function parseMemoryToBytes(value?: string | null) { + if (!value) return 0; + const normalized = value.trim(); + const matched = normalized.match(/^([0-9.]+)([A-Za-z]+)?$/); + if (!matched) return 0; + const amount = Number(matched[1]); + if (!Number.isFinite(amount)) return 0; + + const unit = matched[2] ?? ''; + const multipliers: Record = { + Ki: 1024, + Mi: 1024 ** 2, + Gi: 1024 ** 3, + Ti: 1024 ** 4, + K: 1000, + M: 1000 ** 2, + G: 1000 ** 3, + T: 1000 ** 4, + }; + + return amount * (multipliers[unit] ?? 1); +} + +function percentage(used: number, capacity: number) { + if (!Number.isFinite(used) || !Number.isFinite(capacity) || capacity <= 0) return null; + return Math.max(0, Math.min(100, Math.round((used / capacity) * 100))); +} + +function averagePercent(values: Array) { + const validValues = values.filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); + if (!validValues.length) return null; + return Math.round(validValues.reduce((sum, value) => sum + value, 0) / validValues.length); +} + +function formatBytes(value: number) { + const units = ['B', 'Ki', 'Mi', 'Gi', 'Ti']; + let amount = value; + let unitIndex = 0; + + while (amount >= 1024 && unitIndex < units.length - 1) { + amount /= 1024; + unitIndex += 1; + } + + return `${amount >= 10 ? amount.toFixed(0) : amount.toFixed(1)}${units[unitIndex]}`; +}