Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 51 additions & 0 deletions src/cli/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>;
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();
}
});
});
97 changes: 97 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -241,6 +251,49 @@ function printPageHint<T>(
}
}

async function collectMachineComparison(machineId: string): Promise<MachineComparisonRow> {
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 },
Expand Down Expand Up @@ -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 <n>", "Number of machines to show", parseLimitOption, DEFAULT_LIST_LIMIT)
.option("--cursor <n>", "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
Expand Down
Loading