feat: finish the host-metrics plugin, move host status into core (B16)

This commit is contained in:
LukeGus committed 2026-09-24 11:21:23 -05:00
1 parent 14eb558b85
commit 0a3f05d99d
308 files changed
+53073 -8985

No files matched your search

@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { HostStatsTab } from "../../src/frontend/HostEditorStatsTab";
afterEach(cleanup);
function renderSection(pluginSettings: Record<string, unknown> = {}) {
let form: Record<string, unknown> = {
quickActions: [],
pluginSettings: { "host-metrics": pluginSettings, other: { keep: 1 } },
};
const updateForm = vi.fn(
(patch: (current: Record<string, unknown>) => Record<string, unknown>) => {
form = { ...form, ...patch(form) };
},
);
render(
<HostStatsTab
form={form}
setField={vi.fn()}
updateForm={updateForm as never}
host={{ id: "7", name: "h", ip: "10.0.0.7", port: 22 }}
snippets={[]}
protocols={{ ssh: true }}
/>,
);
return { updateForm, form: () => form };
}
describe("HostStatsTab", () => {
it("writes the plugin's host settings and leaves other plugins alone", () => {
const { form } = renderSection({ metricsEnabled: true });
fireEvent.change(
screen.getByPlaceholderText("hosts.excludedMountsPlaceholder"),
{ target: { value: "/snap" } },
);
fireEvent.click(screen.getByText(/hosts.addExcludedMount/));
expect(form().pluginSettings).toEqual({
"host-metrics": { metricsEnabled: true, excludedMounts: ["/snap"] },
other: { keep: 1 },
});
});
it("has no status check options, which are core's", () => {
renderSection();
expect(screen.queryByText("hosts.statusChecksLabel")).toBeNull();
});
});
@@ -1,7 +1,7 @@
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";
import type { ServerMetrics } from "../../src/shared/metrics.js";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
@@ -35,25 +35,28 @@ const SAMPLE = {
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("../../src/frontend/host-metrics-api", async (importOriginal) => {
const api = {
getMetrics: vi.fn(async () => SAMPLE),
startMetrics: vi.fn(async () => ({
success: true,
viewerSessionId: "viewer-1",
})),
stopMetrics: vi.fn(async () => undefined),
heartbeat: vi.fn(async () => true),
submitTotp: vi.fn(),
};
return {
...(await importOriginal<object>()),
useHostMetricsApi: () => api,
};
});
vi.mock("@/contexts/UiPreferencesContext", async (importOriginal) => ({
...(await importOriginal<typeof import("@/contexts/UiPreferencesContext")>()),
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
...(await importOriginal<object>()),
useAreaPreferences: () => ({ columns: 3 }),
}));
vi.mock("@/hooks/use-confirmation.ts", () => ({
useConfirmation: () => ({ confirmWithToast: vi.fn() }),
logActivity: vi.fn(async () => undefined),
}));
vi.mock("../../src/frontend/hooks/useHostMetricsPreferences.ts", () => ({
@@ -90,11 +93,13 @@ describe("HostMetricsTab", () => {
username: "admin",
port: 22,
authType: "password",
statsConfig: JSON.stringify({
metricsEnabled: true,
metricsInterval: 30,
enabledWidgets: ["gpu"],
}),
pluginSettings: {
"host-metrics": {
metricsEnabled: true,
metricsInterval: 30,
enabledWidgets: ["gpu"],
},
},
}}
/>,
);
@@ -38,6 +38,18 @@ describe(`${manifest.id} activate`, () => {
}
});
it("fills core's metric slots and offers disk usage to other plugins", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.slot("dashboard.hostMetrics")).toEqual([
"host-metrics.dashboardHost",
]);
expect(rendered.registered.slot("homepage.hostMetrics")).toEqual([
"host-metrics.homepageHost",
]);
expect(rendered.registered.actions()).toContain("host-metrics.disk");
expect(rendered.registered.hostEditorSections()).toEqual(["host-metrics"]);
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
@@ -1,11 +1,12 @@
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";
import type { ServerMetrics } from "../../../src/shared/metrics.js";
import type { GpuMetrics } from "../../../src/shared/stats-widgets.js";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
import { GpuCard } from "../../../src/frontend/cards/GpuCard";
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { GpuDevice } from "@/types/stats-widgets";
import type { GpuDevice } from "../../../src/shared/stats-widgets.js";
import { appendGpuHistories } from "../../../src/frontend/cards/gpu-history";
function gpu(
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import { createHostMetricsApi } from "../../src/frontend/host-metrics-api";
function client() {
return {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
};
}
const notFound = () =>
Object.assign(new Error("Not found"), { response: { status: 404 } });
describe("host metrics api", () => {
it("answers null while a host has no sample", async () => {
const api = client();
api.get.mockRejectedValue(notFound());
await expect(createHostMetricsApi(api).getMetrics(7)).resolves.toBeNull();
expect(api.get).toHaveBeenCalledWith("/metrics/7");
});
it("reports a swept viewer instead of throwing", async () => {
const api = client();
api.post.mockRejectedValueOnce(notFound());
await expect(
createHostMetricsApi(api).heartbeat("expired-viewer"),
).resolves.toBe(false);
expect(api.post).toHaveBeenCalledWith("/metrics/heartbeat", {
viewerSessionId: "expired-viewer",
});
api.post.mockResolvedValueOnce({ data: { success: true } });
await expect(createHostMetricsApi(api).heartbeat("live")).resolves.toBe(
true,
);
});
it("carries the server's connection logs on a failed start", async () => {
const api = client();
api.post.mockRejectedValue(
Object.assign(new Error("boom"), {
response: {
status: 500,
data: { error: "No route", connectionLogs: [{ message: "x" }] },
},
}),
);
await expect(
createHostMetricsApi(api).startMetrics(7),
).rejects.toMatchObject({
message: "No route",
connectionLogs: [{ message: "x" }],
});
});
it("puts the host id between a manager and its action", async () => {
const api = client();
api.post.mockResolvedValue({ data: { success: true } });
await createHostMetricsApi(api).managerPost(
7,
"services",
{ a: 1 },
"action",
);
expect(api.post).toHaveBeenCalledWith(
"/host-metrics/managers/services/7/action",
{ a: 1 },
);
});
});
@@ -1,119 +0,0 @@
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);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ServerMetrics } from "@/main-axios";
import type { GpuMetrics } from "@/types/stats-widgets";
import type { ServerMetrics } from "../../src/shared/metrics.js";
import type { GpuMetrics } from "../../src/shared/stats-widgets.js";
import { metricsChangeKey } from "../../src/frontend/metrics-change-key";
function poll(gpu: GpuMetrics): ServerMetrics {
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createMetricsSummaryStore } from "../../src/frontend/summary-store";
import type { HostMetricsApi } from "../../src/frontend/host-metrics-api";
afterEach(() => {
vi.useRealTimers();
});
function fakeApi() {
return {
registerViewer: vi.fn(async () => ({
success: true,
viewerSessionId: "v1",
})),
unregisterViewer: vi.fn(async () => undefined),
heartbeat: vi.fn(async () => true),
getMetrics: vi.fn(async () => ({ cpu: { percent: 5 } })),
};
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("metrics summary store", () => {
it("holds one viewer per host while anything watches it", async () => {
const api = fakeApi();
const store = createMetricsSummaryStore(api as unknown as HostMetricsApi);
const listener = vi.fn();
const first = store.watch(7, listener);
const second = store.watch(7, vi.fn());
await flush();
expect(api.registerViewer).toHaveBeenCalledOnce();
expect(store.get(7)).toEqual({ cpu: { percent: 5 } });
expect(listener).toHaveBeenCalled();
first();
expect(api.unregisterViewer).not.toHaveBeenCalled();
second();
expect(api.unregisterViewer).toHaveBeenCalledWith(7, "v1");
expect(store.get(7)).toBeNull();
});
it("registers again when the server dropped the viewer", async () => {
vi.useFakeTimers();
const api = fakeApi();
const store = createMetricsSummaryStore(api as unknown as HostMetricsApi);
store.watch(7, vi.fn());
await vi.advanceTimersByTimeAsync(0);
api.heartbeat.mockResolvedValueOnce(false);
await vi.advanceTimersByTimeAsync(30_000);
expect(api.registerViewer).toHaveBeenCalledTimes(2);
store.dispose();
});
});