From dcef36ffa7cffa2e55f806fb0b5bdf334dad3667 Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 11 Aug 2026 12:47:57 +0200 Subject: [PATCH] perf: optimize widget integration requests --- .../adguard-home/adguard-home-integration.ts | 25 ++-- .../test/adguard-home-integration.spec.ts | 95 +++++++++++++++ .../src/dashdot/dashdot-integration.ts | 15 ++- .../dashdot/test/dashdot-integration.spec.ts | 114 ++++++++++++++++++ .../nzbget/nzbget-integration.ts | 8 +- .../nzbget/test/nzbget-integration.spec.ts | 75 ++++++++++++ .../sabnzbd/sabnzbd-integration.ts | 16 ++- .../sabnzbd/test/sabnzbd-integration.spec.ts | 71 +++++++++++ .../truenas/test/truenas-integration.spec.ts | 54 +++++++-- .../src/truenas/truenas-integration.ts | 11 +- .../src/umami/test/umami-integration.spec.ts | 3 + .../src/umami/umami-integration.ts | 10 +- .../src/media-request-stats.spec.ts | 60 +++++++++ .../src/media-request-stats.ts | 8 +- 14 files changed, 517 insertions(+), 48 deletions(-) create mode 100644 packages/integrations/src/adguard-home/test/adguard-home-integration.spec.ts create mode 100644 packages/integrations/src/dashdot/test/dashdot-integration.spec.ts create mode 100644 packages/integrations/src/download-client/nzbget/test/nzbget-integration.spec.ts create mode 100644 packages/integrations/src/download-client/sabnzbd/test/sabnzbd-integration.spec.ts create mode 100644 packages/request-handler/src/media-request-stats.spec.ts diff --git a/packages/integrations/src/adguard-home/adguard-home-integration.ts b/packages/integrations/src/adguard-home/adguard-home-integration.ts index 6292617bb..cd7a54f71 100644 --- a/packages/integrations/src/adguard-home/adguard-home-integration.ts +++ b/packages/integrations/src/adguard-home/adguard-home-integration.ts @@ -11,11 +11,14 @@ import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration { public async getSummaryAsync(): Promise { - const statsResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/stats"), { - headers: { - Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, - }, - }); + const headers = { + Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, + }; + const [statsResponse, statusResponse, filteringStatusResponse] = await Promise.all([ + fetchWithTrustedCertificatesAsync(this.url("/control/stats"), { headers }), + fetchWithTrustedCertificatesAsync(this.url("/control/status"), { headers }), + fetchWithTrustedCertificatesAsync(this.url("/control/filtering/status"), { headers }), + ]); if (!statsResponse.ok) { throw new Error( @@ -23,24 +26,12 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar ); } - const statusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/status"), { - headers: { - Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, - }, - }); - if (!statusResponse.ok) { throw new Error( `Failed to fetch status for ${this.integration.name} (${this.integration.id}): ${statusResponse.statusText}`, ); } - const filteringStatusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/filtering/status"), { - headers: { - Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, - }, - }); - if (!filteringStatusResponse.ok) { throw new Error( `Failed to fetch filtering status for ${this.integration.name} (${this.integration.id}): ${filteringStatusResponse.statusText}`, diff --git a/packages/integrations/src/adguard-home/test/adguard-home-integration.spec.ts b/packages/integrations/src/adguard-home/test/adguard-home-integration.spec.ts new file mode 100644 index 000000000..980b390fd --- /dev/null +++ b/packages/integrations/src/adguard-home/test/adguard-home-integration.spec.ts @@ -0,0 +1,95 @@ +// @vitest-environment node + +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; +}); + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { AdGuardHomeIntegration } from "../adguard-home-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() })); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); +const integration = new AdGuardHomeIntegration({ + id: "adguard-1", + name: "AdGuard Home", + url: "https://adguard.example.com", + externalUrl: null, + decryptedSecrets: [ + { kind: "username", value: "admin" }, + { kind: "password", value: "secret" }, + ], +}); + +describe("AdGuardHomeIntegration", () => { + beforeEach(() => vi.clearAllMocks()); + + test("starts all summary requests before awaiting any response", async () => { + const requestGates = new Map>>(); + mockFetch.mockImplementation((url) => { + const path = new URL(url.toString()).pathname; + const gate = Promise.withResolvers(); + requestGates.set(path, gate); + return gate.promise; + }); + + const resultPromise = integration.getSummaryAsync(); + + await vi.waitFor(() => { + expect([...requestGates.keys()]).toEqual(["/control/stats", "/control/status", "/control/filtering/status"]); + }); + + requestGates.get("/control/stats")?.resolve(jsonResponse(stats)); + requestGates.get("/control/status")?.resolve(jsonResponse(status)); + requestGates.get("/control/filtering/status")?.resolve(jsonResponse(filteringStatus)); + + await expect(resultPromise).resolves.toMatchObject({ + status: "enabled", + adsBlockedToday: 20, + dnsQueriesToday: 100, + domainsBeingBlocked: 12, + }); + }); +}); + +const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); + +const stats = { + time_units: "hours", + top_queried_domains: [], + top_clients: [], + top_blocked_domains: [], + dns_queries: [40, 60], + blocked_filtering: [5, 15], + replaced_safebrowsing: [], + replaced_parental: [], + num_dns_queries: 100, + num_blocked_filtering: 20, + num_replaced_safebrowsing: 0, + num_replaced_safesearch: 0, + num_replaced_parental: 0, + avg_processing_time: 0.01, +}; + +const status = { + version: "1.0.0", + language: "en", + dns_addresses: ["0.0.0.0"], + dns_port: 53, + http_port: 3000, + protection_enabled: true, + dhcp_available: false, + running: true, +}; + +const filteringStatus = { + filters: [ + { url: "https://example.com/filter.txt", name: "Enabled", id: 1, rules_count: 12, enabled: true }, + { url: "https://example.com/disabled.txt", name: "Disabled", id: 2, rules_count: 50, enabled: false }, + ], +}; diff --git a/packages/integrations/src/dashdot/dashdot-integration.ts b/packages/integrations/src/dashdot/dashdot-integration.ts index 5d18b4422..4ad19681e 100644 --- a/packages/integrations/src/dashdot/dashdot-integration.ts +++ b/packages/integrations/src/dashdot/dashdot-integration.ts @@ -26,13 +26,16 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni } public async getSystemInfoAsync(): Promise { - const info = await this.getInfoAsync(); - const cpuLoad = await this.getCurrentCpuLoadAsync(); - const memoryLoad = await this.getCurrentMemoryLoadAsync(); - const storageLoad = await this.getCurrentStorageLoadAsync(); - const networkLoad = await this.getCurrentNetworkLoadAsync(); - const gpuLoad = await this.getCurrentGpuLoadAsync(); + const [info, cpuLoad, memoryLoad, storageLoad, networkLoad, gpuLoad] = await Promise.all([ + this.getInfoAsync(), + this.getCurrentCpuLoadAsync(), + this.getCurrentMemoryLoadAsync(), + this.getCurrentStorageLoadAsync(), + this.getCurrentNetworkLoadAsync(), + this.getCurrentGpuLoadAsync(), + ]); + // Read history after the CPU request has persisted its newest sample. const channel = this.getChannel(); const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate()); diff --git a/packages/integrations/src/dashdot/test/dashdot-integration.spec.ts b/packages/integrations/src/dashdot/test/dashdot-integration.spec.ts new file mode 100644 index 000000000..29350fdc4 --- /dev/null +++ b/packages/integrations/src/dashdot/test/dashdot-integration.spec.ts @@ -0,0 +1,114 @@ +// @vitest-environment node + +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; +}); + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { createChannelEventHistoryOld } from "../../../../redis/src/lib/channel"; +import { DashDotIntegration } from "../dashdot-integration"; + +vi.mock("@homarr/redis", () => ({})); +vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() })); +vi.mock("../../../../redis/src/lib/channel", () => ({ createChannelEventHistoryOld: vi.fn() })); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); +const mockCreateHistory = vi.mocked(createChannelEventHistoryOld); + +const channel = { + pushAsync: vi.fn(), + getSliceUntilTimeAsync: vi.fn(), +}; + +const integration = new DashDotIntegration({ + id: "dashdot-1", + name: "DashDot", + url: "https://dashdot.example.com", + externalUrl: null, + decryptedSecrets: [], +}); + +describe("DashDotIntegration", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateHistory.mockReturnValue(channel as never); + }); + + test("starts independent requests together and reads history after the CPU sample is persisted", async () => { + const requestGates = new Map>>(); + const persistenceGate = Promise.withResolvers(); + const order: string[] = []; + + mockFetch.mockImplementation((url) => { + const path = new URL(url.toString()).pathname; + const gate = Promise.withResolvers(); + requestGates.set(path, gate); + return gate.promise; + }); + channel.pushAsync.mockImplementation(async () => { + order.push("push-start"); + await persistenceGate.promise; + order.push("push-end"); + }); + channel.getSliceUntilTimeAsync.mockImplementation(async () => { + order.push("history"); + return [cpuLoad]; + }); + + const resultPromise = integration.getSystemInfoAsync(); + + await vi.waitFor(() => { + expect([...requestGates.keys()]).toEqual([ + "/info", + "/load/cpu", + "/load/ram", + "/load/storage", + "/load/network", + "/load/gpu", + ]); + }); + + requestGates.get("/info")?.resolve(jsonResponse(serverInfo)); + requestGates.get("/load/cpu")?.resolve(jsonResponse(cpuLoad)); + requestGates.get("/load/ram")?.resolve(jsonResponse({ load: 4_000 })); + requestGates.get("/load/storage")?.resolve(jsonResponse([250])); + requestGates.get("/load/network")?.resolve(jsonResponse({ up: 20, down: 10 })); + requestGates.get("/load/gpu")?.resolve(jsonResponse({ layout: [{ load: 30, memory: 40 }] })); + + await vi.waitFor(() => expect(channel.pushAsync).toHaveBeenCalledOnce()); + expect(channel.getSliceUntilTimeAsync).not.toHaveBeenCalled(); + + persistenceGate.resolve(); + const result = await resultPromise; + + expect(order).toEqual(["push-start", "push-end", "history"]); + expect(result).toMatchObject({ + cpuUtilization: 15, + memUsedInBytes: 4_000, + memAvailableInBytes: 12_000, + network: { up: 20, down: 10 }, + fileSystem: [{ used: "250", available: "750", percentage: 25 }], + gpu: [{ memoryUtilization: 40, processorUtilization: 30 }], + }); + }); +}); + +const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); + +const cpuLoad = [ + { load: 10, temp: 40 }, + { load: 20, temp: 50 }, +]; + +const serverInfo = { + os: { distro: "Linux", kernel: "6.0", release: "1", uptime: 60 }, + cpu: { brand: "Intel", model: "Test CPU" }, + ram: { size: 16_000 }, + storage: [{ size: 1_000, disks: [{ device: "sda", brand: "Test", type: "SSD" }] }], + gpu: { layout: [{ brand: "Test GPU" }] }, +}; diff --git a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts index 1d5c66429..889a29754 100644 --- a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts +++ b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts @@ -23,9 +23,11 @@ export class NzbGetIntegration extends Integration implements IDownloadClientInt public async getClientJobsAndStatusAsync(input: { limit: number }): Promise { const type = "usenet"; - const queue = await this.nzbGetApiCallAsync("listgroups"); - const history = await this.nzbGetApiCallAsync("history"); - const nzbGetStatus = await this.nzbGetApiCallAsync("status"); + const [queue, history, nzbGetStatus] = await Promise.all([ + this.nzbGetApiCallAsync("listgroups"), + this.nzbGetApiCallAsync("history"), + this.nzbGetApiCallAsync("status"), + ]); const status: DownloadClientStatus = { paused: nzbGetStatus.DownloadPaused, rates: { down: nzbGetStatus.DownloadRate }, diff --git a/packages/integrations/src/download-client/nzbget/test/nzbget-integration.spec.ts b/packages/integrations/src/download-client/nzbget/test/nzbget-integration.spec.ts new file mode 100644 index 000000000..4f84db932 --- /dev/null +++ b/packages/integrations/src/download-client/nzbget/test/nzbget-integration.spec.ts @@ -0,0 +1,75 @@ +// @vitest-environment node + +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; +}); + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationSecret } from "../../../base/types"; +import { NzbGetIntegration } from "../nzbget-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); +const secrets: IntegrationSecret[] = [ + { kind: "username", value: "test-user" }, + { kind: "password", value: "test-password" }, +]; + +const createIntegration = () => + new NzbGetIntegration({ + id: "test-nzbget", + name: "Test NZBGet", + url: "https://nzbget.example.com", + externalUrl: null, + decryptedSecrets: secrets, + }); + +describe("NzbGetIntegration.getClientJobsAndStatusAsync", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + test("starts queue, history, and status requests together", async () => { + const releaseRequests = Promise.withResolvers(); + const startedMethods: string[] = []; + const results = { + listgroups: [], + history: [], + status: { DownloadPaused: false, DownloadRate: 0 }, + } as const; + + mockFetch.mockImplementation(async (_url, init) => { + const method = (JSON.parse(String(init?.body)) as { method: keyof typeof results }).method; + startedMethods.push(method); + await releaseRequests.promise; + return new Response(JSON.stringify({ result: results[method] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + const resultPromise = createIntegration().getClientJobsAndStatusAsync({ limit: 10 }); + + try { + await vi.waitFor(() => { + expect(startedMethods).toEqual(expect.arrayContaining(["listgroups", "history", "status"])); + expect(startedMethods).toHaveLength(3); + }); + } finally { + releaseRequests.resolve(); + } + + await expect(resultPromise).resolves.toStrictEqual({ + status: { paused: false, rates: { down: 0 }, types: ["usenet"] }, + items: [], + }); + }); +}); diff --git a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts index 69352ca02..dd8b8b963 100644 --- a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts +++ b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts @@ -25,12 +25,16 @@ export class SabnzbdIntegration extends Integration implements IDownloadClientIn public async getClientJobsAndStatusAsync(input: { limit: number }): Promise { const type = "usenet"; - const { queue } = await queueSchema.parseAsync( - await this.sabNzbApiCallAsync("queue", { limit: input.limit.toString() }), - ); - const { history } = await historySchema.parseAsync( - await this.sabNzbApiCallAsync("history", { limit: input.limit.toString() }), - ); + const [queueResult, historyResult] = await Promise.all([ + this.sabNzbApiCallAsync("queue", { limit: input.limit.toString() }).then((result) => + queueSchema.parseAsync(result), + ), + this.sabNzbApiCallAsync("history", { limit: input.limit.toString() }).then((result) => + historySchema.parseAsync(result), + ), + ]); + const { queue } = queueResult; + const { history } = historyResult; const status: DownloadClientStatus = { paused: queue.paused, rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps () diff --git a/packages/integrations/src/download-client/sabnzbd/test/sabnzbd-integration.spec.ts b/packages/integrations/src/download-client/sabnzbd/test/sabnzbd-integration.spec.ts new file mode 100644 index 000000000..9cde2689a --- /dev/null +++ b/packages/integrations/src/download-client/sabnzbd/test/sabnzbd-integration.spec.ts @@ -0,0 +1,71 @@ +// @vitest-environment node + +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; +}); + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationSecret } from "../../../base/types"; +import { SabnzbdIntegration } from "../sabnzbd-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); +const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "test-api-key" }]; + +const createIntegration = () => + new SabnzbdIntegration({ + id: "test-sabnzbd", + name: "Test SABnzbd", + url: "https://sabnzbd.example.com", + externalUrl: null, + decryptedSecrets: secrets, + }); + +describe("SabnzbdIntegration.getClientJobsAndStatusAsync", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + test("starts queue and history requests together", async () => { + const releaseRequests = Promise.withResolvers(); + const startedModes: string[] = []; + const results = { + queue: { queue: { paused: false, kbpersec: "0", slots: [] } }, + history: { history: { slots: [] } }, + } as const; + + mockFetch.mockImplementation(async (url) => { + const mode = new URL(String(url)).searchParams.get("mode") as keyof typeof results; + startedModes.push(mode); + await releaseRequests.promise; + return new Response(JSON.stringify(results[mode]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + const resultPromise = createIntegration().getClientJobsAndStatusAsync({ limit: 10 }); + + try { + await vi.waitFor(() => { + expect(startedModes).toEqual(expect.arrayContaining(["queue", "history"])); + expect(startedModes).toHaveLength(2); + }); + } finally { + releaseRequests.resolve(); + } + + await expect(resultPromise).resolves.toStrictEqual({ + status: { paused: false, rates: { down: 0 }, types: ["usenet"] }, + items: [], + }); + }); +}); diff --git a/packages/integrations/src/truenas/test/truenas-integration.spec.ts b/packages/integrations/src/truenas/test/truenas-integration.spec.ts index 91ab0662c..61060cea9 100644 --- a/packages/integrations/src/truenas/test/truenas-integration.spec.ts +++ b/packages/integrations/src/truenas/test/truenas-integration.spec.ts @@ -1,5 +1,12 @@ +// @vitest-environment node + import { beforeEach, describe, expect, test, vi } from "vitest"; +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; +}); + import { IntegrationRequestError } from "../../base/errors/http/integration-request-error"; import type { IntegrationSecret } from "../../base/types"; @@ -8,7 +15,7 @@ const ws = vi.hoisted(() => { const constructedUrls: string[] = []; // A failure with a `code` emits a coded TLS-style error; without one it mimics a missing endpoint (HTTP 404). const failures: { path: string; code?: string }[] = []; - let responder: (method: string, params: unknown[]) => unknown = () => undefined; + let responder: (method: string, params: unknown[]) => unknown | Promise = () => undefined; type Listener = (...args: unknown[]) => void; @@ -58,12 +65,15 @@ const ws = vi.hoisted(() => { return; } - const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []); - const envelope = - payload.jsonrpc === "2.0" - ? { jsonrpc: "2.0", id: payload.id, result } - : { msg: "result", id: payload.id, result }; - this.respond(JSON.stringify(envelope)); + void Promise.resolve(responder(payload.method as string, (payload.params as unknown[] | undefined) ?? [])).then( + (result) => { + const envelope = + payload.jsonrpc === "2.0" + ? { jsonrpc: "2.0", id: payload.id, result } + : { msg: "result", id: payload.id, result }; + this.respond(JSON.stringify(envelope)); + }, + ); } close() { @@ -85,7 +95,7 @@ const ws = vi.hoisted(() => { sentPayloads, constructedUrls, failures, - setResponder: (fn: (method: string, params: unknown[]) => unknown) => { + setResponder: (fn: (method: string, params: unknown[]) => unknown | Promise) => { responder = fn; }, }; @@ -210,6 +220,34 @@ describe("TrueNasIntegration", () => { }); }); + test("starts independent system requests together while preserving the network interface dependency", async () => { + const gates = new Map>>(); + ws.setResponder((method) => { + if (method.startsWith("auth.")) return true; + const gate = Promise.withResolvers(); + gates.set(method, gate); + return gate.promise; + }); + const integration = createIntegration(credentials); + + const resultPromise = integration.getSystemInfoAsync(); + + await vi.waitFor(() => { + expect([...gates.keys()]).toEqual(["system.info", "reporting.get_data", "pool.query", "interface.query"]); + }); + expect(gates.has("reporting.netdata_get_data")).toBe(false); + + gates.get("interface.query")?.resolve(happyResponder("interface.query")); + await vi.waitFor(() => expect(gates.has("reporting.netdata_get_data")).toBe(true)); + + for (const [method, gate] of gates) gate.resolve(happyResponder(method)); + + await expect(resultPromise).resolves.toMatchObject({ + version: "TrueNAS-SCALE-24.10", + network: { up: 20_000, down: 10_000 }, + }); + }); + test("authenticates with an API key when one is configured", async () => { const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]); diff --git a/packages/integrations/src/truenas/truenas-integration.ts b/packages/integrations/src/truenas/truenas-integration.ts index 21f526201..0d6338cc1 100644 --- a/packages/integrations/src/truenas/truenas-integration.ts +++ b/packages/integrations/src/truenas/truenas-integration.ts @@ -24,15 +24,16 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni } public async getSystemInfoAsync(): Promise { - const systemInformation = await this.getSystemInformationAsync(); - const reporting = await this.getReportingAsync(); + const [systemInformation, reporting, datasets, netdata] = await Promise.all([ + this.getSystemInformationAsync(), + this.getReportingAsync(), + this.getPoolsAsync(), + this.getReportingNetdataAsync(), + ]); const cpuData = this.extractLatestReportingData(reporting, "cpu"); const cpuTempData = this.extractLatestReportingData(reporting, "cputemp"); const memoryData = this.extractLatestReportingData(reporting, "memory"); - const datasets = await this.getPoolsAsync(); - - const netdata = await this.getReportingNetdataAsync(); const upload = this.extractNetworkTrafficData(netdata, 2); // Index 2 is "sent" const download = this.extractNetworkTrafficData(netdata, 1); // Index 1 is "received" diff --git a/packages/integrations/src/umami/test/umami-integration.spec.ts b/packages/integrations/src/umami/test/umami-integration.spec.ts index ccddb0da5..7507a45b4 100644 --- a/packages/integrations/src/umami/test/umami-integration.spec.ts +++ b/packages/integrations/src/umami/test/umami-integration.spec.ts @@ -1,3 +1,5 @@ +// @vitest-environment node + import { Request, Response } from "undici"; import { beforeEach, describe, expect, test, vi } from "vitest"; @@ -76,6 +78,7 @@ describe("UmamiIntegration", () => { expect(requestedUrls).toHaveLength(1); expect(requestedUrls[0]?.path).toContain("/metrics"); expect(requestedUrls[0]?.params.get("type")).toBe("event"); + expect(requestedUrls[0]?.params.get("limit")).toBe("5000"); expect(requestedUrls.some((requested) => requested.path.endsWith("/events"))).toBe(false); }); diff --git a/packages/integrations/src/umami/umami-integration.ts b/packages/integrations/src/umami/umami-integration.ts index 86d8c67c6..f47d143d5 100644 --- a/packages/integrations/src/umami/umami-integration.ts +++ b/packages/integrations/src/umami/umami-integration.ts @@ -29,6 +29,7 @@ import { // Umami event type ID for custom events (type 1 = page view, type 2 = custom event) const UMAMI_CUSTOM_EVENT_TYPE = 2; +const UMAMI_EVENT_NAME_LIMIT = 5_000; const UMAMI_EVENT_PAGE_SIZE = 5_000; const UMAMI_EVENT_PAGE_MAX = 20; @@ -184,7 +185,14 @@ export class UmamiIntegration extends Integration { const authHeaders = await this.getAuthHeadersAsync(); const endAt = Date.now(); const startAt = dayjs().subtract(30, "day").valueOf(); - const metrics = await this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "event", authHeaders); + const metrics = await this.getWebsiteMetricsAsync( + websiteId, + startAt, + endAt, + "event", + authHeaders, + UMAMI_EVENT_NAME_LIMIT, + ); const names = new Set(); for (const metric of metrics) { if (metric.x) names.add(metric.x); diff --git a/packages/request-handler/src/media-request-stats.spec.ts b/packages/request-handler/src/media-request-stats.spec.ts new file mode 100644 index 000000000..09ff5609e --- /dev/null +++ b/packages/request-handler/src/media-request-stats.spec.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { MediaRequestStats } from "@homarr/integrations/types"; + +import { mediaRequestStatsRequestHandler } from "./media-request-stats"; + +const mocks = vi.hoisted(() => ({ + createIntegrationAsync: vi.fn(), +})); + +vi.mock("@homarr/integrations", () => ({ + createIntegrationAsync: mocks.createIntegrationAsync, +})); + +const integration = { + id: "media-request-integration", + kind: "overseerr", +} as Parameters[0]; + +describe("mediaRequestStatsRequestHandler", () => { + beforeEach(() => { + mediaRequestStatsRequestHandler.invalidateCache(); + mocks.createIntegrationAsync.mockReset(); + }); + + test("starts independent upstream requests together", async () => { + const requestsStarted = Promise.withResolvers(); + const expected = { + stats: { + total: 8, + movie: 5, + tv: 3, + pending: 1, + approved: 2, + declined: 1, + processing: 1, + available: 3, + }, + users: [{ id: 1, displayName: "User", avatar: "/avatar.png", requestCount: 4, link: "/users/1" }], + } satisfies MediaRequestStats; + const getStatsAsync = vi.fn(async () => { + await requestsStarted.promise; + return expected.stats; + }); + const getUsersAsync = vi.fn(async () => { + await requestsStarted.promise; + return expected.users; + }); + mocks.createIntegrationAsync.mockResolvedValue({ getStatsAsync, getUsersAsync }); + + const resultPromise = mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); + + await vi.waitFor(() => { + expect(getStatsAsync).toHaveBeenCalledOnce(); + expect(getUsersAsync).toHaveBeenCalledOnce(); + }); + requestsStarted.resolve(); + await expect(resultPromise).resolves.toMatchObject({ data: expected }); + }); +}); diff --git a/packages/request-handler/src/media-request-stats.ts b/packages/request-handler/src/media-request-stats.ts index 558af213b..57d67b508 100644 --- a/packages/request-handler/src/media-request-stats.ts +++ b/packages/request-handler/src/media-request-stats.ts @@ -11,9 +11,13 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler< >({ async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); + const [stats, users] = await Promise.all([ + integrationInstance.getStatsAsync(), + integrationInstance.getUsersAsync(), + ]); return { - stats: await integrationInstance.getStatsAsync(), - users: await integrationInstance.getUsersAsync(), + stats, + users, }; }, cacheTtlMs: 60_000,