diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 4fc3459..4255bad 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1,9 +1,20 @@ -import { describe, expect, it } from "bun:test"; -import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; +import { spawnSync } from "node:child_process"; import type { SystemSnapshot } from "../collectors/local.js"; import { formatCompactStatus } from "./index.js"; +let configDir: string | undefined; + +afterEach(() => { + if (configDir) { + rmSync(configDir, { recursive: true, force: true }); + configDir = undefined; + } +}); + function makeSnapshot(overrides: Partial = {}): SystemSnapshot { return { machineId: "local", @@ -55,6 +66,11 @@ function stripAnsi(value: string): string { return value.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""); } +function writeConfig(config: unknown): void { + configDir = mkdtempSync(join(tmpdir(), "monitor-cli-")); + writeFileSync(join(configDir, "config.json"), JSON.stringify(config)); +} + describe("monitor status --compact", () => { it("formats a stable single-line summary using the root disk", () => { const output = stripAnsi(formatCompactStatus(makeSnapshot())); @@ -82,3 +98,58 @@ describe("monitor status --compact", () => { expect(child.stdout).not.toContain("\u001B"); }); }); + +describe("monitor compare", () => { + it("emits one JSON row per configured machine", () => { + writeConfig({ + machines: [ + { id: "local-a", label: "Local A", type: "local" }, + { id: "local-b", label: "Local B", type: "local" }, + ], + }); + + const result = spawnSync( + process.execPath, + ["run", "./bins/monitor.ts", "compare", "--json"], + { + cwd: process.cwd(), + env: { ...process.env, MONITOR_CONFIG_DIR: configDir }, + encoding: "utf-8", + timeout: 30_000, + } + ); + + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout) as Array>; + expect(rows).toHaveLength(2); + expect(rows.map((row) => row.machineId)).toEqual(["local-a", "local-b"]); + for (const row of rows) { + expect(typeof row.cpuPercent).toBe("number"); + expect(typeof row.memPercent).toBe("number"); + expect(row.diskPercent === null || typeof row.diskPercent === "number").toBe(true); + expect(row.error).toBeNull(); + } + }); + + it("resolves explicit machine aliases", () => { + writeConfig({ + machines: [{ id: "local-a", label: "Local A", type: "local" }], + aliases: { prod: "local-a" }, + }); + + const result = spawnSync( + process.execPath, + ["run", "./bins/monitor.ts", "compare", "prod", "--json"], + { + cwd: process.cwd(), + env: { ...process.env, MONITOR_CONFIG_DIR: configDir }, + encoding: "utf-8", + timeout: 30_000, + } + ); + + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout) as Array>; + expect(rows.map((row) => row.machineId)).toEqual(["local-a"]); + }); +}); diff --git a/src/cli/index.ts b/src/cli/index.ts index 9f4cf96..d619c39 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -75,6 +75,16 @@ type MachineListItem = { host?: string | null; }; +type MachineComparisonRow = { + machineId: string; + hostname: string | null; + cpuPercent: number | null; + memPercent: number | null; + diskPercent: number | null; + diskMount: string | null; + error: string | null; +}; + // ── Unicode progress bar ─────────────────────────────────────────────────────── function progressBar(pct: number, width = 20): string { @@ -293,6 +303,49 @@ function printPageHint( } } +async function collectMachineComparison(machineId: string): Promise { + try { + const result = await getCollectorForMachine(machineId).collect(); + if (!result.ok) { + return { + machineId, + hostname: null, + cpuPercent: null, + memPercent: null, + diskPercent: null, + diskMount: null, + error: result.error, + }; + } + + const disk = result.snapshot.disks.reduce<(typeof result.snapshot.disks)[number] | null>( + (highest, candidate) => + !highest || candidate.usagePercent > highest.usagePercent ? candidate : highest, + null + ); + + return { + machineId: result.snapshot.machineId, + hostname: result.snapshot.hostname, + cpuPercent: result.snapshot.cpu.usagePercent, + memPercent: result.snapshot.mem.usagePercent, + diskPercent: disk?.usagePercent ?? null, + diskMount: disk?.mount ?? null, + error: null, + }; + } catch (error) { + return { + machineId, + hostname: null, + cpuPercent: null, + memPercent: null, + diskPercent: null, + diskMount: null, + error: error instanceof Error ? error.message : String(error), + }; + } +} + async function renderInstalledApps( machineArg: string | undefined, opts: { all?: boolean; compare?: boolean; json?: boolean; limit?: number; cursor?: number; verbose?: boolean }, @@ -468,6 +521,50 @@ program console.log(); }); +// ── monitor compare [machines...] ──────────────────────────────────────────── + +program + .command("compare [machines...]") + .description("Compare CPU, memory, and disk usage across machines") + .option("-n, --limit ", "Number of machines to show", parseLimitOption, DEFAULT_LIST_LIMIT) + .option("--cursor ", "Zero-based row offset for the next page", parseCursorOption, 0) + .option("-j, --json", "Output raw JSON") + .action(async (machineArgs: string[], opts) => { + const machineIds = machineArgs.length > 0 ? machineArgs.map(resolveMachineId) : listKnownMachineIds(); + const rows = await Promise.all(machineIds.map(collectMachineComparison)); + + if (opts.json) { + console.log(JSON.stringify(rows, null, 2)); + return; + } + + console.log(); + console.log( + chalk.dim( + ` ${"MACHINE".padEnd(24)} ${"CPU".padStart(7)} ${"MEMORY".padStart(7)} ${"DISK".padStart(7)} MOUNT` + ) + ); + const page = pageItems(rows, { + limit: opts.limit, + cursor: opts.cursor, + defaultLimit: DEFAULT_LIST_LIMIT, + }); + + for (const row of page.items) { + const machine = truncateText(row.machineId, 24).padEnd(24); + if (row.error) { + console.log(` ${machine} ${chalk.red(row.error)}`); + continue; + } + + console.log( + ` ${machine} ${formatPct(row.cpuPercent!)} ${formatPct(row.memPercent!)} ${row.diskPercent === null ? chalk.dim(" - ") : formatPct(row.diskPercent)} ${row.diskMount ?? "-"}` + ); + } + printPageHint(page, "Use --limit, --cursor, or --json for more machines."); + console.log(); + }); + // ── monitor health ──────────────────────────────────────────────────────────── program