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:
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",
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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"));
|
||||
@@ -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]);
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
Reference in new issue
Block a user