perf: optimize widget integration requests
This commit is contained in:
1 parent
f65969053a
commit
dcef36ffa7
14 files changed
+517
-48
No files matched your search
@@ -11,11 +11,14 @@ import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from
|
||||
|
||||
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
|
||||
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||
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}`,
|
||||
|
||||
@@ -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<string, ReturnType<typeof Promise.withResolvers<Response>>>();
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const path = new URL(url.toString()).pathname;
|
||||
const gate = Promise.withResolvers<Response>();
|
||||
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 },
|
||||
],
|
||||
};
|
||||
@@ -26,13 +26,16 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
|
||||
}
|
||||
|
||||
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
|
||||
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());
|
||||
|
||||
|
||||
@@ -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<string, ReturnType<typeof Promise.withResolvers<Response>>>();
|
||||
const persistenceGate = Promise.withResolvers<void>();
|
||||
const order: string[] = [];
|
||||
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const path = new URL(url.toString()).pathname;
|
||||
const gate = Promise.withResolvers<Response>();
|
||||
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" }] },
|
||||
};
|
||||
@@ -23,9 +23,11 @@ export class NzbGetIntegration extends Integration implements IDownloadClientInt
|
||||
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
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 },
|
||||
|
||||
@@ -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<void>();
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,12 +25,16 @@ export class SabnzbdIntegration extends Integration implements IDownloadClientIn
|
||||
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
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 ()
|
||||
|
||||
@@ -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<void>();
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<unknown> = () => 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<unknown>) => {
|
||||
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<string, ReturnType<typeof Promise.withResolvers<unknown>>>();
|
||||
ws.setResponder((method) => {
|
||||
if (method.startsWith("auth.")) return true;
|
||||
const gate = Promise.withResolvers<unknown>();
|
||||
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" }]);
|
||||
|
||||
|
||||
@@ -24,15 +24,16 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
}
|
||||
|
||||
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
|
||||
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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
for (const metric of metrics) {
|
||||
if (metric.x) names.add(metric.x);
|
||||
|
||||
@@ -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<typeof mediaRequestStatsRequestHandler.handler>[0];
|
||||
|
||||
describe("mediaRequestStatsRequestHandler", () => {
|
||||
beforeEach(() => {
|
||||
mediaRequestStatsRequestHandler.invalidateCache();
|
||||
mocks.createIntegrationAsync.mockReset();
|
||||
});
|
||||
|
||||
test("starts independent upstream requests together", async () => {
|
||||
const requestsStarted = Promise.withResolvers<void>();
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user