mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -0,0 +1,136 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
supportsMetrics,
|
||||
isTcpPingEnabled,
|
||||
parseStatusHostIds,
|
||||
tcpPingThroughJumpHost,
|
||||
} from "../../src/backend/helpers.js";
|
||||
import { createConnectionLog } from "../../../../src/backend/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,92 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
deriveEnabledWidgets,
|
||||
defaultLayoutFromWidgets,
|
||||
isMetricCardId,
|
||||
isManagerCardId,
|
||||
METRIC_CARD_IDS,
|
||||
} from "../../../../src/types/host-metrics.js";
|
||||
|
||||
describe("deriveEnabledWidgets", () => {
|
||||
it("returns only metric ids, in canonical order, deduped", () => {
|
||||
const slots = [
|
||||
{ id: "firewall" },
|
||||
{ id: "cpu" },
|
||||
{ id: "cpu" },
|
||||
{ id: "memory" },
|
||||
];
|
||||
expect(deriveEnabledWidgets(slots)).toEqual(["cpu", "memory", "firewall"]);
|
||||
});
|
||||
|
||||
it("excludes manager card ids (mobile contract: never leak managers)", () => {
|
||||
const slots = [
|
||||
{ id: "cpu" },
|
||||
{ id: "service_manager" },
|
||||
{ id: "log_viewer" },
|
||||
{ id: "disk" },
|
||||
];
|
||||
const out = deriveEnabledWidgets(slots);
|
||||
expect(out).toEqual(["cpu", "disk"]);
|
||||
for (const id of out) expect(isMetricCardId(id)).toBe(true);
|
||||
});
|
||||
|
||||
it("output is always a subset of the 10 known WidgetTypes", () => {
|
||||
const slots = METRIC_CARD_IDS.map((id) => ({ id })).concat([
|
||||
{ id: "user_manager" } as { id: (typeof METRIC_CARD_IDS)[number] },
|
||||
{ id: "bogus" } as { id: (typeof METRIC_CARD_IDS)[number] },
|
||||
]);
|
||||
const out = deriveEnabledWidgets(slots);
|
||||
expect(out).toEqual(METRIC_CARD_IDS);
|
||||
expect(out.length).toBe(METRIC_CARD_IDS.length);
|
||||
});
|
||||
|
||||
it("empty slots -> empty widgets", () => {
|
||||
expect(deriveEnabledWidgets([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultLayoutFromWidgets", () => {
|
||||
it("builds slots in canonical order with dense ordering", () => {
|
||||
const layout = defaultLayoutFromWidgets(["disk", "cpu", "memory"]);
|
||||
expect(layout.slots.map((s) => s.id)).toEqual(["cpu", "memory", "disk"]);
|
||||
expect(layout.slots.map((s) => s.order)).toEqual([0, 1, 2]);
|
||||
expect(layout.columns).toBe(3);
|
||||
});
|
||||
|
||||
it("ignores unknown widget ids", () => {
|
||||
const layout = defaultLayoutFromWidgets(["cpu", "nope", "service_manager"]);
|
||||
expect(layout.slots.map((s) => s.id)).toEqual(["cpu"]);
|
||||
});
|
||||
|
||||
it("assigns valid colSpans (1..3)", () => {
|
||||
const layout = defaultLayoutFromWidgets([...METRIC_CARD_IDS]);
|
||||
for (const s of layout.slots) {
|
||||
expect([1, 2, 3]).toContain(s.colSpan);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the opt-in gpu card when deriving enabledWidgets", () => {
|
||||
expect(deriveEnabledWidgets([{ id: "gpu" }, { id: "cpu" }])).toEqual([
|
||||
"cpu",
|
||||
"gpu",
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips: deriveEnabledWidgets(defaultLayout) === input order", () => {
|
||||
const layout = defaultLayoutFromWidgets(["ports", "cpu", "firewall"]);
|
||||
expect(deriveEnabledWidgets(layout.slots)).toEqual([
|
||||
"cpu",
|
||||
"ports",
|
||||
"firewall",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("card id guards", () => {
|
||||
it("classifies metric vs manager ids", () => {
|
||||
expect(isMetricCardId("cpu")).toBe(true);
|
||||
expect(isMetricCardId("service_manager")).toBe(false);
|
||||
expect(isManagerCardId("service_manager")).toBe(true);
|
||||
expect(isManagerCardId("cpu")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
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("../../src/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();
|
||||
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,
|
||||
},
|
||||
);
|
||||
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", 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(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();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
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, "../../src/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 "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { parsePlatformProbe } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
|
||||
import {
|
||||
isValidSystemdUnit,
|
||||
isValidPid,
|
||||
isValidPort,
|
||||
isValidPackageName,
|
||||
isValidUsername,
|
||||
isValidDomain,
|
||||
isValidDnsProvider,
|
||||
isValidSignal,
|
||||
isValidServiceAction,
|
||||
isAllowedPath,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import {
|
||||
parseServiceList,
|
||||
buildServiceActionCommand,
|
||||
} from "../../../src/backend/managers/services.js";
|
||||
import {
|
||||
parseProcessList,
|
||||
buildKillCommand,
|
||||
} from "../../../src/backend/managers/processes.js";
|
||||
import {
|
||||
parseDfMounts,
|
||||
parseTopMemory,
|
||||
} from "../../../src/backend/managers/simple-reads.js";
|
||||
import {
|
||||
parseCrontab,
|
||||
serializeCrontab,
|
||||
isValidCronSchedule,
|
||||
buildApplyCrontabCommand,
|
||||
} from "../../../src/backend/managers/cron.js";
|
||||
import {
|
||||
buildPackageActionCommand,
|
||||
parseUpgradable,
|
||||
buildListUpgradableCommand,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/package-commands.js";
|
||||
import {
|
||||
buildIssueCommand,
|
||||
buildRenewCommand,
|
||||
buildRevokeCommand,
|
||||
isValidCertName,
|
||||
parseCertbotCertificates,
|
||||
} from "../../../src/backend/managers/ssl.js";
|
||||
import {
|
||||
buildIptablesRuleCommand,
|
||||
buildNftRuleCommand,
|
||||
} from "../../../src/backend/managers/firewall.js";
|
||||
import {
|
||||
parsePasswd,
|
||||
parseSudoers,
|
||||
} from "../../../src/backend/managers/users.js";
|
||||
import {
|
||||
buildHealthCheckCommand,
|
||||
parseHealthResult,
|
||||
} from "../../../src/backend/managers/health.js";
|
||||
import {
|
||||
buildTailCommand,
|
||||
clampLines,
|
||||
} from "../../../src/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,41 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toFixedNum,
|
||||
kibToGiB,
|
||||
encodePowerShellCommand,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
describe("toFixedNum", () => {
|
||||
it("rounds to the requested digit count", () => {
|
||||
expect(toFixedNum(3.14159, 2)).toBe(3.14);
|
||||
expect(toFixedNum(3.14159, 0)).toBe(3);
|
||||
expect(toFixedNum(2.5, 0)).toBe(3);
|
||||
});
|
||||
|
||||
it("defaults to 2 digits", () => {
|
||||
expect(toFixedNum(1.23456)).toBe(1.23);
|
||||
});
|
||||
|
||||
it("returns null for non-finite or non-number input", () => {
|
||||
expect(toFixedNum(null)).toBeNull();
|
||||
expect(toFixedNum(undefined)).toBeNull();
|
||||
expect(toFixedNum(NaN)).toBeNull();
|
||||
expect(toFixedNum(Infinity)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("kibToGiB", () => {
|
||||
it("converts kibibytes to gibibytes", () => {
|
||||
expect(kibToGiB(1024 * 1024)).toBe(1);
|
||||
expect(kibToGiB(0)).toBe(0);
|
||||
expect(kibToGiB(2 * 1024 * 1024)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodePowerShellCommand", () => {
|
||||
it("UTF-16LE base64 encodes the script, decodable back to the source", () => {
|
||||
const script = "Write-Output 'hello'";
|
||||
const encoded = encodePowerShellCommand(script);
|
||||
expect(Buffer.from(encoded, "base64").toString("utf16le")).toBe(script);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock(
|
||||
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
|
||||
() => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}),
|
||||
);
|
||||
|
||||
import {
|
||||
execElevated,
|
||||
ElevationError,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, stderr = "", code: number | null = 0) {
|
||||
return { stdout, stderr, code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("execElevated (no force)", () => {
|
||||
it("returns the direct result when the command succeeds unprivileged", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("hello", "", 0));
|
||||
const r = await execElevated(fakeClient, "echo hello", "pw");
|
||||
expect(r.usedSudo).toBe(false);
|
||||
expect(r.stdout).toBe("hello");
|
||||
expect(execCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT escalate when stdout merely contains scary words", async () => {
|
||||
// A non-zero exit whose OUTPUT contains 'permission denied' but stderr does
|
||||
// not should be surfaced as-is, never retried under sudo.
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("log line: permission denied for user foo", "", 1),
|
||||
);
|
||||
const r = await execElevated(fakeClient, "grep denied /tmp/app.log", "pw");
|
||||
expect(r.usedSudo).toBe(false);
|
||||
expect(execCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("escalates when stderr indicates a permission problem", async () => {
|
||||
execCommand
|
||||
.mockResolvedValueOnce(result("", "Permission denied", 1))
|
||||
.mockResolvedValueOnce(result("__TX_SUDO_OK__\nelevated output", "", 0));
|
||||
const r = await execElevated(fakeClient, "cat /etc/shadow", "pw");
|
||||
expect(r.usedSudo).toBe(true);
|
||||
expect(r.stdout).toBe("elevated output");
|
||||
expect(execCommand).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws SUDO_REQUIRED when elevation is needed but no password is set", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", "Permission denied", 1));
|
||||
await expect(
|
||||
execElevated(fakeClient, "cat /etc/shadow", undefined),
|
||||
).rejects.toMatchObject({ code: "SUDO_REQUIRED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("execElevated (forced)", () => {
|
||||
it("strips the success marker from stdout", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("__TX_SUDO_OK__\nthe real output\n", "", 0),
|
||||
);
|
||||
const r = await execElevated(fakeClient, "id", "pw", { forceSudo: true });
|
||||
expect(r.stdout).toBe("the real output\n");
|
||||
expect(r.usedSudo).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT throw when command output contains 'incorrect password' but sudo authenticated", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
"__TX_SUDO_OK__\nUser entered an incorrect password earlier",
|
||||
"",
|
||||
0,
|
||||
),
|
||||
);
|
||||
const r = await execElevated(fakeClient, "tail /var/log/auth.log", "pw", {
|
||||
forceSudo: true,
|
||||
});
|
||||
expect(r.usedSudo).toBe(true);
|
||||
expect(r.stdout).toContain("incorrect password");
|
||||
});
|
||||
|
||||
it("throws SUDO_FAILED on a real wrong-password (no marker, sudo stderr)", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("", "sudo: 1 incorrect password attempt", 1),
|
||||
);
|
||||
await expect(
|
||||
execElevated(fakeClient, "id", "wrong", { forceSudo: true }),
|
||||
).rejects.toMatchObject({ code: "SUDO_FAILED" });
|
||||
});
|
||||
|
||||
it("throws NOT_SUDOER when the user is not in sudoers", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("", "deploy is not in the sudoers file.", 1),
|
||||
);
|
||||
await expect(
|
||||
execElevated(fakeClient, "id", "pw", { forceSudo: true }),
|
||||
).rejects.toMatchObject({ code: "NOT_SUDOER" });
|
||||
});
|
||||
|
||||
it("throws SUDO_REQUIRED when forced without a password", async () => {
|
||||
await expect(
|
||||
execElevated(fakeClient, "id", undefined, { forceSudo: true }),
|
||||
).rejects.toBeInstanceOf(ElevationError);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHostKeyVerificationError,
|
||||
statusAfterAuthentication,
|
||||
statusAfterReachabilityCheck,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/host-status.js";
|
||||
|
||||
describe("isHostKeyVerificationError", () => {
|
||||
it.each([
|
||||
"Host denied (verification failed)",
|
||||
"Host key changed - please connect via Terminal to verify the new key",
|
||||
])("classifies %s", (message) => {
|
||||
expect(isHostKeyVerificationError(new Error(message))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not classify ordinary authentication failures", () => {
|
||||
expect(isHostKeyVerificationError(new Error("Permission denied"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host availability status", () => {
|
||||
it("does not call a TCP-reachable host online before authentication", () => {
|
||||
expect(statusAfterReachabilityCheck(true)).toBe("reachable");
|
||||
});
|
||||
|
||||
it("keeps a verified host online across later reachability checks", () => {
|
||||
expect(statusAfterReachabilityCheck(true, "online")).toBe("online");
|
||||
});
|
||||
|
||||
it("marks successful authentication online", () => {
|
||||
expect(statusAfterAuthentication(true, "reachable")).toBe("online");
|
||||
});
|
||||
|
||||
it("downgrades failed authentication without hiding reachability", () => {
|
||||
expect(statusAfterAuthentication(false, "online")).toBe("reachable");
|
||||
expect(statusAfterAuthentication(false, "offline")).toBe("offline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
authFailureTracker,
|
||||
canStartInitialMetrics,
|
||||
ConcurrentLimiter,
|
||||
HostPollCache,
|
||||
metricsConcurrencyFor,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/state.js";
|
||||
|
||||
describe("auth failure tracking", () => {
|
||||
it("permanently pauses polling after a host key mismatch", () => {
|
||||
const hostId = 991266;
|
||||
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
|
||||
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
expect(authFailureTracker.getSkipReason(hostId)).toContain(
|
||||
"host key changed",
|
||||
);
|
||||
|
||||
authFailureTracker.reset(hostId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initial metrics admission", () => {
|
||||
it("requires both an active viewer and a confirmed online status", () => {
|
||||
expect(canStartInitialMetrics("online", true)).toBe(true);
|
||||
expect(canStartInitialMetrics("reachable", true)).toBe(true);
|
||||
expect(canStartInitialMetrics("offline", true)).toBe(false);
|
||||
expect(canStartInitialMetrics(undefined, true)).toBe(false);
|
||||
expect(canStartInitialMetrics("online", false)).toBe(false);
|
||||
expect(canStartInitialMetrics(undefined, true, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConcurrentLimiter", () => {
|
||||
it("never exceeds max concurrent runners", async () => {
|
||||
const limiter = new ConcurrentLimiter(2);
|
||||
let peak = 0;
|
||||
let current = 0;
|
||||
|
||||
const job = async () => {
|
||||
current += 1;
|
||||
peak = Math.max(peak, current);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
current -= 1;
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
]);
|
||||
|
||||
expect(peak).toBeLessThanOrEqual(2);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("runs waiters in FIFO order after a slot frees", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
const order: number[] = [];
|
||||
|
||||
const first = limiter.run(async () => {
|
||||
order.push(1);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
const second = limiter.run(async () => {
|
||||
order.push(2);
|
||||
});
|
||||
const third = limiter.run(async () => {
|
||||
order.push(3);
|
||||
});
|
||||
|
||||
await Promise.all([first, second, third]);
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("rejects invalid maxConcurrent", () => {
|
||||
expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/);
|
||||
});
|
||||
|
||||
describe("setLimit", () => {
|
||||
it("releases queued waiters as soon as the ceiling is raised", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(1);
|
||||
expect(limiter.pendingCount).toBe(3);
|
||||
|
||||
// Widening must drain the backlog without waiting for the running job.
|
||||
limiter.setLimit(4);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(4);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
|
||||
release.forEach((fn) => fn());
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not over-release beyond the new ceiling", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
// Later waves of woken jobs enqueue their own resolvers, so draining has
|
||||
// to keep going until nothing is left rather than flushing a snapshot.
|
||||
const drain = async () => {
|
||||
while (release.length > 0) {
|
||||
release.splice(0).forEach((fn) => fn());
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
};
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
limiter.setLimit(3);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(3);
|
||||
expect(limiter.pendingCount).toBe(2);
|
||||
|
||||
await drain();
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("lets running work finish when the ceiling shrinks", async () => {
|
||||
const limiter = new ConcurrentLimiter(4);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
const drain = async () => {
|
||||
while (release.length > 0) {
|
||||
release.splice(0).forEach((fn) => fn());
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
};
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(4);
|
||||
|
||||
// Shrinking never kills in-flight work; it applies to later releases.
|
||||
limiter.setLimit(2);
|
||||
expect(limiter.activeCount).toBe(4);
|
||||
|
||||
await drain();
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
// The two queued jobs ran only after the shrink, so they never pushed
|
||||
// occupancy back up to the old width.
|
||||
expect(peak).toBe(4);
|
||||
});
|
||||
|
||||
it("rejects an invalid new limit", () => {
|
||||
const limiter = new ConcurrentLimiter(2);
|
||||
expect(() => limiter.setLimit(0)).toThrow(/maxConcurrent/);
|
||||
expect(limiter.limit).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("metricsConcurrencyFor", () => {
|
||||
it("keeps a floor for small installs", () => {
|
||||
expect(metricsConcurrencyFor(0, {})).toBe(5);
|
||||
expect(metricsConcurrencyFor(1, {})).toBe(5);
|
||||
expect(metricsConcurrencyFor(60, {})).toBe(5);
|
||||
});
|
||||
|
||||
it("scales up with the fleet", () => {
|
||||
expect(metricsConcurrencyFor(200, {})).toBe(10);
|
||||
expect(metricsConcurrencyFor(500, {})).toBe(25);
|
||||
});
|
||||
|
||||
it("caps so a huge fleet cannot exhaust the host", () => {
|
||||
expect(metricsConcurrencyFor(100000, {})).toBe(50);
|
||||
});
|
||||
|
||||
it("lets an operator override the sizing", () => {
|
||||
expect(metricsConcurrencyFor(1000, { METRICS_POLL_CONCURRENCY: "8" })).toBe(
|
||||
8,
|
||||
);
|
||||
});
|
||||
|
||||
it("still caps an oversized override", () => {
|
||||
expect(
|
||||
metricsConcurrencyFor(10, { METRICS_POLL_CONCURRENCY: "9999" }),
|
||||
).toBe(50);
|
||||
});
|
||||
|
||||
it("ignores a nonsense override", () => {
|
||||
expect(
|
||||
metricsConcurrencyFor(500, { METRICS_POLL_CONCURRENCY: "abc" }),
|
||||
).toBe(25);
|
||||
});
|
||||
|
||||
it("sweeps 500 hosts inside a 30s interval, which the old fixed 5 could not", () => {
|
||||
const POLL_MS = 400;
|
||||
const hosts = 500;
|
||||
const sweepAt = (c: number) => Math.ceil(hosts / c) * POLL_MS;
|
||||
|
||||
expect(sweepAt(5)).toBeGreaterThan(30_000);
|
||||
expect(sweepAt(metricsConcurrencyFor(hosts, {}))).toBeLessThan(30_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostPollCache", () => {
|
||||
it("returns cached host within TTL for the same user", () => {
|
||||
const cache = new HostPollCache<{ id: number; name: string }>(60_000);
|
||||
cache.set(1, "user-a", { id: 1, name: "alpha" });
|
||||
expect(cache.get(1, "user-a")).toEqual({ id: 1, name: "alpha" });
|
||||
expect(cache.get(1, "user-b")).toBeNull();
|
||||
});
|
||||
|
||||
it("expires entries after TTL", () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new HostPollCache<{ id: number }>(1_000);
|
||||
cache.set(7, "u", { id: 7 });
|
||||
expect(cache.get(7, "u")).toEqual({ id: 7 });
|
||||
vi.advanceTimersByTime(1_001);
|
||||
expect(cache.get(7, "u")).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("invalidate drops a host or the whole cache", () => {
|
||||
const cache = new HostPollCache<{ id: number }>(60_000);
|
||||
cache.set(1, "u", { id: 1 });
|
||||
cache.set(2, "u", { id: 2 });
|
||||
cache.invalidate(1);
|
||||
expect(cache.get(1, "u")).toBeNull();
|
||||
expect(cache.get(2, "u")).toEqual({ id: 2 });
|
||||
cache.invalidate();
|
||||
expect(cache.get(2, "u")).toBeNull();
|
||||
});
|
||||
});
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
|
||||
import { HostMetricsHistoryRepository } from "../../../../../src/backend/database/repositories/host-metrics-history-repository.js";
|
||||
|
||||
describe("HostMetricsHistoryRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HostMetricsHistoryRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('user-1', 'user-1', 'hash'),
|
||||
('user-2', 'user-2', 'hash');
|
||||
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password');
|
||||
INSERT INTO host_metrics_history (
|
||||
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
|
||||
)
|
||||
VALUES
|
||||
(1, '2026-01-01 00:00:00', 10, 20, 30, 100, 200),
|
||||
(1, '2026-01-02 00:00:00', 11, 21, 31, 101, 201),
|
||||
(1, '2999-01-01 00:00:00', 12, 22, 32, 102, 202),
|
||||
(2, '2026-01-02 00:00:00', 99, 99, 99, 999, 999);
|
||||
`);
|
||||
|
||||
return new HostMetricsHistoryRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists metrics history rows by range", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.create({
|
||||
hostId: 1,
|
||||
cpuPercent: 12,
|
||||
memPercent: 22,
|
||||
diskPercent: 32,
|
||||
netRxBytes: 102,
|
||||
netTxBytes: 202,
|
||||
});
|
||||
|
||||
const rows = await repo.listRange(
|
||||
1,
|
||||
"2026-01-01 00:00:00",
|
||||
"2026-01-02 23:59:59",
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.cpuPercent)).toEqual([10, 11]);
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("prunes old history for a host only", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.pruneOlderThan(1, 1);
|
||||
|
||||
const rows = await repo.listRange(
|
||||
1,
|
||||
"2000-01-01 00:00:00",
|
||||
"2999-12-31 23:59:59",
|
||||
);
|
||||
expect(rows.map((row) => row.ts)).toEqual(["2999-01-01 00:00:00"]);
|
||||
expect(
|
||||
await repo.listRange(2, "2026-01-01 00:00:00", "2026-01-03 00:00:00"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
|
||||
import { HostMetricsPreferenceRepository } from "../../../../../src/backend/database/repositories/host-metrics-preference-repository.js";
|
||||
|
||||
describe("HostMetricsPreferenceRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HostMetricsPreferenceRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, stats_config, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'one', '{}', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '{}', '10.0.0.1', 22, 'root', 'password');
|
||||
INSERT INTO host_metrics_preferences (
|
||||
user_id, host_id, layout, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'user-1',
|
||||
1,
|
||||
'{"slots":[],"columns":3}',
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
'2026-01-01T00:00:00.000Z'
|
||||
);
|
||||
`);
|
||||
|
||||
return new HostMetricsPreferenceRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds a saved layout by user and host", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const existing = await repo.findByUserAndHost("user-1", 1);
|
||||
expect(existing?.layout).toBe('{"slots":[],"columns":3}');
|
||||
expect(await repo.findByUserAndHost("user-1", 2)).toBeNull();
|
||||
});
|
||||
|
||||
it("updates and inserts layouts with write notifications", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const updated = await repo.upsertLayout(
|
||||
"user-1",
|
||||
1,
|
||||
'{"slots":[{"id":"cpu"}],"columns":2}',
|
||||
"2026-02-01T00:00:00.000Z",
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
id: 1,
|
||||
layout: '{"slots":[{"id":"cpu"}],"columns":2}',
|
||||
updatedAt: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const created = await repo.upsertLayout(
|
||||
"user-2",
|
||||
2,
|
||||
'{"slots":[{"id":"mem"}],"columns":1}',
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
);
|
||||
expect(created).toMatchObject({
|
||||
userId: "user-2",
|
||||
hostId: 2,
|
||||
layout: '{"slots":[{"id":"mem"}],"columns":1}',
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("updates host stats config for the owning user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.updateHostStatsConfig(
|
||||
"user-1",
|
||||
1,
|
||||
'{"enabledWidgets":["cpu","memory"]}',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repo.updateHostStatsConfig("user-2", 1, '{"enabledWidgets":["disk"]}'),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes all metric preferences for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsertLayout("user-2", 2, '{"slots":["mem"]}');
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
|
||||
expect((await repo.findByUserAndHost("user-2", 2))?.layout).toBe(
|
||||
'{"slots":["mem"]}',
|
||||
);
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
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, "../../src/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 "../../../src/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 "../../../src/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 "../../../src/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 "../../../src/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 "../../../src/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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const SAMPLE = {
|
||||
cpu: { percent: 12, cores: 8, load: [0.5, 0.4, 0.3] },
|
||||
memory: { percent: 40, usedGiB: 12.8, totalGiB: 32 },
|
||||
disk: { percent: 55 },
|
||||
gpu: {
|
||||
source: "nvidia-smi",
|
||||
gpus: [
|
||||
{
|
||||
index: 0,
|
||||
uuid: "GPU-0",
|
||||
name: "NVIDIA GeForce RTX 2070 SUPER",
|
||||
driverVersion: "580.173.02",
|
||||
utilizationPercent: 0,
|
||||
memoryUsedMiB: 5689,
|
||||
memoryTotalMiB: 8192,
|
||||
memoryPercent: 69.4,
|
||||
temperatureCelsius: 35,
|
||||
powerDrawWatts: 24,
|
||||
powerLimitWatts: 215,
|
||||
fanPercent: 0,
|
||||
},
|
||||
],
|
||||
processes: [],
|
||||
},
|
||||
lastChecked: "2026-09-18T07:00:00.000Z",
|
||||
} as ServerMetrics;
|
||||
|
||||
vi.mock("@/main-axios.ts", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/main-axios.ts")>()),
|
||||
getServerStatusById: vi.fn(async () => ({ status: "online" })),
|
||||
getServerMetricsById: vi.fn(async () => SAMPLE),
|
||||
startMetricsPolling: vi.fn(async () => ({ viewerSessionId: "viewer-1" })),
|
||||
stopMetricsPolling: vi.fn(async () => undefined),
|
||||
sendMetricsHeartbeat: vi.fn(async () => true),
|
||||
getSnippets: vi.fn(async () => []),
|
||||
getSSHHosts: vi.fn(async () => []),
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/UiPreferencesContext", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/contexts/UiPreferencesContext")>()),
|
||||
useAreaPreferences: () => ({ columns: 3 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-confirmation.ts", () => ({
|
||||
useConfirmation: () => ({ confirmWithToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/frontend/hooks/useHostMetricsPreferences.ts", () => ({
|
||||
useHostMetricsPreferences: () => ({
|
||||
layout: {
|
||||
slots: [{ id: "gpu", order: 0, colSpan: 2, height: null }],
|
||||
columns: 3,
|
||||
},
|
||||
setLayout: vi.fn(),
|
||||
loaded: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { HostMetricsTab } from "../../src/frontend/HostMetricsTab";
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver ??= class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("HostMetricsTab", () => {
|
||||
it("draws the trend lines from the first metrics sample, before the next poll", async () => {
|
||||
render(
|
||||
<HostMetricsTab
|
||||
hostConfig={{
|
||||
id: 1,
|
||||
name: "gpu-box",
|
||||
ip: "10.0.0.5",
|
||||
username: "admin",
|
||||
port: 22,
|
||||
authType: "password",
|
||||
statsConfig: JSON.stringify({
|
||||
metricsEnabled: true,
|
||||
metricsInterval: 30,
|
||||
enabledWidgets: ["gpu"],
|
||||
}),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trend = await screen.findByRole(
|
||||
"figure",
|
||||
{ name: "hostMetrics.gpu.memory" },
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(trend.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import type { GpuMetrics } from "@/types/stats-widgets";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { GpuCard } from "../../../src/frontend/cards/GpuCard";
|
||||
|
||||
const GPU_METRICS: GpuMetrics = {
|
||||
source: "nvidia-smi",
|
||||
gpus: [
|
||||
{
|
||||
index: 0,
|
||||
uuid: "GPU-rtx",
|
||||
name: "NVIDIA GeForce RTX 4090",
|
||||
driverVersion: "550.54.14",
|
||||
utilizationPercent: 37,
|
||||
memoryUsedMiB: 8123,
|
||||
memoryTotalMiB: 24564,
|
||||
memoryPercent: 33.1,
|
||||
temperatureCelsius: 61,
|
||||
powerDrawWatts: 182.4,
|
||||
powerLimitWatts: 450,
|
||||
fanPercent: 45,
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
uuid: "GPU-h100",
|
||||
name: "NVIDIA H100 80GB HBM3",
|
||||
driverVersion: "550.54.14",
|
||||
utilizationPercent: 0,
|
||||
memoryUsedMiB: 1,
|
||||
memoryTotalMiB: 81559,
|
||||
memoryPercent: 0,
|
||||
temperatureCelsius: 34,
|
||||
powerDrawWatts: null,
|
||||
powerLimitWatts: 700,
|
||||
fanPercent: null,
|
||||
},
|
||||
],
|
||||
processes: [
|
||||
{
|
||||
gpuIndex: 0,
|
||||
pid: 12345,
|
||||
name: "/usr/bin/python3",
|
||||
memoryUsedMiB: 7890,
|
||||
},
|
||||
{ gpuIndex: 1, pid: 23456, name: "worker", memoryUsedMiB: null },
|
||||
],
|
||||
};
|
||||
|
||||
function renderCard(gpu: GpuMetrics | undefined, gpuHistories = {}) {
|
||||
const metrics = { gpu } as ServerMetrics;
|
||||
render(<GpuCard metrics={metrics} gpuHistories={gpuHistories} />);
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("GpuCard", () => {
|
||||
it("explains that no NVIDIA GPU was found", () => {
|
||||
renderCard({ source: "none", gpus: [], processes: [] });
|
||||
|
||||
expect(screen.getByText("hostMetrics.gpu.noDevice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats metrics from a backend without gpu support as no GPU", () => {
|
||||
renderCard(undefined);
|
||||
|
||||
expect(screen.getByText("hostMetrics.gpu.noDevice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the readings of every GPU", () => {
|
||||
renderCard(GPU_METRICS);
|
||||
|
||||
expect(screen.getByText("NVIDIA GeForce RTX 4090")).toBeInTheDocument();
|
||||
expect(screen.getByText("NVIDIA H100 80GB HBM3")).toBeInTheDocument();
|
||||
expect(screen.getByText("7.9/24.0G")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.0/79.6G")).toBeInTheDocument();
|
||||
expect(screen.getByText("61°C")).toBeInTheDocument();
|
||||
expect(screen.getByText("182/450W")).toBeInTheDocument();
|
||||
expect(screen.getByText("N/A/700W")).toBeInTheDocument();
|
||||
expect(screen.getByText("45%")).toBeInTheDocument();
|
||||
expect(screen.queryByText("hostMetrics.gpu.noDevice")).toBeNull();
|
||||
});
|
||||
|
||||
it("draws each GPU's history as labelled utilization and VRAM trends", () => {
|
||||
renderCard(
|
||||
{ ...GPU_METRICS, gpus: [GPU_METRICS.gpus[0]] },
|
||||
{ 0: { utilization: [10, 20], memory: [30, 31] } },
|
||||
);
|
||||
|
||||
for (const name of [
|
||||
"hostMetrics.gpu.utilization",
|
||||
"hostMetrics.gpu.memory",
|
||||
]) {
|
||||
const trend = screen.getByRole("figure", { name });
|
||||
expect(trend.querySelector("svg")).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("lists GPU processes by executable name with the GPU and VRAM they hold", () => {
|
||||
renderCard(GPU_METRICS);
|
||||
|
||||
const python = screen.getByText("python3");
|
||||
expect(python).toHaveAttribute("title", "/usr/bin/python3");
|
||||
expect(screen.getByText("7.7G · GPU 0")).toBeInTheDocument();
|
||||
expect(screen.getByText("N/A · GPU 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when no process is using a GPU", () => {
|
||||
renderCard({ ...GPU_METRICS, processes: [] });
|
||||
|
||||
expect(screen.getByText("hostMetrics.gpu.noProcesses")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GpuDevice } from "@/types/stats-widgets";
|
||||
import { appendGpuHistories } from "../../../src/frontend/cards/gpu-history";
|
||||
|
||||
function gpu(
|
||||
index: number,
|
||||
utilizationPercent: number | null,
|
||||
memoryPercent: number | null,
|
||||
): GpuDevice {
|
||||
return {
|
||||
index,
|
||||
uuid: `GPU-${index}`,
|
||||
name: `GPU ${index}`,
|
||||
driverVersion: "550.54.14",
|
||||
utilizationPercent,
|
||||
memoryUsedMiB: null,
|
||||
memoryTotalMiB: null,
|
||||
memoryPercent,
|
||||
temperatureCelsius: null,
|
||||
powerDrawWatts: null,
|
||||
powerLimitWatts: null,
|
||||
fanPercent: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("appendGpuHistories", () => {
|
||||
it("records utilization and memory in a separate series per GPU", () => {
|
||||
const first = appendGpuHistories({}, [gpu(0, 10, 20), gpu(1, 90, 5)], 30);
|
||||
const second = appendGpuHistories(
|
||||
first,
|
||||
[gpu(0, 15, 25), gpu(1, 80, 6)],
|
||||
30,
|
||||
);
|
||||
|
||||
expect(second).toEqual({
|
||||
0: { utilization: [10, 15], memory: [20, 25] },
|
||||
1: { utilization: [90, 80], memory: [5, 6] },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps only the newest samples once the series is full", () => {
|
||||
let histories = {};
|
||||
for (const value of [1, 2, 3, 4]) {
|
||||
histories = appendGpuHistories(histories, [gpu(0, value, value)], 3);
|
||||
}
|
||||
|
||||
expect(histories).toEqual({
|
||||
0: { utilization: [2, 3, 4], memory: [2, 3, 4] },
|
||||
});
|
||||
});
|
||||
|
||||
it("records an unavailable reading as zero", () => {
|
||||
expect(appendGpuHistories({}, [gpu(0, null, null)], 30)).toEqual({
|
||||
0: { utilization: [0], memory: [0] },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops the series of a GPU that is no longer reported", () => {
|
||||
const before = appendGpuHistories({}, [gpu(0, 10, 10), gpu(1, 20, 20)], 30);
|
||||
|
||||
expect(appendGpuHistories(before, [gpu(0, 11, 11)], 30)).toEqual({
|
||||
0: { utilization: [10, 11], memory: [10, 11] },
|
||||
});
|
||||
expect(appendGpuHistories(before, undefined, 30)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const statsApiMock = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }));
|
||||
const remoteStatsApiMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const sshHostApiMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const resolveConnectionOriginMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
statsApi: statsApiMock,
|
||||
sshHostApi: sshHostApiMock,
|
||||
getRemoteStatsApi: () => remoteStatsApiMock,
|
||||
isElectron: () => true,
|
||||
handleApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/hosts-request-cache", () => ({
|
||||
getCachedServerStatuses: (loader: () => Promise<unknown>) => loader(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/connection-origin", () => ({
|
||||
resolveConnectionOrigin: resolveConnectionOriginMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
getAllServerStatuses,
|
||||
getServerMetricsById,
|
||||
sendMetricsHeartbeat,
|
||||
} from "../../../../src/ui/api/host-metrics-status-api";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.electronAPI = {
|
||||
invoke: vi.fn(async (channel: string) =>
|
||||
channel === "get-remote-sync-config"
|
||||
? { serverUrl: "https://termix.example.test" }
|
||||
: null,
|
||||
),
|
||||
} as unknown as NonNullable<typeof window.electronAPI>;
|
||||
statsApiMock.get.mockResolvedValue({ data: { 1: { status: "online" } } });
|
||||
remoteStatsApiMock.get.mockResolvedValue({
|
||||
data: { 2: { status: "reachable" } },
|
||||
});
|
||||
});
|
||||
|
||||
describe("status check origin routing", () => {
|
||||
it("only asks the embedded backend to poll local-origin hosts", async () => {
|
||||
sshHostApiMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 1, connectionOrigin: "local" },
|
||||
{ id: 2, connectionOrigin: "remote" },
|
||||
],
|
||||
});
|
||||
resolveConnectionOriginMock.mockImplementation(async (host) =>
|
||||
host.connectionOrigin === "local" ? "local" : "remote",
|
||||
);
|
||||
|
||||
await expect(getAllServerStatuses()).resolves.toEqual({
|
||||
1: { status: "online" },
|
||||
2: { status: "reachable" },
|
||||
});
|
||||
|
||||
expect(statsApiMock.get).toHaveBeenCalledWith("/status", {
|
||||
timeout: 2000,
|
||||
params: { hostIds: "1" },
|
||||
__silentRetry: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends an empty allowlist when every host uses the remote server", async () => {
|
||||
sshHostApiMock.get.mockResolvedValue({
|
||||
data: [{ id: 2, connectionOrigin: "remote" }],
|
||||
});
|
||||
resolveConnectionOriginMock.mockResolvedValue("remote");
|
||||
|
||||
await getAllServerStatuses();
|
||||
|
||||
expect(statsApiMock.get).toHaveBeenCalledWith("/status", {
|
||||
timeout: 2000,
|
||||
params: { hostIds: "" },
|
||||
__silentRetry: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("metrics request coalescing", () => {
|
||||
it("shares simultaneous reads for the same host", async () => {
|
||||
statsApiMock.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: { cpu: { percent: 12 } },
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
getServerMetricsById(991),
|
||||
getServerMetricsById(991),
|
||||
]);
|
||||
|
||||
expect(first).toEqual({ cpu: { percent: 12 } });
|
||||
expect(second).toBe(first);
|
||||
expect(statsApiMock.get).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("metrics viewer heartbeat", () => {
|
||||
it("reports a swept viewer session without throwing or logging", async () => {
|
||||
statsApiMock.post.mockResolvedValueOnce({ status: 404 });
|
||||
|
||||
await expect(sendMetricsHeartbeat("expired-viewer")).resolves.toBe(false);
|
||||
expect(statsApiMock.post).toHaveBeenCalledWith(
|
||||
"/metrics/heartbeat",
|
||||
{ viewerSessionId: "expired-viewer" },
|
||||
{ validateStatus: expect.any(Function) },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a live viewer session", async () => {
|
||||
statsApiMock.post.mockResolvedValueOnce({ status: 200 });
|
||||
await expect(sendMetricsHeartbeat("live-viewer")).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import type { GpuMetrics } from "@/types/stats-widgets";
|
||||
import { metricsChangeKey } from "../../src/frontend/metrics-change-key";
|
||||
|
||||
function poll(gpu: GpuMetrics): ServerMetrics {
|
||||
return {
|
||||
cpu: { percent: 3, cores: 8, load: [0.1, 0.1, 0.1] },
|
||||
memory: { percent: 40, usedGiB: 12, totalGiB: 32 },
|
||||
disk: { percent: 55 },
|
||||
gpu,
|
||||
lastChecked: "2026-09-18T00:00:00.000Z",
|
||||
} as ServerMetrics;
|
||||
}
|
||||
|
||||
function gpuAt(utilizationPercent: number, memoryPercent: number): GpuMetrics {
|
||||
return {
|
||||
source: "nvidia-smi",
|
||||
gpus: [
|
||||
{
|
||||
index: 0,
|
||||
uuid: "GPU-0",
|
||||
name: "NVIDIA GeForce RTX 4090",
|
||||
driverVersion: "550.54.14",
|
||||
utilizationPercent,
|
||||
memoryUsedMiB: 1000,
|
||||
memoryTotalMiB: 24564,
|
||||
memoryPercent,
|
||||
temperatureCelsius: 50,
|
||||
powerDrawWatts: 30,
|
||||
powerLimitWatts: 450,
|
||||
fanPercent: 30,
|
||||
},
|
||||
],
|
||||
processes: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("metricsChangeKey", () => {
|
||||
it("changes when only GPU utilization moves", () => {
|
||||
expect(metricsChangeKey(poll(gpuAt(0, 4)))).not.toBe(
|
||||
metricsChangeKey(poll(gpuAt(95, 4))),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes when only GPU memory moves", () => {
|
||||
expect(metricsChangeKey(poll(gpuAt(0, 4)))).not.toBe(
|
||||
metricsChangeKey(poll(gpuAt(0, 80))),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes when a GPU process starts", () => {
|
||||
const idle = gpuAt(0, 4);
|
||||
const busy: GpuMetrics = {
|
||||
...idle,
|
||||
processes: [
|
||||
{ gpuIndex: 0, pid: 4242, name: "python3", memoryUsedMiB: 900 },
|
||||
],
|
||||
};
|
||||
|
||||
expect(metricsChangeKey(poll(idle))).not.toBe(metricsChangeKey(poll(busy)));
|
||||
});
|
||||
|
||||
it("ignores GPU jitter inside the same 5% bucket", () => {
|
||||
expect(metricsChangeKey(poll(gpuAt(41, 20)))).toBe(
|
||||
metricsChangeKey(poll(gpuAt(42, 21))),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
selectTemperatureSensor,
|
||||
temperaturePreferenceKey,
|
||||
} from "../../src/frontend/temperature-preference";
|
||||
|
||||
const sensors = [
|
||||
{ label: "k10temp: Tctl", celsius: 55 },
|
||||
{ label: "nvme-pci-0100: Composite", celsius: 70 },
|
||||
];
|
||||
|
||||
describe("temperature sensor preference", () => {
|
||||
it("selects the configured sensor by its complete label", () => {
|
||||
expect(selectTemperatureSensor(sensors, "k10temp: Tctl")).toEqual(
|
||||
sensors[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back when a sensor disappears", () => {
|
||||
expect(selectTemperatureSensor(sensors, "missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("scopes the preference to a host", () => {
|
||||
expect(temperaturePreferenceKey(42)).toBe(
|
||||
"termix-host-metrics:42:temperature-sensor",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user