feat!: release v2 (#6545)
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>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -0,0 +1,101 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { airQualityRequestHandler } from "./air-quality";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const makeAirQualityResponse = () => {
|
||||
const times = ["2026-07-01T00:00", "2026-07-01T01:00", "2026-07-02T00:00"];
|
||||
const values = [10, 20, 30];
|
||||
const fields = [
|
||||
"european_aqi",
|
||||
"us_aqi",
|
||||
"uv_index",
|
||||
"pm10",
|
||||
"pm2_5",
|
||||
"carbon_monoxide",
|
||||
"nitrogen_dioxide",
|
||||
"sulphur_dioxide",
|
||||
"ozone",
|
||||
"european_aqi_pm2_5",
|
||||
"european_aqi_pm10",
|
||||
"european_aqi_nitrogen_dioxide",
|
||||
"european_aqi_ozone",
|
||||
"european_aqi_sulphur_dioxide",
|
||||
"us_aqi_pm2_5",
|
||||
"us_aqi_pm10",
|
||||
"us_aqi_nitrogen_dioxide",
|
||||
"us_aqi_carbon_monoxide",
|
||||
"us_aqi_ozone",
|
||||
"us_aqi_sulphur_dioxide",
|
||||
"alder_pollen",
|
||||
"birch_pollen",
|
||||
"grass_pollen",
|
||||
"mugwort_pollen",
|
||||
"olive_pollen",
|
||||
"ragweed_pollen",
|
||||
] as const;
|
||||
|
||||
return {
|
||||
timezone: "Europe/Berlin",
|
||||
utc_offset_seconds: 7200,
|
||||
current: {
|
||||
time: "2026-07-01T12:00",
|
||||
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? null : 12])),
|
||||
},
|
||||
hourly: {
|
||||
time: times,
|
||||
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? [null, null, null] : values])),
|
||||
},
|
||||
fields,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
airQualityRequestHandler.invalidateCache();
|
||||
});
|
||||
|
||||
describe("air quality request handler", () => {
|
||||
it("requests and normalizes the full useful forecast field set", async () => {
|
||||
const fixture = makeAirQualityResponse();
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify(fixture)));
|
||||
|
||||
const first = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
const second = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
|
||||
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
|
||||
expect(first.data.current.pollutants.pm2_5).toBe(12);
|
||||
expect(first.data.current.pollen.ragweed).toBeNull();
|
||||
expect(first.data.hourly[0]?.observedAt).toBe("2026-06-30T22:00:00.000Z");
|
||||
expect(first.data.daily).toEqual([
|
||||
{ date: "2026-07-01", europeanAqiMax: 20, usAqiMax: 20, uvIndexMax: 20, pm2_5Max: 20, pm10Max: 20 },
|
||||
{ date: "2026-07-02", europeanAqiMax: 30, usAqiMax: 30, uvIndexMax: 30, pm2_5Max: 30, pm10Max: 30 },
|
||||
]);
|
||||
expect(second.data).toStrictEqual(first.data);
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
|
||||
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
|
||||
expect(requestUrl.origin).toBe("https://air-quality-api.open-meteo.com");
|
||||
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
|
||||
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
|
||||
expect(requestUrl.searchParams.get("current")).toContain("european_aqi_pm2_5");
|
||||
expect(requestUrl.searchParams.get("hourly")).toContain("us_aqi_carbon_monoxide");
|
||||
expect(requestUrl.searchParams.get("hourly")).toContain("ragweed_pollen");
|
||||
});
|
||||
|
||||
it("rejects unsuccessful Open-Meteo responses", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
|
||||
|
||||
await expect(
|
||||
airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const FORECAST_DAYS = 7;
|
||||
|
||||
const requestedVariables = [
|
||||
"european_aqi",
|
||||
"us_aqi",
|
||||
"uv_index",
|
||||
"pm10",
|
||||
"pm2_5",
|
||||
"carbon_monoxide",
|
||||
"nitrogen_dioxide",
|
||||
"sulphur_dioxide",
|
||||
"ozone",
|
||||
"european_aqi_pm2_5",
|
||||
"european_aqi_pm10",
|
||||
"european_aqi_nitrogen_dioxide",
|
||||
"european_aqi_ozone",
|
||||
"european_aqi_sulphur_dioxide",
|
||||
"us_aqi_pm2_5",
|
||||
"us_aqi_pm10",
|
||||
"us_aqi_nitrogen_dioxide",
|
||||
"us_aqi_carbon_monoxide",
|
||||
"us_aqi_ozone",
|
||||
"us_aqi_sulphur_dioxide",
|
||||
"alder_pollen",
|
||||
"birch_pollen",
|
||||
"grass_pollen",
|
||||
"mugwort_pollen",
|
||||
"olive_pollen",
|
||||
"ragweed_pollen",
|
||||
] as const;
|
||||
|
||||
export const airQualityRequestHandler = createWidgetRequestHandler({
|
||||
// Coordinates are a public, continuous key space. Keep this cache process-local and bounded.
|
||||
cacheTtlMs: 15 * 60 * 1000,
|
||||
fallbackToStaleOnError: true,
|
||||
staleIfErrorTtlMs: 24 * 60 * 60 * 1000,
|
||||
requestTimeoutMs: 10_000,
|
||||
async requestAsync(input: { latitude: number; longitude: number }, signal: AbortSignal) {
|
||||
const url = new URL("https://air-quality-api.open-meteo.com/v1/air-quality");
|
||||
url.searchParams.set("latitude", input.latitude.toString());
|
||||
url.searchParams.set("longitude", input.longitude.toString());
|
||||
url.searchParams.set("current", requestedVariables.join(","));
|
||||
url.searchParams.set("hourly", requestedVariables.join(","));
|
||||
url.searchParams.set("forecast_days", FORECAST_DAYS.toString());
|
||||
url.searchParams.set("timezone", "auto");
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const json: unknown = await response.json();
|
||||
const airQuality = await atLocationOutput.parseAsync(json);
|
||||
const hourly = airQuality.hourly.time.map((value, index) => {
|
||||
return normalizePoint(airQuality.hourly, index, toUtcInstant(value, airQuality.timezone));
|
||||
});
|
||||
|
||||
return {
|
||||
timezone: airQuality.timezone,
|
||||
utcOffsetSeconds: airQuality.utc_offset_seconds,
|
||||
current: normalizePoint(
|
||||
airQuality.current,
|
||||
undefined,
|
||||
toUtcInstant(airQuality.current.time, airQuality.timezone),
|
||||
),
|
||||
hourly,
|
||||
daily: getDailyMaximums(airQuality.hourly),
|
||||
} satisfies AirQuality;
|
||||
},
|
||||
});
|
||||
|
||||
const nullableNumber = z.number().nullable();
|
||||
const nullableNumberArray = z.array(nullableNumber);
|
||||
|
||||
const currentValues = z.object({
|
||||
time: z.string(),
|
||||
european_aqi: nullableNumber,
|
||||
us_aqi: nullableNumber,
|
||||
uv_index: nullableNumber,
|
||||
pm10: nullableNumber,
|
||||
pm2_5: nullableNumber,
|
||||
carbon_monoxide: nullableNumber,
|
||||
nitrogen_dioxide: nullableNumber,
|
||||
sulphur_dioxide: nullableNumber,
|
||||
ozone: nullableNumber,
|
||||
european_aqi_pm2_5: nullableNumber,
|
||||
european_aqi_pm10: nullableNumber,
|
||||
european_aqi_nitrogen_dioxide: nullableNumber,
|
||||
european_aqi_ozone: nullableNumber,
|
||||
european_aqi_sulphur_dioxide: nullableNumber,
|
||||
us_aqi_pm2_5: nullableNumber,
|
||||
us_aqi_pm10: nullableNumber,
|
||||
us_aqi_nitrogen_dioxide: nullableNumber,
|
||||
us_aqi_carbon_monoxide: nullableNumber,
|
||||
us_aqi_ozone: nullableNumber,
|
||||
us_aqi_sulphur_dioxide: nullableNumber,
|
||||
alder_pollen: nullableNumber,
|
||||
birch_pollen: nullableNumber,
|
||||
grass_pollen: nullableNumber,
|
||||
mugwort_pollen: nullableNumber,
|
||||
olive_pollen: nullableNumber,
|
||||
ragweed_pollen: nullableNumber,
|
||||
});
|
||||
|
||||
const hourlyValues = z.object({
|
||||
time: z.array(z.string()),
|
||||
european_aqi: nullableNumberArray,
|
||||
us_aqi: nullableNumberArray,
|
||||
uv_index: nullableNumberArray,
|
||||
pm10: nullableNumberArray,
|
||||
pm2_5: nullableNumberArray,
|
||||
carbon_monoxide: nullableNumberArray,
|
||||
nitrogen_dioxide: nullableNumberArray,
|
||||
sulphur_dioxide: nullableNumberArray,
|
||||
ozone: nullableNumberArray,
|
||||
european_aqi_pm2_5: nullableNumberArray,
|
||||
european_aqi_pm10: nullableNumberArray,
|
||||
european_aqi_nitrogen_dioxide: nullableNumberArray,
|
||||
european_aqi_ozone: nullableNumberArray,
|
||||
european_aqi_sulphur_dioxide: nullableNumberArray,
|
||||
us_aqi_pm2_5: nullableNumberArray,
|
||||
us_aqi_pm10: nullableNumberArray,
|
||||
us_aqi_nitrogen_dioxide: nullableNumberArray,
|
||||
us_aqi_carbon_monoxide: nullableNumberArray,
|
||||
us_aqi_ozone: nullableNumberArray,
|
||||
us_aqi_sulphur_dioxide: nullableNumberArray,
|
||||
alder_pollen: nullableNumberArray,
|
||||
birch_pollen: nullableNumberArray,
|
||||
grass_pollen: nullableNumberArray,
|
||||
mugwort_pollen: nullableNumberArray,
|
||||
olive_pollen: nullableNumberArray,
|
||||
ragweed_pollen: nullableNumberArray,
|
||||
});
|
||||
|
||||
const atLocationOutput = z.object({
|
||||
timezone: z.string().min(1),
|
||||
utc_offset_seconds: z.number(),
|
||||
current: currentValues,
|
||||
hourly: hourlyValues,
|
||||
});
|
||||
|
||||
type CurrentValues = z.infer<typeof currentValues>;
|
||||
type HourlyValues = z.infer<typeof hourlyValues>;
|
||||
|
||||
const getValue = (values: CurrentValues | HourlyValues, field: keyof Omit<CurrentValues, "time">, index?: number) => {
|
||||
const value = values[field];
|
||||
if (Array.isArray(value)) return value[index ?? 0] ?? null;
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizePoint = (
|
||||
values: CurrentValues | HourlyValues,
|
||||
index: number | undefined,
|
||||
observedAt: string,
|
||||
): AirQualityPoint => ({
|
||||
observedAt,
|
||||
europeanAqi: getValue(values, "european_aqi", index),
|
||||
usAqi: getValue(values, "us_aqi", index),
|
||||
uvIndex: getValue(values, "uv_index", index),
|
||||
pollutants: {
|
||||
pm2_5: getValue(values, "pm2_5", index),
|
||||
pm10: getValue(values, "pm10", index),
|
||||
ozone: getValue(values, "ozone", index),
|
||||
nitrogenDioxide: getValue(values, "nitrogen_dioxide", index),
|
||||
sulphurDioxide: getValue(values, "sulphur_dioxide", index),
|
||||
carbonMonoxide: getValue(values, "carbon_monoxide", index),
|
||||
},
|
||||
europeanSubIndices: {
|
||||
pm2_5: getValue(values, "european_aqi_pm2_5", index),
|
||||
pm10: getValue(values, "european_aqi_pm10", index),
|
||||
ozone: getValue(values, "european_aqi_ozone", index),
|
||||
nitrogenDioxide: getValue(values, "european_aqi_nitrogen_dioxide", index),
|
||||
sulphurDioxide: getValue(values, "european_aqi_sulphur_dioxide", index),
|
||||
carbonMonoxide: null,
|
||||
},
|
||||
usSubIndices: {
|
||||
pm2_5: getValue(values, "us_aqi_pm2_5", index),
|
||||
pm10: getValue(values, "us_aqi_pm10", index),
|
||||
ozone: getValue(values, "us_aqi_ozone", index),
|
||||
nitrogenDioxide: getValue(values, "us_aqi_nitrogen_dioxide", index),
|
||||
sulphurDioxide: getValue(values, "us_aqi_sulphur_dioxide", index),
|
||||
carbonMonoxide: getValue(values, "us_aqi_carbon_monoxide", index),
|
||||
},
|
||||
pollen: {
|
||||
alder: getValue(values, "alder_pollen", index),
|
||||
birch: getValue(values, "birch_pollen", index),
|
||||
grass: getValue(values, "grass_pollen", index),
|
||||
mugwort: getValue(values, "mugwort_pollen", index),
|
||||
olive: getValue(values, "olive_pollen", index),
|
||||
ragweed: getValue(values, "ragweed_pollen", index),
|
||||
},
|
||||
});
|
||||
|
||||
const getDailyMaximums = (hourly: HourlyValues): AirQuality["daily"] => {
|
||||
const grouped = new Map<string, AirQuality["daily"][number]>();
|
||||
|
||||
hourly.time.forEach((time, index) => {
|
||||
const date = time.slice(0, 10);
|
||||
const existing = grouped.get(date) ?? {
|
||||
date,
|
||||
europeanAqiMax: null,
|
||||
usAqiMax: null,
|
||||
uvIndexMax: null,
|
||||
pm2_5Max: null,
|
||||
pm10Max: null,
|
||||
};
|
||||
|
||||
existing.europeanAqiMax = maxNullable(existing.europeanAqiMax, hourly.european_aqi[index]);
|
||||
existing.usAqiMax = maxNullable(existing.usAqiMax, hourly.us_aqi[index]);
|
||||
existing.uvIndexMax = maxNullable(existing.uvIndexMax, hourly.uv_index[index]);
|
||||
existing.pm2_5Max = maxNullable(existing.pm2_5Max, hourly.pm2_5[index]);
|
||||
existing.pm10Max = maxNullable(existing.pm10Max, hourly.pm10[index]);
|
||||
grouped.set(date, existing);
|
||||
});
|
||||
|
||||
return Array.from(grouped.values()).slice(0, FORECAST_DAYS);
|
||||
};
|
||||
|
||||
const maxNullable = (current: number | null, candidate: number | null | undefined): number | null => {
|
||||
if (candidate === null || candidate === undefined) return current;
|
||||
if (current === null) return candidate;
|
||||
return Math.max(current, candidate);
|
||||
};
|
||||
|
||||
const toUtcInstant = (value: string, timeZone: string): string => {
|
||||
const parsed = dayjs.tz(value, timeZone);
|
||||
if (!parsed.isValid()) throw new Error(`Invalid air quality timestamp: ${value}`);
|
||||
return parsed.utc().toISOString();
|
||||
};
|
||||
|
||||
export interface AirQualityPollutants {
|
||||
pm2_5: number | null;
|
||||
pm10: number | null;
|
||||
ozone: number | null;
|
||||
nitrogenDioxide: number | null;
|
||||
sulphurDioxide: number | null;
|
||||
carbonMonoxide: number | null;
|
||||
}
|
||||
|
||||
export interface AirQualityPoint {
|
||||
observedAt: string;
|
||||
europeanAqi: number | null;
|
||||
usAqi: number | null;
|
||||
uvIndex: number | null;
|
||||
pollutants: AirQualityPollutants;
|
||||
europeanSubIndices: AirQualityPollutants;
|
||||
usSubIndices: AirQualityPollutants;
|
||||
pollen: {
|
||||
alder: number | null;
|
||||
birch: number | null;
|
||||
grass: number | null;
|
||||
mugwort: number | null;
|
||||
olive: number | null;
|
||||
ragweed: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AirQuality {
|
||||
timezone: string;
|
||||
utcOffsetSeconds: number;
|
||||
current: AirQualityPoint;
|
||||
hourly: AirQualityPoint[];
|
||||
daily: {
|
||||
date: string;
|
||||
europeanAqiMax: number | null;
|
||||
usAqiMax: number | null;
|
||||
uvIndexMax: number | null;
|
||||
pm2_5Max: number | null;
|
||||
pm10Max: number | null;
|
||||
}[];
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
@@ -8,6 +8,7 @@ export const anchorNotesListRequestHandler = createIntegrationRequestHandler<
|
||||
"anchor",
|
||||
AnchorNotesListInput
|
||||
>({
|
||||
cacheNamespace: "anchor-notes:list",
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return instance.listNotesAsync(input);
|
||||
@@ -15,6 +16,7 @@ export const anchorNotesListRequestHandler = createIntegrationRequestHandler<
|
||||
});
|
||||
|
||||
export const anchorNoteRequestHandler = createIntegrationRequestHandler<AnchorNote, "anchor", { noteId: string }>({
|
||||
cacheNamespace: "anchor-notes:detail",
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return instance.getNoteAsync(input.noteId);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
@@ -9,6 +9,7 @@ export const archiveTeamWarriorRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"archiving">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "archive-team-warrior:status",
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getStatusAsync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -8,6 +8,7 @@ export const audiobookshelfRequestHandler = createIntegrationRequestHandler<
|
||||
"audiobookshelf",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "audiobookshelf:stats",
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getDashboardDataAsync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { BazarrBadges } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -8,6 +8,7 @@ export const bazarrBadgesRequestHandler = createIntegrationRequestHandler<
|
||||
"bazarr",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "bazarr:badges",
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getBadgesAsync();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type {
|
||||
BeszelAlert,
|
||||
BeszelAlertHistory,
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "beszelRequestHandler" });
|
||||
const MEBIBYTE = 1024 ** 2;
|
||||
|
||||
export type { BeszelSystemRow } from "@homarr/integrations/types";
|
||||
|
||||
@@ -22,7 +23,7 @@ export type { BeszelSystemRow } from "@homarr/integrations/types";
|
||||
*
|
||||
* BeszelSystemInfo uses short field names (see beszel-types.ts for full docs):
|
||||
* cpu=CPU%, mp=memory%, dp=disk%, g=GPU%, la=loadAvg, u=uptime(s), v=version,
|
||||
* bb=bandwidth(bytes/s), b=bandwidth(Mbps legacy), dt=disk temp, bat=battery,
|
||||
* bb=bandwidth(bytes/s), b=bandwidth(MiB/s legacy), dt=disk temp, bat=battery,
|
||||
* sv=[running,total] services, h=hostname, m=CPU model, c=cores, ct=threads
|
||||
*/
|
||||
function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | null): BeszelSystemRow {
|
||||
@@ -37,8 +38,8 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
|
||||
extraFilesystems: info.efs ?? {},
|
||||
gpu: info.g ?? 0,
|
||||
loadAvg: info.la ?? null,
|
||||
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
|
||||
netBytes: info.bb ?? (info.b ?? 0) * 1_000_000,
|
||||
// bb = bytes/s (newer), b = MiB/s (legacy, normalize to bytes/s)
|
||||
netBytes: info.bb ?? (info.b ?? 0) * MEBIBYTE,
|
||||
temp: info.dt ?? null,
|
||||
battery: info.bat ?? null,
|
||||
services: info.sv?.[0] ?? 0,
|
||||
@@ -57,6 +58,7 @@ export const beszelSystemsRequestHandler = createIntegrationRequestHandler<
|
||||
"beszel" | "mock",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "beszel:systems",
|
||||
async requestAsync(integration) {
|
||||
const start = performance.now();
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
@@ -89,6 +91,7 @@ export const beszelAlertsRequestHandler = createIntegrationRequestHandler<
|
||||
"beszel" | "mock",
|
||||
{ includeHistory: boolean; maxHistoryItems: number }
|
||||
>({
|
||||
cacheNamespace: "beszel:alerts",
|
||||
async requestAsync(integration, input) {
|
||||
const start = performance.now();
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
@@ -123,6 +126,7 @@ export const beszelStatsRequestHandler = createIntegrationRequestHandler<
|
||||
"beszel" | "mock",
|
||||
{ systemId: string; timePeriod: string; includeDocker: boolean }
|
||||
>({
|
||||
cacheNamespace: "beszel:stats",
|
||||
// No cache — the widget polls every 5s for live updates;
|
||||
// a TTL here would serve stale records between polls.
|
||||
cacheTtlMs: 0,
|
||||
@@ -130,13 +134,17 @@ export const beszelStatsRequestHandler = createIntegrationRequestHandler<
|
||||
const start = performance.now();
|
||||
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
||||
const containerStats = input.includeDocker
|
||||
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch((error) => {
|
||||
const systemStatsPromise = instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
||||
let containerStatsPromise: Promise<BeszelContainerStatsRecord[]> = Promise.resolve([]);
|
||||
if (input.includeDocker) {
|
||||
containerStatsPromise = instance
|
||||
.getContainerStatsAsync(input.systemId, config.type, config.perPage)
|
||||
.catch((error) => {
|
||||
logger.warn("Failed to fetch Beszel container stats", { systemId: input.systemId, error: String(error) });
|
||||
return [];
|
||||
})
|
||||
: [];
|
||||
});
|
||||
}
|
||||
const [systemStats, containerStats] = await Promise.all([systemStatsPromise, containerStatsPromise]);
|
||||
logger.debug("beszelStats fetch completed", {
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getCalendarMonthRange } from "./calendar";
|
||||
|
||||
describe("getCalendarMonthRange", () => {
|
||||
test("treats month 5 as May and includes the calendar overflow days", () => {
|
||||
const { startDate, endDate } = getCalendarMonthRange(2025, 5);
|
||||
|
||||
expect(startDate.getFullYear()).toBe(2025);
|
||||
expect(startDate.getMonth()).toBe(3);
|
||||
expect(startDate.getDate()).toBe(25);
|
||||
expect(endDate.getFullYear()).toBe(2025);
|
||||
expect(endDate.getMonth()).toBe(5);
|
||||
expect(endDate.getDate()).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,30 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { CalendarEvent, RadarrReleaseType } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const getCalendarMonthRange = (year: number, month: number) => {
|
||||
const zeroBasedMonth = month - 1;
|
||||
return {
|
||||
startDate: dayjs().year(year).month(zeroBasedMonth).startOf("month").subtract(6, "days").toDate(),
|
||||
endDate: dayjs().year(year).month(zeroBasedMonth).endOf("month").add(6, "days").toDate(),
|
||||
};
|
||||
};
|
||||
|
||||
export const calendarMonthRequestHandler = createIntegrationRequestHandler<
|
||||
CalendarEvent[],
|
||||
IntegrationKindByCategory<"calendar">,
|
||||
{ year: number; month: number; releaseType: RadarrReleaseType[]; showUnmonitored: boolean }
|
||||
>({
|
||||
cacheNamespace: "calendar:month",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
// Calendar component shows up to 6 days before and after the month, for example if 1. of january is sunday, it shows the last 6 days of december.
|
||||
const startDate = dayjs().year(input.year).month(input.month).startOf("month").subtract(6, "days");
|
||||
const endDate = dayjs().year(input.year).month(input.month).endOf("month").add(6, "days");
|
||||
const { startDate, endDate } = getCalendarMonthRange(input.year, input.month);
|
||||
|
||||
return await integrationInstance.getCalendarEventsAsync(
|
||||
startDate.toDate(),
|
||||
endDate.toDate(),
|
||||
input.showUnmonitored,
|
||||
);
|
||||
return await integrationInstance.getCalendarEventsAsync(startDate, endDate, input.showUnmonitored);
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -8,6 +8,7 @@ export const coolifyRequestHandler = createIntegrationRequestHandler<
|
||||
"coolify",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "coolify:resources",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getInstanceInfoAsync();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -9,6 +9,7 @@ export const dnsHoleRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"dnsHole">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "dns-hole:summary",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getSummaryAsync();
|
||||
|
||||
@@ -8,15 +8,40 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { db, like, or } from "@homarr/db";
|
||||
import { icons } from "@homarr/db/schema";
|
||||
import { extractContainerImageName } from "@homarr/definitions";
|
||||
import type { ContainerState, Port } from "@homarr/docker";
|
||||
import type { ContainerState, DockerContainerTarget, DockerEndpointStatus, Port } from "@homarr/docker";
|
||||
import { dockerLabels, DockerSingleton } from "@homarr/docker";
|
||||
|
||||
import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "dockerRequestHandler" });
|
||||
export const dockerWidgetEndpointTimeoutMs = 5_000;
|
||||
|
||||
const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
|
||||
const withDockerTimeoutAsync = async <T>(
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
message: string,
|
||||
timeoutMs: number,
|
||||
) => {
|
||||
const controller = new AbortController();
|
||||
const timeoutError = new Error(message);
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation(controller.signal),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort(timeoutError);
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const isDemoMode = () => ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
|
||||
|
||||
const port = (privatePort: number, publicPort: number, type: string): Port => ({
|
||||
IP: "0.0.0.0",
|
||||
@@ -170,40 +195,64 @@ const mockContainers: {
|
||||
},
|
||||
];
|
||||
|
||||
const demoDockerEndpoint = {
|
||||
id: "demo",
|
||||
name: "Demo Docker",
|
||||
status: "available",
|
||||
kind: "docker",
|
||||
transport: "socket",
|
||||
capabilities: ["inventory"],
|
||||
source: "default",
|
||||
scope: "admin",
|
||||
} satisfies DockerEndpointStatus;
|
||||
|
||||
export const dockerContainersRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync() {
|
||||
if (isDemoMode) {
|
||||
return mockContainers;
|
||||
cacheNamespace: "docker-containers",
|
||||
async requestAsync({ endpointIds }: { endpointIds?: string[] }) {
|
||||
if (isDemoMode()) {
|
||||
const includesDemoEndpoint =
|
||||
endpointIds === undefined || endpointIds.length === 0 || endpointIds.includes("demo");
|
||||
if (!includesDemoEndpoint) return { containers: [], endpoints: [] };
|
||||
|
||||
return {
|
||||
containers: mockContainers.map((container) => ({
|
||||
...container,
|
||||
endpointId: "demo",
|
||||
endpointName: "Demo Docker",
|
||||
resourceId: `demo:${container.id}`,
|
||||
})),
|
||||
endpoints: [demoDockerEndpoint],
|
||||
};
|
||||
}
|
||||
return await getContainersWithStatsAsync();
|
||||
return await getContainersWithStatsAsync(dockerWidgetEndpointTimeoutMs, endpointIds);
|
||||
},
|
||||
});
|
||||
|
||||
const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image);
|
||||
|
||||
const findContainerByIdAsync = async (id: string) => {
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const containers = await Promise.all(
|
||||
dockerInstances.map(async ({ instance }) => {
|
||||
const container = instance.getContainer(id);
|
||||
export const hasDockerEndpointCapability = (
|
||||
endpointId: string,
|
||||
capability: "inventory" | "logs" | "lifecycle" | "remove",
|
||||
) =>
|
||||
isDemoMode()
|
||||
? endpointId === "demo" && capability === "inventory"
|
||||
: DockerSingleton.hasCapability(endpointId, capability);
|
||||
|
||||
return await new Promise<Container | null>((resolve) => {
|
||||
container.inspect((err, data) => {
|
||||
if (err || !data) {
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(container);
|
||||
}
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
export const findDockerContainerAsync = async (
|
||||
{ endpointId, id }: DockerContainerTarget,
|
||||
requiredCapability: "inventory" | "logs" | "lifecycle" | "remove" = "inventory",
|
||||
) => {
|
||||
const dockerInstance = DockerSingleton.findInstance(endpointId);
|
||||
if (!dockerInstance || !hasDockerEndpointCapability(endpointId, requiredCapability)) return null;
|
||||
|
||||
return containers.find((container) => container) ?? null;
|
||||
const container = dockerInstance.instance.getContainer(id);
|
||||
return await new Promise<Container | null>((resolve) => {
|
||||
container.inspect((err, data) => resolve(err || !data ? null : container));
|
||||
});
|
||||
};
|
||||
|
||||
export const getContainerLogsAsync = async (id: string, tail = 200) => {
|
||||
const container = await findContainerByIdAsync(id);
|
||||
export const getContainerLogsAsync = async (target: DockerContainerTarget, tail = 200) => {
|
||||
const container = await findDockerContainerAsync(target, "logs");
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
@@ -219,12 +268,12 @@ export const getContainerLogsAsync = async (id: string, tail = 200) => {
|
||||
};
|
||||
|
||||
export const streamContainerLogsAsync = async (
|
||||
id: string,
|
||||
target: DockerContainerTarget,
|
||||
tail: number,
|
||||
onData: (data: string) => void,
|
||||
onError: (err: Error) => void,
|
||||
) => {
|
||||
const container = await findContainerByIdAsync(id);
|
||||
const container = await findDockerContainerAsync(target, "logs");
|
||||
if (!container) {
|
||||
onError(new Error("Container not found"));
|
||||
return () => undefined;
|
||||
@@ -259,25 +308,126 @@ export const streamContainerLogsAsync = async (
|
||||
};
|
||||
};
|
||||
|
||||
async function getContainersWithStatsAsync() {
|
||||
export const getDockerEndpointsAsync = (): DockerEndpointStatus[] => {
|
||||
if (isDemoMode()) return [demoDockerEndpoint];
|
||||
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const initializationFailures = DockerSingleton.getInitializationFailures();
|
||||
|
||||
return [
|
||||
...dockerInstances.map(({ endpointId, endpointName, descriptor }) => ({
|
||||
id: endpointId,
|
||||
name: endpointName,
|
||||
status: "available" as const,
|
||||
kind: descriptor.kind,
|
||||
transport: descriptor.transport.type,
|
||||
capabilities: descriptor.capabilities,
|
||||
source: descriptor.source,
|
||||
scope: descriptor.scope,
|
||||
})),
|
||||
...initializationFailures.map(({ descriptor }) => ({
|
||||
id: descriptor.id,
|
||||
name: descriptor.name,
|
||||
status: "unavailable" as const,
|
||||
kind: descriptor.kind,
|
||||
transport: descriptor.transport.type,
|
||||
capabilities: descriptor.capabilities,
|
||||
source: descriptor.source,
|
||||
scope: descriptor.scope,
|
||||
})),
|
||||
];
|
||||
};
|
||||
|
||||
/** CPU counters suitable as a delta baseline for current-usage calculation. */
|
||||
export type CpuUsageCounters = {
|
||||
totalUsage: number;
|
||||
systemUsage: number;
|
||||
};
|
||||
|
||||
type CachedCpuUsageCounters = CpuUsageCounters & {
|
||||
cachedAt: number;
|
||||
};
|
||||
|
||||
// Docker's one-shot stats endpoint returns no usable precpu_stats baseline (the
|
||||
// fields are present but zero), so current CPU usage cannot be derived from a
|
||||
// single response. The request-handler process is long-lived and polls
|
||||
// repeatedly, so the previous poll's counters serve as the baseline instead.
|
||||
// Entries expire so removed containers do not accumulate.
|
||||
const cachedCpuUsageCounters = new Map<string, CachedCpuUsageCounters>();
|
||||
const cpuUsageCountersCacheTtlMs = 10 * 60 * 1000;
|
||||
|
||||
/** @internal Only intended for tests to isolate the process-local counter cache between cases. */
|
||||
export function clearCpuUsageCountersCacheForTesting(): void {
|
||||
cachedCpuUsageCounters.clear();
|
||||
}
|
||||
|
||||
export async function getContainersWithStatsAsync(
|
||||
timeoutMs = dockerWidgetEndpointTimeoutMs,
|
||||
endpointIds: string[] = [],
|
||||
) {
|
||||
const cacheEvictedBefore = Date.now() - cpuUsageCountersCacheTtlMs;
|
||||
for (const [key, counters] of cachedCpuUsageCounters) {
|
||||
if (counters.cachedAt < cacheEvictedBefore) {
|
||||
cachedCpuUsageCounters.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedEndpointIds = new Set(endpointIds);
|
||||
const includesAllEndpoints = selectedEndpointIds.size === 0;
|
||||
const dockerInstances = DockerSingleton.getInstances().filter(
|
||||
({ endpointId }) => includesAllEndpoints || selectedEndpointIds.has(endpointId),
|
||||
);
|
||||
const initializationFailures = DockerSingleton.getInitializationFailures().filter(
|
||||
({ descriptor }) => includesAllEndpoints || selectedEndpointIds.has(descriptor.id),
|
||||
);
|
||||
const results = await Promise.allSettled(
|
||||
dockerInstances.map(async ({ instance, host }) => {
|
||||
const instanceContainers = await instance.listContainers({ all: true });
|
||||
dockerInstances.map(async ({ instance, host, endpointId, endpointName }) => {
|
||||
const instanceContainers = await withDockerTimeoutAsync(
|
||||
async (signal) => await instance.listContainers({ all: true, abortSignal: signal }),
|
||||
`Timed out listing containers from Docker host ${host}`,
|
||||
timeoutMs,
|
||||
);
|
||||
return instanceContainers
|
||||
.filter((container) => !(dockerLabels.hide in container.Labels))
|
||||
.map((container) => ({ ...container, instance: host }));
|
||||
.map((container) => ({ ...container, instance: host, endpointId, endpointName }));
|
||||
}),
|
||||
);
|
||||
|
||||
const endpoints = [
|
||||
...results.map((result, index) => {
|
||||
const dockerInstance = dockerInstances.at(index);
|
||||
if (!dockerInstance) throw new Error("Docker endpoint result did not match a configured endpoint");
|
||||
return {
|
||||
id: dockerInstance.endpointId,
|
||||
name: dockerInstance.endpointName,
|
||||
status: result.status === "fulfilled" ? ("available" as const) : ("unavailable" as const),
|
||||
kind: dockerInstance.descriptor.kind,
|
||||
transport: dockerInstance.descriptor.transport.type,
|
||||
capabilities: dockerInstance.descriptor.capabilities,
|
||||
source: dockerInstance.descriptor.source,
|
||||
scope: dockerInstance.descriptor.scope,
|
||||
} satisfies DockerEndpointStatus;
|
||||
}),
|
||||
...initializationFailures.map(({ descriptor }) => ({
|
||||
id: descriptor.id,
|
||||
name: descriptor.name,
|
||||
status: "unavailable" as const,
|
||||
kind: descriptor.kind,
|
||||
transport: descriptor.transport.type,
|
||||
capabilities: descriptor.capabilities,
|
||||
source: descriptor.source,
|
||||
scope: descriptor.scope,
|
||||
})),
|
||||
] satisfies DockerEndpointStatus[];
|
||||
|
||||
const containers = results.flatMap((result, index) => {
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
const dockerInstance = dockerInstances.at(index);
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to list containers from Docker host",
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
host: dockerInstances[index]!.host,
|
||||
host: dockerInstance?.host ?? "unknown",
|
||||
},
|
||||
{ cause: result.reason },
|
||||
),
|
||||
@@ -294,26 +444,56 @@ async function getContainersWithStatsAsync() {
|
||||
})
|
||||
: [];
|
||||
|
||||
const degradedEndpointIds = new Set<string>();
|
||||
const containerStatsPromises = containers.map(async (container) => {
|
||||
const instance = dockerInstances.find(({ host }) => host === container.instance)?.instance;
|
||||
const instance = DockerSingleton.findInstance(container.endpointId)?.instance;
|
||||
if (!instance) return null;
|
||||
|
||||
const stats = await instance
|
||||
.getContainer(container.Id)
|
||||
.stats({ stream: false, "one-shot": true })
|
||||
.catch(
|
||||
() =>
|
||||
({
|
||||
cpu_stats: { online_cpus: 0, cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
|
||||
memory_stats: { usage: 0 },
|
||||
}) as ContainerStats,
|
||||
let stats: ContainerStats;
|
||||
try {
|
||||
stats = await withDockerTimeoutAsync(
|
||||
async (signal) => {
|
||||
const options = { stream: false as const, "one-shot": true, abortSignal: signal };
|
||||
return await instance.getContainer(container.Id).stats(options);
|
||||
},
|
||||
`Timed out reading Docker container stats for ${container.Id}`,
|
||||
timeoutMs,
|
||||
);
|
||||
} catch (error) {
|
||||
degradedEndpointIds.add(container.endpointId);
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to read Docker container stats",
|
||||
{ endpointId: container.endpointId, containerId: container.Id, host: container.instance },
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
stats = {
|
||||
cpu_stats: { online_cpus: 0, cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
|
||||
memory_stats: { usage: 0 },
|
||||
} as ContainerStats;
|
||||
}
|
||||
|
||||
const cpuUsageCacheKey = `${container.endpointId}:${container.Id}`;
|
||||
const cpuUsage = calculateCpuUsage(stats, cachedCpuUsageCounters.get(cpuUsageCacheKey));
|
||||
|
||||
// Remember this poll's counters so the next one-shot response has a delta
|
||||
// baseline even though the daemon does not provide precpu_stats.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const totalUsage = stats.cpu_stats?.cpu_usage?.total_usage;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const systemUsage = stats.cpu_stats?.system_cpu_usage;
|
||||
if (typeof totalUsage === "number" && totalUsage > 0 && typeof systemUsage === "number" && systemUsage > 0) {
|
||||
cachedCpuUsageCounters.set(cpuUsageCacheKey, { totalUsage, systemUsage, cachedAt: Date.now() });
|
||||
}
|
||||
|
||||
const cpuUsage = calculateCpuUsage(stats);
|
||||
const memoryUsage = calculateMemoryUsage(stats);
|
||||
|
||||
return {
|
||||
id: container.Id,
|
||||
endpointId: container.endpointId,
|
||||
endpointName: container.endpointName,
|
||||
resourceId: `${container.endpointId}:${container.Id}`,
|
||||
name: container.Names[0]?.split("/")[1] ?? "Unknown",
|
||||
host: container.instance,
|
||||
state: container.State as ContainerState,
|
||||
@@ -325,23 +505,43 @@ async function getContainersWithStatsAsync() {
|
||||
};
|
||||
});
|
||||
|
||||
return (await Promise.all(containerStatsPromises)).filter((container) => container !== null);
|
||||
const resolvedContainers = (await Promise.all(containerStatsPromises)).filter((container) => container !== null);
|
||||
return {
|
||||
containers: resolvedContainers,
|
||||
endpoints: endpoints.map((endpoint) =>
|
||||
endpoint.status === "available" && degradedEndpointIds.has(endpoint.id)
|
||||
? { ...endpoint, status: "degraded" as const }
|
||||
: endpoint,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateCpuUsage(stats: ContainerStats): number {
|
||||
export function calculateCpuUsage(stats: ContainerStats, previousCounters?: CpuUsageCounters): number {
|
||||
// Handle containers with missing or invalid stats (e.g., exited, dead containers, Podman responses)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!stats.cpu_stats?.online_cpus || stats.cpu_stats.online_cpus === 0 || !stats.cpu_stats.cpu_usage?.total_usage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const numberOfCpus = stats.cpu_stats.online_cpus;
|
||||
const usage = stats.cpu_stats.system_cpu_usage;
|
||||
if (!usage || usage === 0) {
|
||||
// Docker's one-shot mode (and Podman) return precpu_stats with zeroed values,
|
||||
// so a system counter of zero means there is no usable daemon-side baseline;
|
||||
// fall back to the previous poll's counters, then to the cumulative totals.
|
||||
const hasPrecpuBaseline = (stats.precpu_stats?.system_cpu_usage ?? 0) > 0;
|
||||
const previousTotalUsage = hasPrecpuBaseline
|
||||
? // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
(stats.precpu_stats?.cpu_usage?.total_usage ?? 0)
|
||||
: (previousCounters?.totalUsage ?? 0);
|
||||
const previousSystemUsage = hasPrecpuBaseline
|
||||
? (stats.precpu_stats?.system_cpu_usage ?? 0)
|
||||
: (previousCounters?.systemUsage ?? 0);
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - previousTotalUsage;
|
||||
const systemDelta = (stats.cpu_stats.system_cpu_usage ?? 0) - previousSystemUsage;
|
||||
|
||||
if (systemDelta <= 0 || cpuDelta < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
|
||||
return (cpuDelta / systemDelta) * 100;
|
||||
}
|
||||
|
||||
export function calculateMemoryUsage(stats: ContainerStats): number {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
@@ -9,6 +9,8 @@ export const downloadClientRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"downloadClient">,
|
||||
{ limit: number }
|
||||
>({
|
||||
cacheNamespace: "downloads:jobs-and-status",
|
||||
cacheTtlMs: 10 * 1000,
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getClientJobsAndStatusAsync(input);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type {
|
||||
FirewallCpuSummary,
|
||||
FirewallInterfacesSummary,
|
||||
@@ -14,6 +14,7 @@ export const firewallCpuRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"firewall">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "firewall:cpu",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return integrationInstance.getFirewallCpuAsync();
|
||||
@@ -25,6 +26,7 @@ export const firewallMemoryRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"firewall">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "firewall:memory",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getFirewallMemoryAsync();
|
||||
@@ -36,6 +38,7 @@ export const firewallInterfacesRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"firewall">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "firewall:interfaces",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getFirewallInterfacesAsync();
|
||||
@@ -47,6 +50,7 @@ export const firewallVersionRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"firewall">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "firewall:version",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getFirewallVersionAsync();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { ProxmoxClusterInfo, SystemHealthMonitoring } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -9,6 +9,7 @@ export const systemInfoRequestHandler = createIntegrationRequestHandler<
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon" | "wud">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "health-monitoring:system-info",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getSystemInfoAsync();
|
||||
@@ -20,6 +21,7 @@ export const clusterInfoRequestHandler = createIntegrationRequestHandler<
|
||||
"proxmox" | "mock",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "health-monitoring:cluster-info",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getClusterInfoAsync();
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { immichAlbumsRequestHandler } from "./immich";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createIntegrationAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/integrations/factory", () => ({
|
||||
createIntegrationAsync: mocks.createIntegrationAsync,
|
||||
}));
|
||||
|
||||
const integration = {
|
||||
id: "immich-integration",
|
||||
kind: "immich",
|
||||
} as Parameters<typeof immichAlbumsRequestHandler.handler>[0];
|
||||
|
||||
const albums = [
|
||||
{ id: "upstream-first", albumName: "Zebra", assetCount: 1 },
|
||||
{ id: "second-b", albumName: "Beta", assetCount: 10 },
|
||||
{ id: "second-a", albumName: "Alpha", assetCount: 10 },
|
||||
];
|
||||
|
||||
describe("immichAlbumsRequestHandler", () => {
|
||||
beforeEach(() => {
|
||||
immichAlbumsRequestHandler.invalidateCache();
|
||||
mocks.createIntegrationAsync.mockReset();
|
||||
});
|
||||
|
||||
test("sorts deterministically before caching a limited response", async () => {
|
||||
const getAlbumsAsync = vi.fn().mockResolvedValue([...albums]);
|
||||
mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync });
|
||||
|
||||
const first = await immichAlbumsRequestHandler.handler(integration, { limit: 2 }).getDataAsync();
|
||||
const cached = await immichAlbumsRequestHandler.handler(integration, { limit: 2 }).getDataAsync();
|
||||
|
||||
expect(first.data.map(({ id }) => id)).toStrictEqual(["second-a", "second-b"]);
|
||||
expect(cached.data).toStrictEqual(first.data);
|
||||
expect(getAlbumsAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("preserves upstream order when no limit is requested", async () => {
|
||||
const getAlbumsAsync = vi.fn().mockResolvedValue([...albums]);
|
||||
mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync });
|
||||
|
||||
const result = await immichAlbumsRequestHandler.handler(integration, {}).getDataAsync();
|
||||
|
||||
expect(result.data).toStrictEqual(albums);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { ImmichAlbum, ImmichServerStats } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -9,6 +9,7 @@ export const immichStatsRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"photoService">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "immich:server-stats",
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getServerStatsAsync();
|
||||
@@ -22,21 +23,48 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
|
||||
assetCount: number;
|
||||
}[],
|
||||
IntegrationKindByCategory<"photoService">,
|
||||
Record<string, never>
|
||||
{ limit?: number }
|
||||
>({
|
||||
async requestAsync(integration) {
|
||||
cacheNamespace: "immich:albums",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getAlbumsAsync();
|
||||
const albums = await integrationInstance.getAlbumsAsync();
|
||||
|
||||
if (input.limit === undefined) return albums;
|
||||
|
||||
return albums
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
right.assetCount - left.assetCount ||
|
||||
compareText(left.albumName, right.albumName) ||
|
||||
compareText(left.id, right.id),
|
||||
)
|
||||
.slice(0, input.limit);
|
||||
},
|
||||
});
|
||||
|
||||
const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
|
||||
|
||||
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
|
||||
ImmichAlbum,
|
||||
IntegrationKindByCategory<"photoService">,
|
||||
{ albumId?: string }
|
||||
>({
|
||||
cacheNamespace: "immich:album",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getAlbumAsync(input.albumId);
|
||||
},
|
||||
});
|
||||
|
||||
export const immichAlbumPreviewRequestHandler = createIntegrationRequestHandler<
|
||||
ImmichAlbum,
|
||||
IntegrationKindByCategory<"photoService">,
|
||||
{ albumId: string; randomizePhotos: boolean }
|
||||
>({
|
||||
cacheNamespace: "immich:album-preview",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getAlbumPreviewAsync(input.albumId, input.randomizePhotos);
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { Indexer } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -9,6 +9,7 @@ export const indexerManagerRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"indexerManager">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "indexer-manager:indexers",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getIndexersAsync();
|
||||
|
||||
@@ -1,22 +1,63 @@
|
||||
import { createKeyedFingerprint } from "@homarr/common/server";
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis";
|
||||
|
||||
import { createIntegrationSharedCacheAsync, hashSharedCacheInput } from "./shared-cache";
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
import type { SharedCacheAdapter } from "./request-handler";
|
||||
|
||||
type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"> & {
|
||||
kind: TKind;
|
||||
decryptedSecrets: Modify<Pick<IntegrationSecret, "kind" | "value">, { value: string }>[];
|
||||
decryptedSecrets: Modify<
|
||||
Pick<IntegrationSecret, "kind" | "value"> & Partial<Pick<IntegrationSecret, "updatedAt">>,
|
||||
{ value: string }
|
||||
>[];
|
||||
externalUrl: string | null;
|
||||
};
|
||||
|
||||
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput, signal: AbortSignal) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
cacheNamespace?: string;
|
||||
cacheVersion?: string;
|
||||
}
|
||||
|
||||
export const getIntegrationCacheIdentity = <TKind extends IntegrationKind, TInput extends Record<string, unknown>>({
|
||||
integration,
|
||||
options,
|
||||
}: {
|
||||
integration: IntegrationOfKind<TKind>;
|
||||
options: TInput;
|
||||
}) => {
|
||||
const secretRevisions = (integration.decryptedSecrets ?? [])
|
||||
.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
fingerprint: createKeyedFingerprint(JSON.stringify([secret.kind, secret.value])),
|
||||
updatedAt: secret.updatedAt?.toISOString(),
|
||||
}))
|
||||
.toSorted((left, right) => {
|
||||
const kindComparison = left.kind.localeCompare(right.kind);
|
||||
if (kindComparison !== 0) return kindComparison;
|
||||
return left.fingerprint.localeCompare(right.fingerprint);
|
||||
});
|
||||
|
||||
return hashSharedCacheInput({
|
||||
integration: {
|
||||
id: integration.id,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
externalUrl: integration.externalUrl,
|
||||
secretRevisions,
|
||||
},
|
||||
options,
|
||||
});
|
||||
};
|
||||
|
||||
export const createIntegrationRequestHandler = <
|
||||
TData,
|
||||
TKind extends IntegrationKind,
|
||||
@@ -24,17 +65,46 @@ export const createIntegrationRequestHandler = <
|
||||
>(
|
||||
options: Options<TData, TKind, TInput>,
|
||||
) => {
|
||||
const cacheNamespace = options.cacheNamespace;
|
||||
let getSharedCacheAsync:
|
||||
| ((
|
||||
input: { integration: IntegrationOfKind<TKind>; options: TInput },
|
||||
cacheIdentity: string,
|
||||
) => Promise<SharedCacheAdapter<TData>>)
|
||||
| undefined;
|
||||
if (cacheNamespace) {
|
||||
getSharedCacheAsync = async ({ integration }, cacheIdentity) =>
|
||||
await createIntegrationSharedCacheAsync({
|
||||
namespace: cacheNamespace,
|
||||
integrationId: integration.id,
|
||||
cacheIdentity,
|
||||
cacheVersion: options.cacheVersion,
|
||||
});
|
||||
}
|
||||
|
||||
const inner = createRequestHandler<TData, { integration: IntegrationOfKind<TKind>; options: TInput }>({
|
||||
requestAsync: async ({ integration, options: itemOptions }) => options.requestAsync(integration, itemOptions),
|
||||
getCacheKey: ({ integration, options: itemOptions }) =>
|
||||
JSON.stringify({ integrationId: integration.id, itemOptions }),
|
||||
requestAsync: async ({ integration, options: itemOptions }, signal) =>
|
||||
options.requestAsync(integration, itemOptions, signal),
|
||||
getCacheKey: getIntegrationCacheIdentity,
|
||||
cacheTtlMs: options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
|
||||
requestTimeoutMs: options.requestTimeoutMs,
|
||||
getSharedCacheAsync,
|
||||
});
|
||||
|
||||
return {
|
||||
invalidateCache: inner.invalidateCache,
|
||||
/** Clears this handler's L1 and every shared response-cache namespace for these integrations. */
|
||||
invalidateCacheAsync: async (integrationIds: readonly string[]) => {
|
||||
inner.invalidateCache();
|
||||
if (!cacheNamespace) return;
|
||||
await Promise.all(
|
||||
[...new Set(integrationIds)].map(async (integrationId) => {
|
||||
await invalidateIntegrationResponseCacheAsync(integrationId);
|
||||
}),
|
||||
);
|
||||
},
|
||||
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
|
||||
inner.handler({ integration, options: itemOptions }),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./integration-request-handler";
|
||||
import type { CacheEntry, SharedCacheAdapter } from "./request-handler";
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
import { createWidgetRequestHandler } from "./widget-request-handler";
|
||||
|
||||
@@ -52,6 +53,134 @@ describe("createRequestHandler", () => {
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("uses a fresh shared cache entry before calling the upstream service", async () => {
|
||||
const timestamp = new Date();
|
||||
const requestAsync = vi.fn(async () => "upstream");
|
||||
const sharedCache = {
|
||||
generation: "shared-1",
|
||||
isShared: true,
|
||||
getAsync: vi.fn(async () => ({
|
||||
data: "shared",
|
||||
timestamp,
|
||||
expiresAt: Date.now() + 1_000,
|
||||
staleUntil: Date.now() + 1_000,
|
||||
})),
|
||||
setAsync: vi.fn(async () => undefined),
|
||||
acquireRefreshLockAsync: vi.fn(async () => "lock"),
|
||||
renewRefreshLockAsync: vi.fn(async () => true),
|
||||
releaseRefreshLockAsync: vi.fn(async () => undefined),
|
||||
};
|
||||
const handler = createRequestHandler({
|
||||
cacheTtlMs: 1_000,
|
||||
requestAsync,
|
||||
getSharedCacheAsync: async () => sharedCache,
|
||||
});
|
||||
|
||||
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "shared", timestamp });
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
expect(sharedCache.acquireRefreshLockAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renews the refresh lock while the upstream request is still running", async () => {
|
||||
vi.useFakeTimers();
|
||||
const upstreamRequest = deferred<string>();
|
||||
const renewRefreshLockAsync = vi.fn(async () => true);
|
||||
const releaseRefreshLockAsync = vi.fn(async () => undefined);
|
||||
const sharedCache: SharedCacheAdapter<string> = {
|
||||
generation: "shared-1",
|
||||
isShared: true,
|
||||
getAsync: async () => null,
|
||||
setAsync: async () => undefined,
|
||||
acquireRefreshLockAsync: async () => "lock",
|
||||
renewRefreshLockAsync,
|
||||
releaseRefreshLockAsync,
|
||||
};
|
||||
const handler = createRequestHandler({
|
||||
cacheTtlMs: 1_000,
|
||||
requestAsync: async () => await upstreamRequest.promise,
|
||||
getSharedCacheAsync: async () => sharedCache,
|
||||
});
|
||||
|
||||
const pending = handler.handler({ id: "same" }).getDataAsync();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(renewRefreshLockAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
upstreamRequest.resolve("upstream");
|
||||
await expect(pending).resolves.toMatchObject({ data: "upstream" });
|
||||
expect(releaseRefreshLockAsync).toHaveBeenCalledWith("lock");
|
||||
});
|
||||
|
||||
it("waits out a refresh lock held by another process instead of stampeding the upstream", async () => {
|
||||
vi.useFakeTimers();
|
||||
const timestamp = new Date();
|
||||
let sharedEntry: CacheEntry<string> | null = null;
|
||||
const requestAsync = vi.fn(async () => "upstream");
|
||||
const sharedCache = {
|
||||
generation: "shared-1",
|
||||
isShared: true,
|
||||
getAsync: vi.fn(async () => sharedEntry),
|
||||
setAsync: vi.fn(async () => undefined),
|
||||
// A different process/pod already holds the refresh lock.
|
||||
acquireRefreshLockAsync: vi.fn(async () => null),
|
||||
renewRefreshLockAsync: vi.fn(async () => true),
|
||||
releaseRefreshLockAsync: vi.fn(async () => undefined),
|
||||
};
|
||||
const handler = createRequestHandler<string, { id: string }>({
|
||||
cacheTtlMs: 1_000,
|
||||
requestAsync,
|
||||
getSharedCacheAsync: async () => sharedCache,
|
||||
});
|
||||
|
||||
const pending = handler.handler({ id: "same" }).getDataAsync();
|
||||
|
||||
// Poll well past the old fixed wait window while the lock is still held and the
|
||||
// shared cache is empty: the waiter must keep waiting, not fetch upstream itself.
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
|
||||
// The lock holder finishes and publishes the entry to the shared cache.
|
||||
sharedEntry = {
|
||||
data: "shared",
|
||||
timestamp,
|
||||
expiresAt: Date.now() + 1_000,
|
||||
staleUntil: Date.now() + 1_000,
|
||||
};
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ data: "shared", timestamp });
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deduplicates while the shared cache is being resolved", async () => {
|
||||
const sharedCacheRequest = deferred<SharedCacheAdapter<string>>();
|
||||
const upstreamRequest = deferred<string>();
|
||||
const getSharedCacheAsync = vi.fn(async () => await sharedCacheRequest.promise);
|
||||
const requestAsync = vi.fn(async () => await upstreamRequest.promise);
|
||||
const handler = createRequestHandler({
|
||||
cacheTtlMs: 1_000,
|
||||
requestAsync,
|
||||
getSharedCacheAsync,
|
||||
});
|
||||
|
||||
const first = handler.handler({ id: "same" }).getDataAsync();
|
||||
const second = handler.handler({ id: "same" }).getDataAsync();
|
||||
expect(getSharedCacheAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
sharedCacheRequest.resolve({
|
||||
generation: "shared-1",
|
||||
isShared: false,
|
||||
getAsync: async () => undefined,
|
||||
setAsync: async () => undefined,
|
||||
acquireRefreshLockAsync: async () => undefined,
|
||||
renewRefreshLockAsync: async () => undefined,
|
||||
releaseRefreshLockAsync: async () => undefined,
|
||||
});
|
||||
await vi.waitFor(() => expect(requestAsync).toHaveBeenCalledTimes(1));
|
||||
upstreamRequest.resolve("value");
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
|
||||
});
|
||||
|
||||
it("keeps in-flight deduplication but retains no payload when ttl is zero", async () => {
|
||||
let calls = 0;
|
||||
const request = deferred<{ call: number }>();
|
||||
@@ -193,7 +322,7 @@ describe("createRequestHandler", () => {
|
||||
});
|
||||
|
||||
describe("createIntegrationRequestHandler", () => {
|
||||
it("keys requests by integration id and options without retaining secret values", async () => {
|
||||
it("does not deduplicate requests across rotated integration secrets", async () => {
|
||||
let calls = 0;
|
||||
const request = deferred<string>();
|
||||
const handler = createIntegrationRequestHandler<string, "sonarr", { page: number }>({
|
||||
@@ -203,11 +332,16 @@ describe("createIntegrationRequestHandler", () => {
|
||||
},
|
||||
});
|
||||
const first = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync();
|
||||
const duplicate = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync();
|
||||
const second = handler.handler(createIntegration("rotated-secret"), { page: 1 }).getDataAsync();
|
||||
expect(calls).toBe(1);
|
||||
expect(calls).toBe(2);
|
||||
|
||||
request.resolve("value");
|
||||
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
|
||||
await expect(Promise.all([first, duplicate, second])).resolves.toMatchObject([
|
||||
{ data: "value" },
|
||||
{ data: "value" },
|
||||
{ data: "value" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,114 @@
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
requestAsync: (input: TInput, signal: AbortSignal) => Promise<TData>;
|
||||
getCacheKey?: (input: TInput) => string;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
getSharedCacheAsync?: (input: TInput, cacheIdentity: string) => Promise<SharedCacheAdapter<TData>>;
|
||||
}
|
||||
|
||||
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number; staleUntil: number };
|
||||
export interface CacheEntry<TData> {
|
||||
data: TData;
|
||||
timestamp: Date;
|
||||
expiresAt: number;
|
||||
staleUntil: number;
|
||||
}
|
||||
|
||||
export interface SharedCacheAdapter<TData> {
|
||||
generation: string;
|
||||
isShared: boolean;
|
||||
getAsync: () => Promise<CacheEntry<TData> | null | undefined>;
|
||||
setAsync: (entry: CacheEntry<TData>, refreshLockToken: string) => Promise<boolean | void>;
|
||||
acquireRefreshLockAsync: () => Promise<string | null | undefined>;
|
||||
renewRefreshLockAsync: (token: string) => Promise<boolean | undefined>;
|
||||
releaseRefreshLockAsync: (token: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
const MAX_INFLIGHT_REQUESTS = 100;
|
||||
const MAX_UPSTREAM_SETTLEMENT_GRACE_MS = 60_000;
|
||||
const DEFAULT_TTL_MS = 10_000;
|
||||
const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000;
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
||||
const SHARED_REFRESH_POLL_MS = 100;
|
||||
const SHARED_REFRESH_LOCK_RETRY_MS = 1_000;
|
||||
const SHARED_REFRESH_STALE_WAIT_MS = 15_000;
|
||||
const SHARED_REFRESH_MAX_WAIT_MS = 30_000;
|
||||
// Must remain comfortably below REFRESH_LOCK_TTL_SECONDS in shared-cache.ts.
|
||||
const SHARED_REFRESH_LOCK_RENEW_MS = 5_000;
|
||||
|
||||
class RequestHandlerTimeoutError extends Error {
|
||||
constructor() {
|
||||
super("Request handler deadline exceeded");
|
||||
this.name = RequestHandlerTimeoutError.name;
|
||||
}
|
||||
}
|
||||
|
||||
class RequestHandlerOverloadedError extends Error {
|
||||
constructor() {
|
||||
super("Request handler concurrency limit exceeded");
|
||||
this.name = RequestHandlerOverloadedError.name;
|
||||
}
|
||||
}
|
||||
|
||||
const throwIfDeadlineExceeded = (deadlineAt: number) => {
|
||||
if (Date.now() >= deadlineAt) throw new RequestHandlerTimeoutError();
|
||||
};
|
||||
|
||||
const delayAsync = async (durationMs: number, deadlineAt: number) => {
|
||||
throwIfDeadlineExceeded(deadlineAt);
|
||||
const remainingMs = deadlineAt - Date.now();
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, Math.min(durationMs, remainingMs));
|
||||
timer.unref?.();
|
||||
});
|
||||
throwIfDeadlineExceeded(deadlineAt);
|
||||
};
|
||||
|
||||
const waitForSettlementGraceAsync = async (settledAsync: Promise<void>, durationMs: number) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, durationMs);
|
||||
timer.unref?.();
|
||||
void settledAsync.then(() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const startRefreshLockRenewal = <TData>(sharedCache: SharedCacheAdapter<TData>, token: string) => {
|
||||
let isOwned = true;
|
||||
let isStopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const schedule = () => {
|
||||
timer = setTimeout(() => {
|
||||
void sharedCache.renewRefreshLockAsync(token).then(
|
||||
(renewed) => {
|
||||
if (renewed !== true) {
|
||||
isOwned = false;
|
||||
return;
|
||||
}
|
||||
if (!isStopped) schedule();
|
||||
},
|
||||
() => {
|
||||
isOwned = false;
|
||||
},
|
||||
);
|
||||
}, SHARED_REFRESH_LOCK_RENEW_MS);
|
||||
timer.unref?.();
|
||||
};
|
||||
|
||||
schedule();
|
||||
return {
|
||||
isOwned: () => isOwned,
|
||||
stop: () => {
|
||||
isStopped = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
|
||||
const now = Date.now();
|
||||
@@ -22,12 +120,94 @@ const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
|
||||
export const createRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
||||
throw new Error("Request timeout must be a positive finite duration");
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry<TData>>();
|
||||
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
|
||||
const sharedCacheResolutions = new Map<string, Promise<SharedCacheAdapter<TData>>>();
|
||||
const upstreamSettlements = new Map<string, Promise<void>>();
|
||||
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let expiryTimerAt: number | undefined;
|
||||
let generation = 0;
|
||||
|
||||
const storeInMemory = (key: string, entry: CacheEntry<TData>, requestGeneration: number) => {
|
||||
if (generation !== requestGeneration || Date.now() >= entry.staleUntil) return;
|
||||
evictExpired(cache);
|
||||
cache.delete(key);
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest !== undefined) cache.delete(oldest);
|
||||
}
|
||||
cache.set(key, entry);
|
||||
scheduleExpiry(entry.staleUntil);
|
||||
};
|
||||
|
||||
const getFromMemory = (key: string) => {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return undefined;
|
||||
cache.delete(key);
|
||||
cache.set(key, entry);
|
||||
return entry;
|
||||
};
|
||||
|
||||
const startRequestWithDeadline = (input: TInput, deadlineAt: number, upstreamKey: string) => {
|
||||
throwIfDeadlineExceeded(deadlineAt);
|
||||
if (upstreamSettlements.has(upstreamKey) || upstreamSettlements.size >= MAX_INFLIGHT_REQUESTS) {
|
||||
throw new RequestHandlerOverloadedError();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutError = new RequestHandlerTimeoutError();
|
||||
const remainingMs = deadlineAt - Date.now();
|
||||
let upstreamRequest: Promise<TData>;
|
||||
try {
|
||||
upstreamRequest = Promise.resolve(options.requestAsync(input, controller.signal));
|
||||
} catch (error) {
|
||||
upstreamRequest = Promise.reject(error);
|
||||
}
|
||||
const settledAsync = upstreamRequest.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
upstreamSettlements.set(upstreamKey, settledAsync);
|
||||
void settledAsync.then(() => {
|
||||
if (upstreamSettlements.get(upstreamKey) === settledAsync) upstreamSettlements.delete(upstreamKey);
|
||||
});
|
||||
|
||||
const resultAsync = new Promise<TData>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort(timeoutError);
|
||||
reject(timeoutError);
|
||||
}, remainingMs);
|
||||
timer.unref?.();
|
||||
|
||||
void upstreamRequest.then(resolve, reject).finally(() => clearTimeout(timer));
|
||||
});
|
||||
|
||||
return { resultAsync, settledAsync };
|
||||
};
|
||||
|
||||
const resolveSharedCacheAsync = (input: TInput, cacheIdentity: string, requestGeneration: number) => {
|
||||
if (!options.getSharedCacheAsync) return undefined;
|
||||
|
||||
const resolutionKey = `${requestGeneration}:${cacheIdentity}`;
|
||||
const existing = sharedCacheResolutions.get(resolutionKey);
|
||||
if (existing) return existing;
|
||||
if (sharedCacheResolutions.size >= MAX_INFLIGHT_REQUESTS) {
|
||||
throw new RequestHandlerOverloadedError();
|
||||
}
|
||||
|
||||
let promise: Promise<SharedCacheAdapter<TData>>;
|
||||
promise = options.getSharedCacheAsync(input, cacheIdentity).finally(() => {
|
||||
if (sharedCacheResolutions.get(resolutionKey) === promise) sharedCacheResolutions.delete(resolutionKey);
|
||||
});
|
||||
sharedCacheResolutions.set(resolutionKey, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
const scheduleExpiry = (candidateExpiryAt?: number) => {
|
||||
if (candidateExpiryAt !== undefined && expiryTimerAt !== undefined && expiryTimerAt <= candidateExpiryAt) return;
|
||||
if (expiryTimer) clearTimeout(expiryTimer);
|
||||
@@ -53,72 +233,149 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
invalidateCache: () => {
|
||||
generation += 1;
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
if (expiryTimer) clearTimeout(expiryTimer);
|
||||
expiryTimer = undefined;
|
||||
expiryTimerAt = undefined;
|
||||
},
|
||||
handler: (input: TInput) => ({
|
||||
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
|
||||
const deadlineAt = Date.now() + requestTimeoutMs;
|
||||
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
|
||||
const key = options.getCacheKey?.(input) ?? JSON.stringify(input);
|
||||
|
||||
let cached = cache.get(key);
|
||||
const now = Date.now();
|
||||
if (cached && now < cached.expiresAt) {
|
||||
return { data: cached.data, timestamp: cached.timestamp };
|
||||
const baseKey = options.getCacheKey?.(input) ?? JSON.stringify(input);
|
||||
const requestGeneration = generation;
|
||||
let sharedCache: SharedCacheAdapter<TData> | undefined;
|
||||
const sharedCacheResolution = ttl > 0 ? resolveSharedCacheAsync(input, baseKey, requestGeneration) : undefined;
|
||||
if (sharedCacheResolution) {
|
||||
sharedCache = await sharedCacheResolution;
|
||||
throwIfDeadlineExceeded(deadlineAt);
|
||||
}
|
||||
|
||||
const key = `${sharedCache?.generation ?? "local"}:${baseKey}`;
|
||||
const inflightKey = `${requestGeneration}:${key}`;
|
||||
const existing = inflight.get(inflightKey);
|
||||
if (existing) return existing;
|
||||
|
||||
let cached = getFromMemory(key);
|
||||
const now = Date.now();
|
||||
if (cached && now < cached.expiresAt) return cached;
|
||||
if (cached && now >= cached.staleUntil) {
|
||||
cache.delete(key);
|
||||
scheduleExpiry();
|
||||
cached = undefined;
|
||||
}
|
||||
if (upstreamSettlements.has(inflightKey) || inflight.size >= MAX_INFLIGHT_REQUESTS) {
|
||||
if (options.fallbackToStaleOnError && cached) return cached;
|
||||
throw new RequestHandlerOverloadedError();
|
||||
}
|
||||
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const requestGeneration = generation;
|
||||
let cleanupAfterUpstreamAsync: Promise<void> | undefined;
|
||||
let promise: Promise<CacheEntry<TData>>;
|
||||
promise = options
|
||||
.requestAsync(input)
|
||||
.then((data) => {
|
||||
const expiresAt = Date.now() + ttl;
|
||||
promise = (async () => {
|
||||
let stale = cached;
|
||||
let lockToken: string | null | undefined;
|
||||
|
||||
if (sharedCache?.isShared) {
|
||||
const sharedEntry = await sharedCache.getAsync();
|
||||
if (sharedEntry && Date.now() < sharedEntry.expiresAt) {
|
||||
storeInMemory(key, sharedEntry, requestGeneration);
|
||||
return sharedEntry;
|
||||
}
|
||||
if (sharedEntry && Date.now() < sharedEntry.staleUntil) stale = sharedEntry;
|
||||
|
||||
lockToken = await sharedCache.acquireRefreshLockAsync();
|
||||
if (lockToken === null) {
|
||||
const returnStaleAt = Date.now() + SHARED_REFRESH_STALE_WAIT_MS;
|
||||
const waitDeadlineAt = Math.min(deadlineAt, Date.now() + SHARED_REFRESH_MAX_WAIT_MS);
|
||||
let retryLockAt = Date.now() + SHARED_REFRESH_LOCK_RETRY_MS;
|
||||
while (lockToken === null && sharedCache.isShared) {
|
||||
await delayAsync(SHARED_REFRESH_POLL_MS, waitDeadlineAt);
|
||||
const refreshedEntry = await sharedCache.getAsync();
|
||||
if (refreshedEntry && Date.now() < refreshedEntry.expiresAt) {
|
||||
storeInMemory(key, refreshedEntry, requestGeneration);
|
||||
return refreshedEntry;
|
||||
}
|
||||
if (refreshedEntry && Date.now() < refreshedEntry.staleUntil) stale = refreshedEntry;
|
||||
if (Date.now() >= returnStaleAt && options.fallbackToStaleOnError && stale) {
|
||||
storeInMemory(key, stale, requestGeneration);
|
||||
return stale;
|
||||
}
|
||||
if (Date.now() >= retryLockAt) {
|
||||
lockToken = await sharedCache.acquireRefreshLockAsync();
|
||||
retryLockAt = Date.now() + SHARED_REFRESH_LOCK_RETRY_MS;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refreshLockRenewal =
|
||||
lockToken && sharedCache ? startRefreshLockRenewal(sharedCache, lockToken) : undefined;
|
||||
let upstreamSettledAsync: Promise<void> | undefined;
|
||||
try {
|
||||
const request = startRequestWithDeadline(input, deadlineAt, inflightKey);
|
||||
upstreamSettledAsync = request.settledAsync;
|
||||
const data = await request.resultAsync;
|
||||
const completedAt = Date.now();
|
||||
const staleIfErrorTtlMs = options.fallbackToStaleOnError
|
||||
? Math.max(0, options.staleIfErrorTtlMs ?? DEFAULT_STALE_IF_ERROR_TTL_MS)
|
||||
: 0;
|
||||
const entry: CacheEntry<TData> = {
|
||||
data,
|
||||
timestamp: new Date(),
|
||||
expiresAt,
|
||||
staleUntil: expiresAt + staleIfErrorTtlMs,
|
||||
timestamp: new Date(completedAt),
|
||||
expiresAt: completedAt + ttl,
|
||||
staleUntil: completedAt + ttl + staleIfErrorTtlMs,
|
||||
};
|
||||
if (ttl > 0 && generation === requestGeneration) {
|
||||
evictExpired(cache);
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest !== undefined) cache.delete(oldest);
|
||||
if (ttl > 0) {
|
||||
let canStoreInMemory = true;
|
||||
if (sharedCache?.isShared) {
|
||||
canStoreInMemory = false;
|
||||
if (lockToken && refreshLockRenewal?.isOwned() !== false) {
|
||||
const stored = await sharedCache.setAsync(entry, lockToken);
|
||||
if (stored !== false || !sharedCache.isShared) canStoreInMemory = true;
|
||||
}
|
||||
}
|
||||
cache.set(key, entry);
|
||||
scheduleExpiry(entry.staleUntil);
|
||||
if (canStoreInMemory) storeInMemory(key, entry, requestGeneration);
|
||||
}
|
||||
return entry;
|
||||
})
|
||||
.catch((err) => {
|
||||
} catch (error) {
|
||||
if (
|
||||
options.fallbackToStaleOnError &&
|
||||
cached &&
|
||||
Date.now() < cached.staleUntil &&
|
||||
stale &&
|
||||
Date.now() < stale.staleUntil &&
|
||||
generation === requestGeneration
|
||||
) {
|
||||
return cached;
|
||||
storeInMemory(key, stale, requestGeneration);
|
||||
return stale;
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflight.get(key) === promise) inflight.delete(key);
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
const cleanupAsync = async () => {
|
||||
refreshLockRenewal?.stop();
|
||||
if (lockToken) await sharedCache?.releaseRefreshLockAsync(lockToken);
|
||||
};
|
||||
if (upstreamSettledAsync) {
|
||||
const settlementGraceMs = Math.min(requestTimeoutMs, MAX_UPSTREAM_SETTLEMENT_GRACE_MS);
|
||||
cleanupAfterUpstreamAsync = waitForSettlementGraceAsync(upstreamSettledAsync, settlementGraceMs).then(
|
||||
cleanupAsync,
|
||||
);
|
||||
} else {
|
||||
await cleanupAsync();
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
inflight.set(key, promise);
|
||||
inflight.set(inflightKey, promise);
|
||||
const removeInflight = () => {
|
||||
const cleanupAsync = cleanupAfterUpstreamAsync ?? Promise.resolve();
|
||||
void cleanupAsync.then(
|
||||
() => {
|
||||
if (inflight.get(inflightKey) === promise) inflight.delete(inflightKey);
|
||||
},
|
||||
() => {
|
||||
if (inflight.get(inflightKey) === promise) inflight.delete(inflightKey);
|
||||
},
|
||||
);
|
||||
};
|
||||
void promise.then(removeInflight, removeInflight);
|
||||
return promise;
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { serialize } from "superjson";
|
||||
|
||||
import { isRecord } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import {
|
||||
createGetSetChannel,
|
||||
createLockChannel,
|
||||
getIntegrationCacheGenerationAsync,
|
||||
getWidgetCacheGenerationAsync,
|
||||
} from "@homarr/redis";
|
||||
import type { IntegrationCacheGeneration, WidgetCacheGeneration } from "@homarr/redis";
|
||||
|
||||
import type { CacheEntry, SharedCacheAdapter } from "./request-handler";
|
||||
|
||||
const logger = createLogger({ module: "sharedRequestCache" });
|
||||
// The dedicated cache client rejects commands after 500 ms. This guard stays
|
||||
// above that deadline so lock outcomes are never abandoned while still pending.
|
||||
const REDIS_OPERATION_TIMEOUT_MS = 750;
|
||||
const REFRESH_LOCK_TTL_SECONDS = 15;
|
||||
const SHARED_CACHE_ENVELOPE_VERSION = 2;
|
||||
const DEFAULT_HANDLER_CACHE_VERSION = "v1";
|
||||
const namespacePattern = /^[a-z0-9][a-z0-9:-]*$/;
|
||||
const cacheVersionPattern = /^[a-z0-9][a-z0-9._-]*$/;
|
||||
const canonicalize = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (!isRecord(value)) return value;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value).toSorted()) {
|
||||
result[key] = canonicalize(value[key]);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const hashSharedCacheInput = (options: Record<string, unknown>) => {
|
||||
const serialized = JSON.stringify(canonicalize(serialize(options)));
|
||||
return createHash("sha256").update(serialized).digest("hex");
|
||||
};
|
||||
|
||||
export const hashIntegrationCacheOptions = hashSharedCacheInput;
|
||||
|
||||
const withTimeoutAsync = async <T>(promise: Promise<T>, durationMs: number): Promise<T> => {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("Redis operation timed out")), durationMs);
|
||||
timer.unref?.();
|
||||
void promise.then(resolve, reject).finally(() => clearTimeout(timer));
|
||||
});
|
||||
};
|
||||
|
||||
interface SharedCacheEnvelope<TData> {
|
||||
version: typeof SHARED_CACHE_ENVELOPE_VERSION;
|
||||
entry: CacheEntry<TData>;
|
||||
}
|
||||
|
||||
const isSharedCacheEnvelope = (value: unknown): value is SharedCacheEnvelope<unknown> => {
|
||||
if (!isRecord(value) || value.version !== SHARED_CACHE_ENVELOPE_VERSION || !isRecord(value.entry)) return false;
|
||||
|
||||
const entry = value.entry;
|
||||
if (!Object.hasOwn(entry, "data") || !(entry.timestamp instanceof Date)) return false;
|
||||
const timestamp = entry.timestamp.getTime();
|
||||
return (
|
||||
Number.isFinite(timestamp) &&
|
||||
typeof entry.expiresAt === "number" &&
|
||||
Number.isFinite(entry.expiresAt) &&
|
||||
typeof entry.staleUntil === "number" &&
|
||||
Number.isFinite(entry.staleUntil) &&
|
||||
timestamp <= entry.expiresAt &&
|
||||
entry.expiresAt <= entry.staleUntil
|
||||
);
|
||||
};
|
||||
|
||||
interface IntegrationSharedCacheOptions {
|
||||
namespace: string;
|
||||
integrationId: string;
|
||||
cacheIdentity: string;
|
||||
cacheVersion?: string;
|
||||
}
|
||||
|
||||
interface RedisSharedCacheOptions {
|
||||
namespace: string;
|
||||
cacheKind: "integration" | "widget";
|
||||
cacheIdentity: string;
|
||||
cacheVersion: string;
|
||||
generation: string;
|
||||
metadata?: Record<string, string>;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
const logRedisFailure = (operation: string, metadata: Record<string, unknown>, error: unknown) => {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Redis response cache operation failed",
|
||||
{
|
||||
...metadata,
|
||||
operation,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const createRedisSharedCache = <TData>({
|
||||
namespace,
|
||||
cacheKind,
|
||||
cacheIdentity,
|
||||
cacheVersion,
|
||||
generation,
|
||||
metadata = {},
|
||||
isShared: initialIsShared = true,
|
||||
}: RedisSharedCacheOptions): SharedCacheAdapter<TData> => {
|
||||
const logMetadata = { cacheNamespace: namespace, cacheKind, ...metadata };
|
||||
const payloadName = `${cacheKind}-cache:payload:v2:${cacheVersion}:${namespace}:${generation}:${cacheIdentity}`;
|
||||
const lockName = `${cacheKind}-cache:lock:v2:${cacheVersion}:${namespace}:${generation}:${cacheIdentity}`;
|
||||
const payloadChannel = createGetSetChannel<unknown>(payloadName, { useBoundedCacheClient: true });
|
||||
const lock = createLockChannel(lockName, { useBoundedCacheClient: true });
|
||||
let isShared = initialIsShared;
|
||||
|
||||
const runRedisOperationAsync = async <T>(operation: string, callback: () => Promise<T>, fallback: T): Promise<T> => {
|
||||
if (!isShared) return fallback;
|
||||
try {
|
||||
return await withTimeoutAsync(callback(), REDIS_OPERATION_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
isShared = false;
|
||||
logRedisFailure(operation, logMetadata, error);
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
generation,
|
||||
get isShared() {
|
||||
return isShared;
|
||||
},
|
||||
getAsync: async () => {
|
||||
const value = await runRedisOperationAsync("payload-read", () => payloadChannel.getAsync(), undefined);
|
||||
if (value === undefined || value === null) return value;
|
||||
if (isSharedCacheEnvelope(value)) return value.entry as CacheEntry<TData>;
|
||||
|
||||
logger.warn("Ignored invalid Redis response cache entry", {
|
||||
...logMetadata,
|
||||
operation: "payload-validate",
|
||||
});
|
||||
await runRedisOperationAsync("payload-remove", () => payloadChannel.removeAsync(), undefined);
|
||||
return null;
|
||||
},
|
||||
setAsync: async (entry, refreshLockToken) => {
|
||||
const retentionMs = entry.staleUntil - Date.now();
|
||||
if (retentionMs <= 0) return false;
|
||||
const envelope: SharedCacheEnvelope<TData> = {
|
||||
version: SHARED_CACHE_ENVELOPE_VERSION,
|
||||
entry,
|
||||
};
|
||||
return await runRedisOperationAsync(
|
||||
"payload-write",
|
||||
() => lock.setIfOwnedAsync(refreshLockToken, payloadName, envelope, retentionMs),
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
acquireRefreshLockAsync: async () =>
|
||||
await runRedisOperationAsync(
|
||||
"refresh-lock-acquire",
|
||||
() => lock.acquireAsync(REFRESH_LOCK_TTL_SECONDS),
|
||||
undefined,
|
||||
),
|
||||
renewRefreshLockAsync: async (token) =>
|
||||
await runRedisOperationAsync(
|
||||
"refresh-lock-renew",
|
||||
() => lock.renewAsync(token, REFRESH_LOCK_TTL_SECONDS),
|
||||
undefined,
|
||||
),
|
||||
releaseRefreshLockAsync: async (token) => {
|
||||
try {
|
||||
await withTimeoutAsync(lock.releaseAsync(token), REDIS_OPERATION_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
logRedisFailure("refresh-lock-release", logMetadata, error);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface WidgetSharedCacheOptions {
|
||||
namespace: string;
|
||||
cacheIdentity: string;
|
||||
cacheVersion?: string;
|
||||
}
|
||||
|
||||
export const createWidgetSharedCacheAsync = async <TData>({
|
||||
namespace,
|
||||
cacheIdentity,
|
||||
cacheVersion = DEFAULT_HANDLER_CACHE_VERSION,
|
||||
}: WidgetSharedCacheOptions): Promise<SharedCacheAdapter<TData>> => {
|
||||
if (typeof window !== "undefined") throw new Error("Widget request caching is server-only");
|
||||
if (!namespacePattern.test(namespace)) throw new Error(`Invalid widget cache namespace '${namespace}'`);
|
||||
if (!cacheVersionPattern.test(cacheVersion)) throw new Error(`Invalid widget cache version '${cacheVersion}'`);
|
||||
|
||||
const metadata = { cacheNamespace: namespace };
|
||||
let generation: WidgetCacheGeneration;
|
||||
try {
|
||||
generation = await getWidgetCacheGenerationAsync(namespace);
|
||||
} catch (error) {
|
||||
logRedisFailure("generation-read", metadata, error);
|
||||
generation = { value: "redis-unavailable", isShared: false };
|
||||
}
|
||||
|
||||
return createRedisSharedCache<TData>({
|
||||
namespace,
|
||||
cacheKind: "widget",
|
||||
cacheIdentity,
|
||||
cacheVersion,
|
||||
generation: generation.value,
|
||||
isShared: generation.isShared,
|
||||
});
|
||||
};
|
||||
|
||||
export const createIntegrationSharedCacheAsync = async <TData>({
|
||||
namespace,
|
||||
integrationId,
|
||||
cacheIdentity,
|
||||
cacheVersion = DEFAULT_HANDLER_CACHE_VERSION,
|
||||
}: IntegrationSharedCacheOptions): Promise<SharedCacheAdapter<TData>> => {
|
||||
if (typeof window !== "undefined") throw new Error("Integration request caching is server-only");
|
||||
if (!namespacePattern.test(namespace)) throw new Error(`Invalid integration cache namespace '${namespace}'`);
|
||||
if (!cacheVersionPattern.test(cacheVersion)) throw new Error(`Invalid integration cache version '${cacheVersion}'`);
|
||||
|
||||
const metadata = { cacheNamespace: namespace, integrationId };
|
||||
let generation: IntegrationCacheGeneration;
|
||||
try {
|
||||
generation = await getIntegrationCacheGenerationAsync(integrationId);
|
||||
} catch (error) {
|
||||
logRedisFailure("generation-read", metadata, error);
|
||||
generation = { value: "redis-unavailable", isShared: false };
|
||||
}
|
||||
|
||||
const sharedCache = createRedisSharedCache<TData>({
|
||||
namespace,
|
||||
cacheKind: "integration",
|
||||
cacheIdentity,
|
||||
cacheVersion,
|
||||
generation: generation.value,
|
||||
metadata: { integrationId },
|
||||
isShared: generation.isShared,
|
||||
});
|
||||
return sharedCache;
|
||||
};
|
||||
@@ -1,12 +1,47 @@
|
||||
import { invalidateWidgetCache } from "@homarr/redis";
|
||||
|
||||
import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./shared-cache";
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
import type { SharedCacheAdapter } from "./request-handler";
|
||||
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
requestAsync: (input: TInput, signal: AbortSignal) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
cacheNamespace?: string;
|
||||
cacheVersion?: string;
|
||||
}
|
||||
|
||||
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
const inner = createRequestHandler<TData, TInput>({ requestAsync: options.requestAsync });
|
||||
return inner;
|
||||
const cacheNamespace = options.cacheNamespace;
|
||||
let getSharedCacheAsync: ((input: TInput, cacheIdentity: string) => Promise<SharedCacheAdapter<TData>>) | undefined;
|
||||
if (cacheNamespace) {
|
||||
getSharedCacheAsync = async (_input, cacheIdentity) =>
|
||||
await createWidgetSharedCacheAsync<TData>({
|
||||
namespace: cacheNamespace,
|
||||
cacheIdentity,
|
||||
cacheVersion: options.cacheVersion,
|
||||
});
|
||||
}
|
||||
|
||||
const inner = createRequestHandler<TData, TInput>({
|
||||
...options,
|
||||
cacheTtlMs: options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
|
||||
requestTimeoutMs: options.requestTimeoutMs,
|
||||
getCacheKey: hashSharedCacheInput,
|
||||
getSharedCacheAsync,
|
||||
});
|
||||
return {
|
||||
invalidateCache: () => {
|
||||
inner.invalidateCache();
|
||||
if (cacheNamespace) invalidateWidgetCache(cacheNamespace);
|
||||
},
|
||||
handler: inner.handler,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { LlamacppStats } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const llamacppStatsRequestHandler = createIntegrationRequestHandler<
|
||||
LlamacppStats,
|
||||
"llamacpp",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "llamacpp:stats",
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getStatsAsync();
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
@@ -16,8 +16,9 @@ export const mediaOrganizerRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"mediaOrganizer">,
|
||||
{ pageSize: number }
|
||||
>({
|
||||
cacheNamespace: "media-organizer:summary",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = (await createIntegrationAsync(integration)) as unknown as IMediaOrganizerIntegration;
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
const [missingResult, queueResult] = await Promise.all([
|
||||
integrationInstance.getMissingAsync(input.pageSize),
|
||||
integrationInstance.getMediaQueueAsync(input.pageSize),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { MediaRelease } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -9,6 +9,7 @@ export const mediaReleaseRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"mediaRelease">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "media-release:list",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getMediaReleasesAsync();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { MediaRequest, MediaRequestStatus } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -28,6 +28,7 @@ export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"mediaRequest">,
|
||||
MediaRequestListInput
|
||||
>({
|
||||
cacheNamespace: "media-requests:list",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
const requests = await integrationInstance.getRequestsAsync();
|
||||
|
||||
@@ -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/factory", () => ({
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { MediaRequestStats } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -9,11 +9,16 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"mediaRequest">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "media-requests:stats",
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
@@ -11,6 +11,7 @@ export const mediaServerRequestHandler = createIntegrationRequestHandler<
|
||||
showOnlyPlaying: boolean;
|
||||
}
|
||||
>({
|
||||
cacheNamespace: "media-server:sessions",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getCurrentSessionsAsync({ showOnlyPlaying: input.showOnlyPlaying });
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { mediaTranscodingRequestHandler } from "./media-transcoding";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createIntegrationAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/integrations/factory", () => ({
|
||||
createIntegrationAsync: mocks.createIntegrationAsync,
|
||||
}));
|
||||
|
||||
const integration = {
|
||||
id: "tdarr-integration",
|
||||
kind: "tdarr",
|
||||
} as Parameters<typeof mediaTranscodingRequestHandler.handler>[0];
|
||||
|
||||
describe("mediaTranscodingRequestHandler", () => {
|
||||
beforeEach(() => {
|
||||
mediaTranscodingRequestHandler.invalidateCache();
|
||||
mocks.createIntegrationAsync.mockReset();
|
||||
});
|
||||
|
||||
test("starts independent upstream requests together", async () => {
|
||||
const requestsStarted = Promise.withResolvers<void>();
|
||||
const getQueueAsync = vi.fn(async () => {
|
||||
await requestsStarted.promise;
|
||||
return { array: [], totalCount: 0, startIndex: 0, endIndex: 0 };
|
||||
});
|
||||
const getWorkersAsync = vi.fn(async () => {
|
||||
await requestsStarted.promise;
|
||||
return [];
|
||||
});
|
||||
const getStatisticsAsync = vi.fn(async () => {
|
||||
await requestsStarted.promise;
|
||||
return {};
|
||||
});
|
||||
mocks.createIntegrationAsync.mockResolvedValue({ getQueueAsync, getWorkersAsync, getStatisticsAsync });
|
||||
|
||||
const resultPromise = mediaTranscodingRequestHandler
|
||||
.handler(integration, { pageOffset: 0, pageSize: 10 })
|
||||
.getDataAsync();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getQueueAsync).toHaveBeenCalledOnce();
|
||||
expect(getWorkersAsync).toHaveBeenCalledOnce();
|
||||
expect(getStatisticsAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
requestsStarted.resolve();
|
||||
await resultPromise;
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -9,12 +9,18 @@ export const mediaTranscodingRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"mediaTranscoding">,
|
||||
{ pageOffset: number; pageSize: number }
|
||||
>({
|
||||
cacheNamespace: "media-transcoding:summary",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
const [queue, workers, statistics] = await Promise.all([
|
||||
integrationInstance.getQueueAsync(input.pageOffset, input.pageSize),
|
||||
integrationInstance.getWorkersAsync(),
|
||||
integrationInstance.getStatisticsAsync(),
|
||||
]);
|
||||
return {
|
||||
queue: await integrationInstance.getQueueAsync(input.pageOffset, input.pageSize),
|
||||
workers: await integrationInstance.getWorkersAsync(),
|
||||
statistics: await integrationInstance.getStatisticsAsync(),
|
||||
queue,
|
||||
workers,
|
||||
statistics,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
export const minecraftServerStatusRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { domain: string; isBedrockServer: boolean }) {
|
||||
// Domains are supplied through a public procedure and form an unbounded key space.
|
||||
requestTimeoutMs: 10_000,
|
||||
async requestAsync(input: { domain: string; isBedrockServer: boolean }, signal) {
|
||||
const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`;
|
||||
|
||||
const response = await withTimeoutAsync(async (signal) =>
|
||||
fetchWithTrustedCertificatesAsync(`https://api.mcsrvstat.us${path}`, { signal }),
|
||||
);
|
||||
const response = await fetchWithTrustedCertificatesAsync(`https://api.mcsrvstat.us${path}`, { signal });
|
||||
return responseSchema.parse(await response.json());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { NavidromeDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -8,6 +8,7 @@ export const navidromeRequestHandler = createIntegrationRequestHandler<
|
||||
"navidrome",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "navidrome:dashboard",
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getDashboardDataAsync();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { NetworkControllerSummary } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -9,6 +9,7 @@ export const networkControllerRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"networkController">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "network-controller:status",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getNetworkSummaryAsync();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { Notification } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
@@ -9,6 +9,7 @@ export const notificationsRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"notifications">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "notifications:list",
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getNotificationsAsync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { PaperlessNgxStats } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -8,6 +8,7 @@ export const paperlessNgxStatsRequestHandler = createIntegrationRequestHandler<
|
||||
"paperlessNgx",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "paperless-ngx:stats",
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getStatsAsync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { PatchMonStats } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -8,6 +8,7 @@ export const patchmonStatsRequestHandler = createIntegrationRequestHandler<
|
||||
"patchmon",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "patchmon:stats",
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getStatsAsync();
|
||||
|
||||
@@ -179,11 +179,30 @@ const getGithubReleaseAsync = async (
|
||||
|
||||
const api = getGithubApi(baseUrl, "Homarr-Lab/Homarr:GithubReleaseProvider", token);
|
||||
try {
|
||||
const allReleases = await api.paginate(api.rest.repos.listReleases, {
|
||||
owner: parsed.owner,
|
||||
repo: parsed.name,
|
||||
per_page: 100,
|
||||
});
|
||||
const allReleases: Awaited<ReturnType<typeof api.rest.repos.listReleases>>["data"] = [];
|
||||
try {
|
||||
for await (const page of api.paginate.iterator(api.rest.repos.listReleases, {
|
||||
owner: parsed.owner,
|
||||
repo: parsed.name,
|
||||
per_page: 100,
|
||||
})) {
|
||||
allReleases.push(...page.data);
|
||||
}
|
||||
} catch (error) {
|
||||
// GitHub can reject a later page even though earlier pages contain the latest release.
|
||||
if (
|
||||
!(error instanceof OctokitRequestError) ||
|
||||
error.status !== 422 ||
|
||||
!error.message.includes("Only the first 1000 results are available") ||
|
||||
allReleases.length === 0
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
logger.warn("GitHub release history capped; using the returned releases", {
|
||||
identifier,
|
||||
count: allReleases.length,
|
||||
});
|
||||
}
|
||||
if (allReleases.length === 0) {
|
||||
return { success: false, error: { code: "noReleasesFound", message: `${identifier} has no GitHub releases` } };
|
||||
}
|
||||
|
||||
@@ -10,18 +10,23 @@ import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
const logger = createLogger({ module: "rssFeedsRequestHandler" });
|
||||
|
||||
export const rssFeedsRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { url: string; count: number }) {
|
||||
const result = (await extract(input.url, {
|
||||
getExtraEntryFields: (feedEntry) => {
|
||||
const media = attemptGetImageFromEntry(input.url, feedEntry);
|
||||
if (!media) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
enclosure: media,
|
||||
};
|
||||
// Authorized editors can supply arbitrary URLs, so keep this cache process-local and bounded.
|
||||
async requestAsync(input: { url: string; count: number }, signal) {
|
||||
const result = (await extract(
|
||||
input.url,
|
||||
{
|
||||
getExtraEntryFields: (feedEntry) => {
|
||||
const media = attemptGetImageFromEntry(input.url, feedEntry);
|
||||
if (!media) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
enclosure: media,
|
||||
};
|
||||
},
|
||||
},
|
||||
})) as ExtendedFeedData;
|
||||
{ signal },
|
||||
)) as ExtendedFeedData;
|
||||
|
||||
return {
|
||||
...result,
|
||||
@@ -39,7 +44,8 @@ const attemptGetImageFromEntry = (feedUrl: string, entry: object) => {
|
||||
};
|
||||
|
||||
const getImageFromStringAsFallback = (feedUrl: string, content: string) => {
|
||||
const regex = /https?:\/\/\S+?\.(jpg|jpeg|png|gif|bmp|svg|webp|tiff)/i;
|
||||
const regex =
|
||||
/https?:\/\/[^\s"'<>\\]+?\.(jpg|jpeg|png|gif|bmp|svg|webp|tiff)\b(?:\?[^\s"'<>\\#]*)?(?:#[^\s"'<>\\]*)?/i;
|
||||
const result = regex.exec(content);
|
||||
|
||||
if (result == null) {
|
||||
@@ -52,50 +58,35 @@ const getImageFromStringAsFallback = (feedUrl: string, content: string) => {
|
||||
return result[0];
|
||||
};
|
||||
|
||||
const mediaProperties = [
|
||||
{
|
||||
path: ["enclosure", "@_url"],
|
||||
},
|
||||
{
|
||||
path: ["media:content", "@_url"],
|
||||
},
|
||||
];
|
||||
const mediaProperties = ["enclosure", "media:content", "media:thumbnail"] as const;
|
||||
|
||||
/**
|
||||
* The RSS and Atom standards are poorly adhered to in most of the web.
|
||||
* We want to show pretty background images on the posts and therefore need to extract
|
||||
* the enclosure (aka. media images). This function uses the dynamic properties defined above
|
||||
* to search through the possible paths and detect valid image URLs.
|
||||
* the enclosure (aka. media images). This function checks known media fields in priority
|
||||
* order and picks the first valid image URL from a field.
|
||||
* @param feedObject The object to scan for.
|
||||
* @returns the value of the first path that is found within the object
|
||||
* @returns the first valid media URL, or null when none is found
|
||||
*/
|
||||
const getFirstMediaProperty = (feedObject: object) => {
|
||||
for (const mediaProperty of mediaProperties) {
|
||||
let propertyIndex = 0;
|
||||
let objectAtPath: object = feedObject;
|
||||
while (propertyIndex < mediaProperty.path.length) {
|
||||
const key = mediaProperty.path[propertyIndex];
|
||||
if (key === undefined) {
|
||||
break;
|
||||
}
|
||||
const propertyEntries = Object.entries(objectAtPath);
|
||||
const propertyEntry = propertyEntries.find(([entryKey]) => entryKey === key);
|
||||
if (!propertyEntry) {
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const [_, propertyEntryValue] = propertyEntry;
|
||||
objectAtPath = propertyEntryValue as object;
|
||||
propertyIndex++;
|
||||
}
|
||||
const mediaValue: unknown = Object.entries(feedObject).find(([key]) => key === mediaProperty)?.[1];
|
||||
const mediaEntries: unknown[] = Array.isArray(mediaValue) ? mediaValue : [mediaValue];
|
||||
|
||||
const validationResult = z.string().url().safeParse(objectAtPath);
|
||||
if (!validationResult.success) {
|
||||
continue;
|
||||
}
|
||||
for (const mediaEntry of mediaEntries) {
|
||||
if (mediaEntry === null || typeof mediaEntry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(`Found an image in the feed entry: ${validationResult.data}`);
|
||||
return validationResult.data;
|
||||
const url: unknown = Object.entries(mediaEntry).find(([key]) => key === "@_url")?.[1];
|
||||
const validationResult = z.string().url().safeParse(url);
|
||||
if (!validationResult.success) {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(`Found an image in the feed entry: ${validationResult.data}`);
|
||||
return validationResult.data;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { toSafeEntityDetails } from "./smart-home-entity-state";
|
||||
|
||||
describe("toSafeEntityDetails", () => {
|
||||
it("keeps display attributes and removes sensitive Home Assistant metadata", () => {
|
||||
const details = toSafeEntityDetails({
|
||||
entity_id: "camera.front_door",
|
||||
state: "streaming",
|
||||
last_changed: new Date("2026-01-01T00:00:00Z"),
|
||||
last_updated: new Date("2026-01-01T00:00:01Z"),
|
||||
attributes: {
|
||||
friendly_name: "Front door",
|
||||
icon: "mdi:camera",
|
||||
access_token: "secret-camera-token",
|
||||
latitude: 48.85,
|
||||
},
|
||||
});
|
||||
|
||||
expect(details.attributes).toEqual({ friendly_name: "Front door", icon: "mdi:camera" });
|
||||
expect(details.attributes).not.toHaveProperty("access_token");
|
||||
expect(details.attributes).not.toHaveProperty("latitude");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,33 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { EntityState } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
const safeAttributeKeys = ["friendly_name", "unit_of_measurement", "device_class", "icon"] as const;
|
||||
|
||||
export const toSafeEntityDetails = (entity: EntityState) => {
|
||||
const attributes: Partial<Record<(typeof safeAttributeKeys)[number], string>> = {};
|
||||
for (const key of safeAttributeKeys) {
|
||||
const value = entity.attributes[key];
|
||||
if (typeof value === "string") attributes[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
attributes,
|
||||
entity_id: entity.entity_id,
|
||||
last_changed: entity.last_changed,
|
||||
last_updated: entity.last_updated,
|
||||
state: entity.state,
|
||||
};
|
||||
};
|
||||
|
||||
export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandler<
|
||||
string,
|
||||
EntityState,
|
||||
IntegrationKindByCategory<"smartHomeServer">,
|
||||
{ entityId: string }
|
||||
>({
|
||||
cacheNamespace: "smart-home:entity-state",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
const result = await integrationInstance.getEntityStateAsync(input.entityId);
|
||||
@@ -16,7 +36,7 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
|
||||
throw new Error(`Unable to fetch data from Home Assistant error='${result.error as string}'`);
|
||||
}
|
||||
|
||||
return result.data.state;
|
||||
return result.data;
|
||||
},
|
||||
// The widget already polls every 30 seconds and refetches after toggles.
|
||||
cacheTtlMs: 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -8,6 +8,7 @@ export const speedtestTrackerRequestHandler = createIntegrationRequestHandler<
|
||||
"speedtestTracker",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "speedtest-tracker:dashboard",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getDashboardDataAsync();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { getDemoStatsValues, getStatsSnapshotAsync } from "./stats";
|
||||
|
||||
const previousDemoMode = process.env.DEMO_MODE;
|
||||
|
||||
afterEach(() => {
|
||||
if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = previousDemoMode;
|
||||
});
|
||||
|
||||
describe("demo integration statistics", () => {
|
||||
test("returns showcase values only for seeded demo sources", () => {
|
||||
process.env.DEMO_MODE = "true";
|
||||
|
||||
expect(getDemoStatsValues({ kind: "sonarr", url: "https://demo.homarr.dev" })).toMatchObject({
|
||||
shows: 74,
|
||||
episodes: 3_986,
|
||||
});
|
||||
expect(getDemoStatsValues({ kind: "mealie", url: "https://demo.homarr.dev" })).toEqual({
|
||||
recipes: 318,
|
||||
users: 14,
|
||||
categories: 28,
|
||||
tags: 76,
|
||||
});
|
||||
expect(getDemoStatsValues({ kind: "spoolman", url: "https://demo.homarr.dev" })).toEqual({
|
||||
spools: 24,
|
||||
remainingWeight: 8_120,
|
||||
});
|
||||
expect(getDemoStatsValues({ kind: "sonarr", url: "https://sonarr.example.com" })).toBeUndefined();
|
||||
expect(getDemoStatsValues({ kind: "mock", url: "https://demo.homarr.dev" })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("serves seeded values through the read-only snapshot path", async () => {
|
||||
process.env.DEMO_MODE = "true";
|
||||
|
||||
const snapshot = await getStatsSnapshotAsync({
|
||||
id: "demo-spoolman",
|
||||
appId: null,
|
||||
kind: "spoolman",
|
||||
name: "Spoolman",
|
||||
url: "https://demo.homarr.dev",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [],
|
||||
});
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
values: { spools: 24, remainingWeight: 8_120 },
|
||||
retryAt: 0,
|
||||
error: false,
|
||||
stale: false,
|
||||
});
|
||||
expect(snapshot.updatedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
test("does not replace live statistics outside demo mode", () => {
|
||||
process.env.DEMO_MODE = "false";
|
||||
|
||||
expect(getDemoStatsValues({ kind: "sonarr", url: "https://demo.homarr.dev" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { fetchStatsAsync, getStatsMetrics } from "@homarr/integrations/stats";
|
||||
import type { StatsValue } from "@homarr/integrations/stats";
|
||||
import { createGetSetChannel, createLockChannel, getIntegrationCacheGenerationAsync } from "@homarr/redis";
|
||||
|
||||
import { getIntegrationCacheIdentity } from "./lib/integration-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "integrationStats" });
|
||||
|
||||
const FRESH_MS = 60 * 60 * 1000;
|
||||
const RETRY_MS = 60_000;
|
||||
const MAX_PENDING = 128;
|
||||
const snapshotSchema = z.object({
|
||||
identity: z.string(),
|
||||
values: z.record(z.string(), z.union([z.number().finite(), z.string().max(4096), z.boolean(), z.null()])),
|
||||
updatedAt: z.number().nullable(),
|
||||
retryAt: z.number(),
|
||||
error: z.boolean(),
|
||||
});
|
||||
type Snapshot = z.infer<typeof snapshotSchema>;
|
||||
type Input = Parameters<typeof getIntegrationCacheIdentity>[0]["integration"] & { kind: IntegrationKind };
|
||||
const running = new Map<string, Promise<void>>();
|
||||
const cacheOptions = { useBoundedCacheClient: true };
|
||||
|
||||
const demoValues: Partial<Record<IntegrationKind, Record<string, StatsValue>>> = {
|
||||
sonarr: {
|
||||
shows: 74,
|
||||
monitored: 68,
|
||||
downloaded: 3_128,
|
||||
storage: 1.82 * 1024 ** 4,
|
||||
missing: 6,
|
||||
queued: 3,
|
||||
episodes: 3_986,
|
||||
},
|
||||
radarr: {
|
||||
movies: 386,
|
||||
monitored: 342,
|
||||
downloaded: 371,
|
||||
storage: 3.74 * 1024 ** 4,
|
||||
missing: 15,
|
||||
queued: 4,
|
||||
},
|
||||
qBittorrent: { download: 38 * 1024 ** 2, upload: 9.3 * 1024 ** 2, paused: false },
|
||||
proxmox: { nodes: 3, vms: 12, lxcs: 8 },
|
||||
piHole: {
|
||||
dnsQueriesToday: 119_872,
|
||||
adsBlockedToday: 19_641,
|
||||
adsBlockedTodayPercentage: 16.38,
|
||||
domainsBeingBlocked: 1_186_431,
|
||||
},
|
||||
immich: {
|
||||
userCount: 4,
|
||||
photoCount: 48_392,
|
||||
videoCount: 2_438,
|
||||
totalLibraryUsageInBytes: 684.5 * 1024 ** 3,
|
||||
},
|
||||
karakeep: { bookmarks: 1_247, favorites: 86, archived: 212, highlights: 418, lists: 24, tags: 93 },
|
||||
mealie: { recipes: 318, users: 14, categories: 28, tags: 76 },
|
||||
spoolman: { spools: 24, remainingWeight: 8_120 },
|
||||
};
|
||||
|
||||
export const getDemoStatsValues = (integration: Pick<Input, "kind" | "url">) => {
|
||||
const demoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
|
||||
if (!demoMode || integration.url !== "https://demo.homarr.dev") return undefined;
|
||||
return demoValues[integration.kind];
|
||||
};
|
||||
|
||||
const resolveAsync = async (integration: Input) => {
|
||||
const generation = await getIntegrationCacheGenerationAsync(integration.id);
|
||||
if (!generation.isShared) throw new Error("Statistics cache is temporarily unavailable");
|
||||
// Snapshots outlive the expiring response-cache generation. Credentials and URLs
|
||||
// remain part of their identity; generations only guard in-flight refreshes.
|
||||
const identity = getIntegrationCacheIdentity({ integration, options: { version: 1 } });
|
||||
// A stable key replaces old credentials' data instead of retaining unreachable snapshots forever.
|
||||
const name = `integration-stats:snapshot:v1:${integration.id}`;
|
||||
const channel = createGetSetChannel<Snapshot>(name, cacheOptions);
|
||||
const parsed = snapshotSchema.safeParse(await channel.getAsync());
|
||||
let snapshot: Snapshot = { identity, values: {}, updatedAt: null, retryAt: 0, error: false };
|
||||
// Retain snapshots written before response generations were decoupled.
|
||||
if (parsed.success && (parsed.data.identity === identity || parsed.data.identity.endsWith(`:${identity}`)))
|
||||
snapshot = { ...parsed.data, identity };
|
||||
return { identity, generation: generation.value, name, channel, snapshot };
|
||||
};
|
||||
|
||||
export const getStatsSnapshotAsync = async (integration: Input) => {
|
||||
const demo = getDemoStatsValues(integration);
|
||||
if (demo) {
|
||||
return {
|
||||
values: demo,
|
||||
updatedAt: Date.now(),
|
||||
retryAt: 0,
|
||||
error: false,
|
||||
stale: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { snapshot } = await resolveAsync(integration);
|
||||
return {
|
||||
values: snapshot.values,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
retryAt: snapshot.retryAt,
|
||||
error: snapshot.error,
|
||||
stale: snapshot.updatedAt === null || Date.now() - snapshot.updatedAt >= FRESH_MS,
|
||||
};
|
||||
};
|
||||
|
||||
const pauseAsync = () => new Promise<void>((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
export const refreshStatsAsync = async (integration: Input, force: boolean) => {
|
||||
const key = integration.id;
|
||||
const pending = running.get(key);
|
||||
if (pending) return await pending;
|
||||
if (running.size >= MAX_PENDING) throw new Error("Statistics refresh queue is full");
|
||||
const work = runRefreshAsync(integration, force).finally(() => running.delete(key));
|
||||
running.set(key, work);
|
||||
return await work;
|
||||
};
|
||||
|
||||
const runRefreshAsync = async (integration: Input, force: boolean) => {
|
||||
const initial = await resolveAsync(integration);
|
||||
if (!force && initial.snapshot.retryAt > Date.now()) return;
|
||||
if (!force && initial.snapshot.updatedAt !== null && Date.now() - initial.snapshot.updatedAt < FRESH_MS) return;
|
||||
const lock = createLockChannel(`integration-stats:lock:${integration.id}`, cacheOptions);
|
||||
const token = await lock.acquireAsync(120);
|
||||
// Another process owns this source. The caller keeps its snapshot and checks again later.
|
||||
if (!token) return;
|
||||
let slot: ReturnType<typeof createLockChannel> | undefined;
|
||||
let slotToken: string | undefined;
|
||||
let owned = true;
|
||||
const controller = new AbortController();
|
||||
const renewal = setInterval(() => {
|
||||
void (async () => {
|
||||
if (!(await lock.renewAsync(token, 120))) owned = false;
|
||||
if (slot && slotToken && !(await slot.renewAsync(slotToken, 120))) owned = false;
|
||||
if (!owned) controller.abort();
|
||||
})().catch(() => {
|
||||
owned = false;
|
||||
controller.abort();
|
||||
});
|
||||
}, 10_000);
|
||||
renewal.unref?.();
|
||||
let deadline: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const waitUntil = Date.now() + 60_000;
|
||||
while (!slot && Date.now() < waitUntil) {
|
||||
if (!owned) break;
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const candidate = createLockChannel(`integration-stats:slot:${index}`, cacheOptions);
|
||||
const acquired = await candidate.acquireAsync(120);
|
||||
if (!acquired) continue;
|
||||
slot = candidate;
|
||||
slotToken = acquired;
|
||||
break;
|
||||
}
|
||||
if (!slot) await pauseAsync();
|
||||
}
|
||||
if (!slot || !owned) return;
|
||||
// Recheck after queuing: credentials or another refresh may have changed the snapshot.
|
||||
const current = await resolveAsync(integration);
|
||||
if (current.identity !== initial.identity || current.generation !== initial.generation) return;
|
||||
if (!force && current.snapshot.updatedAt !== null && Date.now() - current.snapshot.updatedAt < FRESH_MS) return;
|
||||
deadline = setTimeout(() => controller.abort(), 60_000);
|
||||
deadline.unref?.();
|
||||
try {
|
||||
const raw = getDemoStatsValues(integration) ?? (await fetchBeforeAbortAsync(integration, controller.signal));
|
||||
controller.signal.throwIfAborted();
|
||||
const values: Snapshot["values"] = {};
|
||||
for (const metric of getStatsMetrics(integration.kind)) values[metric.key] = raw[metric.key] ?? null;
|
||||
const snapshot = snapshotSchema.parse({
|
||||
identity: initial.identity,
|
||||
values,
|
||||
updatedAt: Date.now(),
|
||||
retryAt: 0,
|
||||
error: false,
|
||||
});
|
||||
const latest = await resolveAsync(integration);
|
||||
if (owned && latest.identity === initial.identity && latest.generation === initial.generation)
|
||||
await lock.setPersistentIfOwnedAsync(token, initial.name, snapshot);
|
||||
} catch {
|
||||
// Promise.all can reject before sibling provider requests finish.
|
||||
controller.abort();
|
||||
logger.warn("Statistics source refresh failed", { integrationId: integration.id, kind: integration.kind });
|
||||
const latest = await resolveAsync(integration);
|
||||
if (owned && latest.identity === initial.identity && latest.generation === initial.generation) {
|
||||
await lock.setPersistentIfOwnedAsync(token, initial.name, {
|
||||
...current.snapshot,
|
||||
retryAt: Date.now() + RETRY_MS,
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
controller.abort();
|
||||
clearInterval(renewal);
|
||||
if (deadline) clearTimeout(deadline);
|
||||
const releases = [lock.releaseAsync(token)];
|
||||
if (slot && slotToken) releases.push(slot.releaseAsync(slotToken));
|
||||
await Promise.allSettled(releases);
|
||||
}
|
||||
};
|
||||
|
||||
// Legacy adapters may ignore cancellation. Stop waiting so one stalled source cannot
|
||||
// retain a distributed refresh slot forever; their own in-flight guard prevents overlap.
|
||||
const fetchBeforeAbortAsync = async (integration: Input, signal: AbortSignal) => {
|
||||
signal.throwIfAborted();
|
||||
let onAbort: (() => void) | undefined;
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
onAbort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([fetchStatsAsync(integration, signal), aborted]);
|
||||
} finally {
|
||||
if (onAbort) signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
};
|
||||
@@ -1,18 +1,17 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
export const fetchStockPriceHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) {
|
||||
const response = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
`https://query1.finance.yahoo.com/v8/finance/chart/${input.stock}?range=${input.timeRange}&interval=${input.timeInterval}`,
|
||||
{ signal },
|
||||
);
|
||||
});
|
||||
// Symbols are supplied through a public procedure and form an unbounded key space.
|
||||
requestTimeoutMs: 10_000,
|
||||
async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }, signal) {
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
`https://query1.finance.yahoo.com/v8/finance/chart/${input.stock}?range=${input.timeRange}&interval=${input.timeInterval}`,
|
||||
{ signal },
|
||||
);
|
||||
const data = dataSchema.parse(await response.json());
|
||||
|
||||
if ("error" in data) {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { ContainerStats } from "dockerode";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { DockerSingleton } from "@homarr/docker";
|
||||
|
||||
import { calculateCpuUsage, calculateMemoryUsage } from "../docker";
|
||||
import {
|
||||
calculateCpuUsage,
|
||||
calculateMemoryUsage,
|
||||
clearCpuUsageCountersCacheForTesting,
|
||||
dockerContainersRequestHandler,
|
||||
getDockerEndpointsAsync,
|
||||
getContainersWithStatsAsync,
|
||||
hasDockerEndpointCapability,
|
||||
} from "../docker";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
@@ -64,16 +73,42 @@ describe("calculateCpuUsage", () => {
|
||||
const stats = createStats({
|
||||
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 2000 }, system_cpu_usage: 10000 },
|
||||
});
|
||||
// (2000 / 10000) * 4 * 100 = 80
|
||||
expect(calculateCpuUsage(stats)).toBe(80);
|
||||
// (2000 / 10000) * 100 = 20
|
||||
expect(calculateCpuUsage(stats)).toBe(20);
|
||||
});
|
||||
|
||||
test("should handle fractional CPU usage", () => {
|
||||
const stats = createStats({
|
||||
cpu_stats: { online_cpus: 2, cpu_usage: { total_usage: 500 }, system_cpu_usage: 100000 },
|
||||
});
|
||||
// (500 / 100000) * 2 * 100 = 1
|
||||
expect(calculateCpuUsage(stats)).toBe(1);
|
||||
// (500 / 100000) * 100 = 0.5
|
||||
expect(calculateCpuUsage(stats)).toBe(0.5);
|
||||
});
|
||||
|
||||
test("should use previous poll counters when precpu_stats is zeroed (one-shot mode)", () => {
|
||||
const stats = createStats({
|
||||
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 3_000 }, system_cpu_usage: 20_000 },
|
||||
// Docker's one-shot response contains precpu_stats with all-zero values
|
||||
precpu_stats: { cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
|
||||
});
|
||||
// Delta vs previous poll: cpu 3_000 - 1_000 = 2_000, system 20_000 - 10_000 = 10_000 → 20%
|
||||
expect(calculateCpuUsage(stats, { totalUsage: 1_000, systemUsage: 10_000 })).toBe(20);
|
||||
});
|
||||
|
||||
test("should prefer a valid precpu_stats baseline over previous poll counters", () => {
|
||||
const stats = createStats({
|
||||
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 3_000 }, system_cpu_usage: 20_000 },
|
||||
precpu_stats: { cpu_usage: { total_usage: 2_500 }, system_cpu_usage: 15_000 },
|
||||
});
|
||||
// precpu deltas: cpu 500, system 5_000 → 10%; the previous counters must be ignored
|
||||
expect(calculateCpuUsage(stats, { totalUsage: 1_000, systemUsage: 10_000 })).toBe(10);
|
||||
});
|
||||
|
||||
test("should return 0 when current counters are lower than the previous poll (container restart)", () => {
|
||||
const stats = createStats({
|
||||
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 100 }, system_cpu_usage: 20_000 },
|
||||
});
|
||||
expect(calculateCpuUsage(stats, { totalUsage: 1_000, systemUsage: 10_000 })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,3 +154,194 @@ describe("calculateMemoryUsage", () => {
|
||||
expect(calculateMemoryUsage(stats)).toBe(512);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContainersWithStatsAsync", () => {
|
||||
beforeEach(() => {
|
||||
clearCpuUsageCountersCacheForTesting();
|
||||
});
|
||||
|
||||
test("computes current CPU usage from successive one-shot polls", async () => {
|
||||
let totalUsage = 1_000_000;
|
||||
let systemUsage = 10_000_000;
|
||||
const dockerInstance = createDockerInstance("local", async () => [
|
||||
{
|
||||
Id: "busy",
|
||||
Image: "immich:latest",
|
||||
Labels: {},
|
||||
Names: ["/immich"],
|
||||
State: "running",
|
||||
Ports: [],
|
||||
},
|
||||
]);
|
||||
dockerInstance.instance.getContainer = (() => ({
|
||||
stats: async () => ({
|
||||
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: totalUsage }, system_cpu_usage: systemUsage },
|
||||
memory_stats: { usage: 0 },
|
||||
}),
|
||||
})) as never;
|
||||
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([dockerInstance] as never);
|
||||
vi.spyOn(DockerSingleton, "findInstance").mockReturnValue(dockerInstance as never);
|
||||
|
||||
try {
|
||||
// First poll has no baseline: falls back to the cumulative ratio (10%)
|
||||
const first = await getContainersWithStatsAsync(50);
|
||||
expect(first.containers).toEqual([expect.objectContaining({ id: "busy", cpuUsage: 10 })]);
|
||||
|
||||
// Next poll used 2 of 4 core-seconds → current usage 50%, not a lifetime average
|
||||
totalUsage += 2_000_000;
|
||||
systemUsage += 4_000_000;
|
||||
|
||||
const second = await getContainersWithStatsAsync(50);
|
||||
expect(second.containers).toEqual([expect.objectContaining({ id: "busy", cpuUsage: 50 })]);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test("queries only selected Docker endpoints and leaves an empty selection as all", async () => {
|
||||
const firstListContainers = vi.fn(async () => []);
|
||||
const secondListContainers = vi.fn(async () => []);
|
||||
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([
|
||||
createDockerInstance("first", firstListContainers),
|
||||
createDockerInstance("second", secondListContainers),
|
||||
] as never);
|
||||
vi.spyOn(DockerSingleton, "getInitializationFailures").mockReturnValue([]);
|
||||
|
||||
try {
|
||||
const selectedResult = await getContainersWithStatsAsync(50, ["second"]);
|
||||
|
||||
expect(firstListContainers).not.toHaveBeenCalled();
|
||||
expect(secondListContainers).toHaveBeenCalledOnce();
|
||||
expect(selectedResult.endpoints.map(({ id }) => id)).toEqual(["second"]);
|
||||
|
||||
await getContainersWithStatsAsync(50, []);
|
||||
|
||||
expect(firstListContainers).toHaveBeenCalledOnce();
|
||||
expect(secondListContainers).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test("marks a timed-out endpoint unavailable without blocking healthy endpoints", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([
|
||||
createDockerInstance("stalled", () => new Promise(() => undefined)),
|
||||
createDockerInstance("healthy", async () => []),
|
||||
] as never);
|
||||
|
||||
const resultPromise = getContainersWithStatsAsync(50);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.endpoints).toEqual([
|
||||
expect.objectContaining({ id: "stalled", status: "unavailable" }),
|
||||
expect.objectContaining({ id: "healthy", status: "available" }),
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test("marks an endpoint degraded and returns zeroed stats when its stats request times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const dockerInstance = createDockerInstance("healthy", async () => [
|
||||
{
|
||||
Id: "container-1",
|
||||
Image: "sonarr:latest",
|
||||
Labels: {},
|
||||
Names: ["/sonarr"],
|
||||
State: "running",
|
||||
Ports: [],
|
||||
},
|
||||
]);
|
||||
let observedSignal: AbortSignal | undefined;
|
||||
dockerInstance.instance.getContainer = (() => ({
|
||||
stats: (options: { abortSignal?: AbortSignal }) => {
|
||||
observedSignal = options.abortSignal;
|
||||
return new Promise(() => undefined);
|
||||
},
|
||||
})) as never;
|
||||
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([dockerInstance] as never);
|
||||
vi.spyOn(DockerSingleton, "findInstance").mockReturnValue(dockerInstance as never);
|
||||
|
||||
const resultPromise = getContainersWithStatsAsync(50);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(observedSignal?.aborted).toBe(true);
|
||||
expect(result.containers).toEqual([expect.objectContaining({ id: "container-1", cpuUsage: 0, memoryUsage: 0 })]);
|
||||
expect(result.endpoints).toEqual([expect.objectContaining({ id: "healthy", status: "degraded" })]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test("includes endpoints that failed during TLS initialization", async () => {
|
||||
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([]);
|
||||
vi.spyOn(DockerSingleton, "getInitializationFailures").mockReturnValue([
|
||||
{
|
||||
host: "broken.example:2376",
|
||||
descriptor: {
|
||||
id: "broken-tls",
|
||||
name: "Broken TLS",
|
||||
kind: "docker",
|
||||
transport: { type: "tls", host: "broken.example", port: 2376, caPath: "/missing/ca.pem" },
|
||||
capabilities: ["inventory"],
|
||||
scope: "admin",
|
||||
source: "environment",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await getContainersWithStatsAsync(50);
|
||||
|
||||
expect(result.endpoints).toEqual([
|
||||
expect.objectContaining({ id: "broken-tls", status: "unavailable", transport: "tls" }),
|
||||
]);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
test("demo mode advertises and filters the same inventory-only endpoint", async () => {
|
||||
const previousDemoMode = process.env.DEMO_MODE;
|
||||
process.env.DEMO_MODE = "true";
|
||||
try {
|
||||
expect(getDockerEndpointsAsync()).toEqual([
|
||||
expect.objectContaining({ id: "demo", name: "Demo Docker", capabilities: ["inventory"] }),
|
||||
]);
|
||||
|
||||
const selected = await dockerContainersRequestHandler.handler({ endpointIds: ["demo"] }).getDataAsync();
|
||||
expect(selected.data.endpoints).toEqual([expect.objectContaining({ id: "demo" })]);
|
||||
expect(selected.data.containers.length).toBeGreaterThan(0);
|
||||
|
||||
const excluded = await dockerContainersRequestHandler.handler({ endpointIds: ["other"] }).getDataAsync();
|
||||
expect(excluded.data).toEqual({ containers: [], endpoints: [] });
|
||||
|
||||
expect(hasDockerEndpointCapability("demo", "inventory")).toBe(true);
|
||||
expect(hasDockerEndpointCapability("demo", "logs")).toBe(false);
|
||||
expect(hasDockerEndpointCapability("other", "inventory")).toBe(false);
|
||||
} finally {
|
||||
dockerContainersRequestHandler.invalidateCache();
|
||||
if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = previousDemoMode;
|
||||
}
|
||||
});
|
||||
|
||||
const createDockerInstance = (endpointId: string, listContainers: () => Promise<unknown[]>) => ({
|
||||
endpointId,
|
||||
endpointName: endpointId,
|
||||
host: endpointId,
|
||||
descriptor: {
|
||||
kind: "docker",
|
||||
transport: { type: "socket" },
|
||||
capabilities: ["inventory"],
|
||||
source: "environment",
|
||||
scope: "admin",
|
||||
},
|
||||
instance: { listContainers, getContainer: () => ({}) },
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Response } from "undici";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { getLatestMatchingReleaseAsync } from "../release-providers";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => ({ warn: vi.fn() }) }));
|
||||
|
||||
const mockedFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
const limitMessage = "Only the first 1000 results are available";
|
||||
|
||||
const mockHistoryFailure = (failurePage: number, status: number, message: string) => {
|
||||
const pages: number[] = [];
|
||||
mockedFetch.mockImplementation(async (input) => {
|
||||
let requestUrl = input.toString();
|
||||
if (typeof input === "object" && "url" in input) requestUrl = String(input.url);
|
||||
const url = new URL(requestUrl);
|
||||
if (!url.pathname.endsWith("/releases")) {
|
||||
return new Response(JSON.stringify({ created_at: "2020-01-01T00:00:00Z" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const page = Number(url.searchParams.get("page") ?? 1);
|
||||
pages.push(page);
|
||||
if (page === failurePage) {
|
||||
return new Response(JSON.stringify({ message }), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
const releases = Array.from({ length: 100 }, (_, index) => {
|
||||
const offset = (page - 1) * 100 + index;
|
||||
return {
|
||||
tag_name: `v${1001 - offset}`,
|
||||
published_at: new Date(Date.UTC(2026, 0, 1) - offset * 1000).toISOString(),
|
||||
html_url: "https://example.com/release",
|
||||
body: "",
|
||||
prerelease: false,
|
||||
};
|
||||
});
|
||||
url.searchParams.set("page", String(page + 1));
|
||||
return new Response(JSON.stringify(releases), {
|
||||
headers: { "content-type": "application/json", link: `<${url}>; rel="next"` },
|
||||
});
|
||||
});
|
||||
return pages;
|
||||
};
|
||||
|
||||
const request = (versionRegex?: string) =>
|
||||
getLatestMatchingReleaseAsync({ id: "fixture", provider: "github", identifier: "owner/repo", versionRegex });
|
||||
|
||||
describe("GitHub release history limits", () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ versionRegex: undefined, expected: "v1001" },
|
||||
{ versionRegex: "^v2$", expected: "v2" },
|
||||
])(
|
||||
"retains matching release $expected when the next page reaches GitHub's cap",
|
||||
async ({ versionRegex, expected }) => {
|
||||
const pages = mockHistoryFailure(11, 422, limitMessage);
|
||||
const result = await request(versionRegex);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) throw new Error(result.error.message);
|
||||
expect(result.data.latestRelease).toBe(expected);
|
||||
expect(pages).toEqual(Array.from({ length: 11 }, (_, index) => index + 1));
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ page: 2, status: 422, message: "Validation Failed" },
|
||||
{ page: 2, status: 401, message: "Bad credentials" },
|
||||
{ page: 1, status: 422, message: limitMessage },
|
||||
])("does not hide an API error on page $page: $message", async ({ page, status, message }) => {
|
||||
mockHistoryFailure(page, status, message);
|
||||
const result = await request();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) throw new Error("Expected API failure");
|
||||
expect(result.error.code).toBe("unexpected");
|
||||
expect(result.error.message).toContain(message);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
|
||||
createIntegrationAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/integrations", () => ({
|
||||
vi.mock("@homarr/integrations/factory", () => ({
|
||||
createIntegrationAsync: mocks.createIntegrationAsync,
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Response } from "undici";
|
||||
|
||||
import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
|
||||
|
||||
export const DEFAULT_TIMETABLE_BASE_URL = "https://search.ch";
|
||||
export const MAX_TIMETABLE_RESPONSE_BYTES = 512 * 1024;
|
||||
|
||||
export const normalizeTimetableBaseUrl = (baseUrl: string) => {
|
||||
if (baseUrl.length > 2_048) throw new Error("Timetable base URL is too long");
|
||||
|
||||
const url = new URL(baseUrl);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("Timetable base URL must use HTTP or HTTPS");
|
||||
}
|
||||
if (url.username || url.password) throw new Error("Timetable base URL must not contain credentials");
|
||||
if (url.search || url.hash) throw new Error("Timetable base URL must not contain a query or fragment");
|
||||
|
||||
return url.toString().replace(/\/$/, "");
|
||||
};
|
||||
|
||||
export async function readBoundedTimetableJsonAsync(
|
||||
response: Response,
|
||||
maxBytes = MAX_TIMETABLE_RESPONSE_BYTES,
|
||||
): Promise<unknown> {
|
||||
return await readBoundedJsonResponse(response, maxBytes, "Timetable response");
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler } from "./timetable";
|
||||
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
createCertificateAgentAsync: vi.fn(),
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
const mockCreateCertificateAgent = vi.mocked(createCertificateAgentAsync);
|
||||
const mockCloseDispatcher = vi.fn();
|
||||
const mockDestroyDispatcher = vi.fn().mockResolvedValue(undefined);
|
||||
const mockDispatcher = { close: mockCloseDispatcher, destroy: mockDestroyDispatcher };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateCertificateAgent.mockResolvedValue(mockDispatcher as never);
|
||||
});
|
||||
|
||||
describe("normalizeTimetableBaseUrl", () => {
|
||||
it("normalizes an HTTP endpoint", () => {
|
||||
expect(normalizeTimetableBaseUrl("https://search.ch/")).toBe("https://search.ch");
|
||||
});
|
||||
|
||||
it.each(["file:///etc/passwd", "https://user:secret@example.com", "https://example.com?redirect=/admin"])(
|
||||
"rejects unsafe base URL %s",
|
||||
(url) => expect(() => normalizeTimetableBaseUrl(url)).toThrow(),
|
||||
);
|
||||
});
|
||||
|
||||
describe("timetable response bounds", () => {
|
||||
it("rejects a response once its byte budget is exceeded", async () => {
|
||||
const response = new Response(JSON.stringify({ value: "x".repeat(100) }));
|
||||
await expect(readBoundedTimetableJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes");
|
||||
});
|
||||
|
||||
it("caps and caches station results and rejects redirects", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
Array.from({ length: 125 }, (_, index) => ({
|
||||
id: String(index),
|
||||
label: `Station ${index}`,
|
||||
iconclass: "train",
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
const input = { baseUrl: "http://timetable.internal", query: "bounded-stations" };
|
||||
|
||||
const first = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
|
||||
const second = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
|
||||
|
||||
expect(first.data).toHaveLength(100);
|
||||
expect(second.data).toHaveLength(100);
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ redirect: "error", bodyTimeout: 10_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("pins the vetted address into a direct connection without changing the request hostname", async () => {
|
||||
const pinnedAddresses = [
|
||||
{ address: "93.184.216.34", family: 4 as const },
|
||||
{ address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 as const },
|
||||
];
|
||||
mockFetch.mockImplementation(async () => {
|
||||
const pinnedLookup = mockCreateCertificateAgent.mock.calls[0]?.[0]?.lookup;
|
||||
expect(pinnedLookup).toBeTypeOf("function");
|
||||
if (!pinnedLookup) throw new Error("Expected a pinned lookup function");
|
||||
|
||||
const lookupCallback = vi.fn();
|
||||
pinnedLookup("timetable.example.com", { all: true }, lookupCallback);
|
||||
expect(lookupCallback).toHaveBeenCalledWith(null, pinnedAddresses);
|
||||
|
||||
const repeatedLookupCallback = vi.fn();
|
||||
pinnedLookup("timetable.example.com", { all: true }, repeatedLookupCallback);
|
||||
expect(repeatedLookupCallback).toHaveBeenCalledWith(null, pinnedAddresses);
|
||||
expect(mockCloseDispatcher).not.toHaveBeenCalled();
|
||||
|
||||
const unexpectedHostCallback = vi.fn();
|
||||
pinnedLookup("different.example.com", { all: true }, unexpectedHostCallback);
|
||||
expect(unexpectedHostCallback.mock.calls[0]?.[0]).toMatchObject({ code: "ENOTFOUND" });
|
||||
|
||||
return new Response(JSON.stringify([{ id: "station", label: "Station", iconclass: "train" }]));
|
||||
});
|
||||
|
||||
await timetableSearchStationsRequestHandler
|
||||
.handler({
|
||||
baseUrl: "https://timetable.example.com",
|
||||
query: "pinned-stations",
|
||||
pinnedAddresses,
|
||||
})
|
||||
.getDataAsync();
|
||||
|
||||
expect(mockCreateCertificateAgent).toHaveBeenCalledWith(
|
||||
{ lookup: expect.any(Function) },
|
||||
{
|
||||
autoSelectFamily: true,
|
||||
bodyTimeout: 10_000,
|
||||
httpProxy: "",
|
||||
httpsProxy: "",
|
||||
noProxy: "*",
|
||||
},
|
||||
);
|
||||
const requestUrl = mockFetch.mock.calls[0]?.[0];
|
||||
expect(new URL(String(requestUrl)).hostname).toBe("timetable.example.com");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ dispatcher: mockDispatcher, redirect: "error" }),
|
||||
);
|
||||
|
||||
expect(mockCloseDispatcher).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("pins timetable requests and closes their direct dispatcher", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
connections: [
|
||||
{
|
||||
time: "2026-01-01T00:00:00Z",
|
||||
line: "1",
|
||||
color: "ffffff",
|
||||
terminal: { name: "Stop" },
|
||||
dep_delay: "0",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const pinnedAddresses = [{ address: "93.184.216.34", family: 4 as const }];
|
||||
|
||||
await timetableGetTimetableRequestHandler
|
||||
.handler({
|
||||
baseUrl: "https://timetable.example.com",
|
||||
stationId: "pinned-departures",
|
||||
limit: 1,
|
||||
pinnedAddresses,
|
||||
})
|
||||
.getDataAsync();
|
||||
|
||||
expect(mockCreateCertificateAgent).toHaveBeenCalledWith(
|
||||
{ lookup: expect.any(Function) },
|
||||
expect.objectContaining({ noProxy: "*" }),
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ dispatcher: mockDispatcher, redirect: "error" }),
|
||||
);
|
||||
expect(mockCloseDispatcher).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("destroys the pinned station dispatcher when response handling fails", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("not json"));
|
||||
|
||||
await expect(
|
||||
timetableSearchStationsRequestHandler
|
||||
.handler({
|
||||
baseUrl: "https://timetable.example.com",
|
||||
query: "invalid-stations",
|
||||
pinnedAddresses: [{ address: "93.184.216.34", family: 4 }],
|
||||
})
|
||||
.getDataAsync(),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(mockDestroyDispatcher).toHaveBeenCalledOnce();
|
||||
expect(mockCloseDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("destroys the pinned timetable dispatcher when response handling fails", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("not json"));
|
||||
|
||||
await expect(
|
||||
timetableGetTimetableRequestHandler
|
||||
.handler({
|
||||
baseUrl: "https://timetable.example.com",
|
||||
stationId: "invalid-departures",
|
||||
limit: 1,
|
||||
pinnedAddresses: [{ address: "93.184.216.34", family: 4 }],
|
||||
})
|
||||
.getDataAsync(),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(mockDestroyDispatcher).toHaveBeenCalledOnce();
|
||||
expect(mockCloseDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never caches more departures than requested", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
connections: Array.from({ length: 5 }, (_, index) => ({
|
||||
time: `2026-01-01T00:0${index}:00Z`,
|
||||
line: "1",
|
||||
color: "ffffff",
|
||||
terminal: { name: `Stop ${index}` },
|
||||
dep_delay: "0",
|
||||
})),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await timetableGetTimetableRequestHandler
|
||||
.handler({ baseUrl: "http://timetable.internal", stationId: "bounded-departures", limit: 2 })
|
||||
.getDataAsync();
|
||||
|
||||
expect(result.data.entries).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { LookupFunction } from "node:net";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { createRequestHandler } from "./lib/request-handler";
|
||||
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
|
||||
|
||||
export interface Station {
|
||||
id: string;
|
||||
@@ -31,51 +33,126 @@ export interface Timetable {
|
||||
}
|
||||
|
||||
const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"];
|
||||
const MAX_STATION_RESULTS = 100;
|
||||
const MAX_TIMETABLE_ENTRIES = 100;
|
||||
const timetableFetchOptions = { redirect: "error", timeout: 10_000, bodyTimeout: 10_000 } as const;
|
||||
|
||||
export interface TimetableResolvedAddress {
|
||||
address: string;
|
||||
family: 4 | 6;
|
||||
}
|
||||
|
||||
export const timetableSearchStationsRequestHandler = createRequestHandler<
|
||||
Station[],
|
||||
{ baseUrl: string; query: string }
|
||||
{ baseUrl: string; query: string; pinnedAddresses?: TimetableResolvedAddress[] }
|
||||
>({
|
||||
async requestAsync(input) {
|
||||
return await searchStationsAsync(input.baseUrl, input.query);
|
||||
return await searchStationsAsync(input.baseUrl, input.query, input.pinnedAddresses);
|
||||
},
|
||||
cacheTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
export const timetableGetTimetableRequestHandler = createRequestHandler<
|
||||
Timetable,
|
||||
{ baseUrl: string; stationId: string; limit: number }
|
||||
{ baseUrl: string; stationId: string; limit: number; pinnedAddresses?: TimetableResolvedAddress[] }
|
||||
>({
|
||||
async requestAsync(input) {
|
||||
return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit });
|
||||
return await getTimetableAsync(
|
||||
input.baseUrl,
|
||||
{ stationId: input.stationId, limit: input.limit },
|
||||
input.pinnedAddresses,
|
||||
);
|
||||
},
|
||||
cacheTtlMs: 60_000,
|
||||
});
|
||||
|
||||
const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record<string, string | number>) => {
|
||||
const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`);
|
||||
const url = new URL(`${normalizeTimetableBaseUrl(baseUrl)}${path}`);
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
url.searchParams.set(key, value.toString());
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const searchStationsAsync = async (baseUrl: string, query: string): Promise<Station[]> => {
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
|
||||
);
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
const normalizeLookupHostname = (hostname: string) =>
|
||||
hostname
|
||||
.replace(/^\[(.*)\]$/, "$1")
|
||||
.toLowerCase()
|
||||
.replace(/\.$/, "");
|
||||
|
||||
const data = await searchSchema.parseAsync(await response.json());
|
||||
return data
|
||||
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
|
||||
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
|
||||
.filter((item) => item !== null);
|
||||
const createPinnedLookup = (baseUrl: string, addresses: TimetableResolvedAddress[]): LookupFunction => {
|
||||
const expectedHostname = normalizeLookupHostname(new URL(baseUrl).hostname);
|
||||
|
||||
return (hostname, options, callback) => {
|
||||
const requestedFamily = options.family === 4 || options.family === 6 ? options.family : undefined;
|
||||
const candidates = requestedFamily ? addresses.filter(({ family }) => family === requestedFamily) : addresses;
|
||||
if (normalizeLookupHostname(hostname) !== expectedHostname || candidates.length === 0) {
|
||||
const error = new Error("Pinned timetable address is unavailable") as NodeJS.ErrnoException;
|
||||
error.code = "ENOTFOUND";
|
||||
callback(error, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.all) {
|
||||
callback(null, candidates);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = candidates[0];
|
||||
if (!selected) return callback(new Error("Pinned timetable address is unavailable"), []);
|
||||
callback(null, selected.address, selected.family);
|
||||
};
|
||||
};
|
||||
|
||||
const createPinnedTimetableDispatcherAsync = async (baseUrl: string, addresses: TimetableResolvedAddress[]) => {
|
||||
// Proxies resolve CONNECT targets independently, which would defeat DNS pinning. Force this security-sensitive
|
||||
// configuration request to connect directly. The original URL remains unchanged for the Host header and TLS SNI.
|
||||
return await createCertificateAgentAsync(
|
||||
{ lookup: createPinnedLookup(baseUrl, addresses) },
|
||||
{
|
||||
autoSelectFamily: addresses.length > 1,
|
||||
bodyTimeout: timetableFetchOptions.bodyTimeout,
|
||||
httpProxy: "",
|
||||
httpsProxy: "",
|
||||
noProxy: "*",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const searchStationsAsync = async (
|
||||
baseUrl: string,
|
||||
query: string,
|
||||
pinnedAddresses?: TimetableResolvedAddress[],
|
||||
): Promise<Station[]> => {
|
||||
let dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
|
||||
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>> | undefined;
|
||||
try {
|
||||
response = await fetchWithTrustedCertificatesAsync(
|
||||
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
|
||||
{ ...timetableFetchOptions, dispatcher },
|
||||
);
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const body = await readBoundedTimetableJsonAsync(response);
|
||||
const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body);
|
||||
return data
|
||||
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
|
||||
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
|
||||
.filter((item) => item !== null);
|
||||
} catch (error) {
|
||||
if (response?.body) await response.body.cancel().catch(() => {});
|
||||
await dispatcher?.destroy().catch(() => {});
|
||||
dispatcher = undefined;
|
||||
throw error;
|
||||
} finally {
|
||||
await dispatcher?.close();
|
||||
}
|
||||
};
|
||||
|
||||
const getTimetableAsync = async (
|
||||
baseUrl: string,
|
||||
options: { stationId: string; limit: number },
|
||||
pinnedAddresses?: TimetableResolvedAddress[],
|
||||
): Promise<Timetable> => {
|
||||
const now = new Date();
|
||||
const date = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -90,43 +167,59 @@ const getTimetableAsync = async (
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
buildUrl(baseUrl, "/timetable/api/stationboard.json", {
|
||||
stop: options.stationId,
|
||||
limit: options.limit,
|
||||
show_delays: 1,
|
||||
show_tracks: 1,
|
||||
date,
|
||||
time,
|
||||
}),
|
||||
);
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
let dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
|
||||
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>> | undefined;
|
||||
try {
|
||||
response = await fetchWithTrustedCertificatesAsync(
|
||||
buildUrl(baseUrl, "/timetable/api/stationboard.json", {
|
||||
stop: options.stationId,
|
||||
limit: options.limit,
|
||||
show_delays: 1,
|
||||
show_tracks: 1,
|
||||
date,
|
||||
time,
|
||||
}),
|
||||
{ ...timetableFetchOptions, dispatcher },
|
||||
);
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const data = await timetableSchema.parseAsync(await response.json());
|
||||
return {
|
||||
stationId: options.stationId,
|
||||
timestamp: now,
|
||||
entries: data.connections.map((connection) => {
|
||||
const color = connection.color.split("~")[0];
|
||||
return {
|
||||
timestamp: connection.time,
|
||||
line: connection.line
|
||||
? {
|
||||
name: connection.line,
|
||||
color: color && color.length >= 1 ? `#${color}` : null,
|
||||
}
|
||||
: null,
|
||||
location: connection.terminal.name,
|
||||
delay: connection.dep_delay,
|
||||
platform: connection.track
|
||||
? {
|
||||
name: connection.track.replace("!", ""),
|
||||
hasChanged: connection.track.includes("!"),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
const body = await readBoundedTimetableJsonAsync(response);
|
||||
const envelope = timetableEnvelopeSchema.parse(body);
|
||||
const data = await timetableSchema.parseAsync({
|
||||
connections: envelope.connections.slice(0, Math.min(options.limit, MAX_TIMETABLE_ENTRIES)),
|
||||
});
|
||||
return {
|
||||
stationId: options.stationId,
|
||||
timestamp: now,
|
||||
entries: data.connections.map((connection) => {
|
||||
const color = connection.color.split("~")[0];
|
||||
return {
|
||||
timestamp: connection.time,
|
||||
line: connection.line
|
||||
? {
|
||||
name: connection.line,
|
||||
color: color && color.length >= 1 ? `#${color}` : null,
|
||||
}
|
||||
: null,
|
||||
location: connection.terminal.name,
|
||||
delay: connection.dep_delay,
|
||||
platform: connection.track
|
||||
? {
|
||||
name: connection.track.replace("!", ""),
|
||||
hasChanged: connection.track.includes("!"),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (response?.body) await response.body.cancel().catch(() => {});
|
||||
await dispatcher?.destroy().catch(() => {});
|
||||
dispatcher = undefined;
|
||||
throw error;
|
||||
} finally {
|
||||
await dispatcher?.close();
|
||||
}
|
||||
};
|
||||
|
||||
const timetableSchema = z.object({
|
||||
@@ -145,6 +238,8 @@ const timetableSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
const timetableEnvelopeSchema = z.object({ connections: z.array(z.unknown()) });
|
||||
|
||||
const searchSchema = z.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { TracearrDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -8,6 +8,7 @@ export const tracearrRequestHandler = createIntegrationRequestHandler<
|
||||
"tracearr",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "tracearr:dashboard",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getDashboardDataAsync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { TraefikDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -8,6 +8,7 @@ export const traefikRequestHandler = createIntegrationRequestHandler<
|
||||
"traefik",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "traefik:summary",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getDashboardDataAsync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -8,6 +8,7 @@ export const umamiRequestHandler = createIntegrationRequestHandler<
|
||||
"umami",
|
||||
{ websiteId: string; timeFrame: string; eventName?: string }
|
||||
>({
|
||||
cacheNamespace: "umami:stats",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getVisitorStatsAsync(input.websiteId, input.timeFrame, input.eventName);
|
||||
@@ -15,6 +16,7 @@ export const umamiRequestHandler = createIntegrationRequestHandler<
|
||||
});
|
||||
|
||||
export const umamiEventNamesRequestHandler = createIntegrationRequestHandler<string[], "umami", { websiteId: string }>({
|
||||
cacheNamespace: "umami:event-names",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getEventNamesAsync(input.websiteId);
|
||||
@@ -26,6 +28,7 @@ export const umamiTopPagesRequestHandler = createIntegrationRequestHandler<
|
||||
"umami",
|
||||
{ websiteId: string; timeFrame: string; limit: number }
|
||||
>({
|
||||
cacheNamespace: "umami:top-pages",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getTopPagesAsync(input.websiteId, input.timeFrame, input.limit);
|
||||
@@ -37,6 +40,7 @@ export const umamiTopReferrersRequestHandler = createIntegrationRequestHandler<
|
||||
"umami",
|
||||
{ websiteId: string; timeFrame: string; limit: number }
|
||||
>({
|
||||
cacheNamespace: "umami:top-referrers",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getTopReferrersAsync(input.websiteId, input.timeFrame, input.limit);
|
||||
@@ -48,6 +52,7 @@ export const umamiMultiEventRequestHandler = createIntegrationRequestHandler<
|
||||
"umami",
|
||||
{ websiteId: string; timeFrame: string; eventNames: string[] }
|
||||
>({
|
||||
cacheNamespace: "umami:multi-event",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getMultiEventTimeSeriesAsync(input.websiteId, input.timeFrame, input.eventNames);
|
||||
@@ -59,6 +64,7 @@ export const umamiActiveVisitorsRequestHandler = createIntegrationRequestHandler
|
||||
"umami",
|
||||
{ websiteId: string }
|
||||
>({
|
||||
cacheNamespace: "umami:active-visitors",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getActiveVisitorsAsync(input.websiteId);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { UpsSummary } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -9,6 +9,7 @@ export const upsSummariesRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"ups">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "ups:summaries",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getUpsSummariesAsync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
@@ -8,6 +8,7 @@ export const uptimeKumaRequestHandler = createIntegrationRequestHandler<
|
||||
"uptimeKuma",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "uptime-kuma:monitors",
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getDashboardDataAsync();
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { VpnSummary } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "vpnRequestHandler" });
|
||||
|
||||
export const vpnSummaryHandler = createIntegrationRequestHandler<
|
||||
VpnSummary | null,
|
||||
VpnSummary,
|
||||
IntegrationKindByCategory<"vpn">,
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "vpn:summary",
|
||||
async requestAsync(integration, _input) {
|
||||
try {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getSummaryAsync();
|
||||
} catch (error) {
|
||||
logger.warn("Failed to fetch VPN summary, marking as unavailable", {
|
||||
integrationId: integration.id,
|
||||
integrationKind: integration.kind,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getSummaryAsync();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type {
|
||||
WazuhAgentsOverview,
|
||||
WazuhAlert,
|
||||
WazuhAlertTimeline,
|
||||
WazuhAuthFailures,
|
||||
WazuhCveOrder,
|
||||
WazuhFimEventFilter,
|
||||
WazuhFimOverview,
|
||||
WazuhSecuritySummary,
|
||||
WazuhTimelineInterval,
|
||||
WazuhTimeRange,
|
||||
WazuhTopList,
|
||||
WazuhTopListKind,
|
||||
WazuhVulnerabilityOverview,
|
||||
} from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
/** Alert data changes constantly but the widgets only need minute resolution. */
|
||||
const WAZUH_CACHE_TTL_MS = 60 * 1000;
|
||||
/** Aggregations over long windows and the vulnerability state change slowly; cache them longer. */
|
||||
const WAZUH_SLOW_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export const wazuhAgentsRequestHandler = createIntegrationRequestHandler<
|
||||
WazuhAgentsOverview,
|
||||
"wazuh",
|
||||
Record<string, never>
|
||||
>({
|
||||
cacheNamespace: "wazuh:agents:v2",
|
||||
cacheTtlMs: WAZUH_CACHE_TTL_MS,
|
||||
async requestAsync(integration) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return await instance.getAgentsOverviewAsync();
|
||||
},
|
||||
});
|
||||
|
||||
export const wazuhAlertsRequestHandler = createIntegrationRequestHandler<
|
||||
WazuhAlert[],
|
||||
"wazuh",
|
||||
{ minLevel: number; limit: number; hours: number }
|
||||
>({
|
||||
cacheNamespace: "wazuh:alerts:v2",
|
||||
cacheTtlMs: WAZUH_CACHE_TTL_MS,
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return await instance.getRecentAlertsAsync(input);
|
||||
},
|
||||
});
|
||||
|
||||
export const wazuhSummaryRequestHandler = createIntegrationRequestHandler<
|
||||
WazuhSecuritySummary,
|
||||
"wazuh",
|
||||
{ range: WazuhTimeRange }
|
||||
>({
|
||||
cacheNamespace: "wazuh:summary:v2",
|
||||
cacheTtlMs: WAZUH_CACHE_TTL_MS,
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return await instance.getSecuritySummaryAsync(input);
|
||||
},
|
||||
});
|
||||
|
||||
const createTimelineRequestHandler = (cacheNamespace: string, cacheTtlMs: number) =>
|
||||
createIntegrationRequestHandler<
|
||||
WazuhAlertTimeline,
|
||||
"wazuh",
|
||||
{ range: WazuhTimeRange; interval: WazuhTimelineInterval; minLevel: number }
|
||||
>({
|
||||
cacheNamespace,
|
||||
cacheTtlMs,
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return await instance.getAlertTimelineAsync(input);
|
||||
},
|
||||
});
|
||||
|
||||
const wazuhTimelineRequestHandler = createTimelineRequestHandler("wazuh:timeline", WAZUH_CACHE_TTL_MS);
|
||||
const wazuhLongTimelineRequestHandler = createTimelineRequestHandler("wazuh:timeline:long", WAZUH_SLOW_CACHE_TTL_MS);
|
||||
|
||||
/** Long windows barely change between refreshes, so they are cached for longer. */
|
||||
export const getWazuhTimelineRequestHandler = (range: WazuhTimeRange) =>
|
||||
range === "7d" || range === "30d" ? wazuhLongTimelineRequestHandler : wazuhTimelineRequestHandler;
|
||||
|
||||
export const wazuhTopListRequestHandler = createIntegrationRequestHandler<
|
||||
WazuhTopList,
|
||||
"wazuh",
|
||||
{ kind: WazuhTopListKind; range: WazuhTimeRange; limit: number; minLevel: number }
|
||||
>({
|
||||
cacheNamespace: "wazuh:top-list",
|
||||
cacheTtlMs: WAZUH_CACHE_TTL_MS * 2,
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return await instance.getTopListAsync(input);
|
||||
},
|
||||
});
|
||||
|
||||
export const wazuhVulnerabilitiesRequestHandler = createIntegrationRequestHandler<
|
||||
WazuhVulnerabilityOverview,
|
||||
"wazuh",
|
||||
{ cveLimit: number; agentLimit: number; cveOrder: WazuhCveOrder; minSeverity: "all" | "medium" | "high" | "critical" }
|
||||
>({
|
||||
cacheNamespace: "wazuh:vulnerabilities",
|
||||
cacheTtlMs: WAZUH_SLOW_CACHE_TTL_MS,
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return await instance.getVulnerabilitiesAsync(input);
|
||||
},
|
||||
});
|
||||
|
||||
export const wazuhFimRequestHandler = createIntegrationRequestHandler<
|
||||
WazuhFimOverview,
|
||||
"wazuh",
|
||||
{ range: WazuhTimeRange; event: WazuhFimEventFilter; limit: number; includeRegistry: boolean }
|
||||
>({
|
||||
cacheNamespace: "wazuh:fim",
|
||||
cacheTtlMs: WAZUH_CACHE_TTL_MS,
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return await instance.getFimEventsAsync(input);
|
||||
},
|
||||
});
|
||||
|
||||
export const wazuhAuthFailuresRequestHandler = createIntegrationRequestHandler<
|
||||
WazuhAuthFailures,
|
||||
"wazuh",
|
||||
{ hours: number; limit: number }
|
||||
>({
|
||||
cacheNamespace: "wazuh:auth-failures",
|
||||
cacheTtlMs: WAZUH_CACHE_TTL_MS,
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return await instance.getAuthFailuresAsync(input);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { weatherRequestHandler } from "./weather";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const makeWeatherResponse = () => {
|
||||
const hours = Array.from({ length: 25 }, (_, index) => `2026-07-01T${String(index % 24).padStart(2, "0")}:00`);
|
||||
const days = [
|
||||
"2026-06-30",
|
||||
...Array.from({ length: 7 }, (_, index) => `2026-07-${String(index + 1).padStart(2, "0")}`),
|
||||
];
|
||||
const hourlyValues = hours.map<number | null>((_, index) => index);
|
||||
const dailyValues = days.map<number | null>((_, index) => index);
|
||||
const sunrise = days.map<string | null>((day) => `${day}T05:00`);
|
||||
|
||||
return {
|
||||
timezone: "Europe/Berlin",
|
||||
utc_offset_seconds: 7200,
|
||||
current: {
|
||||
time: "2026-07-01T12:00",
|
||||
temperature_2m: 24,
|
||||
relative_humidity_2m: 55,
|
||||
apparent_temperature: 25,
|
||||
is_day: 1,
|
||||
precipitation: 0,
|
||||
weather_code: 1,
|
||||
wind_speed_10m: 12,
|
||||
wind_gusts_10m: 20,
|
||||
},
|
||||
hourly: {
|
||||
time: hours,
|
||||
temperature_2m: hourlyValues,
|
||||
apparent_temperature: hourlyValues,
|
||||
precipitation_probability: hourlyValues,
|
||||
precipitation: hourlyValues,
|
||||
weather_code: hourlyValues,
|
||||
relative_humidity_2m: hourlyValues,
|
||||
wind_speed_10m: hourlyValues,
|
||||
wind_gusts_10m: hourlyValues,
|
||||
},
|
||||
daily: {
|
||||
time: days,
|
||||
weather_code: dailyValues,
|
||||
temperature_2m_max: dailyValues,
|
||||
temperature_2m_min: dailyValues,
|
||||
apparent_temperature_max: dailyValues,
|
||||
apparent_temperature_min: dailyValues,
|
||||
precipitation_probability_max: dailyValues,
|
||||
precipitation_sum: dailyValues,
|
||||
relative_humidity_2m_mean: dailyValues,
|
||||
wind_speed_10m_max: dailyValues,
|
||||
wind_gusts_10m_max: dailyValues,
|
||||
uv_index_max: dailyValues,
|
||||
sunrise,
|
||||
sunset: days.map((day) => `${day}T21:00`),
|
||||
daylight_duration: dailyValues,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
weatherRequestHandler.invalidateCache();
|
||||
});
|
||||
|
||||
describe("weather request handler", () => {
|
||||
it("requests bounded useful forecasts and normalizes location times", async () => {
|
||||
const response = makeWeatherResponse();
|
||||
response.hourly.temperature_2m = [null];
|
||||
Reflect.deleteProperty(response.hourly, "wind_gusts_10m");
|
||||
response.daily.sunrise[1] = null;
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify(response)));
|
||||
|
||||
const first = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
const second = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
|
||||
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
|
||||
expect(first.data.hourly).toHaveLength(24);
|
||||
expect(first.data.daily).toHaveLength(7);
|
||||
expect(first.data.daily[0]?.date).toBe("2026-07-01");
|
||||
expect(first.data.hourly[0]?.temperature).toBeNull();
|
||||
expect(first.data.hourly[1]?.temperature).toBeNull();
|
||||
expect(first.data.hourly[0]?.windGusts).toBeNull();
|
||||
expect(first.data.daily[0]?.sunriseAt).toBeNull();
|
||||
expect(first.data.daily[1]?.sunriseAt).toBe("2026-07-02T03:00:00.000Z");
|
||||
expect(second.data).toStrictEqual(first.data);
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
|
||||
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
|
||||
expect(requestUrl.searchParams.get("forecast_hours")).toBe("24");
|
||||
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
|
||||
expect(requestUrl.searchParams.has("past_days")).toBe(false);
|
||||
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
|
||||
expect(requestUrl.searchParams.get("current")).toContain("apparent_temperature");
|
||||
expect(requestUrl.searchParams.get("hourly")).toContain("precipitation_probability");
|
||||
expect(requestUrl.searchParams.get("daily")).toContain("uv_index_max");
|
||||
});
|
||||
|
||||
it("retries one transient 503 before returning weather", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(makeWeatherResponse())));
|
||||
|
||||
const result = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
|
||||
|
||||
expect(result.data.current.temperature).toBe(24);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects unsuccessful Open-Meteo responses after one retry", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
|
||||
|
||||
await expect(weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync()).rejects.toThrow();
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry other unsuccessful responses", async () => {
|
||||
mockFetch.mockResolvedValue(new Response("unavailable", { status: 500 }));
|
||||
|
||||
await expect(weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync()).rejects.toThrow();
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,73 +1,265 @@
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const HOURLY_FORECAST_LENGTH = 24;
|
||||
const DAILY_FORECAST_LENGTH = 7;
|
||||
|
||||
const requestWeatherAsync = async (
|
||||
input: { latitude: number; longitude: number },
|
||||
signal: AbortSignal,
|
||||
): Promise<Weather> => {
|
||||
const url = new URL("https://api.open-meteo.com/v1/forecast");
|
||||
url.searchParams.set("latitude", input.latitude.toString());
|
||||
url.searchParams.set("longitude", input.longitude.toString());
|
||||
url.searchParams.set(
|
||||
"current",
|
||||
[
|
||||
"temperature_2m",
|
||||
"relative_humidity_2m",
|
||||
"apparent_temperature",
|
||||
"is_day",
|
||||
"precipitation",
|
||||
"weather_code",
|
||||
"wind_speed_10m",
|
||||
"wind_gusts_10m",
|
||||
].join(","),
|
||||
);
|
||||
url.searchParams.set(
|
||||
"hourly",
|
||||
[
|
||||
"temperature_2m",
|
||||
"apparent_temperature",
|
||||
"precipitation_probability",
|
||||
"precipitation",
|
||||
"weather_code",
|
||||
"relative_humidity_2m",
|
||||
"wind_speed_10m",
|
||||
"wind_gusts_10m",
|
||||
].join(","),
|
||||
);
|
||||
url.searchParams.set(
|
||||
"daily",
|
||||
[
|
||||
"weather_code",
|
||||
"temperature_2m_max",
|
||||
"temperature_2m_min",
|
||||
"apparent_temperature_max",
|
||||
"apparent_temperature_min",
|
||||
"precipitation_probability_max",
|
||||
"precipitation_sum",
|
||||
"relative_humidity_2m_mean",
|
||||
"wind_speed_10m_max",
|
||||
"wind_gusts_10m_max",
|
||||
"uv_index_max",
|
||||
"sunrise",
|
||||
"sunset",
|
||||
"daylight_duration",
|
||||
].join(","),
|
||||
);
|
||||
url.searchParams.set("timezone", "auto");
|
||||
url.searchParams.set("forecast_hours", HOURLY_FORECAST_LENGTH.toString());
|
||||
url.searchParams.set("forecast_days", DAILY_FORECAST_LENGTH.toString());
|
||||
|
||||
let res = await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
if (res.status === 503) {
|
||||
await res.body?.cancel().catch(() => undefined);
|
||||
// Retry one transient upstream outage while the server is already fetching
|
||||
// for the board, before hydration starts a separate browser request.
|
||||
await setTimeout(150, undefined, { signal });
|
||||
res = await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
}
|
||||
if (!res.ok) throw new ResponseError(res);
|
||||
|
||||
const json: unknown = await res.json();
|
||||
const weather = await atLocationOutput.parseAsync(json);
|
||||
const currentLocalDate = weather.current.time.slice(0, 10);
|
||||
const matchedCurrentDayIndex = weather.daily.time.indexOf(currentLocalDate);
|
||||
const currentDayIndex = matchedCurrentDayIndex < 0 ? 0 : matchedCurrentDayIndex;
|
||||
const dailyIndexes = weather.daily.time
|
||||
.map((_, index) => index)
|
||||
.slice(currentDayIndex, currentDayIndex + DAILY_FORECAST_LENGTH);
|
||||
return {
|
||||
timezone: weather.timezone,
|
||||
utcOffsetSeconds: weather.utc_offset_seconds,
|
||||
current: {
|
||||
observedAt: toUtcInstant(weather.current.time, weather.timezone),
|
||||
temperature: weather.current.temperature_2m,
|
||||
apparentTemperature: weather.current.apparent_temperature,
|
||||
relativeHumidity: weather.current.relative_humidity_2m,
|
||||
precipitation: weather.current.precipitation,
|
||||
weatherCode: weather.current.weather_code,
|
||||
weathercode: weather.current.weather_code,
|
||||
windSpeed: weather.current.wind_speed_10m,
|
||||
windspeed: weather.current.wind_speed_10m,
|
||||
windGusts: weather.current.wind_gusts_10m,
|
||||
isDay: weather.current.is_day === 1,
|
||||
},
|
||||
hourly: weather.hourly.time.slice(0, HOURLY_FORECAST_LENGTH).map((value, index) => {
|
||||
return {
|
||||
observedAt: toUtcInstant(value, weather.timezone),
|
||||
temperature: weather.hourly.temperature_2m[index] ?? null,
|
||||
apparentTemperature: weather.hourly.apparent_temperature[index] ?? null,
|
||||
precipitationProbability: weather.hourly.precipitation_probability[index] ?? null,
|
||||
precipitation: weather.hourly.precipitation[index] ?? null,
|
||||
weatherCode: weather.hourly.weather_code[index] ?? null,
|
||||
relativeHumidity: weather.hourly.relative_humidity_2m[index] ?? null,
|
||||
windSpeed: weather.hourly.wind_speed_10m[index] ?? null,
|
||||
windGusts: weather.hourly.wind_gusts_10m[index] ?? null,
|
||||
};
|
||||
}),
|
||||
daily: dailyIndexes.map((index) => {
|
||||
const value = weather.daily.time[index] ?? currentLocalDate;
|
||||
return {
|
||||
date: value,
|
||||
time: value,
|
||||
weatherCode: weather.daily.weather_code[index] ?? 404,
|
||||
maxTemperature: weather.daily.temperature_2m_max[index] ?? null,
|
||||
maxTemp: weather.daily.temperature_2m_max[index] ?? undefined,
|
||||
minTemperature: weather.daily.temperature_2m_min[index] ?? null,
|
||||
minTemp: weather.daily.temperature_2m_min[index] ?? undefined,
|
||||
maxApparentTemperature: weather.daily.apparent_temperature_max[index] ?? null,
|
||||
minApparentTemperature: weather.daily.apparent_temperature_min[index] ?? null,
|
||||
precipitationProbability: weather.daily.precipitation_probability_max[index] ?? null,
|
||||
precipitation: weather.daily.precipitation_sum[index] ?? null,
|
||||
averageHumidity: weather.daily.relative_humidity_2m_mean[index] ?? null,
|
||||
humidity: weather.daily.relative_humidity_2m_mean[index] ?? undefined,
|
||||
maxWindSpeed: weather.daily.wind_speed_10m_max[index] ?? null,
|
||||
maxWindGusts: weather.daily.wind_gusts_10m_max[index] ?? null,
|
||||
uvIndex: weather.daily.uv_index_max[index] ?? null,
|
||||
sunriseAt: toOptionalUtcInstant(weather.daily.sunrise[index], weather.timezone),
|
||||
sunrise: weather.daily.sunrise[index] ?? undefined,
|
||||
sunsetAt: toOptionalUtcInstant(weather.daily.sunset[index], weather.timezone),
|
||||
sunset: weather.daily.sunset[index] ?? undefined,
|
||||
daylightDuration: weather.daily.daylight_duration[index] ?? null,
|
||||
};
|
||||
}),
|
||||
} satisfies Weather;
|
||||
};
|
||||
|
||||
export const weatherRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { latitude: number; longitude: number }) {
|
||||
const res = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean¤t_weather=true&timezone=auto`,
|
||||
{ signal },
|
||||
);
|
||||
});
|
||||
const json: unknown = await res.json();
|
||||
const weather = await atLocationOutput.parseAsync(json);
|
||||
return {
|
||||
current: weather.current_weather,
|
||||
daily: weather.daily.time.map((value, index) => {
|
||||
return {
|
||||
time: value,
|
||||
weatherCode: weather.daily.weathercode[index] ?? 404,
|
||||
maxTemp: weather.daily.temperature_2m_max[index],
|
||||
minTemp: weather.daily.temperature_2m_min[index],
|
||||
sunrise: weather.daily.sunrise[index],
|
||||
sunset: weather.daily.sunset[index],
|
||||
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
|
||||
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
|
||||
humidity: weather.daily.relative_humidity_2m_mean[index],
|
||||
};
|
||||
}),
|
||||
} satisfies Weather;
|
||||
},
|
||||
// Coordinates are a public, continuous key space. Keep this cache process-local and bounded.
|
||||
cacheTtlMs: 5 * 60 * 1000,
|
||||
requestTimeoutMs: 10_000,
|
||||
requestAsync: requestWeatherAsync,
|
||||
});
|
||||
|
||||
const nullableNumberArray = z.array(z.number().nullable()).default([]);
|
||||
const nullableStringArray = z.array(z.string().nullable()).default([]);
|
||||
|
||||
const atLocationOutput = z.object({
|
||||
current_weather: z.object({
|
||||
weathercode: z.number(),
|
||||
temperature: z.number(),
|
||||
windspeed: z.number(),
|
||||
timezone: z.string().min(1),
|
||||
utc_offset_seconds: z.number(),
|
||||
current: z.object({
|
||||
time: z.string(),
|
||||
temperature_2m: z.number(),
|
||||
relative_humidity_2m: z.number(),
|
||||
apparent_temperature: z.number(),
|
||||
is_day: z.number(),
|
||||
precipitation: z.number(),
|
||||
weather_code: z.number(),
|
||||
wind_speed_10m: z.number(),
|
||||
wind_gusts_10m: z.number(),
|
||||
}),
|
||||
hourly: z.object({
|
||||
time: z.array(z.string()),
|
||||
temperature_2m: nullableNumberArray,
|
||||
apparent_temperature: nullableNumberArray,
|
||||
precipitation_probability: nullableNumberArray,
|
||||
precipitation: nullableNumberArray,
|
||||
weather_code: nullableNumberArray,
|
||||
relative_humidity_2m: nullableNumberArray,
|
||||
wind_speed_10m: nullableNumberArray,
|
||||
wind_gusts_10m: nullableNumberArray,
|
||||
}),
|
||||
daily: z.object({
|
||||
time: z.array(z.string()),
|
||||
weathercode: z.array(z.number()),
|
||||
temperature_2m_max: z.array(z.number()),
|
||||
temperature_2m_min: z.array(z.number()),
|
||||
sunrise: z.array(z.string()),
|
||||
sunset: z.array(z.string()),
|
||||
wind_speed_10m_max: z.array(z.number()),
|
||||
wind_gusts_10m_max: z.array(z.number()),
|
||||
relative_humidity_2m_mean: z.array(z.number()),
|
||||
weather_code: nullableNumberArray,
|
||||
temperature_2m_max: nullableNumberArray,
|
||||
temperature_2m_min: nullableNumberArray,
|
||||
apparent_temperature_max: nullableNumberArray,
|
||||
apparent_temperature_min: nullableNumberArray,
|
||||
precipitation_probability_max: nullableNumberArray,
|
||||
precipitation_sum: nullableNumberArray,
|
||||
relative_humidity_2m_mean: nullableNumberArray,
|
||||
wind_speed_10m_max: nullableNumberArray,
|
||||
wind_gusts_10m_max: nullableNumberArray,
|
||||
uv_index_max: nullableNumberArray,
|
||||
sunrise: nullableStringArray,
|
||||
sunset: nullableStringArray,
|
||||
daylight_duration: nullableNumberArray,
|
||||
}),
|
||||
});
|
||||
|
||||
const toUtcInstant = (value: string, timeZone: string): string => {
|
||||
const parsed = dayjs.tz(value, timeZone);
|
||||
if (!parsed.isValid()) throw new Error(`Invalid weather timestamp: ${value}`);
|
||||
return parsed.utc().toISOString();
|
||||
};
|
||||
|
||||
const toOptionalUtcInstant = (value: string | null | undefined, timeZone: string): string | null => {
|
||||
if (!value) return null;
|
||||
return toUtcInstant(value, timeZone);
|
||||
};
|
||||
|
||||
export interface Weather {
|
||||
timezone: string;
|
||||
utcOffsetSeconds: number;
|
||||
current: {
|
||||
weathercode: number;
|
||||
temperature: number;
|
||||
apparentTemperature: number;
|
||||
relativeHumidity: number;
|
||||
precipitation: number;
|
||||
weatherCode: number;
|
||||
weathercode: number;
|
||||
windSpeed: number;
|
||||
windspeed: number;
|
||||
windGusts: number;
|
||||
isDay: boolean;
|
||||
observedAt: string;
|
||||
};
|
||||
hourly: {
|
||||
observedAt: string;
|
||||
temperature: number | null;
|
||||
apparentTemperature: number | null;
|
||||
precipitationProbability: number | null;
|
||||
precipitation: number | null;
|
||||
weatherCode: number | null;
|
||||
relativeHumidity: number | null;
|
||||
windSpeed: number | null;
|
||||
windGusts: number | null;
|
||||
}[];
|
||||
daily: {
|
||||
date: string;
|
||||
time: string;
|
||||
weatherCode: number;
|
||||
maxTemperature: number | null;
|
||||
maxTemp: number | undefined;
|
||||
minTemperature: number | null;
|
||||
minTemp: number | undefined;
|
||||
sunrise: string | undefined;
|
||||
sunset: string | undefined;
|
||||
maxWindSpeed: number | undefined;
|
||||
maxWindGusts: number | undefined;
|
||||
maxApparentTemperature: number | null;
|
||||
minApparentTemperature: number | null;
|
||||
precipitationProbability: number | null;
|
||||
precipitation: number | null;
|
||||
averageHumidity: number | null;
|
||||
humidity: number | undefined;
|
||||
maxWindSpeed: number | null;
|
||||
maxWindGusts: number | null;
|
||||
uvIndex: number | null;
|
||||
sunriseAt: string | null;
|
||||
sunrise: string | undefined;
|
||||
sunsetAt: string | null;
|
||||
sunset: string | undefined;
|
||||
daylightDuration: number | null;
|
||||
}[];
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { WudStats } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const wudStatsRequestHandler = createIntegrationRequestHandler<WudStats, "wud", Record<string, never>>({
|
||||
cacheNamespace: "wud:stats",
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getStatsAsync();
|
||||
|
||||
Reference in new issue
Block a user