refactor: simplify request handlers and deduplicate config
- Re-enable TanStack Query retry (retry: 3 with exponential backoff), delete all server-side retry logic from request handlers - Strip handler factories to bare minimum: remove isValid, retry, widgetKind - Remove redundant refetchOnWindowFocus/refetchOnReconnect from docker-table - Deduplicate queryCacheMaxValueBytes (pass as param, single source of truth) - Simplify system-resources chart: use dataUpdatedAt instead of ref-based tracking - Fix leading blank lines in 27 request handler files
This commit is contained in:
15 files changed
+36
-114
No files matched your search
@@ -71,7 +71,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
gcTime: queryCacheDefaultGcTimeMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
retry: 3,
|
||||
},
|
||||
mutations: {
|
||||
onError(error) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -129,14 +129,13 @@ export const createGetSetChannel = <TData>(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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -172,7 +172,6 @@ const mockContainers: {
|
||||
|
||||
export const dockerContainersRequestHandler = createWidgetRequestHandler({
|
||||
queryKey: "dockerContainersResult",
|
||||
widgetKind: "dockerContainers",
|
||||
async requestAsync() {
|
||||
if (isDemoMode) {
|
||||
return mockContainers;
|
||||
|
||||
@@ -13,8 +13,6 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
|
||||
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
||||
queryKey: string;
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
retry?: { attempts?: number; delayMs?: number };
|
||||
isValid?: (data: TData) => boolean;
|
||||
}
|
||||
|
||||
export const createIntegrationRequestHandler = <
|
||||
@@ -23,14 +21,10 @@ export const createIntegrationRequestHandler = <
|
||||
TInput extends Record<string, unknown>,
|
||||
>(
|
||||
options: Options<TData, TKind, TInput>,
|
||||
) => {
|
||||
return {
|
||||
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
|
||||
createRequestHandler<TData, { options: TInput; integration: IntegrationOfKind<TKind> }>({
|
||||
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<TKind>, itemOptions: TInput) =>
|
||||
createRequestHandler<TData, { options: TInput; integration: IntegrationOfKind<TKind> }>({
|
||||
queryKey: options.queryKey,
|
||||
requestAsync: async (input) => options.requestAsync(input.integration, input.options),
|
||||
}).handler({ options: itemOptions, integration }),
|
||||
});
|
||||
@@ -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<TData, TInput extends Record<string, unknown>> {
|
||||
queryKey: string;
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
retry?: { attempts?: number; delayMs?: number };
|
||||
isValid?: (data: TData) => boolean;
|
||||
}
|
||||
|
||||
const defaultRetry = { attempts: 1, delayMs: 0 };
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const createRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
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() };
|
||||
},
|
||||
};
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -1,25 +1,16 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
|
||||
interface Options<TData, TKind extends WidgetKind, TInput extends Record<string, unknown>> {
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
queryKey: string;
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
retry?: { attempts?: number; delayMs?: number };
|
||||
isValid?: (data: TData) => boolean;
|
||||
widgetKind: TKind;
|
||||
}
|
||||
|
||||
export const createWidgetRequestHandler = <TData, TKind extends WidgetKind, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TKind, TInput>,
|
||||
) => {
|
||||
return {
|
||||
handler: (widgetOptions: TInput) =>
|
||||
createRequestHandler<TData, TInput>({
|
||||
queryKey: options.queryKey,
|
||||
requestAsync: options.requestAsync,
|
||||
retry: options.retry,
|
||||
isValid: options.isValid,
|
||||
}).handler(widgetOptions),
|
||||
};
|
||||
};
|
||||
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => ({
|
||||
handler: (widgetOptions: TInput) =>
|
||||
createRequestHandler<TData, TInput>({
|
||||
queryKey: options.queryKey,
|
||||
requestAsync: options.requestAsync,
|
||||
}).handler(widgetOptions),
|
||||
});
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<number | null>(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"));
|
||||
|
||||
Reference in new issue
Block a user