diff --git a/apps/docs/docs/integrations/gotify/index.mdx b/apps/docs/docs/integrations/gotify/index.mdx new file mode 100644 index 000000000..09c81265b --- /dev/null +++ b/apps/docs/docs/integrations/gotify/index.mdx @@ -0,0 +1,33 @@ +--- +title: 'Gotify' +description: "Gotify is a simple, self-hosted server for sending and receiving messages in real time." +hide_title: true +--- + +import { notificationsWidget } from '@site/docs/widgets/notifications'; +import { AddingIntegration } from '@site/src/components/integrations/adding'; +import { IntegrationHeader } from '@site/src/components/integrations/header'; +import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; +import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; +import { gotifyIntegration } from '.'; + + + +### Widgets & Capabilities + + +### Adding the integration + + +### Secrets + diff --git a/apps/docs/docs/integrations/gotify/index.ts b/apps/docs/docs/integrations/gotify/index.ts new file mode 100644 index 000000000..aceb6216f --- /dev/null +++ b/apps/docs/docs/integrations/gotify/index.ts @@ -0,0 +1,8 @@ +import { IntegrationDefinition } from "@site/src/types"; + +export const gotifyIntegration: IntegrationDefinition = { + name: "Gotify", + description: "Gotify is a simple, self-hosted server for sending and receiving messages in real time.", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gotify.svg", + path: "../../integrations/gotify", +}; diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 66e0fca27..5317ecce2 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -130,6 +130,7 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query** | ------------------ | -------------------------------------------------------- | ---------- | | `app` | List all apps, get app by ID, create/update/delete app | GET + POST | | `apiKeys` | List API keys | GET | +| `beszel` | Systems status, alerts, historical system metrics | GET | | `board` | List boards, get board by ID, create/update/delete board | GET + POST | | `docker` | List containers, inspect container, start/stop/restart | GET + POST | | `icon` | Search icons | GET | @@ -162,3 +163,4 @@ The tools table on the MCP tab is dynamically generated - it always reflects the - **"Create a new app pointing to my Grafana instance"** - AI creates an app with the correct icon and URL - **"How many users are on my Homarr instance?"** - AI lists all users (admin only) - **"Toggle Pi-hole blocking for 5 minutes"** - AI interacts with DNS controls +- **"How are my servers doing?"** - AI fetches Beszel system stats including CPU, memory, disk, and alerts diff --git a/apps/docs/docs/management/tools/_category_.json b/apps/docs/docs/management/tools/_category_.json deleted file mode 100644 index 04139d7f9..000000000 --- a/apps/docs/docs/management/tools/_category_.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "label": "Tools", - "collapsed": true, - "position": 10 -} diff --git a/apps/docs/docs/management/tools/backup/index.mdx b/apps/docs/docs/management/tools/backup/index.mdx deleted file mode 100644 index b4f05cf28..000000000 --- a/apps/docs/docs/management/tools/backup/index.mdx +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: SQLite Backup & Restore -tags: - - Management - - Backup - - Restore - - SQLite ---- - -The SQLite Backup & Restore feature allows you to export and import your entire Homarr database as a portable ZIP file. This is useful for creating backups, migrating between instances, or recovering from failures. - -:::info SQLite only -This feature is only available when using the SQLite database driver (`DB_DRIVER=better-sqlite3`). MySQL and PostgreSQL users should use their respective database backup tools. -::: - -## Accessing Backup & Restore - -Navigate to **Management → Tools → Backup** from the sidebar. This page is only visible when running with SQLite. - -## Exporting a Backup - -Click the **Export** button to download a ZIP archive containing: - -- **`db.sqlite`** — A consistent snapshot of your database (created via `VACUUM INTO`) -- **`metadata.json`** — Contains: - - `homarrVersion` — The version of Homarr that created the backup - - `exportedAt` — Timestamp of the export - - `dbDialect` — The database dialect (`sqlite`) - - `encryptionKey` — Your `SECRET_ENCRYPTION_KEY` (needed to decrypt integration secrets) - -:::caution -The exported ZIP contains your encryption key and database. Store it securely and do not share it publicly. -::: - -A confirmation dialog will ask you to type **"I understand"** before the export proceeds. - -## Importing a Backup - -1. Click the **Import** button and upload a previously exported ZIP file -2. Homarr analyzes the backup **entirely in your browser** using WebAssembly (sql.js) — no data is sent to the server during preview -3. The preview shows: - - **Entity counts** — How many boards, apps, users, integrations, etc. are in the backup - - **Board names** — A list of all boards contained in the backup - - **Migration status** — If the backup is from an older version, Homarr shows each pending database migration as animated timeline steps -4. Review the preview and type **"I understand"** to confirm -5. Homarr applies any pending migrations, re-encrypts integration secrets if the `SECRET_ENCRYPTION_KEY` differs between instances, and replaces the live database -6. The server automatically restarts to pick up the new database - -:::tip -The import process is **backward-compatible** with older backups that used a separate `encryption-key.txt` file instead of embedding the key in `metadata.json`. -::: - -## Onboarding Integration - -When setting up a fresh Homarr instance with SQLite, the onboarding wizard includes a **"Restore from SQLite backup"** option. This lets you upload a backup ZIP to bootstrap your new instance with data from a previous installation. - -## Docker Restart Behavior - -After a successful import, only the Next.js process restarts — nginx and Redis remain running. The `run.sh` script automatically restarts Next.js in a loop, so the application comes back online within seconds. Running `docker stop` still works gracefully. - -## Best Practices - -- **Back up regularly** — Create exports before major updates or configuration changes -- **Test restores** — Periodically verify your backups by importing them on a test instance -- **Secure storage** — Keep backups in an encrypted or access-controlled location since they contain your encryption key -- **Check versions** — Backups from newer versions cannot be imported into older Homarr instances; always import into the same or newer version diff --git a/apps/docs/docs/widgets/beszel-system-stats/index.ts b/apps/docs/docs/widgets/beszel-system-stats/index.ts index 4b7d5fe7c..2f48019e4 100644 --- a/apps/docs/docs/widgets/beszel-system-stats/index.ts +++ b/apps/docs/docs/widgets/beszel-system-stats/index.ts @@ -8,12 +8,19 @@ export const beszelSystemStatsWidget: WidgetDefinition = { path: "../../widgets/beszel-system-stats", configuration: { items: [ + { + name: "System", + description: "The Beszel system to display stats for. Searchable select populated from the integration.", + values: { type: "string" }, + defaultValue: "First available system", + }, { name: "Time period", - description: "The time range for chart data", + description: + "The time range for chart data. '1 Minute' enables live streaming via PocketBase SSE (requires Beszel agent >= 0.13.0)", values: { type: "select", - options: ["1 Hour", "12 Hours", "24 Hours", "1 Week", "30 Days"], + options: ["1 Minute", "1 Hour", "12 Hours", "24 Hours", "1 Week", "30 Days"], }, defaultValue: "1 Hour", }, diff --git a/apps/docs/docs/widgets/notifications/index.mdx b/apps/docs/docs/widgets/notifications/index.mdx index e6f927b8a..0a715ab3c 100644 --- a/apps/docs/docs/widgets/notifications/index.mdx +++ b/apps/docs/docs/widgets/notifications/index.mdx @@ -13,7 +13,8 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import { notificationsWidget } from '.'; import { ntfyIntegration } from '@site/docs/integrations/ntfy'; -import { nextcloudIntegration } from '@site/docs/integrations/nextcloud' +import { nextcloudIntegration } from '@site/docs/integrations/nextcloud'; +import { gotifyIntegration } from '@site/docs/integrations/gotify'; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx index 031b19c20..7732dcc33 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx @@ -57,13 +57,13 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => { {definitions.map((def) => ( - + {def.iconUrl ? ( ) : ( )} - + {def.name} diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx index 08ef65a49..c46c368ac 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx @@ -1,17 +1,5 @@ import { notFound } from "next/navigation"; -import { - Alert, - Anchor, - Center, - Group, - Stack, - Table, - TableTbody, - TableTd, - TableTr, - Text, - Title, -} from "@mantine/core"; +import { Alert, Anchor, Center, Group, Stack, Table, TableTbody, TableTd, TableTr, Text, Title } from "@mantine/core"; import { IconExclamationCircle } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; @@ -53,14 +41,10 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) { const group = await api.group.getById({ id: params.id }); const isReserved = group.name === everyoneGroup; - const filteredMembers = searchParams.search - ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - group.members.filter((member) => - member.name - ?.toLowerCase() - .includes(searchParams.search!.trim().toLowerCase()), - ) - : group.members; + const searchTerm = searchParams.search?.trim().toLowerCase(); + const filteredMembers = group.members.filter( + (member) => !searchTerm || member.name?.toLowerCase().includes(searchTerm), + ); // "local" = every enabled provider managed locally, "external" = none, "mixed" = some. const managementType = getGroupMemberManagementType(); @@ -75,21 +59,14 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) { ) : ( managementType !== "local" && ( - } - > + }> {t(`group.memberNotice.${managementType}`)} ) )} - + {canManageMembers && !isReserved && ( {filteredMembers.map((member) => ( - + ))} @@ -140,11 +112,7 @@ const Row = ({ member, groupId, disabled }: RowProps) => { - - {canBeRemoved && !disabled && ( - - )} - + {canBeRemoved && !disabled && } ); }; diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.ts index 2cfcfbb24..364fe90c5 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.ts @@ -148,6 +148,8 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr **"Check server health"** → Use \`healthMonitoring_getSystemHealthStatus\` for NAS/server metrics or \`healthMonitoring_getClusterHealthStatus\` for Proxmox clusters. +**"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`). + **"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs. **"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation. diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index 299a88e0b..887e878ad 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -9,6 +9,7 @@ import { iconsRouter } from "./router/icons"; import { infoRouter } from "./router/info"; import { integrationRouter } from "./router/integration/integration-router"; import { inviteRouter } from "./router/invite"; +import { beszelRouter } from "./router/widgets/beszel"; import { calendarRouter } from "./router/widgets/calendar"; import { dnsHoleRouter } from "./router/widgets/dns-hole"; import { downloadsRouter } from "./router/widgets/downloads"; @@ -26,6 +27,7 @@ export const mcpRouter = createTRPCRouter({ info: infoRouter, integration: integrationRouter, invite: inviteRouter, + beszel: beszelRouter, calendar: calendarRouter, dnsHole: dnsHoleRouter, downloads: downloadsRouter, diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index d3b895d88..25ede9289 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -1,6 +1,9 @@ +import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; +import { createIntegrationAsync } from "@homarr/integrations"; +import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; import { beszelAlertsRequestHandler, beszelStatsRequestHandler, @@ -13,12 +16,21 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; export const beszelRouter = createTRPCRouter({ getSystems: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)", + }, + }) .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .query(async ({ ctx }) => { const results = await Promise.all( ctx.integrations.map(async (integration) => { const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); return { integrationId: integration.id, integrationName: integration.name, @@ -34,7 +46,11 @@ export const beszelRouter = createTRPCRouter({ subscribeSystems: publicProcedure .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .subscription(({ ctx }) => { - return observable<{ integrationId: string; systems: BeszelSystemRow[]; timestamp: Date }>((emit) => { + return observable<{ + integrationId: string; + systems: BeszelSystemRow[]; + timestamp: Date; + }>((emit) => { const unsubscribes = ctx.integrations.map((integration) => { const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); return innerHandler.subscribe((systems) => { @@ -52,6 +68,13 @@ export const beszelRouter = createTRPCRouter({ }), getAlerts: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get Beszel alerts and optional alert history for all monitored systems. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all). OPTIONAL: includeHistory (default true), maxHistoryItems (default 10)", + }, + }) .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .input( z.object({ @@ -96,14 +119,22 @@ export const beszelRouter = createTRPCRouter({ }), ) .subscription(({ ctx, input }) => { - return observable<{ integrationId: string; alerts: BeszelAlertsData; timestamp: Date }>((emit) => { + return observable<{ + integrationId: string; + alerts: BeszelAlertsData; + timestamp: Date; + }>((emit) => { const unsubscribes = ctx.integrations.map((integration) => { const innerHandler = beszelAlertsRequestHandler.handler(integration, { includeHistory: input.includeHistory, maxHistoryItems: input.maxHistoryItems, }); return innerHandler.subscribe((data) => { - emit.next({ integrationId: integration.id, alerts: data, timestamp: new Date() }); + emit.next({ + integrationId: integration.id, + alerts: data, + timestamp: new Date(), + }); }); }); return () => { @@ -113,17 +144,26 @@ export const beszelRouter = createTRPCRouter({ }), getSystemStats: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)", + }, + }) .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .input( z.object({ systemId: z.string(), - timePeriod: z.enum(["1h", "12h", "24h", "1w", "30d"]), + timePeriod: z.enum(["1m", "1h", "12h", "24h", "1w", "30d"]).default("1h"), includeDocker: z.boolean().default(true), }), ) .query(async ({ ctx, input }) => { const integration = ctx.integrations[0]; - if (!integration) return null; + if (!integration) { + throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" }); + } const innerHandler = beszelStatsRequestHandler.handler(integration, { systemId: input.systemId, timePeriod: input.timePeriod, @@ -132,4 +172,45 @@ export const beszelRouter = createTRPCRouter({ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return { integrationId: integration.id, ...data, updatedAt: timestamp }; }), + + subscribeSystemStats: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .input( + z.object({ + systemId: z.string(), + }), + ) + .subscription(({ ctx, input }) => { + return observable<{ + stats: BeszelSystemStatsRecord; + containerStats: BeszelContainerStatsRecord | null; + }>((emit) => { + const integration = ctx.integrations[0]; + if (!integration) { + emit.error(new Error("No Beszel integration configured")); + return () => {}; + } + + const abortController = new AbortController(); + + void (async () => { + try { + const instance = await createIntegrationAsync(integration); + await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal); + } catch (error) { + if (!abortController.signal.aborted) { + if (error instanceof Error) { + emit.error(error); + } else { + emit.error(new Error(String(error))); + } + } + } + })(); + + return () => { + abortController.abort(); + }; + }); + }), }); diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 39ee81c08..9f906f95e 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -323,6 +323,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/github-containerregistry" | "/docs/integrations/quay" | "/docs/integrations/ntfy" + | "/docs/integrations/gotify" | "/docs/integrations/ical" | "/docs/integrations/anchor" | "/docs/integrations/truenas" diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index f6cae0298..99ec6ebb0 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -40,6 +40,7 @@ export const integrationDocSlugs: Record = { gitHubContainerRegistry: "github-containerregistry", quay: "quay", ntfy: "ntfy", + gotify: "gotify", ical: "ical", anchor: "anchor", truenas: "truenas", @@ -56,5 +57,6 @@ export const integrationDocSlugs: Record = { umami: "umami", peaNut: "peanut", beszel: "beszel", + archiveTeamWarrior: null, mock: null, }; diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index 8ae545f3e..3ffae6408 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -44,6 +44,7 @@ export const widgetDocSlugs: Record = { audioStats: "audio-stats", umami: "umami", ups: "ups", + archiveTeamWarrior: null, beszelSystemTable: "beszel-system-table", beszelSystemGrid: "beszel-system-grid", beszelAlerts: "beszel-alerts", diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts index eb646c5dc..50f64668c 100644 --- a/packages/integrations/src/beszel/beszel-integration.ts +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -1,4 +1,5 @@ import { ResponseError } from "@homarr/common/server"; +import { createLogger } from "@homarr/core/infrastructure/logs"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import type { IntegrationInput, IntegrationTestingInput } from "../base/integration"; @@ -12,17 +13,21 @@ import type { BeszelAlertHistory, BeszelAuthResponse, BeszelContainer, + BeszelContainerStats, BeszelContainerStatsRecord, BeszelSmartDevice, BeszelSystem, BeszelSystemDetails, BeszelSystemdService, + BeszelSystemStats, BeszelSystemStatsRecord, CreateAlertInput, PocketBaseListResponse, UpdateAlertInput, } from "./beszel-types"; +const logger = createLogger({ module: "beszel-integration" }); + const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); interface BeszelSession { @@ -245,6 +250,100 @@ export class BeszelIntegration extends Integration { }); } + public async subscribeRealtimeMetrics( + systemId: string, + onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void, + signal: AbortSignal, + ): Promise { + const session = await this.authenticateAsync(); + const sseUrl = this.url("/api/realtime"); + + const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, { + headers: { Authorization: session.token }, + signal, + }); + + if (!sseResponse.ok || !sseResponse.body) { + throw new ResponseError(sseResponse); + } + + const reader = sseResponse.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let clientId: string | null = null; + + const processLine = (line: string) => { + if (!line.startsWith("data:")) return; + const jsonStr = line.slice(5).trim(); + if (!jsonStr) return; + + try { + const parsed = JSON.parse(jsonStr) as Record; + + if (!clientId && typeof parsed.clientId === "string") { + clientId = parsed.clientId; + this.subscribeTopic(clientId, systemId, session.token).catch((err) => { + logger.warn("Failed to subscribe to rt_metrics topic, aborting SSE", { error: err }); + reader.cancel().catch(() => {}); + }); + return; + } + + if ("stats" in parsed) { + const now = new Date().toISOString(); + const envelope = { id: "", system: systemId, type: "1m" as const, created: now, updated: now }; + const stats: BeszelSystemStatsRecord = { ...envelope, stats: parsed.stats as BeszelSystemStats }; + + let containerStats: BeszelContainerStatsRecord | null = null; + const hasContainers = + parsed.container && + Array.isArray(parsed.container) && + (parsed.container as BeszelContainerStats[]).length > 0; + if (hasContainers) { + containerStats = { ...envelope, stats: parsed.container as BeszelContainerStats[] }; + } + onMessage({ stats, containerStats }); + } + } catch { + logger.debug("Failed to parse SSE message", { line }); + } + }; + + try { + while (!signal.aborted) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + processLine(line); + } + } + if (!signal.aborted) { + throw new Error("Beszel SSE stream ended unexpectedly"); + } + } finally { + reader.cancel().catch(() => {}); + } + } + + private async subscribeTopic(clientId: string, systemId: string, token: string): Promise { + const topic = `rt_metrics?options=${JSON.stringify({ query: { system: systemId } })}`; + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/realtime"), { + method: "POST", + headers: { + Authorization: token, + "Content-Type": "application/json", + }, + body: JSON.stringify({ clientId, subscriptions: [topic] }), + }); + if (!response.ok) { + throw new ResponseError(response); + } + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), { method: "POST", diff --git a/packages/integrations/src/mock/data/beszel.ts b/packages/integrations/src/mock/data/beszel.ts index 40acc78be..c6998aa84 100644 --- a/packages/integrations/src/mock/data/beszel.ts +++ b/packages/integrations/src/mock/data/beszel.ts @@ -330,6 +330,40 @@ export class BeszelMockService { ]; } + public async subscribeRealtimeMetrics( + systemId: string, + onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void, + signal: AbortSignal, + ): Promise { + const sys = resolveSystem(systemId); + const emit = () => { + if (signal.aborted) return; + const [stats] = generateSystemStatsSeries(1, sys); + if (!stats) return; + const record: BeszelSystemStatsRecord = { + id: `mock-rt-${Date.now()}`, + system: systemId, + stats, + type: "1m", + created: new Date().toISOString(), + updated: new Date().toISOString(), + }; + onMessage({ stats: record, containerStats: null }); + }; + if (signal.aborted) return; + await new Promise((resolve) => { + const interval = setInterval(emit, 1000); + signal.addEventListener( + "abort", + () => { + clearInterval(interval); + resolve(); + }, + { once: true }, + ); + }); + } + public async getAlertHistoryAsync(_systemId?: string, perPage = 10): Promise { const now = Date.now(); const names = ["CPU", "Memory", "Disk", "Temperature", "Bandwidth"]; @@ -341,7 +375,7 @@ export class BeszelMockService { name: names[i % names.length] ?? "CPU", val: rand(60, 95), created: new Date(now - (i + 1) * 3_600_000).toISOString(), - resolved: i % 2 === 0 ? new Date(now - i * 1_800_000).toISOString() : null, + resolved: (i % 2 === 0 && new Date(now - i * 1_800_000).toISOString()) || null, })); } } diff --git a/packages/integrations/src/mock/mock-integration.ts b/packages/integrations/src/mock/mock-integration.ts index 0804267b1..d7eb412cf 100644 --- a/packages/integrations/src/mock/mock-integration.ts +++ b/packages/integrations/src/mock/mock-integration.ts @@ -140,4 +140,5 @@ export class MockIntegration getContainerStatsAsync = MockIntegration.beszel.getContainerStatsAsync.bind(MockIntegration.beszel); getAlertsAsync = MockIntegration.beszel.getAlertsAsync.bind(MockIntegration.beszel); getAlertHistoryAsync = MockIntegration.beszel.getAlertHistoryAsync.bind(MockIntegration.beszel); + subscribeRealtimeMetrics = MockIntegration.beszel.subscribeRealtimeMetrics.bind(MockIntegration.beszel); } diff --git a/packages/request-handler/src/beszel.ts b/packages/request-handler/src/beszel.ts index 053edf52b..a59309201 100644 --- a/packages/request-handler/src/beszel.ts +++ b/packages/request-handler/src/beszel.ts @@ -90,12 +90,13 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler< queryKey: "beszelAlerts", }); -const timePeriodConfig: Record = { - "1h": { type: "1m", perPage: 60 }, - "12h": { type: "10m", perPage: 72 }, - "24h": { type: "20m", perPage: 72 }, - "1w": { type: "120m", perPage: 84 }, - "30d": { type: "480m", perPage: 90 }, +const timePeriodConfig: Record = { + "1m": { type: "1m", perPage: 60, cacheSeconds: 5 }, + "1h": { type: "1m", perPage: 60, cacheSeconds: 60 }, + "12h": { type: "10m", perPage: 72, cacheSeconds: 300 }, + "24h": { type: "20m", perPage: 72, cacheSeconds: 600 }, + "1w": { type: "120m", perPage: 84, cacheSeconds: 1800 }, + "30d": { type: "480m", perPage: 90, cacheSeconds: 3600 }, }; export interface BeszelStatsData { @@ -109,7 +110,7 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler< { systemId: string; timePeriod: string; includeDocker: boolean } >({ async requestAsync(integration, input) { - const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 }; + const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 }; const instance = await createIntegrationAsync(integration); const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage); const containerStats = input.includeDocker @@ -117,6 +118,11 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler< : []; return { systemStats, containerStats }; }, - cacheDuration: dayjs.duration(30, "seconds"), + cacheDuration: dayjs.duration(60, "seconds"), queryKey: "beszelStats", + cacheDurationForInput(input) { + const config = timePeriodConfig[input.timePeriod]; + if (!config) return undefined; + return dayjs.duration(config.cacheSeconds, "seconds"); + }, }); diff --git a/packages/request-handler/src/lib/cached-integration-request-handler.ts b/packages/request-handler/src/lib/cached-integration-request-handler.ts index 3cae8df0f..8c26fcbe6 100644 --- a/packages/request-handler/src/lib/cached-integration-request-handler.ts +++ b/packages/request-handler/src/lib/cached-integration-request-handler.ts @@ -14,10 +14,10 @@ type IntegrationOfKind = Omit> { - // Unique key for this request handler queryKey: string; requestAsync: (integration: IntegrationOfKind, input: TInput) => Promise; cacheDuration: Duration; + cacheDurationForInput?: (input: TInput) => Duration | undefined; } export const createCachedIntegrationRequestHandler = < @@ -34,9 +34,9 @@ export const createCachedIntegrationRequestHandler = < requestAsync: async (input: { options: TInput; integration: IntegrationOfKind }) => { return await options.requestAsync(input.integration, input.options); }, - cacheDuration: options.cacheDuration, - createRedisChannel(input, options) { - return createIntegrationOptionsChannel(input.integration.id, options.queryKey, input.options); + cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration, + createRedisChannel(input, handlerOptions) { + return createIntegrationOptionsChannel(input.integration.id, handlerOptions.queryKey, input.options); }, }).handler({ options: itemOptions, integration }), }; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index b0a126733..e59c38dae 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1652,6 +1652,13 @@ } }, "widget": { + "integrationSelect": { + "configureIntegrationFirst": "Connect an integration first", + "loading": "Loading...", + "placeholder": "Select an option", + "noResults": "No results found", + "loadError": "Failed to load options" + }, "app": { "name": "App", "description": "Embeds an app into the board.", @@ -3570,6 +3577,9 @@ } }, "noRecords": "No systems found", + "error": { + "internalServerError": "Unable to connect to Beszel. Check your integration settings." + }, "column": { "system": "System", "cpu": "CPU", @@ -3626,6 +3636,9 @@ "label": "Show agent version" } }, + "error": { + "internalServerError": "Unable to connect to Beszel. Check your integration settings." + }, "metric": { "cpu": "CPU", "memory": "Memory", @@ -3652,6 +3665,9 @@ } }, "empty": "No alerts configured", + "error": { + "internalServerError": "Unable to connect to Beszel. Check your integration settings." + }, "history": "History", "resolved": "Resolved", "status": { @@ -3663,6 +3679,10 @@ "name": "Beszel System Stats", "description": "Time-series charts for CPU, memory, disk, network, and Docker container metrics", "option": { + "systemId": { + "label": "System", + "description": "Select a Beszel system to display stats for" + }, "timePeriod": { "label": "Time period" }, @@ -3692,6 +3712,12 @@ } }, "selectSystem": "Select system", + "error": { + "internalServerError": "Unable to connect to Beszel. Check your integration settings.", + "liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.", + "switchToHistorical": "Switch to historical view", + "systemNotFound": "Selected system was not found. Choose a different system." + }, "chart": { "cpu": { "title": "CPU Usage", diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts index 639a9df5e..ac9ef1a67 100644 --- a/packages/widgets/src/_inputs/index.ts +++ b/packages/widgets/src/_inputs/index.ts @@ -16,6 +16,7 @@ import { WidgetTextInput } from "./widget-text-input"; import { WidgetUmamiEventInput } from "./widget-umami-event-input"; import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input"; import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input"; +import { WidgetIntegrationSelectInput } from "./widget-integration-select-input"; const mapping = { anchorNote: WidgetAnchorNoteInput, @@ -35,6 +36,7 @@ const mapping = { umamiEventNames: WidgetUmamiEventNamesInput, umamiWebsite: WidgetUmamiWebsiteInput, customWidgetSelect: WidgetCustomWidgetSelectInput, + integrationSelect: WidgetIntegrationSelectInput, } satisfies Record; export const getInputForType = (type: TType) => { diff --git a/packages/widgets/src/_inputs/widget-integration-select-input.tsx b/packages/widgets/src/_inputs/widget-integration-select-input.tsx new file mode 100644 index 000000000..e91c07a7e --- /dev/null +++ b/packages/widgets/src/_inputs/widget-integration-select-input.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Select, Text } from "@mantine/core"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { CommonWidgetInputProps } from "./common"; +import { useWidgetInputTranslation } from "./common"; +import { useFormContext } from "./form"; + +const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect"); + +export const WidgetIntegrationSelectInput = ({ + property, + kind, + options, +}: CommonWidgetInputProps<"integrationSelect">) => { + const t = useIntegrationSelectTranslation(); + const tInput = useWidgetInputTranslation(kind, property); + const form = useFormContext(); + + const integrationIds = form.values.integrationIds as string[]; + + const { data: selectData = [], isPending, isError } = options.useOptions(integrationIds); + const currentValue = form.getInputProps(`options.${property}`).value as string; + const hasCurrentInData = !currentValue || selectData.some((opt) => opt.value === currentValue); + const data = hasCurrentInData ? selectData : [...selectData, { value: currentValue, label: currentValue }]; + + if (integrationIds.length === 0) { + return ( + + {t("configureIntegrationFirst")} + + ); + } + + const description = options.withDescription ? tInput("description") : undefined; + const placeholder = isPending ? t("loading") : t("placeholder"); + const error = isError ? t("loadError") : undefined; + + return ( + value && handleSelectSystem(value)} + /> + + + ); + } + + if (isLive && liveError && !liveData) { + return ( +
+ + + + {t("error.liveConnectionFailed")} + + + +
+ ); + } return ( - + -