diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index ff225f642..157a41c44 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -2,8 +2,8 @@ import type { MutableRefObject, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { Group, Loader, Menu, Switch, Text } from "@mantine/core"; -import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react"; +import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core"; +import { IconAlertTriangle, IconCircleCheck, IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { useIsFetching, useQueryClient } from "@tanstack/react-query"; @@ -15,6 +15,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { translateIfNecessary } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import type { TranslationFunction } from "@homarr/translation"; import type { WidgetContextMenuAction } from "@homarr/widgets"; import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetEditModal } from "@homarr/widgets/modals"; @@ -232,11 +233,9 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont onClick={handleRefetch} disabled={isWidgetFetching} > - + {tMenu("refresh")} - - - + { - // 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity + t: TranslationFunction; +} + +const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => { const [, setTick] = useState(0); useEffect(() => { const id = setInterval(() => setTick((n) => n + 1), 1000); return () => clearInterval(id); }, []); - if (isFetching) return ; + const queries = queryClient.getQueryCache().findAll({ queryKey }); - const timestamps = queryClient - .getQueryCache() - .findAll({ queryKey }) - .map((q) => q.state.dataUpdatedAt) - .filter(Boolean); - if (timestamps.length === 0) return null; + if (isFetching) { + return ( + + + + {t("item.menu.status.loading")} + + + ); + } - const latest = Math.max(...timestamps); + if (queries.length === 0) { + return {t("item.menu.status.idle")}; + } + + const hasError = queries.some((q) => q.state.status === "error"); + const timestamps = queries.map((q) => q.state.dataUpdatedAt).filter(Boolean); + const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0; const seconds = Math.floor((Date.now() - latest) / 1000); - if (seconds < 5) return "just now"; - if (seconds < 60) return `${seconds}s ago`; - return `${Math.floor(seconds / 60)}m ago`; + const ageLabel = seconds < 5 ? "just now" : seconds < 60 ? `${seconds}s ago` : `${Math.floor(seconds / 60)}m ago`; + + if (hasError) { + const errorQuery = queries.find((q) => q.state.status === "error"); + const errorMessage = errorQuery?.state.error instanceof Error + ? errorQuery.state.error.message + : String(errorQuery?.state.error ?? "Unknown error"); + + return ( + + + + + {t("item.menu.status.error")} · {ageLabel} + + + + ); + } + + return ( + + + + {t("item.menu.status.success")} · {ageLabel} + + + ); }; diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index a4429bd27..9de482008 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -153,9 +153,9 @@ export class OverseerrIntegration ); } else if (pendingResults.length > 0) requests = pendingResults; else if (allResults.length > 0) requests = allResults; - else return Promise.all([]); + else return []; - return await Promise.all( + const settled = await Promise.allSettled( requests.map(async (request): Promise => { const information = await this.getItemInformationAsync(request.media.tmdbId, request.type); @@ -184,6 +184,10 @@ export class OverseerrIntegration }; }), ); + + return settled + .filter((result): result is PromiseFulfilledResult => result.status === "fulfilled") + .map((result) => result.value); } protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus { @@ -301,6 +305,10 @@ export class OverseerrIntegration }, }); + if (!response.ok) { + throw new Error(`Failed to fetch ${type} information for id ${id}: ${response.status} ${response.statusText}`); + } + if (type === "tv") { const series = (await response.json()) as TvInformation; return { diff --git a/packages/request-handler/src/lib/integration-request-handler.ts b/packages/request-handler/src/lib/integration-request-handler.ts index e60be1fb8..cf6f49cea 100644 --- a/packages/request-handler/src/lib/integration-request-handler.ts +++ b/packages/request-handler/src/lib/integration-request-handler.ts @@ -13,6 +13,7 @@ type IntegrationOfKind = Omit> { requestAsync: (integration: IntegrationOfKind, input: TInput) => Promise; cacheTtlMs?: number; + fallbackToStaleOnError?: boolean; } export const createIntegrationRequestHandler = < @@ -32,6 +33,7 @@ export const createIntegrationRequestHandler = < return options.requestAsync(integration, input.options); }, cacheTtlMs: options.cacheTtlMs, + fallbackToStaleOnError: options.fallbackToStaleOnError, }); return { diff --git a/packages/request-handler/src/lib/request-handler.ts b/packages/request-handler/src/lib/request-handler.ts index f733078ee..4dbdb5b11 100644 --- a/packages/request-handler/src/lib/request-handler.ts +++ b/packages/request-handler/src/lib/request-handler.ts @@ -1,6 +1,7 @@ interface Options> { requestAsync: (input: TInput) => Promise; cacheTtlMs?: number; + fallbackToStaleOnError?: boolean; } type CacheEntry = { data: TData; timestamp: Date; expiresAt: number }; @@ -35,7 +36,6 @@ export const createRequestHandler = { inflight.delete(key); + if (options.fallbackToStaleOnError && cached) { + return cached; + } throw err; }); diff --git a/packages/request-handler/src/media-request-list.ts b/packages/request-handler/src/media-request-list.ts index 62cf29a18..7a99dae76 100644 --- a/packages/request-handler/src/media-request-list.ts +++ b/packages/request-handler/src/media-request-list.ts @@ -13,4 +13,6 @@ export const mediaRequestListRequestHandler = createIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getRequestsAsync(); }, + cacheTtlMs: 60_000, + fallbackToStaleOnError: true, }); diff --git a/packages/translation/src/lang/en-gb.json b/packages/translation/src/lang/en-gb.json index 15ed8c7fe..04360618a 100644 --- a/packages/translation/src/lang/en-gb.json +++ b/packages/translation/src/lang/en-gb.json @@ -1598,10 +1598,16 @@ }, "menu": { "label": { - "settings": "Settings", + "settings": "", "refresh": "", "options": "", "actions": "" + }, + "status": { + "success": "", + "error": "", + "loading": "", + "idle": "" } }, "create": { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 4ea57bea3..86ecfdee2 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1622,6 +1622,12 @@ "refresh": "Refresh", "options": "Options", "actions": "Actions" + }, + "status": { + "success": "OK", + "error": "Error", + "loading": "Loading", + "idle": "Idle" } }, "create": {