From cf1fd8b4886d94964687ccd9ce03e9a1dc8c68ed Mon Sep 17 00:00:00 2001 From: mzl2233 Date: Thu, 14 May 2026 08:20:35 +0000 Subject: [PATCH] Implement health and logs commands --- src/__tests__/health.test.ts | 41 ++++++++++++++++++-- src/commands/health.ts | 74 ++++++++++++++++++++++++++++++++++-- src/commands/logs.ts | 59 ++++++++++++++++++++++++++-- 3 files changed, 164 insertions(+), 10 deletions(-) diff --git a/src/__tests__/health.test.ts b/src/__tests__/health.test.ts index 0c524bd..eb88a45 100644 --- a/src/__tests__/health.test.ts +++ b/src/__tests__/health.test.ts @@ -1,13 +1,32 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Command } from 'commander'; import { registerHealthCommand } from '../commands/health'; +import { getRunningContainers } from '../docker/containers'; +import { getRunningPods } from '../kubernetes/pods'; import { logger } from '../utils/logger'; +import * as tableUtils from '../ui/table'; +vi.mock('../docker/containers', () => ({ + getRunningContainers: vi.fn(), +})); +vi.mock('../kubernetes/pods', () => ({ + getRunningPods: vi.fn(), +})); vi.mock('../utils/logger', () => ({ logger: { - info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), }, })); +vi.mock('../ui/spinner', () => ({ + createSpinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); +vi.mock('../ui/table', () => ({ + renderTable: vi.fn(), +})); describe('health command', () => { let program: Command; @@ -23,8 +42,24 @@ describe('health command', () => { expect(healthCmd).toBeDefined(); }); - it('should call logger.info on health ', async () => { + it('should render health for all workloads', async () => { + (getRunningContainers as any).mockResolvedValue([{ name: 'web', state: 'running', status: 'Up' }]); + (getRunningPods as any).mockResolvedValue([{ name: 'api', namespace: 'default', status: 'Running', restarts: 0 }]); + await program.parseAsync(['node', 'test', 'health', 'all']); - expect(logger.info).toHaveBeenCalledWith('Showing health for all...'); + + expect(tableUtils.renderTable).toHaveBeenCalledWith(expect.objectContaining({ + head: ['TYPE', 'NAME', 'HEALTH', 'DETAILS'], + rows: expect.arrayContaining([ + expect.arrayContaining(['container', 'web']), + expect.arrayContaining(['pod', 'api']), + ]), + })); + }); + + it('should reject unknown health targets', async () => { + await program.parseAsync(['node', 'test', 'health', 'bad-target']); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Unknown target')); }); }); diff --git a/src/commands/health.ts b/src/commands/health.ts index 2a5f297..634ffbf 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,11 +1,77 @@ import { Command } from 'commander'; +import chalk from 'chalk'; +import { getRunningContainers } from '../docker/containers'; +import { getRunningPods } from '../kubernetes/pods'; import { logger } from '../utils/logger'; +import { createSpinner } from '../ui/spinner'; +import { renderTable } from '../ui/table'; + +const healthColor = (status: string) => { + if (status === 'healthy' || status === 'running' || status === 'Running') { + return chalk.green(status); + } + + if (status === 'unhealthy' || status === 'exited' || status === 'Failed') { + return chalk.red(status); + } + + return chalk.yellow(status); +}; + +export const showHealth = async (target: string) => { + logger.info?.(`Showing health for ${target}...`); + + if (target !== 'all' && target !== 'containers' && target !== 'pods') { + logger.error?.(`Unknown target: ${target}. Valid targets are: all, pods, containers.`); + return; + } + + const spinner = createSpinner(`Checking ${target} health...`).start(); + + try { + const rows: (string | number)[][] = []; + + if (target === 'all' || target === 'containers') { + const containers = await getRunningContainers(); + rows.push(...containers.map((container) => [ + 'container', + container.name, + healthColor(container.state), + container.status, + ])); + } + + if (target === 'all' || target === 'pods') { + const pods = await getRunningPods(); + rows.push(...pods.map((pod) => [ + 'pod', + pod.name, + healthColor(pod.status), + `namespace: ${pod.namespace}, restarts: ${pod.restarts}`, + ])); + } + + spinner.stop(); + + if (rows.length === 0) { + logger.warn(`No ${target === 'all' ? 'workloads' : target} found.`); + return; + } + + renderTable({ + head: ['TYPE', 'NAME', 'HEALTH', 'DETAILS'], + rows, + }); + } catch (error) { + spinner.stop(); + const message = error instanceof Error ? error.message : String(error); + logger.error(`Failed to check ${target} health: ${message}`); + } +}; export const registerHealthCommand = (program: Command) => { program .command('health ') - .description('Show health status for pods or containers') - .action((target) => { - logger.info(`Showing health for ${target}...`); - }); + .description('Show health status for pods, containers, or all workloads') + .action(showHealth); }; diff --git a/src/commands/logs.ts b/src/commands/logs.ts index 172e585..f57e734 100644 --- a/src/commands/logs.ts +++ b/src/commands/logs.ts @@ -1,11 +1,64 @@ import { Command } from 'commander'; +import { getDockerClient } from '../docker/client'; +import { getK8sApi } from '../kubernetes/client'; import { logger } from '../utils/logger'; +import { createSpinner } from '../ui/spinner'; + +const printStream = (value: unknown) => { + if (Buffer.isBuffer(value)) { + process.stdout.write(value.toString()); + return; + } + + process.stdout.write(String(value)); +}; + +export const showLogs = async (name: string) => { + logger.info?.(`Showing logs for ${name}...`); + const spinner = createSpinner(`Fetching logs for ${name}...`).start(); + + try { + const docker = getDockerClient(); + const containers = await docker.listContainers({ all: true }); + const match = containers.find((container) => + container.Id.startsWith(name) || + container.Names.some((containerName) => containerName.replace(/^\//, '') === name) + ); + + if (match) { + const output = await docker.getContainer(match.Id).logs({ stdout: true, stderr: true, tail: 100 }); + spinner.stop(); + printStream(output); + return; + } + } catch { + // Fall through to Kubernetes logs when Docker is unavailable or has no match. + } + + try { + const api = getK8sApi(); + const pods = await api.listPodForAllNamespaces(); + const pod = pods.body.items.find((item) => item.metadata?.name === name); + + if (!pod?.metadata?.name || !pod.metadata.namespace) { + spinner.stop(); + logger.error?.(`No container or pod named ${name} found.`); + return; + } + + const response = await api.readNamespacedPodLog(pod.metadata.name, pod.metadata.namespace, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, 100); + spinner.stop(); + printStream(response.body); + } catch (error) { + spinner.stop(); + const message = error instanceof Error ? error.message : String(error); + logger.error?.(`Failed to fetch logs for ${name}: ${message}`); + } +}; export const registerLogsCommand = (program: Command) => { program .command('logs ') .description('Show logs for a container or pod') - .action((name) => { - logger.info(`Showing logs for ${name}...`); - }); + .action(showLogs); };