From f83ef563b03b1d8e1afc958aae1bcd88f680593b Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Wed, 17 Jun 2026 23:39:14 +0200 Subject: [PATCH] fix(beszel): widget transparency, error handling, and cache invalidation - Remove hardcoded background colors from Beszel grid/table widgets so the board's opacity slider controls the background via the parent card's --background-color CSS variable - Grid: set bg="transparent" on inner Card components - Table: override mantine-datatable default backgrounds to transparent - Stats: make system selector toggle only visible on hover - Stats: fix chart width/height(-1) warning by setting minHeight: 1 - All Beszel widgets: throw query errors to trigger the ErrorBoundary instead of silently rendering an empty/black UI - Add retry: false to beszel-system-stats queries - Add invalidateIntegrationCacheAsync() to clear all cached data and session stores for an integration when it is updated or deleted, preventing stale/errored data from persisting after config changes --- .../router/integration/integration-router.ts | 8 ++++++ packages/redis/src/index.ts | 1 + packages/redis/src/lib/channel.ts | 25 +++++++++++++++++++ .../widgets/src/beszel-alerts/component.tsx | 4 ++- .../src/beszel-system-grid/component.tsx | 16 +++++------- .../beszel-system-stats/component.module.css | 8 ++++++ .../src/beszel-system-stats/component.tsx | 22 ++++++++++------ .../src/beszel-system-table/component.tsx | 4 ++- .../src/beszel-system-table/styles.css | 14 +++++++++++ packages/widgets/src/beszel/_shared/chart.tsx | 2 +- 10 files changed, 84 insertions(+), 20 deletions(-) diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index c5dd9a9b6..af237bb31 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -27,6 +27,7 @@ import { integrationSecretKindObject, } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; +import { invalidateIntegrationCacheAsync } from "@homarr/redis"; import { byIdSchema } from "@homarr/validation/common"; import { integrationCreateSchema, @@ -469,6 +470,10 @@ export const integrationRouter = createTRPCRouter({ kind: integration.kind, url: input.url, }); + + // Invalidate all cached data for this integration so that widgets pick up the + // new configuration immediately instead of serving stale (or errored) data. + await invalidateIntegrationCacheAsync(input.id); }), delete: protectedProcedure .meta({ @@ -490,6 +495,9 @@ export const integrationRouter = createTRPCRouter({ } await ctx.db.delete(integrations).where(eq(integrations.id, input.id)); + + // Clean up any cached data left behind by the deleted integration. + await invalidateIntegrationCacheAsync(input.id); }), getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 8e6e3db86..6c99c11b6 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -15,6 +15,7 @@ export { createGetSetChannel, createQueryCacheChannel, getAllQueryCacheAsync, + invalidateIntegrationCacheAsync, } from "./lib/channel"; export { createIntegrationHistoryChannel } from "./lib/channels/history-channel"; diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 9f2b04a98..7d3b9bb88 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -261,6 +261,31 @@ export const createIntegrationOptionsChannel = ( return createChannelWithLatestAndEvents(channelName); }; +/** + * Invalidates all cached data for a given integration by deleting every Redis key + * that starts with `integration:${integrationId}:` as well as the integration's + * session store entry. This ensures that stale data from a misconfigured or + * updated integration is never served from cache. + */ +export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise => { + const patterns = [`integration:${integrationId}:*`, `session-store:${integrationId}`]; + let deletedCount = 0; + for (const pattern of patterns) { + let cursor = "0"; + do { + const [nextCursor, keys] = await getSetClient.scan(cursor, "MATCH", pattern, "COUNT", 200); + cursor = nextCursor; + if (keys.length > 0) { + await getSetClient.del(...keys); + deletedCount += keys.length; + } + } while (cursor !== "0"); + } + if (deletedCount > 0) { + logger.info("Invalidated integration cache", { integrationId, deletedKeys: deletedCount }); + } +}; + export const createWidgetOptionsChannel = ( widgetKind: WidgetKind, queryKey: string, diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 82725db51..6d8d9a651 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -40,7 +40,7 @@ export default function BeszelAlertsWidget({ () => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }), [integrationIds, options.showHistory, options.maxHistoryItems], ); - const { data: results = [] } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, { + const { data: results = [], error: alertsError } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, { staleTime: 15_000, refetchInterval: 15_000, retry: false, @@ -91,6 +91,8 @@ export default function BeszelAlertsWidget({ const triggeredAlerts = alerts.filter((a) => a.triggered); const okAlerts = alerts.filter((a) => !a.triggered); + if (alertsError) throw alertsError; + return ( diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index 60d367d8f..0b3221522 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -1,7 +1,7 @@ "use client"; import type { MantineSize } from "@mantine/core"; -import { Badge, Box, Card, Group, Progress, Text, Stack, useComputedColorScheme } from "@mantine/core"; +import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core"; import { Activity, Battery, @@ -153,7 +153,6 @@ interface SystemCardProps { t: ReturnType>; size: SizeConfig; maxMetrics: number; - backgroundColor: string; itemRadius: MantineSize; } @@ -311,14 +310,14 @@ const metricRenderers = [ }, ] as const; -const SystemCard = ({ system, options, t, size, maxMetrics, backgroundColor, itemRadius }: SystemCardProps) => { +const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius }: SystemCardProps) => { const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics); return ( ) { const t = useScopedI18n("widget.beszelSystemGrid"); const board = useRequiredBoard(); - const colorScheme = useComputedColorScheme("light"); - const opacity = board.opacity / 100; - const bgBase: Record<"light" | "dark", string> = { dark: "57, 57, 57", light: "246, 247, 248" }; - const backgroundColor = `rgba(${bgBase[colorScheme]}, ${opacity})`; - const { data: results = [] } = clientApi.widget.beszel.getSystems.useQuery( + const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery( { integrationIds }, { staleTime: 5_000, refetchInterval: 5_000, retry: false }, ); @@ -367,6 +362,8 @@ export default function BeszelSystemGridWidget({ useBeszelSystemsSubscription(integrationIds, !isEditMode); const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); + if (systemsError) throw systemsError; + const cols = getColCount(width, filteredSystems.length); const rows = Math.ceil(filteredSystems.length / cols) || 1; const rawCellHeight = height / rows; @@ -396,7 +393,6 @@ export default function BeszelSystemGridWidget({ t={t} size={size} maxMetrics={maxMetrics} - backgroundColor={backgroundColor} itemRadius={board.itemRadius} /> ))} diff --git a/packages/widgets/src/beszel-system-stats/component.module.css b/packages/widgets/src/beszel-system-stats/component.module.css index ca1613eef..7750108f9 100644 --- a/packages/widgets/src/beszel-system-stats/component.module.css +++ b/packages/widgets/src/beszel-system-stats/component.module.css @@ -3,4 +3,12 @@ top: 6px; left: 6px; z-index: 1; + opacity: 0; + transition: opacity 0.15s ease; + pointer-events: none; +} + +.beszelStatsContainer:hover .beszelStatsSystemToggle { + opacity: 1; + pointer-events: auto; } diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 2c40205ed..a0e1842a8 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -61,10 +61,11 @@ export default function BeszelSystemStatsWidget({ const { data: session } = useSession(); const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false; const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation(); - const { data: systemsResult = [], isPending: systemsPending } = clientApi.widget.beszel.getSystems.useQuery( - { integrationIds }, - { staleTime: 30 * 1000 }, - ); + const { + data: systemsResult = [], + isPending: systemsPending, + error: systemsError, + } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }, { staleTime: 30 * 1000, retry: false }); const systems = useMemo( () => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))), @@ -89,14 +90,14 @@ export default function BeszelSystemStatsWidget({ const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork; const isLive = options.timePeriod === "1m"; - const { data: statsResult } = clientApi.widget.beszel.getSystemStats.useQuery( + const { data: statsResult, error: statsError } = clientApi.widget.beszel.getSystemStats.useQuery( { integrationIds, systemId: selectedSystem, timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d", includeDocker: showDocker, }, - { staleTime: 30 * 1000, enabled: !isLive && systemReady && selectedSystem !== "" }, + { staleTime: 30 * 1000, enabled: !isLive && systemReady && selectedSystem !== "", retry: false }, ); const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady); @@ -185,6 +186,9 @@ export default function BeszelSystemStatsWidget({ const cols = gridColumns[Number(width > 600)]; + if (systemsError) throw systemsError; + if (!isLive && statsError) throw statsError; + if (!systemsPending && !systemExists && systems.length > 0) { return (
@@ -222,7 +226,11 @@ export default function BeszelSystemStatsWidget({ } return ( - + {!isEditMode && systems.length > 1 && ( diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx index 43d640b0f..02ae5d822 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -66,7 +66,7 @@ export default function BeszelSystemTableWidget({ width, }: WidgetComponentProps<"beszelSystemTable">) { const t = useScopedI18n("widget.beszelSystemTable"); - const { data: results = [] } = clientApi.widget.beszel.getSystems.useQuery( + const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery( { integrationIds }, { staleTime: 5_000, refetchInterval: 5_000, retry: false }, ); @@ -255,6 +255,8 @@ export default function BeszelSystemTableWidget({ return cols.filter(Boolean) as DataTableColumn[]; }, [options, t, size]); + if (systemsError) throw systemsError; + return ( (records: T[], live: boolean) { } const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const; -const chartStyle = { minWidth: 0, minHeight: 0 } as const; +const chartStyle = { minWidth: 0, minHeight: 1 } as const; const panelStyle = { minWidth: 0, overflow: "hidden" } as const; export const CPU_Y_AXIS_DOMAIN: [number, string] = [0, "auto"];