From 4dbe4343e5c3c0c6f2a3d4baf49215c09b0d9e12 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 5 Oct 2026 09:51:35 +0200 Subject: [PATCH] fix(demo): stop seeded widget background polling (#6980) * fix(demo): stop seeded widget background polling * docs(demo): clarify single-replica requirements * fix: abort startup when auth secret generation fails * docs: remove demo deployment changes from PR * fix: scope demo polling changes --- .../app/[locale]/_client-providers/trpc.tsx | 22 ++++++++++++------- apps/nextjs/src/app/[locale]/layout.tsx | 2 +- packages/widgets/package.json | 1 + .../widgets/src/beszel/_shared/stats-view.tsx | 4 +++- packages/widgets/src/custom-api/component.tsx | 7 ++++-- packages/widgets/src/demo-read-only.tsx | 9 ++++++++ packages/widgets/src/stats/component.tsx | 10 +++++---- packages/widgets/src/umami/umami-content.tsx | 3 ++- packages/widgets/src/umami/umami-top-list.tsx | 4 +++- packages/widgets/src/umami/umami-utils.ts | 5 ++++- 10 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 packages/widgets/src/demo-read-only.tsx diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index ee611b313..a33ec44cf 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -34,6 +34,7 @@ import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { useSession } from "@homarr/auth/client"; import { env } from "@homarr/common/env"; import { showWarningNotification } from "@homarr/notifications"; +import { DemoReadOnlyProvider } from "@homarr/widgets/demo-read-only"; import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; import { useAuthContext } from "./session"; @@ -66,7 +67,7 @@ const constructWebsocketUrl = () => { return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`; }; -export function TRPCReactProvider({ children }: PropsWithChildren) { +export function TRPCReactProvider({ children, demoReadOnly }: PropsWithChildren<{ demoReadOnly: boolean }>) { const { data: session } = useSession(); const { logoutRedirectInProgress } = useAuthContext(); const sessionQueryScope = getSessionQueryScope(session); @@ -81,7 +82,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) { currentScope={sessionQueryScope} onScopeChange={handleScopeChange} > - {children} + {children} ); } @@ -102,7 +103,7 @@ const clearLegacyDashboardPersistence = () => { } }; -const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => { +const ScopedTRPCReactProvider = ({ children, demoReadOnly }: PropsWithChildren<{ demoReadOnly: boolean }>) => { useEffect(clearLegacyDashboardPersistence, []); const wsClient = useMemo( () => @@ -150,21 +151,26 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => { }, }); client.setQueryDefaults([["widget"]], { - refetchInterval: queryCacheDefaultRefetchIntervalMs, + refetchInterval: demoReadOnly ? false : queryCacheDefaultRefetchIntervalMs, refetchOnWindowFocus: false, refetchOnReconnect: false, }); for (const queryDefaults of widgetQueryRefetchIntervals) { const policy: { refetchInterval?: number | false; staleTime?: number } = {}; - if (queryDefaults.intervalSeconds === null) policy.refetchInterval = false; - if (typeof queryDefaults.intervalSeconds === "number") { + if (demoReadOnly || queryDefaults.intervalSeconds === null) policy.refetchInterval = false; + else if (typeof queryDefaults.intervalSeconds === "number") { policy.refetchInterval = queryDefaults.intervalSeconds * 1000; } if ("staleTimeSeconds" in queryDefaults) policy.staleTime = queryDefaults.staleTimeSeconds * 1000; client.setQueryDefaults(queryDefaults.queryKey, policy); } for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) { - client.setQueryDefaults(queryKey, policy); + client.setQueryDefaults( + queryKey, + demoReadOnly + ? { ...policy, refetchInterval: false, refetchOnWindowFocus: false, refetchOnReconnect: false } + : policy, + ); } if (typeof window === "undefined") { client.getQueryCache().subscribe((event) => { @@ -239,7 +245,7 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => { }, }} > - {children} + {children} {process.env.NODE_ENV === "development" && } diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 755d06b5a..8e4757d91 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -167,7 +167,7 @@ export default async function Layout(props: { /> ), (innerProps) => , - (innerProps) => , + (innerProps) => , (innerProps) => , (innerProps) => , (innerProps) => ( diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 6ac7d70e9..251d9bf3a 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -19,6 +19,7 @@ "./custom-api/component": "./src/custom-api/component.tsx", "./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx", "./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts", + "./demo-read-only": "./src/demo-read-only.tsx", "./assistant/context": "./src/assistant/context.tsx", "./catalog": "./src/catalog.ts", "./definition": "./src/definition.ts", diff --git a/packages/widgets/src/beszel/_shared/stats-view.tsx b/packages/widgets/src/beszel/_shared/stats-view.tsx index dfb5d7915..93ddaed28 100644 --- a/packages/widgets/src/beszel/_shared/stats-view.tsx +++ b/packages/widgets/src/beszel/_shared/stats-view.tsx @@ -28,6 +28,7 @@ import { import { createByteChartAxisFormatters, formatPercent } from "./format"; import { makeTooltipProps } from "./tooltip"; import { useLiveStats } from "./use-live-stats"; +import { useDemoReadOnly } from "../../demo-read-only"; const CHART_HEIGHT = 180; const MEBIBYTE = 1024 * 1024; @@ -97,9 +98,10 @@ export function BeszelStatsView({ ); const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork; const isLive = timePeriod === "1m"; + const demoReadOnly = useDemoReadOnly(); const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery( { integrationIds, systemId, timePeriod, includeDocker: showDocker }, - { refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" }, + { refetchInterval: isLive || demoReadOnly ? false : 5_000, enabled: !isLive && systemId !== "" }, ); const historicalData = isLive ? historicalQuery.data : getUsableWidgetQueryData(historicalQuery); const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== ""); diff --git a/packages/widgets/src/custom-api/component.tsx b/packages/widgets/src/custom-api/component.tsx index 0ce693ee3..8e5f104ea 100644 --- a/packages/widgets/src/custom-api/component.tsx +++ b/packages/widgets/src/custom-api/component.tsx @@ -11,6 +11,7 @@ import { Link, zoomCompensatedSize } from "@homarr/ui"; import { WidgetQueryLoadingState } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; +import { useDemoReadOnly } from "../demo-read-only"; import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state"; const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false }); @@ -23,6 +24,7 @@ export default function CustomApiWidget({ }: WidgetComponentProps<"customApi">) { const t = useI18n("widget.customApi"); const tCustomJsx = useI18n("widget.customApi.customJsx"); + const demoReadOnly = useDemoReadOnly(); const { data: session } = useSession(); const isAdmin = session?.user.permissions.includes("admin") ?? false; const definitionId = options.definitionId; @@ -33,8 +35,9 @@ export default function CustomApiWidget({ { enabled: Boolean(itemId) && Boolean(definitionId), refetchInterval: (currentQuery) => - isTerminalCustomWidgetDefinitionError(currentQuery.state.error) ? false : intervalMs, - retry: (failureCount, error) => !isTerminalCustomWidgetDefinitionError(error) && failureCount < 3, + demoReadOnly || isTerminalCustomWidgetDefinitionError(currentQuery.state.error) ? false : intervalMs, + retry: (failureCount, error) => + !demoReadOnly && !isTerminalCustomWidgetDefinitionError(error) && failureCount < 3, }, ); diff --git a/packages/widgets/src/demo-read-only.tsx b/packages/widgets/src/demo-read-only.tsx new file mode 100644 index 000000000..2e6470b70 --- /dev/null +++ b/packages/widgets/src/demo-read-only.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { createContext, useContext } from "react"; + +const DemoReadOnlyContext = createContext(false); + +export const DemoReadOnlyProvider = DemoReadOnlyContext.Provider; + +export const useDemoReadOnly = () => useContext(DemoReadOnlyContext); diff --git a/packages/widgets/src/stats/component.tsx b/packages/widgets/src/stats/component.tsx index 79d2b07a3..9d5b5b9b7 100644 --- a/packages/widgets/src/stats/component.tsx +++ b/packages/widgets/src/stats/component.tsx @@ -22,6 +22,7 @@ import { clientApi } from "@homarr/api/client"; import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { useDemoReadOnly } from "../demo-read-only"; import { useWidgetRuntimeActions } from "../runtime-hooks"; import { statsEntriesSchema } from "./config"; import { StatsTable } from "./table"; @@ -41,6 +42,7 @@ export default function StatsWidget({ displayScale = 1, }: WidgetComponentProps<"stats">) { const board = useRequiredBoard(); + const demoReadOnly = useDemoReadOnly(); const advanced = displayMode === "advanced"; const t = useI18n("widget.stats"); const utils = clientApi.useUtils(); @@ -81,8 +83,8 @@ export default function StatsWidget({ { integrationId }, { staleTime: 30_000, - refetchInterval: 60_000, - refetchOnWindowFocus: true, + refetchInterval: demoReadOnly ? false : 60_000, + refetchOnWindowFocus: !demoReadOnly, refetchIntervalInBackground: false, retry: false, }, @@ -153,13 +155,13 @@ export default function StatsWidget({ }); useEffect(() => { - if (document.hidden) return; + if (demoReadOnly || document.hidden) return; for (const [index, integrationId] of visibleIds.entries()) { const snapshot = snapshots[index]; if (!snapshot?.data || snapshot.error) continue; if (snapshot.data.updatedAt === null && snapshot.data.retryAt <= Date.now()) void refresh(integrationId, false); } - }, [visibleIds, snapshots, refresh]); + }, [demoReadOnly, visibleIds, snapshots, refresh]); const tableEntries = advanced ? ids.flatMap((id) => { diff --git a/packages/widgets/src/umami/umami-content.tsx b/packages/widgets/src/umami/umami-content.tsx index 494a69980..f09fc87df 100644 --- a/packages/widgets/src/umami/umami-content.tsx +++ b/packages/widgets/src/umami/umami-content.tsx @@ -12,7 +12,7 @@ import classes from "./component.module.css"; import { UmamiEventsContent } from "./umami-events-content"; import { getUmamiLayout } from "./umami-layout"; import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list"; -import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils"; +import { formatTimeFrameLabel, formatXLabel, useUmamiQueryOptions } from "./umami-utils"; import type { TimeFrame } from "./umami-utils"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { WidgetQueryLoadingState } from "../common/query-state-indicator"; @@ -48,6 +48,7 @@ export function UmamiContent({ }: UmamiContentProps) { const t = useI18n("widget.umami"); const locale = useCurrentIntlLocale(); + const umamiQueryOptions = useUmamiQueryOptions(); const tickColor = "var(--mantine-color-dimmed)"; const statsQuery = clientApi.widget.umami.getVisitorStats.useQuery( diff --git a/packages/widgets/src/umami/umami-top-list.tsx b/packages/widgets/src/umami/umami-top-list.tsx index ffb060134..e426558f3 100644 --- a/packages/widgets/src/umami/umami-top-list.tsx +++ b/packages/widgets/src/umami/umami-top-list.tsx @@ -7,7 +7,7 @@ import type { UmamiMetricItem } from "@homarr/integrations/types"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import classes from "./component.module.css"; -import { umamiQueryOptions } from "./umami-utils"; +import { useUmamiQueryOptions } from "./umami-utils"; import { getUsableWidgetQueryData } from "../common/query-state"; interface UmamiTopListProps { @@ -19,6 +19,7 @@ interface UmamiTopListProps { export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) { const t = useI18n("widget.umami"); + const umamiQueryOptions = useUmamiQueryOptions(); const data = getUsableWidgetQueryData( clientApi.widget.umami.getTopPages.useQuery( @@ -31,6 +32,7 @@ export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, lim export function UmamiTopReferrersContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) { const t = useI18n("widget.umami"); + const umamiQueryOptions = useUmamiQueryOptions(); const data = getUsableWidgetQueryData( clientApi.widget.umami.getTopReferrers.useQuery( diff --git a/packages/widgets/src/umami/umami-utils.ts b/packages/widgets/src/umami/umami-utils.ts index d87881f86..2d4a8d674 100644 --- a/packages/widgets/src/umami/umami-utils.ts +++ b/packages/widgets/src/umami/umami-utils.ts @@ -1,8 +1,11 @@ import type { useI18n } from "@homarr/translation/client"; +import { useDemoReadOnly } from "../demo-read-only"; import { timeFrameValues } from "./index"; -export const umamiQueryOptions = { refetchInterval: 60_000 } as const; +export const useUmamiQueryOptions = (): { refetchInterval: number | false } => ({ + refetchInterval: useDemoReadOnly() ? false : 60_000, +}); export const EVENT_COLORS = ["blue.5", "orange.5", "green.5", "red.5", "violet.5", "teal.5", "yellow.5", "pink.5"];