feat: ship host metrics as a first-party in-process plugin

This commit is contained in:
LukeGus committed 2026-09-21 11:30:46 -05:00
1 parent a2479c19b0
commit 19b70430a1
145 files changed
+675 -349

No files matched your search

@@ -136,3 +136,53 @@ describe("bundled docker plugin", () => {
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled host-metrics plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "host-metrics");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("host-metrics"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("host-metrics");
expect(manifest?.category).toBe("Monitoring");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("host-metrics"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("host-metrics")).toBe(true);
expect(runsInProcess("host-metrics", manifest!.permissions)).toBe(true);
});
it("contributes the host-metrics tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("host-metrics"));
const tab = manifest?.contributes?.tabs?.[0];
// The shell keys tab content off this id, so it has to stay "host-metrics".
expect(tab?.id).toBe("host-metrics");
expect(tab?.openFrom).toContain("host-context-menu");
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"host-metrics",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
+1 -1
View File
@@ -137,7 +137,7 @@ describe("hosts publishers route through the bus", () => {
it("notifyAutomationStatus and notifyAutomationMetrics emit their topics", async () => {
const { notifyAutomationStatus, notifyAutomationMetrics } =
await import("../../hosts/metrics/automation-bridge.js");
await import("../../../../plugins/host-metrics/backend/automation-bridge.js");
const status = vi.fn();
const metrics = vi.fn();
+20 -2
View File
@@ -39,16 +39,18 @@ function manifest(overrides: Record<string, unknown> = {}) {
}
describe("first-party allowlist", () => {
it("contains only ssh-terminal and docker", () => {
it("contains only ssh-terminal, docker and host-metrics", () => {
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"docker",
"host-metrics",
"ssh-terminal",
]);
});
it("recognises ssh-terminal and docker and nothing else", () => {
it("recognises ssh-terminal, docker and host-metrics and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("host-metrics")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false);
@@ -61,11 +63,15 @@ describe("runsInProcess", () => {
true,
);
expect(runsInProcess("docker", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("host-metrics", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
// On the list but not asking for it: stays in a worker, so the manifest
// remains an honest description of what the plugin does.
expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false);
expect(runsInProcess("docker", ["hosts.read"])).toBe(false);
expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false);
// Asking for it but not on the list.
expect(
@@ -121,6 +127,18 @@ describe("manifest gate on the reserved capability", () => {
expect(parsed?.id).toBe("docker");
});
it("allows it for host-metrics", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "host-metrics",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("host-metrics");
});
it("still accepts an ordinary manifest that never mentions it", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: ["hosts.read"] }),
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* Once the alert rules have been migrated into automations, both systems hold
* a copy of every rule. If the old engine kept evaluating, every alert would
* be delivered twice.
*/
const listEnabledRulesForHost = vi.fn();
const listEnabledRulesForHostUser = vi.fn();
const createFiring = vi.fn();
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentAlertRepository: () => ({
listEnabledRulesForHost,
listEnabledRulesForHostUser,
createFiring,
pruneFiringsOlderThan: vi.fn(),
listEnabledChannelsForRule: vi.fn(async () => []),
findRuleById: vi.fn(async () => null),
getHostDisplayName: vi.fn(async () => "host"),
}),
}));
vi.mock("../../../utils/logger.js", () => ({
statsLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
vi.mock("../../../utils/notification-sender.js", () => ({
sendNotification: vi.fn(async () => undefined),
}));
vi.mock("../../../utils/discord-sender.js", () => ({
sendDiscord: vi.fn(async () => undefined),
}));
const alertEngineModule =
await import("../../../../../plugins/host-metrics/backend/alert-engine.js");
const cpuRule = {
id: 1,
userId: "user-1",
hostId: null,
name: "CPU",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 50,
thresholdDurationSeconds: 0,
cooldownMinutes: 0,
};
beforeEach(() => {
vi.clearAllMocks();
listEnabledRulesForHost.mockResolvedValue([cpuRule]);
listEnabledRulesForHostUser.mockResolvedValue([cpuRule]);
});
describe("AlertEngine before migration", () => {
it("still evaluates rules", async () => {
expect(alertEngineModule.isAlertEngineSuperseded()).toBe(false);
await alertEngineModule.AlertEngine.getInstance().evaluateMetrics(7, {
cpu: { percent: 90 },
});
expect(listEnabledRulesForHost).toHaveBeenCalled();
});
});
describe("AlertEngine after migration", () => {
it("stops evaluating every trigger type", async () => {
alertEngineModule.markAlertEngineSuperseded();
expect(alertEngineModule.isAlertEngineSuperseded()).toBe(true);
const engine = alertEngineModule.AlertEngine.getInstance();
await engine.evaluateMetrics(7, { cpu: { percent: 99 } });
await engine.evaluateStatus(7, false);
await engine.evaluateHealthCheck(7, "user-1", "web", false);
await engine.evaluateUserLogin(7, "user-1", "root", "10.0.0.1");
// Nothing is even loaded, so nothing can be delivered a second time.
expect(listEnabledRulesForHost).not.toHaveBeenCalled();
expect(listEnabledRulesForHostUser).not.toHaveBeenCalled();
expect(createFiring).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,136 @@
import type { Client } from "ssh2";
import { describe, it, expect, vi } from "vitest";
import {
supportsMetrics,
isTcpPingEnabled,
parseStatusHostIds,
tcpPingThroughJumpHost,
} from "../../../../../plugins/host-metrics/backend/helpers.js";
import { createConnectionLog } from "../../../hosts/connection-log.js";
describe("supportsMetrics", () => {
it("supports plain ssh hosts", () => {
expect(
supportsMetrics({ connectionType: "ssh", authType: "password" }),
).toBe(true);
});
it("defaults missing connectionType to ssh", () => {
expect(supportsMetrics({ authType: "key" })).toBe(true);
});
it("rejects non-ssh connection types", () => {
expect(supportsMetrics({ connectionType: "rdp" })).toBe(false);
expect(supportsMetrics({ connectionType: "vnc" })).toBe(false);
expect(supportsMetrics({ connectionType: "telnet" })).toBe(false);
});
it("rejects ssh hosts that cannot run shell commands", () => {
expect(supportsMetrics({ connectionType: "ssh", authType: "none" })).toBe(
false,
);
expect(supportsMetrics({ connectionType: "ssh", authType: "opkssh" })).toBe(
false,
);
});
});
describe("isTcpPingEnabled", () => {
it("is enabled when status checks are on and tcp ping is not disabled", () => {
expect(
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: false }),
).toBe(true);
expect(isTcpPingEnabled({ statusCheckEnabled: true })).toBe(true);
});
it("is disabled when status checks are off", () => {
expect(isTcpPingEnabled({ statusCheckEnabled: false })).toBe(false);
});
it("is disabled when tcp ping is explicitly disabled", () => {
expect(
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: true }),
).toBe(false);
});
});
describe("parseStatusHostIds", () => {
it("distinguishes an unrestricted request from an empty host set", () => {
expect(parseStatusHostIds(undefined)).toBeNull();
expect(parseStatusHostIds("")).toEqual(new Set());
});
it("keeps only valid positive host IDs", () => {
expect(parseStatusHostIds("7,2,7,-1,nope,1.5")).toEqual(new Set([7, 2]));
});
});
describe("createConnectionLog", () => {
it("builds a log entry without id/timestamp", () => {
const entry = createConnectionLog("info", "connection", "Connecting", {
hostId: 1,
});
expect(entry).toEqual({
type: "info",
stage: "connection",
message: "Connecting",
details: { hostId: 1 },
});
expect("id" in entry).toBe(false);
expect("timestamp" in entry).toBe(false);
});
});
describe("tcpPingThroughJumpHost", () => {
it("reports the final destination online when forwarding succeeds", async () => {
const stream = { destroy: vi.fn() };
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
expect(host).toBe("private.example");
expect(port).toBe(22);
callback(undefined, stream);
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(true);
expect(stream.destroy).toHaveBeenCalledOnce();
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding fails", async () => {
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => {
callback(new Error("Connection refused"));
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding times out", async () => {
vi.useFakeTimers();
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn(),
} as unknown as Pick<Client, "forwardOut" | "end">;
const result = tcpPingThroughJumpHost(
jumpClient,
"private.example",
22,
5000,
);
await vi.advanceTimersByTimeAsync(5000);
await expect(result).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
vi.useRealTimers();
});
});
@@ -0,0 +1,106 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { beforeEach, expect, it, vi } from "vitest";
// Load the production route without starting metrics polling or SSH services.
const source = readFileSync(
new URL(
"../../../../../plugins/host-metrics/backend/routes.ts",
import.meta.url,
),
"utf8",
);
const ast = ts.createSourceFile(
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const route = ast.statements.find(
(node) =>
ts.isExpressionStatement(node) &&
ts.isCallExpression(node.expression) &&
node.expression.expression.getText(ast) === "app.post" &&
node.expression.arguments[0]?.getText(ast) === '"/internal/login-alert"',
);
if (!route) throw new Error("Login event route is missing");
const notify = vi.fn();
const legacy = vi.fn().mockResolvedValue(undefined);
let handler: (req: unknown, res: unknown) => Promise<void>;
runInNewContext(
ts.transpileModule(route.getText(ast), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
{
app: {
post: (_path: string, callback: typeof handler) => {
handler = callback;
},
},
require: () => ({
SystemCrypto: {
getInstance: () => ({
getInternalAuthToken: async () => "internal-test-token",
}),
},
}),
notifyAutomationInternalEvent: notify,
AlertEngine: { getInstance: () => ({ evaluateUserLogin: legacy }) },
},
);
const body = {
hostId: 42,
userId: "owner",
sshUser: "alice",
fromIp: "192.0.2.4",
};
const response = () => ({ status: vi.fn().mockReturnThis(), json: vi.fn() });
beforeEach(() => vi.clearAllMocks());
it("delivers successful SSH login details to automations while retaining the legacy hook", async () => {
const res = response();
await handler(
{
socket: { remoteAddress: "127.0.0.1" },
headers: { "x-internal-auth": "internal-test-token" },
body,
},
res,
);
expect(notify).toHaveBeenCalledExactlyOnceWith("user_login", "owner", 42, {
sshUser: "alice",
fromIp: "192.0.2.4",
});
expect(legacy).toHaveBeenCalledExactlyOnceWith(
42,
"owner",
"alice",
"192.0.2.4",
);
expect(res.json).toHaveBeenCalledWith({ ok: true });
});
it.each([
["192.0.2.9", "internal-test-token"],
["127.0.0.1", "incorrect"],
])(
"rejects untrusted login notifications (%s)",
async (remoteAddress, token) => {
const res = response();
await handler(
{
socket: { remoteAddress },
headers: { "x-internal-auth": token },
body,
},
res,
);
expect(res.status).toHaveBeenCalledWith(403);
expect(notify).not.toHaveBeenCalled();
expect(legacy).not.toHaveBeenCalled();
},
);
@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
// Regression guard for: the /internal/login-alert route was registered
// after the global JWT auth middleware, so every service-to-service login
// alert got rejected with 401 before the route's own IP+token check ever
// ran. Spinning up the full metrics-service Express app (DB, SSH clients,
// polling managers, etc.) just to hit this one route is out of scope, so
// this asserts the registration order directly against the source instead.
describe("metrics service /internal/login-alert route order", () => {
it("is registered before the global auth middleware", () => {
const source = fs.readFileSync(
path.resolve(
__dirname,
"../../../../../plugins/host-metrics/backend/routes.ts",
),
"utf8",
);
const routeIndex = source.indexOf('app.post("/internal/login-alert"');
const authMiddlewareIndex = source.indexOf(
"app.use(authManager.createAuthMiddleware())",
);
expect(routeIndex).toBeGreaterThan(-1);
expect(authMiddlewareIndex).toBeGreaterThan(-1);
expect(routeIndex).toBeLessThan(authMiddlewareIndex);
});
});
@@ -0,0 +1,440 @@
import { describe, it, expect } from "vitest";
import {
buildSudoCommand,
shellSingleQuote,
} from "../../../../hosts/metrics-shared/exec-elevated.js";
import { parsePlatformProbe } from "../../../../hosts/metrics-shared/platform.js";
import {
isValidSystemdUnit,
isValidPid,
isValidPort,
isValidPackageName,
isValidUsername,
isValidDomain,
isValidDnsProvider,
isValidSignal,
isValidServiceAction,
isAllowedPath,
} from "../../../../hosts/metrics-shared/validation.js";
import {
parseServiceList,
buildServiceActionCommand,
} from "../../../../../../plugins/host-metrics/backend/managers/services.js";
import {
parseProcessList,
buildKillCommand,
} from "../../../../../../plugins/host-metrics/backend/managers/processes.js";
import {
parseDfMounts,
parseTopMemory,
} from "../../../../../../plugins/host-metrics/backend/managers/simple-reads.js";
import {
parseCrontab,
serializeCrontab,
isValidCronSchedule,
buildApplyCrontabCommand,
} from "../../../../../../plugins/host-metrics/backend/managers/cron.js";
import {
buildPackageActionCommand,
parseUpgradable,
buildListUpgradableCommand,
} from "../../../../hosts/metrics-shared/package-commands.js";
import {
buildIssueCommand,
buildRenewCommand,
buildRevokeCommand,
isValidCertName,
parseCertbotCertificates,
} from "../../../../../../plugins/host-metrics/backend/managers/ssl.js";
import {
buildIptablesRuleCommand,
buildNftRuleCommand,
} from "../../../../../../plugins/host-metrics/backend/managers/firewall.js";
import {
parsePasswd,
parseSudoers,
} from "../../../../../../plugins/host-metrics/backend/managers/users.js";
import {
buildHealthCheckCommand,
parseHealthResult,
} from "../../../../../../plugins/host-metrics/backend/managers/health.js";
import {
buildTailCommand,
clampLines,
} from "../../../../../../plugins/host-metrics/backend/managers/logs.js";
describe("exec-elevated", () => {
it("single-quotes and escapes for the shell", () => {
expect(shellSingleQuote("abc")).toBe("'abc'");
expect(shellSingleQuote("a'b")).toBe(`'a'"'"'b'`);
});
it("builds the sudo -S pipeline wrapping the command in sh -c with a success marker", () => {
expect(buildSudoCommand("systemctl restart nginx", "pw")).toBe(
`echo 'pw' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; systemctl restart nginx'`,
);
});
it("does not merge stderr into stdout (no 2>&1)", () => {
expect(buildSudoCommand("id", "pw")).not.toContain("2>&1");
});
it("escapes a password containing a quote", () => {
expect(buildSudoCommand("id", "p'w")).toContain(`echo 'p'"'"'w'`);
});
});
describe("platform probe parsing", () => {
it("parses capabilities and prefers dnf over yum", () => {
const out = [
"systemd=1",
"apt=0",
"dnf=1",
"yum=1",
"pacman=0",
"certbot=1",
"acmesh=0",
"docker=1",
"os=Fedora Linux 40",
].join("\n");
const p = parsePlatformProbe(out);
expect(p.hasSystemd).toBe(true);
expect(p.pkg).toBe("dnf");
expect(p.hasCertbot).toBe(true);
expect(p.hasAcmeSh).toBe(false);
expect(p.hasDocker).toBe(true);
expect(p.osPrettyName).toBe("Fedora Linux 40");
});
it("picks apt when present", () => {
expect(parsePlatformProbe("apt=1\ndnf=1").pkg).toBe("apt");
});
});
describe("validation (injection defense)", () => {
it("systemd units", () => {
expect(isValidSystemdUnit("nginx.service")).toBe(true);
expect(isValidSystemdUnit("ssh.socket")).toBe(true);
expect(isValidSystemdUnit("nginx.service; rm -rf /")).toBe(false);
expect(isValidSystemdUnit("nginx")).toBe(false);
});
it("pids", () => {
expect(isValidPid(123)).toBe(true);
expect(isValidPid("123")).toBe(true);
expect(isValidPid(0)).toBe(false);
expect(isValidPid("1; reboot")).toBe(false);
expect(isValidPid(-5)).toBe(false);
});
it("ports", () => {
expect(isValidPort(443)).toBe(true);
expect(isValidPort(0)).toBe(false);
expect(isValidPort(70000)).toBe(false);
});
it("package names", () => {
expect(isValidPackageName("nginx")).toBe(true);
expect(isValidPackageName("lib-foo.bar+1")).toBe(true);
expect(isValidPackageName("nginx && curl evil")).toBe(false);
expect(isValidPackageName("-rf")).toBe(false);
});
it("usernames and domains", () => {
expect(isValidUsername("deploy")).toBe(true);
expect(isValidUsername("root; rm")).toBe(false);
expect(isValidDomain("example.com")).toBe(true);
expect(isValidDomain("*.example.com")).toBe(true);
expect(isValidDomain("ex ample.com")).toBe(false);
expect(isValidDomain("a;b.com")).toBe(false);
});
it("dns providers, signals, service actions", () => {
expect(isValidDnsProvider("cloudflare")).toBe(true);
expect(isValidDnsProvider("cf; rm")).toBe(false);
expect(isValidSignal("KILL")).toBe(true);
expect(isValidSignal("BOOM")).toBe(false);
expect(isValidServiceAction("restart")).toBe(true);
expect(isValidServiceAction("destroy")).toBe(false);
});
it("path allowlist rejects traversal and out-of-allowlist", () => {
expect(isAllowedPath("/var/log/syslog", ["/var/log"])).toBe(true);
expect(isAllowedPath("/var/log/../../etc/passwd", ["/var/log"])).toBe(
false,
);
expect(isAllowedPath("/etc/passwd", ["/var/log"])).toBe(false);
expect(isAllowedPath("relative/path", ["/var/log"])).toBe(false);
});
});
describe("services", () => {
it("parses list-units --plain", () => {
const out =
"nginx.service loaded active running A high performance web server\n" +
"ssh.service loaded active running OpenBSD Secure Shell server\n" +
"cron.service loaded inactive dead Regular background program";
const rows = parseServiceList(out);
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({ unit: "nginx.service", active: "active" });
expect(rows[2].active).toBe("inactive");
});
it("builds action command", () => {
expect(buildServiceActionCommand("nginx.service", "restart")).toBe(
"systemctl restart nginx.service",
);
});
});
describe("processes", () => {
it("parses ps output", () => {
const out =
"1234 1 root 12.5 3.2 45000 S /usr/bin/node node server.js\n" +
"5678 1234 deploy 0.0 1.1 12000 Sl bash -bash";
const rows = parseProcessList(out);
expect(rows[0]).toMatchObject({
pid: 1234,
user: "root",
cpu: 12.5,
command: "/usr/bin/node",
});
expect(rows[1].pid).toBe(5678);
});
it("builds kill command", () => {
expect(buildKillCommand(42, "TERM")).toBe("kill -TERM 42");
});
});
describe("simple reads", () => {
it("parses df -Pk and skips tmpfs", () => {
const out =
"/dev/sda1 100000 40000 60000 40% /\n" +
"tmpfs 8000 0 8000 0% /dev/shm\n" +
"/dev/sdb1 200000 100000 100000 50% /data";
const mounts = parseDfMounts(out);
expect(mounts).toHaveLength(2);
expect(mounts[0].mount).toBe("/");
expect(mounts[1].usePct).toBe(50);
});
it("parses top by memory", () => {
const rows = parseTopMemory("1234 root 5.5 50000 node");
expect(rows[0]).toMatchObject({ pid: 1234, mem: 5.5, command: "node" });
});
});
describe("cron", () => {
it("parses enabled and toggled entries", () => {
const out = "0 2 * * * /backup.sh\n# 30 4 * * * /old.sh\nPATH=/usr/bin";
const entries = parseCrontab(out);
expect(entries).toHaveLength(2);
expect(entries[0]).toMatchObject({ enabled: true, schedule: "0 2 * * *" });
expect(entries[1].enabled).toBe(false);
});
it("validates schedules", () => {
expect(isValidCronSchedule("0 2 * * *")).toBe(true);
expect(isValidCronSchedule("@daily")).toBe(true);
expect(isValidCronSchedule("not a schedule")).toBe(false);
});
it("serializes (commenting disabled entries) and round-trips", () => {
const body = serializeCrontab([
{ raw: "", enabled: true, schedule: "0 2 * * *", command: "/a.sh" },
{ raw: "", enabled: false, schedule: "@daily", command: "/b.sh" },
]);
expect(body).toBe("0 2 * * * /a.sh\n# @daily /b.sh\n");
const reparsed = parseCrontab(body);
expect(reparsed[0].enabled).toBe(true);
expect(reparsed[1].enabled).toBe(false);
});
it("builds apply command piping into crontab -", () => {
expect(buildApplyCrontabCommand("x\n")).toBe(
`printf '%s' 'x\n' | crontab -`,
);
});
});
describe("packages", () => {
it("builds per-distro commands", () => {
expect(buildPackageActionCommand("apt", "install", "nginx")).toContain(
"apt-get -y install nginx",
);
expect(buildPackageActionCommand("pacman", "upgrade-all")).toBe(
"pacman -Syu --noconfirm",
);
expect(buildPackageActionCommand(null, "install", "x")).toBeNull();
});
it("lists per distro", () => {
expect(buildListUpgradableCommand("apt")).toContain(
"apt list --upgradable",
);
expect(buildListUpgradableCommand(null)).toBeNull();
});
it("parses apt upgradable", () => {
const out =
"nginx/focal-updates 1.18.0-2 amd64 [upgradable from: 1.18.0-1]";
const pkgs = parseUpgradable("apt", out);
expect(pkgs[0]).toMatchObject({
name: "nginx",
newVersion: "1.18.0-2",
currentVersion: "1.18.0-1",
});
});
it("parses pacman upgradable", () => {
const pkgs = parseUpgradable("pacman", "linux 6.1 -> 6.2");
expect(pkgs[0]).toMatchObject({ name: "linux", newVersion: "6.2" });
});
});
describe("ssl (dual client)", () => {
it("builds certbot issue for http + dns", () => {
expect(
buildIssueCommand({
client: "certbot",
domains: ["a.com"],
challenge: "http-standalone",
}),
).toContain(
"certbot certonly --non-interactive --agree-tos --standalone -d 'a.com'",
);
expect(
buildIssueCommand({
client: "certbot",
domains: ["a.com"],
challenge: "dns",
dnsProvider: "cloudflare",
}),
).toContain("--dns-cloudflare");
});
it("builds acme.sh issue", () => {
const cmd = buildIssueCommand({
client: "acme.sh",
domains: ["a.com", "b.com"],
challenge: "dns",
dnsProvider: "cf",
});
expect(cmd).toContain("--issue --dns dns_cf");
expect(cmd).toContain("-d 'a.com'");
expect(cmd).toContain("-d 'b.com'");
});
it("builds renew per client", () => {
expect(buildRenewCommand("certbot", true)).toBe("certbot renew --dry-run");
expect(buildRenewCommand("acme.sh", false)).toContain("--renew-all");
});
it("builds revoke per client", () => {
expect(buildRevokeCommand("certbot", "example.com")).toBe(
"certbot revoke --non-interactive --cert-name 'example.com' --delete-after-revoke",
);
const acme = buildRevokeCommand("acme.sh", "example.com");
expect(acme).toContain("--revoke -d 'example.com'");
expect(acme).toContain("--remove -d 'example.com'");
});
it("validates certificate names (rejects shell metachars)", () => {
expect(isValidCertName("example.com")).toBe(true);
expect(isValidCertName("example.com-0001")).toBe(true);
expect(isValidCertName("*.example.com")).toBe(true);
expect(isValidCertName("a.com; rm -rf /")).toBe(false);
expect(isValidCertName("")).toBe(false);
expect(isValidCertName(undefined)).toBe(false);
});
it("parses certbot certificates", () => {
const out = [
"Found the following certs:",
" Certificate Name: example.com",
" Domains: example.com www.example.com",
" Expiry Date: 2026-09-01 12:00:00+00:00 (VALID: 80 days)",
" Certificate Path: /etc/letsencrypt/live/example.com/fullchain.pem",
].join("\n");
const certs = parseCertbotCertificates(out);
expect(certs[0]).toMatchObject({
name: "example.com",
client: "certbot",
});
expect(certs[0].domains).toContain("www.example.com");
});
});
describe("firewall", () => {
it("builds iptables add/delete on INPUT only", () => {
expect(
buildIptablesRuleCommand("add", {
protocol: "tcp",
port: 443,
target: "ACCEPT",
}),
).toBe("iptables -A INPUT -p tcp --dport 443 -j ACCEPT");
expect(
buildIptablesRuleCommand("delete", {
protocol: "udp",
port: 53,
target: "DROP",
}),
).toBe("iptables -D INPUT -p udp --dport 53 -j DROP");
});
it("builds nft rules", () => {
expect(
buildNftRuleCommand("add", {
protocol: "tcp",
port: 22,
target: "ACCEPT",
}),
).toContain("add rule inet filter input tcp dport 22 accept");
});
});
describe("users", () => {
it("parses passwd for human users only", () => {
const out =
"root:x:0:0:root:/root:/bin/bash\n" +
"deploy:x:1000:1000:Deploy:/home/deploy:/bin/bash\n" +
"nobody:x:65534:65534:nobody:/:/usr/sbin/nologin";
const users = parsePasswd(out);
expect(users).toHaveLength(1);
expect(users[0].name).toBe("deploy");
});
it("parses sudoers membership", () => {
const out = "sudo:x:27:deploy,alice\nwheel:x:10:bob";
expect(parseSudoers(out).sort()).toEqual(["alice", "bob", "deploy"]);
});
});
describe("health checks", () => {
it("builds tcp and http commands", () => {
const tcp = buildHealthCheckCommand({
id: "1",
name: "ssh",
type: "tcp",
target: "localhost",
port: 22,
});
expect(tcp).toContain("/dev/tcp/");
const http = buildHealthCheckCommand({
id: "2",
name: "web",
type: "http",
target: "example.com",
path: "/health",
});
expect(http).toContain("curl -s -o /dev/null");
expect(http).toContain("http://example.com/health");
});
it("parses tcp and http results", () => {
const tcp = parseHealthResult(
{ id: "1", name: "ssh", type: "tcp", target: "h", port: 22 },
"ok 12",
);
expect(tcp).toMatchObject({ ok: true, latencyMs: 12 });
const http = parseHealthResult(
{ id: "2", name: "web", type: "http", target: "h" },
"200 0.045",
);
expect(http).toMatchObject({ ok: true, latencyMs: 45 });
const bad = parseHealthResult(
{ id: "3", name: "web", type: "http", target: "h" },
"500 0.01",
);
expect(bad.ok).toBe(false);
});
});
describe("logs", () => {
it("clamps line counts", () => {
expect(clampLines(50)).toBe(50);
expect(clampLines(99999)).toBe(2000);
expect(clampLines("abc")).toBe(200);
expect(clampLines(0)).toBe(1);
});
it("builds a quoted tail command without suppressing stderr", () => {
expect(buildTailCommand("/var/log/syslog", 100)).toBe(
"tail -n 100 '/var/log/syslog'",
);
});
});
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
// Regression guard for Support#1216: routine status polling on a host with
// metrics disabled ran a full SSH authentication attempt on every interval,
// which fires a live 2FA push each cycle on RADIUS/Duo-backed devices with
// no active session. Spinning up the full metrics-service Express app (DB,
// SSH clients, timers, polling managers, etc.) just to drive pollHostStatus
// through two intervals is out of scope, so this asserts directly against
// the source that the status-poll path never calls into SSH auth, the same
// way login-alert-route-order.test.ts guards route registration order.
describe("metrics service status polling", () => {
it("never authenticates over SSH from pollHostStatus, on the first poll or any later one", () => {
const source = fs.readFileSync(
path.resolve(
__dirname,
"../../../../../plugins/host-metrics/backend/routes.ts",
),
"utf8",
);
const pollHostStatusStart = source.indexOf("private async pollHostStatus(");
const pollHostMetricsStart = source.indexOf(
"private async pollHostMetrics(",
);
expect(pollHostStatusStart).toBeGreaterThan(-1);
expect(pollHostMetricsStart).toBeGreaterThan(pollHostStatusStart);
const pollHostStatusBody = source.slice(
pollHostStatusStart,
pollHostMetricsStart,
);
expect(pollHostStatusBody).not.toContain("withSshConnection");
expect(pollHostStatusBody).not.toContain("statusAfterAuthentication");
});
});
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import {
parseCpuLine,
parseDarwinCpuUsageLine,
} from "../../../../../../plugins/host-metrics/backend/widgets/cpu-collector.js";
describe("parseCpuLine", () => {
it("parses a standard /proc/stat cpu line", () => {
// user nice system idle iowait irq softirq
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
expect(result).toBeDefined();
// idle = idle(800) + iowait(30)
expect(result?.idle).toBe(830);
// total = sum of all fields
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
});
it("tolerates leading/trailing whitespace", () => {
const result = parseCpuLine(" cpu 1 2 3 4 ");
expect(result?.total).toBe(10);
expect(result?.idle).toBe(4);
});
it("returns undefined for non-cpu lines", () => {
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
expect(parseCpuLine("intr 12345")).toBeUndefined();
});
it("returns undefined when there are fewer than 4 numeric fields", () => {
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
});
});
describe("parseDarwinCpuUsageLine", () => {
it("parses macOS `top -l 1` CPU usage line", () => {
const result = parseDarwinCpuUsageLine(
"CPU usage: 12.34% user, 5.66% sys, 82.00% idle",
);
expect(result?.percent).toBeCloseTo(18.0, 5);
});
it("returns undefined for unrelated lines", () => {
expect(parseDarwinCpuUsageLine("Processes: 400 total")).toBeUndefined();
});
});
@@ -0,0 +1,319 @@
import { describe, it, expect } from "vitest";
import {
parseDfLines,
findWorstMountIndex,
buildFilesystemList,
selectPrimaryFilesystem,
filterExcludedFilesystems,
mergeMonitoredFilesystems,
parseWindowsDiskJson,
parseDarwinMountTypes,
parseDarwinDfRows,
} from "../../../../../../plugins/host-metrics/backend/widgets/disk-collector.js";
describe("parseDfLines", () => {
it("parses df -T -P output into rows", () => {
const output =
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(2);
expect(rows[0].mount).toBe("/");
expect(rows[0].type).toBe("ext4");
expect(rows[1].mount).toBe("/data");
});
it("filters out pseudo filesystems", () => {
const output =
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
"overlay overlay 100 50 50 50% /\n" +
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(1);
expect(rows[0].mount).toBe("/mnt/data");
});
it("captures the filesystem type for network shares", () => {
const output =
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
const rows = parseDfLines(output);
expect(rows[0].type).toBe("nfs4");
expect(rows[1].type).toBe("cifs");
});
});
describe("findWorstMountIndex", () => {
it("picks the most-utilized mount, not just the first row", () => {
const rows = parseDfLines(
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
expect(worst.totalBytes).toBe(15393162788864);
expect(worst.usedBytes).toBe(15239230844928);
});
it("falls back to the only mount available", () => {
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(0);
});
it("skips rows with invalid or zero totals", () => {
const rows = parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
"/dev/sda2 ext4 100 40 60 40% /ok\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
});
it("returns index -1 when there are no usable rows", () => {
const worst = findWorstMountIndex([]);
expect(worst.index).toBe(-1);
expect(worst.totalBytes).toBe(0);
});
});
const BYTES_OUTPUT =
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
const HUMAN_OUTPUT =
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
describe("buildFilesystemList", () => {
it("returns every real filesystem with byte maths and human strings", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({
mount: "/",
type: "ext4",
percent: 40,
usedHuman: "400",
totalHuman: "1.0K",
availableHuman: "600",
usedBytes: 400,
totalBytes: 1000,
});
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
});
it("matches human rows by mount when the row counts differ", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
);
expect(list[0].totalHuman).toBeNull();
expect(list[1].totalHuman).toBe("2.0K");
});
it("drops filesystems with a zero or invalid total", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
),
[],
);
expect(list).toHaveLength(1);
expect(list[0].mount).toBe("/ok");
});
});
describe("selectPrimaryFilesystem", () => {
it("prefers root over a fuller secondary mount", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
});
it("falls back to the most-utilized mount when there is no root", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
),
[],
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
});
it("returns null for an empty list", () => {
expect(selectPrimaryFilesystem([])).toBeNull();
});
});
describe("filterExcludedFilesystems", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 400 600 40% /\n" +
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
),
[],
);
it("returns the same list when no mounts are excluded", () => {
expect(filterExcludedFilesystems(list)).toHaveLength(3);
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
});
it("excludes an exact mount path match", () => {
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("excludes by filesystem type substring, case-insensitively", () => {
const filtered = filterExcludedFilesystems(list, ["NFS"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("supports excluding multiple network filesystem types at once", () => {
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
});
it("ignores blank/whitespace-only entries", () => {
const filtered = filterExcludedFilesystems(list, [" ", ""]);
expect(filtered).toHaveLength(3);
});
});
describe("mergeMonitoredFilesystems", () => {
it("adds an arbitrary path with a user label", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const custom = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/config", label: "Home Assistant" }],
custom,
);
expect(result).toHaveLength(2);
expect(result[1]).toMatchObject({
mount: "/config",
label: "Home Assistant",
totalBytes: 1000,
});
});
it("labels a path that is already a detected mount", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/data", label: "Media" }],
detected,
);
expect(result).toHaveLength(1);
expect(result[0].label).toBe("Media");
});
});
// Representative output shape from `df -Pk` and `mount` on a modern macOS
// (Sonoma/Sequoia, Apple Silicon) system, where the APFS root volume group
// surfaces as several synthetic system volumes alongside the real "/" and
// "/System/Volumes/Data" mounts.
const DARWIN_DF_OUTPUT =
"Filesystem 1024-blocks Used Available Capacity Mounted on\n" +
"/dev/disk3s1s1 971350180 10904432 723545416 2% /\n" +
"devfs 205 205 0 100% /dev\n" +
"/dev/disk3s6 971350180 8437248 723545416 2% /System/Volumes/VM\n" +
"/dev/disk3s2 971350180 6067864 723545416 1% /System/Volumes/Preboot\n" +
"/dev/disk3s4 971350180 16612 723545416 1% /System/Volumes/Update\n" +
"/dev/disk1s2 500098 6104 479950 2% /System/Volumes/xarts\n" +
"/dev/disk1s1 500098 5388 479950 2% /System/Volumes/iSCPreboot\n" +
"/dev/disk1s3 500098 36 479950 1% /System/Volumes/Hardware\n" +
"/dev/disk3s5 971350180 235925064 723545416 25% /System/Volumes/Data\n" +
"map auto_home 0 0 0 100% /System/Volumes/Data/home\n";
const DARWIN_MOUNT_OUTPUT =
"/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)\n" +
"devfs on /dev (devfs, local, nobrowse)\n" +
"/dev/disk3s6 on /System/Volumes/VM (apfs, local, noexec, journaled, noatime, nobrowse)\n" +
"/dev/disk3s2 on /System/Volumes/Preboot (apfs, local, journaled, nobrowse)\n" +
"/dev/disk3s4 on /System/Volumes/Update (apfs, local, journaled, nobrowse)\n" +
"/dev/disk1s2 on /System/Volumes/xarts (apfs, local, noexec, journaled, noatime, nobrowse)\n" +
"/dev/disk1s1 on /System/Volumes/iSCPreboot (apfs, local, journaled, nobrowse)\n" +
"/dev/disk1s3 on /System/Volumes/Hardware (apfs, local, journaled, nobrowse)\n" +
"/dev/disk3s5 on /System/Volumes/Data (apfs, local, journaled, nobrowse)\n" +
"map auto_home on /System/Volumes/Data/home (autofs, automounted, nobrowse)\n";
describe("parseDarwinMountTypes", () => {
it("maps each mount point to its filesystem type", () => {
const map = parseDarwinMountTypes(DARWIN_MOUNT_OUTPUT);
expect(map.get("/")).toBe("apfs");
expect(map.get("/dev")).toBe("devfs");
expect(map.get("/System/Volumes/Data/home")).toBe("autofs");
});
});
describe("parseDarwinDfRows", () => {
it("parses real `df -Pk` rows and drops devfs/autofs and synthetic system volumes", () => {
const typeByMount = parseDarwinMountTypes(DARWIN_MOUNT_OUTPUT);
const rows = parseDarwinDfRows(DARWIN_DF_OUTPUT, typeByMount);
expect(rows.map((r) => r.mount)).toEqual(["/", "/System/Volumes/Data"]);
const root = rows[0];
expect(root.type).toBe("apfs");
expect(root.totalBytes).toBe(971350180 * 1024);
expect(root.usedBytes).toBe(10904432 * 1024);
expect(root.availableBytes).toBe(723545416 * 1024);
// Our percent is used/total (matches the Linux/Windows collectors), which
// differs slightly from df's own Capacity% (used/(used+available)).
expect(root.percent).toBe(1);
expect(root.usedHuman).toBeTruthy();
expect(root.totalHuman).toBeTruthy();
});
it("returns an empty list for blank output", () => {
expect(parseDarwinDfRows("", new Map())).toEqual([]);
});
});
describe("parseWindowsDiskJson", () => {
it("handles ConvertTo-Json collapsing a single result to an object", () => {
const rows = parseWindowsDiskJson(
'{"drive":"C:","total":1000000000,"free":400000000}',
);
expect(rows).toEqual([{ drive: "C:", total: 1000000000, free: 400000000 }]);
});
it("parses an array of drives", () => {
const rows = parseWindowsDiskJson(
'[{"drive":"C:","total":1000,"free":400},{"drive":"D:","total":2000,"free":1900}]',
);
expect(rows).toHaveLength(2);
expect(rows[1]).toEqual({ drive: "D:", total: 2000, free: 1900 });
});
it("drops drives with a zero or missing total", () => {
const rows = parseWindowsDiskJson(
'[{"drive":"C:","total":0,"free":0},{"drive":"D:","total":100,"free":50}]',
);
expect(rows).toHaveLength(1);
expect(rows[0].drive).toBe("D:");
});
it("returns an empty array for blank or invalid output", () => {
expect(parseWindowsDiskJson("")).toEqual([]);
expect(parseWindowsDiskJson("not json")).toEqual([]);
});
});
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import {
parseNvidiaSmiGpus,
parseNvidiaSmiOutput,
parseNvidiaSmiProcesses,
} from "../../../../../../plugins/host-metrics/backend/widgets/gpu-collector.js";
const RTX_UUID = "GPU-3b8f2c1a-5d4e-7f60-8a9b-0c1d2e3f4a5b";
const H100_UUID = "GPU-9a8b7c6d-5e4f-3a2b-1c0d-e9f8a7b6c5d4";
// index, uuid, driver_version, utilization.gpu, memory.used, memory.total,
// temperature.gpu, power.draw, power.limit, fan.speed, name
const GPU_QUERY_OUTPUT = [
`0, ${RTX_UUID}, 550.54.14, 37, 8123, 24564, 61, 182.47, 450.00, 45, NVIDIA GeForce RTX 4090`,
`1, ${H100_UUID}, 550.54.14, 0, 1, 81559, 34, 62.10, 700.00, [N/A], NVIDIA H100 80GB HBM3`,
"",
].join("\n");
// gpu_uuid, pid, used_memory, process_name
const APPS_QUERY_OUTPUT = [
`${RTX_UUID}, 12345, 7890, /usr/bin/python3`,
`${H100_UUID}, 23456, [N/A], /opt/app/worker, gpu, v2`,
"",
].join("\n");
const DRIVER_FAILURE =
"NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver. Make sure that the latest NVIDIA driver is installed and running.\n";
const RTX = {
index: 0,
uuid: RTX_UUID,
name: "NVIDIA GeForce RTX 4090",
driverVersion: "550.54.14",
utilizationPercent: 37,
memoryUsedMiB: 8123,
memoryTotalMiB: 24564,
memoryPercent: 33.1,
temperatureCelsius: 61,
powerDrawWatts: 182.5,
powerLimitWatts: 450,
fanPercent: 45,
};
const H100 = {
index: 1,
uuid: H100_UUID,
name: "NVIDIA H100 80GB HBM3",
driverVersion: "550.54.14",
utilizationPercent: 0,
memoryUsedMiB: 1,
memoryTotalMiB: 81559,
memoryPercent: 0,
temperatureCelsius: 34,
powerDrawWatts: 62.1,
powerLimitWatts: 700,
fanPercent: null,
};
describe("gpu collector", () => {
describe("parseNvidiaSmiGpus", () => {
it("parses one device per row of --query-gpu output", () => {
expect(parseNvidiaSmiGpus(GPU_QUERY_OUTPUT)).toEqual([RTX, H100]);
});
it("maps unsupported and unavailable readings to null", () => {
const [gpu] = parseNvidiaSmiGpus(
"0, GPU-aaaa, 470.182.03, [N/A], 512, [N/A], [Not Supported], [Not Supported], [N/A], [Unknown Error], Tesla K80\n",
);
expect(gpu).toEqual({
index: 0,
uuid: "GPU-aaaa",
name: "Tesla K80",
driverVersion: "470.182.03",
utilizationPercent: null,
memoryUsedMiB: 512,
memoryTotalMiB: null,
memoryPercent: null,
temperatureCelsius: null,
powerDrawWatts: null,
powerLimitWatts: null,
fanPercent: null,
});
});
it("keeps a device name that contains commas intact", () => {
const [gpu] = parseNvidiaSmiGpus(
"0, GPU-bbbb, 535.0, 5, 10, 100, 40, 20, 70, 30, Quadro RTX, Mobile, Max-Q\n",
);
expect(gpu.name).toBe("Quadro RTX, Mobile, Max-Q");
expect(gpu.fanPercent).toBe(30);
});
it("ignores driver error text printed to stdout", () => {
expect(parseNvidiaSmiGpus(DRIVER_FAILURE)).toEqual([]);
expect(parseNvidiaSmiGpus("No devices were found\n")).toEqual([]);
});
it("skips a csv header row", () => {
const header =
"index, uuid, driver_version, utilization.gpu [%], memory.used [MiB], memory.total [MiB], temperature.gpu, power.draw [W], power.limit [W], fan.speed [%], name\n";
expect(parseNvidiaSmiGpus(header + GPU_QUERY_OUTPUT)).toEqual([
RTX,
H100,
]);
});
});
describe("parseNvidiaSmiProcesses", () => {
it("attaches each process to the GPU with the matching uuid", () => {
expect(parseNvidiaSmiProcesses(APPS_QUERY_OUTPUT, [RTX, H100])).toEqual([
{
gpuIndex: 0,
pid: 12345,
name: "/usr/bin/python3",
memoryUsedMiB: 7890,
},
{
gpuIndex: 1,
pid: 23456,
name: "/opt/app/worker, gpu, v2",
memoryUsedMiB: null,
},
]);
});
it("keeps processes whose uuid matches no listed GPU", () => {
expect(
parseNvidiaSmiProcesses("GPU-unknown, 77, 100, /bin/app\n", [RTX]),
).toEqual([
{ gpuIndex: null, pid: 77, name: "/bin/app", memoryUsedMiB: 100 },
]);
});
it("ignores driver error text printed to stdout", () => {
expect(parseNvidiaSmiProcesses(DRIVER_FAILURE, [RTX])).toEqual([]);
});
it("skips a csv header row", () => {
expect(
parseNvidiaSmiProcesses(
"gpu_uuid, pid, used_gpu_memory [MiB], process_name\n",
[RTX],
),
).toEqual([]);
});
});
describe("parseNvidiaSmiOutput", () => {
it("splits the combined command output into devices and processes", () => {
const result = parseNvidiaSmiOutput(
`${GPU_QUERY_OUTPUT}---\n${APPS_QUERY_OUTPUT}`,
);
expect(result.source).toBe("nvidia-smi");
expect(result.gpus).toEqual([RTX, H100]);
expect(result.processes.map((p) => [p.gpuIndex, p.pid])).toEqual([
[0, 12345],
[1, 23456],
]);
});
it("reports no source when nvidia-smi is not installed", () => {
expect(parseNvidiaSmiOutput("")).toEqual({
source: "none",
gpus: [],
processes: [],
});
});
it("reports no source when the driver cannot be reached", () => {
expect(
parseNvidiaSmiOutput(`${DRIVER_FAILURE}---\n${DRIVER_FAILURE}`),
).toEqual({ source: "none", gpus: [], processes: [] });
});
});
});
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
counterRate,
parseNetworkCounters,
parseDarwinIfconfig,
parseDarwinNetstat,
parseWindowsAdapterJson,
} from "../../../../../../plugins/host-metrics/backend/widgets/network-collector.js";
const PROC_NET = `Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
eth0: 1024 1 0 0 0 0 0 0 2048 2 0 0 0 0 0 0
lo: 4096 4 0 0 0 0 0 0 4096 4 0 0 0 0 0 0`;
describe("network counters", () => {
it("parses Linux proc counters", () => {
expect(parseNetworkCounters(PROC_NET).get("eth0")).toEqual({
rx: "1024",
tx: "2048",
});
});
it("calculates bytes per second and rejects counter resets", () => {
expect(counterRate("1000", "2500", 0.5)).toBe(3000);
expect(counterRate("2500", "1000", 0.5)).toBeNull();
});
});
const DARWIN_IFCONFIG = `lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
inet 127.0.0.1 netmask 0xff000000
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
ether ac:de:48:00:11:22
inet 192.168.1.5 netmask 0xffffff00 broadcast 192.168.1.255
status: active
en1: flags=8822<BROADCAST,SMART,SIMPLEX,MULTICAST> mtu 1500
ether ac:de:48:00:11:23
status: inactive`;
const DARWIN_NETSTAT = `Name Mtu Network Address Ipkts Ierrs Ibytes Opkts Oerrs Obytes Coll
lo0 16384 <Link#1> 466472 0 62511610 466472 0 62511610 0
lo0 16384 127 127.0.0.1 466472 - 62511610 466472 - 62511610 -
en0 1500 <Link#4> ac:de:48:00:11:22 123456 0 987654321 65432 0 123456789 0
en0 1500 192.168.1 192.168.1.5 123456 - 987654321 65432 - 123456789 -`;
describe("darwin network parsing", () => {
it("parses interface addresses and up/down state from ifconfig", () => {
const map = parseDarwinIfconfig(DARWIN_IFCONFIG);
expect(map.has("lo0")).toBe(false);
expect(map.get("en0")).toEqual({ ip: "192.168.1.5", state: "UP" });
expect(map.get("en1")).toEqual({ ip: "", state: "DOWN" });
});
it("parses byte counters from the Link-layer netstat -ib row", () => {
const counters = parseDarwinNetstat(DARWIN_NETSTAT);
expect(counters.has("lo0")).toBe(false);
expect(counters.get("en0")).toEqual({
rx: "987654321",
tx: "123456789",
});
});
});
describe("parseWindowsAdapterJson", () => {
it("handles ConvertTo-Json collapsing a single result to an object", () => {
const rows = parseWindowsAdapterJson(
'{"name":"Ethernet","ip":"10.0.0.5","state":"UP","rx":1000,"tx":2000}',
);
expect(rows).toEqual([
{ name: "Ethernet", ip: "10.0.0.5", state: "UP", rx: "1000", tx: "2000" },
]);
});
it("parses an array of adapters", () => {
const rows = parseWindowsAdapterJson(
'[{"name":"Ethernet","ip":"10.0.0.5","state":"UP","rx":1000,"tx":2000},' +
'{"name":"Wi-Fi","ip":null,"state":"UP","rx":500,"tx":700}]',
);
expect(rows).toHaveLength(2);
expect(rows[1]).toEqual({
name: "Wi-Fi",
ip: "",
state: "UP",
rx: "500",
tx: "700",
});
});
it("returns an empty array for blank or invalid output", () => {
expect(parseWindowsAdapterJson("")).toEqual([]);
expect(parseWindowsAdapterJson("not json")).toEqual([]);
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
parseSensorsOutput,
parseSysfsThermalOutput,
} from "../../../../../../plugins/host-metrics/backend/widgets/temperature-collector.js";
describe("temperature collectors", () => {
it("parses sysfs thermal zone output", () => {
const result = parseSysfsThermalOutput(
"x86_pkg_temp\t51000\nacpitz\t42\nbad\tnope\n",
);
expect(result).toEqual([
{ label: "x86_pkg_temp", celsius: 51 },
{ label: "acpitz", celsius: 42 },
]);
});
it("parses lm-sensors temperature lines", () => {
const result = parseSensorsOutput(`
coretemp-isa-0000
Adapter: ISA adapter
Package id 0: +52.0°C (high = +80.0°C, crit = +100.0°C)
Core 0: +48.5°C
fan1: 1200 RPM
`);
expect(result).toEqual([
{ label: "coretemp-isa-0000: Package id 0", celsius: 52 },
{ label: "coretemp-isa-0000: Core 0", celsius: 48.5 },
]);
});
it("keeps duplicate sensor names distinct across devices", () => {
const result = parseSensorsOutput(`
nvme-pci-0100
Composite: +41.0°C
nvme-pci-0200
Composite: +52.0°C
`);
expect(result).toEqual([
{ label: "nvme-pci-0100: Composite", celsius: 41 },
{ label: "nvme-pci-0200: Composite", celsius: 52 },
]);
});
});