diff --git a/apps/docs/docs/widgets/beszel-system-grid/index.mdx b/apps/docs/docs/widgets/beszel-system-grid/index.mdx
index de3578aeb..e3b17623b 100644
--- a/apps/docs/docs/widgets/beszel-system-grid/index.mdx
+++ b/apps/docs/docs/widgets/beszel-system-grid/index.mdx
@@ -18,7 +18,9 @@ import beszelSystemGrid from './img/beszel-system-grid.png';
categories={['System Monitoring']}
/>
-This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space.
+This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
+
+Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Screenshots
diff --git a/apps/docs/docs/widgets/beszel-system-stats/index.mdx b/apps/docs/docs/widgets/beszel-system-stats/index.mdx
index 8efa3fb46..1e9c896b9 100644
--- a/apps/docs/docs/widgets/beszel-system-stats/index.mdx
+++ b/apps/docs/docs/widgets/beszel-system-stats/index.mdx
@@ -1,24 +1,21 @@
---
-title: 'Beszel System Stats'
-description: 'Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.'
+title: "Beszel System Stats"
+description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel."
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
-import { beszelSystemStatsWidget } from '.';
-import { beszelIntegration } from '@site/docs/integrations/beszel';
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
+import { beszelSystemStatsWidget } from ".";
+import { beszelIntegration } from "@site/docs/integrations/beszel";
-import beszelSystemStats from './img/beszel-system-stats.png';
+import beszelSystemStats from "./img/beszel-system-stats.png";
-
+
-This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. Data is cached and refreshed periodically. You can select a time period ranging from 1 hour to 30 days.
+This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
### Screenshots
@@ -26,14 +23,19 @@ This widget displays time-series charts for a selected Beszel system, including
### Supported Integrations
-
+
### Adding the widget
### Configuration
+
diff --git a/apps/docs/docs/widgets/beszel-system-table/index.mdx b/apps/docs/docs/widgets/beszel-system-table/index.mdx
index dfab7a977..df68fcc1b 100644
--- a/apps/docs/docs/widgets/beszel-system-table/index.mdx
+++ b/apps/docs/docs/widgets/beszel-system-table/index.mdx
@@ -17,7 +17,9 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']}
/>
-This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column.
+This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
+
+Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Supported Integrations
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
index fbe310781..7d06a8951 100644
--- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
+++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
@@ -10,6 +10,7 @@ import {
createWSClient,
httpBatchStreamLink,
httpLink,
+ httpSubscriptionLink,
isNonJsonSerializable,
loggerLink,
splitLink,
@@ -101,9 +102,17 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}),
splitLink({
condition: ({ type }) => type === "subscription",
- true: wsLink({
- client: wsClient,
- transformer: superjson,
+ true: splitLink({
+ condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
+ true: httpSubscriptionLink({
+ url: getTrpcUrl(),
+ transformer: superjson,
+ eventSourceOptions: { withCredentials: true },
+ }),
+ false: wsLink({
+ client: wsClient,
+ transformer: superjson,
+ }),
}),
false: splitLink({
condition: ({ input }) => isNonJsonSerializable(input),
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx
index d67002432..16be5c478 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx
@@ -22,6 +22,7 @@ import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata";
+import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client";
@@ -40,7 +41,7 @@ export const createBoardContentPage = >(
}: Props) => {
return {
layout: createBoardLayout({
- headerActions: ,
+ headerActions: ,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
index 454256366..30c91de40 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
@@ -41,15 +41,12 @@ import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dy
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
-export const BoardContentHeaderActions = () => {
+export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
- // Fall back to read-only if the query has no data (e.g. errored) so we never expose
- // edit/save/settings UI that would then fail server-side.
- const { data: demoReadOnly = true, isLoading } = clientApi.info.isDemoReadOnly.useQuery();
- if (!hasChangeAccess || isLoading) {
+ if (!hasChangeAccess) {
return ;
}
diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
index c3f3469cb..ff225f642 100644
--- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx
+++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
@@ -4,7 +4,7 @@ import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
-import type { QueryClient } from "@tanstack/react-query";
+import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
@@ -49,7 +49,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
- const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
+ const widgetQueryKey = useMemo(
+ () => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
+ [currentDefinition, item.kind],
+ );
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
@@ -276,7 +279,7 @@ const WidgetCacheAge = ({
isFetching,
}: {
queryClient: QueryClient;
- queryKey: unknown[];
+ queryKey: QueryKey;
isFetching: boolean;
}) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
@@ -295,8 +298,8 @@ const WidgetCacheAge = ({
.filter(Boolean);
if (timestamps.length === 0) return null;
- const oldest = Math.min(...timestamps);
- const seconds = Math.floor((Date.now() - oldest) / 1000);
+ const latest = Math.max(...timestamps);
+ const seconds = Math.floor((Date.now() - latest) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
return `${Math.floor(seconds / 60)}m ago`;
diff --git a/apps/nextjs/src/env.ts b/apps/nextjs/src/env.ts
index 96f03e7b7..d285a8393 100644
--- a/apps/nextjs/src/env.ts
+++ b/apps/nextjs/src/env.ts
@@ -4,6 +4,7 @@ export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
+ DEMO_READ_ONLY: createBooleanSchema(true),
},
experimental__runtimeEnv: process.env,
});
diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts
index 9890483e2..a8cdb9641 100644
--- a/packages/api/src/query-cache.ts
+++ b/packages/api/src/query-cache.ts
@@ -16,7 +16,7 @@ export const setActiveQueryCacheBoardId = (boardId: string | null) => {
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
-const excludedWidgetPaths = new Set(["widget.app.ping"]);
+const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts
index 66516b372..81fae10a0 100644
--- a/packages/api/src/router/widgets/beszel.ts
+++ b/packages/api/src/router/widgets/beszel.ts
@@ -207,6 +207,13 @@ export const beszelRouter = createTRPCRouter({
return observable((emit) => {
const controller = new AbortController();
let isActive = true;
+ let emittedEventCount = 0;
+
+ logger.debug("Beszel realtime subscription started", {
+ userId: ctx.session?.user?.id,
+ integrationIds: ctx.integrations.map((integration) => integration.id),
+ systemId: input.systemId,
+ });
void (async () => {
const integration = ctx.integrations[0];
@@ -223,7 +230,19 @@ export const beszelRouter = createTRPCRouter({
await instance.subscribeRealtimeMetrics(
input.systemId,
(event) => {
- if (isActive) emit.next(event);
+ if (!isActive) return;
+ emittedEventCount += 1;
+ if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
+ logger.debug("Forwarding Beszel realtime events", {
+ userId: ctx.session?.user?.id,
+ integrationId: integration.id,
+ systemId: input.systemId,
+ eventType: event.type,
+ emittedEventCount,
+ statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
+ });
+ }
+ emit.next(event);
},
controller.signal,
);
@@ -237,6 +256,13 @@ export const beszelRouter = createTRPCRouter({
}
} catch (error) {
if (isActive) {
+ logger.warn("Beszel realtime subscription failed", {
+ userId: ctx.session?.user?.id,
+ integrationId: integration.id,
+ systemId: input.systemId,
+ emittedEventCount,
+ error: errorMessage(error),
+ });
emit.error(
error instanceof TRPCError
? error
@@ -249,12 +275,24 @@ export const beszelRouter = createTRPCRouter({
}
if (isActive) {
+ logger.debug("Beszel realtime subscription completed", {
+ userId: ctx.session?.user?.id,
+ integrationId: integration.id,
+ systemId: input.systemId,
+ emittedEventCount,
+ });
emit.complete();
}
})();
return () => {
isActive = false;
+ logger.debug("Beszel realtime subscription cancelled", {
+ userId: ctx.session?.user?.id,
+ integrationIds: ctx.integrations.map((integration) => integration.id),
+ systemId: input.systemId,
+ emittedEventCount,
+ });
controller.abort();
};
});
diff --git a/packages/api/src/test/query-cache.spec.ts b/packages/api/src/test/query-cache.spec.ts
index 090544cb2..68d28eb15 100644
--- a/packages/api/src/test/query-cache.spec.ts
+++ b/packages/api/src/test/query-cache.spec.ts
@@ -67,6 +67,8 @@ describe("isPersistableWidgetQueryKey", () => {
[[["docker", "getContainers"], { type: "query" }], true],
[[["integration", "byIds"], { type: "query" }], true],
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
+ [[["widget", "beszel", "getSystemStats"], { type: "query" }], false],
+ [[["widget", "beszel", "getSystems"], { type: "query" }], true],
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
diff --git a/packages/core/src/infrastructure/http/request.ts b/packages/core/src/infrastructure/http/request.ts
index 9bfc376fa..b13eb0cc2 100644
--- a/packages/core/src/infrastructure/http/request.ts
+++ b/packages/core/src/infrastructure/http/request.ts
@@ -2,7 +2,7 @@ import type { AgentOptions } from "node:https";
import { Agent as HttpsAgent } from "node:https";
import { checkServerIdentity } from "node:tls";
import axios from "axios";
-import type { RequestInfo, RequestInit, Response } from "undici";
+import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici";
import { fetch } from "undici";
import {
@@ -29,11 +29,15 @@ export const createCustomCheckServerIdentity = (
};
};
-export const createCertificateAgentAsync = async (override?: {
- ca: string | string[];
- checkServerIdentity: typeof checkServerIdentity;
-}) => {
+export const createCertificateAgentAsync = async (
+ override?: {
+ ca: string | string[];
+ checkServerIdentity: typeof checkServerIdentity;
+ },
+ agentOptions?: Pick,
+) => {
return new UndiciHttpAgent({
+ ...agentOptions,
connect: override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
@@ -61,14 +65,18 @@ export const createAxiosCertificateInstanceAsync = async (
export const fetchWithTrustedCertificatesAsync = async (
url: RequestInfo,
- options?: RequestInit & { timeout?: number },
+ options?: RequestInit & { timeout?: number; bodyTimeout?: number },
): Promise => {
- const agent = await createCertificateAgentAsync(undefined);
+ const agent = await createCertificateAgentAsync(
+ undefined,
+ options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
+ );
if (options?.timeout) {
+ const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
return await withTimeoutAsync(
async (signal) =>
fetch(url, {
- ...options,
+ ...fetchOptions,
signal,
dispatcher: agent,
}),
@@ -76,8 +84,9 @@ export const fetchWithTrustedCertificatesAsync = async (
);
}
+ const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
return fetch(url, {
- ...options,
+ ...fetchOptions,
dispatcher: agent,
});
};
diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts
index e9c4b35b2..f8c681da3 100644
--- a/packages/integrations/src/beszel/beszel-integration.ts
+++ b/packages/integrations/src/beszel/beszel-integration.ts
@@ -19,15 +19,66 @@ import type {
BeszelSystemDetails,
BeszelSystemdService,
BeszelSystemStatsRecord,
+ BeszelSystemStats,
+ BeszelContainerStats,
CreateAlertInput,
LiveStatsEvent,
PocketBaseListResponse,
UpdateAlertInput,
} from "./beszel-types";
+import { createRealtimeMetricsTopic, PocketBaseSseParser } from "./pocketbase-realtime";
const logger = createLogger({ module: "beszel-integration" });
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
+const realtimeRetryDelaysMs = [200, 300, 500, 1_000, 1_500, 2_000] as const;
+
+const supportsRealtimeMetrics = (version: string) => {
+ const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
+ return major > 0 || minor >= 13;
+};
+
+const waitForRetryAsync = async (delayMs: number, signal: AbortSignal) =>
+ await new Promise((resolve) => {
+ if (signal.aborted) return resolve();
+ const timeout = setTimeout(done, delayMs);
+ signal.addEventListener("abort", done, { once: true });
+ function done() {
+ clearTimeout(timeout);
+ signal.removeEventListener("abort", done);
+ resolve();
+ }
+ });
+
+interface BeszelRealtimeSnapshot {
+ stats?: BeszelSystemStats;
+ container?: BeszelContainerStats[];
+}
+
+export const normalizeRealtimeSnapshot = (
+ payload: BeszelRealtimeSnapshot,
+ systemId: string,
+ receivedAt = new Date(),
+): LiveStatsEvent[] => {
+ const created = receivedAt.toISOString();
+ const id = `realtime-${receivedAt.getTime()}`;
+ const events: LiveStatsEvent[] = [];
+
+ if (payload.stats && typeof payload.stats === "object" && !Array.isArray(payload.stats)) {
+ events.push({
+ type: "system_stats",
+ record: { id, system: systemId, stats: payload.stats, type: "1m", created, updated: created },
+ });
+ }
+ if (Array.isArray(payload.container)) {
+ events.push({
+ type: "container_stats",
+ record: { id, system: systemId, stats: payload.container, type: "1m", created, updated: created },
+ });
+ }
+
+ return events;
+};
interface BeszelSession {
token: string;
@@ -285,123 +336,163 @@ export class BeszelIntegration extends Integration {
});
}
- /**
- * Subscribe to real-time system and container stats via PocketBase SSE.
- * Connects to the PocketBase Realtime API and parses incoming SSE events,
- * filtering by systemId. This provides live-updating metrics (≤1s latency
- * from agent) instead of polling the REST API every 5s.
- */
+ /** Subscribe to Beszel's custom one-second `rt_metrics` PocketBase topic. */
public async subscribeRealtimeMetrics(
systemId: string,
onMessage: (event: LiveStatsEvent) => void,
signal: AbortSignal,
): Promise {
- const session = await this.authenticateAsync();
- const realtimeUrl = this.url("/api/realtime");
-
- logger.debug("Opening Beszel SSE connection for realtime metrics", {
- integrationId: this.integration.id,
- systemId,
- url: realtimeUrl.pathname,
- });
-
- const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
- headers: { Authorization: session.token },
- signal,
- });
-
- if (!response.ok) {
- logger.warn("Beszel SSE connection failed", {
- integrationId: this.integration.id,
- systemId,
- status: response.status,
- });
- throw new ResponseError(response);
+ const systems = await this.getSystemsAsync();
+ const system = systems.find((candidate) => candidate.id === systemId);
+ if (!system) throw new Error("Selected Beszel system was not found");
+ if (system.status !== "up")
+ throw new Error(`Beszel system is ${system.status}; Live mode requires an online system`);
+ if (!supportsRealtimeMetrics(system.info.v)) {
+ throw new Error(`Beszel agent ${system.info.v} does not support Live mode; version 0.13.0 or newer is required`);
}
- const reader = response.body?.getReader();
- if (!reader) throw new Error("No response body in SSE stream");
-
- const decoder = new TextDecoder();
- let buffer = "";
- let subscribed = false;
-
- const processLine = async (line: string) => {
- if (!line.startsWith("data: ")) return;
+ const realtimeUrl = this.url("/api/realtime");
+ const topic = createRealtimeMetricsTopic(systemId);
+ let retryAttempt = 0;
+ let emittedSnapshotCount = 0;
+ while (!signal.aborted) {
try {
- const parsed = JSON.parse(line.slice(6)) as Record;
-
- // PB_CONNECT delivers the clientId — use it to POST our subscriptions
- if (!subscribed && typeof parsed.clientId === "string") {
- subscribed = true;
- const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
- method: "POST",
+ let session = await this.authenticateAsync();
+ const openStream = async () =>
+ await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: {
+ Accept: "text/event-stream",
Authorization: session.token,
- "Content-Type": "application/json",
+ "Cache-Control": "no-cache",
+ Pragma: "no-cache",
},
- body: JSON.stringify({
- clientId: parsed.clientId,
- subscriptions: ["system_stats", "container_stats"],
- }),
signal,
+ bodyTimeout: 0,
});
- if (!subscribeResponse.ok) {
- logger.warn("Beszel SSE subscription POST failed", {
+ logger.debug("Opening Beszel rt_metrics SSE connection", {
+ integrationId: this.integration.id,
+ systemId,
+ retryAttempt,
+ });
+ let response = await openStream();
+ if (response.status === 401) {
+ await this.sessionStore.clearAsync();
+ session = await this.authenticateAsync();
+ response = await openStream();
+ }
+ if (!response.ok) throw new ResponseError(response);
+
+ const reader = response.body?.getReader();
+ if (!reader) throw new Error("Beszel realtime response has no body");
+ const decoder = new TextDecoder();
+ const parser = new PocketBaseSseParser();
+ let clientId: string | undefined;
+
+ const processFrameAsync = async (frame: ReturnType[number]) => {
+ if (frame.event === "PB_CONNECT") {
+ let fallbackClientId: string | undefined;
+ try {
+ const connectData = JSON.parse(frame.data) as { clientId?: string };
+ fallbackClientId = connectData.clientId;
+ } catch {
+ // PocketBase normally provides the client ID in the SSE id field.
+ }
+ clientId = frame.id || fallbackClientId;
+ if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
+
+ const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
+ method: "POST",
+ headers: { Authorization: session.token, "Content-Type": "application/json" },
+ body: JSON.stringify({ clientId, subscriptions: [topic] }),
+ signal,
+ });
+ if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
+ if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
+ logger.debug("Subscribed to Beszel rt_metrics topic", {
integrationId: this.integration.id,
systemId,
- status: subscribeResponse.status,
+ clientId,
});
- throw new ResponseError(subscribeResponse);
+ return;
}
- logger.debug("Beszel SSE subscribed to collections", {
+ if (!frame.event.startsWith("rt_metrics")) return;
+ try {
+ const snapshot = JSON.parse(frame.data) as BeszelRealtimeSnapshot;
+ const events = normalizeRealtimeSnapshot(snapshot, systemId);
+ if (events.length === 0) return;
+ emittedSnapshotCount += 1;
+ retryAttempt = 0;
+ for (const event of events) onMessage(event);
+ if (emittedSnapshotCount === 1 || emittedSnapshotCount % 30 === 0) {
+ logger.debug("Forwarded Beszel rt_metrics snapshots", {
+ integrationId: this.integration.id,
+ systemId,
+ emittedSnapshotCount,
+ containerCount: snapshot.container?.length ?? 0,
+ });
+ }
+ } catch (error) {
+ logger.warn("Failed to parse Beszel rt_metrics payload", {
+ integrationId: this.integration.id,
+ systemId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ };
+
+ logger.debug("Beszel rt_metrics SSE connection established", {
+ integrationId: this.integration.id,
+ systemId,
+ contentType: response.headers.get("content-type"),
+ });
+ try {
+ while (!signal.aborted) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ for (const frame of parser.push(decoder.decode(value, { stream: true }))) {
+ await processFrameAsync(frame);
+ }
+ }
+ for (const frame of parser.push(decoder.decode())) await processFrameAsync(frame);
+ for (const frame of parser.finish()) await processFrameAsync(frame);
+ } finally {
+ await reader.cancel().catch(() => {});
+ reader.releaseLock();
+ }
+
+ if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
+ } catch (error) {
+ if (signal.aborted) break;
+ if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
+ retryAttempt += 1;
+ logger.debug("Retrying Beszel rt_metrics handshake after reauthentication", {
integrationId: this.integration.id,
systemId,
- clientId: parsed.clientId,
});
- return;
+ continue;
}
-
- const record = parsed.record as Record | undefined;
- if (!record || record.system !== systemId) return;
-
- if (Array.isArray(record.stats)) {
- onMessage({
- type: "container_stats",
- record: record as unknown as BeszelContainerStatsRecord,
- });
- } else if (record.stats && typeof record.stats === "object") {
- onMessage({
- type: "system_stats",
- record: record as unknown as BeszelSystemStatsRecord,
- });
- }
- } catch (error) {
- if (error instanceof ResponseError) throw error;
- // skip malformed SSE data lines
+ if (error instanceof ResponseError && error.statusCode < 500 && error.statusCode !== 429) throw error;
+ const delayMs = realtimeRetryDelaysMs[Math.min(retryAttempt, realtimeRetryDelaysMs.length - 1)] ?? 2_000;
+ retryAttempt += 1;
+ logger.warn("Beszel rt_metrics connection lost; retrying", {
+ integrationId: this.integration.id,
+ systemId,
+ retryAttempt,
+ delayMs,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ await waitForRetryAsync(delayMs, signal);
}
- };
-
- try {
- while (true) {
- 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) {
- await processLine(line);
- }
- }
- } finally {
- reader.releaseLock();
- logger.debug("Beszel SSE connection closed", { integrationId: this.integration.id, systemId });
}
+
+ logger.debug("Beszel rt_metrics subscription stopped", {
+ integrationId: this.integration.id,
+ systemId,
+ emittedSnapshotCount,
+ });
}
protected async testingAsync(input: IntegrationTestingInput): Promise {
diff --git a/packages/integrations/src/beszel/beszel-types.ts b/packages/integrations/src/beszel/beszel-types.ts
index 1abbc6564..e8504bb87 100644
--- a/packages/integrations/src/beszel/beszel-types.ts
+++ b/packages/integrations/src/beszel/beszel-types.ts
@@ -133,9 +133,9 @@ export interface BeszelSystemStats {
/** disk usage (%) */
dp: number;
/** disk read (MB/s) */
- dr: number;
+ dr?: number;
/** disk write (MB/s) */
- dw: number;
+ dw?: number;
/** disk read max (MB/s) */
drm?: number;
/** disk write max (MB/s) */
@@ -145,9 +145,9 @@ export interface BeszelSystemStats {
/** disk IOPS max [read, write] */
diom?: [number, number];
/** network sent — all interfaces (bytes/s) */
- ns: number;
+ ns?: number;
/** network received — all interfaces (bytes/s) */
- nr: number;
+ nr?: number;
/** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
b?: [number, number];
/** network sent max (bytes/s) */
@@ -169,26 +169,26 @@ export interface BeszelSystemStats {
}
export interface BeszelExtraFsStats {
- /** total disk (bytes) */
+ /** total disk (GiB) */
d: number;
- /** disk used (bytes) */
+ /** disk used (GiB) */
du: number;
/** read (bytes/s) */
r: number;
/** write (bytes/s) */
w: number;
/** read max (bytes/s) */
- rm: number;
+ rm?: number;
/** write max (bytes/s) */
- wm: number;
+ wm?: number;
/** read (IOPS) */
- rb: number;
+ rb?: number;
/** write (IOPS) */
- wb: number;
+ wb?: number;
/** read max (IOPS) */
- rbm: number;
+ rbm?: number;
/** write max (IOPS) */
- wbm: number;
+ wbm?: number;
}
export interface BeszelGPUData {
@@ -382,6 +382,8 @@ export interface BeszelSystemRow {
memory: number;
/** disk usage (%) */
disk: number;
+ /** extra filesystem usage (%) keyed by mount path */
+ extraFilesystems: Record;
/** GPU usage (%) */
gpu: number;
/** load average [1m, 5m, 15m] */
diff --git a/packages/integrations/src/beszel/pocketbase-realtime.ts b/packages/integrations/src/beszel/pocketbase-realtime.ts
new file mode 100644
index 000000000..e3330834b
--- /dev/null
+++ b/packages/integrations/src/beszel/pocketbase-realtime.ts
@@ -0,0 +1,65 @@
+export interface PocketBaseSseFrame {
+ event: string;
+ id?: string;
+ data: string;
+}
+
+export const createRealtimeMetricsTopic = (systemId: string) =>
+ `rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: systemId } }))}`;
+
+export class PocketBaseSseParser {
+ private buffer = "";
+ private event = "message";
+ private id: string | undefined;
+ private dataLines: string[] = [];
+
+ public push(chunk: string): PocketBaseSseFrame[] {
+ this.buffer += chunk;
+ const frames: PocketBaseSseFrame[] = [];
+ const lines = this.buffer.split(/\r?\n/);
+ this.buffer = lines.pop() ?? "";
+
+ for (const line of lines) {
+ const frame = this.processLine(line);
+ if (frame) frames.push(frame);
+ }
+
+ return frames;
+ }
+
+ public finish(): PocketBaseSseFrame[] {
+ const frames: PocketBaseSseFrame[] = [];
+ if (this.buffer !== "") {
+ const frame = this.processLine(this.buffer);
+ if (frame) frames.push(frame);
+ this.buffer = "";
+ }
+ const trailingFrame = this.dispatch();
+ if (trailingFrame) frames.push(trailingFrame);
+ return frames;
+ }
+
+ private processLine(line: string): PocketBaseSseFrame | null {
+ if (line === "") return this.dispatch();
+ if (line.startsWith(":")) return null;
+
+ const colonIndex = line.indexOf(":");
+ const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
+ let value = colonIndex === -1 ? "" : line.slice(colonIndex + 1);
+ if (value.startsWith(" ")) value = value.slice(1);
+
+ if (field === "event") this.event = value;
+ else if (field === "id") this.id = value;
+ else if (field === "data") this.dataLines.push(value);
+ return null;
+ }
+
+ private dispatch(): PocketBaseSseFrame | null {
+ if (this.dataLines.length === 0 && this.event === "message" && this.id === undefined) return null;
+ const frame = { event: this.event, id: this.id, data: this.dataLines.join("\n") };
+ this.event = "message";
+ this.id = undefined;
+ this.dataLines = [];
+ return frame;
+ }
+}
diff --git a/packages/integrations/src/beszel/test/beszel-integration.spec.ts b/packages/integrations/src/beszel/test/beszel-integration.spec.ts
index a68e1dbc8..22465fd19 100644
--- a/packages/integrations/src/beszel/test/beszel-integration.spec.ts
+++ b/packages/integrations/src/beszel/test/beszel-integration.spec.ts
@@ -40,7 +40,124 @@ vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
}));
-import { BeszelIntegration } from "../beszel-integration";
+import { BeszelIntegration, normalizeRealtimeSnapshot } from "../beszel-integration";
+import type { LiveStatsEvent } from "../beszel-types";
+import { createRealtimeMetricsTopic } from "../pocketbase-realtime";
+
+describe("normalizeRealtimeSnapshot", () => {
+ test("normalizes current Beszel host and container snapshots", () => {
+ const receivedAt = new Date("2026-07-11T13:51:30.217Z");
+ const events = normalizeRealtimeSnapshot(
+ {
+ stats: {
+ cpu: 18.07,
+ m: 31.09,
+ mu: 16.53,
+ mp: 53.18,
+ mb: 14.32,
+ s: 4,
+ su: 3.9,
+ d: 938.8,
+ du: 455.81,
+ dp: 51.15,
+ efs: { sda: { d: 1906.8, du: 1656.45, r: 25.63, w: 0 } },
+ },
+ container: [{ n: "homarr", c: 1.65, m: 650.56, b: [356066, 19702867] }],
+ },
+ "system-1",
+ receivedAt,
+ );
+
+ expect(events).toHaveLength(2);
+ expect(events[0]).toMatchObject({
+ type: "system_stats",
+ record: { system: "system-1", created: receivedAt.toISOString(), stats: { cpu: 18.07 } },
+ });
+ expect(events[1]).toMatchObject({
+ type: "container_stats",
+ record: { system: "system-1", stats: [{ n: "homarr", m: 650.56 }] },
+ });
+ });
+
+ test("ignores snapshots without metric data", () => {
+ expect(normalizeRealtimeSnapshot({}, "system-1")).toEqual([]);
+ });
+});
+
+describe("subscribeRealtimeMetrics", () => {
+ test("subscribes to Beszel rt_metrics and forwards one-second snapshots", async () => {
+ const originalFetch = globalThis.fetch;
+ const topic = createRealtimeMetricsTopic("system-1");
+ const subscriptionBodies: unknown[] = [];
+ const encoder = new TextEncoder();
+
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith("/api/collections/users/auth-with-password")) {
+ return Response.json({ token: "test-token", record: { id: "user-1" } });
+ }
+ if (url.includes("/api/collections/systems/records")) {
+ return Response.json({
+ page: 1,
+ perPage: 500,
+ totalItems: 1,
+ totalPages: 1,
+ items: [
+ {
+ id: "system-1",
+ name: "Server",
+ host: "localhost",
+ port: "45876",
+ status: "up",
+ info: { cpu: 1, mp: 2, dp: 3, u: 4, v: "0.18.7" },
+ created: "2026-07-11T00:00:00.000Z",
+ updated: "2026-07-11T00:00:00.000Z",
+ },
+ ],
+ });
+ }
+ if (url.endsWith("/api/realtime") && init?.method === "POST") {
+ subscriptionBodies.push(JSON.parse(String(init.body)));
+ expect(new Headers(init.headers).get("Authorization")).toBe("test-token");
+ return new Response(null, { status: 204 });
+ }
+ if (url.endsWith("/api/realtime")) {
+ const snapshot = JSON.stringify({
+ stats: { cpu: 5, m: 10, mu: 4, mp: 40, mb: 1, s: 0, su: 0, d: 20, du: 8, dp: 40 },
+ container: [{ n: "homarr", c: 1, m: 256 }],
+ });
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode("event: PB_CONNECT\nid: client-1\ndata: {}\n\n"));
+ controller.enqueue(encoder.encode(`event: ${topic}\ndata: ${snapshot}\n\n`));
+ controller.close();
+ },
+ });
+ return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
+ }
+ throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${url}`);
+ }) as typeof fetch;
+
+ try {
+ const integration = createBeszelIntegration();
+ const controller = new AbortController();
+ const events: LiveStatsEvent[] = [];
+ await integration.subscribeRealtimeMetrics(
+ "system-1",
+ (event) => {
+ events.push(event);
+ if (events.length === 2) controller.abort();
+ },
+ controller.signal,
+ );
+
+ expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
+ expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+});
function createBeszelIntegration() {
return new BeszelIntegration({
diff --git a/packages/integrations/src/beszel/test/pocketbase-realtime.spec.ts b/packages/integrations/src/beszel/test/pocketbase-realtime.spec.ts
new file mode 100644
index 000000000..938278d95
--- /dev/null
+++ b/packages/integrations/src/beszel/test/pocketbase-realtime.spec.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test } from "vitest";
+
+import { createRealtimeMetricsTopic, PocketBaseSseParser } from "../pocketbase-realtime";
+
+describe("PocketBaseSseParser", () => {
+ test("parses PB_CONNECT client IDs and CRLF-delimited frames split across chunks", () => {
+ const parser = new PocketBaseSseParser();
+ expect(parser.push("event: PB_CONNECT\r\nid: client-")).toEqual([]);
+ expect(parser.push("123\r\ndata: {}\r\n\r\n")).toEqual([{ event: "PB_CONNECT", id: "client-123", data: "{}" }]);
+ });
+
+ test("joins multiline data and ignores keepalive comments", () => {
+ const parser = new PocketBaseSseParser();
+ expect(parser.push(': keepalive\n\nevent: rt_metrics\ndata: {"stats":\ndata: {}}\n\n')).toEqual([
+ { event: "rt_metrics", id: undefined, data: '{"stats":\n{}}' },
+ ]);
+ });
+
+ test("flushes a trailing frame when the stream ends without a blank line", () => {
+ const parser = new PocketBaseSseParser();
+ expect(parser.push("event: rt_metrics\ndata: {}")).toEqual([]);
+ expect(parser.finish()).toEqual([{ event: "rt_metrics", id: undefined, data: "{}" }]);
+ });
+});
+
+describe("createRealtimeMetricsTopic", () => {
+ test("matches the PocketBase custom-topic options encoding used by Beszel", () => {
+ const topic = createRealtimeMetricsTopic("system-1");
+ expect(topic).toBe(`rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: "system-1" } }))}`);
+ });
+});
diff --git a/packages/integrations/src/mock/data/beszel.ts b/packages/integrations/src/mock/data/beszel.ts
index a83347553..f79de9d99 100644
--- a/packages/integrations/src/mock/data/beszel.ts
+++ b/packages/integrations/src/mock/data/beszel.ts
@@ -226,6 +226,7 @@ export class BeszelMockService {
u: randInt(86400, 2_000_000),
mp: clamp(sys.baseMem + rand(-5, 5), 0, 100),
dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100),
+ efs: i === 0 ? { "/mnt/sda": 86.9, "/mnt/sdb": 70.5, "/mnt/sdc": 42.7 } : undefined,
bb: rand(200, 3000) * KB,
v: "0.8.2",
g: i === 3 ? rand(15, 45) : 0,
diff --git a/packages/request-handler/src/beszel.ts b/packages/request-handler/src/beszel.ts
index a6619e6ef..dab263e3a 100644
--- a/packages/request-handler/src/beszel.ts
+++ b/packages/request-handler/src/beszel.ts
@@ -34,6 +34,7 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
cpu: info.cpu,
memory: info.mp,
disk: info.dp,
+ extraFilesystems: info.efs ?? {},
gpu: info.g ?? 0,
loadAvg: info.la ?? null,
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index c314dd1ef..7a2c27b24 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -4031,9 +4031,6 @@
},
"showDockerNetwork": {
"label": "Show Docker network chart"
- },
- "showStorage": {
- "label": "Show storage chart"
}
},
"selectSystem": "Select system",
@@ -4060,8 +4057,8 @@
},
"disk": {
"title": "Disk Usage",
- "subtitle": "Usage of root partition",
- "series": "Used"
+ "subtitle": "Usage by filesystem",
+ "series": "Root"
},
"diskIO": {
"title": "Disk I/O",
diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx
index 5d4671f5f..51e5d014c 100644
--- a/packages/widgets/src/beszel-system-grid/component.tsx
+++ b/packages/widgets/src/beszel-system-grid/component.tsx
@@ -27,10 +27,18 @@ import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
-import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
+import {
+ formatByteRate,
+ formatLoadAvg,
+ formatPercent,
+ formatTemp,
+ formatUptime,
+ getProgressTrackSize,
+} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
+import { DiskUsage } from "../beszel/_shared/disk-usage";
interface SizeConfig {
iconSize: number;
@@ -106,22 +114,14 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
return Math.max(1, Math.floor(available / size.rowHeight));
};
-const MIN_CELL_WIDTH = 120;
+const MIN_CELL_WIDTH = 180;
const MIN_CELL_HEIGHT = 80;
-const getColCount = (width: number, itemCount: number): number => {
+const getColCount = (width: number, height: number, itemCount: number): number => {
if (itemCount <= 1) return 1;
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
-
- let best = 1;
- for (let c = 1; c <= maxCols; c++) {
- const emptyCells = (c - (itemCount % c)) % c;
- const bestEmpty = (best - (itemCount % best)) % best;
- if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
- best = c;
- }
- }
- return best;
+ const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
+ return Math.min(maxCols, Math.max(1, Math.ceil(Math.sqrt(itemCount * aspectRatio))));
};
interface MetricRowProps {
@@ -133,20 +133,25 @@ interface MetricRowProps {
}
const MetricValue = ({ value, progress, size }: Pick) => (
-
-
+
+
{value}
{progress && (
-
+
)}
);
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
-
+
{icon}
-
+
{label}
@@ -195,14 +200,13 @@ const metricRenderers = [
{
key: "showDisk",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
- }
- label={t("metric.disk")}
- value={formatPercent(s.disk)}
- progress={{ value: s.disk, color: thresholdColor(s.disk) }}
- size={sz}
- />
+
+
+
+ {t("metric.disk")}
+
+
+
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
},
@@ -399,7 +403,7 @@ export default function BeszelSystemGridWidget({
);
}
- const cols = getColCount(width, filteredSystems.length);
+ const cols = getColCount(width, height, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
diff --git a/packages/widgets/src/beszel-system-grid/index.ts b/packages/widgets/src/beszel-system-grid/index.ts
index 9551adfc6..69dc7e2ba 100644
--- a/packages/widgets/src/beszel-system-grid/index.ts
+++ b/packages/widgets/src/beszel-system-grid/index.ts
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
+ queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx
index 21b21981f..91dcd7292 100644
--- a/packages/widgets/src/beszel-system-stats/component.tsx
+++ b/packages/widgets/src/beszel-system-stats/component.tsx
@@ -1,54 +1,21 @@
"use client";
import { useCallback, useMemo } from "react";
-import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
-import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react";
+import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
+import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
-import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
-import { containerColors } from "../beszel/_shared/colors";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
-import {
- BeszelChartPanel,
- CPU_Y_AXIS_DOMAIN,
- useContainerNames,
- useDockerChartData,
- useEfsChartData,
- useSystemChartData,
-} from "../beszel/_shared/chart";
-import {
- chartAxisFormatters,
- formatByteRate,
- formatGB,
- formatPercent,
- formatStorageBytes,
-} from "../beszel/_shared/format";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
-import { makeTooltipProps } from "../beszel/_shared/tooltip";
-import { useLiveStats } from "../beszel/_shared/use-live-stats";
-
-const tooltipPercent = makeTooltipProps(formatPercent);
-const tooltipGB = makeTooltipProps(formatGB, true);
-const tooltipRate = makeTooltipProps(formatByteRate, true);
-const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
-const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
-
-const CHART_HEIGHT = 180;
-const MB = 1024 * 1024;
-const gridColumns = [1, 2] as const;
-
-function statsWhenShown(show: boolean | undefined, data: T | undefined): T | undefined {
- if (!show || !data) return undefined;
- return data;
-}
+import { BeszelStatsView } from "../beszel/_shared/stats-view";
export default function BeszelSystemStatsWidget({
options,
@@ -71,14 +38,12 @@ export default function BeszelSystemStatsWidget({
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useMemo(
- () => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
+ () => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
[systemsResult],
);
-
const selectedSystem = options.systemId || systems[0]?.value || "";
- const selectedLabel = systems.find((s) => s.value === selectedSystem)?.label;
- const systemExists = systems.some((s) => s.value === selectedSystem);
- const systemReady = !systemsPending && (selectedSystem === "" || systemExists);
+ const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
+ const systemExists = systems.some((system) => system.value === selectedSystem);
const handleSelectSystem = useCallback(
(value: string) => {
@@ -90,139 +55,13 @@ export default function BeszelSystemStatsWidget({
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
);
- const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
- const isLive = options.timePeriod === "1m";
-
- const { data: statsResult, error: statsError } = clientApi.widget.beszel.getSystemStats.useQuery(
- {
- integrationIds,
- systemId: selectedSystem,
- timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
- includeDocker: showDocker,
- },
- {
- refetchInterval: isLive ? false : 5_000,
- enabled: !isLive && systemReady && selectedSystem !== "",
- },
- );
-
- const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
-
- let activeStats:
- | { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
- | null
- | undefined = statsResult;
- if (isLive) {
- activeStats = liveData;
- }
-
- const mappers = useMemo(
- () => ({
- cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
- memory: (s: { mu: number; mb: number }) => ({
- [t("chart.memory.series")]: s.mu,
- [t("chart.memory.cache")]: s.mb ?? 0,
- }),
- disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
- diskIO: (s: { dr?: number; dw?: number }) => ({
- [t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
- [t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
- }),
- network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
- [t("chart.network.sent")]: s.b?.[0] ?? s.ns,
- [t("chart.network.recv")]: s.b?.[1] ?? s.nr,
- }),
- }),
- [t],
- );
-
- const series = useMemo(
- () => ({
- cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
- memory: [
- { name: t("chart.memory.series"), color: "teal.5" },
- { name: t("chart.memory.cache"), color: "teal.8" },
- ],
- disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
- diskIO: [
- { name: t("chart.diskIO.write"), color: "orange.6" },
- { name: t("chart.diskIO.read"), color: "blue.6" },
- ],
- network: [
- { name: t("chart.network.sent"), color: "blue.6" },
- { name: t("chart.network.recv"), color: "teal.6" },
- ],
- }),
- [t],
- );
-
- const systemStats = activeStats?.systemStats;
- const containerStatsRaw = activeStats?.containerStats;
-
- const timePeriod = options.timePeriod as BeszelTimePeriod;
- const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
- const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
- const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
- const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, timePeriod);
- const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, timePeriod);
-
- const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
- const dockerCpuData = useDockerChartData(
- statsWhenShown(options.showDockerCpu, containerStatsRaw),
- containerNames,
- "cpu",
- timePeriod,
- );
- const dockerMemData = useDockerChartData(
- statsWhenShown(options.showDockerMemory, containerStatsRaw),
- containerNames,
- "memory",
- timePeriod,
- );
- const dockerNetData = useDockerChartData(
- statsWhenShown(options.showDockerNetwork, containerStatsRaw),
- containerNames,
- "network",
- timePeriod,
- );
-
- const efsPaths = useMemo(() => {
- if (!systemStats) return [];
- const paths = new Set();
- for (const record of systemStats) {
- if (record.stats.efs) {
- for (const path of Object.keys(record.stats.efs)) {
- paths.add(path);
- }
- }
- }
- return [...paths];
- }, [systemStats]);
-
- const storageData = useEfsChartData(statsWhenShown(options.showStorage, systemStats), efsPaths, timePeriod);
-
- const containerSeries = useMemo(
- () => containerNames.map((name, i) => ({ name, color: containerColors[i % containerColors.length] as string })),
- [containerNames],
- );
-
- const storageSeries = useMemo(
- () => efsPaths.map((path, i) => ({ name: path, color: containerColors[i % containerColors.length] as string })),
- [efsPaths],
- );
-
- const cols = gridColumns[Number(width > 600)];
-
if (systemsError) throw systemsError;
- if (!isLive && statsError) throw statsError;
-
- if (systemsPending) {
+ if (systemsPending)
return (
);
- }
if (systems.length === 0) {
return (
@@ -240,7 +79,7 @@ export default function BeszelSystemStatsWidget({
);
}
- if (!systemsPending && !systemExists && systems.length > 0) {
+ if (!systemExists) {
return (
@@ -260,22 +99,6 @@ export default function BeszelSystemStatsWidget({
);
}
- if (isLive && liveError && !liveData) {
- return (
-
-
-
-
- {t("error.liveConnectionFailed")}
-
-
-
-
- );
- }
-
return (
@@ -284,7 +107,7 @@ export default function BeszelSystemStatsWidget({
{!isEditMode && systems.length > 1 && (
@@ -300,176 +123,37 @@ export default function BeszelSystemStatsWidget({
- {systems.map((s) => (
+ {systems.map((system) => (
handleSelectSystem(s.value)}
+ fw={system.value === selectedSystem ? 600 : 400}
+ c={system.value !== selectedSystem ? "dimmed" : undefined}
+ onClick={() => handleSelectSystem(system.value)}
>
- {s.label}
+ {system.label}
))}
)}
-
- {!isLive && !activeStats && systemReady && selectedSystem !== "" && (
-
-
-
- )}
-
- {!isLive && activeStats && "error" in activeStats && activeStats.error && (
-
-
-
-
- {activeStats.error}
-
-
-
- )}
-
- {activeStats && (!("error" in activeStats) || !activeStats.error) && (
-
- {options.showCpu && cpuData.length > 0 && (
-
- )}
-
- {options.showMemory && memoryData.length > 0 && (
-
- )}
-
- {options.showDisk && diskData.length > 0 && (
-
- )}
-
- {options.showDiskIO && diskIOData.length > 0 && (
-
- )}
-
- {options.showNetwork && networkData.length > 0 && (
-
- )}
-
- {options.showStorage && storageData.length > 0 && storageSeries.length > 0 && (
-
- )}
-
- {showDocker && containerSeries.length > 0 && (
- <>
- {options.showDockerCpu && dockerCpuData.length > 0 && (
-
- )}
-
- {options.showDockerMemory && dockerMemData.length > 0 && (
-
- )}
-
- {options.showDockerNetwork && dockerNetData.length > 0 && (
-
- )}
- >
- )}
-
- )}
+ 600 ? 2 : 1}
+ visibility={{
+ cpu: options.showCpu,
+ memory: options.showMemory,
+ disk: options.showDisk,
+ diskIO: options.showDiskIO,
+ network: options.showNetwork,
+ dockerCpu: options.showDockerCpu,
+ dockerMemory: options.showDockerMemory,
+ dockerNetwork: options.showDockerNetwork,
+ }}
+ onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
+ />
diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts
index 3e48a1e4f..af2907b25 100644
--- a/packages/widgets/src/beszel-system-stats/index.ts
+++ b/packages/widgets/src/beszel-system-stats/index.ts
@@ -16,6 +16,7 @@ const timePeriodOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
+ queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -45,7 +46,6 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
showDockerCpu: factory.switch({ defaultValue: true }),
showDockerMemory: factory.switch({ defaultValue: true }),
showDockerNetwork: factory.switch({ defaultValue: true }),
- showStorage: factory.switch({ defaultValue: true }),
}));
},
errors: {
diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx
index 49b3dca74..5352eb170 100644
--- a/packages/widgets/src/beszel-system-table/component.tsx
+++ b/packages/widgets/src/beszel-system-table/component.tsx
@@ -26,10 +26,18 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
-import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
+import {
+ formatByteRate,
+ formatLoadAvg,
+ formatPercent,
+ formatTemp,
+ formatUptime,
+ getProgressTrackSize,
+} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
+import { DiskUsage } from "../beszel/_shared/disk-usage";
const directionMultiplier: Record = { asc: 1, desc: -1 };
@@ -97,11 +105,16 @@ export default function BeszelSystemTableWidget({
}, [filteredSystems, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
-
-
+
+
{formatPercent(value)}
-
+
);
@@ -156,7 +169,15 @@ export default function BeszelSystemTableWidget({
),
sortable: true,
- render: (record) => ,
+ render: (record) => (
+
+ ),
},
options.showGpu && {
accessor: "gpu",
diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts
index 538649afd..ba90cb5e9 100644
--- a/packages/widgets/src/beszel-system-table/index.ts
+++ b/packages/widgets/src/beszel-system-table/index.ts
@@ -25,6 +25,7 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
+ queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
diff --git a/packages/widgets/src/beszel/_shared/chart.spec.ts b/packages/widgets/src/beszel/_shared/chart.spec.ts
new file mode 100644
index 000000000..5a86bf2bb
--- /dev/null
+++ b/packages/widgets/src/beszel/_shared/chart.spec.ts
@@ -0,0 +1,87 @@
+import { describe, expect, test } from "vitest";
+
+import type { BeszelSystemStatsRecord } from "@homarr/integrations/types";
+
+import { buildDiskChartData, padLiveTimeGrid } from "./chart";
+
+const record = (
+ created: string,
+ du: number,
+ efs: BeszelSystemStatsRecord["stats"]["efs"],
+): BeszelSystemStatsRecord => ({
+ id: created,
+ system: "system-1",
+ type: "1m",
+ created,
+ updated: created,
+ stats: {
+ cpu: 0,
+ m: 0,
+ mu: 0,
+ mp: 0,
+ mb: 0,
+ s: 0,
+ su: 0,
+ d: 938.8,
+ du,
+ dp: 51.15,
+ efs,
+ },
+});
+
+describe("buildDiskChartData", () => {
+ test("keeps root and extra filesystem usage in GiB without stacking or byte conversion", () => {
+ const data = buildDiskChartData(
+ [
+ record("2026-07-11T13:46:00.000Z", 455.81, {
+ sda: { d: 1906.8, du: 1656.45, r: 0, w: 0 },
+ sdb: { d: 5587.04, du: 3936.86, r: 0, w: 0 },
+ sdc: { d: 3724.18, du: 1589.84, r: 0, w: 0 },
+ }),
+ ],
+ ["sda", "sdb", "sdc"],
+ "Root",
+ "1h",
+ );
+
+ expect(data).toHaveLength(1);
+ expect(data[0]).toMatchObject({ Root: 455.81, sda: 1656.45, sdb: 3936.86, sdc: 1589.84 });
+ });
+
+ test("uses zero for a filesystem missing from an individual sample and orders historical records oldest first", () => {
+ const data = buildDiskChartData(
+ [
+ record("2026-07-11T13:47:00.000Z", 2, { sda: { d: 10, du: 4, r: 0, w: 0 } }),
+ record("2026-07-11T13:46:00.000Z", 1, undefined),
+ ],
+ ["sda"],
+ "Root",
+ "1h",
+ );
+
+ expect(data.map((point) => ({ Root: point.Root, sda: point.sda }))).toEqual([
+ { Root: 1, sda: 0 },
+ { Root: 2, sda: 4 },
+ ]);
+ });
+});
+
+describe("padLiveTimeGrid", () => {
+ test("keeps a fixed 60-second window with the newest sample at the right edge", () => {
+ const data = padLiveTimeGrid([{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.217Z", CPU: 18.07 }]);
+
+ expect(data).toHaveLength(60);
+ expect(data[0]).not.toHaveProperty("CPU");
+ expect(data[58]).not.toHaveProperty("CPU");
+ expect(data[59]).toMatchObject({ CPU: 18.07 });
+ });
+
+ test("leaves null gaps for missing seconds instead of stretching existing points", () => {
+ const data = padLiveTimeGrid([
+ { time: "13:51:28", rawTime: "2026-07-11T13:51:28.000Z", CPU: 10 },
+ { time: "13:51:30", rawTime: "2026-07-11T13:51:30.000Z", CPU: 20 },
+ ]);
+
+ expect(data.slice(-3).map((point) => point.CPU)).toEqual([10, undefined, 20]);
+ });
+});
diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx
index 0866dc9d8..7ba5b66fe 100644
--- a/packages/widgets/src/beszel/_shared/chart.tsx
+++ b/packages/widgets/src/beszel/_shared/chart.tsx
@@ -30,6 +30,7 @@ function prepareRecords(records: T[], timePeriod: BeszelTimePeriod) {
}
function padTimeGrid(data: Record[], timePeriod: BeszelTimePeriod) {
+ if (timePeriod === "1m") return padLiveTimeGrid(data);
const days = periodDays[timePeriod];
if (!days) return data;
@@ -50,6 +51,38 @@ function padTimeGrid(data: Record[], timePeriod: BeszelTimePeri
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
}
+export function padLiveTimeGrid(data: Record[], pointCount = 60) {
+ if (data.length === 0) return data;
+
+ const end = dayjs(
+ data.reduce((latest, point) => {
+ const timestamp = new Date(point.rawTime as string).getTime();
+ return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
+ }, 0),
+ ).startOf("second");
+ const pointsBySecond = new Map(
+ data.map(
+ (point) =>
+ [
+ dayjs(point.rawTime as string)
+ .startOf("second")
+ .valueOf(),
+ point,
+ ] as const,
+ ),
+ );
+
+ return Array.from({ length: pointCount }, (_, index) => {
+ const timestamp = end.subtract(pointCount - index - 1, "second");
+ return (
+ pointsBySecond.get(timestamp.valueOf()) ?? {
+ time: timestamp.format(timeFormats["1m"]),
+ rawTime: timestamp.toISOString(),
+ }
+ );
+ });
+}
+
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
const chartStyle = { minWidth: 0, minHeight: 1 } as const;
const panelStyle = { minWidth: 0, overflow: "hidden" } as const;
@@ -174,24 +207,39 @@ const defaultContainerExtractors: Record = {
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
};
-export const useEfsChartData = (
+export const useDiskChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
efsPaths: string[],
+ rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
) =>
- useMemo(() => {
- if (!systemStats?.length) return [];
- const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
- const mapped = ordered.map((record) => {
- const point: Record = { time: fmt(record.created), rawTime: record.created };
- const efs = record.stats.efs ?? {};
- for (const path of efsPaths) {
- point[path] = efs[path]?.du ?? 0;
- }
- return point;
- });
- return padTimeGrid(mapped, timePeriod);
- }, [systemStats, efsPaths, timePeriod]);
+ useMemo(
+ () => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
+ [systemStats, efsPaths, rootSeriesName, timePeriod],
+ );
+
+export const buildDiskChartData = (
+ systemStats: BeszelSystemStatsRecord[] | undefined,
+ efsPaths: string[],
+ rootSeriesName: string,
+ timePeriod: BeszelTimePeriod = "1h",
+) => {
+ if (!systemStats?.length) return [];
+ const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
+ const mapped = ordered.map((record) => {
+ const point: Record = {
+ time: fmt(record.created),
+ rawTime: record.created,
+ [rootSeriesName]: record.stats.du,
+ };
+ const efs = record.stats.efs ?? {};
+ for (const path of efsPaths) {
+ point[path] = efs[path]?.du ?? 0;
+ }
+ return point;
+ });
+ return padTimeGrid(mapped, timePeriod);
+};
export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined,
diff --git a/packages/widgets/src/beszel/_shared/disk-usage.tsx b/packages/widgets/src/beszel/_shared/disk-usage.tsx
new file mode 100644
index 000000000..9aa2a14ad
--- /dev/null
+++ b/packages/widgets/src/beszel/_shared/disk-usage.tsx
@@ -0,0 +1,105 @@
+import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
+
+import type { BeszelSystemRow } from "./types";
+import { thresholdColor } from "./colors";
+import { formatPercent, getProgressTrackSize } from "./format";
+
+interface DiskUsageProps {
+ system: BeszelSystemRow;
+ fontSize: string;
+ progressSize: "xs" | "sm";
+ valueMiw: number;
+ valueGap?: number;
+}
+
+const mountLabel = (path: string) =>
+ path === "/" ? "ROOT" : (path.split("/").filter(Boolean).at(-1) ?? path).toUpperCase();
+const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`;
+
+export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => {
+ const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/");
+ const trackSize = getProgressTrackSize(progressSize);
+ const dotSize = progressSize === "xs" ? 2 : 3;
+ const dotGap = 2;
+
+ return (
+
+
+ {formatPercent(system.disk)}
+
+
+
+ {filesystems.length > 0 ? (
+
+
+
+
+ {filesystems.map(([path, value]) => (
+
+ ))}
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+ {[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
+
+
+ {mountLabel(path)}
+
+
+
+ {formatPercent(value)}
+
+
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/packages/widgets/src/beszel/_shared/format.spec.ts b/packages/widgets/src/beszel/_shared/format.spec.ts
new file mode 100644
index 000000000..ec4545063
--- /dev/null
+++ b/packages/widgets/src/beszel/_shared/format.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, test } from "vitest";
+
+import { chartAxisFormatters, formatGB, getProgressTrackSize } from "./format";
+
+describe("Beszel storage formatting", () => {
+ test("keeps smaller values in GB", () => {
+ expect(formatGB(455.81)).toBe("455.81 GB");
+ expect(chartAxisFormatters.gb(455.81)).toBe("456G");
+ });
+
+ test("promotes large GiB values to TB", () => {
+ expect(formatGB(3323)).toBe("3.25 TB");
+ expect(formatGB(3936.86)).toBe("3.84 TB");
+ expect(chartAxisFormatters.gb(3323)).toBe("3.2T");
+ });
+
+ test("maps progress sizes consistently", () => {
+ expect(getProgressTrackSize("xs")).toBe(6);
+ expect(getProgressTrackSize("sm")).toBe(9);
+ });
+});
diff --git a/packages/widgets/src/beszel/_shared/format.ts b/packages/widgets/src/beszel/_shared/format.ts
index 8e84c74e4..dc483daa6 100644
--- a/packages/widgets/src/beszel/_shared/format.ts
+++ b/packages/widgets/src/beszel/_shared/format.ts
@@ -29,14 +29,22 @@ export const formatStorageBytes = (bytes: number): string => formatScaled(bytes,
export const formatGB = (value: number): string => {
if (!value || !Number.isFinite(value)) return "0 GB";
+ if (Math.abs(value) >= 1024) return `${(value / 1024).toFixed(2)} TB`;
return `${value.toFixed(2)} GB`;
};
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
+export const getProgressTrackSize = (size: "xs" | "sm"): number => (size === "xs" ? 6 : 9);
+
export const chartAxisFormatters = {
percent: formatPercent,
- gb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}G`),
+ gb: (value: number) => {
+ const numericValue = Number(value);
+ if (!numericValue) return "0";
+ if (Math.abs(numericValue) >= 1024) return `${(numericValue / 1024).toFixed(1)}T`;
+ return `${numericValue.toFixed(0)}G`;
+ },
mb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}M`),
bytes: (value: number) => formatScaledCompact(Number(value), byteUnits, "0"),
rate: (value: number) => formatScaledCompact(Number(value), rateUnits, "0"),
diff --git a/packages/widgets/src/beszel/_shared/stats-view.tsx b/packages/widgets/src/beszel/_shared/stats-view.tsx
new file mode 100644
index 000000000..48706d4ad
--- /dev/null
+++ b/packages/widgets/src/beszel/_shared/stats-view.tsx
@@ -0,0 +1,339 @@
+"use client";
+
+import { useMemo } from "react";
+import { Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
+import { IconPlugConnectedX, IconServerOff } 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 { containerColors } from "./colors";
+import type { BeszelTimePeriod } from "./chart";
+import {
+ BeszelChartPanel,
+ CPU_Y_AXIS_DOMAIN,
+ useContainerNames,
+ useDiskChartData,
+ useDockerChartData,
+ useSystemChartData,
+} from "./chart";
+import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
+import { makeTooltipProps } from "./tooltip";
+import { useLiveStats } from "./use-live-stats";
+
+const CHART_HEIGHT = 180;
+const MB = 1024 * 1024;
+const tooltipPercent = makeTooltipProps(formatPercent);
+const tooltipGB = makeTooltipProps(formatGB, true);
+const tooltipRate = makeTooltipProps(formatByteRate, true);
+const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
+const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
+
+const ChartSkeleton = () => (
+
+
+
+
+
+
+
+);
+
+export interface BeszelStatsVisibility {
+ cpu: boolean;
+ memory: boolean;
+ disk: boolean;
+ diskIO: boolean;
+ network: boolean;
+ dockerCpu: boolean;
+ dockerMemory: boolean;
+ dockerNetwork: boolean;
+}
+
+interface BeszelStatsViewProps {
+ integrationIds: string[];
+ systemId: string;
+ timePeriod: BeszelTimePeriod;
+ visibility: BeszelStatsVisibility;
+ columns: 1 | 2;
+ onSwitchToHistorical?: () => void;
+}
+
+const whenVisible = (visible: boolean, data: T | undefined) => (visible ? data : undefined);
+
+export function BeszelStatsView({
+ integrationIds,
+ systemId,
+ timePeriod,
+ visibility,
+ columns,
+ onSwitchToHistorical,
+}: BeszelStatsViewProps) {
+ const t = useScopedI18n("widget.beszelSystemStats");
+ const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
+ const isLive = timePeriod === "1m";
+
+ const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
+ { integrationIds, systemId, timePeriod, includeDocker: showDocker },
+ { refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
+ );
+ const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
+
+ const activeStats:
+ | { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
+ | null
+ | undefined = isLive ? liveData : historicalData;
+
+ const mappers = useMemo(
+ () => ({
+ cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
+ memory: (s: { mu: number; mb: number }) => ({
+ [t("chart.memory.series")]: s.mu,
+ [t("chart.memory.cache")]: s.mb ?? 0,
+ }),
+ diskIO: (s: { dr?: number; dw?: number }) => ({
+ [t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
+ [t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
+ }),
+ network: (s: { ns?: number; nr?: number; b?: [number, number] }) => ({
+ [t("chart.network.sent")]: s.b?.[0] ?? s.ns ?? 0,
+ [t("chart.network.recv")]: s.b?.[1] ?? s.nr ?? 0,
+ }),
+ }),
+ [t],
+ );
+
+ const series = useMemo(
+ () => ({
+ cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
+ memory: [
+ { name: t("chart.memory.series"), color: "teal.5" },
+ { name: t("chart.memory.cache"), color: "teal.8" },
+ ],
+ diskIO: [
+ { name: t("chart.diskIO.write"), color: "orange.6" },
+ { name: t("chart.diskIO.read"), color: "blue.6" },
+ ],
+ network: [
+ { name: t("chart.network.sent"), color: "blue.6" },
+ { name: t("chart.network.recv"), color: "teal.6" },
+ ],
+ }),
+ [t],
+ );
+
+ const systemStats = activeStats?.systemStats;
+ const containerStats = activeStats?.containerStats;
+ const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod);
+ const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod);
+ const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod);
+ const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod);
+
+ const efsPaths = useMemo(() => {
+ const paths = new Set();
+ for (const record of systemStats ?? []) {
+ for (const path of Object.keys(record.stats.efs ?? {})) paths.add(path);
+ }
+ return [...paths];
+ }, [systemStats]);
+ const rootSeriesName = t("chart.disk.series");
+ const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
+ const diskSeries = useMemo(
+ () => [
+ { name: rootSeriesName, color: "grape.6" },
+ ...efsPaths.map((path, index) => ({
+ name: path,
+ color: containerColors[(index + 1) % containerColors.length] as string,
+ })),
+ ],
+ [efsPaths, rootSeriesName],
+ );
+
+ const containerNames = useContainerNames(whenVisible(showDocker, containerStats));
+ const containerSeries = useMemo(
+ () =>
+ containerNames.map((name, index) => ({ name, color: containerColors[index % containerColors.length] as string })),
+ [containerNames],
+ );
+ const dockerCpuData = useDockerChartData(
+ whenVisible(visibility.dockerCpu, containerStats),
+ containerNames,
+ "cpu",
+ timePeriod,
+ );
+ const dockerMemoryData = useDockerChartData(
+ whenVisible(visibility.dockerMemory, containerStats),
+ containerNames,
+ "memory",
+ timePeriod,
+ );
+ const dockerNetworkData = useDockerChartData(
+ whenVisible(visibility.dockerNetwork, containerStats),
+ containerNames,
+ "network",
+ timePeriod,
+ );
+
+ if (historicalError) throw historicalError;
+
+ if (isLive && liveError) {
+ return (
+
+
+
+
+ {t("error.liveConnectionFailed")}
+
+ {onSwitchToHistorical && (
+
+ )}
+
+
+ );
+ }
+
+ if (!activeStats) {
+ const visibleChartCount = Object.values(visibility).filter(Boolean).length;
+ return (
+
+ {Array.from({ length: visibleChartCount }, (_, index) => (
+
+ ))}
+
+ );
+ }
+
+ if ("error" in activeStats && activeStats.error) {
+ return (
+
+
+
+
+ {activeStats.error}
+
+
+
+ );
+ }
+
+ return (
+
+ {visibility.cpu && cpuData.length > 0 && (
+
+ )}
+ {visibility.memory && memoryData.length > 0 && (
+
+ )}
+ {visibility.disk && diskData.length > 0 && (
+
+ )}
+ {visibility.diskIO && diskIOData.length > 0 && (
+
+ )}
+ {visibility.network && networkData.length > 0 && (
+
+ )}
+ {showDocker && containerSeries.length > 0 && (
+ <>
+ {visibility.dockerCpu && dockerCpuData.length > 0 && (
+
+ )}
+ {visibility.dockerMemory && dockerMemoryData.length > 0 && (
+
+ )}
+ {visibility.dockerNetwork && dockerNetworkData.length > 0 && (
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/packages/widgets/src/beszel/_shared/system-stats-modal.tsx b/packages/widgets/src/beszel/_shared/system-stats-modal.tsx
index 1071ccc53..fda3146d2 100644
--- a/packages/widgets/src/beszel/_shared/system-stats-modal.tsx
+++ b/packages/widgets/src/beszel/_shared/system-stats-modal.tsx
@@ -1,25 +1,15 @@
"use client";
-import { useMemo, useState } from "react";
-import { SegmentedControl, SimpleGrid, Stack } from "@mantine/core";
+import { useState } from "react";
+import { SegmentedControl, Stack } from "@mantine/core";
-import { clientApi } from "@homarr/api/client";
import { createModal } from "@homarr/modals";
-import { useScopedI18n } from "@homarr/translation/client";
import type { BeszelTimePeriod } from "./chart";
-import { BeszelChartPanel, CPU_Y_AXIS_DOMAIN, useSystemChartData } from "./chart";
-import { chartAxisFormatters, formatByteRate, formatGB, formatPercent } from "./format";
-import { makeTooltipProps } from "./tooltip";
-
-const tooltipPercent = makeTooltipProps(formatPercent);
-const tooltipGB = makeTooltipProps(formatGB, true);
-const tooltipRate = makeTooltipProps(formatByteRate, true);
-
-const CHART_HEIGHT = 180;
-const MB = 1024 * 1024;
+import { BeszelStatsView } from "./stats-view";
const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
+ { value: "1m", label: "Live" },
{ value: "1h", label: "1H" },
{ value: "12h", label: "12H" },
{ value: "24h", label: "24H" },
@@ -27,141 +17,41 @@ const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "30d", label: "30D" },
];
+const allCharts = {
+ cpu: true,
+ memory: true,
+ disk: true,
+ diskIO: true,
+ network: true,
+ dockerCpu: true,
+ dockerMemory: true,
+ dockerNetwork: true,
+} as const;
+
interface BeszelSystemStatsModalProps {
integrationId: string;
systemId: string;
}
export const BeszelSystemStatsModal = createModal(({ innerProps }) => {
- const t = useScopedI18n("widget.beszelSystemStats");
const [timePeriod, setTimePeriod] = useState("1h");
- const { data } = clientApi.widget.beszel.getSystemStats.useQuery({
- integrationIds: [innerProps.integrationId],
- systemId: innerProps.systemId,
- timePeriod,
- includeDocker: false,
- });
-
- const mappers = useMemo(
- () => ({
- cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
- memory: (s: { mu: number; mb: number }) => ({
- [t("chart.memory.series")]: s.mu,
- [t("chart.memory.cache")]: s.mb ?? 0,
- }),
- disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
- diskIO: (s: { dr?: number; dw?: number }) => ({
- [t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
- [t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
- }),
- network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
- [t("chart.network.sent")]: s.b?.[0] ?? s.ns,
- [t("chart.network.recv")]: s.b?.[1] ?? s.nr,
- }),
- }),
- [t],
- );
-
- const series = useMemo(
- () => ({
- cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
- memory: [
- { name: t("chart.memory.series"), color: "teal.5" },
- { name: t("chart.memory.cache"), color: "teal.8" },
- ],
- disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
- diskIO: [
- { name: t("chart.diskIO.write"), color: "orange.6" },
- { name: t("chart.diskIO.read"), color: "blue.6" },
- ],
- network: [
- { name: t("chart.network.sent"), color: "blue.6" },
- { name: t("chart.network.recv"), color: "teal.6" },
- ],
- }),
- [t],
- );
-
- const systemStats = data?.systemStats;
- const cpuData = useSystemChartData(systemStats, mappers.cpu, timePeriod);
- const memoryData = useSystemChartData(systemStats, mappers.memory, timePeriod);
- const diskData = useSystemChartData(systemStats, mappers.disk, timePeriod);
- const diskIOData = useSystemChartData(systemStats, mappers.diskIO, timePeriod);
- const networkData = useSystemChartData(systemStats, mappers.network, timePeriod);
-
return (
setTimePeriod(v as BeszelTimePeriod)}
+ onChange={(value) => setTimePeriod(value as BeszelTimePeriod)}
data={timePeriodOptions}
/>
-
- {cpuData.length > 0 && (
-
- )}
- {memoryData.length > 0 && (
-
- )}
- {diskData.length > 0 && (
-
- )}
- {diskIOData.length > 0 && (
-
- )}
- {networkData.length > 0 && (
-
- )}
-
+ setTimePeriod("1h")}
+ />
);
}).withOptions({
diff --git a/packages/widgets/src/beszel/_shared/use-live-stats.ts b/packages/widgets/src/beszel/_shared/use-live-stats.ts
index f8c351bf8..f2ab5df34 100644
--- a/packages/widgets/src/beszel/_shared/use-live-stats.ts
+++ b/packages/widgets/src/beszel/_shared/use-live-stats.ts
@@ -1,12 +1,14 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
+import { skipToken } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
-// 120 records at ~1 per second = 2 minutes of rolling data for charts
-const MAX_BUFFER = 120;
+// Beszel emits roughly one record per second. Keep a sliding one-minute window
+// without synthesizing points for missed updates.
+const MAX_BUFFER = 60;
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
const [systemStats, setSystemStats] = useState([]);
@@ -29,9 +31,8 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
}, []);
clientApi.widget.beszel.subscribeSystemStats.useSubscription(
- { integrationIds, systemId },
+ enabled && systemId !== "" ? { integrationIds, systemId } : skipToken,
{
- enabled: enabled && systemId !== "",
onData(event) {
setError(null);
if (event.type === "system_stats") {
@@ -48,7 +49,7 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
// Reset buffers when system changes or integration changes
const prevKeyRef = useRef("");
- const currentKey = `${integrationIds.join(",")}:${systemId}`;
+ const currentKey = `${integrationIds.join(",")}:${systemId}:${enabled}`;
useEffect(() => {
if (prevKeyRef.current !== currentKey) {
prevKeyRef.current = currentKey;
diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts
index a6e769d5d..aaf60269f 100644
--- a/packages/widgets/src/definition.ts
+++ b/packages/widgets/src/definition.ts
@@ -1,6 +1,6 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
-import type { QueryClient } from "@tanstack/react-query";
+import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -69,6 +69,7 @@ export const createWidgetDefinition =