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.
) : (