diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 1520375f6..ae8f57903 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -71,7 +71,7 @@ export function TRPCReactProvider(props: PropsWithChildren) { gcTime: queryCacheDefaultGcTimeMs, refetchOnWindowFocus: false, refetchOnReconnect: false, - retry: false, + retry: 3, }, mutations: { onError(error) { diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx index 0a97d1c84..208d90a4f 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx @@ -156,8 +156,6 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"][" const { data } = clientApi.docker.getContainers.useQuery(undefined, { initialData: { containers, timestamp }, refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, }); const relativeTime = useTimeAgo(data.timestamp); const utils = clientApi.useUtils(); diff --git a/packages/api/src/router/query-cache.ts b/packages/api/src/router/query-cache.ts index 910525ac9..875c96135 100644 --- a/packages/api/src/router/query-cache.ts +++ b/packages/api/src/router/query-cache.ts @@ -25,7 +25,7 @@ export const queryCacheRouter = createTRPCRouter({ await validateBoardAccessAsync(ctx, input.boardId); const userId = ctx.session?.user.id ?? "anonymous"; return { - stored: await setQueryCacheAsync(userId, input.boardId, input.value, queryCacheDefaultGcTimeMs), + stored: await setQueryCacheAsync(userId, input.boardId, input.value, queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes), }; }), removeItem: publicProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => { diff --git a/packages/api/src/test/query-cache.spec.ts b/packages/api/src/test/query-cache.spec.ts index ad3ea1091..8069b239d 100644 --- a/packages/api/src/test/query-cache.spec.ts +++ b/packages/api/src/test/query-cache.spec.ts @@ -19,8 +19,8 @@ const redisMock = vi.hoisted(() => { return { values, - setQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string, value: string, _ttlMs: number) => { - if (Buffer.byteLength(value, "utf8") > 1024 * 1024) return false; + setQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string, value: string, _ttlMs: number, maxValueBytes: number) => { + if (Buffer.byteLength(value, "utf8") > maxValueBytes) return false; values.set(`${_userId}:${_boardId}`, value); return true; }), diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index cf215cfc7..3f59b39e6 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -129,14 +129,13 @@ export const createGetSetChannel = (name: string) => { }; const queryCacheKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`; -const queryCacheMaxValueBytes = 1024 * 1024; -export const setQueryCacheAsync = async (userId: string, boardId: string, value: string, ttlMs: number) => { - if (Buffer.byteLength(value, "utf8") > queryCacheMaxValueBytes) { +export const setQueryCacheAsync = async (userId: string, boardId: string, value: string, ttlMs: number, maxValueBytes: number) => { + if (Buffer.byteLength(value, "utf8") > maxValueBytes) { logger.warn("Query cache value exceeded maximum size", { key: queryCacheKey(userId, boardId), valueBytes: Buffer.byteLength(value, "utf8"), - maxValueBytes: queryCacheMaxValueBytes, + maxValueBytes, }); return false; } diff --git a/packages/request-handler/src/beszel.ts b/packages/request-handler/src/beszel.ts index 27c6d037a..449df7b9b 100644 --- a/packages/request-handler/src/beszel.ts +++ b/packages/request-handler/src/beszel.ts @@ -73,7 +73,6 @@ export const beszelSystemsRequestHandler = createIntegrationRequestHandler< }); return enriched; }, - retry: { attempts: 2, delayMs: 500 }, queryKey: "beszelSystems", }); @@ -100,7 +99,6 @@ export const beszelAlertsRequestHandler = createIntegrationRequestHandler< }); return { alerts, history }; }, - retry: { attempts: 2, delayMs: 500 }, queryKey: "beszelAlerts", }); @@ -141,6 +139,5 @@ export const beszelStatsRequestHandler = createIntegrationRequestHandler< }); return { systemStats, containerStats }; }, - retry: { attempts: 2, delayMs: 500 }, queryKey: "beszelStats", }); diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index 29936bfb1..4b891e952 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -172,7 +172,6 @@ const mockContainers: { export const dockerContainersRequestHandler = createWidgetRequestHandler({ queryKey: "dockerContainersResult", - widgetKind: "dockerContainers", async requestAsync() { if (isDemoMode) { return mockContainers; diff --git a/packages/request-handler/src/lib/integration-request-handler.ts b/packages/request-handler/src/lib/integration-request-handler.ts index 45b089ca9..8bffad266 100644 --- a/packages/request-handler/src/lib/integration-request-handler.ts +++ b/packages/request-handler/src/lib/integration-request-handler.ts @@ -13,8 +13,6 @@ type IntegrationOfKind = Omit> { queryKey: string; requestAsync: (integration: IntegrationOfKind, input: TInput) => Promise; - retry?: { attempts?: number; delayMs?: number }; - isValid?: (data: TData) => boolean; } export const createIntegrationRequestHandler = < @@ -23,14 +21,10 @@ export const createIntegrationRequestHandler = < TInput extends Record, >( options: Options, -) => { - return { - handler: (integration: IntegrationOfKind, itemOptions: TInput) => - createRequestHandler }>({ - queryKey: options.queryKey, - requestAsync: async (input) => options.requestAsync(input.integration, input.options), - retry: options.retry, - isValid: options.isValid, - }).handler({ options: itemOptions, integration }), - }; -}; +) => ({ + handler: (integration: IntegrationOfKind, itemOptions: TInput) => + createRequestHandler }>({ + queryKey: options.queryKey, + requestAsync: async (input) => options.requestAsync(input.integration, input.options), + }).handler({ options: itemOptions, integration }), +}); diff --git a/packages/request-handler/src/lib/request-handler.ts b/packages/request-handler/src/lib/request-handler.ts index 7e605dd6a..124f4d5e8 100644 --- a/packages/request-handler/src/lib/request-handler.ts +++ b/packages/request-handler/src/lib/request-handler.ts @@ -1,59 +1,15 @@ -import { createLogger } from "@homarr/core/infrastructure/logs"; - -const logger = createLogger({ module: "requestHandler" }); - -const isAbortedError = (error: unknown): boolean => { - if (!(error instanceof Error)) return false; - return error.name === "AbortError" || error.name === "TimeoutError"; -}; - interface Options> { queryKey: string; requestAsync: (input: TInput) => Promise; - retry?: { attempts?: number; delayMs?: number }; - isValid?: (data: TData) => boolean; } -const defaultRetry = { attempts: 1, delayMs: 0 }; -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - export const createRequestHandler = >( options: Options, -) => { - return { - handler: (input: TInput) => { - const retryConfig = { ...defaultRetry, ...options.retry }; - - return { - async getDataAsync(): Promise<{ data: TData; timestamp: Date }> { - 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("Request handler received invalid data", { - queryKey: options.queryKey, - attempt, - }); - } - return { data, timestamp: new Date() }; - } catch (error) { - lastError = error; - if (isAbortedError(error) || attempt === retryConfig.attempts) { - throw error; - } - logger.warn("Request handler fetch failed, retrying", { - queryKey: options.queryKey, - attempt, - nextDelayMs: retryConfig.delayMs, - error: error instanceof Error ? `${error.name}: ${error.message}` : String(error), - }); - await sleep(retryConfig.delayMs); - } - } - throw lastError; - }, - }; +) => ({ + handler: (input: TInput) => ({ + async getDataAsync(): Promise<{ data: TData; timestamp: Date }> { + const data = await options.requestAsync(input); + return { data, timestamp: new Date() }; }, - }; -}; + }), +}); diff --git a/packages/request-handler/src/lib/widget-request-handler.ts b/packages/request-handler/src/lib/widget-request-handler.ts index 2bd41142f..aff397ee0 100644 --- a/packages/request-handler/src/lib/widget-request-handler.ts +++ b/packages/request-handler/src/lib/widget-request-handler.ts @@ -1,25 +1,16 @@ -import type { WidgetKind } from "@homarr/definitions"; - import { createRequestHandler } from "./request-handler"; -interface Options> { +interface Options> { queryKey: string; requestAsync: (input: TInput) => Promise; - retry?: { attempts?: number; delayMs?: number }; - isValid?: (data: TData) => boolean; - widgetKind: TKind; } -export const createWidgetRequestHandler = >( - options: Options, -) => { - return { - handler: (widgetOptions: TInput) => - createRequestHandler({ - queryKey: options.queryKey, - requestAsync: options.requestAsync, - retry: options.retry, - isValid: options.isValid, - }).handler(widgetOptions), - }; -}; +export const createWidgetRequestHandler = >( + options: Options, +) => ({ + handler: (widgetOptions: TInput) => + createRequestHandler({ + queryKey: options.queryKey, + requestAsync: options.requestAsync, + }).handler(widgetOptions), +}); diff --git a/packages/request-handler/src/minecraft-server-status.ts b/packages/request-handler/src/minecraft-server-status.ts index c015afa7b..33ba259a4 100644 --- a/packages/request-handler/src/minecraft-server-status.ts +++ b/packages/request-handler/src/minecraft-server-status.ts @@ -7,7 +7,6 @@ import { createWidgetRequestHandler } from "./lib/widget-request-handler"; export const minecraftServerStatusRequestHandler = createWidgetRequestHandler({ queryKey: "minecraftServerStatusApiResult", - widgetKind: "minecraftServerStatus", async requestAsync(input: { domain: string; isBedrockServer: boolean }) { const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`; diff --git a/packages/request-handler/src/rss-feeds.ts b/packages/request-handler/src/rss-feeds.ts index 6e1a3f364..3098fdb1d 100644 --- a/packages/request-handler/src/rss-feeds.ts +++ b/packages/request-handler/src/rss-feeds.ts @@ -11,7 +11,6 @@ const logger = createLogger({ module: "rssFeedsRequestHandler" }); export const rssFeedsRequestHandler = createWidgetRequestHandler({ queryKey: "rssFeedList", - widgetKind: "rssFeed", async requestAsync(input: { url: string; count: number }) { const result = (await extract(input.url, { getExtraEntryFields: (feedEntry) => { diff --git a/packages/request-handler/src/stock-price.ts b/packages/request-handler/src/stock-price.ts index abc9abe0b..2e382828e 100644 --- a/packages/request-handler/src/stock-price.ts +++ b/packages/request-handler/src/stock-price.ts @@ -7,7 +7,6 @@ import { createWidgetRequestHandler } from "./lib/widget-request-handler"; export const fetchStockPriceHandler = createWidgetRequestHandler({ queryKey: "fetchStockPriceResult", - widgetKind: "stockPrice", async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) { const response = await withTimeoutAsync(async (signal) => { return await fetchWithTrustedCertificatesAsync( diff --git a/packages/request-handler/src/weather.ts b/packages/request-handler/src/weather.ts index 0dac8432e..c095485ad 100644 --- a/packages/request-handler/src/weather.ts +++ b/packages/request-handler/src/weather.ts @@ -7,7 +7,6 @@ import { createWidgetRequestHandler } from "./lib/widget-request-handler"; export const weatherRequestHandler = createWidgetRequestHandler({ queryKey: "weatherAtLocation", - widgetKind: "weather", async requestAsync(input: { latitude: number; longitude: number }) { const res = await withTimeoutAsync(async (signal) => { return await fetchWithTrustedCertificatesAsync( diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx index b67e0299a..3a1a0d378 100644 --- a/packages/widgets/src/system-resources/component.tsx +++ b/packages/widgets/src/system-resources/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { Box, Group, Stack } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; @@ -33,7 +33,7 @@ const toChartItem = (healthInfo: { export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemResources">) { const { ref, width } = useElementSize(); - const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ + const { data = [], dataUpdatedAt } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds, }); const memoryCapacityInBytes = @@ -41,21 +41,13 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo const [items, setItems] = useState< { cpu: number; memory: number; gpu: number; network: { up: number; down: number } | null }[] - >(() => data.map((item) => toChartItem(item.healthInfo))); - - const lastUpdatedAtRef = useRef(null); + >([]); useEffect(() => { const firstItem = data[0]; if (!firstItem) return; - - const updatedAt = firstItem.updatedAt?.getTime?.() ?? null; - if (updatedAt !== null && updatedAt === lastUpdatedAtRef.current) return; - lastUpdatedAtRef.current = updatedAt; - - const next = toChartItem(firstItem.healthInfo); - setItems((previousItems) => [...previousItems, next].slice(-MAX_QUEUE_SIZE)); - }, [data]); + setItems((prev) => [...prev, toChartItem(firstItem.healthInfo)].slice(-MAX_QUEUE_SIZE)); + }, [dataUpdatedAt]); const showNetwork = items.length === 0 || (items.every((item) => item.network !== null) && options.visibleCharts.includes("network"));