From 82eac2dcc10530ad4aff2e993beee6c9b4273aa6 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 14:58:14 +0200 Subject: [PATCH 01/56] fix(beszel): make chart stacking configurable and fix color mismatch BeszelAreaChart hardcoded type="stacked" for all charts, causing Docker Network I/O to render blended colors that didn't match the tooltip. - Make `type` prop configurable (default "default") - Only stack Memory, Docker CPU, and Docker Memory charts - Add systemId as a persisted widget option with Menu-based selector - Replace Popover with Menu trigger="click-hover" for proper hover UX - Show error state with Select when selected system is not found - Remove 1-minute time period (live SSE not wired yet) --- .../src/beszel-system-stats/component.tsx | 107 +++++++++++++----- .../widgets/src/beszel-system-stats/index.ts | 14 ++- packages/widgets/src/beszel/_shared/chart.tsx | 53 +++++++-- 3 files changed, 132 insertions(+), 42 deletions(-) diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 2612ee81a..f502c5598 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -1,11 +1,14 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; -import { ScrollArea, Select, SimpleGrid, Stack } from "@mantine/core"; +import { useCallback, useMemo } from "react"; +import { ActionIcon, Center, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core"; +import { IconServer, IconServerQuestion } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; +import classes from "./component.module.css"; + import type { WidgetComponentProps } from "../definition"; import { containerColors } from "../beszel/_shared/colors"; import { @@ -37,6 +40,7 @@ export default function BeszelSystemStatsWidget({ integrationIds, isEditMode, width, + setOptions, }: WidgetComponentProps<"beszelSystemStats">) { const t = useScopedI18n("widget.beszelSystemStats"); const { data: systemsResult = [] } = clientApi.widget.beszel.getSystems.useQuery( @@ -49,24 +53,27 @@ export default function BeszelSystemStatsWidget({ [systemsResult], ); - const [selectedSystem, setSelectedSystem] = useState(systems[0]?.value ?? null); + const selectedSystem = options.systemId || systems[0]?.value || ""; + const selectedLabel = systems.find((s) => s.value === selectedSystem)?.label; + const systemExists = systems.length === 0 || systems.some((s) => s.value === selectedSystem); + + const handleSelectSystem = useCallback( + (value: string) => setOptions({ newOptions: { systemId: value } }), + [setOptions], + ); const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork; const { data: statsResult } = clientApi.widget.beszel.getSystemStats.useQuery( { integrationIds, - systemId: selectedSystem ?? systems[0]?.value ?? "", + systemId: selectedSystem, timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d", includeDocker: showDocker, }, - { staleTime: 30 * 1000 }, + { staleTime: 30 * 1000, enabled: systemExists && selectedSystem !== "" }, ); - // BeszelSystemStats fields: storage values (m, mu, mb, d, du, s, su) are already in GB. - // Disk I/O (dr, dw) is in MB/s — scale to bytes/s for display formatters. - // Network (ns, nr, b) is in bytes/s. Container memory (m) is in MB. - const cpuMap = useCallback((s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), [t]); const memoryMap = useCallback( (s: { mu: number; mb: number }) => ({ @@ -83,7 +90,6 @@ export default function BeszelSystemStatsWidget({ }), [t], ); - // b = public interface bandwidth (preferred), ns/nr = all-interface traffic (fallback) const networkMap = useCallback( (s: { ns: number; nr: number; b?: [number, number] }) => ({ [t("chart.network.sent")]: s.b?.[0] ?? s.ns, @@ -110,16 +116,68 @@ export default function BeszelSystemStatsWidget({ const cols = width > 600 ? 2 : 1; + if (!systemExists && systems.length > 0) { + return ( +
+ + + + {t("error.systemNotFound")} + + + {!isEditMode && systems.length > 1 && ( + + + + + + + + {systems.map((s) => ( + handleSelectSystem(s.value)} + > + {s.label} + + ))} + + + )} {statsResult && ( @@ -129,7 +187,6 @@ export default function BeszelSystemStatsWidget({ h={CHART_HEIGHT} data={cpuData} series={[{ name: t("chart.cpu.series"), color: "teal.6" }]} - fillOpacity={0.4} yAxisFormatter={chartAxisFormatters.percent} yAxisDomain={[0, "auto"]} tooltipProps={tooltipPercent} @@ -142,14 +199,13 @@ export default function BeszelSystemStatsWidget({ )} @@ -160,7 +216,6 @@ export default function BeszelSystemStatsWidget({ h={CHART_HEIGHT} data={diskData} series={[{ name: t("chart.disk.series"), color: "grape.6" }]} - fillOpacity={0.4} yAxisFormatter={chartAxisFormatters.gb} tooltipProps={tooltipGB} /> @@ -176,10 +231,8 @@ export default function BeszelSystemStatsWidget({ { name: t("chart.diskIO.write"), color: "orange.6" }, { name: t("chart.diskIO.read"), color: "blue.6" }, ]} - fillOpacity={0.3} yAxisFormatter={chartAxisFormatters.rate} tooltipProps={tooltipRate} - withLegend /> )} @@ -193,10 +246,8 @@ export default function BeszelSystemStatsWidget({ { name: t("chart.network.sent"), color: "blue.6" }, { name: t("chart.network.recv"), color: "teal.6" }, ]} - fillOpacity={0.3} yAxisFormatter={chartAxisFormatters.rate} tooltipProps={tooltipRate} - withLegend /> )} @@ -210,7 +261,6 @@ export default function BeszelSystemStatsWidget({ data={dockerCpuData} type="stacked" series={containerSeries} - fillOpacity={0.6} yAxisFormatter={chartAxisFormatters.percent} tooltipProps={tooltipPercentTotal} /> @@ -224,7 +274,6 @@ export default function BeszelSystemStatsWidget({ data={dockerMemData} type="stacked" series={containerSeries} - fillOpacity={0.6} yAxisFormatter={chartAxisFormatters.bytes} tooltipProps={tooltipBytesTotal} /> @@ -236,9 +285,7 @@ export default function BeszelSystemStatsWidget({ diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts index 1afff7465..674c41d4c 100644 --- a/packages/widgets/src/beszel-system-stats/index.ts +++ b/packages/widgets/src/beszel-system-stats/index.ts @@ -1,4 +1,4 @@ -import { IconChartAreaLine } from "@tabler/icons-react"; +import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -17,7 +17,11 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys integrationsRequired: true, createOptions() { return optionsBuilder.from((factory) => ({ - timePeriod: factory.select({ defaultValue: "1h", options: timePeriodOptions }), + systemId: factory.text({ defaultValue: "" }), + timePeriod: factory.select({ + defaultValue: "1h", + options: timePeriodOptions, + }), showCpu: factory.switch({ defaultValue: true }), showMemory: factory.switch({ defaultValue: true }), showDisk: factory.switch({ defaultValue: true }), @@ -28,4 +32,10 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys showDockerNetwork: factory.switch({ defaultValue: true }), })); }, + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.beszelSystemStats.error.internalServerError"), + }, + }, }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx index f7b735255..14c6a64f0 100644 --- a/packages/widgets/src/beszel/_shared/chart.tsx +++ b/packages/widgets/src/beszel/_shared/chart.tsx @@ -6,9 +6,14 @@ import type { AreaChartProps } from "@mantine/charts"; import { AreaChart } from "@mantine/charts"; import dayjs from "dayjs"; -import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; +import type { + BeszelContainerStatsRecord, + BeszelSystemStatsRecord, +} from "@homarr/integrations/types"; const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm"); +const formatTimeLive = (timestamp: string) => + dayjs(timestamp).format("HH:mm:ss"); const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const; @@ -34,22 +39,41 @@ export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => ( ); -type BeszelAreaChartProps = Omit & { +type BeszelAreaChartProps = Omit< + AreaChartProps, + "dataKey" | "curveType" | "withDots" | "withXAxis" | "withYAxis" +> & { yAxisFormatter: (value: number) => string; yAxisDomain?: [number, string]; }; -export const BeszelAreaChart = ({ yAxisFormatter, yAxisDomain, yAxisProps, style, ...props }: BeszelAreaChartProps) => ( +export const BeszelAreaChart = ({ + yAxisFormatter, + yAxisDomain, + yAxisProps, + style, + type = "default", + ...props +}: BeszelAreaChartProps) => ( Record, + live = false, ) => useMemo(() => { if (!systemStats) return []; + const fmt = live ? formatTimeLive : formatTime; return [...systemStats].toReversed().map((r) => ({ - time: formatTime(r.created), + time: fmt(r.created), ...mapFn(r.stats), })); - }, [systemStats, mapFn]); + }, [systemStats, mapFn, live]); -export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => +export const useContainerNames = ( + containerStats: BeszelContainerStatsRecord[] | undefined, + max = 15, +) => useMemo(() => { if (!containerStats?.length) return []; const names = new Set(); @@ -82,7 +111,9 @@ export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | return [...names].slice(0, max); }, [containerStats, max]); -type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number; +type ContainerExtractor = ( + container: BeszelContainerStatsRecord["stats"][number] | undefined, +) => number; // c = CPU (%), m = memory (MB), b = bandwidth [sent,recv] (bytes/s), ns/nr = legacy net (bytes/s) const MB = 1024 * 1024; @@ -97,16 +128,18 @@ export const useDockerChartData = ( containerStats: BeszelContainerStatsRecord[] | undefined, containerNames: string[], metric: "cpu" | "memory" | "network", + live = false, ) => useMemo(() => { if (!containerStats?.length) return []; const extract = defaultContainerExtractors[metric]; if (!extract) return []; + const fmt = live ? formatTimeLive : formatTime; return [...containerStats].toReversed().map((record) => { - const point: Record = { time: formatTime(record.created) }; + const point: Record = { time: fmt(record.created) }; for (const name of containerNames) { point[name] = extract(record.stats.find((c) => c.n === name)); } return point; }); - }, [containerStats, containerNames, metric]); + }, [containerStats, containerNames, metric, live]); From 90ceaf5fe87922be52f9feec4dd5a5fbc6edbe1a Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 14:58:20 +0200 Subject: [PATCH 02/56] feat(beszel): add hover-reveal CSS for system toggle button System selector icon fades in on widget hover via CSS module. --- .../widgets/src/beszel-system-stats/component.module.css | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 packages/widgets/src/beszel-system-stats/component.module.css diff --git a/packages/widgets/src/beszel-system-stats/component.module.css b/packages/widgets/src/beszel-system-stats/component.module.css new file mode 100644 index 000000000..a4a7c56a6 --- /dev/null +++ b/packages/widgets/src/beszel-system-stats/component.module.css @@ -0,0 +1,8 @@ +.beszelStatsSystemToggle { + opacity: 0; + transition: opacity 150ms ease; +} + +.beszelStatsWrapper:hover .beszelStatsSystemToggle { + opacity: 1; +} From 824f31d6aaeab5f846eda6a3019471e0a07421b9 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 14:58:27 +0200 Subject: [PATCH 03/56] fix(beszel): deduplicate tooltip entries and round indicator pill Non-stacked charts produce duplicate Recharts payload entries (stroke + fill). Deduplicate by dataKey keeping the first entry (correct color). Also increase indicator borderRadius for a fully rounded pill. --- packages/widgets/src/beszel/_shared/tooltip.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/widgets/src/beszel/_shared/tooltip.tsx b/packages/widgets/src/beszel/_shared/tooltip.tsx index 8f89fa4d4..136addb97 100644 --- a/packages/widgets/src/beszel/_shared/tooltip.tsx +++ b/packages/widgets/src/beszel/_shared/tooltip.tsx @@ -13,13 +13,11 @@ const styles: Record = { fontSize: 12, lineHeight: 1.5, pointerEvents: "none", - maxHeight: 320, - overflowY: "auto", overflowX: "hidden", }, - header: { marginBottom: 4, fontWeight: 500, color: "var(--mantine-color-gray-2)" }, + header: { marginBottom: 4, fontWeight: 550, color: "var(--mantine-color-gray-2)" }, row: { display: "flex", alignItems: "center", gap: 6, padding: "1px 0" }, - indicator: { width: 3, height: 14, borderRadius: 1, flexShrink: 0 }, + indicator: { width: 5, height: 14, borderRadius: 5, flexShrink: 0 }, name: { flex: 1, color: "var(--mantine-color-gray-3)", @@ -78,7 +76,11 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: if (!isActive) return null; - const sorted = [...(payload ?? [])].filter((p) => p.value > 0).toSorted((a, b) => b.value - a.value); + const deduped = new Map(); + for (const p of (payload ?? []).filter((p) => p.value > 0)) { + if (!deduped.has(p.dataKey)) deduped.set(p.dataKey, p); + } + const sorted = [...deduped.values()].toSorted((a, b) => b.value - a.value); if (!sorted.length) return null; From 4d8beac7d3bf39f99edeaca680741b34716519d4 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 14:58:32 +0200 Subject: [PATCH 04/56] style(beszel): reorder container colors to rainbow spectrum Rearrange containerColors from random order to rainbow sequence (red -> orange -> yellow -> ... -> violet -> grape -> pink). --- packages/widgets/src/beszel/_shared/colors.ts | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/widgets/src/beszel/_shared/colors.ts b/packages/widgets/src/beszel/_shared/colors.ts index 6badd5c58..22e1e663b 100644 --- a/packages/widgets/src/beszel/_shared/colors.ts +++ b/packages/widgets/src/beszel/_shared/colors.ts @@ -21,19 +21,16 @@ export const statusColorMap: Record = { }; export const containerColors = [ - "blue.6", - "teal.6", - "orange.6", - "grape.6", - "cyan.6", - "pink.6", - "lime.6", - "violet.6", - "indigo.6", - "yellow.6", "red.6", + "orange.6", + "yellow.6", + "lime.6", "green.6", - "blue.4", - "teal.4", - "orange.4", + "teal.6", + "cyan.6", + "blue.6", + "indigo.6", + "violet.6", + "grape.6", + "pink.6", ]; From 5035c26dbddfef78ef5a1fc873f81ea9c8194f51 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 14:58:38 +0200 Subject: [PATCH 05/56] feat(beszel): add translation keys for system stats widget Add systemId option label, systemNotFound error message, and internalServerError translations for all beszel widgets. --- packages/translation/src/lang/en.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 98be3be86..e66006857 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3561,6 +3561,9 @@ } }, "noRecords": "No systems found", + "error": { + "internalServerError": "Unable to connect to Beszel. Check your integration settings." + }, "column": { "system": "System", "cpu": "CPU", @@ -3617,6 +3620,9 @@ "label": "Show agent version" } }, + "error": { + "internalServerError": "Unable to connect to Beszel. Check your integration settings." + }, "metric": { "cpu": "CPU", "memory": "Memory", @@ -3643,6 +3649,9 @@ } }, "empty": "No alerts configured", + "error": { + "internalServerError": "Unable to connect to Beszel. Check your integration settings." + }, "history": "History", "resolved": "Resolved", "status": { @@ -3654,6 +3663,9 @@ "name": "Beszel System Stats", "description": "Time-series charts for CPU, memory, disk, network, and Docker container metrics", "option": { + "systemId": { + "label": "System ID" + }, "timePeriod": { "label": "Time period" }, @@ -3683,6 +3695,10 @@ } }, "selectSystem": "Select system", + "error": { + "internalServerError": "Unable to connect to Beszel. Check your integration settings.", + "systemNotFound": "The {name} system was not found. Choose a different system." + }, "chart": { "cpu": { "title": "CPU Usage", From c9769bf53083147a38c69603fd19933f69d8d06c Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 14:58:47 +0200 Subject: [PATCH 06/56] feat(beszel): add SSE subscription endpoint and per-period cache TTL - Add subscribeSystemStats tRPC subscription bridging PocketBase SSE - Add subscribeRealtimeMetrics to BeszelIntegration with SSE parsing - Add error handling: try/catch in subscription, response check in topic POST - Add cacheDurationForInput to request handler for per-period cache TTL - Add mock subscribeRealtimeMetrics with 1s interval for dev testing --- packages/api/src/router/widgets/beszel.ts | 100 ++++++++++++++--- .../src/beszel/beszel-integration.ts | 102 ++++++++++++++++++ packages/integrations/src/mock/data/beszel.ts | 29 +++++ .../integrations/src/mock/mock-integration.ts | 1 + packages/request-handler/src/beszel.ts | 81 ++++++++------ .../lib/cached-integration-request-handler.ts | 8 +- 6 files changed, 272 insertions(+), 49 deletions(-) diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index d3b895d88..c1c0fe7c6 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -1,12 +1,17 @@ import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; +import { createIntegrationAsync } from "@homarr/integrations"; +import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; import { beszelAlertsRequestHandler, beszelStatsRequestHandler, beszelSystemsRequestHandler, } from "@homarr/request-handler/beszel"; -import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel"; +import type { + BeszelAlertsData, + BeszelSystemRow, +} from "@homarr/request-handler/beszel"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -17,8 +22,14 @@ export const beszelRouter = createTRPCRouter({ .query(async ({ ctx }) => { const results = await Promise.all( ctx.integrations.map(async (integration) => { - const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const innerHandler = beszelSystemsRequestHandler.handler( + integration, + {}, + ); + const { data, timestamp } = + await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); return { integrationId: integration.id, integrationName: integration.name, @@ -34,9 +45,16 @@ export const beszelRouter = createTRPCRouter({ subscribeSystems: publicProcedure .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .subscription(({ ctx }) => { - return observable<{ integrationId: string; systems: BeszelSystemRow[]; timestamp: Date }>((emit) => { + return observable<{ + integrationId: string; + systems: BeszelSystemRow[]; + timestamp: Date; + }>((emit) => { const unsubscribes = ctx.integrations.map((integration) => { - const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); + const innerHandler = beszelSystemsRequestHandler.handler( + integration, + {}, + ); return innerHandler.subscribe((systems) => { emit.next({ integrationId: integration.id, @@ -62,11 +80,17 @@ export const beszelRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { const results = await Promise.all( ctx.integrations.map(async (integration) => { - const alertsHandler = beszelAlertsRequestHandler.handler(integration, { - includeHistory: input.includeHistory, - maxHistoryItems: input.maxHistoryItems, - }); - const systemsHandler = beszelSystemsRequestHandler.handler(integration, {}); + const alertsHandler = beszelAlertsRequestHandler.handler( + integration, + { + includeHistory: input.includeHistory, + maxHistoryItems: input.maxHistoryItems, + }, + ); + const systemsHandler = beszelSystemsRequestHandler.handler( + integration, + {}, + ); const [alertsResult, systemsResult] = await Promise.all([ alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), @@ -96,14 +120,22 @@ export const beszelRouter = createTRPCRouter({ }), ) .subscription(({ ctx, input }) => { - return observable<{ integrationId: string; alerts: BeszelAlertsData; timestamp: Date }>((emit) => { + return observable<{ + integrationId: string; + alerts: BeszelAlertsData; + timestamp: Date; + }>((emit) => { const unsubscribes = ctx.integrations.map((integration) => { const innerHandler = beszelAlertsRequestHandler.handler(integration, { includeHistory: input.includeHistory, maxHistoryItems: input.maxHistoryItems, }); return innerHandler.subscribe((data) => { - emit.next({ integrationId: integration.id, alerts: data, timestamp: new Date() }); + emit.next({ + integrationId: integration.id, + alerts: data, + timestamp: new Date(), + }); }); }); return () => { @@ -117,7 +149,7 @@ export const beszelRouter = createTRPCRouter({ .input( z.object({ systemId: z.string(), - timePeriod: z.enum(["1h", "12h", "24h", "1w", "30d"]), + timePeriod: z.enum(["1m", "1h", "12h", "24h", "1w", "30d"]), includeDocker: z.boolean().default(true), }), ) @@ -129,7 +161,47 @@ export const beszelRouter = createTRPCRouter({ timePeriod: input.timePeriod, includeDocker: input.includeDocker, }); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data, timestamp } = + await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return { integrationId: integration.id, ...data, updatedAt: timestamp }; }), + + subscribeSystemStats: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .input( + z.object({ + systemId: z.string(), + includeDocker: z.boolean().default(true), + }), + ) + .subscription(({ ctx, input }) => { + return observable<{ + stats: BeszelSystemStatsRecord; + containerStats: BeszelContainerStatsRecord | null; + }>((emit) => { + const integration = ctx.integrations[0]; + if (!integration) return () => {}; + + const abortController = new AbortController(); + + void (async () => { + try { + const instance = await createIntegrationAsync(integration); + await instance.subscribeRealtimeMetrics( + input.systemId, + (data) => emit.next(data), + abortController.signal, + ); + } catch (error) { + if (!abortController.signal.aborted) { + emit.error(error instanceof Error ? error : new Error(String(error))); + } + } + })(); + + return () => { + abortController.abort(); + }; + }); + }), }); diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts index eb646c5dc..d74303164 100644 --- a/packages/integrations/src/beszel/beszel-integration.ts +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -1,4 +1,5 @@ import { ResponseError } from "@homarr/common/server"; +import { createLogger } from "@homarr/core/infrastructure/logs"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import type { IntegrationInput, IntegrationTestingInput } from "../base/integration"; @@ -12,17 +13,21 @@ import type { BeszelAlertHistory, BeszelAuthResponse, BeszelContainer, + BeszelContainerStats, BeszelContainerStatsRecord, BeszelSmartDevice, BeszelSystem, BeszelSystemDetails, BeszelSystemdService, + BeszelSystemStats, BeszelSystemStatsRecord, CreateAlertInput, PocketBaseListResponse, UpdateAlertInput, } from "./beszel-types"; +const logger = createLogger({ module: "beszel-integration" }); + const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); interface BeszelSession { @@ -245,6 +250,103 @@ export class BeszelIntegration extends Integration { }); } + public async subscribeRealtimeMetrics( + systemId: string, + onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void, + signal: AbortSignal, + ): Promise { + const session = await this.authenticateAsync(); + const sseUrl = this.url("/api/realtime"); + + const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, { + headers: { Authorization: session.token }, + signal, + }); + + if (!sseResponse.ok || !sseResponse.body) { + throw new ResponseError(sseResponse); + } + + const reader = sseResponse.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let clientId: string | null = null; + + const processLine = (line: string) => { + if (!line.startsWith("data:")) return; + const jsonStr = line.slice(5).trim(); + if (!jsonStr) return; + + try { + const parsed = JSON.parse(jsonStr) as Record; + + if (!clientId && typeof parsed.clientId === "string") { + clientId = parsed.clientId; + this.subscribeTopic(clientId, systemId, session.token).catch((err) => { + logger.warn("Failed to subscribe to rt_metrics topic", { error: err }); + }); + return; + } + + if ("stats" in parsed) { + const stats: BeszelSystemStatsRecord = { + id: "", + system: systemId, + stats: parsed.stats as BeszelSystemStats, + type: "1m", + created: new Date().toISOString(), + updated: new Date().toISOString(), + }; + const containerStats: BeszelContainerStatsRecord | null = + parsed.container && Array.isArray(parsed.container) && (parsed.container as BeszelContainerStats[]).length > 0 + ? { + id: "", + system: systemId, + stats: parsed.container as BeszelContainerStats[], + type: "1m", + created: new Date().toISOString(), + updated: new Date().toISOString(), + } + : null; + onMessage({ stats, containerStats }); + } + } catch { + logger.debug("Failed to parse SSE message", { line }); + } + }; + + try { + while (!signal.aborted) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + processLine(line); + } + } + } finally { + reader.cancel().catch(() => {}); + } + } + + private async subscribeTopic(clientId: string, systemId: string, token: string): Promise { + const topic = `rt_metrics?options=${JSON.stringify({ query: { system: systemId } })}`; + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/realtime"), { + method: "POST", + headers: { + Authorization: token, + "Content-Type": "application/json", + }, + body: JSON.stringify({ clientId, subscriptions: [topic] }), + }); + if (!response.ok) { + throw new ResponseError(response); + } + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), { method: "POST", diff --git a/packages/integrations/src/mock/data/beszel.ts b/packages/integrations/src/mock/data/beszel.ts index 40acc78be..e099f00be 100644 --- a/packages/integrations/src/mock/data/beszel.ts +++ b/packages/integrations/src/mock/data/beszel.ts @@ -330,6 +330,35 @@ export class BeszelMockService { ]; } + public async subscribeRealtimeMetrics( + systemId: string, + onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void, + signal: AbortSignal, + ): Promise { + const sys = resolveSystem(systemId); + const emit = () => { + if (signal.aborted) return; + const [stats] = generateSystemStatsSeries(1, sys); + if (!stats) return; + const record: BeszelSystemStatsRecord = { + id: `mock-rt-${Date.now()}`, + system: systemId, + stats, + type: "1m", + created: new Date().toISOString(), + updated: new Date().toISOString(), + }; + onMessage({ stats: record, containerStats: null }); + }; + await new Promise((resolve) => { + const interval = setInterval(emit, 1000); + signal.addEventListener("abort", () => { + clearInterval(interval); + resolve(); + }); + }); + } + public async getAlertHistoryAsync(_systemId?: string, perPage = 10): Promise { const now = Date.now(); const names = ["CPU", "Memory", "Disk", "Temperature", "Bandwidth"]; diff --git a/packages/integrations/src/mock/mock-integration.ts b/packages/integrations/src/mock/mock-integration.ts index 0804267b1..d7eb412cf 100644 --- a/packages/integrations/src/mock/mock-integration.ts +++ b/packages/integrations/src/mock/mock-integration.ts @@ -140,4 +140,5 @@ export class MockIntegration getContainerStatsAsync = MockIntegration.beszel.getContainerStatsAsync.bind(MockIntegration.beszel); getAlertsAsync = MockIntegration.beszel.getAlertsAsync.bind(MockIntegration.beszel); getAlertHistoryAsync = MockIntegration.beszel.getAlertHistoryAsync.bind(MockIntegration.beszel); + subscribeRealtimeMetrics = MockIntegration.beszel.subscribeRealtimeMetrics.bind(MockIntegration.beszel); } diff --git a/packages/request-handler/src/beszel.ts b/packages/request-handler/src/beszel.ts index 053edf52b..f3dfc8f44 100644 --- a/packages/request-handler/src/beszel.ts +++ b/packages/request-handler/src/beszel.ts @@ -24,7 +24,10 @@ export type { BeszelSystemRow } from "@homarr/integrations/types"; * bb=bandwidth(bytes/s), b=bandwidth(Mbps legacy), dt=disk temp, bat=battery, * sv=[running,total] services, h=hostname, m=CPU model, c=cores, ct=threads */ -function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | null): BeszelSystemRow { +function mapToSystemRow( + system: BeszelSystem, + details: BeszelSystemDetails | null, +): BeszelSystemRow { const info = system.info; return { id: system.id, @@ -50,25 +53,28 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul }; } -export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler< - BeszelSystemRow[], - "beszel" | "mock", - Record ->({ - async requestAsync(integration) { - const instance = await createIntegrationAsync(integration); - const systems = await instance.getSystemsAsync(); - const enriched = await Promise.all( - systems.map(async (system) => { - const details = await instance.getSystemDetailsAsync(system.id).catch(() => null); - return mapToSystemRow(system, details); - }), - ); - return enriched; - }, - cacheDuration: dayjs.duration(5, "seconds"), - queryKey: "beszelSystems", -}); +export const beszelSystemsRequestHandler = + createCachedIntegrationRequestHandler< + BeszelSystemRow[], + "beszel" | "mock", + Record + >({ + async requestAsync(integration) { + const instance = await createIntegrationAsync(integration); + const systems = await instance.getSystemsAsync(); + const enriched = await Promise.all( + systems.map(async (system) => { + const details = await instance + .getSystemDetailsAsync(system.id) + .catch(() => null); + return mapToSystemRow(system, details); + }), + ); + return enriched; + }, + cacheDuration: dayjs.duration(5, "seconds"), + queryKey: "beszelSystems", + }); export interface BeszelAlertsData { alerts: BeszelAlert[]; @@ -83,19 +89,22 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler< async requestAsync(integration, input) { const instance = await createIntegrationAsync(integration); const alerts = await instance.getAlertsAsync(); - const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : []; + const history = input.includeHistory + ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) + : []; return { alerts, history }; }, cacheDuration: dayjs.duration(15, "seconds"), queryKey: "beszelAlerts", }); -const timePeriodConfig: Record = { - "1h": { type: "1m", perPage: 60 }, - "12h": { type: "10m", perPage: 72 }, - "24h": { type: "20m", perPage: 72 }, - "1w": { type: "120m", perPage: 84 }, - "30d": { type: "480m", perPage: 90 }, +const timePeriodConfig: Record = { + "1m": { type: "1m", perPage: 60, cacheSeconds: 5 }, + "1h": { type: "1m", perPage: 60, cacheSeconds: 60 }, + "12h": { type: "10m", perPage: 72, cacheSeconds: 300 }, + "24h": { type: "20m", perPage: 72, cacheSeconds: 600 }, + "1w": { type: "120m", perPage: 84, cacheSeconds: 1800 }, + "30d": { type: "480m", perPage: 90, cacheSeconds: 3600 }, }; export interface BeszelStatsData { @@ -109,14 +118,24 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler< { systemId: string; timePeriod: string; includeDocker: boolean } >({ async requestAsync(integration, input) { - const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 }; + const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 }; const instance = await createIntegrationAsync(integration); - const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage); + const systemStats = await instance.getSystemStatsAsync( + input.systemId, + config.type, + config.perPage, + ); const containerStats = input.includeDocker - ? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => []) + ? await instance + .getContainerStatsAsync(input.systemId, config.type, config.perPage) + .catch(() => []) : []; return { systemStats, containerStats }; }, - cacheDuration: dayjs.duration(30, "seconds"), + cacheDuration: dayjs.duration(60, "seconds"), queryKey: "beszelStats", + cacheDurationForInput(input) { + const config = timePeriodConfig[input.timePeriod]; + return config ? dayjs.duration(config.cacheSeconds, "seconds") : undefined; + }, }); diff --git a/packages/request-handler/src/lib/cached-integration-request-handler.ts b/packages/request-handler/src/lib/cached-integration-request-handler.ts index 3cae8df0f..8c26fcbe6 100644 --- a/packages/request-handler/src/lib/cached-integration-request-handler.ts +++ b/packages/request-handler/src/lib/cached-integration-request-handler.ts @@ -14,10 +14,10 @@ type IntegrationOfKind = Omit> { - // Unique key for this request handler queryKey: string; requestAsync: (integration: IntegrationOfKind, input: TInput) => Promise; cacheDuration: Duration; + cacheDurationForInput?: (input: TInput) => Duration | undefined; } export const createCachedIntegrationRequestHandler = < @@ -34,9 +34,9 @@ export const createCachedIntegrationRequestHandler = < requestAsync: async (input: { options: TInput; integration: IntegrationOfKind }) => { return await options.requestAsync(input.integration, input.options); }, - cacheDuration: options.cacheDuration, - createRedisChannel(input, options) { - return createIntegrationOptionsChannel(input.integration.id, options.queryKey, input.options); + cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration, + createRedisChannel(input, handlerOptions) { + return createIntegrationOptionsChannel(input.integration.id, handlerOptions.queryKey, input.options); }, }).handler({ options: itemOptions, integration }), }; From 441cc692f914497bef9e6f3b4d8aa122a9ff8f80 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 14:58:53 +0200 Subject: [PATCH 07/56] feat(beszel): add error definitions and retry config to beszel widgets Add INTERNAL_SERVER_ERROR error definitions to alerts, grid, and table widgets. Set retry: false and appropriate staleTime/refetchInterval. --- .../widgets/src/beszel-alerts/component.tsx | 21 +++++++++++++++---- packages/widgets/src/beszel-alerts/index.ts | 13 ++++++++++-- .../src/beszel-system-grid/component.tsx | 8 +++++-- .../widgets/src/beszel-system-grid/index.ts | 13 ++++++++++-- .../src/beszel-system-table/component.tsx | 20 ++++++++++++++---- .../widgets/src/beszel-system-table/index.ts | 18 +++++++++++++--- 6 files changed, 76 insertions(+), 17 deletions(-) diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 1048ebdae..76ef5318e 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -42,22 +42,35 @@ export default function BeszelAlertsWidget({ includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems, }, - { staleTime: 30 * 1000 }, + { staleTime: 15_000, refetchInterval: 15_000, retry: false }, ); const utils = clientApi.useUtils(); clientApi.widget.beszel.subscribeAlerts.useSubscription( - { integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }, + { + integrationIds, + includeHistory: options.showHistory, + maxHistoryItems: options.maxHistoryItems, + }, { enabled: !isEditMode, onData(data) { utils.widget.beszel.getAlerts.setData( - { integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }, + { + integrationIds, + includeHistory: options.showHistory, + maxHistoryItems: options.maxHistoryItems, + }, (prev) => { if (!prev) return prev; return prev.map((r) => r.integrationId === data.integrationId - ? { ...r, alerts: data.alerts.alerts, history: data.alerts.history, updatedAt: data.timestamp } + ? { + ...r, + alerts: data.alerts.alerts, + history: data.alerts.history, + updatedAt: data.timestamp, + } : r, ); }, diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts index 828c88b27..5e3799a90 100644 --- a/packages/widgets/src/beszel-alerts/index.ts +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -1,4 +1,4 @@ -import { IconBell } from "@tabler/icons-react"; +import { IconBell, IconServerOff } from "@tabler/icons-react"; import { z } from "zod/v4"; import { createWidgetDefinition } from "../definition"; @@ -11,7 +11,16 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelAle createOptions() { return optionsBuilder.from((factory) => ({ showHistory: factory.switch({ defaultValue: true }), - maxHistoryItems: factory.number({ defaultValue: 10, validate: z.number().min(1).max(100) }), + maxHistoryItems: factory.number({ + defaultValue: 10, + validate: z.number().min(1).max(100), + }), })); }, + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.beszelAlerts.error.internalServerError"), + }, + }, }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index 6bdb7a139..b213c5e72 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -316,7 +316,11 @@ const SystemCard = ({ system, options, t, size, maxMetrics }: SystemCardProps) = radius="sm" withBorder h="100%" - style={{ overflow: "hidden", display: "flex", flexDirection: "column" as const }} + style={{ + overflow: "hidden", + display: "flex", + flexDirection: "column" as const, + }} > ({ - statusFilter: factory.select({ defaultValue: "all", options: statusOptions }), + statusFilter: factory.select({ + defaultValue: "all", + options: statusOptions, + }), showCpu: factory.switch({ defaultValue: true }), showMemory: factory.switch({ defaultValue: true }), showDisk: factory.switch({ defaultValue: true }), @@ -24,4 +27,10 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys showAgent: factory.switch({ defaultValue: true }), })); }, + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.beszelSystemGrid.error.internalServerError"), + }, + }, }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx index bb306d8f9..43d640b0f 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -34,7 +34,7 @@ type SystemRowWithKey = BeszelSystemRow & { _key: string }; interface SizeConfig { iconSize: number; - fontSize: "xs" | "10px"; + fontSize: "xs" | "sm"; progressSize: "xs" | "sm"; cellPadding: number; valueMiw: number; @@ -42,9 +42,21 @@ interface SizeConfig { const getSizeConfig = (width: number): SizeConfig => { if (width < 400) { - return { iconSize: 10, fontSize: "10px", progressSize: "xs", cellPadding: 2, valueMiw: 30 }; + return { + iconSize: 10, + fontSize: "xs", + progressSize: "xs", + cellPadding: 2, + valueMiw: 30, + }; } - return { iconSize: 14, fontSize: "xs", progressSize: "sm", cellPadding: 4, valueMiw: 38 }; + return { + iconSize: 14, + fontSize: "sm", + progressSize: "sm", + cellPadding: 4, + valueMiw: 38, + }; }; export default function BeszelSystemTableWidget({ @@ -56,7 +68,7 @@ export default function BeszelSystemTableWidget({ const t = useScopedI18n("widget.beszelSystemTable"); const { data: results = [] } = clientApi.widget.beszel.getSystems.useQuery( { integrationIds }, - { staleTime: 30 * 1000 }, + { staleTime: 5_000, refetchInterval: 5_000, retry: false }, ); const size = getSizeConfig(width); diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts index 4e8f985c4..538649afd 100644 --- a/packages/widgets/src/beszel-system-table/index.ts +++ b/packages/widgets/src/beszel-system-table/index.ts @@ -1,4 +1,4 @@ -import { IconTable } from "@tabler/icons-react"; +import { IconServerOff, IconTable } from "@tabler/icons-react"; import { createWidgetDefinition } from "../definition"; import { statusOptions } from "../beszel/_shared/options"; @@ -29,9 +29,15 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys integrationsRequired: true, createOptions() { return optionsBuilder.from((factory) => ({ - statusFilter: factory.select({ defaultValue: "all", options: statusOptions }), + statusFilter: factory.select({ + defaultValue: "all", + options: statusOptions, + }), sortBy: factory.select({ defaultValue: "name", options: sortOptions }), - sortDirection: factory.select({ defaultValue: "asc", options: sortDirectionOptions }), + sortDirection: factory.select({ + defaultValue: "asc", + options: sortDirectionOptions, + }), showCpu: factory.switch({ defaultValue: true }), showMemory: factory.switch({ defaultValue: true }), showDisk: factory.switch({ defaultValue: true }), @@ -45,4 +51,10 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys showAgent: factory.switch({ defaultValue: true }), })); }, + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.beszelSystemTable.error.internalServerError"), + }, + }, }).withDynamicImport(() => import("./component")); From e22eee271c6b73138bf029efd71fc1c61e625323 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 14:59:01 +0200 Subject: [PATCH 08/56] docs(beszel): update system stats widget documentation and add time-period config Update docs with new widget options and add client-side time-period configuration for future live streaming support. --- .../docs/widgets/beszel-system-stats/index.ts | 4 ++-- .../widgets/src/beszel/_shared/time-period.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 packages/widgets/src/beszel/_shared/time-period.ts diff --git a/apps/docs/docs/widgets/beszel-system-stats/index.ts b/apps/docs/docs/widgets/beszel-system-stats/index.ts index 4b7d5fe7c..d19ae9154 100644 --- a/apps/docs/docs/widgets/beszel-system-stats/index.ts +++ b/apps/docs/docs/widgets/beszel-system-stats/index.ts @@ -10,10 +10,10 @@ export const beszelSystemStatsWidget: WidgetDefinition = { items: [ { name: "Time period", - description: "The time range for chart data", + description: "The time range for chart data. '1 Minute' enables live streaming via PocketBase SSE (requires Beszel agent >= 0.13.0)", values: { type: "select", - options: ["1 Hour", "12 Hours", "24 Hours", "1 Week", "30 Days"], + options: ["1 Minute", "1 Hour", "12 Hours", "24 Hours", "1 Week", "30 Days"], }, defaultValue: "1 Hour", }, diff --git a/packages/widgets/src/beszel/_shared/time-period.ts b/packages/widgets/src/beszel/_shared/time-period.ts new file mode 100644 index 000000000..83ecba855 --- /dev/null +++ b/packages/widgets/src/beszel/_shared/time-period.ts @@ -0,0 +1,19 @@ +export const beszelTimePeriods = ["1m", "1h", "12h", "24h", "1w", "30d"] as const; +export type BeszelTimePeriod = (typeof beszelTimePeriods)[number]; + +export const beszelTimePeriodConfig: Record< + BeszelTimePeriod, + { + beszelStatType: string; + perPage: number; + refetchMs: number; + serverCacheSeconds: number; + } +> = { + "1m": { beszelStatType: "1m", perPage: 60, refetchMs: 0, serverCacheSeconds: 5 }, + "1h": { beszelStatType: "1m", perPage: 60, refetchMs: 60_000, serverCacheSeconds: 60 }, + "12h": { beszelStatType: "10m", perPage: 72, refetchMs: 300_000, serverCacheSeconds: 300 }, + "24h": { beszelStatType: "20m", perPage: 72, refetchMs: 600_000, serverCacheSeconds: 600 }, + "1w": { beszelStatType: "120m", perPage: 84, refetchMs: 1_800_000, serverCacheSeconds: 1800 }, + "30d": { beszelStatType: "480m", perPage: 90, refetchMs: 3_600_000, serverCacheSeconds: 3600 }, +}; From 680cbc69d84627c6316f5fa5cfcc911a90d2cf8a Mon Sep 17 00:00:00 2001 From: ajnart <49837342+ajnart@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:00:46 +0000 Subject: [PATCH 09/56] style: auto-fix lint and format --- .../docs/widgets/beszel-system-stats/index.ts | 3 +- packages/api/src/router/widgets/beszel.ts | 47 ++++----------- .../src/beszel/beszel-integration.ts | 4 +- packages/request-handler/src/beszel.ts | 60 +++++++------------ packages/widgets/src/beszel/_shared/chart.tsx | 22 ++----- 5 files changed, 46 insertions(+), 90 deletions(-) diff --git a/apps/docs/docs/widgets/beszel-system-stats/index.ts b/apps/docs/docs/widgets/beszel-system-stats/index.ts index d19ae9154..f320a7ea9 100644 --- a/apps/docs/docs/widgets/beszel-system-stats/index.ts +++ b/apps/docs/docs/widgets/beszel-system-stats/index.ts @@ -10,7 +10,8 @@ export const beszelSystemStatsWidget: WidgetDefinition = { items: [ { name: "Time period", - description: "The time range for chart data. '1 Minute' enables live streaming via PocketBase SSE (requires Beszel agent >= 0.13.0)", + description: + "The time range for chart data. '1 Minute' enables live streaming via PocketBase SSE (requires Beszel agent >= 0.13.0)", values: { type: "select", options: ["1 Minute", "1 Hour", "12 Hours", "24 Hours", "1 Week", "30 Days"], diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index c1c0fe7c6..fb1e58b5c 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -8,10 +8,7 @@ import { beszelStatsRequestHandler, beszelSystemsRequestHandler, } from "@homarr/request-handler/beszel"; -import type { - BeszelAlertsData, - BeszelSystemRow, -} from "@homarr/request-handler/beszel"; +import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -22,14 +19,10 @@ export const beszelRouter = createTRPCRouter({ .query(async ({ ctx }) => { const results = await Promise.all( ctx.integrations.map(async (integration) => { - const innerHandler = beszelSystemsRequestHandler.handler( - integration, - {}, - ); - const { data, timestamp } = - await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); + const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); return { integrationId: integration.id, integrationName: integration.name, @@ -51,10 +44,7 @@ export const beszelRouter = createTRPCRouter({ timestamp: Date; }>((emit) => { const unsubscribes = ctx.integrations.map((integration) => { - const innerHandler = beszelSystemsRequestHandler.handler( - integration, - {}, - ); + const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); return innerHandler.subscribe((systems) => { emit.next({ integrationId: integration.id, @@ -80,17 +70,11 @@ export const beszelRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { const results = await Promise.all( ctx.integrations.map(async (integration) => { - const alertsHandler = beszelAlertsRequestHandler.handler( - integration, - { - includeHistory: input.includeHistory, - maxHistoryItems: input.maxHistoryItems, - }, - ); - const systemsHandler = beszelSystemsRequestHandler.handler( - integration, - {}, - ); + const alertsHandler = beszelAlertsRequestHandler.handler(integration, { + includeHistory: input.includeHistory, + maxHistoryItems: input.maxHistoryItems, + }); + const systemsHandler = beszelSystemsRequestHandler.handler(integration, {}); const [alertsResult, systemsResult] = await Promise.all([ alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), @@ -161,8 +145,7 @@ export const beszelRouter = createTRPCRouter({ timePeriod: input.timePeriod, includeDocker: input.includeDocker, }); - const { data, timestamp } = - await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return { integrationId: integration.id, ...data, updatedAt: timestamp }; }), @@ -187,11 +170,7 @@ export const beszelRouter = createTRPCRouter({ void (async () => { try { const instance = await createIntegrationAsync(integration); - await instance.subscribeRealtimeMetrics( - input.systemId, - (data) => emit.next(data), - abortController.signal, - ); + await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal); } catch (error) { if (!abortController.signal.aborted) { emit.error(error instanceof Error ? error : new Error(String(error))); diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts index d74303164..d0766b7fe 100644 --- a/packages/integrations/src/beszel/beszel-integration.ts +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -298,7 +298,9 @@ export class BeszelIntegration extends Integration { updated: new Date().toISOString(), }; const containerStats: BeszelContainerStatsRecord | null = - parsed.container && Array.isArray(parsed.container) && (parsed.container as BeszelContainerStats[]).length > 0 + parsed.container && + Array.isArray(parsed.container) && + (parsed.container as BeszelContainerStats[]).length > 0 ? { id: "", system: systemId, diff --git a/packages/request-handler/src/beszel.ts b/packages/request-handler/src/beszel.ts index f3dfc8f44..33777a845 100644 --- a/packages/request-handler/src/beszel.ts +++ b/packages/request-handler/src/beszel.ts @@ -24,10 +24,7 @@ export type { BeszelSystemRow } from "@homarr/integrations/types"; * bb=bandwidth(bytes/s), b=bandwidth(Mbps legacy), dt=disk temp, bat=battery, * sv=[running,total] services, h=hostname, m=CPU model, c=cores, ct=threads */ -function mapToSystemRow( - system: BeszelSystem, - details: BeszelSystemDetails | null, -): BeszelSystemRow { +function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | null): BeszelSystemRow { const info = system.info; return { id: system.id, @@ -53,28 +50,25 @@ function mapToSystemRow( }; } -export const beszelSystemsRequestHandler = - createCachedIntegrationRequestHandler< - BeszelSystemRow[], - "beszel" | "mock", - Record - >({ - async requestAsync(integration) { - const instance = await createIntegrationAsync(integration); - const systems = await instance.getSystemsAsync(); - const enriched = await Promise.all( - systems.map(async (system) => { - const details = await instance - .getSystemDetailsAsync(system.id) - .catch(() => null); - return mapToSystemRow(system, details); - }), - ); - return enriched; - }, - cacheDuration: dayjs.duration(5, "seconds"), - queryKey: "beszelSystems", - }); +export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler< + BeszelSystemRow[], + "beszel" | "mock", + Record +>({ + async requestAsync(integration) { + const instance = await createIntegrationAsync(integration); + const systems = await instance.getSystemsAsync(); + const enriched = await Promise.all( + systems.map(async (system) => { + const details = await instance.getSystemDetailsAsync(system.id).catch(() => null); + return mapToSystemRow(system, details); + }), + ); + return enriched; + }, + cacheDuration: dayjs.duration(5, "seconds"), + queryKey: "beszelSystems", +}); export interface BeszelAlertsData { alerts: BeszelAlert[]; @@ -89,9 +83,7 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler< async requestAsync(integration, input) { const instance = await createIntegrationAsync(integration); const alerts = await instance.getAlertsAsync(); - const history = input.includeHistory - ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) - : []; + const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : []; return { alerts, history }; }, cacheDuration: dayjs.duration(15, "seconds"), @@ -120,15 +112,9 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler< async requestAsync(integration, input) { const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 }; const instance = await createIntegrationAsync(integration); - const systemStats = await instance.getSystemStatsAsync( - input.systemId, - config.type, - config.perPage, - ); + const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage); const containerStats = input.includeDocker - ? await instance - .getContainerStatsAsync(input.systemId, config.type, config.perPage) - .catch(() => []) + ? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => []) : []; return { systemStats, containerStats }; }, diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx index 14c6a64f0..02c3c728d 100644 --- a/packages/widgets/src/beszel/_shared/chart.tsx +++ b/packages/widgets/src/beszel/_shared/chart.tsx @@ -6,14 +6,10 @@ import type { AreaChartProps } from "@mantine/charts"; import { AreaChart } from "@mantine/charts"; import dayjs from "dayjs"; -import type { - BeszelContainerStatsRecord, - BeszelSystemStatsRecord, -} from "@homarr/integrations/types"; +import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm"); -const formatTimeLive = (timestamp: string) => - dayjs(timestamp).format("HH:mm:ss"); +const formatTimeLive = (timestamp: string) => dayjs(timestamp).format("HH:mm:ss"); const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const; @@ -39,10 +35,7 @@ export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => ( ); -type BeszelAreaChartProps = Omit< - AreaChartProps, - "dataKey" | "curveType" | "withDots" | "withXAxis" | "withYAxis" -> & { +type BeszelAreaChartProps = Omit & { yAxisFormatter: (value: number) => string; yAxisDomain?: [number, string]; }; @@ -96,10 +89,7 @@ export const useSystemChartData = ( })); }, [systemStats, mapFn, live]); -export const useContainerNames = ( - containerStats: BeszelContainerStatsRecord[] | undefined, - max = 15, -) => +export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => useMemo(() => { if (!containerStats?.length) return []; const names = new Set(); @@ -111,9 +101,7 @@ export const useContainerNames = ( return [...names].slice(0, max); }, [containerStats, max]); -type ContainerExtractor = ( - container: BeszelContainerStatsRecord["stats"][number] | undefined, -) => number; +type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number; // c = CPU (%), m = memory (MB), b = bandwidth [sent,recv] (bytes/s), ns/nr = legacy net (bytes/s) const MB = 1024 * 1024; From c5266a7a42b4c0e92f42827782aae04fbeaf136d Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 15:09:36 +0200 Subject: [PATCH 10/56] feat(beszel): wire live SSE streaming for 1-minute time period Add "Live" option to time period selector that uses the existing subscribeSystemStats tRPC subscription (PocketBase SSE) instead of REST polling. A useLiveStats hook accumulates incoming data points in a 60-entry rolling buffer. Charts use HH:mm:ss timestamps in live mode. --- .../src/beszel-system-stats/component.tsx | 32 ++++++++------ .../widgets/src/beszel-system-stats/index.ts | 1 + .../src/beszel/_shared/use-live-stats.ts | 42 +++++++++++++++++++ 3 files changed, 62 insertions(+), 13 deletions(-) create mode 100644 packages/widgets/src/beszel/_shared/use-live-stats.ts diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index f502c5598..67adf9302 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react"; import { ActionIcon, Center, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core"; -import { IconServer, IconServerQuestion } from "@tabler/icons-react"; +import { IconServer, IconQuestionMark } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; @@ -26,6 +26,7 @@ import { formatStorageBytes, } from "../beszel/_shared/format"; import { makeTooltipProps } from "../beszel/_shared/tooltip"; +import { useLiveStats } from "../beszel/_shared/use-live-stats"; const tooltipPercent = makeTooltipProps(formatPercent); const tooltipGB = makeTooltipProps(formatGB, true); @@ -63,6 +64,7 @@ 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( { @@ -71,9 +73,13 @@ export default function BeszelSystemStatsWidget({ timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d", includeDocker: showDocker, }, - { staleTime: 30 * 1000, enabled: systemExists && selectedSystem !== "" }, + { staleTime: 30 * 1000, enabled: !isLive && systemExists && selectedSystem !== "" }, ); + const liveData = useLiveStats(integrationIds, selectedSystem, showDocker, isLive && systemExists); + + const activeStats = isLive ? liveData : statsResult; + const cpuMap = useCallback((s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), [t]); const memoryMap = useCallback( (s: { mu: number; mb: number }) => ({ @@ -98,16 +104,16 @@ export default function BeszelSystemStatsWidget({ [t], ); - const cpuData = useSystemChartData(statsResult?.systemStats, cpuMap); - const memoryData = useSystemChartData(statsResult?.systemStats, memoryMap); - const diskData = useSystemChartData(statsResult?.systemStats, diskMap); - const diskIOData = useSystemChartData(statsResult?.systemStats, diskIOMap); - const networkData = useSystemChartData(statsResult?.systemStats, networkMap); + const cpuData = useSystemChartData(activeStats?.systemStats, cpuMap, isLive); + const memoryData = useSystemChartData(activeStats?.systemStats, memoryMap, isLive); + const diskData = useSystemChartData(activeStats?.systemStats, diskMap, isLive); + const diskIOData = useSystemChartData(activeStats?.systemStats, diskIOMap, isLive); + const networkData = useSystemChartData(activeStats?.systemStats, networkMap, isLive); - const containerNames = useContainerNames(statsResult?.containerStats); - const dockerCpuData = useDockerChartData(statsResult?.containerStats, containerNames, "cpu"); - const dockerMemData = useDockerChartData(statsResult?.containerStats, containerNames, "memory"); - const dockerNetData = useDockerChartData(statsResult?.containerStats, containerNames, "network"); + const containerNames = useContainerNames(activeStats?.containerStats); + const dockerCpuData = useDockerChartData(activeStats?.containerStats, containerNames, "cpu", isLive); + const dockerMemData = useDockerChartData(activeStats?.containerStats, containerNames, "memory", isLive); + const dockerNetData = useDockerChartData(activeStats?.containerStats, containerNames, "network", isLive); const containerSeries = containerNames.map((name, i) => ({ name, @@ -120,7 +126,7 @@ export default function BeszelSystemStatsWidget({ return (
- + {t("error.systemNotFound")} @@ -179,7 +185,7 @@ export default function BeszelSystemStatsWidget({ )} - {statsResult && ( + {activeStats && ( {options.showCpu && cpuData.length > 0 && ( diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts index 674c41d4c..e7111a586 100644 --- a/packages/widgets/src/beszel-system-stats/index.ts +++ b/packages/widgets/src/beszel-system-stats/index.ts @@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; const timePeriodOptions = [ + { value: "1m", label: "Live" }, { value: "1h", label: "1 Hour" }, { value: "12h", label: "12 Hours" }, { value: "24h", label: "24 Hours" }, diff --git a/packages/widgets/src/beszel/_shared/use-live-stats.ts b/packages/widgets/src/beszel/_shared/use-live-stats.ts new file mode 100644 index 000000000..4d5f5fd69 --- /dev/null +++ b/packages/widgets/src/beszel/_shared/use-live-stats.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +import { clientApi } from "@homarr/api/client"; +import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; + +const MAX_BUFFER = 60; + +interface LiveStatsData { + systemStats: BeszelSystemStatsRecord[]; + containerStats: BeszelContainerStatsRecord[]; +} + +export const useLiveStats = ( + integrationIds: string[], + systemId: string, + includeDocker: boolean, + enabled: boolean, +) => { + const bufferRef = useRef({ systemStats: [], containerStats: [] }); + const [data, setData] = useState(null); + + const onData = useCallback( + (incoming: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => { + const buf = bufferRef.current; + buf.systemStats = [...buf.systemStats, incoming.stats].slice(-MAX_BUFFER); + if (incoming.containerStats) { + buf.containerStats = [...buf.containerStats, incoming.containerStats].slice(-MAX_BUFFER); + } + setData({ ...buf }); + }, + [], + ); + + clientApi.widget.beszel.subscribeSystemStats.useSubscription( + { integrationIds, systemId, includeDocker }, + { enabled: enabled && systemId !== "", onData }, + ); + + return data; +}; From 4e32e81c544bd5e090430499c5197bb298e32bf1 Mon Sep 17 00:00:00 2001 From: ajnart <49837342+ajnart@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:11:01 +0000 Subject: [PATCH 11/56] style: auto-fix lint and format --- packages/widgets/src/beszel/_shared/use-live-stats.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/widgets/src/beszel/_shared/use-live-stats.ts b/packages/widgets/src/beszel/_shared/use-live-stats.ts index 4d5f5fd69..6dab58e96 100644 --- a/packages/widgets/src/beszel/_shared/use-live-stats.ts +++ b/packages/widgets/src/beszel/_shared/use-live-stats.ts @@ -12,12 +12,7 @@ interface LiveStatsData { containerStats: BeszelContainerStatsRecord[]; } -export const useLiveStats = ( - integrationIds: string[], - systemId: string, - includeDocker: boolean, - enabled: boolean, -) => { +export const useLiveStats = (integrationIds: string[], systemId: string, includeDocker: boolean, enabled: boolean) => { const bufferRef = useRef({ systemStats: [], containerStats: [] }); const [data, setData] = useState(null); From 7e46c711870985c5eaa9b8fa0595005c821b0a0a Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 15:11:41 +0200 Subject: [PATCH 12/56] fix(beszel): remove unused {name} placeholder from systemNotFound translation --- packages/translation/src/lang/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index e66006857..bb7e3b805 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3697,7 +3697,7 @@ "selectSystem": "Select system", "error": { "internalServerError": "Unable to connect to Beszel. Check your integration settings.", - "systemNotFound": "The {name} system was not found. Choose a different system." + "systemNotFound": "Selected system was not found. Choose a different system." }, "chart": { "cpu": { From d13effdda04169b9d7d4aedab0134071bedec5ef Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 15:15:06 +0200 Subject: [PATCH 13/56] fix(beszel): address review findings for live streaming - Skip toReversed() for live data (already chronological from buffer) - Reset live buffer on systemId/integration change to avoid stale data - Throw on unexpected SSE stream end so subscription emits error - Delete unused time-period.ts config (duplicate of server-side config) --- .../src/beszel/beszel-integration.ts | 3 +++ packages/widgets/src/beszel/_shared/chart.tsx | 6 ++++-- .../widgets/src/beszel/_shared/time-period.ts | 19 ------------------- .../src/beszel/_shared/use-live-stats.ts | 14 ++++++++++++-- 4 files changed, 19 insertions(+), 23 deletions(-) delete mode 100644 packages/widgets/src/beszel/_shared/time-period.ts diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts index d0766b7fe..9066b9cad 100644 --- a/packages/integrations/src/beszel/beszel-integration.ts +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -329,6 +329,9 @@ export class BeszelIntegration extends Integration { processLine(line); } } + if (!signal.aborted) { + throw new Error("Beszel SSE stream ended unexpectedly"); + } } finally { reader.cancel().catch(() => {}); } diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx index 02c3c728d..08112d2f8 100644 --- a/packages/widgets/src/beszel/_shared/chart.tsx +++ b/packages/widgets/src/beszel/_shared/chart.tsx @@ -83,7 +83,8 @@ export const useSystemChartData = ( useMemo(() => { if (!systemStats) return []; const fmt = live ? formatTimeLive : formatTime; - return [...systemStats].toReversed().map((r) => ({ + const ordered = live ? systemStats : [...systemStats].toReversed(); + return ordered.map((r) => ({ time: fmt(r.created), ...mapFn(r.stats), })); @@ -123,7 +124,8 @@ export const useDockerChartData = ( const extract = defaultContainerExtractors[metric]; if (!extract) return []; const fmt = live ? formatTimeLive : formatTime; - return [...containerStats].toReversed().map((record) => { + const ordered = live ? containerStats : [...containerStats].toReversed(); + return ordered.map((record) => { const point: Record = { time: fmt(record.created) }; for (const name of containerNames) { point[name] = extract(record.stats.find((c) => c.n === name)); diff --git a/packages/widgets/src/beszel/_shared/time-period.ts b/packages/widgets/src/beszel/_shared/time-period.ts deleted file mode 100644 index 83ecba855..000000000 --- a/packages/widgets/src/beszel/_shared/time-period.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const beszelTimePeriods = ["1m", "1h", "12h", "24h", "1w", "30d"] as const; -export type BeszelTimePeriod = (typeof beszelTimePeriods)[number]; - -export const beszelTimePeriodConfig: Record< - BeszelTimePeriod, - { - beszelStatType: string; - perPage: number; - refetchMs: number; - serverCacheSeconds: number; - } -> = { - "1m": { beszelStatType: "1m", perPage: 60, refetchMs: 0, serverCacheSeconds: 5 }, - "1h": { beszelStatType: "1m", perPage: 60, refetchMs: 60_000, serverCacheSeconds: 60 }, - "12h": { beszelStatType: "10m", perPage: 72, refetchMs: 300_000, serverCacheSeconds: 300 }, - "24h": { beszelStatType: "20m", perPage: 72, refetchMs: 600_000, serverCacheSeconds: 600 }, - "1w": { beszelStatType: "120m", perPage: 84, refetchMs: 1_800_000, serverCacheSeconds: 1800 }, - "30d": { beszelStatType: "480m", perPage: 90, refetchMs: 3_600_000, serverCacheSeconds: 3600 }, -}; diff --git a/packages/widgets/src/beszel/_shared/use-live-stats.ts b/packages/widgets/src/beszel/_shared/use-live-stats.ts index 6dab58e96..639c69ce5 100644 --- a/packages/widgets/src/beszel/_shared/use-live-stats.ts +++ b/packages/widgets/src/beszel/_shared/use-live-stats.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { clientApi } from "@homarr/api/client"; import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; @@ -12,10 +12,20 @@ interface LiveStatsData { containerStats: BeszelContainerStatsRecord[]; } -export const useLiveStats = (integrationIds: string[], systemId: string, includeDocker: boolean, enabled: boolean) => { +export const useLiveStats = ( + integrationIds: string[], + systemId: string, + includeDocker: boolean, + enabled: boolean, +) => { const bufferRef = useRef({ systemStats: [], containerStats: [] }); const [data, setData] = useState(null); + useEffect(() => { + bufferRef.current = { systemStats: [], containerStats: [] }; + setData(null); + }, [systemId, enabled, ...integrationIds]); + const onData = useCallback( (incoming: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => { const buf = bufferRef.current; From e0e493e2f9e1e71ac8b1aace5508c999255cd4fa Mon Sep 17 00:00:00 2001 From: ajnart <49837342+ajnart@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:16:43 +0000 Subject: [PATCH 14/56] style: auto-fix lint and format --- packages/widgets/src/beszel/_shared/use-live-stats.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/widgets/src/beszel/_shared/use-live-stats.ts b/packages/widgets/src/beszel/_shared/use-live-stats.ts index 639c69ce5..f664a9484 100644 --- a/packages/widgets/src/beszel/_shared/use-live-stats.ts +++ b/packages/widgets/src/beszel/_shared/use-live-stats.ts @@ -12,12 +12,7 @@ interface LiveStatsData { containerStats: BeszelContainerStatsRecord[]; } -export const useLiveStats = ( - integrationIds: string[], - systemId: string, - includeDocker: boolean, - enabled: boolean, -) => { +export const useLiveStats = (integrationIds: string[], systemId: string, includeDocker: boolean, enabled: boolean) => { const bufferRef = useRef({ systemStats: [], containerStats: [] }); const [data, setData] = useState(null); From ed333e1cbbc3dd1a010af8152153df11c83bf68c Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 15:16:50 +0200 Subject: [PATCH 15/56] feat(beszel): add searchable system select input for widget options Replace plain text systemId option with a dedicated beszelSystem factory type backed by a searchable Select that fetches available systems from the integration. Shows proper states for no integration, loading, and no systems found. --- packages/translation/src/lang/en.json | 7 ++- packages/widgets/src/_inputs/index.ts | 2 + .../_inputs/widget-beszel-system-input.tsx | 46 +++++++++++++++++++ .../widgets/src/beszel-system-stats/index.ts | 2 +- packages/widgets/src/options.ts | 5 ++ 5 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 packages/widgets/src/_inputs/widget-beszel-system-input.tsx diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index bb7e3b805..7f04d6b06 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3664,7 +3664,12 @@ "description": "Time-series charts for CPU, memory, disk, network, and Docker container metrics", "option": { "systemId": { - "label": "System ID" + "label": "System", + "description": "Select a Beszel system to display stats for", + "configureIntegrationFirst": "Configure a Beszel integration first", + "loading": "Loading systems...", + "placeholder": "Select a system", + "noSystems": "No systems found" }, "timePeriod": { "label": "Time period" diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts index 639a9df5e..82b02078f 100644 --- a/packages/widgets/src/_inputs/index.ts +++ b/packages/widgets/src/_inputs/index.ts @@ -16,6 +16,7 @@ import { WidgetTextInput } from "./widget-text-input"; import { WidgetUmamiEventInput } from "./widget-umami-event-input"; import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input"; import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input"; +import { WidgetBeszelSystemInput } from "./widget-beszel-system-input"; const mapping = { anchorNote: WidgetAnchorNoteInput, @@ -35,6 +36,7 @@ const mapping = { umamiEventNames: WidgetUmamiEventNamesInput, umamiWebsite: WidgetUmamiWebsiteInput, customWidgetSelect: WidgetCustomWidgetSelectInput, + beszelSystem: WidgetBeszelSystemInput, } satisfies Record; export const getInputForType = (type: TType) => { diff --git a/packages/widgets/src/_inputs/widget-beszel-system-input.tsx b/packages/widgets/src/_inputs/widget-beszel-system-input.tsx new file mode 100644 index 000000000..4b5604c28 --- /dev/null +++ b/packages/widgets/src/_inputs/widget-beszel-system-input.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Select, Text } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { CommonWidgetInputProps } from "./common"; +import { useWidgetInputTranslation } from "./common"; +import { useFormContext } from "./form"; + +export const WidgetBeszelSystemInput = ({ property, kind }: CommonWidgetInputProps<"beszelSystem">) => { + const t = useScopedI18n("widget.beszelSystemStats.option.systemId"); + const tInput = useWidgetInputTranslation(kind, property); + const form = useFormContext(); + + const integrationIds = form.values.integrationIds; + + const { data: systemsResult = [], isPending } = clientApi.widget.beszel.getSystems.useQuery( + { integrationIds }, + { enabled: integrationIds.length > 0, staleTime: 30_000 }, + ); + + if (integrationIds.length === 0) { + return ( + + {t("configureIntegrationFirst")} + + ); + } + + const systems = systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))); + + return ( + ); diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 209e7e69a..b8746b621 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -5,6 +5,7 @@ import { ActionIcon, Button, Center, Menu, ScrollArea, Select, SimpleGrid, Stack import { IconPlugConnectedX, IconServer, IconQuestionMark } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; import classes from "./component.module.css"; @@ -79,7 +80,10 @@ export default function BeszelSystemStatsWidget({ const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady); - const activeStats = isLive ? liveData : statsResult; + let activeStats: { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[] } | null | undefined = statsResult; + if (isLive) { + activeStats = liveData; + } const cpuMap = useCallback((s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), [t]); const memoryMap = useCallback( @@ -204,7 +208,7 @@ export default function BeszelSystemStatsWidget({ {!isEditMode && systems.length > 1 && ( @@ -233,8 +237,8 @@ export default function BeszelSystemStatsWidget({ handleSelectSystem(s.value)} > {s.label} diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx index 331117c81..c5fc2b2f3 100644 --- a/packages/widgets/src/beszel/_shared/chart.tsx +++ b/packages/widgets/src/beszel/_shared/chart.tsx @@ -11,9 +11,15 @@ import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homar const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm"); const formatTimeLive = (timestamp: string) => dayjs(timestamp).format("HH:mm:ss"); +const timeFormatters: Record string> = { + live: formatTimeLive, + historical: formatTime, +}; + function prepareRecords(records: T[], live: boolean) { + const mode = live ? "live" : "historical"; return { - fmt: live ? formatTimeLive : formatTime, + fmt: timeFormatters[mode] as (ts: string) => string, ordered: live ? records : [...records].toReversed(), }; } @@ -21,7 +27,6 @@ function prepareRecords(records: T[], live: boolean) { const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const; const chartStyle = { minWidth: 0, minHeight: 0 } as const; const panelStyle = { minWidth: 0, overflow: "hidden" } as const; -const noAnimation = { isAnimationActive: false } as const; export const CPU_Y_AXIS_DOMAIN: [number, string] = [0, "auto"]; interface BeszelChartPanelProps { @@ -59,17 +64,19 @@ const BeszelAreaChart = memo( type = "default", ...props }: BeszelAreaChartProps) => { - const mergedYAxis = useMemo( - () => ({ + const mergedYAxis = useMemo(() => { + const base = { ...yAxisBase, width: 48, tickMargin: 2, - ...(yAxisDomain ? { domain: yAxisDomain } : {}), tickFormatter: yAxisFormatter, ...yAxisPropsOverride, - }), - [yAxisFormatter, yAxisDomain, yAxisPropsOverride], - ); + }; + if (yAxisDomain) { + return { ...base, domain: yAxisDomain }; + } + return base; + }, [yAxisFormatter, yAxisDomain, yAxisPropsOverride]); return ( { const prevRef = useRef([]); return useMemo(() => { - if (!containerStats?.length) return prevRef.current.length === 0 ? prevRef.current : (prevRef.current = []); + if (!containerStats?.length) { + if (prevRef.current.length === 0) return prevRef.current; + prevRef.current = []; + return prevRef.current; + } const names = new Set(); for (const record of containerStats) { for (const c of record.stats) { diff --git a/packages/widgets/src/beszel/_shared/tooltip.tsx b/packages/widgets/src/beszel/_shared/tooltip.tsx index 136addb97..a670af67d 100644 --- a/packages/widgets/src/beszel/_shared/tooltip.tsx +++ b/packages/widgets/src/beszel/_shared/tooltip.tsx @@ -77,8 +77,8 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: if (!isActive) return null; const deduped = new Map(); - for (const p of (payload ?? []).filter((p) => p.value > 0)) { - if (!deduped.has(p.dataKey)) deduped.set(p.dataKey, p); + for (const item of (payload ?? []).filter((entry) => entry.value > 0)) { + if (!deduped.has(item.dataKey)) deduped.set(item.dataKey, item); } const sorted = [...deduped.values()].toSorted((a, b) => b.value - a.value); From 3d93ae12462859aeffbe91e85e1d5e8984839fca Mon Sep 17 00:00:00 2001 From: ajnart <49837342+ajnart@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:25:49 +0000 Subject: [PATCH 35/56] style: auto-fix lint and format --- .../src/_inputs/widget-beszel-system-input.tsx | 4 ++-- .../widgets/src/beszel-system-stats/component.tsx | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/widgets/src/_inputs/widget-beszel-system-input.tsx b/packages/widgets/src/_inputs/widget-beszel-system-input.tsx index 706307dca..0234ecf17 100644 --- a/packages/widgets/src/_inputs/widget-beszel-system-input.tsx +++ b/packages/widgets/src/_inputs/widget-beszel-system-input.tsx @@ -39,13 +39,13 @@ export const WidgetBeszelSystemInput = ({ property, kind }: CommonWidgetInputPro ); -}; +}; \ No newline at end of file diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts index 842170eb7..e346d7a64 100644 --- a/packages/widgets/src/beszel-system-stats/index.ts +++ b/packages/widgets/src/beszel-system-stats/index.ts @@ -1,5 +1,7 @@ import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react"; +import { clientApi } from "@homarr/api/client"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -18,7 +20,20 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys integrationsRequired: true, createOptions() { return optionsBuilder.from((factory) => ({ - systemId: factory.beszelSystem(), + systemId: factory.integrationSelect({ + withDescription: true, + clearable: true, + useOptions: (integrationIds: string[]) => { + const { data = [], isPending, isError } = clientApi.widget.beszel.getSystems.useQuery( + { integrationIds }, + { enabled: integrationIds.length > 0, staleTime: 30_000 }, + ); + const selectData = data.flatMap((r) => + r.systems.map((s) => ({ value: s.id, label: s.name })), + ); + return { data: selectData, isPending, isError }; + }, + }), timePeriod: factory.select({ defaultValue: "1h", options: timePeriodOptions, diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index e0a3a132c..5500e072c 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -60,6 +60,18 @@ interface DynamicSelectInput extends CommonInput { }; } +interface IntegrationSelectInput extends CommonInput { + clearable?: boolean; + searchable?: boolean; + useOptions: ( + integrationIds: string[], + ) => { + data: { value: string; label: string }[]; + isPending: boolean; + isError: boolean; + }; +} + interface NumberInput extends CommonInput { validate: z.ZodNumber; step?: number; @@ -175,10 +187,13 @@ const optionsFactory = { defaultValue: "", withDescription: true, }), - beszelSystem: () => ({ - type: "beszelSystem" as const, - defaultValue: "", - withDescription: true, + integrationSelect: (input: IntegrationSelectInput) => ({ + type: "integrationSelect" as const, + defaultValue: input.defaultValue ?? "", + withDescription: input.withDescription ?? false, + clearable: input.clearable ?? false, + searchable: input.searchable ?? true, + useOptions: input.useOptions, }), customWidgetSelect: (input?: CommonInput) => ({ type: "customWidgetSelect" as const, From c751feedf74c7f2810daeee6fcb12ccb4b74003c Mon Sep 17 00:00:00 2001 From: ajnart <49837342+ajnart@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:39:26 +0000 Subject: [PATCH 41/56] style: auto-fix lint and format --- .../src/_inputs/widget-integration-select-input.tsx | 5 ++--- packages/widgets/src/beszel-system-stats/index.ts | 10 ++++++---- packages/widgets/src/options.ts | 4 +--- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/widgets/src/_inputs/widget-integration-select-input.tsx b/packages/widgets/src/_inputs/widget-integration-select-input.tsx index 4a9840dc9..5b2ff0158 100644 --- a/packages/widgets/src/_inputs/widget-integration-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-integration-select-input.tsx @@ -8,8 +8,7 @@ import type { CommonWidgetInputProps } from "./common"; import { useWidgetInputTranslation } from "./common"; import { useFormContext } from "./form"; -const useIntegrationSelectTranslation = () => - useScopedI18n("widget.integrationSelect"); +const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect"); export const WidgetIntegrationSelectInput = ({ property, @@ -46,4 +45,4 @@ export const WidgetIntegrationSelectInput = ({ {...form.getInputProps(`options.${property}`)} /> ); -}; \ No newline at end of file +}; diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts index e346d7a64..0b1cdf127 100644 --- a/packages/widgets/src/beszel-system-stats/index.ts +++ b/packages/widgets/src/beszel-system-stats/index.ts @@ -24,13 +24,15 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys withDescription: true, clearable: true, useOptions: (integrationIds: string[]) => { - const { data = [], isPending, isError } = clientApi.widget.beszel.getSystems.useQuery( + const { + data = [], + isPending, + isError, + } = clientApi.widget.beszel.getSystems.useQuery( { integrationIds }, { enabled: integrationIds.length > 0, staleTime: 30_000 }, ); - const selectData = data.flatMap((r) => - r.systems.map((s) => ({ value: s.id, label: s.name })), - ); + const selectData = data.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))); return { data: selectData, isPending, isError }; }, }), diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index 5500e072c..1d19f628a 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -63,9 +63,7 @@ interface DynamicSelectInput extends CommonInput { interface IntegrationSelectInput extends CommonInput { clearable?: boolean; searchable?: boolean; - useOptions: ( - integrationIds: string[], - ) => { + useOptions: (integrationIds: string[]) => { data: { value: string; label: string }[]; isPending: boolean; isError: boolean; From 2a5398bec085c16af6cb6ba80867dc65a58c7905 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 16 Jun 2026 15:02:58 +0200 Subject: [PATCH 42/56] fix(beszel): system grid widget respects board opacity and radius The inner Card components used Mantine's default opaque background and hard-coded radius, clashing with board styling. Now uses board.opacity and board.itemRadius with the same pattern as system-resources widgets. Closes #5968 --- .../src/beszel-system-grid/component.tsx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index b213c5e72..ab265e6ad 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core"; +import { Badge, Box, Card, Group, Progress, Text, Stack, useComputedColorScheme } from "@mantine/core"; import { Activity, Battery, @@ -16,6 +16,7 @@ import { } from "lucide-react"; import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; @@ -151,6 +152,8 @@ interface SystemCardProps { t: ReturnType>; size: SizeConfig; maxMetrics: number; + backgroundColor: string; + itemRadius: string; } const metricRenderers = [ @@ -307,19 +310,20 @@ const metricRenderers = [ }, ] as const; -const SystemCard = ({ system, options, t, size, maxMetrics }: SystemCardProps) => { +const SystemCard = ({ system, options, t, size, maxMetrics, backgroundColor, itemRadius }: SystemCardProps) => { const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics); return ( @@ -348,6 +352,11 @@ export default function BeszelSystemGridWidget({ height, }: WidgetComponentProps<"beszelSystemGrid">) { const t = useScopedI18n("widget.beszelSystemGrid"); + const board = useRequiredBoard(); + const colorScheme = useComputedColorScheme("light"); + const opacity = board.opacity / 100; + const backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`; + const { data: results = [] } = clientApi.widget.beszel.getSystems.useQuery( { integrationIds }, { staleTime: 5_000, refetchInterval: 5_000, retry: false }, @@ -378,7 +387,16 @@ export default function BeszelSystemGridWidget({ }} > {filteredSystems.map((system) => ( - + ))} ); From 8600c63bab4a1c902f067e22de10e2c97dce876b Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 16 Jun 2026 15:09:36 +0200 Subject: [PATCH 43/56] feat(beszel): expose Beszel systems, alerts, and stats via MCP Register beszelRouter in the MCP server with .meta({ mcp: enabled }) on getSystems, getAlerts, and getSystemStats procedures. AI assistants can now query Beszel-monitored systems, view alerts, and fetch historical metrics through the MCP protocol. Ref #5968 --- apps/docs/docs/management/mcp.mdx | 2 ++ .../src/app/api/mcp/[transport]/route.ts | 2 ++ packages/api/src/mcp.ts | 2 ++ packages/api/src/router/widgets/beszel.ts | 21 +++++++++++++++++++ 4 files changed, 27 insertions(+) diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 66e0fca27..5317ecce2 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -130,6 +130,7 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query** | ------------------ | -------------------------------------------------------- | ---------- | | `app` | List all apps, get app by ID, create/update/delete app | GET + POST | | `apiKeys` | List API keys | GET | +| `beszel` | Systems status, alerts, historical system metrics | GET | | `board` | List boards, get board by ID, create/update/delete board | GET + POST | | `docker` | List containers, inspect container, start/stop/restart | GET + POST | | `icon` | Search icons | GET | @@ -162,3 +163,4 @@ The tools table on the MCP tab is dynamically generated - it always reflects the - **"Create a new app pointing to my Grafana instance"** - AI creates an app with the correct icon and URL - **"How many users are on my Homarr instance?"** - AI lists all users (admin only) - **"Toggle Pi-hole blocking for 5 minutes"** - AI interacts with DNS controls +- **"How are my servers doing?"** - AI fetches Beszel system stats including CPU, memory, disk, and alerts diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.ts index 2cfcfbb24..0611a5a57 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.ts @@ -148,6 +148,8 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr **"Check server health"** → Use \`healthMonitoring_getSystemHealthStatus\` for NAS/server metrics or \`healthMonitoring_getClusterHealthStatus\` for Proxmox clusters. +**"Check Beszel systems"** → Use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`). + **"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs. **"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation. diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index 299a88e0b..1a08d37f1 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -16,6 +16,7 @@ import { healthMonitoringRouter } from "./router/widgets/health-monitoring"; import { mediaRequestsRouter } from "./router/widgets/media-requests"; import { mediaServerRouter } from "./router/widgets/media-server"; import { smartHomeRouter } from "./router/widgets/smart-home"; +import { beszelRouter } from "./router/widgets/beszel"; export const mcpRouter = createTRPCRouter({ app: appRouterForApps, @@ -26,6 +27,7 @@ export const mcpRouter = createTRPCRouter({ info: infoRouter, integration: integrationRouter, invite: inviteRouter, + beszel: beszelRouter, calendar: calendarRouter, dnsHole: dnsHoleRouter, downloads: downloadsRouter, diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index 036558ac1..a20182240 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -15,6 +15,13 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; export const beszelRouter = createTRPCRouter({ getSystems: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)", + }, + }) .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .query(async ({ ctx }) => { const results = await Promise.all( @@ -60,6 +67,13 @@ export const beszelRouter = createTRPCRouter({ }), getAlerts: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get Beszel alerts and optional alert history for all monitored systems. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all). OPTIONAL: includeHistory (default true), maxHistoryItems (default 10)", + }, + }) .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .input( z.object({ @@ -129,6 +143,13 @@ export const beszelRouter = createTRPCRouter({ }), getSystemStats: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1h/12h/24h/1w/30d, default 1h), includeDocker (default true)", + }, + }) .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .input( z.object({ From 420c0c30e4f38c6cee25615ad4b5055574344486 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 16 Jun 2026 15:13:35 +0200 Subject: [PATCH 44/56] fix(beszel): MCP description accuracy and type strictness - Add .default("1h") to timePeriod so MCP clients can omit it - Include "1m" in the description's valid timePeriod values - Clarify that getSystemStats uses only the first integrationId - Use MantineSize type instead of plain string for itemRadius --- packages/api/src/router/widgets/beszel.ts | 4 ++-- packages/widgets/src/beszel-system-grid/component.tsx | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index a20182240..da00ea4f5 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -147,14 +147,14 @@ export const beszelRouter = createTRPCRouter({ mcp: { enabled: true, description: - "Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1h/12h/24h/1w/30d, default 1h), includeDocker (default true)", + "Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)", }, }) .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .input( z.object({ systemId: z.string(), - timePeriod: z.enum(["1m", "1h", "12h", "24h", "1w", "30d"]), + timePeriod: z.enum(["1m", "1h", "12h", "24h", "1w", "30d"]).default("1h"), includeDocker: z.boolean().default(true), }), ) diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index ab265e6ad..cd902963c 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -1,5 +1,6 @@ "use client"; +import type { MantineSize } from "@mantine/core"; import { Badge, Box, Card, Group, Progress, Text, Stack, useComputedColorScheme } from "@mantine/core"; import { Activity, @@ -153,7 +154,7 @@ interface SystemCardProps { size: SizeConfig; maxMetrics: number; backgroundColor: string; - itemRadius: string; + itemRadius: MantineSize; } const metricRenderers = [ From 0a05bbed8dd49e42f6cd50fcd27d0f01a4d68770 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 16 Jun 2026 15:18:13 +0200 Subject: [PATCH 45/56] fix(beszel): review fixes for MCP and widget styling - Throw TRPCError instead of returning null when no integration - Sort beszelRouter import alphabetically in mcp.ts - Replace ternary with lookup table for background color - Mention integration_all in MCP server instructions --- apps/nextjs/src/app/api/mcp/[transport]/route.ts | 2 +- packages/api/src/mcp.ts | 2 +- packages/api/src/router/widgets/beszel.ts | 5 ++++- packages/widgets/src/beszel-system-grid/component.tsx | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.ts index 0611a5a57..364fe90c5 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.ts @@ -148,7 +148,7 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr **"Check server health"** → Use \`healthMonitoring_getSystemHealthStatus\` for NAS/server metrics or \`healthMonitoring_getClusterHealthStatus\` for Proxmox clusters. -**"Check Beszel systems"** → Use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`). +**"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`). **"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs. diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index 1a08d37f1..887e878ad 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -9,6 +9,7 @@ import { iconsRouter } from "./router/icons"; import { infoRouter } from "./router/info"; import { integrationRouter } from "./router/integration/integration-router"; import { inviteRouter } from "./router/invite"; +import { beszelRouter } from "./router/widgets/beszel"; import { calendarRouter } from "./router/widgets/calendar"; import { dnsHoleRouter } from "./router/widgets/dns-hole"; import { downloadsRouter } from "./router/widgets/downloads"; @@ -16,7 +17,6 @@ import { healthMonitoringRouter } from "./router/widgets/health-monitoring"; import { mediaRequestsRouter } from "./router/widgets/media-requests"; import { mediaServerRouter } from "./router/widgets/media-server"; import { smartHomeRouter } from "./router/widgets/smart-home"; -import { beszelRouter } from "./router/widgets/beszel"; export const mcpRouter = createTRPCRouter({ app: appRouterForApps, diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index da00ea4f5..25ede9289 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -1,3 +1,4 @@ +import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; @@ -160,7 +161,9 @@ export const beszelRouter = createTRPCRouter({ ) .query(async ({ ctx, input }) => { const integration = ctx.integrations[0]; - if (!integration) return null; + if (!integration) { + throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" }); + } const innerHandler = beszelStatsRequestHandler.handler(integration, { systemId: input.systemId, timePeriod: input.timePeriod, diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index cd902963c..63c6f785a 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -356,7 +356,8 @@ export default function BeszelSystemGridWidget({ const board = useRequiredBoard(); const colorScheme = useComputedColorScheme("light"); const opacity = board.opacity / 100; - const backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`; + const bgBase: Record = { dark: "57, 57, 57", light: "246, 247, 248" }; + const backgroundColor = `rgba(${bgBase[colorScheme]}, ${opacity})`; const { data: results = [] } = clientApi.widget.beszel.getSystems.useQuery( { integrationIds }, From 4da2feda35c7d918d1041d095f56a827b737c68e Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 16 Jun 2026 15:49:06 +0200 Subject: [PATCH 46/56] refactor(beszel): tighten background color lookup type --- packages/widgets/src/beszel-system-grid/component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index 63c6f785a..60d367d8f 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -356,7 +356,7 @@ export default function BeszelSystemGridWidget({ const board = useRequiredBoard(); const colorScheme = useComputedColorScheme("light"); const opacity = board.opacity / 100; - const bgBase: Record = { dark: "57, 57, 57", light: "246, 247, 248" }; + 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( From ece436a1ccb0c2f747644552764db3cb94e19f73 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 16 Jun 2026 15:59:20 +0200 Subject: [PATCH 47/56] fix(docs): use valid string type for beszel system stats config The "text" type is not a valid WidgetConfigurationItem value type; the searchable system selector maps to "string". --- apps/docs/docs/widgets/beszel-system-stats/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/docs/widgets/beszel-system-stats/index.ts b/apps/docs/docs/widgets/beszel-system-stats/index.ts index 4a9e06c79..2f48019e4 100644 --- a/apps/docs/docs/widgets/beszel-system-stats/index.ts +++ b/apps/docs/docs/widgets/beszel-system-stats/index.ts @@ -11,7 +11,7 @@ export const beszelSystemStatsWidget: WidgetDefinition = { { name: "System", description: "The Beszel system to display stats for. Searchable select populated from the integration.", - values: { type: "text" }, + values: { type: "string" }, defaultValue: "First available system", }, { From 5f588ae28780ecbed2e774507ab3f49a03d027a5 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 16 Jun 2026 16:23:02 +0200 Subject: [PATCH 48/56] fix(definitions): add doc slugs for gotify and archiveTeamWarrior Merging dev introduced the Gotify integration and ArchiveTeam Warrior widget/integration, which require entries in the doc-slug maps and the generated documentation sitemap. Also remove a misplaced non-null assertion in the group members page flagged by oxlint. --- .../manage/users/groups/[id]/members/page.tsx | 48 ++++--------------- .../src/docs/homarr-docs-sitemap.ts | 1 + .../src/docs/integration-doc-slugs.ts | 2 + .../definitions/src/docs/widget-doc-slugs.ts | 1 + 4 files changed, 12 insertions(+), 40 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx index 08ef65a49..0c7e14dbb 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx @@ -1,17 +1,5 @@ import { notFound } from "next/navigation"; -import { - Alert, - Anchor, - Center, - Group, - Stack, - Table, - TableTbody, - TableTd, - TableTr, - Text, - Title, -} from "@mantine/core"; +import { Alert, Anchor, Center, Group, Stack, Table, TableTbody, TableTd, TableTr, Text, Title } from "@mantine/core"; import { IconExclamationCircle } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; @@ -53,13 +41,9 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) { const group = await api.group.getById({ id: params.id }); const isReserved = group.name === everyoneGroup; - const filteredMembers = searchParams.search - ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - group.members.filter((member) => - member.name - ?.toLowerCase() - .includes(searchParams.search!.trim().toLowerCase()), - ) + const searchTerm = searchParams.search?.trim().toLowerCase(); + const filteredMembers = searchTerm + ? group.members.filter((member) => member.name?.toLowerCase().includes(searchTerm)) : group.members; // "local" = every enabled provider managed locally, "external" = none, "mixed" = some. @@ -75,21 +59,14 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) { ) : ( managementType !== "local" && ( - } - > + }> {t(`group.memberNotice.${managementType}`)} ) )} - + {canManageMembers && !isReserved && ( {filteredMembers.map((member) => ( - + ))} @@ -140,11 +112,7 @@ const Row = ({ member, groupId, disabled }: RowProps) => { - - {canBeRemoved && !disabled && ( - - )} - + {canBeRemoved && !disabled && } ); }; diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 39ee81c08..9f906f95e 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -323,6 +323,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/github-containerregistry" | "/docs/integrations/quay" | "/docs/integrations/ntfy" + | "/docs/integrations/gotify" | "/docs/integrations/ical" | "/docs/integrations/anchor" | "/docs/integrations/truenas" diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index f6cae0298..99ec6ebb0 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -40,6 +40,7 @@ export const integrationDocSlugs: Record = { gitHubContainerRegistry: "github-containerregistry", quay: "quay", ntfy: "ntfy", + gotify: "gotify", ical: "ical", anchor: "anchor", truenas: "truenas", @@ -56,5 +57,6 @@ export const integrationDocSlugs: Record = { umami: "umami", peaNut: "peanut", beszel: "beszel", + archiveTeamWarrior: null, mock: null, }; diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index 8ae545f3e..3ffae6408 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -44,6 +44,7 @@ export const widgetDocSlugs: Record = { audioStats: "audio-stats", umami: "umami", ups: "ups", + archiveTeamWarrior: null, beszelSystemTable: "beszel-system-table", beszelSystemGrid: "beszel-system-grid", beszelAlerts: "beszel-alerts", From d0450b515395cf1521e3456ef4a851c797c9990f Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 16 Jun 2026 16:30:07 +0200 Subject: [PATCH 49/56] docs(integrations): add missing Gotify integration page The Gotify integration (merged from dev) declares a documentationUrl but had no docs page, failing the docs-coverage validation plugin once gotify was added to the integration doc-slug map. --- apps/docs/docs/integrations/gotify/index.mdx | 33 ++++++++++++++++++++ apps/docs/docs/integrations/gotify/index.ts | 8 +++++ 2 files changed, 41 insertions(+) create mode 100644 apps/docs/docs/integrations/gotify/index.mdx create mode 100644 apps/docs/docs/integrations/gotify/index.ts diff --git a/apps/docs/docs/integrations/gotify/index.mdx b/apps/docs/docs/integrations/gotify/index.mdx new file mode 100644 index 000000000..0542934c6 --- /dev/null +++ b/apps/docs/docs/integrations/gotify/index.mdx @@ -0,0 +1,33 @@ +--- +title: 'Gotify' +description: "Gotify is a simple server for sending and receiving messages in real time via push notifications." +hide_title: true +--- + +import { notificationsWidget } from '@site/docs/widgets/notifications'; +import { AddingIntegration } from '@site/src/components/integrations/adding'; +import { IntegrationHeader } from '@site/src/components/integrations/header'; +import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; +import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; +import { gotifyIntegration } from '.'; + + + +### Widgets & Capabilities + + +### Adding the integration + + +### Secrets + diff --git a/apps/docs/docs/integrations/gotify/index.ts b/apps/docs/docs/integrations/gotify/index.ts new file mode 100644 index 000000000..a02ea684e --- /dev/null +++ b/apps/docs/docs/integrations/gotify/index.ts @@ -0,0 +1,8 @@ +import { IntegrationDefinition } from "@site/src/types"; + +export const gotifyIntegration: IntegrationDefinition = { + name: "Gotify", + description: "Gotify is a simple server for sending and receiving messages in real time via push notifications.", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gotify.svg", + path: "../../integrations/gotify", +}; From 2af2d59b8f2f2254262b3ebe6b23a112af398767 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 16 Jun 2026 16:41:30 +0200 Subject: [PATCH 50/56] docs: align Gotify integration page with documentation PR #558 Match the official Gotify description wording and list Gotify as a supported integration on the Notifications widget page. --- apps/docs/docs/integrations/gotify/index.mdx | 2 +- apps/docs/docs/integrations/gotify/index.ts | 2 +- apps/docs/docs/widgets/notifications/index.mdx | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/docs/docs/integrations/gotify/index.mdx b/apps/docs/docs/integrations/gotify/index.mdx index 0542934c6..09c81265b 100644 --- a/apps/docs/docs/integrations/gotify/index.mdx +++ b/apps/docs/docs/integrations/gotify/index.mdx @@ -1,6 +1,6 @@ --- title: 'Gotify' -description: "Gotify is a simple server for sending and receiving messages in real time via push notifications." +description: "Gotify is a simple, self-hosted server for sending and receiving messages in real time." hide_title: true --- diff --git a/apps/docs/docs/integrations/gotify/index.ts b/apps/docs/docs/integrations/gotify/index.ts index a02ea684e..aceb6216f 100644 --- a/apps/docs/docs/integrations/gotify/index.ts +++ b/apps/docs/docs/integrations/gotify/index.ts @@ -2,7 +2,7 @@ import { IntegrationDefinition } from "@site/src/types"; export const gotifyIntegration: IntegrationDefinition = { name: "Gotify", - description: "Gotify is a simple server for sending and receiving messages in real time via push notifications.", + description: "Gotify is a simple, self-hosted server for sending and receiving messages in real time.", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gotify.svg", path: "../../integrations/gotify", }; diff --git a/apps/docs/docs/widgets/notifications/index.mdx b/apps/docs/docs/widgets/notifications/index.mdx index e6f927b8a..0a715ab3c 100644 --- a/apps/docs/docs/widgets/notifications/index.mdx +++ b/apps/docs/docs/widgets/notifications/index.mdx @@ -13,7 +13,8 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import { notificationsWidget } from '.'; import { ntfyIntegration } from '@site/docs/integrations/ntfy'; -import { nextcloudIntegration } from '@site/docs/integrations/nextcloud' +import { nextcloudIntegration } from '@site/docs/integrations/nextcloud'; +import { gotifyIntegration } from '@site/docs/integrations/gotify'; From 39900090b3b83e112169ecbd2f319e4cf5b41a50 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 16 Jun 2026 17:24:21 +0200 Subject: [PATCH 51/56] fix(ui): prevent icon/name wrapping in custom widgets table The Group in the Name cell lacked wrap="nowrap", causing the icon to detach onto its own line when the widget name was too long. --- .../[locale]/manage/custom-widgets/_custom-widget-list.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx index 031b19c20..7732dcc33 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx @@ -57,13 +57,13 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => { {definitions.map((def) => ( - + {def.iconUrl ? ( ) : ( )} - + {def.name} From 065b78fa3e89d4b8a13d8889232943a3686989e4 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 16 Jun 2026 17:55:37 +0200 Subject: [PATCH 52/56] fix(widgets): prevent TabsContainer state reset on data refetch react-jsx-parser auto-generates random keys on every render, causing full remounts that reset uncontrolled Tabs to defaultValue. Fix by: - Adding disableKeyGeneration to preserve React reconciliation - Memoizing bindings to avoid unnecessary re-parses - Converting TabsContainer to controlled Tabs with useState - Validating defaultTab against actual panel values - Clearing stale parseErrors when template/data changes --- packages/widgets/src/custom-api/custom-jsx-display.tsx | 10 ++++++++-- .../src/custom-api/jsx-interactive-components.tsx | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/widgets/src/custom-api/custom-jsx-display.tsx b/packages/widgets/src/custom-api/custom-jsx-display.tsx index 43ed33c78..1a6bf2f93 100644 --- a/packages/widgets/src/custom-api/custom-jsx-display.tsx +++ b/packages/widgets/src/custom-api/custom-jsx-display.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import JsxParser from "react-jsx-parser"; import { Alert, Stack, Text } from "@mantine/core"; import { IconAlertTriangle } from "@tabler/icons-react"; @@ -19,6 +19,11 @@ export default function CustomJsxDisplay({ data }: { data: Record([]); + const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]); + + useEffect(() => { + setParseErrors([]); + }, [template, bindings]); const handleError = useCallback((error: Error) => { setParseErrors((prev) => appendParseError(prev, error.message)); @@ -39,7 +44,8 @@ export default function CustomJsxDisplay({ data }: { data: Record p.props.value)); + const initialTab = (defaultTab && panelValues.has(defaultTab) ? defaultTab : firstPanel?.props.value) ?? null; + const [activeTab, setActiveTab] = useState(initialTab); return ( - + {panels.map((panel) => ( From 5078c300f824029450db9ad25418af770626d60e Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 16 Jun 2026 17:59:41 +0200 Subject: [PATCH 53/56] fix(widgets): race conditions in useLiveStats and stale value in integration select - Add generation guard to prevent late SSE events from writing stale data after systemId/integration changes - Sort integrationIds for stable key (avoids unnecessary resubscribes) - Include integrationKey and systemId in flush interval deps - Preserve current form value in WidgetIntegrationSelectInput when it's missing from loaded options (matches WidgetDynamicSelectInput pattern) --- .../widget-integration-select-input.tsx | 7 ++++- .../src/beszel/_shared/use-live-stats.ts | 26 ++++++++++++------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/widgets/src/_inputs/widget-integration-select-input.tsx b/packages/widgets/src/_inputs/widget-integration-select-input.tsx index 5b2ff0158..0b0f8f51b 100644 --- a/packages/widgets/src/_inputs/widget-integration-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-integration-select-input.tsx @@ -22,6 +22,11 @@ export const WidgetIntegrationSelectInput = ({ const integrationIds = form.values.integrationIds as string[]; const { data: selectData = [], isPending, isError } = options.useOptions(integrationIds); + const currentValue = form.getInputProps(`options.${property}`).value as string; + const data = + currentValue && !selectData.some((opt) => opt.value === currentValue) + ? [...selectData, { value: currentValue, label: currentValue }] + : selectData; if (integrationIds.length === 0) { return ( @@ -39,7 +44,7 @@ export const WidgetIntegrationSelectInput = ({ clearable={options.clearable} searchable={options.searchable} nothingFoundMessage={t("noResults")} - data={selectData} + data={data} disabled={isError} error={(isError && t("loadError")) || undefined} {...form.getInputProps(`options.${property}`)} diff --git a/packages/widgets/src/beszel/_shared/use-live-stats.ts b/packages/widgets/src/beszel/_shared/use-live-stats.ts index d8e7d5142..440800d0d 100644 --- a/packages/widgets/src/beszel/_shared/use-live-stats.ts +++ b/packages/widgets/src/beszel/_shared/use-live-stats.ts @@ -30,10 +30,12 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled const [error, setError] = useState(null); const bufferRef = useRef(null); const dirtyRef = useRef(false); + const generationRef = useRef(0); - const integrationKey = integrationIds.join(","); + const integrationKey = [...integrationIds].sort().join(","); useEffect(() => { + generationRef.current += 1; setData(null); setError(null); bufferRef.current = null; @@ -42,13 +44,17 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled const onData = useCallback( (incoming: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => { - setError(null); - const isFirst = !bufferRef.current; - bufferRef.current = appendToBuffer(bufferRef.current, incoming); - dirtyRef.current = true; - if (isFirst) { - setData(bufferRef.current); - } + const gen = generationRef.current; + queueMicrotask(() => { + if (generationRef.current !== gen) return; + setError(null); + const isFirst = !bufferRef.current; + bufferRef.current = appendToBuffer(bufferRef.current, incoming); + dirtyRef.current = true; + if (isFirst) { + setData(bufferRef.current); + } + }); }, [], ); @@ -66,13 +72,15 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled useEffect(() => { if (!subscriptionEnabled) return; + const gen = generationRef.current; const id = setInterval(() => { + if (generationRef.current !== gen) return; if (!dirtyRef.current || !bufferRef.current) return; dirtyRef.current = false; setData(bufferRef.current); }, FLUSH_INTERVAL_MS); return () => clearInterval(id); - }, [subscriptionEnabled]); + }, [subscriptionEnabled, integrationKey, systemId]); clientApi.widget.beszel.subscribeSystemStats.useSubscription(subscriptionInput, { enabled: subscriptionEnabled, From 45dfe3b9ab9b95492a15e9411fe9f01e71a1a69f Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 16 Jun 2026 18:29:28 +0200 Subject: [PATCH 54/56] fix: address PR review findings - Remove ternary operators from new code (widget-integration-select, TabsContainer, mock data, members page) - Remove stale /docs/management/tools/backup from sitemap type - Add value prop to error-state Select in beszel-system-stats --- .../manage/users/groups/[id]/members/page.tsx | 6 +++--- .../definitions/src/docs/homarr-docs-sitemap.ts | 1 - packages/integrations/src/mock/data/beszel.ts | 2 +- .../_inputs/widget-integration-select-input.tsx | 16 +++++++++------- .../src/beszel-system-stats/component.tsx | 1 + .../custom-api/jsx-interactive-components.tsx | 3 ++- 6 files changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx index 0c7e14dbb..c46c368ac 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx @@ -42,9 +42,9 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) { const isReserved = group.name === everyoneGroup; const searchTerm = searchParams.search?.trim().toLowerCase(); - const filteredMembers = searchTerm - ? group.members.filter((member) => member.name?.toLowerCase().includes(searchTerm)) - : group.members; + const filteredMembers = group.members.filter( + (member) => !searchTerm || member.name?.toLowerCase().includes(searchTerm), + ); // "local" = every enabled provider managed locally, "external" = none, "mixed" = some. const managementType = getGroupMemberManagementType(); diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 9f906f95e..6309a0d36 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -233,7 +233,6 @@ export type HomarrDocumentationPath = | "/docs/management/search-engines" | "/docs/management/settings" | "/docs/management/tasks" - | "/docs/management/tools/backup" | "/docs/management/users" | "/docs/widgets/anchor-note" | "/docs/widgets/app" diff --git a/packages/integrations/src/mock/data/beszel.ts b/packages/integrations/src/mock/data/beszel.ts index ea191af81..5f2a26389 100644 --- a/packages/integrations/src/mock/data/beszel.ts +++ b/packages/integrations/src/mock/data/beszel.ts @@ -375,7 +375,7 @@ export class BeszelMockService { name: names[i % names.length] ?? "CPU", val: rand(60, 95), created: new Date(now - (i + 1) * 3_600_000).toISOString(), - resolved: i % 2 === 0 ? new Date(now - i * 1_800_000).toISOString() : null, + resolved: i % 2 === 0 && new Date(now - i * 1_800_000).toISOString() || null, })); } } diff --git a/packages/widgets/src/_inputs/widget-integration-select-input.tsx b/packages/widgets/src/_inputs/widget-integration-select-input.tsx index 0b0f8f51b..e91c07a7e 100644 --- a/packages/widgets/src/_inputs/widget-integration-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-integration-select-input.tsx @@ -23,10 +23,8 @@ export const WidgetIntegrationSelectInput = ({ const { data: selectData = [], isPending, isError } = options.useOptions(integrationIds); const currentValue = form.getInputProps(`options.${property}`).value as string; - const data = - currentValue && !selectData.some((opt) => opt.value === currentValue) - ? [...selectData, { value: currentValue, label: currentValue }] - : selectData; + const hasCurrentInData = !currentValue || selectData.some((opt) => opt.value === currentValue); + const data = hasCurrentInData ? selectData : [...selectData, { value: currentValue, label: currentValue }]; if (integrationIds.length === 0) { return ( @@ -36,17 +34,21 @@ export const WidgetIntegrationSelectInput = ({ ); } + const description = options.withDescription ? tInput("description") : undefined; + const placeholder = isPending ? t("loading") : t("placeholder"); + const error = isError ? t("loadError") : undefined; + return ( value && handleSelectSystem(value)} /> diff --git a/packages/widgets/src/custom-api/jsx-interactive-components.tsx b/packages/widgets/src/custom-api/jsx-interactive-components.tsx index 8c35bdca3..2b18342a3 100644 --- a/packages/widgets/src/custom-api/jsx-interactive-components.tsx +++ b/packages/widgets/src/custom-api/jsx-interactive-components.tsx @@ -70,7 +70,8 @@ export function TabsContainer({ children, defaultTab }: TabsContainerProps) { const panels = Children.toArray(children).filter(isTabPanel); const firstPanel = panels[0]; const panelValues = new Set(panels.map((p) => p.props.value)); - const initialTab = (defaultTab && panelValues.has(defaultTab) ? defaultTab : firstPanel?.props.value) ?? null; + const resolvedDefault = defaultTab && panelValues.has(defaultTab) && defaultTab; + const initialTab = resolvedDefault || firstPanel?.props.value || null; const [activeTab, setActiveTab] = useState(initialTab); return ( From 8bee0174e9bdfe0c54fa21c61becaeabca0f1d78 Mon Sep 17 00:00:00 2001 From: ajnart <49837342+ajnart@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:30:39 +0000 Subject: [PATCH 55/56] style: auto-fix lint and format --- packages/definitions/src/docs/homarr-docs-sitemap.ts | 1 + packages/integrations/src/mock/data/beszel.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 6309a0d36..9f906f95e 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -233,6 +233,7 @@ export type HomarrDocumentationPath = | "/docs/management/search-engines" | "/docs/management/settings" | "/docs/management/tasks" + | "/docs/management/tools/backup" | "/docs/management/users" | "/docs/widgets/anchor-note" | "/docs/widgets/app" diff --git a/packages/integrations/src/mock/data/beszel.ts b/packages/integrations/src/mock/data/beszel.ts index 5f2a26389..c6998aa84 100644 --- a/packages/integrations/src/mock/data/beszel.ts +++ b/packages/integrations/src/mock/data/beszel.ts @@ -375,7 +375,7 @@ export class BeszelMockService { name: names[i % names.length] ?? "CPU", val: rand(60, 95), created: new Date(now - (i + 1) * 3_600_000).toISOString(), - resolved: i % 2 === 0 && new Date(now - i * 1_800_000).toISOString() || null, + resolved: (i % 2 === 0 && new Date(now - i * 1_800_000).toISOString()) || null, })); } } From 91f9329773c84a8ac6e47a03b1400b8ca0b6917d Mon Sep 17 00:00:00 2001 From: "homarr-renovate[bot]" <158783068+homarr-renovate[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:29:01 +0000 Subject: [PATCH 56/56] chore(deps): update pnpm to v11.6.0 (#5980) Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com> --- deployments/prebuilt-debian/package.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deployments/prebuilt-debian/package.json b/deployments/prebuilt-debian/package.json index 99977658b..fa67951fc 100644 --- a/deployments/prebuilt-debian/package.json +++ b/deployments/prebuilt-debian/package.json @@ -6,7 +6,7 @@ }, "engines": { "node": ">=24.16.0", - "pnpm": ">=11.5.3" + "pnpm": ">=11.6.0" }, - "packageManager": "pnpm@11.5.3" + "packageManager": "pnpm@11.6.0" } diff --git a/package.json b/package.json index 0742d3e98..32658078f 100644 --- a/package.json +++ b/package.json @@ -62,9 +62,9 @@ }, "engines": { "node": ">=24.16.0", - "pnpm": ">=11.5.3" + "pnpm": ">=11.6.0" }, - "packageManager": "pnpm@11.5.3", + "packageManager": "pnpm@11.6.0", "dependencies": { "jsonpath-plus": "catalog:" }