diff --git a/src/__tests__/stats.test.ts b/src/__tests__/stats.test.ts new file mode 100644 index 0000000..62f913d --- /dev/null +++ b/src/__tests__/stats.test.ts @@ -0,0 +1,523 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + parseK8sCpuQuantity, + parseK8sMemoryQuantity, + formatK8sBytes, + getK8sClusterStats, + getRunningPods +} from '../kubernetes/pods'; +import { + getDockerSystemStats, + formatDockerBytes, + getRunningContainers +} from '../docker/containers'; + +const mockContainersList = vi.fn(); +const mockContainerStats = vi.fn(); +const mockDockerInfo = vi.fn(); + +vi.mock('../docker/client', () => { + return { + getDockerClient: () => ({ + listContainers: mockContainersList, + getContainer: (id: string) => ({ + stats: mockContainerStats, + }), + info: mockDockerInfo, + }), + }; +}); + +const mockListClusterCustomObject = vi.fn(); +const mockListPodForAllNamespaces = vi.fn(); + +vi.mock('../kubernetes/client', () => { + return { + getK8sApi: () => ({ + listPodForAllNamespaces: mockListPodForAllNamespaces, + }), + getCustomObjectsApi: () => ({ + listClusterCustomObject: mockListClusterCustomObject, + }), + }; +}); + +const mockTriggerAlert = vi.fn(); +vi.mock('../monitor/alerts', () => ({ + triggerAlert: (...args: any[]) => mockTriggerAlert(...args), +})); + +describe('Kubernetes resource quantity parsing', () => { + describe('parseK8sCpuQuantity', () => { + it('parses millicores', () => { + expect(parseK8sCpuQuantity('450m')).toBe(450); + expect(parseK8sCpuQuantity('100m')).toBe(100); + }); + + it('parses cores', () => { + expect(parseK8sCpuQuantity('2')).toBe(2000); + expect(parseK8sCpuQuantity('0.5')).toBe(500); + }); + + it('parses nanocores', () => { + expect(parseK8sCpuQuantity('125000000n')).toBe(125); + }); + + it('parses microcores', () => { + expect(parseK8sCpuQuantity('125000u')).toBe(125); + }); + + it('handles numeric input', () => { + expect(parseK8sCpuQuantity(0.5)).toBe(500); + }); + + it('handles empty or malformed inputs', () => { + expect(parseK8sCpuQuantity('')).toBe(0); + expect(parseK8sCpuQuantity('abc')).toBe(0); + }); + + it('hits the default case for other suffixes', () => { + expect(parseK8sCpuQuantity('2x')).toBe(0); + }); + }); + + describe('parseK8sMemoryQuantity', () => { + it('parses binary power values', () => { + expect(parseK8sMemoryQuantity('2Ki')).toBe(2 * 1024); + expect(parseK8sMemoryQuantity('5Mi')).toBe(5 * 1024 * 1024); + expect(parseK8sMemoryQuantity('1Gi')).toBe(1024 * 1024 * 1024); + }); + + it('parses decimal power values', () => { + expect(parseK8sMemoryQuantity('2k')).toBe(2000); + expect(parseK8sMemoryQuantity('5M')).toBe(5000000); + expect(parseK8sMemoryQuantity('1G')).toBe(1000000000); + }); + + it('handles numeric input', () => { + expect(parseK8sMemoryQuantity(1024)).toBe(1024); + }); + + it('handles empty or malformed inputs', () => { + expect(parseK8sMemoryQuantity('')).toBe(0); + expect(parseK8sMemoryQuantity('abc')).toBe(0); + expect(parseK8sMemoryQuantity('512')).toBe(512); + expect(parseK8sMemoryQuantity('2x')).toBe(0); + }); + }); +}); + +describe('Byte formatting', () => { + it('formats K8s bytes with binary suffixes', () => { + expect(formatK8sBytes(0)).toBe('0B'); + expect(formatK8sBytes(512)).toBe('512B'); + expect(formatK8sBytes(1024)).toBe('1KiB'); + expect(formatK8sBytes(1.5 * 1024 * 1024)).toBe('1.5MiB'); + expect(formatK8sBytes(2 * 1024 * 1024 * 1024)).toBe('2GiB'); + }); + + it('formats Docker bytes with decimal suffixes', () => { + expect(formatDockerBytes(0)).toBe('0B'); + expect(formatDockerBytes(500)).toBe('500B'); + expect(formatDockerBytes(1000)).toBe('1KB'); + expect(formatDockerBytes(1.5 * 1000 * 1000)).toBe('1.5MB'); + expect(formatDockerBytes(2 * 1000 * 1000 * 1000)).toBe('2GB'); + }); +}); + +describe('getDockerSystemStats', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDockerInfo.mockResolvedValue({ MemTotal: 8000000000 }); + }); + + it('calculates aggregate stats for running containers', async () => { + mockContainersList.mockResolvedValueOnce([ + { Id: 'cont1', Names: ['/c1'] }, + { Id: 'cont2', Names: ['/c2'] }, + ]); + + mockContainerStats + // First container stats + .mockResolvedValueOnce({ + cpu_stats: { + cpu_usage: { total_usage: 100 }, + system_cpu_usage: 1000, + online_cpus: 2, + }, + precpu_stats: { + cpu_usage: { total_usage: 50 }, + system_cpu_usage: 500, + }, + memory_stats: { + usage: 1000000, + stats: { cache: 100000 }, + limit: 8000000, + }, + }) + // Second container stats + .mockResolvedValueOnce({ + cpu_stats: { + cpu_usage: { total_usage: 200 }, + system_cpu_usage: 1000, + online_cpus: 1, + }, + precpu_stats: { + cpu_usage: { total_usage: 150 }, + system_cpu_usage: 500, + }, + memory_stats: { + usage: 2000000, + stats: { inactive_file: 200000 }, + limit: 8000000, + }, + }); + + const stats = await getDockerSystemStats(); + expect(stats).not.toBeNull(); + // Cont 1 CPU: ((100-50) / (1000-500)) * 2 * 100 = 20% + // Cont 2 CPU: ((200-150) / (1000-500)) * 1 * 100 = 10% + // Total CPU: 30% + expect(stats?.cpu).toBeCloseTo(30); + // Cont 1 Memory: 1000000 - 100000 = 900000 + // Cont 2 Memory: 2000000 - 200000 = 1800000 + // Total Memory: 2700000 + expect(stats?.memoryUsage).toBe(2700000); + expect(stats?.memoryLimit).toBe(8000000000); + }); + + it.each([ + { + description: 'falls back to docker.info() memory limit if limit from stats is 0', + containers: [{ Id: 'cont1', Names: ['/c1'] }], + statsMock: { resolve: { cpu_stats: {}, precpu_stats: {}, memory_stats: { usage: 1000, limit: 0 } } }, + infoMock: { resolve: { MemTotal: 16000000000 } }, + expectedCpu: 0, + expectedMemoryUsage: 1000, + expectedLimit: 16000000000, + }, + { + description: 'handles docker.info() failure gracefully', + containers: [{ Id: 'cont1', Names: ['/c1'] }], + statsMock: { resolve: { cpu_stats: {}, precpu_stats: {}, memory_stats: { usage: 1000, limit: 0 } } }, + infoMock: { reject: new Error('Info failed') }, + expectedCpu: 0, + expectedMemoryUsage: 1000, + expectedLimit: 0, + }, + { + description: 'handles container.stats failure gracefully for individual containers', + containers: [{ Id: 'cont1', Names: ['/c1'] }], + statsMock: { reject: new Error('Stats failed') }, + infoMock: { resolve: { MemTotal: 8000000000 } }, + expectedCpu: 0, + expectedMemoryUsage: 0, + expectedLimit: 8000000000, + }, + { + description: 'returns zero stats and gets memory limit from docker.info when no containers are running', + containers: [], + statsMock: null, + infoMock: { resolve: { MemTotal: 12000000000 } }, + expectedCpu: 0, + expectedMemoryUsage: 0, + expectedLimit: 12000000000, + }, + { + description: 'handles docker.info failure when no containers are running', + containers: [], + statsMock: null, + infoMock: { reject: new Error('info fail') }, + expectedCpu: 0, + expectedMemoryUsage: 0, + expectedLimit: 0, + }, + ])('$description', async ({ containers, statsMock, infoMock, expectedCpu, expectedMemoryUsage, expectedLimit }) => { + mockContainersList.mockResolvedValueOnce(containers); + + if (statsMock) { + if ('resolve' in statsMock) { + mockContainerStats.mockResolvedValueOnce(statsMock.resolve); + } else { + mockContainerStats.mockRejectedValueOnce(statsMock.reject); + } + } + + if ('resolve' in infoMock) { + mockDockerInfo.mockResolvedValueOnce(infoMock.resolve); + } else { + mockDockerInfo.mockRejectedValueOnce(infoMock.reject); + } + + const stats = await getDockerSystemStats(); + expect(stats?.cpu).toBe(expectedCpu); + expect(stats?.memoryUsage).toBe(expectedMemoryUsage); + expect(stats?.memoryLimit).toBe(expectedLimit); + }); + + it('returns 0 CPU percent when cpuDelta or systemCpuDelta is zero or negative', async () => { + mockContainersList.mockResolvedValueOnce([ + { Id: 'cont1', Names: ['/c1'] }, + ]); + mockContainerStats.mockResolvedValueOnce({ + cpu_stats: { + cpu_usage: { total_usage: 100 }, + system_cpu_usage: 1000, + online_cpus: 2, + }, + precpu_stats: { + cpu_usage: { total_usage: 100 }, + system_cpu_usage: 1000, + }, + memory_stats: { + usage: 1000000, + limit: 8000000, + }, + }); + + const stats = await getDockerSystemStats(); + expect(stats?.cpu).toBe(0); + }); + + it('gracefully degrades to null when listing containers fails', async () => { + mockContainersList.mockRejectedValueOnce(new Error('Docker socket not available')); + const stats = await getDockerSystemStats(); + expect(stats).toBeNull(); + }); +}); + +describe('getK8sClusterStats', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches metrics-server node-level stats', async () => { + mockListClusterCustomObject.mockResolvedValueOnce({ + items: [ + { usage: { cpu: '200m', memory: '1Gi' } }, + { usage: { cpu: '300m', memory: '2Gi' } }, + ], + }); + + const stats = await getK8sClusterStats(); + expect(stats.cpu).toBe('500m'); + expect(stats.memory).toBe('3GiB'); + expect(stats.source).toBe('metrics-server'); + }); + + it('falls back to metrics-server pod-level stats when nodes query fails/empty', async () => { + mockListClusterCustomObject + .mockRejectedValueOnce(new Error('Node metrics 404')) // node metrics fails + .mockResolvedValueOnce({ + items: [ + { + containers: [ + { usage: { cpu: '100m', memory: '256Mi' } }, + ], + }, + { + containers: [ + { usage: { cpu: '150m', memory: '512Mi' } }, + ], + }, + ], + }); + + const stats = await getK8sClusterStats(); + expect(stats.cpu).toBe('250m'); + expect(stats.memory).toBe('768MiB'); + expect(stats.source).toBe('metrics-server'); + }); + + it('falls back to native pod resource requests sum when metrics-server is missing', async () => { + mockListClusterCustomObject + .mockRejectedValueOnce(new Error('No metrics endpoint')) // node metrics fails + .mockRejectedValueOnce(new Error('No metrics endpoint')); // pod metrics fails + + mockListPodForAllNamespaces.mockResolvedValueOnce({ + items: [ + { + status: { phase: 'Running' }, + spec: { + containers: [ + { resources: { requests: { cpu: '200m', memory: '512Mi' } } }, + ], + }, + }, + { + status: { phase: 'Pending' }, + spec: { + containers: [ + { resources: { requests: { cpu: '100m', memory: '256Mi' } } }, + ], + initContainers: [ + { resources: { requests: { cpu: '500m', memory: '1Gi' } } }, + ], + }, + }, + { + status: { phase: 'Failed' }, // should be ignored + spec: { + containers: [ + { resources: { requests: { cpu: '1000m', memory: '4Gi' } } }, + ], + }, + }, + ], + }); + + const stats = await getK8sClusterStats(); + expect(stats.cpu).toBe('300m'); + expect(stats.memory).toBe('768MiB'); + expect(stats.source).toBe('requests'); + }); + + it('gracefully degrades to N/A when all methods fail', async () => { + mockListClusterCustomObject + .mockRejectedValue(new Error('Unreachable')); + mockListPodForAllNamespaces + .mockRejectedValue(new Error('Unreachable')); + + const stats = await getK8sClusterStats(); + expect(stats.cpu).toBe('N/A'); + expect(stats.memory).toBe('N/A'); + expect(stats.source).toBe('N/A'); + }); + + it('handles empty results from metrics-server and empty pods/requests list', async () => { + mockListClusterCustomObject + .mockResolvedValueOnce({ items: [] }) + .mockResolvedValueOnce({ items: [] }); + + mockListPodForAllNamespaces.mockResolvedValueOnce({ + items: [ + { + status: { phase: 'Running' }, + spec: { + containers: [ + { resources: {} }, + { resources: { requests: {} } }, + ], + }, + }, + { + status: { phase: 'Running' }, + spec: { + // containers undefined + }, + }, + { + status: { phase: 'Running' }, + // spec undefined + }, + { + status: { phase: 'Running' }, + spec: { + containers: [ + { resources: { requests: { cpu: '100m', memory: '256Mi' } } } + ] + } + } + ], + }); + + const stats = await getK8sClusterStats(); + expect(stats.cpu).toBe('100m'); + expect(stats.memory).toBe('256MiB'); + expect(stats.source).toBe('requests'); + }); +}); + +describe('getRunningContainers actual implementation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('correctly maps containers and triggers alerts for restart/failure states', async () => { + mockContainersList.mockResolvedValueOnce([ + { Id: 'c123456789012', Names: ['/my-container'], Image: 'nginx', State: 'running', Status: 'Up' }, + { Id: 'c234567890123', Names: ['/my-restarting'], Image: 'nginx', State: 'restarting', Status: 'Restarting (1)' }, + { Id: 'c345678901234', Names: ['/my-failed'], Image: 'nginx', State: 'exited', Status: 'Exited (137)' }, + { Id: 'c456789012345', Names: ['/my-clean-exit'], Image: 'nginx', State: 'exited', Status: 'Exited (0)' }, + ]); + + const containers = await getRunningContainers(); + expect(containers).toHaveLength(4); + expect(containers[0].id).toBe('c12345678901'); + expect(containers[0].name).toBe('my-container'); + + expect(mockTriggerAlert).toHaveBeenCalledWith(expect.objectContaining({ + id: 'container:my-restarting:restarting', + severity: 'warning' + }), expect.any(Object)); + + expect(mockTriggerAlert).toHaveBeenCalledWith(expect.objectContaining({ + id: 'container:my-failed:failure', + severity: 'critical' + }), expect.any(Object)); + }); + + it('propagates errors when listContainers rejects', async () => { + mockContainersList.mockRejectedValueOnce(new Error('List failed')); + await expect(getRunningContainers()).rejects.toThrow('List failed'); + }); +}); + +describe('getRunningPods actual implementation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('correctly maps pods and triggers alerts for failures/waiting conditions', async () => { + mockListPodForAllNamespaces.mockResolvedValueOnce({ + items: [ + { + metadata: { name: 'pod-running', namespace: 'default' }, + status: { + phase: 'Running', + containerStatuses: [ + { restartCount: 2, state: { running: {} } }, + ], + }, + spec: { nodeName: 'node-1' }, + }, + { + metadata: { name: 'pod-failed-phase', namespace: 'default' }, + status: { + phase: 'Failed', + containerStatuses: [], + }, + }, + { + metadata: { name: 'pod-crashloop', namespace: 'default' }, + status: { + phase: 'Pending', + containerStatuses: [ + { name: 'c1', restartCount: 5, state: { waiting: { reason: 'CrashLoopBackOff' } } }, + ], + }, + }, + ], + }); + + const pods = await getRunningPods(); + expect(pods).toHaveLength(3); + expect(pods[0].name).toBe('pod-running'); + expect(pods[0].restarts).toBe(2); + + expect(mockTriggerAlert).toHaveBeenCalledWith(expect.objectContaining({ + id: 'pod:pod-failed-phase:failure', + message: expect.stringContaining('FAILED') + }), expect.any(Object)); + + expect(mockTriggerAlert).toHaveBeenCalledWith(expect.objectContaining({ + id: 'pod:pod-crashloop:failure', + message: expect.stringContaining('CrashLoopBackOff') + }), expect.any(Object)); + }); + + it('propagates errors when listPodForAllNamespaces rejects', async () => { + mockListPodForAllNamespaces.mockRejectedValueOnce(new Error('K8s down')); + await expect(getRunningPods()).rejects.toThrow('K8s down'); + }); +}); diff --git a/src/__tests__/watch-dashboard.test.tsx b/src/__tests__/watch-dashboard.test.tsx new file mode 100644 index 0000000..d3b4d45 --- /dev/null +++ b/src/__tests__/watch-dashboard.test.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from 'ink'; +import { Writable } from 'node:stream'; +import { Console } from 'node:console'; +import { WatchDashboard } from '../ui/WatchDashboard'; +import * as podsMod from '../kubernetes/pods'; +import * as containersMod from '../docker/containers'; + +if (!console.Console) { + console.Console = Console; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const waitForFrameToContain = async (mockStdout: MockWritable, substring: string, timeout = 5000) => { + const start = Date.now(); + while (Date.now() - start < timeout) { + const output = mockStdout.frames.join('\n'); + if (output.includes(substring)) { + return; + } + await sleep(20); + } + throw new Error(`Timed out waiting for "${substring}" to appear in stdout. Output was:\n${mockStdout.frames.join('\n')}`); +}; + +class MockWritable extends Writable { + frames: string[] = []; + isTTY = true; + columns = 80; + rows = 24; + _write(chunk: any, encoding: string, callback: (error?: Error | null) => void) { + this.frames.push(chunk.toString()); + callback(); + } +} + +const getRunningPodsSpy = vi.spyOn(podsMod, 'getRunningPods'); +const getK8sClusterStatsSpy = vi.spyOn(podsMod, 'getK8sClusterStats'); +const getRunningContainersSpy = vi.spyOn(containersMod, 'getRunningContainers'); +const getDockerSystemStatsSpy = vi.spyOn(containersMod, 'getDockerSystemStats'); + +describe('WatchDashboard', () => { + let mockStdout: MockWritable; + + beforeEach(() => { + mockStdout = new MockWritable(); + vi.clearAllMocks(); + }); + + it('renders loading states and then displays pods and containers', async () => { + getRunningPodsSpy.mockResolvedValue([ + { name: 'pod-1', namespace: 'default', status: 'Running', restarts: 0, node: 'node-1' }, + ]); + getK8sClusterStatsSpy.mockResolvedValue({ + cpu: '250m', + memory: '512MiB', + source: 'metrics-server', + }); + getRunningContainersSpy.mockResolvedValue([ + { id: 'c1', name: 'container-1', image: 'nginx', state: 'running', status: 'Up 2 hours' }, + ]); + getDockerSystemStatsSpy.mockResolvedValue({ + cpu: 15.5, + memoryUsage: 2000000000, + memoryLimit: 8000000000, + }); + + const { unmount } = render(, { stdout: mockStdout as any, interactive: true }); + + await waitForFrameToContain(mockStdout, 'pod-1'); + + const output = mockStdout.frames.join('\n'); + expect(output).toContain('KDM Live Dashboard'); + expect(output).toContain('pod-1'); + expect(output).toContain('container-1'); + expect(output).toContain('k8s Stats: CPU: 250m | Mem: 512MiB'); + expect(output).toContain('Docker Stats: CPU: 15.5%'); + expect(output).toContain('Mem: 2GB'); + expect(output).toContain('8GB'); + + unmount(); + }); + + it.each([ + { + description: 'handles K8s API errors gracefully', + mockSetup: () => { + getRunningPodsSpy.mockRejectedValue(new Error('K8s error')); + getK8sClusterStatsSpy.mockRejectedValue(new Error('K8s stats error')); + getRunningContainersSpy.mockResolvedValue([]); + getDockerSystemStatsSpy.mockResolvedValue(null); + }, + errorMsg: 'ERROR: K8S - K8s error', + outputMsg: 'k8s Stats: CPU: N/A | Mem: N/A', + }, + { + description: 'handles Docker API errors gracefully', + mockSetup: () => { + getRunningPodsSpy.mockResolvedValue([]); + getK8sClusterStatsSpy.mockResolvedValue({ cpu: 'N/A', memory: 'N/A', source: 'N/A' }); + getRunningContainersSpy.mockRejectedValue(new Error('Docker error')); + getDockerSystemStatsSpy.mockRejectedValue(new Error('Docker stats error')); + }, + errorMsg: 'ERROR: DOCKER - Docker error', + outputMsg: 'Docker Stats: CPU: N/A | Mem: N/A', + }, + ])('$description', async ({ mockSetup, errorMsg, outputMsg }) => { + mockSetup(); + + const { unmount } = render(, { stdout: mockStdout as any, interactive: true }); + + await waitForFrameToContain(mockStdout, errorMsg); + + const output = mockStdout.frames.join('\n'); + expect(output).toContain(outputMsg); + + unmount(); + }); + + it('handles terminal resize events dynamically', async () => { + getRunningPodsSpy.mockResolvedValue([]); + getK8sClusterStatsSpy.mockResolvedValue({ cpu: 'N/A', memory: 'N/A', source: 'N/A' }); + getRunningContainersSpy.mockResolvedValue([]); + getDockerSystemStatsSpy.mockResolvedValue(null); + + const originalColumns = process.stdout.columns; + + Object.defineProperty(process.stdout, 'columns', { + value: 40, + writable: true, + configurable: true, + }); + + const { unmount } = render(, { stdout: mockStdout as any, interactive: true }); + + process.stdout.emit('resize'); + + await sleep(200); + + const output = mockStdout.frames.join('\n'); + expect(output).toBeDefined(); + + Object.defineProperty(process.stdout, 'columns', { + value: originalColumns, + writable: true, + configurable: true, + }); + + unmount(); + }); +}); diff --git a/src/docker/containers.ts b/src/docker/containers.ts index 3a403d7..a483d1d 100644 --- a/src/docker/containers.ts +++ b/src/docker/containers.ts @@ -57,3 +57,128 @@ export const getRunningContainers = async (options?: { forceAlert?: boolean }): throw error; } }; + +export interface DockerSystemStats { + /** The aggregated CPU usage percentage. */ + cpu: number; + /** The aggregated memory usage in bytes. */ + memoryUsage: number; + /** The maximum memory limit across running containers or total host memory. */ + memoryLimit: number; +} + +/** + * Formats a byte number to decimal-scaled string (e.g. "1.4GB"). + * @param bytes The raw number of bytes. + * @returns Decimal-formatted bytes string. + */ +export const formatDockerBytes = (bytes: number): string => { + if (bytes <= 0) return '0B'; + const decimalK = 1000; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(decimalK)), sizes.length - 1); + const num = bytes / Math.pow(decimalK, i); + return `${parseFloat(num.toFixed(1))}${sizes[i]}`; +}; + +/** + * Calculates a single container's CPU usage percentage based on its stats and pre-stats. + * @param cpuStats The current CPU stats of the container. + * @param precpuStats The previous CPU stats of the container. + * @returns The calculated CPU usage percentage. + */ +const calculateCpuPercent = (cpuStats: any, precpuStats: any): number => { + if (!cpuStats || !precpuStats || !cpuStats.cpu_usage || !precpuStats.cpu_usage) { + return 0; + } + const cpuDelta = (cpuStats.cpu_usage.total_usage || 0) - (precpuStats.cpu_usage.total_usage || 0); + const systemCpuDelta = (cpuStats.system_cpu_usage || 0) - (precpuStats.system_cpu_usage || 0); + const onlineCpus = cpuStats.online_cpus || cpuStats.cpu_usage.percpu_usage?.length || 1; + + if (systemCpuDelta > 0 && cpuDelta > 0) { + return (cpuDelta / systemCpuDelta) * onlineCpus * 100; + } + return 0; +}; + +/** + * Calculates a single container's Memory usage in bytes subtracting cache memory. + * @param memoryStats The memory stats of the container. + * @returns Calculated memory usage in bytes. + */ +const calculateMemoryUsage = (memoryStats: any): number => { + if (!memoryStats) return 0; + let usage = memoryStats.usage || 0; + const cache = memoryStats.stats?.cache || memoryStats.stats?.inactive_file || 0; + if (usage > cache) { + usage -= cache; + } + return usage; +}; + +/** + * Fetches stats for a single container. + * @param docker The Dockerode client. + * @param containerId The ID of the container. + * @returns Stats containing CPU percentage, memory usage, and memory limit. + */ +const fetchContainerStats = async (docker: any, containerId: string) => { + try { + const container = docker.getContainer(containerId); + const stats = await container.stats({ stream: false }); + return { + cpuPercent: calculateCpuPercent(stats.cpu_stats, stats.precpu_stats), + memoryUsage: calculateMemoryUsage(stats.memory_stats), + limit: stats.memory_stats?.limit || 0, + }; + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + const msg = `Failed to fetch stats for container ${containerId}: ${errMsg}`; + if (typeof (logger as any).debug === 'function') { + (logger as any).debug(msg); + } else { + console.debug(msg); + } + return { cpuPercent: 0, memoryUsage: 0, limit: 0 }; + } +}; + +/** + * Aggregates CPU and Memory resource usage stats for all running Docker containers. + * @returns The aggregated Docker stats, or null if the client fails to connect. + */ +export const getDockerSystemStats = async (): Promise => { + const docker = getDockerClient(); + try { + const containers = await docker.listContainers({ filters: { status: ['running'] } }); + + // Always call docker.info() to obtain MemTotal and use that as memoryLimit + const info = await docker.info().catch(() => ({ MemTotal: 0 })); + const memoryLimit = info.MemTotal || 0; + + if (containers.length === 0) { + return { cpu: 0, memoryUsage: 0, memoryLimit }; + } + + const statsPromises = containers.map(c => fetchContainerStats(docker, c.Id)); + const results = await Promise.all(statsPromises); + + let totalCpu = 0; + let totalMemory = 0; + + for (const res of results) { + totalCpu += res.cpuPercent; + totalMemory += res.memoryUsage; + } + + return { + cpu: totalCpu, + memoryUsage: totalMemory, + memoryLimit, + }; + } catch (error) { + return null; + } +}; + + diff --git a/src/kubernetes/pods.ts b/src/kubernetes/pods.ts index 41b885a..dcbb1da 100644 --- a/src/kubernetes/pods.ts +++ b/src/kubernetes/pods.ts @@ -1,4 +1,4 @@ -import { getK8sApi } from './client'; +import { getK8sApi, getCustomObjectsApi } from './client'; import type * as k8s from '@kubernetes/client-node'; import { triggerAlert } from '../monitor/alerts'; import { logger } from '../utils/logger'; @@ -60,3 +60,282 @@ export const getRunningPods = async (options?: { forceAlert?: boolean }): Promis throw error; } }; + +export interface K8sClusterStats { + /** The aggregated CPU usage/requests representation. */ + cpu: string; + /** The aggregated memory usage/requests representation. */ + memory: string; + /** The source of the statistics. */ + source: 'metrics-server' | 'requests' | 'N/A'; +} + +/** + * Parses a Kubernetes CPU quantity (e.g., "450m", "2", "125000000n") into millicores. + * @param q The CPU quantity representation as a string or number. + * @returns The parsed CPU value in millicores. + */ +export function parseK8sCpuQuantity(q: string | number): number { + if (typeof q === 'number') return q * 1000; + if (!q) return 0; + const match = q.trim().match(/^([0-9.]+)([a-zA-Z]*)$/); + if (!match) return 0; + const val = parseFloat(match[1]); + const suffix = match[2]; + switch (suffix) { + case 'n': + return val / 1000000; // nanocores to millicores + case 'u': + return val / 1000; // microcores to millicores + case 'm': + return val; + case '': + return val * 1000; // cores to millicores + default: + return 0; + } +} + +/** + * Parses a Kubernetes memory quantity (e.g., "1Gi", "512Mi", "2k") into bytes. + * @param q The memory quantity representation as a string or number. + * @returns The parsed memory value in bytes. + */ +export function parseK8sMemoryQuantity(q: string | number): number { + if (typeof q === 'number') return q; + if (!q) return 0; + const match = q.trim().match(/^([0-9.]+)([a-zA-Z]*)$/); + if (!match) return 0; + const val = parseFloat(match[1]); + const suffix = match[2]; + + if (suffix === '') { + return val; + } + + const binaryPower: Record = { + Ki: 1024, + Mi: 1024 * 1024, + Gi: 1024 * 1024 * 1024, + Ti: 1024 * 1024 * 1024 * 1024, + Pi: 1024 * 1024 * 1024 * 1024 * 1024, + }; + + const decimalPower: Record = { + k: 1000, + M: 1000 * 1000, + G: 1000 * 1000 * 1000, + T: 1000 * 1000 * 1000 * 1000, + P: 1000 * 1000 * 1000 * 1000 * 1000, + }; + + if (suffix in binaryPower) { + return val * binaryPower[suffix]; + } + if (suffix in decimalPower) { + return val * decimalPower[suffix]; + } + return 0; +} + +/** + * Formats a byte number to binary-scaled string (e.g. "2GiB"). + * @param bytes The raw number of bytes. + * @returns Binary-formatted bytes string. + */ +export const formatK8sBytes = (bytes: number): string => { + if (bytes <= 0) return '0B'; + const k = 1024; + const sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); + const num = bytes / Math.pow(k, i); + return `${parseFloat(num.toFixed(1))}${sizes[i]}`; +}; + +/** + * Sums up CPU and Memory usage from metrics-server node items. + * @param items Node metrics items from custom API. + * @returns Aggregated CPU in millicores and memory in bytes. + */ +const sumNodeMetrics = (items: any[]) => { + let cpu = 0; + let memory = 0; + for (const item of items) { + cpu += parseK8sCpuQuantity(item.usage?.cpu || '0'); + memory += parseK8sMemoryQuantity(item.usage?.memory || '0'); + } + return { cpu, memory }; +}; + +/** + * Sums up CPU and Memory usage from metrics-server pod items. + * @param items Pod metrics items from custom API. + * @returns Aggregated CPU in millicores and memory in bytes. + */ +const sumPodMetrics = (items: any[]) => { + let cpu = 0; + let memory = 0; + for (const item of items) { + const containers = item.containers || []; + for (const container of containers) { + cpu += parseK8sCpuQuantity(container.usage?.cpu || '0'); + memory += parseK8sMemoryQuantity(container.usage?.memory || '0'); + } + } + return { cpu, memory }; +}; + +/** + * Checks if a pod's status phase should be considered for native requests fallback. + * @param phase The pod phase status string. + * @returns True if the pod is active, false otherwise. + */ +const isActivePodPhase = (phase?: string): boolean => { + return phase === 'Running' || phase === 'Pending'; +}; + +/** + * Sums container requests for a single container. + * @param container The Kubernetes container spec. + * @returns Object with parsed CPU and Memory requests. + */ +const getContainerRequests = (container: any) => { + const reqs = container.resources?.requests; + return { + cpu: reqs?.cpu ? parseK8sCpuQuantity(reqs.cpu) : 0, + memory: reqs?.memory ? parseK8sMemoryQuantity(reqs.memory) : 0, + }; +}; + +/** + * Sums up requests for a single pod's containers. + * @param pod The Kubernetes pod object. + * @returns Object with parsed CPU and Memory requests. + */ +const sumPodRequests = (pod: any) => { + let cpu = 0; + let memory = 0; + const containers = pod.spec?.containers || []; + + for (const container of containers) { + const req = getContainerRequests(container); + cpu += req.cpu; + memory += req.memory; + } + return { cpu, memory }; +}; + +/** + * Sums up resource requests from a list of pods. + * @param pods List of pods to aggregate requests from. + * @returns Aggregated CPU requests in millicores and memory requests in bytes. + */ +const sumAllPodsRequests = (pods: any[]) => { + let cpu = 0; + let memory = 0; + for (const pod of pods) { + if (isActivePodPhase(pod.status?.phase)) { + const podReq = sumPodRequests(pod); + cpu += podReq.cpu; + memory += podReq.memory; + } + } + return { cpu, memory }; +}; + +/** + * Fetches metrics-server node stats. + * @returns Node metrics, or null if it fails or returns no nodes. + */ +const fetchNodeMetrics = async () => { + try { + const customApi = getCustomObjectsApi(); + const res = await customApi.listClusterCustomObject({ + group: 'metrics.k8s.io', + version: 'v1beta1', + plural: 'nodes', + }); + const items = (res as any)?.items || []; + return items.length > 0 ? sumNodeMetrics(items) : null; + } catch { + return null; + } +}; + +/** + * Fetches metrics-server pod stats. + * @returns Pod metrics, or null if it fails or returns no pods. + */ +const fetchPodMetrics = async () => { + try { + const customApi = getCustomObjectsApi(); + const res = await customApi.listClusterCustomObject({ + group: 'metrics.k8s.io', + version: 'v1beta1', + plural: 'pods', + }); + const items = (res as any)?.items || []; + return items.length > 0 ? sumPodMetrics(items) : null; + } catch { + return null; + } +}; + +/** + * Fetches native pod resource requests sum. + * @returns Native pod resource requests, or null if it fails or has no metrics. + */ +const fetchNativeRequests = async () => { + try { + const api = getK8sApi(); + const res = await api.listPodForAllNamespaces(); + const pods = res.items || []; + const stats = sumAllPodsRequests(pods); + return (stats.cpu > 0 || stats.memory > 0) ? stats : null; + } catch { + return null; + } +}; + +/** + * Fetches the aggregated CPU and Memory statistics for the Kubernetes cluster, + * checking metrics-server node metrics, metrics-server pod metrics, or falling + * back to native pod requests sum. + * @returns Kubernetes cluster stats. + */ +export const getK8sClusterStats = async (): Promise => { + const nodeStats = await fetchNodeMetrics(); + if (nodeStats) { + return { + cpu: `${Math.round(nodeStats.cpu)}m`, + memory: formatK8sBytes(nodeStats.memory), + source: 'metrics-server', + }; + } + + const podStats = await fetchPodMetrics(); + if (podStats) { + return { + cpu: `${Math.round(podStats.cpu)}m`, + memory: formatK8sBytes(podStats.memory), + source: 'metrics-server', + }; + } + + const nativeRequests = await fetchNativeRequests(); + if (nativeRequests) { + return { + cpu: `${Math.round(nativeRequests.cpu)}m`, + memory: formatK8sBytes(nativeRequests.memory), + source: 'requests', + }; + } + + return { + cpu: 'N/A', + memory: 'N/A', + source: 'N/A', + }; +}; + + diff --git a/src/server/server.ts b/src/server/server.ts index 80f96c0..8c48161 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -127,11 +127,13 @@ export const routeRequest = (req: any, res: any, options: ServerOptions): void = }; if (method === 'GET' && url in getHandlers) { - return getHandlers[url](res); + getHandlers[url](res); + return; } if (method === 'POST' && url === '/analyze') { - return handleAnalyze(req, res, options); + handleAnalyze(req, res, options); + return; } sendJson(res, 404, { error: 'Not found' }); diff --git a/src/ui/WatchDashboard.tsx b/src/ui/WatchDashboard.tsx index faf666f..86608ef 100644 --- a/src/ui/WatchDashboard.tsx +++ b/src/ui/WatchDashboard.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; -import { getRunningPods, PodData } from '../kubernetes/pods'; -import { getRunningContainers, ContainerData } from '../docker/containers'; +import { getRunningPods, PodData, getK8sClusterStats, K8sClusterStats } from '../kubernetes/pods'; +import { getRunningContainers, ContainerData, getDockerSystemStats, DockerSystemStats, formatDockerBytes } from '../docker/containers'; const StatusBadge = ({ status, type }: { status: string, type: 'pod' | 'container' }) => { const isRunning = type === 'pod' ? status === 'Running' : status === 'running'; @@ -20,6 +20,8 @@ const StatusBadge = ({ status, type }: { status: string, type: 'pod' | 'containe export const WatchDashboard = () => { const [pods, setPods] = useState([]); const [containers, setContainers] = useState([]); + const [k8sStats, setK8sStats] = useState(null); + const [dockerStats, setDockerStats] = useState(null); const [error, setError] = useState<{ type: string; message: string } | null>(null); useEffect(() => { @@ -43,9 +45,29 @@ export const WatchDashboard = () => { } }; + const fetchK8sStats = async () => { + try { + const stats = await getK8sClusterStats(); + setK8sStats(stats); + } catch (err) { + setK8sStats(null); + } + }; + + const fetchDockerStats = async () => { + try { + const stats = await getDockerSystemStats(); + setDockerStats(stats); + } catch (err) { + setDockerStats(null); + } + }; + const fetchData = () => { fetchPods(); fetchContainers(); + fetchK8sStats(); + fetchDockerStats(); }; fetchData(); @@ -75,6 +97,13 @@ export const WatchDashboard = () => { Kubernetes Pods ({pods.length}) + + + {k8sStats + ? `${k8sStats.source === 'requests' ? 'k8s Requests' : 'k8s Stats'}: CPU: ${k8sStats.cpu} | Mem: ${k8sStats.memory}` + : 'k8s Stats: CPU: N/A | Mem: N/A'} + + {pods.length === 0 && !error?.type.includes('k8s') ? ( No pods found. ) : ( @@ -91,6 +120,13 @@ export const WatchDashboard = () => { Docker Containers ({containers.length}) + + + {dockerStats + ? `Docker Stats: CPU: ${dockerStats.cpu.toFixed(1)}% | Mem: ${formatDockerBytes(dockerStats.memoryUsage)} / ${formatDockerBytes(dockerStats.memoryLimit)}` + : 'Docker Stats: CPU: N/A | Mem: N/A'} + + {containers.length === 0 && !error?.type.includes('docker') ? ( No containers found. ) : (