Merge branch 'dev' into feat/openwebui-integration
Resolve conflicts by keeping both openWebUi and dev-side registry entries (integration/widget kinds, creators, docs sitemap). Take dev's next.config turbopack settings. Reset SQLite migration base to dev's 0040_add_widget_secret; the integration.creator_id migrations are regenerated for all dialects in a follow-up commit.
This commit is contained in:
517 files changed
+30974
-14645
No files matched your search
@@ -1,30 +1,22 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const anchorNotesListRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const anchorNotesListRequestHandler = createIntegrationRequestHandler<
|
||||
AnchorNoteSummary[],
|
||||
"anchor",
|
||||
AnchorNotesListInput
|
||||
>({
|
||||
queryKey: "anchorNotesList",
|
||||
cacheDuration: dayjs.duration(15, "seconds"),
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return instance.listNotesAsync(input);
|
||||
},
|
||||
});
|
||||
|
||||
export const anchorNoteRequestHandler = createCachedIntegrationRequestHandler<AnchorNote, "anchor", { noteId: string }>(
|
||||
{
|
||||
queryKey: "anchorNote",
|
||||
cacheDuration: dayjs.duration(15, "seconds"),
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return instance.getNoteAsync(input.noteId);
|
||||
},
|
||||
export const anchorNoteRequestHandler = createIntegrationRequestHandler<AnchorNote, "anchor", { noteId: string }>({
|
||||
async requestAsync(integration, input) {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return instance.getNoteAsync(input.noteId);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const archiveTeamWarriorRequestHandler = createIntegrationRequestHandler<
|
||||
ArchiveTeamWarriorStatus,
|
||||
IntegrationKindByCategory<"archiving">,
|
||||
Record<string, never>
|
||||
@@ -15,6 +13,4 @@ export const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHa
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getStatusAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minute"),
|
||||
queryKey: "archiveTeamWarriorStatus",
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const audiobookshelfRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const audiobookshelfRequestHandler = createIntegrationRequestHandler<
|
||||
AudiobookshelfDashboardData,
|
||||
"audiobookshelf",
|
||||
Record<string, never>
|
||||
@@ -14,6 +12,4 @@ export const audiobookshelfRequestHandler = createCachedIntegrationRequestHandle
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getDashboardDataAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(10, "minutes"),
|
||||
queryKey: "audiobookshelfDashboard",
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { BazarrBadges } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const bazarrBadgesRequestHandler = createIntegrationRequestHandler<
|
||||
BazarrBadges,
|
||||
"bazarr",
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getBadgesAsync();
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type {
|
||||
@@ -12,7 +10,7 @@ import type {
|
||||
BeszelSystemStatsRecord,
|
||||
} from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "beszelRequestHandler" });
|
||||
|
||||
@@ -36,6 +34,7 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
|
||||
cpu: info.cpu,
|
||||
memory: info.mp,
|
||||
disk: info.dp,
|
||||
extraFilesystems: info.efs ?? {},
|
||||
gpu: info.g ?? 0,
|
||||
loadAvg: info.la ?? null,
|
||||
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
|
||||
@@ -53,7 +52,7 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
|
||||
};
|
||||
}
|
||||
|
||||
export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const beszelSystemsRequestHandler = createIntegrationRequestHandler<
|
||||
BeszelSystemRow[],
|
||||
"beszel" | "mock",
|
||||
Record<string, never>
|
||||
@@ -64,7 +63,10 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
||||
const systems = await instance.getSystemsAsync();
|
||||
const enriched = await Promise.all(
|
||||
systems.map(async (system) => {
|
||||
const details = await instance.getSystemDetailsAsync(system.id).catch(() => null);
|
||||
const details = await instance.getSystemDetailsAsync(system.id).catch((error) => {
|
||||
logger.warn("Failed to fetch Beszel system details", { systemId: system.id, error: String(error) });
|
||||
return null;
|
||||
});
|
||||
return mapToSystemRow(system, details);
|
||||
}),
|
||||
);
|
||||
@@ -75,10 +77,6 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
||||
});
|
||||
return enriched;
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "second"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelSystems",
|
||||
});
|
||||
|
||||
export interface BeszelAlertsData {
|
||||
@@ -86,7 +84,7 @@ export interface BeszelAlertsData {
|
||||
history: BeszelAlertHistory[];
|
||||
}
|
||||
|
||||
export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const beszelAlertsRequestHandler = createIntegrationRequestHandler<
|
||||
BeszelAlertsData,
|
||||
"beszel" | "mock",
|
||||
{ includeHistory: boolean; maxHistoryItems: number }
|
||||
@@ -104,10 +102,6 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
});
|
||||
return { alerts, history };
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "second"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelAlerts",
|
||||
});
|
||||
|
||||
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
|
||||
@@ -124,18 +118,24 @@ export interface BeszelStatsData {
|
||||
containerStats: BeszelContainerStatsRecord[];
|
||||
}
|
||||
|
||||
export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const beszelStatsRequestHandler = createIntegrationRequestHandler<
|
||||
BeszelStatsData,
|
||||
"beszel" | "mock",
|
||||
{ systemId: string; timePeriod: string; includeDocker: boolean }
|
||||
>({
|
||||
// No cache — the widget polls every 5s for live updates;
|
||||
// a TTL here would serve stale records between polls.
|
||||
cacheTtlMs: 0,
|
||||
async requestAsync(integration, input) {
|
||||
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(() => [])
|
||||
? await 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 [];
|
||||
})
|
||||
: [];
|
||||
logger.debug("beszelStats fetch completed", {
|
||||
integrationId: integration.id,
|
||||
@@ -147,8 +147,4 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
});
|
||||
return { systemStats, containerStats };
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "second"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelStats",
|
||||
});
|
||||
@@ -4,9 +4,9 @@ import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { CalendarEvent, RadarrReleaseType } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const calendarMonthRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const calendarMonthRequestHandler = createIntegrationRequestHandler<
|
||||
CalendarEvent[],
|
||||
IntegrationKindByCategory<"calendar">,
|
||||
{ year: number; month: number; releaseType: RadarrReleaseType[]; showUnmonitored: boolean }
|
||||
@@ -23,6 +23,4 @@ export const calendarMonthRequestHandler = createCachedIntegrationRequestHandler
|
||||
input.showUnmonitored,
|
||||
);
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "minute"),
|
||||
queryKey: "calendarMonth",
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const coolifyRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const coolifyRequestHandler = createIntegrationRequestHandler<
|
||||
CoolifyInstanceInfo,
|
||||
"coolify",
|
||||
Record<string, never>
|
||||
@@ -14,6 +12,4 @@ export const coolifyRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getInstanceInfoAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(30, "seconds"),
|
||||
queryKey: "coolifyInfo",
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const dnsHoleRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const dnsHoleRequestHandler = createIntegrationRequestHandler<
|
||||
DnsHoleSummary,
|
||||
IntegrationKindByCategory<"dnsHole">,
|
||||
Record<string, never>
|
||||
@@ -15,6 +13,4 @@ export const dnsHoleRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getSummaryAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
queryKey: "dnsHoleSummary",
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import dayjs from "dayjs";
|
||||
import type { Container, ContainerInfo, ContainerStats } from "dockerode";
|
||||
import type Dockerode from "dockerode";
|
||||
|
||||
@@ -13,7 +12,7 @@ import type { ContainerState, Port } from "@homarr/docker";
|
||||
import { dockerLabels, DockerSingleton } from "@homarr/docker";
|
||||
|
||||
import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
|
||||
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "dockerRequestHandler" });
|
||||
|
||||
@@ -171,16 +170,13 @@ const mockContainers: {
|
||||
},
|
||||
];
|
||||
|
||||
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
|
||||
queryKey: "dockerContainersResult",
|
||||
widgetKind: "dockerContainers",
|
||||
export const dockerContainersRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync() {
|
||||
if (isDemoMode) {
|
||||
return mockContainers;
|
||||
}
|
||||
return await getContainersWithStatsAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(20, "seconds"),
|
||||
});
|
||||
|
||||
const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image);
|
||||
@@ -304,7 +300,10 @@ async function getContainersWithStatsAsync() {
|
||||
|
||||
const stats = await instance
|
||||
.getContainer(container.Id)
|
||||
.stats({ stream: false, "one-shot": true })
|
||||
// Not in one-shot mode: the daemon then includes precpu_stats (the
|
||||
// previous reading), which is required to compute the current CPU usage
|
||||
// as a delta. One-shot omits it and would only expose lifetime totals.
|
||||
.stats({ stream: false })
|
||||
.catch(
|
||||
() =>
|
||||
({
|
||||
@@ -340,12 +339,22 @@ export function calculateCpuUsage(stats: ContainerStats): number {
|
||||
}
|
||||
|
||||
const numberOfCpus = stats.cpu_stats.online_cpus;
|
||||
const usage = stats.cpu_stats.system_cpu_usage;
|
||||
if (!usage || usage === 0) {
|
||||
|
||||
// Docker's own formula for the current (instantaneous) CPU usage: it compares
|
||||
// the latest reading against the previous one (precpu_stats). Dividing the
|
||||
// cumulative total_usage by the cumulative system_cpu_usage instead would
|
||||
// report the container's lifetime average, which stays high long after a load
|
||||
// spike has ended. precpu_stats is only present when the stats are NOT fetched
|
||||
// in one-shot mode; when it is absent (e.g. Podman) the deltas fall back to the
|
||||
// cumulative totals, matching the previous behaviour.
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
|
||||
const systemDelta = (stats.cpu_stats.system_cpu_usage ?? 0) - (stats.precpu_stats?.system_cpu_usage ?? 0);
|
||||
|
||||
if (systemDelta <= 0 || cpuDelta < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
|
||||
return (cpuDelta / systemDelta) * numberOfCpus * 100;
|
||||
}
|
||||
|
||||
export function calculateMemoryUsage(stats: ContainerStats): number {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const downloadClientRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const downloadClientRequestHandler = createIntegrationRequestHandler<
|
||||
DownloadClientJobsAndStatus,
|
||||
IntegrationKindByCategory<"downloadClient">,
|
||||
{ limit: number }
|
||||
@@ -15,6 +13,4 @@ export const downloadClientRequestHandler = createCachedIntegrationRequestHandle
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getClientJobsAndStatusAsync(input);
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
queryKey: "downloadClientJobStatus",
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
import { createChannelWithLatestAndEvents } from "@homarr/redis";
|
||||
import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
|
||||
import { createRequestHandler } from "@homarr/request-handler/lib/request-handler";
|
||||
|
||||
export type DuckDuckGoBang = z.infer<typeof duckDuckGoBangSchema>;
|
||||
|
||||
@@ -38,9 +36,7 @@ const normalizeBangToken = (token: string) => token.toLowerCase().trim();
|
||||
|
||||
const compareBangTokenAsc = (left: DuckDuckGoBang, right: DuckDuckGoBang) => left.t.localeCompare(right.t);
|
||||
|
||||
export const duckDuckGoBangsRequestHandler = createCachedRequestHandler({
|
||||
queryKey: "duckduckgo-bangs",
|
||||
cacheDuration: dayjs.duration(1, "day"),
|
||||
export const duckDuckGoBangsRequestHandler = createRequestHandler({
|
||||
async requestAsync(_) {
|
||||
if (env.NO_EXTERNAL_CONNECTION) {
|
||||
return [];
|
||||
@@ -73,7 +69,4 @@ export const duckDuckGoBangsRequestHandler = createCachedRequestHandler({
|
||||
|
||||
return normalized;
|
||||
},
|
||||
createRedisChannel() {
|
||||
return createChannelWithLatestAndEvents<DuckDuckGoBang[]>("homarr:duckduckgo-bangs");
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type {
|
||||
@@ -9,9 +7,9 @@ import type {
|
||||
FirewallVersionSummary,
|
||||
} from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const firewallCpuRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const firewallCpuRequestHandler = createIntegrationRequestHandler<
|
||||
FirewallCpuSummary,
|
||||
IntegrationKindByCategory<"firewall">,
|
||||
Record<string, never>
|
||||
@@ -20,11 +18,9 @@ export const firewallCpuRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return integrationInstance.getFirewallCpuAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
queryKey: "firewallCpuSummary",
|
||||
});
|
||||
|
||||
export const firewallMemoryRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const firewallMemoryRequestHandler = createIntegrationRequestHandler<
|
||||
FirewallMemorySummary,
|
||||
IntegrationKindByCategory<"firewall">,
|
||||
Record<string, never>
|
||||
@@ -33,11 +29,9 @@ export const firewallMemoryRequestHandler = createCachedIntegrationRequestHandle
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getFirewallMemoryAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(15, "seconds"),
|
||||
queryKey: "firewallMemorySummary",
|
||||
});
|
||||
|
||||
export const firewallInterfacesRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const firewallInterfacesRequestHandler = createIntegrationRequestHandler<
|
||||
FirewallInterfacesSummary[],
|
||||
IntegrationKindByCategory<"firewall">,
|
||||
Record<string, never>
|
||||
@@ -46,11 +40,9 @@ export const firewallInterfacesRequestHandler = createCachedIntegrationRequestHa
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getFirewallInterfacesAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(30, "seconds"),
|
||||
queryKey: "firewallInterfacesSummary",
|
||||
});
|
||||
|
||||
export const firewallVersionRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const firewallVersionRequestHandler = createIntegrationRequestHandler<
|
||||
FirewallVersionSummary,
|
||||
IntegrationKindByCategory<"firewall">,
|
||||
Record<string, never>
|
||||
@@ -59,6 +51,4 @@ export const firewallVersionRequestHandler = createCachedIntegrationRequestHandl
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getFirewallVersionAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "hour"),
|
||||
queryKey: "firewallVersionSummary",
|
||||
});
|
||||
@@ -1,25 +1,21 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { ProxmoxClusterInfo, SystemHealthMonitoring } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const systemInfoRequestHandler = createIntegrationRequestHandler<
|
||||
SystemHealthMonitoring,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel">,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon">,
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getSystemInfoAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
queryKey: "systemInfo",
|
||||
});
|
||||
|
||||
export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const clusterInfoRequestHandler = createIntegrationRequestHandler<
|
||||
ProxmoxClusterInfo,
|
||||
"proxmox" | "mock",
|
||||
Record<string, never>
|
||||
@@ -28,6 +24,4 @@ export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getClusterInfoAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
queryKey: "clusterInfo",
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { ImmichAlbum, ImmichServerStats } from "@homarr/integrations";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const immichStatsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const immichStatsRequestHandler = createIntegrationRequestHandler<
|
||||
ImmichServerStats,
|
||||
IntegrationKindByCategory<"photoService">,
|
||||
Record<string, never>
|
||||
@@ -15,11 +13,9 @@ export const immichStatsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getServerStatsAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(15, "minute"),
|
||||
queryKey: "immich-server-stats",
|
||||
});
|
||||
|
||||
export const immichAlbumsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
|
||||
{
|
||||
id: string;
|
||||
albumName: string;
|
||||
@@ -32,11 +28,9 @@ export const immichAlbumsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getAlbumsAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(15, "minute"),
|
||||
queryKey: "immich-albums",
|
||||
});
|
||||
|
||||
export const immichAlbumRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
|
||||
ImmichAlbum,
|
||||
IntegrationKindByCategory<"photoService">,
|
||||
{ albumId: string }
|
||||
@@ -45,6 +39,4 @@ export const immichAlbumRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getAlbumAsync(input.albumId);
|
||||
},
|
||||
cacheDuration: dayjs.duration(15, "minute"),
|
||||
queryKey: "immich-album",
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { Indexer } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const indexerManagerRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const indexerManagerRequestHandler = createIntegrationRequestHandler<
|
||||
Indexer[],
|
||||
IntegrationKindByCategory<"indexerManager">,
|
||||
Record<string, never>
|
||||
@@ -15,6 +13,4 @@ export const indexerManagerRequestHandler = createCachedIntegrationRequestHandle
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getIndexersAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "indexerManager",
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { Duration } from "dayjs/plugin/duration";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { createIntegrationOptionsChannel } from "@homarr/redis";
|
||||
|
||||
import { createCachedRequestHandler } from "./cached-request-handler";
|
||||
|
||||
type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"> & {
|
||||
kind: TKind;
|
||||
decryptedSecrets: Modify<Pick<IntegrationSecret, "kind" | "value">, { value: string }>[];
|
||||
externalUrl: string | null;
|
||||
};
|
||||
|
||||
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
||||
queryKey: string;
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
cacheDuration: Duration;
|
||||
cacheDurationForInput?: (input: TInput) => Duration | undefined;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
retry?: { attempts?: number; delayMs?: number };
|
||||
isValid?: (data: TData) => boolean;
|
||||
}
|
||||
|
||||
export const createCachedIntegrationRequestHandler = <
|
||||
TData,
|
||||
TKind extends IntegrationKind,
|
||||
TInput extends Record<string, unknown>,
|
||||
>(
|
||||
options: Options<TData, TKind, TInput>,
|
||||
) => {
|
||||
return {
|
||||
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
|
||||
createCachedRequestHandler({
|
||||
queryKey: options.queryKey,
|
||||
requestAsync: async (input: { options: TInput; integration: IntegrationOfKind<TKind> }) => {
|
||||
return await options.requestAsync(input.integration, input.options);
|
||||
},
|
||||
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
retry: options.retry,
|
||||
isValid: options.isValid,
|
||||
createRedisChannel(input, handlerOptions) {
|
||||
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
|
||||
},
|
||||
}).handler({ options: itemOptions, integration }),
|
||||
};
|
||||
};
|
||||
@@ -1,160 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { Duration } from "dayjs/plugin/duration";
|
||||
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { createChannelWithLatestAndEvents } from "@homarr/redis";
|
||||
|
||||
const logger = createLogger({ module: "cachedRequestHandler" });
|
||||
|
||||
type FetchResult<TData> = { data: TData; timestamp: Date };
|
||||
type InFlightEntry = Promise<FetchResult<unknown>>;
|
||||
|
||||
const inFlightFetches = new Map<string, InFlightEntry>();
|
||||
|
||||
const isAbortedError = (error: unknown): boolean => {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return error.name === "AbortError" || error.name === "TimeoutError";
|
||||
};
|
||||
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
// Unique key for this request handler
|
||||
queryKey: string;
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
createRedisChannel: (
|
||||
input: TInput,
|
||||
options: Options<TData, TInput>,
|
||||
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
|
||||
cacheDuration: Duration;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
// Retry the upstream call on failure. attempts=1 means no retry (default).
|
||||
// AbortError / TimeoutError are never retried.
|
||||
retry?: { attempts?: number; delayMs?: number };
|
||||
// Predicate to decide whether a fetched response is worth caching.
|
||||
// Return false to skip the cache write (next call will refetch).
|
||||
isValid?: (data: TData) => boolean;
|
||||
}
|
||||
|
||||
const defaultRetry = { attempts: 1, delayMs: 0 };
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
return {
|
||||
handler: (input: TInput) => {
|
||||
const channel = options.createRedisChannel(input, options);
|
||||
const retryConfig = { ...defaultRetry, ...options.retry };
|
||||
|
||||
return {
|
||||
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
|
||||
const channelData = await channel.getAsync();
|
||||
|
||||
const requestNewDataAsync = (): Promise<FetchResult<TData>> => {
|
||||
const inflight = inFlightFetches.get(channel.name);
|
||||
if (inflight) {
|
||||
logger.debug("Cached request handler joining in-flight fetch", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
});
|
||||
return inflight as Promise<FetchResult<TData>>;
|
||||
}
|
||||
|
||||
const promise = (async (): Promise<FetchResult<TData>> => {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= retryConfig.attempts; attempt++) {
|
||||
try {
|
||||
const data = await options.requestAsync(input);
|
||||
if (options.isValid && !options.isValid(data)) {
|
||||
logger.warn("Cached request handler received invalid data, not caching", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
attempt,
|
||||
});
|
||||
return { data, timestamp: new Date() };
|
||||
}
|
||||
await channel.publishAndUpdateLastStateAsync(data);
|
||||
return { data, timestamp: new Date() };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (isAbortedError(error) || attempt === retryConfig.attempts) {
|
||||
break;
|
||||
}
|
||||
logger.warn("Cached request handler fetch failed, retrying", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
attempt,
|
||||
nextDelayMs: retryConfig.delayMs,
|
||||
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
|
||||
});
|
||||
await sleep(retryConfig.delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.fallbackToStaleOnError && channelData) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Cached request handler using stale cache after fetch failure",
|
||||
{
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
attempts: retryConfig.attempts,
|
||||
},
|
||||
{ cause: lastError },
|
||||
),
|
||||
);
|
||||
return channelData;
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
})().finally(() => {
|
||||
inFlightFetches.delete(channel.name);
|
||||
});
|
||||
|
||||
inFlightFetches.set(channel.name, promise as InFlightEntry);
|
||||
return promise;
|
||||
};
|
||||
|
||||
if (forceUpdate) {
|
||||
logger.debug("Cached request handler forced update", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
});
|
||||
return await requestNewDataAsync();
|
||||
}
|
||||
|
||||
const shouldRequestNewData =
|
||||
!channelData ||
|
||||
dayjs().diff(channelData.timestamp, "milliseconds") > options.cacheDuration.asMilliseconds();
|
||||
|
||||
if (shouldRequestNewData) {
|
||||
logger.debug("Cached request handler cache miss", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
reason: !channelData ? "no data" : "cache expired",
|
||||
});
|
||||
return await requestNewDataAsync();
|
||||
}
|
||||
|
||||
logger.debug("Cached request handler cache hit", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
expiresAt: dayjs(channelData.timestamp).add(options.cacheDuration).toISOString(),
|
||||
});
|
||||
|
||||
return channelData;
|
||||
},
|
||||
async invalidateAsync() {
|
||||
logger.debug("Cached request handler invalidating cache", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
});
|
||||
await this.getCachedOrUpdatedDataAsync({ forceUpdate: true });
|
||||
},
|
||||
subscribe(callback: (data: TData) => void) {
|
||||
return channel.subscribe(callback);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,115 +0,0 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { hashObjectBase64, Stopwatch } from "@homarr/common";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import type { MaybeArray } from "@homarr/common/types";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { db } from "@homarr/db";
|
||||
import { getItemsWithIntegrationsAsync, getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
// This imports are done that way to avoid circular dependencies.
|
||||
import type { inferSupportedIntegrationsStrict } from "../../../widgets/src";
|
||||
import { reduceWidgetOptionsWithDefaultValues } from "../../../widgets/src";
|
||||
import type { WidgetComponentProps } from "../../../widgets/src/definition";
|
||||
import type { createCachedIntegrationRequestHandler } from "./cached-integration-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "cachedRequestIntegrationJobHandler" });
|
||||
|
||||
export const createRequestIntegrationJobHandler = <
|
||||
TWidgetKind extends WidgetKind,
|
||||
TIntegrationKind extends inferSupportedIntegrationsStrict<TWidgetKind>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
THandler extends ReturnType<typeof createCachedIntegrationRequestHandler<any, TIntegrationKind, any>>["handler"],
|
||||
>(
|
||||
handler: THandler,
|
||||
{
|
||||
widgetKinds,
|
||||
getInput,
|
||||
}: {
|
||||
widgetKinds: TWidgetKind[];
|
||||
getInput: {
|
||||
[key in TWidgetKind]: (options: WidgetComponentProps<key>["options"]) => MaybeArray<Parameters<THandler>[1]>;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
return async () => {
|
||||
const serverSettings = await getServerSettingsAsync(db);
|
||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
||||
kinds: widgetKinds,
|
||||
});
|
||||
|
||||
logger.debug("Found items for integration", {
|
||||
widgetKinds: widgetKinds.join(","),
|
||||
count: itemsForIntegration.length,
|
||||
});
|
||||
|
||||
const distinctIntegrations: {
|
||||
integrationId: string;
|
||||
inputHash: string;
|
||||
integration: (typeof itemsForIntegration)[number]["integrations"][number]["integration"];
|
||||
input: Parameters<THandler>[1];
|
||||
}[] = [];
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
const oneOrMultipleInputs = getInput[itemForIntegration.kind](
|
||||
reduceWidgetOptionsWithDefaultValues(
|
||||
itemForIntegration.kind,
|
||||
{
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
},
|
||||
SuperJSON.parse<Record<string, unknown>>(itemForIntegration.options),
|
||||
) as never,
|
||||
);
|
||||
for (const { integration } of itemForIntegration.integrations) {
|
||||
const inputArray = Array.isArray(oneOrMultipleInputs) ? oneOrMultipleInputs : [oneOrMultipleInputs];
|
||||
|
||||
for (const input of inputArray) {
|
||||
const inputHash = hashObjectBase64(input);
|
||||
if (
|
||||
distinctIntegrations.some(
|
||||
(distinctIntegration) =>
|
||||
distinctIntegration.integrationId === integration.id && distinctIntegration.inputHash === inputHash,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
distinctIntegrations.push({ integrationId: integration.id, inputHash, integration, input });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { integrationId, integration, input, inputHash } of distinctIntegrations) {
|
||||
try {
|
||||
const decryptedSecrets = integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
}));
|
||||
|
||||
const innerHandler = handler(
|
||||
{
|
||||
...integration,
|
||||
kind: integration.kind as TIntegrationKind,
|
||||
decryptedSecrets,
|
||||
externalUrl: integration.app?.href ?? null,
|
||||
},
|
||||
input,
|
||||
);
|
||||
const stopWatch = new Stopwatch();
|
||||
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
|
||||
logger.debug("Ran integration job", {
|
||||
integration: integrationId,
|
||||
inputHash,
|
||||
elapsed: stopWatch.getElapsedInHumanWords(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata("Failed to run integration job", { integrationId, inputHash }, { cause: error }),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Duration } from "dayjs/plugin/duration";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createWidgetOptionsChannel } from "@homarr/redis";
|
||||
|
||||
import { createCachedRequestHandler } from "./cached-request-handler";
|
||||
|
||||
interface Options<TData, TKind extends WidgetKind, TInput extends Record<string, unknown>> {
|
||||
// Unique key for this request handler
|
||||
queryKey: string;
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
cacheDuration: Duration;
|
||||
widgetKind: TKind;
|
||||
}
|
||||
|
||||
export const createCachedWidgetRequestHandler = <
|
||||
TData,
|
||||
TKind extends WidgetKind,
|
||||
TInput extends Record<string, unknown>,
|
||||
>(
|
||||
requestHandlerOptions: Options<TData, TKind, TInput>,
|
||||
) => {
|
||||
return {
|
||||
handler: (widgetOptions: TInput) =>
|
||||
createCachedRequestHandler({
|
||||
queryKey: requestHandlerOptions.queryKey,
|
||||
requestAsync: async (input: TInput) => {
|
||||
return await requestHandlerOptions.requestAsync(input);
|
||||
},
|
||||
cacheDuration: requestHandlerOptions.cacheDuration,
|
||||
createRedisChannel(input, options) {
|
||||
return createWidgetOptionsChannel<TData>(requestHandlerOptions.widgetKind, options.queryKey, input);
|
||||
},
|
||||
}).handler(widgetOptions),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
|
||||
type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"> & {
|
||||
kind: TKind;
|
||||
decryptedSecrets: Modify<Pick<IntegrationSecret, "kind" | "value">, { value: string }>[];
|
||||
externalUrl: string | null;
|
||||
};
|
||||
|
||||
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
}
|
||||
|
||||
export const createIntegrationRequestHandler = <
|
||||
TData,
|
||||
TKind extends IntegrationKind,
|
||||
TInput extends Record<string, unknown>,
|
||||
>(
|
||||
options: Options<TData, TKind, TInput>,
|
||||
) => {
|
||||
const integrationMap = new Map<string, IntegrationOfKind<TKind>>();
|
||||
const inner = createRequestHandler<TData, { integrationId: string; options: TInput }>({
|
||||
requestAsync: async (input) => {
|
||||
const integration = integrationMap.get(input.integrationId);
|
||||
if (!integration) {
|
||||
throw new Error(`Integration ${input.integrationId} not found in cache`);
|
||||
}
|
||||
return options.requestAsync(integration, input.options);
|
||||
},
|
||||
cacheTtlMs: options.cacheTtlMs,
|
||||
});
|
||||
|
||||
return {
|
||||
invalidateCache: inner.invalidateCache,
|
||||
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) => {
|
||||
integrationMap.set(integration.id, integration);
|
||||
return inner.handler({ integrationId: integration.id, options: itemOptions });
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
}
|
||||
|
||||
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
|
||||
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
const DEFAULT_TTL_MS = 10_000;
|
||||
|
||||
const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of cache) {
|
||||
if (now >= entry.expiresAt) cache.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
export const createRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
const cache = new Map<string, CacheEntry<TData>>();
|
||||
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
|
||||
|
||||
return {
|
||||
invalidateCache: () => {
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
},
|
||||
handler: (input: TInput) => ({
|
||||
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
|
||||
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
|
||||
const key = JSON.stringify(input);
|
||||
|
||||
const cached = cache.get(key);
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
return { data: cached.data, timestamp: cached.timestamp };
|
||||
}
|
||||
if (cached) cache.delete(key);
|
||||
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = options
|
||||
.requestAsync(input)
|
||||
.then((data) => {
|
||||
if (cache.size >= MAX_CACHE_SIZE) evictExpired(cache);
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest) cache.delete(oldest);
|
||||
}
|
||||
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
|
||||
cache.set(key, entry);
|
||||
inflight.delete(key);
|
||||
return entry;
|
||||
})
|
||||
.catch((err) => {
|
||||
inflight.delete(key);
|
||||
throw err;
|
||||
});
|
||||
|
||||
inflight.set(key, promise);
|
||||
return promise;
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
}
|
||||
|
||||
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => ({
|
||||
handler: (widgetOptions: TInput) =>
|
||||
createRequestHandler<TData, TInput>({
|
||||
requestAsync: options.requestAsync,
|
||||
}).handler(widgetOptions),
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
interface MediaOrganizerData {
|
||||
missing: MissingMediaItem[];
|
||||
missingCount: number;
|
||||
queued: QueuedMediaItem[];
|
||||
queuedCount: number;
|
||||
}
|
||||
|
||||
export const mediaOrganizerRequestHandler = createIntegrationRequestHandler<
|
||||
MediaOrganizerData,
|
||||
IntegrationKindByCategory<"mediaOrganizer">,
|
||||
{ pageSize: number }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = (await createIntegrationAsync(integration)) as unknown as IMediaOrganizerIntegration;
|
||||
const [missingResult, queueResult] = await Promise.all([
|
||||
integrationInstance.getMissingAsync(input.pageSize),
|
||||
integrationInstance.getMediaQueueAsync(input.pageSize),
|
||||
]);
|
||||
return {
|
||||
missing: missingResult.items,
|
||||
missingCount: missingResult.totalCount,
|
||||
queued: queueResult.items,
|
||||
queuedCount: queueResult.totalCount,
|
||||
};
|
||||
},
|
||||
cacheTtlMs: 60_000,
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { MediaRelease } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const mediaReleaseRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const mediaReleaseRequestHandler = createIntegrationRequestHandler<
|
||||
MediaRelease[],
|
||||
IntegrationKindByCategory<"mediaRelease">,
|
||||
Record<string, never>
|
||||
@@ -15,6 +13,4 @@ export const mediaReleaseRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getMediaReleasesAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "mediaReleases",
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { MediaRequest } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const mediaRequestListRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
|
||||
MediaRequest[],
|
||||
IntegrationKindByCategory<"mediaRequest">,
|
||||
Record<string, never>
|
||||
@@ -15,6 +13,4 @@ export const mediaRequestListRequestHandler = createCachedIntegrationRequestHand
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getRequestsAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "minute"),
|
||||
queryKey: "mediaRequestList",
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { MediaRequestStats } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const mediaRequestStatsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
|
||||
MediaRequestStats,
|
||||
IntegrationKindByCategory<"mediaRequest">,
|
||||
Record<string, never>
|
||||
@@ -18,6 +16,4 @@ export const mediaRequestStatsRequestHandler = createCachedIntegrationRequestHan
|
||||
users: await integrationInstance.getUsersAsync(),
|
||||
};
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "minute"),
|
||||
queryKey: "mediaRequestStats",
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const mediaServerRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const mediaServerRequestHandler = createIntegrationRequestHandler<
|
||||
StreamSession[],
|
||||
IntegrationKindByCategory<"mediaService">,
|
||||
{
|
||||
@@ -17,6 +15,4 @@ export const mediaServerRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getCurrentSessionsAsync({ showOnlyPlaying: input.showOnlyPlaying });
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
queryKey: "mediaServerSessions",
|
||||
});
|
||||
@@ -1,18 +1,14 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "@homarr/integrations";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const mediaTranscodingRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const mediaTranscodingRequestHandler = createIntegrationRequestHandler<
|
||||
MediaTranscoding,
|
||||
IntegrationKindByCategory<"mediaTranscoding">,
|
||||
{ pageOffset: number; pageSize: number }
|
||||
>({
|
||||
queryKey: "mediaTranscoding",
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
export const minecraftServerStatusRequestHandler = createCachedWidgetRequestHandler({
|
||||
queryKey: "minecraftServerStatusApiResult",
|
||||
widgetKind: "minecraftServerStatus",
|
||||
export const minecraftServerStatusRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { domain: string; isBedrockServer: boolean }) {
|
||||
const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`;
|
||||
|
||||
@@ -17,7 +14,6 @@ export const minecraftServerStatusRequestHandler = createCachedWidgetRequestHand
|
||||
);
|
||||
return responseSchema.parse(await response.json());
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
});
|
||||
|
||||
const responseSchema = z
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { NavidromeDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const navidromeRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const navidromeRequestHandler = createIntegrationRequestHandler<
|
||||
NavidromeDashboardData,
|
||||
"navidrome",
|
||||
Record<string, never>
|
||||
@@ -14,6 +12,4 @@ export const navidromeRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getDashboardDataAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(10, "minutes"),
|
||||
queryKey: "navidromeDashboard",
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { NetworkControllerSummary } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const networkControllerRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const networkControllerRequestHandler = createIntegrationRequestHandler<
|
||||
NetworkControllerSummary,
|
||||
IntegrationKindByCategory<"networkController">,
|
||||
Record<string, never>
|
||||
@@ -15,6 +13,4 @@ export const networkControllerRequestHandler = createCachedIntegrationRequestHan
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getNetworkSummaryAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "networkControllerSummary",
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { Notification } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const notificationsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const notificationsRequestHandler = createIntegrationRequestHandler<
|
||||
Notification[],
|
||||
IntegrationKindByCategory<"notifications">,
|
||||
Record<string, never>
|
||||
@@ -15,6 +13,4 @@ export const notificationsRequestHandler = createCachedIntegrationRequestHandler
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getNotificationsAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "notificationsJobStatus",
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { PaperlessNgxStats } from "@homarr/integrations";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const paperlessNgxStatsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const paperlessNgxStatsRequestHandler = createIntegrationRequestHandler<
|
||||
PaperlessNgxStats,
|
||||
"paperlessNgx",
|
||||
Record<string, never>
|
||||
@@ -14,6 +12,4 @@ export const paperlessNgxStatsRequestHandler = createCachedIntegrationRequestHan
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getStatsAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(15, "minute"),
|
||||
queryKey: "paperless-ngx-stats",
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { PatchMonStats } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const patchmonStatsRequestHandler = createIntegrationRequestHandler<
|
||||
PatchMonStats,
|
||||
"patchmon",
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getStatsAsync();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,829 @@
|
||||
import { Octokit, RequestError as OctokitRequestError } from "octokit";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { ReleaseProviderKind } from "@homarr/definitions";
|
||||
import { getReleaseProviderDefaultUrl, normalizeReleaseProviderIdentifier } from "@homarr/definitions";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
const logger = createLogger({ module: "releaseProviders" });
|
||||
|
||||
const PROVIDER_TIMEOUT_MS = 10_000;
|
||||
const fetchProvider: typeof fetchWithTrustedCertificatesAsync = (url, options) =>
|
||||
fetchWithTrustedCertificatesAsync(url, { timeout: PROVIDER_TIMEOUT_MS, ...options });
|
||||
|
||||
export interface ReleasesRepositoryRequest extends Record<string, unknown> {
|
||||
id: string;
|
||||
provider: ReleaseProviderKind;
|
||||
identifier: string;
|
||||
versionRegex?: string;
|
||||
providerUrl?: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface DetailsProviderResponse {
|
||||
projectUrl?: string;
|
||||
projectDescription?: string;
|
||||
isFork?: boolean;
|
||||
isArchived?: boolean;
|
||||
createdAt?: Date;
|
||||
starsCount?: number;
|
||||
openIssues?: number;
|
||||
forksCount?: number;
|
||||
}
|
||||
|
||||
export interface ReleaseProviderResponse {
|
||||
latestRelease: string;
|
||||
latestReleaseAt: Date;
|
||||
releaseUrl?: string;
|
||||
releaseDescription?: string;
|
||||
isPreRelease?: boolean;
|
||||
}
|
||||
|
||||
export type ReleaseData = DetailsProviderResponse & ReleaseProviderResponse;
|
||||
export type ReleaseError = {
|
||||
code: "invalidIdentifier" | "noMatchingVersion" | "noReleasesFound" | "unexpected";
|
||||
message: string;
|
||||
};
|
||||
export type ReleaseResponse = { success: true; data: ReleaseData } | { success: false; error: ReleaseError };
|
||||
|
||||
const compileVersionRegex = (versionRegex?: string): RegExp | null => {
|
||||
if (!versionRegex) return null;
|
||||
try {
|
||||
return new RegExp(versionRegex);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getLatestRelease = (
|
||||
releases: ReleaseProviderResponse[],
|
||||
versionRegex?: string,
|
||||
): { release: ReleaseProviderResponse } | { noMatch: string } => {
|
||||
const regex = compileVersionRegex(versionRegex);
|
||||
if (versionRegex && !regex) {
|
||||
return { noMatch: `Invalid regex: /${versionRegex}/` };
|
||||
}
|
||||
|
||||
const validReleases = releases.filter((result) => {
|
||||
if (!result.latestRelease) return false;
|
||||
return regex ? regex.test(result.latestRelease) : true;
|
||||
});
|
||||
|
||||
if (validReleases.length > 0) {
|
||||
return {
|
||||
release: validReleases.reduce((latest, current) =>
|
||||
current.latestReleaseAt > latest.latestReleaseAt ? current : latest,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const sampleTags = releases
|
||||
.slice(0, 5)
|
||||
.map((r) => r.latestRelease)
|
||||
.join(", ");
|
||||
const reason = versionRegex
|
||||
? `Regex /${versionRegex}/ matched 0/${releases.length} tags. Sample tags: [${sampleTags}]`
|
||||
: `Found ${releases.length} releases but none had a valid tag`;
|
||||
return { noMatch: reason };
|
||||
};
|
||||
|
||||
export const getLatestMatchingReleaseAsync = async (input: ReleasesRepositoryRequest): Promise<ReleaseResponse> => {
|
||||
const baseUrl = removeTrailingSlash(input.providerUrl ?? getReleaseProviderDefaultUrl(input.provider));
|
||||
const identifier = normalizeReleaseProviderIdentifier(input.provider, input.identifier);
|
||||
|
||||
switch (input.provider) {
|
||||
case "github":
|
||||
return await getGithubReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
|
||||
case "gitHubContainerRegistry":
|
||||
return await getGitHubContainerRegistryReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
|
||||
case "dockerHub":
|
||||
return await getDockerHubReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
|
||||
case "gitlab":
|
||||
return await getGitlabReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
|
||||
case "npm":
|
||||
return await getNpmReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
|
||||
case "codeberg":
|
||||
return await getCodebergReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
|
||||
case "linuxServerIO":
|
||||
return await getLinuxServerIOReleaseAsync(baseUrl, identifier, input.versionRegex);
|
||||
case "quay":
|
||||
return await getQuayReleaseAsync(baseUrl, identifier, input.versionRegex);
|
||||
}
|
||||
};
|
||||
|
||||
const parseOwnerName = (identifier: string) => {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) return null;
|
||||
return { owner, name };
|
||||
};
|
||||
|
||||
const parseOwnerPackageName = (identifier: string) => {
|
||||
const [owner, ...nameParts] = identifier.split("/");
|
||||
const name = nameParts.join("/");
|
||||
if (!owner || !name) return null;
|
||||
return { owner, name };
|
||||
};
|
||||
|
||||
const removeTrailingSlash = (url: string) => url.replace(/\/$/, "");
|
||||
|
||||
const buildUrl = (baseUrl: string, path: `/${string}`) => new URL(`${baseUrl}${path}`).toString();
|
||||
|
||||
const getGithubApi = (baseUrl: string, userAgent: string, token?: string) =>
|
||||
new Octokit({
|
||||
baseUrl,
|
||||
auth: token,
|
||||
request: { fetch: fetchWithTrustedCertificatesAsync },
|
||||
throttle: { enabled: true },
|
||||
userAgent,
|
||||
});
|
||||
|
||||
const getGithubReleaseAsync = async (
|
||||
baseUrl: string,
|
||||
identifier: string,
|
||||
versionRegex?: string,
|
||||
token?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
const parsed = parseOwnerName(identifier);
|
||||
if (!parsed)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/repo` },
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
if (allReleases.length === 0) {
|
||||
return { success: false, error: { code: "noReleasesFound", message: `${identifier} has no GitHub releases` } };
|
||||
}
|
||||
|
||||
const releases = allReleases.reduce<ReleaseProviderResponse[]>((acc, release) => {
|
||||
if (!release.published_at) return acc;
|
||||
acc.push({
|
||||
latestRelease: release.tag_name,
|
||||
latestReleaseAt: new Date(release.published_at),
|
||||
releaseUrl: release.html_url,
|
||||
releaseDescription: release.body ?? undefined,
|
||||
isPreRelease: release.prerelease,
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
const result = getLatestRelease(releases, versionRegex);
|
||||
if ("noMatch" in result) {
|
||||
return { success: false, error: { code: "noMatchingVersion", message: result.noMatch } };
|
||||
}
|
||||
|
||||
const details = await getGithubDetailsAsync(api, parsed.owner, parsed.name);
|
||||
return { success: true, data: { ...details, ...result.release } };
|
||||
} catch (error) {
|
||||
const message = error instanceof OctokitRequestError ? error.message : String(error);
|
||||
logger.warn("Failed to get GitHub releases", { identifier, error: message });
|
||||
return { success: false, error: { code: "unexpected", message } };
|
||||
}
|
||||
};
|
||||
|
||||
const getGithubDetailsAsync = async (api: Octokit, owner: string, name: string): Promise<DetailsProviderResponse> => {
|
||||
try {
|
||||
const response = await api.rest.repos.get({ owner, repo: name });
|
||||
return {
|
||||
projectUrl: response.data.html_url,
|
||||
projectDescription: response.data.description ?? undefined,
|
||||
isFork: response.data.fork,
|
||||
isArchived: response.data.archived,
|
||||
createdAt: new Date(response.data.created_at),
|
||||
starsCount: response.data.stargazers_count,
|
||||
openIssues: response.data.open_issues_count,
|
||||
forksCount: response.data.forks_count,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn("Failed to get GitHub details", { owner, name, error: String(error) });
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const getGitHubContainerRegistryReleaseAsync = async (
|
||||
baseUrl: string,
|
||||
identifier: string,
|
||||
versionRegex?: string,
|
||||
token?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
const parsed = parseOwnerPackageName(identifier);
|
||||
if (!parsed)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/package` },
|
||||
};
|
||||
|
||||
try {
|
||||
const repositoryName = `${parsed.owner}/${parsed.name}`;
|
||||
const regex = compileVersionRegex(versionRegex);
|
||||
if (versionRegex && !regex) {
|
||||
return { success: false, error: { code: "noMatchingVersion", message: `Invalid regex: /${versionRegex}/` } };
|
||||
}
|
||||
const registryUrl = new URL(baseUrl);
|
||||
const registryToken = token ?? (await getGitHubContainerRegistryTokenAsync(registryUrl, repositoryName));
|
||||
const tags = await getGitHubContainerRegistryTagsAsync(registryUrl, repositoryName, registryToken);
|
||||
if (tags.length === 0) {
|
||||
return { success: false, error: { code: "noReleasesFound", message: `${repositoryName} has no container tags` } };
|
||||
}
|
||||
const matchingTags = regex ? tags.filter((tag) => regex.test(tag)) : tags;
|
||||
const tagsToCheck = versionRegex
|
||||
? matchingTags.slice(-100)
|
||||
: matchingTags.includes("latest")
|
||||
? ["latest"]
|
||||
: matchingTags.slice(-100);
|
||||
if (tagsToCheck.length === 0) {
|
||||
const sampleTags = tags.slice(0, 5).join(", ");
|
||||
const msg = versionRegex
|
||||
? `Regex /${versionRegex}/ matched 0/${tags.length} tags. Sample: [${sampleTags}]`
|
||||
: `No "latest" tag found among ${tags.length} tags. Sample: [${sampleTags}]`;
|
||||
return { success: false, error: { code: "noMatchingVersion", message: msg } };
|
||||
}
|
||||
|
||||
const releases = await Promise.all(
|
||||
tagsToCheck.map(async (tag) => ({
|
||||
latestRelease: tag,
|
||||
latestReleaseAt: await getGitHubContainerRegistryTagCreatedAtAsync(
|
||||
registryUrl,
|
||||
repositoryName,
|
||||
tag,
|
||||
registryToken,
|
||||
),
|
||||
releaseUrl: `https://github.com/${repositoryName}/pkgs/container/${encodeURIComponent(parsed.name)}`,
|
||||
})),
|
||||
);
|
||||
const result = getLatestRelease(releases, versionRegex);
|
||||
if ("noMatch" in result) {
|
||||
return { success: false, error: { code: "noMatchingVersion", message: result.noMatch } };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
projectUrl: `https://github.com/${repositoryName}`,
|
||||
...result.release,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn("Failed to get GitHub container registry releases", { identifier, error: message });
|
||||
return { success: false, error: { code: "unexpected", message } };
|
||||
}
|
||||
};
|
||||
|
||||
const gitHubContainerRegistryTokenSchema = z.object({ token: z.string() });
|
||||
const gitHubContainerRegistryTagsSchema = z.object({ tags: z.array(z.string()).optional() });
|
||||
const gitHubContainerRegistryManifestSchema = z.object({
|
||||
config: z.object({ digest: z.string() }).optional(),
|
||||
manifests: z.array(z.object({ digest: z.string() })).optional(),
|
||||
});
|
||||
const gitHubContainerRegistryConfigSchema = z.object({
|
||||
created: z.string().transform((value) => new Date(value)),
|
||||
});
|
||||
|
||||
const getGitHubContainerRegistryTokenAsync = async (registryUrl: URL, repositoryName: string) => {
|
||||
const response = await fetchProvider(
|
||||
buildUrl(
|
||||
registryUrl.origin,
|
||||
`/token?service=${registryUrl.host}&scope=${encodeURIComponent(`repository:${repositoryName}:pull`)}` as `/${string}`,
|
||||
),
|
||||
);
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
|
||||
const result = gitHubContainerRegistryTokenSchema.safeParse(await response.json());
|
||||
if (!result.success) throw new Error(result.error.message);
|
||||
|
||||
return result.data.token;
|
||||
};
|
||||
|
||||
const getGitHubContainerRegistryTagsAsync = async (registryUrl: URL, repositoryName: string, token: string) => {
|
||||
const response = await fetchProvider(buildUrl(registryUrl.origin, `/v2/${repositoryName}/tags/list`), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
|
||||
const result = gitHubContainerRegistryTagsSchema.safeParse(await response.json());
|
||||
if (!result.success) throw new Error(result.error.message);
|
||||
|
||||
return result.data.tags ?? [];
|
||||
};
|
||||
|
||||
const getGitHubContainerRegistryTagCreatedAtAsync = async (
|
||||
registryUrl: URL,
|
||||
repositoryName: string,
|
||||
tag: string,
|
||||
token: string,
|
||||
) => {
|
||||
const manifest = await getGitHubContainerRegistryManifestAsync(registryUrl, repositoryName, tag, token);
|
||||
const imageManifest = manifest.config
|
||||
? manifest
|
||||
: await getGitHubContainerRegistryManifestAsync(
|
||||
registryUrl,
|
||||
repositoryName,
|
||||
manifest.manifests?.[0]?.digest ?? "",
|
||||
token,
|
||||
);
|
||||
const configDigest = imageManifest.config?.digest;
|
||||
if (!configDigest) throw new Error("Missing image config digest");
|
||||
|
||||
const response = await fetchProvider(buildUrl(registryUrl.origin, `/v2/${repositoryName}/blobs/${configDigest}`), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
|
||||
const result = gitHubContainerRegistryConfigSchema.safeParse(await response.json());
|
||||
if (!result.success) throw new Error(result.error.message);
|
||||
|
||||
return result.data.created;
|
||||
};
|
||||
|
||||
const getGitHubContainerRegistryManifestAsync = async (
|
||||
registryUrl: URL,
|
||||
repositoryName: string,
|
||||
reference: string,
|
||||
token: string,
|
||||
) => {
|
||||
if (!reference) throw new Error("Missing image manifest reference");
|
||||
|
||||
const response = await fetchProvider(buildUrl(registryUrl.origin, `/v2/${repositoryName}/manifests/${reference}`), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: [
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
].join(", "),
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
|
||||
const result = gitHubContainerRegistryManifestSchema.safeParse(await response.json());
|
||||
if (!result.success) throw new Error(result.error.message);
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const dockerHubReleasesSchema = z.object({
|
||||
results: z.array(
|
||||
z.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) }).transform((tag) => ({
|
||||
latestRelease: tag.name,
|
||||
latestReleaseAt: tag.last_updated,
|
||||
})),
|
||||
),
|
||||
});
|
||||
|
||||
const dockerHubDetailsSchema = z.object({
|
||||
name: z.string(),
|
||||
namespace: z.string(),
|
||||
description: z.string(),
|
||||
star_count: z.number(),
|
||||
date_registered: z.string().transform((value) => new Date(value)),
|
||||
});
|
||||
|
||||
const getDockerHubReleaseAsync = async (
|
||||
baseUrl: string,
|
||||
identifier: string,
|
||||
versionRegex?: string,
|
||||
token?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
const parsed = identifier.includes("/") ? parseOwnerName(identifier) : { owner: "", name: identifier };
|
||||
if (!parsed) return { success: false, error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}"` } };
|
||||
|
||||
const relativeUrl = parsed.owner
|
||||
? `/v2/namespaces/${encodeURIComponent(parsed.owner)}/repositories/${encodeURIComponent(parsed.name)}`
|
||||
: `/v2/repositories/library/${encodeURIComponent(parsed.name)}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
let lastNoMatch = "";
|
||||
for (let page = 0; page <= 5; page++) {
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `${relativeUrl}/tags?page_size=100&page=${page}` as `/${string}`),
|
||||
headers.Authorization ? { headers } : undefined,
|
||||
);
|
||||
if (!response.ok)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "unexpected", message: `Docker Hub ${response.status}: ${response.statusText}` },
|
||||
};
|
||||
|
||||
const json: unknown = await response.json();
|
||||
const result = dockerHubReleasesSchema.safeParse(json);
|
||||
if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } };
|
||||
|
||||
const matched = getLatestRelease(result.data.results, versionRegex);
|
||||
if ("noMatch" in matched) {
|
||||
lastNoMatch = matched.noMatch;
|
||||
continue;
|
||||
}
|
||||
|
||||
const details = await getDockerHubDetailsAsync(baseUrl, relativeUrl as `/${string}`, token);
|
||||
return { success: true, data: { ...details, ...matched.release } };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "noMatchingVersion", message: lastNoMatch || `No matching tags found for ${identifier}` },
|
||||
};
|
||||
};
|
||||
|
||||
const getDockerHubDetailsAsync = async (
|
||||
baseUrl: string,
|
||||
relativeUrl: `/${string}`,
|
||||
token?: string,
|
||||
): Promise<DetailsProviderResponse> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `${relativeUrl}/` as `/${string}`),
|
||||
headers.Authorization ? { headers } : undefined,
|
||||
);
|
||||
if (!response.ok) return {};
|
||||
|
||||
const result = dockerHubDetailsSchema.safeParse(await response.json());
|
||||
if (!result.success) return {};
|
||||
|
||||
return {
|
||||
projectUrl: `https://hub.docker.com/r/${result.data.namespace}/${result.data.name}`,
|
||||
projectDescription: result.data.description,
|
||||
createdAt: result.data.date_registered,
|
||||
starsCount: result.data.star_count,
|
||||
};
|
||||
};
|
||||
|
||||
const gitlabReleasesSchema = z.array(
|
||||
z.object({
|
||||
name: z.string().nullable().optional(),
|
||||
tag_name: z.string(),
|
||||
released_at: z.string().nullable().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
_links: z.object({ self: z.string().optional() }).optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
const gitlabDetailsSchema = z.object({
|
||||
web_url: z.string().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
forked_from_project: z.unknown().nullable().optional(),
|
||||
archived: z.boolean(),
|
||||
created_at: z.string(),
|
||||
star_count: z.number(),
|
||||
open_issues_count: z.number().optional(),
|
||||
forks_count: z.number(),
|
||||
});
|
||||
|
||||
const getGitlabReleaseAsync = async (
|
||||
baseUrl: string,
|
||||
identifier: string,
|
||||
versionRegex?: string,
|
||||
token?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
const encodedIdentifier = encodeURIComponent(identifier);
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["PRIVATE-TOKEN"] = token;
|
||||
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}/releases?per_page=100`),
|
||||
headers["PRIVATE-TOKEN"] ? { headers } : undefined,
|
||||
);
|
||||
if (!response.ok)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "unexpected", message: `GitLab ${response.status}: ${response.statusText}` },
|
||||
};
|
||||
|
||||
const result = gitlabReleasesSchema.safeParse(await response.json());
|
||||
if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } };
|
||||
if (result.data.length === 0)
|
||||
return { success: false, error: { code: "noReleasesFound", message: `${identifier} has no GitLab releases` } };
|
||||
|
||||
const releases = result.data.reduce<ReleaseProviderResponse[]>((acc, release) => {
|
||||
if (!release.released_at) return acc;
|
||||
const releaseDate = new Date(release.released_at);
|
||||
acc.push({
|
||||
latestRelease: release.tag_name,
|
||||
latestReleaseAt: releaseDate,
|
||||
releaseUrl: release._links?.self,
|
||||
releaseDescription: release.description ?? undefined,
|
||||
isPreRelease: releaseDate > new Date(),
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
const matched = getLatestRelease(releases, versionRegex);
|
||||
if ("noMatch" in matched) return { success: false, error: { code: "noMatchingVersion", message: matched.noMatch } };
|
||||
|
||||
const details = await getGitlabDetailsAsync(baseUrl, encodedIdentifier, token);
|
||||
return { success: true, data: { ...details, ...matched.release } };
|
||||
};
|
||||
|
||||
const getGitlabDetailsAsync = async (
|
||||
baseUrl: string,
|
||||
encodedIdentifier: string,
|
||||
token?: string,
|
||||
): Promise<DetailsProviderResponse> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["PRIVATE-TOKEN"] = token;
|
||||
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}`),
|
||||
headers["PRIVATE-TOKEN"] ? { headers } : undefined,
|
||||
);
|
||||
if (!response.ok) return {};
|
||||
|
||||
const result = gitlabDetailsSchema.safeParse(await response.json());
|
||||
if (!result.success || !result.data.web_url) return {};
|
||||
|
||||
return {
|
||||
projectUrl: result.data.web_url,
|
||||
projectDescription: result.data.description ?? undefined,
|
||||
isFork: result.data.forked_from_project !== null,
|
||||
isArchived: result.data.archived,
|
||||
createdAt: new Date(result.data.created_at),
|
||||
starsCount: result.data.star_count,
|
||||
openIssues: result.data.open_issues_count,
|
||||
forksCount: result.data.forks_count,
|
||||
};
|
||||
};
|
||||
|
||||
const npmTimeMetadataKeys = new Set(["created", "modified"]);
|
||||
|
||||
const npmReleasesSchema = z.object({
|
||||
time: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.string().transform((value) => new Date(value)),
|
||||
)
|
||||
.transform((entries) =>
|
||||
Object.entries(entries)
|
||||
.filter(([key]) => !npmTimeMetadataKeys.has(key))
|
||||
.map(([key, value]) => ({ latestRelease: key, latestReleaseAt: value })),
|
||||
),
|
||||
versions: z.record(z.string(), z.object({ description: z.string().optional() })),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const getNpmReleaseAsync = async (
|
||||
baseUrl: string,
|
||||
identifier: string,
|
||||
versionRegex?: string,
|
||||
token?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
if (!identifier) return { success: false, error: { code: "invalidIdentifier", message: "Empty npm package name" } };
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `/${encodeURIComponent(identifier)}`),
|
||||
headers.Authorization ? { headers } : undefined,
|
||||
);
|
||||
if (!response.ok)
|
||||
return { success: false, error: { code: "unexpected", message: `npm ${response.status}: ${response.statusText}` } };
|
||||
|
||||
const result = npmReleasesSchema.safeParse(await response.json());
|
||||
if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } };
|
||||
|
||||
const releases = result.data.time.map((tag) => ({
|
||||
...tag,
|
||||
releaseUrl: `https://www.npmjs.com/package/${encodeURIComponent(result.data.name)}/v/${encodeURIComponent(tag.latestRelease)}`,
|
||||
releaseDescription: result.data.versions[tag.latestRelease]?.description ?? "",
|
||||
}));
|
||||
const matched = getLatestRelease(releases, versionRegex);
|
||||
if ("noMatch" in matched) return { success: false, error: { code: "noMatchingVersion", message: matched.noMatch } };
|
||||
|
||||
return { success: true, data: matched.release };
|
||||
};
|
||||
|
||||
const codebergReleasesSchema = z.array(
|
||||
z.object({
|
||||
tag_name: z.string(),
|
||||
published_at: z.string().transform((value) => new Date(value)),
|
||||
url: z.string(),
|
||||
body: z.string(),
|
||||
prerelease: z.boolean(),
|
||||
}),
|
||||
);
|
||||
|
||||
const codebergDetailsSchema = z.object({
|
||||
html_url: z.string(),
|
||||
description: z.string(),
|
||||
fork: z.boolean(),
|
||||
archived: z.boolean(),
|
||||
created_at: z.string().transform((value) => new Date(value)),
|
||||
stars_count: z.number(),
|
||||
open_issues_count: z.number(),
|
||||
forks_count: z.number(),
|
||||
});
|
||||
|
||||
const getCodebergReleaseAsync = async (
|
||||
baseUrl: string,
|
||||
identifier: string,
|
||||
versionRegex?: string,
|
||||
token?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
const parsed = parseOwnerName(identifier);
|
||||
if (!parsed)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/repo` },
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `token ${token}`;
|
||||
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `/api/v1/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}/releases`),
|
||||
headers.Authorization ? { headers } : undefined,
|
||||
);
|
||||
if (!response.ok)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "unexpected", message: `Codeberg ${response.status}: ${response.statusText}` },
|
||||
};
|
||||
|
||||
const result = codebergReleasesSchema.safeParse(await response.json());
|
||||
if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } };
|
||||
|
||||
const releases = result.data.map((tag) => ({
|
||||
latestRelease: tag.tag_name,
|
||||
latestReleaseAt: tag.published_at,
|
||||
releaseUrl: tag.url,
|
||||
releaseDescription: tag.body,
|
||||
isPreRelease: tag.prerelease,
|
||||
}));
|
||||
const matched = getLatestRelease(releases, versionRegex);
|
||||
if ("noMatch" in matched) return { success: false, error: { code: "noMatchingVersion", message: matched.noMatch } };
|
||||
|
||||
const details = await getCodebergDetailsAsync(baseUrl, parsed.owner, parsed.name, token);
|
||||
return { success: true, data: { ...details, ...matched.release } };
|
||||
};
|
||||
|
||||
const getCodebergDetailsAsync = async (
|
||||
baseUrl: string,
|
||||
owner: string,
|
||||
name: string,
|
||||
token?: string,
|
||||
): Promise<DetailsProviderResponse> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `token ${token}`;
|
||||
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`),
|
||||
headers.Authorization ? { headers } : undefined,
|
||||
);
|
||||
if (!response.ok) return {};
|
||||
|
||||
const result = codebergDetailsSchema.safeParse(await response.json());
|
||||
if (!result.success) return {};
|
||||
|
||||
return {
|
||||
projectUrl: result.data.html_url,
|
||||
projectDescription: result.data.description,
|
||||
isFork: result.data.fork,
|
||||
isArchived: result.data.archived,
|
||||
createdAt: result.data.created_at,
|
||||
starsCount: result.data.stars_count,
|
||||
openIssues: result.data.open_issues_count,
|
||||
forksCount: result.data.forks_count,
|
||||
};
|
||||
};
|
||||
|
||||
const linuxServerIOReleasesSchema = z.object({
|
||||
data: z.object({
|
||||
repositories: z.object({
|
||||
linuxserver: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
initial_date: z
|
||||
.string()
|
||||
.transform((value) => new Date(value))
|
||||
.optional(),
|
||||
github_url: z.string(),
|
||||
description: z.string(),
|
||||
version: z.string(),
|
||||
version_timestamp: z.string().transform((value) => new Date(value)),
|
||||
stars: z.number(),
|
||||
deprecated: z.boolean(),
|
||||
changelog: z.array(z.object({ date: z.string(), desc: z.string() })).optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const getLinuxServerIOReleaseAsync = async (
|
||||
baseUrl: string,
|
||||
identifier: string,
|
||||
versionRegex?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
const parsed = parseOwnerName(identifier);
|
||||
if (!parsed)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/name` },
|
||||
};
|
||||
|
||||
const response = await fetchProvider(buildUrl(baseUrl, "/api/v1/images"));
|
||||
if (!response.ok)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "unexpected", message: `LSIO ${response.status}: ${response.statusText}` },
|
||||
};
|
||||
|
||||
const result = linuxServerIOReleasesSchema.safeParse(await response.json());
|
||||
if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } };
|
||||
|
||||
const release = result.data.data.repositories.linuxserver.find((repo) => repo.name === parsed.name);
|
||||
if (!release)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` },
|
||||
};
|
||||
|
||||
const regex = compileVersionRegex(versionRegex);
|
||||
if (versionRegex && !regex) {
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "noMatchingVersion", message: `Invalid regex: /${versionRegex}/` },
|
||||
};
|
||||
}
|
||||
|
||||
if (regex && !regex.test(release.version)) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "noMatchingVersion",
|
||||
message: `Regex /${versionRegex}/ does not match LSIO version "${release.version}"`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
latestRelease: release.version,
|
||||
latestReleaseAt: release.version_timestamp,
|
||||
releaseDescription: release.changelog?.shift()?.desc,
|
||||
projectUrl: release.github_url,
|
||||
projectDescription: release.description,
|
||||
isArchived: release.deprecated,
|
||||
createdAt: release.initial_date,
|
||||
starsCount: release.stars,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const quayReleasesSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
tags: z.record(z.string(), z.object({ name: z.string(), last_modified: z.string() })),
|
||||
});
|
||||
|
||||
const getQuayReleaseAsync = async (
|
||||
baseUrl: string,
|
||||
identifier: string,
|
||||
versionRegex?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
const parsed = parseOwnerName(identifier);
|
||||
if (!parsed)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/repo` },
|
||||
};
|
||||
|
||||
const response = await fetchProvider(
|
||||
buildUrl(
|
||||
baseUrl,
|
||||
`/api/v1/repository/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}?includeTags=true&includeStats=true`,
|
||||
),
|
||||
);
|
||||
if (!response.ok)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "unexpected", message: `Quay ${response.status}: ${response.statusText}` },
|
||||
};
|
||||
|
||||
const result = quayReleasesSchema.safeParse(await response.json());
|
||||
if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } };
|
||||
|
||||
const releases = Object.values(result.data.tags).reduce<ReleaseProviderResponse[]>((acc, tag) => {
|
||||
if (!tag.name || !tag.last_modified) return acc;
|
||||
acc.push({
|
||||
latestRelease: tag.name,
|
||||
latestReleaseAt: new Date(tag.last_modified),
|
||||
releaseUrl: `https://quay.io/repository/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}/tag/${encodeURIComponent(tag.name)}`,
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
const matched = getLatestRelease(releases, versionRegex);
|
||||
if ("noMatch" in matched) return { success: false, error: { code: "noMatchingVersion", message: matched.noMatch } };
|
||||
|
||||
return { success: true, data: { projectDescription: result.data.description, ...matched.release } };
|
||||
};
|
||||
@@ -1,20 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { ReleaseResponse, ReleasesRepositoryRequest } from "./release-providers";
|
||||
import { getLatestMatchingReleaseAsync } from "./release-providers";
|
||||
import { createRequestHandler } from "./lib/request-handler";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { ReleaseResponse, ReleasesRepository } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
export const releasesRequestHandler = createCachedIntegrationRequestHandler<
|
||||
ReleaseResponse,
|
||||
IntegrationKindByCategory<"releasesProvider">,
|
||||
ReleasesRepository
|
||||
>({
|
||||
requestAsync: async (integration, input) => {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return instance.getLatestMatchingReleaseAsync(input.identifier, input.versionRegex);
|
||||
export const releasesRequestHandler = createRequestHandler<ReleaseResponse, ReleasesRepositoryRequest>({
|
||||
async requestAsync(input) {
|
||||
return await getLatestMatchingReleaseAsync(input);
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "repositoriesReleases",
|
||||
cacheTtlMs: 5 * 60 * 1000,
|
||||
});
|
||||
@@ -1,18 +1,15 @@
|
||||
import type { FeedData, FeedEntry } from "@extractus/feed-extractor";
|
||||
import { extract } from "@extractus/feed-extractor";
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "rssFeedsRequestHandler" });
|
||||
|
||||
export const rssFeedsRequestHandler = createCachedWidgetRequestHandler({
|
||||
queryKey: "rssFeedList",
|
||||
widgetKind: "rssFeed",
|
||||
export const rssFeedsRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { url: string; count: number }) {
|
||||
const result = (await extract(input.url, {
|
||||
getExtraEntryFields: (feedEntry) => {
|
||||
@@ -31,7 +28,6 @@ export const rssFeedsRequestHandler = createCachedWidgetRequestHandler({
|
||||
entries: result.entries?.map((entry) => ({ ...entry, feedUrl: input.url })).slice(0, input.count) ?? [],
|
||||
};
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
});
|
||||
|
||||
const attemptGetImageFromEntry = (feedUrl: string, entry: object) => {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const smartHomeEntityStateRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandler<
|
||||
string,
|
||||
IntegrationKindByCategory<"smartHomeServer">,
|
||||
{ entityId: string }
|
||||
@@ -20,6 +18,4 @@ export const smartHomeEntityStateRequestHandler = createCachedIntegrationRequest
|
||||
|
||||
return result.data.state;
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "minute"),
|
||||
queryKey: "smartHome-entityState",
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const speedtestTrackerRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const speedtestTrackerRequestHandler = createIntegrationRequestHandler<
|
||||
SpeedtestTrackerDashboardData,
|
||||
"speedtestTracker",
|
||||
Record<string, never>
|
||||
@@ -14,6 +12,4 @@ export const speedtestTrackerRequestHandler = createCachedIntegrationRequestHand
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getDashboardDataAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(10, "minutes"),
|
||||
queryKey: "speedtestTrackerDashboard",
|
||||
});
|
||||
@@ -1,14 +1,11 @@
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
export const fetchStockPriceHandler = createCachedWidgetRequestHandler({
|
||||
queryKey: "fetchStockPriceResult",
|
||||
widgetKind: "stockPrice",
|
||||
export const fetchStockPriceHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) {
|
||||
const response = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
@@ -42,7 +39,6 @@ export const fetchStockPriceHandler = createCachedWidgetRequestHandler({
|
||||
shortName: firstResult.meta.shortName,
|
||||
};
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
});
|
||||
|
||||
const dataSchema = z
|
||||
|
||||
@@ -75,6 +75,26 @@ describe("calculateCpuUsage", () => {
|
||||
// (500 / 100000) * 2 * 100 = 1
|
||||
expect(calculateCpuUsage(stats)).toBe(1);
|
||||
});
|
||||
|
||||
test("should use the delta against precpu_stats for the current usage", () => {
|
||||
const stats = createStats({
|
||||
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_002_000 }, system_cpu_usage: 20_000 },
|
||||
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
|
||||
});
|
||||
// Despite a large lifetime total_usage, only the recent delta counts:
|
||||
// (2000 / 10000) * 4 * 100 = 80
|
||||
expect(calculateCpuUsage(stats)).toBe(80);
|
||||
});
|
||||
|
||||
test("should report 0 for a container that was busy over its lifetime but is idle now", () => {
|
||||
const stats = createStats({
|
||||
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 20_000 },
|
||||
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
|
||||
});
|
||||
// No CPU delta since the last reading -> current usage is 0, even though the
|
||||
// lifetime average would be high. This is the regression the fix addresses.
|
||||
expect(calculateCpuUsage(stats)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateMemoryUsage", () => {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
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);
|
||||
|
||||
describe("getLatestMatchingReleaseAsync for GitHub Container Registry", () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
});
|
||||
|
||||
test("normalizes full image refs and returns latest from anonymous OCI registry metadata", async () => {
|
||||
mockGhcrResponses({
|
||||
repositoryName: "homarr-labs/homarr",
|
||||
tags: ["v1.0.0", "latest"],
|
||||
createdAtByTag: {
|
||||
latest: "2026-06-19T20:30:59.853Z",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getLatestMatchingReleaseAsync({
|
||||
id: "homarr",
|
||||
provider: "gitHubContainerRegistry",
|
||||
identifier: "ghcr.io/homarr-labs/homarr:latest",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
projectUrl: "https://github.com/homarr-labs/homarr",
|
||||
latestRelease: "latest",
|
||||
latestReleaseAt: new Date("2026-06-19T20:30:59.853Z"),
|
||||
releaseUrl: "https://github.com/homarr-labs/homarr/pkgs/container/homarr",
|
||||
},
|
||||
});
|
||||
expectUrl(0, "https://ghcr.io/token");
|
||||
expect(new URL(mockedFetch.mock.calls[0]?.[0].toString() ?? "").searchParams.get("service")).toBe("ghcr.io");
|
||||
expect(new URL(mockedFetch.mock.calls[0]?.[0].toString() ?? "").searchParams.get("scope")).toBe(
|
||||
"repository:homarr-labs/homarr:pull",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses custom registry base URL for GHCR-compatible registries", async () => {
|
||||
mockGhcrResponses({
|
||||
registryOrigin: "https://registry.example.com",
|
||||
repositoryName: "homarr-labs/homarr",
|
||||
tags: ["latest"],
|
||||
createdAtByTag: {
|
||||
latest: "2026-06-19T20:30:59.853Z",
|
||||
},
|
||||
});
|
||||
|
||||
await getLatestMatchingReleaseAsync({
|
||||
id: "homarr",
|
||||
provider: "gitHubContainerRegistry",
|
||||
identifier: "homarr-labs/homarr",
|
||||
providerUrl: "https://registry.example.com/",
|
||||
});
|
||||
|
||||
expectUrl(0, "https://registry.example.com/token");
|
||||
expect(new URL(mockedFetch.mock.calls[0]?.[0].toString() ?? "").searchParams.get("service")).toBe(
|
||||
"registry.example.com",
|
||||
);
|
||||
expectUrl(1, "https://registry.example.com/v2/homarr-labs/homarr/tags/list");
|
||||
});
|
||||
|
||||
test("chooses the newest matching version tag", async () => {
|
||||
mockGhcrResponses({
|
||||
repositoryName: "muchobien/pocketbase",
|
||||
tags: ["0.22.0", "0.23.0", "latest"],
|
||||
createdAtByTag: {
|
||||
"0.22.0": "2026-01-01T00:00:00.000Z",
|
||||
"0.23.0": "2026-02-01T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getLatestMatchingReleaseAsync({
|
||||
id: "pocketbase",
|
||||
provider: "gitHubContainerRegistry",
|
||||
identifier: "ghcr.io/muchobien/pocketbase:latest",
|
||||
versionRegex: "^0\\.[0-9]+\\.[0-9]+$",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.data.latestRelease).toBe("0.23.0");
|
||||
expect(result.data.latestReleaseAt).toEqual(new Date("2026-02-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
test("returns invalidIdentifier for missing package name", async () => {
|
||||
const result = await getLatestMatchingReleaseAsync({
|
||||
id: "invalid",
|
||||
provider: "gitHubContainerRegistry",
|
||||
identifier: "ghcr.io/homarr-labs",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.objectContaining({ code: "invalidIdentifier" }),
|
||||
});
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns unexpected with provider error message", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(createJsonResponse({ errors: [] }, { status: 500, statusText: "Registry down" }));
|
||||
|
||||
const result = await getLatestMatchingReleaseAsync({
|
||||
id: "homarr",
|
||||
provider: "gitHubContainerRegistry",
|
||||
identifier: "homarr-labs/homarr",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: { code: "unexpected", message: "Registry down" } });
|
||||
});
|
||||
});
|
||||
|
||||
interface MockGhcrResponsesInput {
|
||||
registryOrigin?: string;
|
||||
repositoryName: string;
|
||||
tags: string[];
|
||||
createdAtByTag: Record<string, string>;
|
||||
}
|
||||
|
||||
const mockGhcrResponses = ({
|
||||
registryOrigin = "https://ghcr.io",
|
||||
repositoryName,
|
||||
tags,
|
||||
createdAtByTag,
|
||||
}: MockGhcrResponsesInput) => {
|
||||
mockedFetch.mockImplementation(async (input) => {
|
||||
const url = new URL(input.toString());
|
||||
const path = url.pathname;
|
||||
|
||||
if (url.origin !== registryOrigin) {
|
||||
return createJsonResponse({ error: "unexpected origin" }, { status: 404, statusText: "Not Found" });
|
||||
}
|
||||
|
||||
if (path === "/token") return createJsonResponse({ token: "token" });
|
||||
if (path === `/v2/${repositoryName}/tags/list`) return createJsonResponse({ name: repositoryName, tags });
|
||||
|
||||
const manifestMatch = new RegExp(`^/v2/${escapeRegExp(repositoryName)}/manifests/(.+)$`).exec(path);
|
||||
if (manifestMatch) {
|
||||
const reference = decodeURIComponent(manifestMatch[1] ?? "");
|
||||
if (reference.startsWith("sha256:")) {
|
||||
return createJsonResponse({ config: { digest: `sha256:config-${reference.slice("sha256:".length)}` } });
|
||||
}
|
||||
return createJsonResponse({
|
||||
manifests: [{ digest: `sha256:${reference}` }],
|
||||
});
|
||||
}
|
||||
|
||||
const blobMatch = new RegExp(`^/v2/${escapeRegExp(repositoryName)}/blobs/sha256:config-(.+)$`).exec(path);
|
||||
if (blobMatch) {
|
||||
const tag = decodeURIComponent(blobMatch[1] ?? "");
|
||||
return createJsonResponse({ created: createdAtByTag[tag] ?? "2026-01-01T00:00:00.000Z" });
|
||||
}
|
||||
|
||||
return createJsonResponse({ error: "not found" }, { status: 404, statusText: "Not Found" });
|
||||
});
|
||||
};
|
||||
|
||||
interface JsonResponseInit {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
}
|
||||
|
||||
const createJsonResponse = (body: unknown, init: JsonResponseInit = {}) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: init.status ?? 200,
|
||||
statusText: init.statusText,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const expectUrl = (callIndex: number, expectedUrl: string) => {
|
||||
expect(mockedFetch.mock.calls[callIndex]?.[0].toString().split("?")[0]).toBe(expectedUrl);
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
@@ -1,36 +1,154 @@
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { Station, Timetable } from "@homarr/integrations/types";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createRequestHandler } from "./lib/request-handler";
|
||||
|
||||
export const timetableSearchStationsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export interface Station {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TimetableEntry {
|
||||
timestamp: Date;
|
||||
delay: number;
|
||||
line: {
|
||||
name: string;
|
||||
color: string | null;
|
||||
} | null;
|
||||
location: string;
|
||||
platform: {
|
||||
name: string;
|
||||
hasChanged: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Timetable {
|
||||
stationId: string;
|
||||
timestamp: Date;
|
||||
entries: TimetableEntry[];
|
||||
}
|
||||
|
||||
const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"];
|
||||
|
||||
export const timetableSearchStationsRequestHandler = createRequestHandler<
|
||||
Station[],
|
||||
IntegrationKindByCategory<"timetable">,
|
||||
{ query: string }
|
||||
{ baseUrl: string; query: string }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.searchStationsAsync(input.query);
|
||||
async requestAsync(input) {
|
||||
return await searchStationsAsync(input.baseUrl, input.query);
|
||||
},
|
||||
queryKey: "timetableSearchStations",
|
||||
cacheDuration: dayjs.duration(1, "day"),
|
||||
cacheTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
export const timetableGetTimetableRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const timetableGetTimetableRequestHandler = createRequestHandler<
|
||||
Timetable,
|
||||
IntegrationKindByCategory<"timetable">,
|
||||
{ stationId: string; limit: number }
|
||||
{ baseUrl: string; stationId: string; limit: number }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getTimetableAsync({
|
||||
stationId: input.stationId,
|
||||
limit: input.limit,
|
||||
});
|
||||
async requestAsync(input) {
|
||||
return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit });
|
||||
},
|
||||
queryKey: "timetableGetTimetable",
|
||||
cacheDuration: dayjs.duration(1, "minute"),
|
||||
cacheTtlMs: 60_000,
|
||||
});
|
||||
|
||||
const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record<string, string | number>) => {
|
||||
const url = new URL(`${baseUrl.replace(/\/$/, "")}${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 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 getTimetableAsync = async (
|
||||
baseUrl: string,
|
||||
options: { stationId: string; limit: number },
|
||||
): Promise<Timetable> => {
|
||||
const now = new Date();
|
||||
const date = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Zurich",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(now);
|
||||
const time = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "Europe/Zurich",
|
||||
hour: "2-digit",
|
||||
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);
|
||||
|
||||
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 timetableSchema = z.object({
|
||||
connections: z.array(
|
||||
z.object({
|
||||
time: z.string().transform((str) => new Date(str)),
|
||||
line: z.string().optional(),
|
||||
color: z.string(),
|
||||
terminal: z.object({ name: z.string() }),
|
||||
track: z.string().optional(),
|
||||
dep_delay: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((str) => (str ? parseInt(str) : 0)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const searchSchema = z.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
label: z.string(),
|
||||
iconclass: z.string(),
|
||||
}),
|
||||
);
|
||||
@@ -1,11 +1,9 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { TracearrDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const tracearrRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const tracearrRequestHandler = createIntegrationRequestHandler<
|
||||
TracearrDashboardData,
|
||||
"tracearr",
|
||||
Record<string, never>
|
||||
@@ -14,6 +12,4 @@ export const tracearrRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getDashboardDataAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
queryKey: "tracearrDashboard",
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { TraefikDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const traefikRequestHandler = createIntegrationRequestHandler<
|
||||
TraefikDashboardData,
|
||||
"traefik",
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getDashboardDataAsync();
|
||||
},
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const umamiRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const umamiRequestHandler = createIntegrationRequestHandler<
|
||||
UmamiVisitorStats,
|
||||
"umami",
|
||||
{ websiteId: string; timeFrame: string; eventName?: string }
|
||||
@@ -14,24 +12,16 @@ export const umamiRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getVisitorStatsAsync(input.websiteId, input.timeFrame, input.eventName);
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "umamiVisitorStats",
|
||||
});
|
||||
|
||||
export const umamiEventNamesRequestHandler = createCachedIntegrationRequestHandler<
|
||||
string[],
|
||||
"umami",
|
||||
{ websiteId: string }
|
||||
>({
|
||||
export const umamiEventNamesRequestHandler = createIntegrationRequestHandler<string[], "umami", { websiteId: string }>({
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getEventNamesAsync(input.websiteId);
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "umamiEventNames",
|
||||
});
|
||||
|
||||
export const umamiTopPagesRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const umamiTopPagesRequestHandler = createIntegrationRequestHandler<
|
||||
UmamiMetricItem[],
|
||||
"umami",
|
||||
{ websiteId: string; timeFrame: string; limit: number }
|
||||
@@ -40,11 +30,9 @@ export const umamiTopPagesRequestHandler = createCachedIntegrationRequestHandler
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getTopPagesAsync(input.websiteId, input.timeFrame, input.limit);
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "umamiTopPages",
|
||||
});
|
||||
|
||||
export const umamiTopReferrersRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const umamiTopReferrersRequestHandler = createIntegrationRequestHandler<
|
||||
UmamiMetricItem[],
|
||||
"umami",
|
||||
{ websiteId: string; timeFrame: string; limit: number }
|
||||
@@ -53,11 +41,9 @@ export const umamiTopReferrersRequestHandler = createCachedIntegrationRequestHan
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getTopReferrersAsync(input.websiteId, input.timeFrame, input.limit);
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "umamiTopReferrers",
|
||||
});
|
||||
|
||||
export const umamiMultiEventRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const umamiMultiEventRequestHandler = createIntegrationRequestHandler<
|
||||
UmamiEventSeries[],
|
||||
"umami",
|
||||
{ websiteId: string; timeFrame: string; eventNames: string[] }
|
||||
@@ -66,11 +52,9 @@ export const umamiMultiEventRequestHandler = createCachedIntegrationRequestHandl
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getMultiEventTimeSeriesAsync(input.websiteId, input.timeFrame, input.eventNames);
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "umamiMultiEvent",
|
||||
});
|
||||
|
||||
export const umamiActiveVisitorsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const umamiActiveVisitorsRequestHandler = createIntegrationRequestHandler<
|
||||
number,
|
||||
"umami",
|
||||
{ websiteId: string }
|
||||
@@ -79,6 +63,4 @@ export const umamiActiveVisitorsRequestHandler = createCachedIntegrationRequestH
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getActiveVisitorsAsync(input.websiteId);
|
||||
},
|
||||
cacheDuration: dayjs.duration(30, "seconds"),
|
||||
queryKey: "umamiActiveVisitors",
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import dayjs from "dayjs";
|
||||
import { Octokit } from "octokit";
|
||||
import { compareSemVer, isValidSemVer, parseSemVer } from "semver-parser";
|
||||
|
||||
@@ -6,27 +5,18 @@ import { env } from "@homarr/common/env";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createChannelWithLatestAndEvents } from "@homarr/redis";
|
||||
import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
|
||||
import { createRequestHandler } from "./lib/request-handler";
|
||||
|
||||
import packageJson from "../../../package.json";
|
||||
|
||||
const logger = createLogger({ module: "updateCheckerRequestHandler" });
|
||||
|
||||
export const updateCheckerRequestHandler = createCachedRequestHandler({
|
||||
queryKey: "homarr-update-checker",
|
||||
cacheDuration: dayjs.duration(1, "day"),
|
||||
fallbackToStaleOnError: true,
|
||||
export const updateCheckerRequestHandler = createRequestHandler({
|
||||
async requestAsync(_) {
|
||||
return {
|
||||
availableUpdates: await getAvailableUpdatesAsync(packageJson.version),
|
||||
};
|
||||
},
|
||||
createRedisChannel() {
|
||||
return createChannelWithLatestAndEvents<{
|
||||
availableUpdates: Update[];
|
||||
}>("homarr:update");
|
||||
const availableUpdates = await getAvailableUpdatesAsync(packageJson.version);
|
||||
return { availableUpdates };
|
||||
},
|
||||
cacheTtlMs: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
interface Update {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { UpsSummary } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const upsSummariesRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const upsSummariesRequestHandler = createIntegrationRequestHandler<
|
||||
UpsSummary[],
|
||||
IntegrationKindByCategory<"ups">,
|
||||
Record<string, never>
|
||||
@@ -15,6 +13,4 @@ export const upsSummariesRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getUpsSummariesAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "minute"),
|
||||
queryKey: "upsSummaries",
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const uptimeKumaRequestHandler = createCachedIntegrationRequestHandler<
|
||||
export const uptimeKumaRequestHandler = createIntegrationRequestHandler<
|
||||
UptimeKumaDashboardData,
|
||||
"uptimeKuma",
|
||||
Record<string, never>
|
||||
@@ -14,6 +12,4 @@ export const uptimeKumaRequestHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getDashboardDataAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "minute"),
|
||||
queryKey: "uptimeKumaDashboard",
|
||||
});
|
||||
@@ -1,15 +1,13 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { VpnSummary } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "vpnRequestHandler" });
|
||||
|
||||
export const vpnSummaryHandler = createCachedIntegrationRequestHandler<
|
||||
export const vpnSummaryHandler = createIntegrationRequestHandler<
|
||||
VpnSummary | null,
|
||||
IntegrationKindByCategory<"vpn">,
|
||||
Record<string, never>
|
||||
@@ -19,9 +17,6 @@ export const vpnSummaryHandler = createCachedIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getSummaryAsync();
|
||||
} catch (error) {
|
||||
// The VPN control server is unreachable (e.g. the container is down).
|
||||
// Return null so the widget can render an "unavailable" status instead of
|
||||
// surfacing a hard error, and so it recovers automatically once it is back up.
|
||||
logger.warn("Failed to fetch VPN summary, marking as unavailable", {
|
||||
integrationId: integration.id,
|
||||
integrationKind: integration.kind,
|
||||
@@ -30,6 +25,4 @@ export const vpnSummaryHandler = createCachedIntegrationRequestHandler<
|
||||
return null;
|
||||
}
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "vpnSummary",
|
||||
});
|
||||
@@ -1,14 +1,11 @@
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
export const weatherRequestHandler = createCachedWidgetRequestHandler({
|
||||
queryKey: "weatherAtLocation",
|
||||
widgetKind: "weather",
|
||||
export const weatherRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { latitude: number; longitude: number }) {
|
||||
const res = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
@@ -34,7 +31,6 @@ export const weatherRequestHandler = createCachedWidgetRequestHandler({
|
||||
}),
|
||||
} satisfies Weather;
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "minute"),
|
||||
});
|
||||
|
||||
const atLocationOutput = z.object({
|
||||
|
||||
Reference in new issue
Block a user