diff --git a/src/__tests__/health.test.ts b/src/__tests__/health.test.ts index 38f80c6..5ee784b 100644 --- a/src/__tests__/health.test.ts +++ b/src/__tests__/health.test.ts @@ -42,7 +42,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Command } from 'commander'; -import { registerHealthCommand } from '../commands/health'; +import { registerHealthCommand, showHealth } from '../commands/health'; import { getRunningContainers } from '../docker/containers'; import { getRunningPods } from '../kubernetes/pods'; import { logger } from '../utils/logger'; @@ -192,4 +192,96 @@ describe('health command', () => { }), ); }); + + it('should register watch and interval options', () => { + const healthCmd = program.commands.find((c) => c.name() === 'health'); + const watchOption = healthCmd?.options.find((o) => o.short === '-w'); + const intervalOption = healthCmd?.options.find((o) => o.short === '-i'); + expect(watchOption).toBeDefined(); + expect(intervalOption).toBeDefined(); + }); + + it('should reject non-integer and malformed intervals strictly', async () => { + await program.parseAsync(['node', 'test', 'health', 'all', '--watch', '--interval', '-1']); + expect(logger.error).toHaveBeenLastCalledWith( + expect.stringContaining('Invalid interval'), + ); + + await program.parseAsync(['node', 'test', 'health', 'all', '--watch', '--interval', '3.5']); + expect(logger.error).toHaveBeenLastCalledWith( + expect.stringContaining('Invalid interval'), + ); + + await program.parseAsync(['node', 'test', 'health', 'all', '--watch', '--interval', '3abc']); + expect(logger.error).toHaveBeenLastCalledWith( + expect.stringContaining('Invalid interval'), + ); + }); + + it('should poll health status periodically in watch mode', async () => { + vi.useFakeTimers(); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + vi.mocked(getRunningContainers).mockResolvedValue([ + { id: '1', name: 'web', image: 'nginx', state: 'running', status: 'Up 2 hours' }, + ]); + vi.mocked(getRunningPods).mockResolvedValue([]); + + // Parse to run the watch mode command + await program.parseAsync(['node', 'test', 'health', 'all', '--watch', '--interval', '3']); + + expect(tableUtils.renderTable).toHaveBeenCalledTimes(1); + expect(writeSpy).toHaveBeenCalledWith('\x1Bc'); + + // Change mock status to verify the next iteration + vi.mocked(getRunningContainers).mockResolvedValue([ + { id: '1', name: 'web', image: 'nginx', state: 'exited', status: 'Exited' }, + ]); + + // Advance timer by 3 seconds (3000ms) + await vi.advanceTimersByTimeAsync(3000); + + expect(tableUtils.renderTable).toHaveBeenCalledTimes(2); + expect(tableUtils.renderTable).toHaveBeenLastCalledWith( + expect.objectContaining({ + rows: expect.arrayContaining([ + expect.arrayContaining(['container', 'web', expect.stringContaining('exited')]), + ]), + }), + ); + + // Clean up + writeSpy.mockRestore(); + vi.useRealTimers(); + }); + + it('should stop watch loop when AbortSignal is aborted', async () => { + vi.useFakeTimers(); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + vi.mocked(getRunningContainers).mockResolvedValue([ + { id: '1', name: 'web', image: 'nginx', state: 'running', status: 'Up 2 hours' }, + ]); + vi.mocked(getRunningPods).mockResolvedValue([]); + + const controller = new AbortController(); + + // Call showHealth directly with the signal and await the first poll tick + await showHealth('all', { watch: true, interval: '3', signal: controller.signal }); + + expect(tableUtils.renderTable).toHaveBeenCalledTimes(1); + + // Now abort the controller + controller.abort(); + + // Advance timers by 3 seconds + await vi.advanceTimersByTimeAsync(3000); + + // It should NOT call renderTable again because it was aborted + expect(tableUtils.renderTable).toHaveBeenCalledTimes(1); + + // Clean up + writeSpy.mockRestore(); + vi.useRealTimers(); + }); }); \ No newline at end of file diff --git a/src/commands/health.ts b/src/commands/health.ts index 49695c9..c49b871 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -32,6 +32,12 @@ import { logger } from '../utils/logger'; import { createSpinner } from '../ui/spinner'; import { renderTable } from '../ui/table'; +export interface HealthOptions { + watch?: boolean; + interval?: string; + signal?: AbortSignal; +} + const healthColor = (status: string): string => { if (status === 'healthy' || status === 'running' || status === 'Running') { return chalk.green(status); @@ -42,66 +48,151 @@ const healthColor = (status: string): string => { return chalk.yellow(status); }; -export const showHealth = async (target: string): Promise => { - logger.info?.(`Showing health for ${target}...`); - - const validTargets = ['all', 'containers', 'pods']; - if (!validTargets.includes(target)) { - logger.error?.( - `Unknown target: ${target}. Valid targets are: ${validTargets.join(', ')}.`, - ); - process.exitCode = 1; - return; - } - - const spinner = createSpinner(`Checking ${target} health...`).start(); +const fetchHealthRows = async (target: string): Promise<(string | number)[][]> => { const rows: (string | number)[][] = []; + const shouldFetchContainers = target === 'all' || target === 'containers'; + const shouldFetchPods = target === 'all' || target === 'pods'; + + const [containerResult, podResult] = await Promise.allSettled([ + shouldFetchContainers ? getRunningContainers() : Promise.resolve([]), + shouldFetchPods ? getRunningPods() : Promise.resolve([]), + ]); - if (target === 'all' || target === 'containers') { - try { - const containers = await getRunningContainers(); + if (shouldFetchContainers) { + if (containerResult.status === 'fulfilled') { rows.push( - ...containers.map((container) => [ + ...containerResult.value.map((container) => [ 'container', container.name, healthColor(container.state), container.status, ]), ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); + } else { + const message = containerResult.reason instanceof Error + ? containerResult.reason.message + : String(containerResult.reason); logger.warn?.(`Docker unavailable: ${message}`); } } - if (target === 'all' || target === 'pods') { - try { - const pods = await getRunningPods(); + if (shouldFetchPods) { + if (podResult.status === 'fulfilled') { rows.push( - ...pods.map((pod) => [ + ...podResult.value.map((pod) => [ 'pod', pod.name, healthColor(pod.status), `namespace: ${pod.namespace}, restarts: ${pod.restarts}`, ]), ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); + } else { + const message = podResult.reason instanceof Error + ? podResult.reason.message + : String(podResult.reason); logger.warn?.(`Kubernetes unavailable: ${message}`); } } - spinner.stop(); + return rows; +}; + +export const showHealth = async (target: string, options: HealthOptions = {}): Promise => { + logger.info?.(`Showing health for ${target}...`); - if (rows.length === 0) { - logger.warn?.(`No ${target === 'all' ? 'workloads' : target} found.`); + const validTargets = ['all', 'containers', 'pods']; + if (!validTargets.includes(target)) { + logger.error?.( + `Unknown target: ${target}. Valid targets are: ${validTargets.join(', ')}.`, + ); + process.exitCode = 1; return; } - renderTable({ - head: ['TYPE', 'NAME', 'HEALTH', 'DETAILS'], - rows, - }); + if (options.watch) { + const intervalStr = options.interval || '5'; + if (!/^\d+$/.test(intervalStr)) { + logger.error?.('Invalid interval. Please provide a positive integer number of seconds.'); + process.exitCode = 1; + return; + } + const intervalSeconds = parseInt(intervalStr, 10); + if (intervalSeconds <= 0) { + logger.error?.('Invalid interval. Please provide a positive integer number of seconds.'); + process.exitCode = 1; + return; + } + + let isRunning = true; + let timer: NodeJS.Timeout | undefined; + + const cleanup = () => { + isRunning = false; + if (timer) { + clearTimeout(timer); + } + if (options.signal) { + options.signal.removeEventListener('abort', cleanup); + } + }; + + if (options.signal) { + if (options.signal.aborted) { + cleanup(); + return; + } + options.signal.addEventListener('abort', cleanup); + } + + const poll = async () => { + if (!isRunning || (options.signal && options.signal.aborted)) { + cleanup(); + return; + } + + const rows = await fetchHealthRows(target); + + // Clear terminal screen + process.stdout.write('\x1Bc'); + + const timestamp = new Date().toLocaleTimeString(); + logger.info?.( + chalk.bold.cyan(`[KDM Health] Target: ${target} | Last updated: ${timestamp} (Interval: ${intervalSeconds}s)`) + ); + logger.info?.(chalk.dim('Press Ctrl+C to exit\n')); + + if (rows.length === 0) { + logger.warn?.(`No ${target === 'all' ? 'workloads' : target} found.`); + } else { + renderTable({ + head: ['TYPE', 'NAME', 'HEALTH', 'DETAILS'], + rows, + }); + } + + if (isRunning && (!options.signal || !options.signal.aborted)) { + timer = setTimeout(poll, intervalSeconds * 1000); + } else { + cleanup(); + } + }; + + await poll(); + } else { + const spinner = createSpinner(`Checking ${target} health...`).start(); + const rows = await fetchHealthRows(target); + spinner.stop(); + + if (rows.length === 0) { + logger.warn?.(`No ${target === 'all' ? 'workloads' : target} found.`); + return; + } + + renderTable({ + head: ['TYPE', 'NAME', 'HEALTH', 'DETAILS'], + rows, + }); + } }; export const registerHealthCommand = (program: Command): void => { @@ -111,5 +202,26 @@ export const registerHealthCommand = (program: Command): void => { 'Show health status for pods, containers, or all workloads.\n' + 'Valid targets: all | containers | pods', ) - .action(showHealth); + .option('-w, --watch', 'Watch mode: continuously refresh health output') + .option('-i, --interval ', 'Refresh interval in seconds', '5') + .action(async (target, options) => { + if (options.watch) { + const controller = new AbortController(); + const sigintHandler = () => { + controller.abort(); + process.exit(0); + }; + process.once('SIGINT', sigintHandler); + process.once('SIGTERM', sigintHandler); + + try { + await showHealth(target, { ...options, signal: controller.signal }); + } finally { + process.off('SIGINT', sigintHandler); + process.off('SIGTERM', sigintHandler); + } + } else { + await showHealth(target, options); + } + }); }; \ No newline at end of file