+12








fcd7c979df
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
import { createDb } from "@homarr/db/test";
|
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
|
|
|
import { GlancesIntegration, parseGlancesCpuTempFromSensors } from "../src/glances/glances-integration";
|
|
|
|
// ─── module mocks ────────────────────────────────────────────────────────────
|
|
|
|
vi.mock("@homarr/db", async (importActual) => {
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
const actual = await importActual<typeof import("@homarr/db")>();
|
|
return { ...actual, db: createDb() };
|
|
});
|
|
|
|
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
|
|
return {
|
|
...actual,
|
|
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
|
};
|
|
});
|
|
|
|
vi.mock("@homarr/core/infrastructure/http", () => ({
|
|
fetchWithTrustedCertificatesAsync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../src/base/session-store", () => ({
|
|
createSessionStore: () => ({
|
|
async getAsync() {
|
|
return { version: "4.0.0" };
|
|
},
|
|
async setAsync() {
|
|
return;
|
|
},
|
|
async clearAsync() {
|
|
return;
|
|
},
|
|
}),
|
|
}));
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
const baseStats = {
|
|
cpu: { total: 10 },
|
|
mem: { total: 8_000_000_000, used: 4_000_000_000, free: 4_000_000_000 },
|
|
network: [{ bytes_sent_rate_per_sec: 1000, bytes_recv_rate_per_sec: 2000 }],
|
|
fs: [{ device_name: "/dev/sda1", used: 10_000, free: 90_000, percent: 10 }],
|
|
uptime: "1 day, 2:03:04",
|
|
gpu: [],
|
|
};
|
|
|
|
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
|
|
|
// Cast lives here so call sites don't need `as never`.
|
|
function makeResponse(body: unknown) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(body),
|
|
} as never;
|
|
}
|
|
|
|
// Glances answers 400 "Unknown plugin smart" when the SMART plugin is disabled (the default).
|
|
const smartDisabledResponse = {
|
|
ok: false,
|
|
status: 400,
|
|
json: () => Promise.resolve({ detail: "Unknown plugin smart (available plugins: ...)" }),
|
|
} as never;
|
|
|
|
function mockGlancesFetch(statsBody: unknown, sensorsBody: unknown, smartBody?: unknown) {
|
|
mockFetch.mockImplementation(((url: string | URL) => {
|
|
const urlString = url.toString();
|
|
if (urlString.endsWith("/api/4/all")) {
|
|
return Promise.resolve(makeResponse(statsBody));
|
|
}
|
|
if (urlString.endsWith("/api/4/sensors")) {
|
|
return Promise.resolve(makeResponse(sensorsBody));
|
|
}
|
|
if (urlString.endsWith("/api/4/smart")) {
|
|
return Promise.resolve(smartBody === undefined ? smartDisabledResponse : makeResponse(smartBody));
|
|
}
|
|
return Promise.reject(new Error(`Unexpected fetch URL: ${urlString}`));
|
|
}) as typeof fetchWithTrustedCertificatesAsync);
|
|
}
|
|
|
|
// Trimmed /api/4/smart response from Glances 4.5.7 (pySMART) with one SATA disk and one NVMe drive.
|
|
const smartStats = [
|
|
{
|
|
DeviceName: "nvme0 KINGSTON SA2000M81000G",
|
|
key: "DeviceName",
|
|
"1": {
|
|
name: "Number of critical warnings",
|
|
key: "criticalWarning",
|
|
value: 0,
|
|
flags: null,
|
|
raw: 0,
|
|
worst: null,
|
|
threshold: null,
|
|
type: null,
|
|
updated: null,
|
|
when_failed: null,
|
|
},
|
|
"2": {
|
|
name: "Temperature (°C)",
|
|
key: "_temperature",
|
|
value: 30,
|
|
flags: null,
|
|
raw: 30,
|
|
worst: null,
|
|
threshold: null,
|
|
type: null,
|
|
updated: null,
|
|
when_failed: null,
|
|
},
|
|
"22": {
|
|
name: "Errors",
|
|
key: "errors",
|
|
value: [],
|
|
flags: null,
|
|
raw: [],
|
|
worst: null,
|
|
threshold: null,
|
|
type: null,
|
|
updated: null,
|
|
when_failed: null,
|
|
},
|
|
},
|
|
{
|
|
DeviceName: "sda WDC WD30EFRX-68EUZN0",
|
|
key: "DeviceName",
|
|
"5": {
|
|
name: "Reallocated_Sector_Ct",
|
|
key: "Reallocated_Sector_Ct",
|
|
value: "200",
|
|
flags: "0x0033",
|
|
raw: "0",
|
|
worst: "200",
|
|
threshold: "140",
|
|
type: "Pre-fail",
|
|
updated: "Always",
|
|
when_failed: "-",
|
|
},
|
|
"194": {
|
|
name: "Temperature_Celsius",
|
|
key: "Temperature_Celsius",
|
|
value: "115",
|
|
flags: "0x0022",
|
|
raw: "35 (Min/Max 20/45)",
|
|
worst: "106",
|
|
threshold: "000",
|
|
type: "Old_age",
|
|
updated: "Always",
|
|
when_failed: "-",
|
|
},
|
|
},
|
|
];
|
|
|
|
const integrationInput = {
|
|
id: "test-glances",
|
|
name: "Glances",
|
|
url: "http://localhost:61208",
|
|
decryptedSecrets: [],
|
|
externalUrl: null,
|
|
};
|
|
|
|
// ─── tests ───────────────────────────────────────────────────────────────────
|
|
|
|
describe("GlancesIntegration schema", () => {
|
|
beforeEach(() => {
|
|
mockFetch.mockReset();
|
|
});
|
|
|
|
test("parses stats when quicklook is present", async () => {
|
|
mockGlancesFetch({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }, []);
|
|
|
|
const integration = new GlancesIntegration(integrationInput);
|
|
const result = await integration.getSystemInfoAsync();
|
|
|
|
expect(result.cpuModelName).toBe("Apple M1");
|
|
expect(result.cpuUtilization).toBe(10);
|
|
});
|
|
|
|
test("returns Unknown cpuModelName when quicklook is absent (macOS scenario)", async () => {
|
|
mockGlancesFetch(baseStats, []);
|
|
|
|
const integration = new GlancesIntegration(integrationInput);
|
|
const result = await integration.getSystemInfoAsync();
|
|
|
|
expect(result.cpuModelName).toBe("Unknown");
|
|
});
|
|
|
|
test("does not throw a Zod parse error when quicklook is missing", async () => {
|
|
mockGlancesFetch(baseStats, []);
|
|
|
|
const integration = new GlancesIntegration(integrationInput);
|
|
await expect(integration.getSystemInfoAsync()).resolves.not.toThrow();
|
|
});
|
|
|
|
test("reads CPU temperature from sensors", async () => {
|
|
mockGlancesFetch(baseStats, [
|
|
{ label: "CPU", unit: "C", value: 58, type: "temperature_core" },
|
|
{ label: "Core 0", unit: "C", value: 54, type: "temperature_core" },
|
|
]);
|
|
|
|
const integration = new GlancesIntegration(integrationInput);
|
|
const result = await integration.getSystemInfoAsync();
|
|
|
|
expect(result.cpuTemp).toBe(58);
|
|
});
|
|
});
|
|
|
|
describe("GlancesIntegration SMART", () => {
|
|
beforeEach(() => {
|
|
mockFetch.mockReset();
|
|
});
|
|
|
|
test("maps a SATA disk with temperature from Temperature_Celsius", async () => {
|
|
mockGlancesFetch(baseStats, [], smartStats);
|
|
|
|
const integration = new GlancesIntegration(integrationInput);
|
|
const result = await integration.getSystemInfoAsync();
|
|
|
|
expect(result.smart).toContainEqual({
|
|
deviceName: "/dev/sda",
|
|
temperature: 35,
|
|
overallStatus: "PASSED",
|
|
healthy: true,
|
|
statusReason: { type: "attributesWithinThresholds" },
|
|
});
|
|
});
|
|
|
|
test("maps an NVMe drive by controller name with temperature from _temperature", async () => {
|
|
mockGlancesFetch(baseStats, [], smartStats);
|
|
|
|
const integration = new GlancesIntegration(integrationInput);
|
|
const result = await integration.getSystemInfoAsync();
|
|
|
|
expect(result.smart).toContainEqual({
|
|
deviceName: "/dev/nvme0",
|
|
temperature: 30,
|
|
overallStatus: "PASSED",
|
|
healthy: true,
|
|
statusReason: { type: "noCriticalWarning" },
|
|
});
|
|
});
|
|
|
|
test("reports FAILED when an ATA attribute has failed or NVMe has a critical warning", async () => {
|
|
const [nvme, sata] = smartStats;
|
|
mockGlancesFetch(
|
|
baseStats,
|
|
[],
|
|
[
|
|
{ ...nvme, "1": { ...nvme?.["1"], value: 4, raw: 4 } },
|
|
{ ...sata, "5": { ...sata?.["5"], when_failed: "FAILING_NOW" } },
|
|
],
|
|
);
|
|
|
|
const integration = new GlancesIntegration(integrationInput);
|
|
const result = await integration.getSystemInfoAsync();
|
|
|
|
expect(result.smart).toEqual([
|
|
expect.objectContaining({
|
|
deviceName: "/dev/nvme0",
|
|
overallStatus: "FAILED",
|
|
healthy: false,
|
|
statusReason: { type: "criticalWarning" },
|
|
}),
|
|
expect.objectContaining({
|
|
deviceName: "/dev/sda",
|
|
overallStatus: "FAILED",
|
|
healthy: false,
|
|
statusReason: { type: "attributesFailed", attributes: ["Reallocated_Sector_Ct"] },
|
|
}),
|
|
]);
|
|
});
|
|
|
|
test("returns no SMART data when the plugin is disabled, keeping the other stats", async () => {
|
|
mockGlancesFetch(baseStats, []);
|
|
|
|
const integration = new GlancesIntegration(integrationInput);
|
|
const result = await integration.getSystemInfoAsync();
|
|
|
|
expect(result.smart).toEqual([]);
|
|
expect(result.cpuUtilization).toBe(10);
|
|
expect(result.memUsedInBytes).toBe(4_000_000_000);
|
|
expect(result.fileSystem).toHaveLength(1);
|
|
expect(result.gpu).toEqual([]);
|
|
});
|
|
|
|
test("omits devices without SMART health data instead of reporting them healthy", async () => {
|
|
const [nvme] = smartStats;
|
|
mockGlancesFetch(
|
|
baseStats,
|
|
[],
|
|
[
|
|
{ DeviceName: "sdc USB Disk", key: "DeviceName" },
|
|
{ ...nvme, "1": { ...nvme?.["1"], value: null, raw: null } },
|
|
],
|
|
);
|
|
|
|
const integration = new GlancesIntegration(integrationInput);
|
|
const result = await integration.getSystemInfoAsync();
|
|
|
|
expect(result.smart).toEqual([]);
|
|
});
|
|
|
|
test("returns no SMART data when the payload is malformed", async () => {
|
|
mockGlancesFetch(baseStats, [], { unexpected: true });
|
|
|
|
const integration = new GlancesIntegration(integrationInput);
|
|
await expect(integration.getSystemInfoAsync()).resolves.toMatchObject({ smart: [] });
|
|
});
|
|
});
|
|
|
|
describe("parseGlancesCpuTempFromSensors", () => {
|
|
test("prefers CPU label over core sensors", () => {
|
|
const result = parseGlancesCpuTempFromSensors([
|
|
{ label: "Core 0", value: 54, type: "temperature_core" },
|
|
{ label: "CPU", value: 58, type: "temperature_core" },
|
|
]);
|
|
|
|
expect(result).toBe(58);
|
|
});
|
|
|
|
test("falls back to Package id 0", () => {
|
|
const result = parseGlancesCpuTempFromSensors([
|
|
{ label: "Core 0", value: 54, type: "temperature_core" },
|
|
{ label: "Package id 0", value: 62, type: "temperature_core" },
|
|
]);
|
|
|
|
expect(result).toBe(62);
|
|
});
|
|
});
|