Merge pull request #5966 from homarr-labs/feat/beszel-fixes

fix(beszel): chart color mismatch, system selector UX, SSE infrastructure, and widget fixes
This commit is contained in:
Thomas authored and GitHub committed 2026-06-17 11:39:23 +02:00
commit 52c624addb
39 files changed
+1051 -393

No files matched your search

@@ -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 '.';
<IntegrationHeader
integration={gotifyIntegration}
categories={['Notifications', 'Messaging']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: notificationsWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: ['username', 'password'],
steps: []
}]} />
@@ -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",
};
+2
View File
@@ -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
@@ -1,5 +0,0 @@
{
"label": "Tools",
"collapsed": true,
"position": 10
}
@@ -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
@@ -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",
},
@@ -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';
<WidgetHeader
widget={notificationsWidget}
@@ -33,7 +34,8 @@ import notificationsScreenshot from './img/notifications.png';
<WidgetIntegrations
items={[
{ integration: ntfyIntegration },
{ integration: nextcloudIntegration }
{ integration: nextcloudIntegration },
{ integration: gotifyIntegration }
]}
/>
@@ -57,13 +57,13 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
{definitions.map((def) => (
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
<Table.Td>
<Group gap="xs">
<Group gap="xs" wrap="nowrap">
{def.iconUrl ? (
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
) : (
<IconApi size={16} />
)}
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"}>
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
{def.name}
</Text>
</Group>
@@ -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) {
<ReservedGroupAlert />
) : (
managementType !== "local" && (
<Alert
variant="light"
color="yellow"
icon={<IconExclamationCircle size="1rem" stroke={1.5} />}
>
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t(`group.memberNotice.${managementType}`)}
</Alert>
)
)}
<Group justify="space-between">
<SearchInput
placeholder={`${tMembers("search")}...`}
defaultValue={searchParams.search}
/>
<SearchInput placeholder={`${tMembers("search")}...`} defaultValue={searchParams.search} />
{canManageMembers && !isReserved && (
<AddGroupMember
groupId={group.id}
@@ -108,12 +85,7 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
<Table striped highlightOnHover>
<TableTbody>
{filteredMembers.map((member) => (
<Row
key={group.id}
member={member}
groupId={group.id}
disabled={isReserved}
/>
<Row key={group.id} member={member} groupId={group.id} disabled={isReserved} />
))}
</TableTbody>
</Table>
@@ -140,11 +112,7 @@ const Row = ({ member, groupId, disabled }: RowProps) => {
</Anchor>
</Group>
</TableTd>
<TableTd w={100}>
{canBeRemoved && !disabled && (
<RemoveGroupMember user={member} groupId={groupId} />
)}
</TableTd>
<TableTd w={100}>{canBeRemoved && !disabled && <RemoveGroupMember user={member} groupId={groupId} />}</TableTd>
</TableTr>
);
};
@@ -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.
+2
View File
@@ -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,
+87 -6
View File
@@ -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();
};
});
}),
});
@@ -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"
@@ -40,6 +40,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
gitHubContainerRegistry: "github-containerregistry",
quay: "quay",
ntfy: "ntfy",
gotify: "gotify",
ical: "ical",
anchor: "anchor",
truenas: "truenas",
@@ -56,5 +57,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
umami: "umami",
peaNut: "peanut",
beszel: "beszel",
archiveTeamWarrior: null,
mock: null,
};
@@ -44,6 +44,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
audioStats: "audio-stats",
umami: "umami",
ups: "ups",
archiveTeamWarrior: null,
beszelSystemTable: "beszel-system-table",
beszelSystemGrid: "beszel-system-grid",
beszelAlerts: "beszel-alerts",
@@ -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<void> {
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<string, unknown>;
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<void> {
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<TestingResult> {
const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
+35 -1
View File
@@ -330,6 +330,40 @@ export class BeszelMockService {
];
}
public async subscribeRealtimeMetrics(
systemId: string,
onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void,
signal: AbortSignal,
): Promise<void> {
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<void>((resolve) => {
const interval = setInterval(emit, 1000);
signal.addEventListener(
"abort",
() => {
clearInterval(interval);
resolve();
},
{ once: true },
);
});
}
public async getAlertHistoryAsync(_systemId?: string, perPage = 10): Promise<BeszelAlertHistory[]> {
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,
}));
}
}
@@ -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);
}
+14 -8
View File
@@ -90,12 +90,13 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
queryKey: "beszelAlerts",
});
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
"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<string, { type: string; perPage: number; cacheSeconds: number }> = {
"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");
},
});
@@ -14,10 +14,10 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
};
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
// Unique key for this request handler
queryKey: string;
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheDuration: Duration;
cacheDurationForInput?: (input: TInput) => Duration | undefined;
}
export const createCachedIntegrationRequestHandler = <
@@ -34,9 +34,9 @@ export const createCachedIntegrationRequestHandler = <
requestAsync: async (input: { options: TInput; integration: IntegrationOfKind<TKind> }) => {
return await options.requestAsync(input.integration, input.options);
},
cacheDuration: options.cacheDuration,
createRedisChannel(input, options) {
return createIntegrationOptionsChannel<TData>(input.integration.id, options.queryKey, input.options);
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
createRedisChannel(input, handlerOptions) {
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
},
}).handler({ options: itemOptions, integration }),
};
+26
View File
@@ -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",
+2
View File
@@ -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<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
@@ -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 (
<Text size="sm" c="dimmed">
{t("configureIntegrationFirst")}
</Text>
);
}
const description = options.withDescription ? tInput("description") : undefined;
const placeholder = isPending ? t("loading") : t("placeholder");
const error = isError ? t("loadError") : undefined;
return (
<Select
label={tInput("label")}
description={description}
placeholder={placeholder}
clearable={options.clearable}
searchable={options.searchable}
nothingFoundMessage={t("noResults")}
data={data}
disabled={isError}
error={error}
{...form.getInputProps(`options.${property}`)}
/>
);
};
@@ -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<string, string> = {};
+11 -2
View File
@@ -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"));
@@ -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<typeof useScopedI18n<"widget.beszelSystemGrid">>;
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 (
<Card
padding={size.cardPadding}
radius="sm"
withBorder
radius={itemRadius}
bg={backgroundColor}
h="100%"
style={{ overflow: "hidden", display: "flex", flexDirection: "column" as const }}
style={{
overflow: "hidden",
display: "flex",
flexDirection: "column" as const,
border: "0.0625rem solid var(--border-color)",
}}
>
<Group gap="xs" mb={2}>
<Badge
@@ -344,9 +353,15 @@ export default function BeszelSystemGridWidget({
height,
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
const board = useRequiredBoard();
const colorScheme = useComputedColorScheme("light");
const opacity = board.opacity / 100;
const 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) => (
<SystemCard key={system._key} system={system} options={options} t={t} size={size} maxMetrics={maxMetrics} />
<SystemCard
key={system._key}
system={system}
options={options}
t={t}
size={size}
maxMetrics={maxMetrics}
backgroundColor={backgroundColor}
itemRadius={board.itemRadius}
/>
))}
</Box>
);
@@ -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"));
@@ -0,0 +1,8 @@
.beszelStatsSystemToggle {
opacity: 0;
transition: opacity 150ms ease;
}
.beszelStatsWrapper:hover .beszelStatsSystemToggle {
opacity: 1;
}
@@ -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<T>(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<string | null>(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 (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
<IconQuestionMark size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.systemNotFound")}
</Text>
<Select
size="xs"
data={systems}
value={selectedSystem}
placeholder={t("selectSystem")}
onChange={(value) => value && handleSelectSystem(value)}
/>
</Stack>
</Center>
);
}
if (isLive && liveError && !liveData) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
<Button size="xs" variant="light" onClick={() => setOptions({ newOptions: { timePeriod: "1h" } })}>
{t("error.switchToHistorical")}
</Button>
</Stack>
</Center>
);
}
return (
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<ScrollArea
h="100%"
className={classes.beszelStatsWrapper}
style={{ pointerEvents: (isEditMode && "none") || undefined }}
>
<Stack gap="md" p="sm">
<Select
size="xs"
data={systems}
value={selectedSystem}
onChange={setSelectedSystem}
placeholder={t("selectSystem")}
/>
{!isEditMode && systems.length > 1 && (
<Menu
trigger="click-hover"
openDelay={100}
closeDelay={300}
position="bottom-start"
withArrow
shadow="md"
withinPortal
>
<Menu.Target>
<ActionIcon
variant="subtle"
size="sm"
className={classes.beszelStatsSystemToggle}
style={{ position: "absolute", top: 6, left: 6, zIndex: 1 }}
title={selectedLabel}
>
<IconServer size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{systems.map((s) => (
<Menu.Item
key={s.value}
fz="xs"
fw={(s.value === selectedSystem && 600) || 400}
c={(s.value !== selectedSystem && "dimmed") || undefined}
onClick={() => handleSelectSystem(s.value)}
>
{s.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{statsResult && (
{activeStats && (
<SimpleGrid cols={cols} spacing="md">
{options.showCpu && cpuData.length > 0 && (
<ChartPanel title={t("chart.cpu.title")} subtitle={t("chart.cpu.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={cpuData}
series={[{ name: t("chart.cpu.series"), color: "teal.6" }]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.percent}
yAxisDomain={[0, "auto"]}
tooltipProps={tooltipPercent}
/>
</ChartPanel>
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{options.showMemory && memoryData.length > 0 && (
<ChartPanel title={t("chart.memory.title")} subtitle={t("chart.memory.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={memoryData}
series={[
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
withLegend
/>
</ChartPanel>
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDisk && diskData.length > 0 && (
<ChartPanel title={t("chart.disk.title")} subtitle={t("chart.disk.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskData}
series={[{ name: t("chart.disk.series"), color: "grape.6" }]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
/>
</ChartPanel>
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDiskIO && diskIOData.length > 0 && (
<ChartPanel title={t("chart.diskIO.title")} subtitle={t("chart.diskIO.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskIOData}
series={[
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
]}
fillOpacity={0.3}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
withLegend
/>
</ChartPanel>
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{options.showNetwork && networkData.length > 0 && (
<ChartPanel title={t("chart.network.title")} subtitle={t("chart.network.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={networkData}
series={[
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
]}
fillOpacity={0.3}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
withLegend
/>
</ChartPanel>
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{showDocker && containerSeries.length > 0 && (
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
<ChartPanel title={t("chart.dockerCpu.title")} subtitle={t("chart.dockerCpu.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerCpuData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.percent}
tooltipProps={tooltipPercentTotal}
/>
</ChartPanel>
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
<ChartPanel title={t("chart.dockerMemory.title")} subtitle={t("chart.dockerMemory.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerMemData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.bytes}
tooltipProps={tooltipBytesTotal}
/>
</ChartPanel>
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
<ChartPanel title={t("chart.dockerNetwork.title")} subtitle={t("chart.dockerNetwork.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerNetData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
/>
</ChartPanel>
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</>
)}
@@ -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"));
@@ -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);
@@ -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"));
+89 -39
View File
@@ -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<T>(records: T[], live: boolean) {
if (live) {
return { fmt: formatTimeLive, ordered: records };
}
return { fmt: formatTime, ordered: [...records].toReversed() };
}
export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => (
<Stack gap={4} style={{ minWidth: 0, overflow: "hidden" }}>
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) => (
<Stack gap={4} style={panelStyle}>
<Group gap="xs">
<Text size="sm" fw={600}>
{title}
@@ -30,57 +41,93 @@ export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => (
</Text>
)}
</Group>
{children}
<BeszelAreaChart {...chartProps} />
</Stack>
);
));
type BeszelAreaChartProps = Omit<AreaChartProps, "dataKey" | "curveType" | "withDots" | "withXAxis" | "withYAxis"> & {
yAxisFormatter: (value: number) => string;
yAxisDomain?: [number, string];
};
export const BeszelAreaChart = ({ yAxisFormatter, yAxisDomain, yAxisProps, style, ...props }: BeszelAreaChartProps) => (
<AreaChart
dataKey="time"
curveType="monotone"
withDots={false}
withXAxis
withYAxis
w="100%"
style={{ minWidth: 0, minHeight: 0, ...style }}
yAxisProps={{
...yAxisBase,
...(yAxisDomain ? { domain: yAxisDomain } : {}),
tickFormatter: yAxisFormatter,
...yAxisProps,
}}
{...props}
/>
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 (
<AreaChart
dataKey="time"
curveType="monotone"
withGradient={false}
connectNulls
withDots={false}
type={type}
strokeWidth={1}
fillOpacity={0.2}
withXAxis
withYAxis
w="100%"
style={chartStyle}
yAxisProps={mergedYAxis}
{...props}
/>
);
},
);
export const useSystemChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
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<string[]>([]);
return useMemo(() => {
if (!containerStats?.length) {
if (prevRef.current.length === 0) return prevRef.current;
prevRef.current = [];
return prevRef.current;
}
const names = new Set<string>();
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<string, unknown> = { time: formatTime(record.created) };
const { fmt, ordered } = prepareRecords(containerStats, live);
return ordered.map((record) => {
const point: Record<string, unknown> = { 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]);
+10 -13
View File
@@ -21,19 +21,16 @@ export const statusColorMap: Record<BeszelSystemStatus, string> = {
};
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",
];
@@ -13,13 +13,11 @@ const styles: Record<string, CSSProperties> = {
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<string, TooltipPayloadItem>();
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;
@@ -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<LiveStatsData | null>(null);
const [error, setError] = useState<Error | null>(null);
const bufferRef = useRef<LiveStatsData | null>(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 };
};
@@ -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<string, unknow
const template = String(data.template ?? "");
const apiData = data.data;
const [parseErrors, setParseErrors] = useState<string[]>([]);
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<string, unknow
<JsxParser
jsx={template}
components={WHITELISTED_COMPONENTS as never}
bindings={SAFE_BINDINGS(apiData)}
bindings={bindings}
disableKeyGeneration
componentsOnly
allowUnknownElements={false}
blacklistedAttrs={[/^on.+/i, /^dangerously/i]}
@@ -69,9 +69,13 @@ function isTabPanel(child: ReactNode): child is React.ReactElement<TabPanelProps
export function TabsContainer({ children, defaultTab }: TabsContainerProps) {
const panels = Children.toArray(children).filter(isTabPanel);
const firstPanel = panels[0];
const panelValues = new Set(panels.map((p) => p.props.value));
const resolvedDefault = defaultTab && panelValues.has(defaultTab) && defaultTab;
const initialTab = resolvedDefault || firstPanel?.props.value || null;
const [activeTab, setActiveTab] = useState<string | null>(initialTab);
return (
<Tabs defaultValue={defaultTab ?? firstPanel?.props.value}>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
{panels.map((panel) => (
<Tabs.Tab key={panel.props.value} value={panel.props.value}>
+18
View File
@@ -60,6 +60,16 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
};
}
interface IntegrationSelectInput extends CommonInput<string> {
clearable?: boolean;
searchable?: boolean;
useOptions: (integrationIds: string[]) => {
data: { value: string; label: string }[];
isPending: boolean;
isError: boolean;
};
}
interface NumberInput extends CommonInput<number> {
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<string>) => ({
type: "customWidgetSelect" as const,
defaultValue: input?.defaultValue ?? "",