From 58058f2ea6a67259676228f7f791230b0e33f57a Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Tue, 16 Jun 2026 01:17:56 -0700 Subject: [PATCH] fix: make quicklook optional in Glances integration schema (#5949) On macOS (and some Glances configurations) the /api/4/all endpoint omits the quicklook key, causing a Zod invalid_type error and crashing the system health widget. Mark quicklook as .optional() in allSchema and add a null-safe fallback (`?? "Unknown"`) for cpuModelName. Fixes #5728 Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- .../src/glances/glances-integration.ts | 10 +- .../integrations/test/glances-unit.spec.ts | 103 ++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 packages/integrations/test/glances-unit.spec.ts diff --git a/packages/integrations/src/glances/glances-integration.ts b/packages/integrations/src/glances/glances-integration.ts index 7b6ae3b79..6364ebcb7 100644 --- a/packages/integrations/src/glances/glances-integration.ts +++ b/packages/integrations/src/glances/glances-integration.ts @@ -55,7 +55,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni })), uptime: stats.uptime.as("seconds"), rebootRequired: false, - cpuModelName: stats.quicklook.cpu_name, + cpuModelName: stats.quicklook?.cpu_name ?? "Unknown", loadAverage: null, smart: [], cpuTemp: undefined, @@ -155,9 +155,11 @@ const allSchema = z.object({ return dayjs.duration({ days, hours, minutes, seconds }); }), - quicklook: z.object({ - cpu_name: z.string(), - }), + quicklook: z + .object({ + cpu_name: z.string(), + }) + .optional(), gpu: z .array( z.object({ diff --git a/packages/integrations/test/glances-unit.spec.ts b/packages/integrations/test/glances-unit.spec.ts new file mode 100644 index 000000000..121ab594a --- /dev/null +++ b/packages/integrations/test/glances-unit.spec.ts @@ -0,0 +1,103 @@ +// @vitest-environment node + +import { describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { GlancesIntegration } 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(); + 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(); + 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; +} + +const integrationInput = { + id: "test-glances", + name: "Glances", + url: "http://localhost:61208", + decryptedSecrets: [], + externalUrl: null, +}; + +// ─── tests ─────────────────────────────────────────────────────────────────── + +describe("GlancesIntegration schema", () => { + test("parses stats when quicklook is present", async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ ...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 () => { + mockFetch.mockResolvedValueOnce(makeResponse(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 () => { + mockFetch.mockResolvedValueOnce(makeResponse(baseStats)); + + const integration = new GlancesIntegration(integrationInput); + await expect(integration.getSystemInfoAsync()).resolves.not.toThrow(); + }); +});