mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: finish the host-metrics plugin, move host status into core (B16)
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user