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

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

No files matched your search

@@ -30,7 +30,7 @@ vi.mock("../../hosts/host-resolver.js", () => ({
}));
const execCommand = vi.fn();
vi.mock("../../hosts/metrics/widgets/common-utils.js", () => ({
vi.mock("../../hosts/metrics-shared/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}));
vi.mock("../../hosts/ssh-client-factory.js", () => ({
@@ -73,15 +73,15 @@ vi.mock("../../../hosts/ssh-connection-pool.js", () => ({
) => fn({}),
}));
vi.mock("../../../hosts/metrics/widgets/common-utils.js", () => ({
vi.mock("../../../hosts/metrics-shared/common-utils.js", () => ({
execCommand: vi.fn(async () => ({ stdout: "ok", stderr: "", code: 0 })),
}));
vi.mock("../../../hosts/metrics/managers/platform.js", () => ({
vi.mock("../../../hosts/metrics-shared/platform.js", () => ({
detectPlatform: vi.fn(async () => ({ pkg: "apt", osPrettyName: "Debian" })),
}));
vi.mock("../../../hosts/metrics/managers/exec-elevated.js", async () => {
vi.mock("../../../hosts/metrics-shared/exec-elevated.js", async () => {
class ElevationError extends Error {
code: string;
constructor(code: string, message: string) {
@@ -95,11 +95,11 @@ vi.mock("../../../hosts/metrics/managers/exec-elevated.js", async () => {
};
});
vi.mock("../../../hosts/metrics/managers/packages.js", () => ({
vi.mock("../../../hosts/metrics-shared/package-commands.js", () => ({
buildPackageActionCommand: vi.fn(() => "apt-get install -y foo"),
}));
vi.mock("../../../hosts/metrics/managers/validation.js", () => ({
vi.mock("../../../hosts/metrics-shared/validation.js", () => ({
isValidPackageName: (v: unknown) => typeof v === "string" && v.length > 0,
}));
@@ -320,7 +320,7 @@ describe("POST /:id/packages", () => {
it("surfaces an ElevationError as a per-host error, not a request failure", async () => {
const { execElevated, ElevationError } =
await import("../../../hosts/metrics/managers/exec-elevated.js");
await import("../../../hosts/metrics-shared/exec-elevated.js");
(execElevated as ReturnType<typeof vi.fn>).mockRejectedValue(
new ElevationError("SUDO_REQUIRED", "sudo password required"),
);
@@ -347,7 +347,7 @@ describe("POST /:id/packages", () => {
});
const { execElevated } =
await import("../../../hosts/metrics/managers/exec-elevated.js");
await import("../../../hosts/metrics-shared/exec-elevated.js");
(execElevated as ReturnType<typeof vi.fn>).mockResolvedValue({
code: 0,
stdout: "done",
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Client } from "ssh2";
const execElevated = vi.fn();
vi.mock("../../../hosts/metrics/managers/exec-elevated.js", () => ({
vi.mock("../../../hosts/metrics-shared/exec-elevated.js", () => ({
execElevated: (...args: unknown[]) => execElevated(...args),
}));
@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
import type { WebSocket } from "ws";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
import { authFailureTracker } from "../../hosts/metrics/state.js";
import { authFailureTracker } from "../../hosts/metrics-shared/state.js";
const { updateHostKey } = vi.hoisted(() => ({ updateHostKey: vi.fn() }));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -3,7 +3,7 @@ import {
toFixedNum,
kibToGiB,
encodePowerShellCommand,
} from "../../../../hosts/metrics/widgets/common-utils.js";
} from "../../../hosts/metrics-shared/common-utils.js";
describe("toFixedNum", () => {
it("rounds to the requested digit count", () => {
@@ -1,14 +1,14 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
vi.mock("../../../hosts/metrics-shared/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}));
import {
execElevated,
ElevationError,
} from "../../../../hosts/metrics/managers/exec-elevated.js";
} from "../../../hosts/metrics-shared/exec-elevated.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
isHostKeyVerificationError,
statusAfterAuthentication,
statusAfterReachabilityCheck,
} from "../../../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");
});
});
@@ -5,7 +5,7 @@ import {
ConcurrentLimiter,
HostPollCache,
metricsConcurrencyFor,
} from "../../../hosts/metrics/state.js";
} from "../../../hosts/metrics-shared/state.js";
describe("auth failure tracking", () => {
it("permanently pauses polling after a host key mismatch", () => {
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}));
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}));
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
@@ -136,3 +136,53 @@ describe("bundled docker plugin", () => {
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled host-metrics plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "host-metrics");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("host-metrics"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("host-metrics");
expect(manifest?.category).toBe("Monitoring");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("host-metrics"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("host-metrics")).toBe(true);
expect(runsInProcess("host-metrics", manifest!.permissions)).toBe(true);
});
it("contributes the host-metrics tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("host-metrics"));
const tab = manifest?.contributes?.tabs?.[0];
// The shell keys tab content off this id, so it has to stay "host-metrics".
expect(tab?.id).toBe("host-metrics");
expect(tab?.openFrom).toContain("host-context-menu");
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"host-metrics",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
+1 -1
View File
@@ -137,7 +137,7 @@ describe("hosts publishers route through the bus", () => {
it("notifyAutomationStatus and notifyAutomationMetrics emit their topics", async () => {
const { notifyAutomationStatus, notifyAutomationMetrics } =
await import("../../hosts/metrics/automation-bridge.js");
await import("../../../../plugins/host-metrics/backend/automation-bridge.js");
const status = vi.fn();
const metrics = vi.fn();
+20 -2
View File
@@ -39,16 +39,18 @@ function manifest(overrides: Record<string, unknown> = {}) {
}
describe("first-party allowlist", () => {
it("contains only ssh-terminal and docker", () => {
it("contains only ssh-terminal, docker and host-metrics", () => {
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"docker",
"host-metrics",
"ssh-terminal",
]);
});
it("recognises ssh-terminal and docker and nothing else", () => {
it("recognises ssh-terminal, docker and host-metrics and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("host-metrics")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false);
@@ -61,11 +63,15 @@ describe("runsInProcess", () => {
true,
);
expect(runsInProcess("docker", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("host-metrics", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
// On the list but not asking for it: stays in a worker, so the manifest
// remains an honest description of what the plugin does.
expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false);
expect(runsInProcess("docker", ["hosts.read"])).toBe(false);
expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false);
// Asking for it but not on the list.
expect(
@@ -121,6 +127,18 @@ describe("manifest gate on the reserved capability", () => {
expect(parsed?.id).toBe("docker");
});
it("allows it for host-metrics", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "host-metrics",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("host-metrics");
});
it("still accepts an ordinary manifest that never mentions it", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: ["hosts.read"] }),
@@ -35,7 +35,7 @@ vi.mock("../../../utils/discord-sender.js", () => ({
}));
const alertEngineModule =
await import("../../../hosts/metrics/alert-engine.js");
await import("../../../../../plugins/host-metrics/backend/alert-engine.js");
const cpuRule = {
id: 1,
@@ -5,7 +5,7 @@ import {
isTcpPingEnabled,
parseStatusHostIds,
tcpPingThroughJumpHost,
} from "../../../hosts/metrics/helpers.js";
} from "../../../../../plugins/host-metrics/backend/helpers.js";
import { createConnectionLog } from "../../../hosts/connection-log.js";
describe("supportsMetrics", () => {
@@ -5,11 +5,14 @@ import { beforeEach, expect, it, vi } from "vitest";
// Load the production route without starting metrics polling or SSH services.
const source = readFileSync(
new URL("../../../hosts/metrics/index.ts", import.meta.url),
new URL(
"../../../../../plugins/host-metrics/backend/routes.ts",
import.meta.url,
),
"utf8",
);
const ast = ts.createSourceFile(
"index.ts",
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
@@ -11,7 +11,10 @@ import path from "path";
describe("metrics service /internal/login-alert route order", () => {
it("is registered before the global auth middleware", () => {
const source = fs.readFileSync(
path.resolve(__dirname, "../../../hosts/metrics/index.ts"),
path.resolve(
__dirname,
"../../../../../plugins/host-metrics/backend/routes.ts",
),
"utf8",
);
@@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest";
import {
buildSudoCommand,
shellSingleQuote,
} from "../../../hosts/metrics/managers/exec-elevated.js";
import { parsePlatformProbe } from "../../../hosts/metrics/managers/platform.js";
} from "../../../../hosts/metrics-shared/exec-elevated.js";
import { parsePlatformProbe } from "../../../../hosts/metrics-shared/platform.js";
import {
isValidSystemdUnit,
isValidPid,
@@ -15,53 +15,53 @@ import {
isValidSignal,
isValidServiceAction,
isAllowedPath,
} from "../../../hosts/metrics/managers/validation.js";
} from "../../../../hosts/metrics-shared/validation.js";
import {
parseServiceList,
buildServiceActionCommand,
} from "../../../hosts/metrics/managers/services.js";
} from "../../../../../../plugins/host-metrics/backend/managers/services.js";
import {
parseProcessList,
buildKillCommand,
} from "../../../hosts/metrics/managers/processes.js";
} from "../../../../../../plugins/host-metrics/backend/managers/processes.js";
import {
parseDfMounts,
parseTopMemory,
} from "../../../hosts/metrics/managers/simple-reads.js";
} from "../../../../../../plugins/host-metrics/backend/managers/simple-reads.js";
import {
parseCrontab,
serializeCrontab,
isValidCronSchedule,
buildApplyCrontabCommand,
} from "../../../hosts/metrics/managers/cron.js";
} from "../../../../../../plugins/host-metrics/backend/managers/cron.js";
import {
buildPackageActionCommand,
parseUpgradable,
buildListUpgradableCommand,
} from "../../../hosts/metrics/managers/packages.js";
} from "../../../../hosts/metrics-shared/package-commands.js";
import {
buildIssueCommand,
buildRenewCommand,
buildRevokeCommand,
isValidCertName,
parseCertbotCertificates,
} from "../../../hosts/metrics/managers/ssl.js";
} from "../../../../../../plugins/host-metrics/backend/managers/ssl.js";
import {
buildIptablesRuleCommand,
buildNftRuleCommand,
} from "../../../hosts/metrics/managers/firewall.js";
} from "../../../../../../plugins/host-metrics/backend/managers/firewall.js";
import {
parsePasswd,
parseSudoers,
} from "../../../hosts/metrics/managers/users.js";
} from "../../../../../../plugins/host-metrics/backend/managers/users.js";
import {
buildHealthCheckCommand,
parseHealthResult,
} from "../../../hosts/metrics/managers/health.js";
} from "../../../../../../plugins/host-metrics/backend/managers/health.js";
import {
buildTailCommand,
clampLines,
} from "../../../hosts/metrics/managers/logs.js";
} from "../../../../../../plugins/host-metrics/backend/managers/logs.js";
describe("exec-elevated", () => {
it("single-quotes and escapes for the shell", () => {
@@ -13,7 +13,10 @@ import path from "path";
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, "../../../hosts/metrics/index.ts"),
path.resolve(
__dirname,
"../../../../../plugins/host-metrics/backend/routes.ts",
),
"utf8",
);
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import {
parseCpuLine,
parseDarwinCpuUsageLine,
} from "../../../../hosts/metrics/widgets/cpu-collector.js";
} from "../../../../../../plugins/host-metrics/backend/widgets/cpu-collector.js";
describe("parseCpuLine", () => {
it("parses a standard /proc/stat cpu line", () => {
@@ -9,7 +9,7 @@ import {
parseWindowsDiskJson,
parseDarwinMountTypes,
parseDarwinDfRows,
} from "../../../../hosts/metrics/widgets/disk-collector.js";
} from "../../../../../../plugins/host-metrics/backend/widgets/disk-collector.js";
describe("parseDfLines", () => {
it("parses df -T -P output into rows", () => {
@@ -3,7 +3,7 @@ import {
parseNvidiaSmiGpus,
parseNvidiaSmiOutput,
parseNvidiaSmiProcesses,
} from "../../../../hosts/metrics/widgets/gpu-collector.js";
} from "../../../../../../plugins/host-metrics/backend/widgets/gpu-collector.js";
const RTX_UUID = "GPU-3b8f2c1a-5d4e-7f60-8a9b-0c1d2e3f4a5b";
const H100_UUID = "GPU-9a8b7c6d-5e4f-3a2b-1c0d-e9f8a7b6c5d4";
@@ -5,7 +5,7 @@ import {
parseDarwinIfconfig,
parseDarwinNetstat,
parseWindowsAdapterJson,
} from "../../../../hosts/metrics/widgets/network-collector.js";
} from "../../../../../../plugins/host-metrics/backend/widgets/network-collector.js";
const PROC_NET = `Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import {
parseSensorsOutput,
parseSysfsThermalOutput,
} from "../../../../hosts/metrics/widgets/temperature-collector.js";
} from "../../../../../../plugins/host-metrics/backend/widgets/temperature-collector.js";
describe("temperature collectors", () => {
it("parses sysfs thermal zone output", () => {