diff --git a/apps/docs/docs/integrations/gotify/index.mdx b/apps/docs/docs/integrations/gotify/index.mdx
new file mode 100644
index 000000000..09c81265b
--- /dev/null
+++ b/apps/docs/docs/integrations/gotify/index.mdx
@@ -0,0 +1,33 @@
+---
+title: 'Gotify'
+description: "Gotify is a simple, self-hosted server for sending and receiving messages in real time."
+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..aceb6216f
--- /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, 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/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/docs/docs/management/tools/_category_.json b/apps/docs/docs/management/tools/_category_.json
deleted file mode 100644
index 04139d7f9..000000000
--- a/apps/docs/docs/management/tools/_category_.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "label": "Tools",
- "collapsed": true,
- "position": 10
-}
diff --git a/apps/docs/docs/management/tools/backup/index.mdx b/apps/docs/docs/management/tools/backup/index.mdx
deleted file mode 100644
index b4f05cf28..000000000
--- a/apps/docs/docs/management/tools/backup/index.mdx
+++ /dev/null
@@ -1,66 +0,0 @@
----
-title: SQLite Backup & Restore
-tags:
- - Management
- - Backup
- - Restore
- - SQLite
----
-
-The SQLite Backup & Restore feature allows you to export and import your entire Homarr database as a portable ZIP file. This is useful for creating backups, migrating between instances, or recovering from failures.
-
-:::info SQLite only
-This feature is only available when using the SQLite database driver (`DB_DRIVER=better-sqlite3`). MySQL and PostgreSQL users should use their respective database backup tools.
-:::
-
-## Accessing Backup & Restore
-
-Navigate to **Management → Tools → Backup** from the sidebar. This page is only visible when running with SQLite.
-
-## Exporting a Backup
-
-Click the **Export** button to download a ZIP archive containing:
-
-- **`db.sqlite`** — A consistent snapshot of your database (created via `VACUUM INTO`)
-- **`metadata.json`** — Contains:
- - `homarrVersion` — The version of Homarr that created the backup
- - `exportedAt` — Timestamp of the export
- - `dbDialect` — The database dialect (`sqlite`)
- - `encryptionKey` — Your `SECRET_ENCRYPTION_KEY` (needed to decrypt integration secrets)
-
-:::caution
-The exported ZIP contains your encryption key and database. Store it securely and do not share it publicly.
-:::
-
-A confirmation dialog will ask you to type **"I understand"** before the export proceeds.
-
-## Importing a Backup
-
-1. Click the **Import** button and upload a previously exported ZIP file
-2. Homarr analyzes the backup **entirely in your browser** using WebAssembly (sql.js) — no data is sent to the server during preview
-3. The preview shows:
- - **Entity counts** — How many boards, apps, users, integrations, etc. are in the backup
- - **Board names** — A list of all boards contained in the backup
- - **Migration status** — If the backup is from an older version, Homarr shows each pending database migration as animated timeline steps
-4. Review the preview and type **"I understand"** to confirm
-5. Homarr applies any pending migrations, re-encrypts integration secrets if the `SECRET_ENCRYPTION_KEY` differs between instances, and replaces the live database
-6. The server automatically restarts to pick up the new database
-
-:::tip
-The import process is **backward-compatible** with older backups that used a separate `encryption-key.txt` file instead of embedding the key in `metadata.json`.
-:::
-
-## Onboarding Integration
-
-When setting up a fresh Homarr instance with SQLite, the onboarding wizard includes a **"Restore from SQLite backup"** option. This lets you upload a backup ZIP to bootstrap your new instance with data from a previous installation.
-
-## Docker Restart Behavior
-
-After a successful import, only the Next.js process restarts — nginx and Redis remain running. The `run.sh` script automatically restarts Next.js in a loop, so the application comes back online within seconds. Running `docker stop` still works gracefully.
-
-## Best Practices
-
-- **Back up regularly** — Create exports before major updates or configuration changes
-- **Test restores** — Periodically verify your backups by importing them on a test instance
-- **Secure storage** — Keep backups in an encrypted or access-controlled location since they contain your encryption key
-- **Check versions** — Backups from newer versions cannot be imported into older Homarr instances; always import into the same or newer version
diff --git a/apps/docs/docs/widgets/beszel-system-stats/index.ts b/apps/docs/docs/widgets/beszel-system-stats/index.ts
index 4b7d5fe7c..2f48019e4 100644
--- a/apps/docs/docs/widgets/beszel-system-stats/index.ts
+++ b/apps/docs/docs/widgets/beszel-system-stats/index.ts
@@ -8,12 +8,19 @@ export const beszelSystemStatsWidget: WidgetDefinition = {
path: "../../widgets/beszel-system-stats",
configuration: {
items: [
+ {
+ name: "System",
+ description: "The Beszel system to display stats for. Searchable select populated from the integration.",
+ values: { type: "string" },
+ defaultValue: "First available system",
+ },
{
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/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';
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}
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..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
@@ -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,14 +41,10 @@ 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()),
- )
- : group.members;
+ const searchTerm = searchParams.search?.trim().toLowerCase();
+ 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();
@@ -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/apps/nextjs/src/app/api/mcp/[transport]/route.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.ts
index 2cfcfbb24..364fe90c5 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"** → 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.
**"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..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";
@@ -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 d3b895d88..25ede9289 100644
--- a/packages/api/src/router/widgets/beszel.ts
+++ b/packages/api/src/router/widgets/beszel.ts
@@ -1,6 +1,9 @@
+import { TRPCError } from "@trpc/server";
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,
@@ -13,12 +16,21 @@ 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(
ctx.integrations.map(async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
+ forceUpdate: false,
+ });
return {
integrationId: integration.id,
integrationName: integration.name,
@@ -34,7 +46,11 @@ 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, {});
return innerHandler.subscribe((systems) => {
@@ -52,6 +68,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({
@@ -96,14 +119,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 () => {
@@ -113,17 +144,26 @@ 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 (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(["1h", "12h", "24h", "1w", "30d"]),
+ timePeriod: z.enum(["1m", "1h", "12h", "24h", "1w", "30d"]).default("1h"),
includeDocker: z.boolean().default(true),
}),
)
.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,
@@ -132,4 +172,45 @@ export const beszelRouter = createTRPCRouter({
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(),
+ }),
+ )
+ .subscription(({ ctx, input }) => {
+ return observable<{
+ stats: BeszelSystemStatsRecord;
+ containerStats: BeszelContainerStatsRecord | null;
+ }>((emit) => {
+ const integration = ctx.integrations[0];
+ if (!integration) {
+ emit.error(new Error("No Beszel integration configured"));
+ 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) {
+ if (error instanceof Error) {
+ emit.error(error);
+ } else {
+ emit.error(new Error(String(error)));
+ }
+ }
+ }
+ })();
+
+ return () => {
+ abortController.abort();
+ };
+ });
+ }),
});
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",
diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts
index eb646c5dc..50f64668c 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,100 @@ 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, aborting SSE", { error: err });
+ reader.cancel().catch(() => {});
+ });
+ return;
+ }
+
+ if ("stats" in parsed) {
+ const now = new Date().toISOString();
+ const envelope = { id: "", system: systemId, type: "1m" as const, created: now, updated: now };
+ const stats: BeszelSystemStatsRecord = { ...envelope, stats: parsed.stats as BeszelSystemStats };
+
+ let containerStats: BeszelContainerStatsRecord | null = null;
+ const hasContainers =
+ parsed.container &&
+ Array.isArray(parsed.container) &&
+ (parsed.container as BeszelContainerStats[]).length > 0;
+ if (hasContainers) {
+ containerStats = { ...envelope, stats: parsed.container as BeszelContainerStats[] };
+ }
+ 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);
+ }
+ }
+ if (!signal.aborted) {
+ throw new Error("Beszel SSE stream ended unexpectedly");
+ }
+ } 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..c6998aa84 100644
--- a/packages/integrations/src/mock/data/beszel.ts
+++ b/packages/integrations/src/mock/data/beszel.ts
@@ -330,6 +330,40 @@ 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 });
+ };
+ if (signal.aborted) return;
+ await new Promise((resolve) => {
+ const interval = setInterval(emit, 1000);
+ signal.addEventListener(
+ "abort",
+ () => {
+ clearInterval(interval);
+ resolve();
+ },
+ { once: true },
+ );
+ });
+ }
+
public async getAlertHistoryAsync(_systemId?: string, perPage = 10): Promise {
const now = Date.now();
const names = ["CPU", "Memory", "Disk", "Temperature", "Bandwidth"];
@@ -341,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/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..a59309201 100644
--- a/packages/request-handler/src/beszel.ts
+++ b/packages/request-handler/src/beszel.ts
@@ -90,12 +90,13 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
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,7 +110,7 @@ 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 containerStats = input.includeDocker
@@ -117,6 +118,11 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
: [];
return { systemStats, containerStats };
},
- cacheDuration: dayjs.duration(30, "seconds"),
+ cacheDuration: dayjs.duration(60, "seconds"),
queryKey: "beszelStats",
+ cacheDurationForInput(input) {
+ const config = timePeriodConfig[input.timePeriod];
+ if (!config) return undefined;
+ return dayjs.duration(config.cacheSeconds, "seconds");
+ },
});
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 }),
};
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index b0a126733..e59c38dae 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -1652,6 +1652,13 @@
}
},
"widget": {
+ "integrationSelect": {
+ "configureIntegrationFirst": "Connect an integration first",
+ "loading": "Loading...",
+ "placeholder": "Select an option",
+ "noResults": "No results found",
+ "loadError": "Failed to load options"
+ },
"app": {
"name": "App",
"description": "Embeds an app into the board.",
@@ -3570,6 +3577,9 @@
}
},
"noRecords": "No systems found",
+ "error": {
+ "internalServerError": "Unable to connect to Beszel. Check your integration settings."
+ },
"column": {
"system": "System",
"cpu": "CPU",
@@ -3626,6 +3636,9 @@
"label": "Show agent version"
}
},
+ "error": {
+ "internalServerError": "Unable to connect to Beszel. Check your integration settings."
+ },
"metric": {
"cpu": "CPU",
"memory": "Memory",
@@ -3652,6 +3665,9 @@
}
},
"empty": "No alerts configured",
+ "error": {
+ "internalServerError": "Unable to connect to Beszel. Check your integration settings."
+ },
"history": "History",
"resolved": "Resolved",
"status": {
@@ -3663,6 +3679,10 @@
"name": "Beszel System Stats",
"description": "Time-series charts for CPU, memory, disk, network, and Docker container metrics",
"option": {
+ "systemId": {
+ "label": "System",
+ "description": "Select a Beszel system to display stats for"
+ },
"timePeriod": {
"label": "Time period"
},
@@ -3692,6 +3712,12 @@
}
},
"selectSystem": "Select system",
+ "error": {
+ "internalServerError": "Unable to connect to Beszel. Check your integration settings.",
+ "liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.",
+ "switchToHistorical": "Switch to historical view",
+ "systemNotFound": "Selected system was not found. Choose a different system."
+ },
"chart": {
"cpu": {
"title": "CPU Usage",
diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts
index 639a9df5e..ac9ef1a67 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 { WidgetIntegrationSelectInput } from "./widget-integration-select-input";
const mapping = {
anchorNote: WidgetAnchorNoteInput,
@@ -35,6 +36,7 @@ const mapping = {
umamiEventNames: WidgetUmamiEventNamesInput,
umamiWebsite: WidgetUmamiWebsiteInput,
customWidgetSelect: WidgetCustomWidgetSelectInput,
+ integrationSelect: WidgetIntegrationSelectInput,
} satisfies Record;
export const getInputForType = (type: TType) => {
diff --git a/packages/widgets/src/_inputs/widget-integration-select-input.tsx b/packages/widgets/src/_inputs/widget-integration-select-input.tsx
new file mode 100644
index 000000000..e91c07a7e
--- /dev/null
+++ b/packages/widgets/src/_inputs/widget-integration-select-input.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { Select, Text } from "@mantine/core";
+
+import { useScopedI18n } from "@homarr/translation/client";
+
+import type { CommonWidgetInputProps } from "./common";
+import { useWidgetInputTranslation } from "./common";
+import { useFormContext } from "./form";
+
+const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect");
+
+export const WidgetIntegrationSelectInput = ({
+ property,
+ kind,
+ options,
+}: CommonWidgetInputProps<"integrationSelect">) => {
+ const t = useIntegrationSelectTranslation();
+ const tInput = useWidgetInputTranslation(kind, property);
+ const form = useFormContext();
+
+ 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 hasCurrentInData = !currentValue || selectData.some((opt) => opt.value === currentValue);
+ const data = hasCurrentInData ? selectData : [...selectData, { value: currentValue, label: currentValue }];
+
+ if (integrationIds.length === 0) {
+ return (
+
+ {t("configureIntegrationFirst")}
+
+ );
+ }
+
+ const description = options.withDescription ? tInput("description") : undefined;
+ const placeholder = isPending ? t("loading") : t("placeholder");
+ const error = isError ? t("loadError") : undefined;
+
+ return (
+
+ );
+};
diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx
index 1048ebdae..82725db51 100644
--- a/packages/widgets/src/beszel-alerts/component.tsx
+++ b/packages/widgets/src/beszel-alerts/component.tsx
@@ -36,35 +36,35 @@ export default function BeszelAlertsWidget({
isEditMode,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
- const { data: results = [] } = clientApi.widget.beszel.getAlerts.useQuery(
- {
- integrationIds,
- includeHistory: options.showHistory,
- maxHistoryItems: options.maxHistoryItems,
- },
- { staleTime: 30 * 1000 },
+ const alertsInput = useMemo(
+ () => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
+ [integrationIds, options.showHistory, options.maxHistoryItems],
);
+ const { data: results = [] } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
+ staleTime: 15_000,
+ refetchInterval: 15_000,
+ retry: false,
+ });
const utils = clientApi.useUtils();
- clientApi.widget.beszel.subscribeAlerts.useSubscription(
- { integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
- {
- enabled: !isEditMode,
- onData(data) {
- utils.widget.beszel.getAlerts.setData(
- { 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,
- );
- },
+ clientApi.widget.beszel.subscribeAlerts.useSubscription(alertsInput, {
+ enabled: !isEditMode,
+ onData(data) {
+ utils.widget.beszel.getAlerts.setData(alertsInput, (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,
);
- },
+ });
},
- );
+ });
const systemNameMap = useMemo(() => {
const map: Record = {};
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..60d367d8f 100644
--- a/packages/widgets/src/beszel-system-grid/component.tsx
+++ b/packages/widgets/src/beszel-system-grid/component.tsx
@@ -1,6 +1,7 @@
"use client";
-import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core";
+import type { MantineSize } from "@mantine/core";
+import { Badge, Box, Card, Group, Progress, Text, Stack, useComputedColorScheme } from "@mantine/core";
import {
Activity,
Battery,
@@ -16,6 +17,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 +153,8 @@ interface SystemCardProps {
t: ReturnType>;
size: SizeConfig;
maxMetrics: number;
+ backgroundColor: string;
+ itemRadius: MantineSize;
}
const metricRenderers = [
@@ -307,16 +311,21 @@ 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 (
) {
const t = useScopedI18n("widget.beszelSystemGrid");
+ const board = useRequiredBoard();
+ const colorScheme = useComputedColorScheme("light");
+ const opacity = board.opacity / 100;
+ const bgBase: Record<"light" | "dark", string> = { dark: "57, 57, 57", light: "246, 247, 248" };
+ const backgroundColor = `rgba(${bgBase[colorScheme]}, ${opacity})`;
+
const { data: results = [] } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
- { staleTime: 30 * 1000 },
+ { staleTime: 5_000, refetchInterval: 5_000, retry: false },
);
useBeszelSystemsSubscription(integrationIds, !isEditMode);
@@ -374,7 +389,16 @@ export default function BeszelSystemGridWidget({
}}
>
{filteredSystems.map((system) => (
-
+
))}
);
diff --git a/packages/widgets/src/beszel-system-grid/index.ts b/packages/widgets/src/beszel-system-grid/index.ts
index fba380645..9551adfc6 100644
--- a/packages/widgets/src/beszel-system-grid/index.ts
+++ b/packages/widgets/src/beszel-system-grid/index.ts
@@ -1,4 +1,4 @@
-import { IconLayoutGrid } from "@tabler/icons-react";
+import { IconLayoutGrid, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { statusOptions } from "../beszel/_shared/options";
@@ -10,7 +10,10 @@ 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,
+ }),
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-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;
+}
diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx
index 2612ee81a..d7669a93c 100644
--- a/packages/widgets/src/beszel-system-stats/component.tsx
+++ b/packages/widgets/src/beszel-system-stats/component.tsx
@@ -1,16 +1,20 @@
"use client";
-import { useCallback, useMemo, useState } from "react";
-import { ScrollArea, Select, SimpleGrid, Stack } from "@mantine/core";
+import { useCallback, useMemo } from "react";
+import { ActionIcon, Button, Center, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
+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";
+
import type { WidgetComponentProps } from "../definition";
import { containerColors } from "../beszel/_shared/colors";
import {
- BeszelAreaChart,
- ChartPanel,
+ BeszelChartPanel,
+ CPU_Y_AXIS_DOMAIN,
useContainerNames,
useDockerChartData,
useSystemChartData,
@@ -23,6 +27,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);
@@ -31,15 +36,23 @@ 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;
+}
export default function BeszelSystemStatsWidget({
options,
integrationIds,
isEditMode,
width,
+ setOptions,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
- const { data: systemsResult = [] } = clientApi.widget.beszel.getSystems.useQuery(
+ const { data: systemsResult = [], isPending: systemsPending } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 30 * 1000 },
);
@@ -49,200 +62,313 @@ 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.some((s) => s.value === selectedSystem);
+ const systemReady = !systemsPending && (selectedSystem === "" || systemExists);
+
+ const handleSelectSystem = useCallback(
+ (value: string) => setOptions({ newOptions: { systemId: value } }),
+ [setOptions],
+ );
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
+ const isLive = options.timePeriod === "1m";
const { data: statsResult } = clientApi.widget.beszel.getSystemStats.useQuery(
{
integrationIds,
- systemId: selectedSystem ?? systems[0]?.value ?? "",
- timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d",
+ systemId: selectedSystem,
+ timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
},
- { staleTime: 30 * 1000 },
+ { staleTime: 30 * 1000, enabled: !isLive && systemReady && 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 { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
- const cpuMap = useCallback((s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), [t]);
- const memoryMap = useCallback(
- (s: { mu: number; mb: number }) => ({
- [t("chart.memory.series")]: s.mu,
- [t("chart.memory.cache")]: s.mb ?? 0,
- }),
- [t],
- );
- const diskMap = useCallback((s: { du: number }) => ({ [t("chart.disk.series")]: s.du }), [t]);
- const diskIOMap = useCallback(
- (s: { dr?: number; dw?: number }) => ({
- [t("chart.diskIO.read")]: (s.dr ?? 0) * (1024 * 1024),
- [t("chart.diskIO.write")]: (s.dw ?? 0) * (1024 * 1024),
- }),
- [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,
- [t("chart.network.recv")]: s.b?.[1] ?? s.nr,
+ let activeStats:
+ | { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[] }
+ | 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 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 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 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 systemStats = activeStats?.systemStats;
+ const containerStatsRaw = activeStats?.containerStats;
- const containerSeries = containerNames.map((name, i) => ({
- name,
- color: containerColors[i % containerColors.length] as string,
- }));
+ const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, isLive);
+ const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, isLive);
+ const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, isLive);
+ const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, isLive);
+ const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, isLive);
- const cols = width > 600 ? 2 : 1;
+ const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
+ const dockerCpuData = useDockerChartData(
+ statsWhenShown(options.showDockerCpu, containerStatsRaw),
+ containerNames,
+ "cpu",
+ isLive,
+ );
+ const dockerMemData = useDockerChartData(
+ statsWhenShown(options.showDockerMemory, containerStatsRaw),
+ containerNames,
+ "memory",
+ isLive,
+ );
+ const dockerNetData = useDockerChartData(
+ statsWhenShown(options.showDockerNetwork, containerStatsRaw),
+ containerNames,
+ "network",
+ isLive,
+ );
+
+ const containerSeries = useMemo(
+ () => containerNames.map((name, i) => ({ name, color: containerColors[i % containerColors.length] as string })),
+ [containerNames],
+ );
+
+ const cols = gridColumns[Number(width > 600)];
+
+ if (!systemsPending && !systemExists && systems.length > 0) {
+ return (
+
+
+
+
+ {t("error.systemNotFound")}
+
+ value && handleSelectSystem(value)}
+ />
+
+
+ );
+ }
+
+ if (isLive && liveError && !liveData) {
+ return (
+
+
+
+
+ {t("error.liveConnectionFailed")}
+
+ setOptions({ newOptions: { timePeriod: "1h" } })}>
+ {t("error.switchToHistorical")}
+
+
+
+ );
+ }
return (
-
+
-
+ {!isEditMode && systems.length > 1 && (
+
+
+
+
+
+
+
+ {systems.map((s) => (
+ handleSelectSystem(s.value)}
+ >
+ {s.label}
+
+ ))}
+
+
+ )}
- {statsResult && (
+ {activeStats && (
{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 && (
-
-
-
+
)}
{showDocker && containerSeries.length > 0 && (
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
-
-
-
+
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
-
-
-
+
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
-
-
-
+
)}
>
)}
diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts
index 1afff7465..0b1cdf127 100644
--- a/packages/widgets/src/beszel-system-stats/index.ts
+++ b/packages/widgets/src/beszel-system-stats/index.ts
@@ -1,9 +1,12 @@
-import { IconChartAreaLine } from "@tabler/icons-react";
+import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react";
+
+import { clientApi } from "@homarr/api/client";
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" },
@@ -17,7 +20,26 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
- timePeriod: factory.select({ defaultValue: "1h", options: timePeriodOptions }),
+ 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,
+ }),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
@@ -28,4 +50,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-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"));
diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx
index f7b735255..3938188f1 100644
--- a/packages/widgets/src/beszel/_shared/chart.tsx
+++ b/packages/widgets/src/beszel/_shared/chart.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useMemo } from "react";
+import { memo, useMemo, useRef } from "react";
import { Group, Stack, Text } from "@mantine/core";
import type { AreaChartProps } from "@mantine/charts";
import { AreaChart } from "@mantine/charts";
@@ -9,17 +9,28 @@ import dayjs from "dayjs";
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;
-
-interface ChartPanelProps {
- title: string;
- subtitle?: string;
- children: React.ReactNode;
+function prepareRecords(records: T[], live: boolean) {
+ if (live) {
+ return { fmt: formatTimeLive, ordered: records };
+ }
+ return { fmt: formatTime, ordered: [...records].toReversed() };
}
-export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => (
-
+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;
+export const CPU_Y_AXIS_DOMAIN: [number, string] = [0, "auto"];
+
+interface BeszelChartPanelProps {
+ title: string;
+ subtitle?: string;
+ chartProps: BeszelAreaChartProps;
+}
+
+export const BeszelChartPanel = memo(({ title, subtitle, chartProps }: BeszelChartPanelProps) => (
+
{title}
@@ -30,57 +41,93 @@ export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => (
)}
- {children}
+
-);
+));
type BeszelAreaChartProps = Omit & {
yAxisFormatter: (value: number) => string;
yAxisDomain?: [number, string];
};
-export const BeszelAreaChart = ({ yAxisFormatter, yAxisDomain, yAxisProps, style, ...props }: BeszelAreaChartProps) => (
-
+const BeszelAreaChart = memo(
+ ({
+ yAxisFormatter,
+ yAxisDomain,
+ yAxisProps: yAxisPropsOverride,
+ type = "default",
+ ...props
+ }: BeszelAreaChartProps) => {
+ const mergedYAxis = useMemo(() => {
+ const base = {
+ ...yAxisBase,
+ width: 48,
+ tickMargin: 2,
+ tickFormatter: yAxisFormatter,
+ ...yAxisPropsOverride,
+ };
+ if (yAxisDomain) {
+ return { ...base, domain: yAxisDomain };
+ }
+ return base;
+ }, [yAxisFormatter, yAxisDomain, yAxisPropsOverride]);
+
+ return (
+
+ );
+ },
);
export const useSystemChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record,
+ live = false,
) =>
useMemo(() => {
if (!systemStats) return [];
- return [...systemStats].toReversed().map((r) => ({
- time: formatTime(r.created),
+ const { fmt, ordered } = prepareRecords(systemStats, live);
+ return ordered.map((r) => ({
+ time: fmt(r.created),
...mapFn(r.stats),
}));
- }, [systemStats, mapFn]);
+ }, [systemStats, mapFn, live]);
-export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) =>
- useMemo(() => {
- if (!containerStats?.length) return [];
+export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
+ const prevRef = useRef([]);
+ return useMemo(() => {
+ 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) {
names.add(c.n);
}
}
- return [...names].slice(0, max);
+ const next = [...names].slice(0, max);
+ const prev = prevRef.current;
+ if (prev.length === next.length && prev.every((n, i) => n === next[i])) return prev;
+ prevRef.current = next;
+ return next;
}, [containerStats, max]);
+};
type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number;
@@ -97,16 +144,19 @@ 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 [];
- return [...containerStats].toReversed().map((record) => {
- const point: Record = { time: formatTime(record.created) };
+ const { fmt, ordered } = prepareRecords(containerStats, live);
+ return ordered.map((record) => {
+ const point: Record = { time: fmt(record.created) };
+ const byName = new Map(record.stats.map((c) => [c.n, c]));
for (const name of containerNames) {
- point[name] = extract(record.stats.find((c) => c.n === name));
+ point[name] = extract(byName.get(name));
}
return point;
});
- }, [containerStats, containerNames, metric]);
+ }, [containerStats, containerNames, metric, live]);
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",
];
diff --git a/packages/widgets/src/beszel/_shared/tooltip.tsx b/packages/widgets/src/beszel/_shared/tooltip.tsx
index 8f89fa4d4..a670af67d 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 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);
if (!sorted.length) return null;
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..440800d0d
--- /dev/null
+++ b/packages/widgets/src/beszel/_shared/use-live-stats.ts
@@ -0,0 +1,92 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { clientApi } from "@homarr/api/client";
+import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
+
+const MAX_BUFFER = 30;
+const FLUSH_INTERVAL_MS = 3000;
+
+interface LiveStatsData {
+ systemStats: BeszelSystemStatsRecord[];
+ containerStats: BeszelContainerStatsRecord[];
+}
+
+function appendToBuffer(
+ prev: LiveStatsData | null,
+ incoming: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null },
+): LiveStatsData {
+ const systemStats = [...(prev?.systemStats ?? []), incoming.stats].slice(-MAX_BUFFER);
+ const prevContainer = prev?.containerStats ?? [];
+ if (!incoming.containerStats) {
+ return { systemStats, containerStats: prevContainer };
+ }
+ return { systemStats, containerStats: [...prevContainer, incoming.containerStats].slice(-MAX_BUFFER) };
+}
+
+export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+ const bufferRef = useRef(null);
+ const dirtyRef = useRef(false);
+ const generationRef = useRef(0);
+
+ const integrationKey = [...integrationIds].sort().join(",");
+
+ useEffect(() => {
+ generationRef.current += 1;
+ setData(null);
+ setError(null);
+ bufferRef.current = null;
+ dirtyRef.current = false;
+ }, [systemId, enabled, integrationKey]);
+
+ const onData = useCallback(
+ (incoming: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => {
+ 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);
+ }
+ });
+ },
+ [],
+ );
+
+ const onError = useCallback((err: unknown) => {
+ if (err instanceof Error) {
+ setError(err);
+ return;
+ }
+ setError(new Error(String(err)));
+ }, []);
+
+ const subscriptionInput = useMemo(() => ({ integrationIds, systemId }), [integrationKey, systemId]);
+ const subscriptionEnabled = enabled && systemId !== "";
+
+ 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, integrationKey, systemId]);
+
+ clientApi.widget.beszel.subscribeSystemStats.useSubscription(subscriptionInput, {
+ enabled: subscriptionEnabled,
+ onData,
+ onError,
+ });
+
+ return { data, error };
+};
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 resolvedDefault = defaultTab && panelValues.has(defaultTab) && defaultTab;
+ const initialTab = resolvedDefault || firstPanel?.props.value || null;
+ const [activeTab, setActiveTab] = useState(initialTab);
return (
-
+
{panels.map((panel) => (
diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts
index 9e56eacec..1d19f628a 100644
--- a/packages/widgets/src/options.ts
+++ b/packages/widgets/src/options.ts
@@ -60,6 +60,16 @@ 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,6 +185,14 @@ const optionsFactory = {
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,
defaultValue: input?.defaultValue ?? "",