Compare commits

...
Author SHA1 Message Date
Thomas Camlong aa80c9b427 fix(redis): skip integration cache invalidation without client 2026-06-19 22:29:02 +02:00
Thomas 10cc29c3a5 Merge branch 'dev' into fix/beszel-widget-transparency-and-errors 2026-06-19 21:53:31 +02:00
Thomas 42e3422c57 Merge branch 'dev' into fix/beszel-widget-transparency-and-errors 2026-06-18 12:46:20 +02:00
Thomas Camlong f83ef563b0 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
2026-06-17 23:39:14 +02:00
10 changed files with 89 additions and 20 deletions

No files matched your search

@@ -27,6 +27,7 @@ import {
integrationSecretKindObject, integrationSecretKindObject,
} from "@homarr/definitions"; } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations";
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
import { byIdSchema } from "@homarr/validation/common"; import { byIdSchema } from "@homarr/validation/common";
import { import {
integrationCreateSchema, integrationCreateSchema,
@@ -469,6 +470,10 @@ export const integrationRouter = createTRPCRouter({
kind: integration.kind, kind: integration.kind,
url: input.url, 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 delete: protectedProcedure
.meta({ .meta({
@@ -490,6 +495,9 @@ export const integrationRouter = createTRPCRouter({
} }
await ctx.db.delete(integrations).where(eq(integrations.id, input.id)); 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 }) => { getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
+1
View File
@@ -15,6 +15,7 @@ export {
createGetSetChannel, createGetSetChannel,
createQueryCacheChannel, createQueryCacheChannel,
getAllQueryCacheAsync, getAllQueryCacheAsync,
invalidateIntegrationCacheAsync,
} from "./lib/channel"; } from "./lib/channel";
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel"; export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
+30
View File
@@ -261,6 +261,36 @@ export const createIntegrationOptionsChannel = <TData>(
return createChannelWithLatestAndEvents<TData>(channelName); return createChannelWithLatestAndEvents<TData>(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<void> => {
const client = getSetClient as typeof getSetClient | null;
if (!client) {
return;
}
const patterns = [`integration:${integrationId}:*`, `session-store:${integrationId}`];
let deletedCount = 0;
for (const pattern of patterns) {
let cursor = "0";
do {
const [nextCursor, keys] = await client.scan(cursor, "MATCH", pattern, "COUNT", 200);
cursor = nextCursor;
if (keys.length > 0) {
await client.del(...keys);
deletedCount += keys.length;
}
} while (cursor !== "0");
}
if (deletedCount > 0) {
logger.info("Invalidated integration cache", { integrationId, deletedKeys: deletedCount });
}
};
export const createWidgetOptionsChannel = <TData>( export const createWidgetOptionsChannel = <TData>(
widgetKind: WidgetKind, widgetKind: WidgetKind,
queryKey: string, queryKey: string,
@@ -40,7 +40,7 @@ export default function BeszelAlertsWidget({
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }), () => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
[integrationIds, options.showHistory, 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, staleTime: 15_000,
refetchInterval: 15_000, refetchInterval: 15_000,
retry: false, retry: false,
@@ -91,6 +91,8 @@ export default function BeszelAlertsWidget({
const triggeredAlerts = alerts.filter((a) => a.triggered); const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered); const okAlerts = alerts.filter((a) => !a.triggered);
if (alertsError) throw alertsError;
return ( return (
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}> <ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="sm" p="sm"> <Stack gap="sm" p="sm">
@@ -1,7 +1,7 @@
"use client"; "use client";
import type { MantineSize } from "@mantine/core"; 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 { import {
Activity, Activity,
Battery, Battery,
@@ -153,7 +153,6 @@ interface SystemCardProps {
t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">>; t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">>;
size: SizeConfig; size: SizeConfig;
maxMetrics: number; maxMetrics: number;
backgroundColor: string;
itemRadius: MantineSize; itemRadius: MantineSize;
} }
@@ -311,14 +310,14 @@ const metricRenderers = [
}, },
] as const; ] 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); const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
return ( return (
<Card <Card
padding={size.cardPadding} padding={size.cardPadding}
radius={itemRadius} radius={itemRadius}
bg={backgroundColor} bg="transparent"
h="100%" h="100%"
style={{ style={{
overflow: "hidden", overflow: "hidden",
@@ -354,12 +353,8 @@ export default function BeszelSystemGridWidget({
}: WidgetComponentProps<"beszelSystemGrid">) { }: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid"); const t = useScopedI18n("widget.beszelSystemGrid");
const board = useRequiredBoard(); 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 }, { integrationIds },
{ staleTime: 5_000, refetchInterval: 5_000, retry: false }, { staleTime: 5_000, refetchInterval: 5_000, retry: false },
); );
@@ -367,6 +362,8 @@ export default function BeszelSystemGridWidget({
useBeszelSystemsSubscription(integrationIds, !isEditMode); useBeszelSystemsSubscription(integrationIds, !isEditMode);
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
if (systemsError) throw systemsError;
const cols = getColCount(width, filteredSystems.length); const cols = getColCount(width, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1; const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows; const rawCellHeight = height / rows;
@@ -396,7 +393,6 @@ export default function BeszelSystemGridWidget({
t={t} t={t}
size={size} size={size}
maxMetrics={maxMetrics} maxMetrics={maxMetrics}
backgroundColor={backgroundColor}
itemRadius={board.itemRadius} itemRadius={board.itemRadius}
/> />
))} ))}
@@ -3,4 +3,12 @@
top: 6px; top: 6px;
left: 6px; left: 6px;
z-index: 1; z-index: 1;
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
}
.beszelStatsContainer:hover .beszelStatsSystemToggle {
opacity: 1;
pointer-events: auto;
} }
@@ -61,10 +61,11 @@ export default function BeszelSystemStatsWidget({
const { data: session } = useSession(); const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false; const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation(); const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation();
const { data: systemsResult = [], isPending: systemsPending } = clientApi.widget.beszel.getSystems.useQuery( const {
{ integrationIds }, data: systemsResult = [],
{ staleTime: 30 * 1000 }, isPending: systemsPending,
); error: systemsError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }, { staleTime: 30 * 1000, retry: false });
const systems = useMemo( const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))), () => 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 showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const isLive = options.timePeriod === "1m"; const isLive = options.timePeriod === "1m";
const { data: statsResult } = clientApi.widget.beszel.getSystemStats.useQuery( const { data: statsResult, error: statsError } = clientApi.widget.beszel.getSystemStats.useQuery(
{ {
integrationIds, integrationIds,
systemId: selectedSystem, systemId: selectedSystem,
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d", timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker, 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); const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
@@ -185,6 +186,9 @@ export default function BeszelSystemStatsWidget({
const cols = gridColumns[Number(width > 600)]; const cols = gridColumns[Number(width > 600)];
if (systemsError) throw systemsError;
if (!isLive && statsError) throw statsError;
if (!systemsPending && !systemExists && systems.length > 0) { if (!systemsPending && !systemExists && systems.length > 0) {
return ( return (
<Center h="100%"> <Center h="100%">
@@ -222,7 +226,11 @@ export default function BeszelSystemStatsWidget({
} }
return ( return (
<ScrollArea h="100%" style={{ pointerEvents: (isEditMode && "none") || undefined }}> <ScrollArea
h="100%"
className={classes.beszelStatsContainer}
style={{ pointerEvents: (isEditMode && "none") || undefined }}
>
<Stack gap="md" p="sm"> <Stack gap="md" p="sm">
{!isEditMode && systems.length > 1 && ( {!isEditMode && systems.length > 1 && (
<Menu position="bottom-start" withArrow shadow="md" withinPortal> <Menu position="bottom-start" withArrow shadow="md" withinPortal>
@@ -66,7 +66,7 @@ export default function BeszelSystemTableWidget({
width, width,
}: WidgetComponentProps<"beszelSystemTable">) { }: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.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 }, { integrationIds },
{ staleTime: 5_000, refetchInterval: 5_000, retry: false }, { staleTime: 5_000, refetchInterval: 5_000, retry: false },
); );
@@ -255,6 +255,8 @@ export default function BeszelSystemTableWidget({
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[]; return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [options, t, size]); }, [options, t, size]);
if (systemsError) throw systemsError;
return ( return (
<DataTable <DataTable
style={{ pointerEvents: isEditMode ? "none" : undefined }} style={{ pointerEvents: isEditMode ? "none" : undefined }}
@@ -1,3 +1,17 @@
/* Make the DataTable transparent so the board card's opacity-controlled
background shows through. The mantine-datatable defaults to
var(--mantine-color-body) which is opaque. */
.beszel-table,
.beszel-table .mantine-datatable-table,
.beszel-table .mantine-datatable-table thead,
.beszel-table .mantine-datatable-table tbody,
.beszel-table .mantine-datatable-table tfoot,
.beszel-table .mantine-datatable-table tr,
.beszel-table th,
.beszel-table td {
background-color: transparent !important;
}
.beszel-table th { .beszel-table th {
white-space: nowrap; white-space: nowrap;
} }
@@ -19,7 +19,7 @@ function prepareRecords<T>(records: T[], live: boolean) {
} }
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const; 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; const panelStyle = { minWidth: 0, overflow: "hidden" } as const;
export const CPU_Y_AXIS_DOMAIN: [number, string] = [0, "auto"]; export const CPU_Y_AXIS_DOMAIN: [number, string] = [0, "auto"];