mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15: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,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