From d312bcf09f1b3fb1c41e627bfbd05ded1f028c51 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 30 Jul 2026 19:03:12 +0300 Subject: [PATCH] feat: OPE46-00045: QoL: Add compare command for multi-machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OPE46-00045: QoL: Add compare command for multi-machine Add `monitor compare [machines...]` to compare metrics across multiple machines side-by-side. MEASURED on origin/main: `monitor compare-apps` exists (src/cli/index.ts:1051) but there is NO general `monitor compare`. Add `monitor compare [machines...]` showing CPU/memory/disk side by side for the named machines (default: all configured), with -j/--json emitting one row per machine. EXECUTION NOTES (added 2026-07-29 for autonomous execution) Repo: github.com/hasna/monitor. CLI commands are registered in src/cli/index.ts (Commander). Verified against origin/main that this surface does NOT already exist. Requirements: implement in src/ (not docs); keep existing output shapes and the -j/--json contract intact; reuse the existing helpers in that file (parseLimitOption, parseCursorOption, chalk formatting) rather than inventing new patterns. Acceptance: `bun run typecheck`, `bun run build` and `bun test` are all green, and a new/extended test under src/ exercises the new behaviour (assert on the command's JSON output and/or exit code, not on colored text). No network, no live remote machines, no credentials — tests must run offline against local collectors/fixtures. X-Factory-Run: run_4491bcb2d83e X-Factory-Task: 9ca20376-ad21-4335-9568-6220578e2f6e --- package.json | 1 + src/cli/index.test.ts | 51 +++++++++++++++++++++++ src/cli/index.ts | 97 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 src/cli/index.test.ts diff --git a/package.json b/package.json index 2c328aa..c36e665 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "prepack": "bun run scan:artifact", "postinstall": "mkdir -p $HOME/.hasna/monitor 2>/dev/null || true", "test": "bun test", + "typecheck": "tsc --noEmit", "lint": "bunx tsc --noEmit", "monitor": "bun run ./bins/monitor.ts", "monitor-mcp": "bun run ./bins/monitor-mcp.ts", diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts new file mode 100644 index 0000000..5dfc2b3 --- /dev/null +++ b/src/cli/index.test.ts @@ -0,0 +1,51 @@ +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"; + +let configDir: string | undefined; + +afterEach(() => { + if (configDir) { + rmSync(configDir, { recursive: true, force: true }); + configDir = undefined; + } +}); + +describe("monitor compare", () => { + it("emits one JSON row per configured machine", () => { + configDir = mkdtempSync(join(tmpdir(), "monitor-compare-")); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify({ + 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(); + } + }); +}); diff --git a/src/cli/index.ts b/src/cli/index.ts index 238bfa3..fc0ba38 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -74,6 +74,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 { @@ -241,6 +251,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 }, @@ -410,6 +463,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 : 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