fix: scope polling to widgets, fix retry, guard dates, throttle umami

- Remove global refetchInterval; scope to widget queries via setQueryDefaults
- Fix error boundary retry: correct queryKey to [["widget"]], move invalidation to onReset
- Add toValidDate() helper in @homarr/common for cache hydration safety
- Guard date formatting in media-releases and media-requests against non-Date values
- Set umami refetchInterval to 60s via shared umamiQueryOptions constant
- Simplify dns-hole mutations to invalidate() instead of manual optimistic setData
This commit is contained in:
Thomas Camlong committed 2026-06-25 17:38:13 +02:00
1 parent 210a62b35a
commit c848dbfc1f
11 files changed
+84 -114

No files matched your search

@@ -61,35 +61,35 @@ const wsClient = createWSClient({
export function TRPCReactProvider(props: PropsWithChildren) {
const [persister] = useState(() => createWidgetQueryPersister());
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: queryCacheDefaultStaleTimeMs,
refetchInterval: queryCacheDefaultRefetchIntervalMs,
gcTime: queryCacheDefaultGcTimeMs,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: 3,
},
mutations: {
onError(error) {
if (
error instanceof TRPCClientError &&
error.data?.code === "FORBIDDEN" &&
error.message === "Mutations are disabled in demo mode"
) {
showWarningNotification({
title: "Demo mode",
message: "This action is disabled in demo mode.",
});
}
},
const [queryClient] = useState(() => {
const client = new QueryClient({
defaultOptions: {
queries: {
staleTime: queryCacheDefaultStaleTimeMs,
gcTime: queryCacheDefaultGcTimeMs,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: 3,
},
mutations: {
onError(error) {
if (
error instanceof TRPCClientError &&
error.data?.code === "FORBIDDEN" &&
error.message === "Mutations are disabled in demo mode"
) {
showWarningNotification({
title: "Demo mode",
message: "This action is disabled in demo mode.",
});
}
},
},
}),
);
},
});
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
return client;
});
const [trpcClient] = useState(() => {
return clientApi.createClient({
@@ -1,6 +1,6 @@
import { Badge, Card } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
import { ErrorBoundary } from "react-error-boundary";
@@ -98,12 +98,16 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
updateItemOptions({ itemId: item.id, newOptions });
const widgetSupportsIntegrations =
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
const queryClient = useQueryClient();
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
onReset={() => {
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
reset();
}}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
-2
View File
@@ -1,7 +1,5 @@
import type { QueryKey } from "@tanstack/react-query";
// ponytail: staleTime 0 = always refetch on mount (fresh data every dashboard load).
// refetchInterval 30s = poll while page is open. gcTime 24h = Redis TTL for persisted cache.
export const queryCacheDefaultStaleTimeMs = 0;
export const queryCacheDefaultRefetchIntervalMs = 30_000;
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
+5
View File
@@ -17,6 +17,11 @@ export function formatDuration(milliseconds: number) {
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
export const toValidDate = (value: unknown): Date | null => {
const date = value instanceof Date ? value : new Date(value as string | number);
return Number.isFinite(date.getTime()) ? date : null;
};
export const isDateWithin = (date: Date, relativeDate: string): boolean => {
if (relativeDate.length < 2) {
throw new Error("Relative date must be at least 2 characters long");
@@ -56,54 +56,11 @@ export default function DnsHoleControlsWidget({
});
const utils = clientApi.useUtils();
// Mutations for dnsHole state, set to undefined on click, and change again on settle
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
onSettled: (_, error, { integrationId }) => {
utils.widget.dnsHole.summary.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId
? {
...item,
summary: {
...item.summary,
status: error ? "disabled" : "enabled",
},
}
: item,
);
},
);
},
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
});
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
onSettled: (_, error, { integrationId }) => {
utils.widget.dnsHole.summary.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId
? {
...item,
summary: {
...item.summary,
status: error ? "enabled" : "disabled",
},
}
: item,
);
},
);
},
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
});
const toggleDns = (integrationId: string) => {
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
@@ -6,7 +6,7 @@ import { IconBook, IconCalendar, IconClock, IconStarFilled } from "@tabler/icons
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getMantineColor } from "@homarr/common";
import { getMantineColor, toValidDate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { MediaRelease } from "@homarr/integrations/types";
import { mediaTypeConfigurations } from "@homarr/integrations/types";
@@ -39,6 +39,19 @@ interface ItemProps {
options: WidgetComponentProps<"mediaReleases">["options"];
}
const formatReleaseDate = (value: unknown, locale: string) => {
const date = toValidDate(value);
if (!date) return "—";
return Intl.DateTimeFormat(locale, {
month: "2-digit",
year: "numeric",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
};
const Item = ({ item, options }: ItemProps) => {
const locale = useCurrentLocale();
const t = useI18n();
@@ -93,14 +106,7 @@ const Item = ({ item, options }: ItemProps) => {
<Group gap={6} style={{ rowGap: 0 }}>
<Info
icon={IconCalendar}
label={Intl.DateTimeFormat(locale, {
month: "2-digit",
year: "numeric",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(item.releaseDate)}
label={formatReleaseDate(item.releaseDate, locale)}
/>
{length !== undefined && (
<>
@@ -19,6 +19,7 @@ import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { toValidDate } from "@homarr/common";
import type { MediaRequestStatus } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
@@ -134,7 +135,7 @@ const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) =
<Group justify="space-between" gap="xs" className="mediaRequests-list-item-top-group">
<Group gap="xs">
<Text className="mediaRequests-list-item-media-year" size="xs">
{request.airDate?.getFullYear() ?? t("toBeDetermined")}
{toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")}
</Text>
{!isTiny && (
<Badge
+10 -9
View File
@@ -10,7 +10,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { UmamiEventsContent } from "./umami-events-content";
import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list";
import { formatTimeFrameLabel, formatXLabel } from "./umami-utils";
import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils";
import type { TimeFrame } from "./umami-utils";
interface UmamiContentProps {
@@ -40,19 +40,20 @@ export function UmamiContent({
const { colorScheme } = useMantineColorScheme();
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
const { data: results = [] } = clientApi.widget.umami.getVisitorStats.useQuery({
integrationIds,
websiteId,
timeFrame,
eventName,
});
const { data: results = [] } = clientApi.widget.umami.getVisitorStats.useQuery(
{ integrationIds, websiteId, timeFrame, eventName },
umamiQueryOptions,
);
const activeVisitorsInput = { integrationId: integrationIds[0] ?? "", websiteId };
const { data: activeVisitors = 0 } = clientApi.widget.umami.getActiveVisitors.useQuery(activeVisitorsInput);
const { data: activeVisitors = 0 } = clientApi.widget.umami.getActiveVisitors.useQuery(
activeVisitorsInput,
umamiQueryOptions,
);
const { data: multiEventSeries } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
{ enabled: viewMode === "events" && eventNames.length > 0 },
{ enabled: viewMode === "events" && eventNames.length > 0, ...umamiQueryOptions },
);
const multiEventTotal = multiEventSeries
@@ -7,7 +7,7 @@ import { clientApi } from "@homarr/api/client";
import type { UmamiEventSeries } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { EVENT_COLORS, formatXLabel } from "./umami-utils";
import { EVENT_COLORS, formatXLabel, umamiQueryOptions } from "./umami-utils";
interface UmamiEventsContentProps {
integrationIds: string[];
@@ -28,12 +28,10 @@ export function UmamiEventsContent({
const { colorScheme } = useMantineColorScheme();
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
const { data: series = [] } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery({
integrationId: integrationIds[0] ?? "",
websiteId,
timeFrame,
eventNames: [...eventNames].toSorted(),
});
const { data: series = [] } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
umamiQueryOptions,
);
if (eventNames.length === 0) {
return (
+10 -12
View File
@@ -6,6 +6,8 @@ import { clientApi } from "@homarr/api/client";
import type { UmamiMetricItem } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { umamiQueryOptions } from "./umami-utils";
interface UmamiTopListProps {
integrationIds: string[];
websiteId: string;
@@ -15,23 +17,19 @@ interface UmamiTopListProps {
export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
const t = useScopedI18n("widget.umami");
const { data = [] } = clientApi.widget.umami.getTopPages.useQuery({
integrationId: integrationIds[0] ?? "",
websiteId,
timeFrame,
limit,
});
const { data = [] } = clientApi.widget.umami.getTopPages.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit },
umamiQueryOptions,
);
return <UmamiTopList items={data} heading={t("option.viewMode.option.topPages")} emptyLabel={t("topPages.direct")} />;
}
export function UmamiTopReferrersContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
const t = useScopedI18n("widget.umami");
const { data = [] } = clientApi.widget.umami.getTopReferrers.useQuery({
integrationId: integrationIds[0] ?? "",
websiteId,
timeFrame,
limit,
});
const { data = [] } = clientApi.widget.umami.getTopReferrers.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit },
umamiQueryOptions,
);
return (
<UmamiTopList
items={data}
@@ -2,6 +2,8 @@ import type { useScopedI18n } from "@homarr/translation/client";
import { timeFrameValues } from "./index";
export const umamiQueryOptions = { refetchInterval: 60_000 } as const;
export const EVENT_COLORS = ["blue.5", "orange.5", "green.5", "red.5", "violet.5", "teal.5", "yellow.5", "pink.5"];
export function formatXLabel(timestamp: string, timeFrame: string): string {