diff --git a/.gitignore b/.gitignore index 0e991cc6c..768e58818 100644 --- a/.gitignore +++ b/.gitignore @@ -66,8 +66,7 @@ e2e/shared/tmp #personal backgrounds apps/nextjs/public/images/background.png -# next-intl -en.d.json.ts + # personal agent config AGENTS.md diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 612972f43..636df3f52 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -22,9 +22,6 @@ const homarrVersion = (() => { // Package path does not work... so we need to use relative path const withNextIntl = createNextIntlPlugin({ - experimental: { - createMessagesDeclaration: "../../packages/translation/src/lang/en.json", - }, requestConfig: "../../packages/translation/src/request.ts", }); diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 1ee201f75..be5ff450b 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -80,6 +80,8 @@ "isomorphic-dompurify": "catalog:", "jszip": "catalog:", "jotai": "catalog:", + "lucide-react": "catalog:", + "mantine-datatable": "catalog:", "mantine-react-table": "catalog:", "next": "catalog:", "postcss-preset-mantine": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 479d9dcc3..a9c6b65cb 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -5,6 +5,7 @@ import "@gfazioli/mantine-onboarding-tour/styles.css"; import "@homarr/notifications/styles.css"; import "@homarr/spotlight/styles.css"; import "@homarr/ui/styles.css"; +import "mantine-datatable/styles.css"; import "~/styles/color-scheme.scss"; import "~/styles/scroll-area.scss"; diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 286b16b23..acd2367c9 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -613,7 +613,7 @@ export const boardRouter = createTRPCRouter({ boardId: board.id, }); - const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount); + const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount); // Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount const layoutToClone = // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -1215,7 +1215,7 @@ export const boardRouter = createTRPCRouter({ }); return { - inherited: dbGroupPermissions.sort((permissionA, permissionB) => { + inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), users: userPermissions @@ -1223,7 +1223,7 @@ export const boardRouter = createTRPCRouter({ user, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); }), groups: dbGroupBoardPermission @@ -1234,7 +1234,7 @@ export const boardRouter = createTRPCRouter({ }, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), }; @@ -1586,7 +1586,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use ...otherBoardProperties, layouts: layouts .map(({ boardId: _, ...layout }) => layout) - .sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint), + .toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint), sections: sections.map(({ collapseStates, ...section }) => parseSection({ ...section, diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts new file mode 100644 index 000000000..d3b895d88 --- /dev/null +++ b/packages/api/src/router/widgets/beszel.ts @@ -0,0 +1,135 @@ +import { observable } from "@trpc/server/observable"; +import { z } from "zod/v4"; + +import { + beszelAlertsRequestHandler, + beszelStatsRequestHandler, + beszelSystemsRequestHandler, +} from "@homarr/request-handler/beszel"; +import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const beszelRouter = createTRPCRouter({ + getSystems: publicProcedure + .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 }); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + systems: data, + updatedAt: timestamp, + }; + }), + ); + return results; + }), + + subscribeSystems: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .subscription(({ ctx }) => { + return observable<{ integrationId: string; systems: BeszelSystemRow[]; timestamp: Date }>((emit) => { + const unsubscribes = ctx.integrations.map((integration) => { + const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); + return innerHandler.subscribe((systems) => { + emit.next({ + integrationId: integration.id, + systems, + timestamp: new Date(), + }); + }); + }); + return () => { + unsubscribes.forEach((fn) => fn()); + }; + }); + }), + + getAlerts: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .input( + z.object({ + includeHistory: z.boolean().default(true), + maxHistoryItems: z.number().min(1).max(100).default(10), + }), + ) + .query(async ({ ctx, input }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const alertsHandler = beszelAlertsRequestHandler.handler(integration, { + includeHistory: input.includeHistory, + maxHistoryItems: input.maxHistoryItems, + }); + const systemsHandler = beszelSystemsRequestHandler.handler(integration, {}); + const [alertsResult, systemsResult] = await Promise.all([ + alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), + systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), + ]); + const systemNameMap: Record = {}; + for (const system of systemsResult.data) { + systemNameMap[system.id] = system.name; + } + return { + integrationId: integration.id, + alerts: alertsResult.data.alerts, + history: alertsResult.data.history, + systemNameMap, + updatedAt: alertsResult.timestamp, + }; + }), + ); + return results; + }), + + subscribeAlerts: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .input( + z.object({ + includeHistory: z.boolean().default(true), + maxHistoryItems: z.number().min(1).max(100).default(10), + }), + ) + .subscription(({ ctx, input }) => { + 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() }); + }); + }); + return () => { + unsubscribes.forEach((fn) => fn()); + }; + }); + }), + + getSystemStats: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .input( + z.object({ + systemId: z.string(), + timePeriod: z.enum(["1h", "12h", "24h", "1w", "30d"]), + includeDocker: z.boolean().default(true), + }), + ) + .query(async ({ ctx, input }) => { + const integration = ctx.integrations[0]; + if (!integration) return null; + const innerHandler = beszelStatsRequestHandler.handler(integration, { + systemId: input.systemId, + timePeriod: input.timePeriod, + includeDocker: input.includeDocker, + }); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return { integrationId: integration.id, ...data, updatedAt: timestamp }; + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 15f0963b4..be34ad462 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -3,6 +3,7 @@ import { lazy } from "@trpc/server"; import { createTRPCRouter } from "../../trpc"; export const widgetRouter = createTRPCRouter({ + beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)), anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)), coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)), immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)), diff --git a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts index 197a61c8e..4d09801d2 100644 --- a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts +++ b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts @@ -10,6 +10,9 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC (integration, itemOptions: Record) => { const { kind } = integration; + if (kind === "beszel") { + return null as never; + } if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") { return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions); } diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index a2f19e59f..d491b6092 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -41,8 +41,7 @@ import { } from "../schema"; import type { Integration } from "../schema"; -const isTruthyEnv = (value: string | undefined) => - ["1", "yes", "t", "true"].includes((value ?? "").toLowerCase()); +const isTruthyEnv = (value: string | undefined) => ["1", "yes", "t", "true"].includes((value ?? "").toLowerCase()); export const seedDataAsync = async (db: Database) => { if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) { @@ -410,7 +409,13 @@ const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [ options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false }, }), ), - // Row 9: notebook + dockerContainers + weather = 12 + // Row 9: beszelSystemGrid + beszelAlerts = 12 + { kind: "beszelSystemGrid", width: 8, height: 3, needsIntegration: true }, + { kind: "beszelAlerts", width: 4, height: 3, needsIntegration: true }, + // Row 10: beszelSystemTable + beszelSystemStats = 12 + { kind: "beszelSystemTable", width: 6, height: 3, needsIntegration: true }, + { kind: "beszelSystemStats", width: 6, height: 4, needsIntegration: true }, + // Row 11: notebook + dockerContainers + weather = 12 { kind: "notebook", width: 4, height: 4, needsIntegration: false }, { kind: "dockerContainers", width: 6, height: 2, needsIntegration: false }, { kind: "weather", width: 2, height: 1, needsIntegration: false }, diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 2984a2519..05a29f29e 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -433,6 +433,14 @@ export const integrationDefs = { category: ["ups"], documentationUrl: null, }, + beszel: { + name: "Beszel", + secretKinds: [["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg", + category: ["healthMonitoring"], + documentationUrl: null, + defaultPort: 8090, + }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) mock: { name: "Mock", diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index fec9eb719..b0d7cdc37 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -39,5 +39,9 @@ export const widgetKinds = [ "speedtestTracker", "umami", "ups", + "beszelSystemTable", + "beszelSystemGrid", + "beszelAlerts", + "beszelSystemStats", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 20688b708..54d9566f7 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -1,6 +1,7 @@ import type { IntegrationKind } from "@homarr/definitions"; import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; +import { BeszelIntegration } from "../beszel/beszel-integration"; import { AnchorIntegration } from "../anchor/anchor-integration"; import { CodebergIntegration } from "../codeberg/codeberg-integration"; import { CoolifyIntegration } from "../coolify/coolify-integration"; @@ -125,6 +126,7 @@ export const integrationCreators = { speedtestTracker: SpeedtestTrackerIntegration, umami: UmamiIntegration, peaNut: PeaNutIntegration, + beszel: BeszelIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts new file mode 100644 index 000000000..eb646c5dc --- /dev/null +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -0,0 +1,269 @@ +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationInput, IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import type { SessionStore } from "../base/session-store"; +import { createSessionStore } from "../base/session-store"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { + BeszelAlert, + BeszelAlertHistory, + BeszelAuthResponse, + BeszelContainer, + BeszelContainerStatsRecord, + BeszelSmartDevice, + BeszelSystem, + BeszelSystemDetails, + BeszelSystemdService, + BeszelSystemStatsRecord, + CreateAlertInput, + PocketBaseListResponse, + UpdateAlertInput, +} from "./beszel-types"; + +const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + +interface BeszelSession { + token: string; + userId: string; +} + +export class BeszelIntegration extends Integration { + private readonly sessionStore: SessionStore; + + constructor(integration: IntegrationInput) { + super(integration); + this.sessionStore = createSessionStore(integration); + } + + private async authenticateAsync(): Promise { + const existingSession = await this.sessionStore.getAsync(); + if (existingSession) { + return existingSession; + } + + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identity: this.getSecretValue("username"), + password: this.getSecretValue("password"), + }), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + const data = (await response.json()) as BeszelAuthResponse; + const session: BeszelSession = { token: data.token, userId: data.record.id }; + await this.sessionStore.setAsync(session); + return session; + } + + private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) { + let session = await this.authenticateAsync(); + + const doFetch = (token: string) => + fetchWithTrustedCertificatesAsync(url, { + method: options.method ?? "GET", + headers: { + Authorization: token, + ...(options.body ? { "Content-Type": "application/json" } : {}), + }, + body: options.body, + }); + + let response = await doFetch(session.token); + + if (response.status === 401) { + await this.sessionStore.clearAsync(); + session = await this.authenticateAsync(); + response = await doFetch(session.token); + } + + if (!response.ok) { + throw new ResponseError(response); + } + + return response; + } + + public async getSystemsAsync(): Promise { + const response = await this.fetchWithAuthAsync( + this.url("/api/collections/systems/records", { perPage: "500", sort: "-updated" }), + ); + const data = (await response.json()) as PocketBaseListResponse; + return data.items; + } + + public async getSystemDetailsAsync(systemId: string): Promise { + const response = await this.fetchWithAuthAsync( + this.url(`/api/collections/system_details/records/${systemId}` as `/${string}`), + ); + return (await response.json()) as BeszelSystemDetails; + } + + public async getSystemStatsAsync(systemId: string, type = "1m", perPage = 60): Promise { + const filter = `system='${escapeFilterValue(systemId)}' && type='${escapeFilterValue(type)}'`; + const response = await this.fetchWithAuthAsync( + this.url("/api/collections/system_stats/records", { + filter, + sort: "-created", + perPage: String(perPage), + }), + ); + const data = (await response.json()) as PocketBaseListResponse; + return data.items; + } + + public async getContainersAsync(systemId: string): Promise { + const response = await this.fetchWithAuthAsync( + this.url("/api/collections/containers/records", { + filter: `system='${escapeFilterValue(systemId)}'`, + sort: "-updated", + perPage: "500", + }), + ); + const data = (await response.json()) as PocketBaseListResponse; + return data.items; + } + + public async getContainerStatsAsync( + systemId: string, + type = "1m", + perPage = 60, + ): Promise { + const filter = `system='${escapeFilterValue(systemId)}' && type='${escapeFilterValue(type)}'`; + const response = await this.fetchWithAuthAsync( + this.url("/api/collections/container_stats/records", { + filter, + sort: "-created", + perPage: String(perPage), + }), + ); + const data = (await response.json()) as PocketBaseListResponse; + return data.items; + } + + public async getSmartDevicesAsync(systemId: string): Promise { + const response = await this.fetchWithAuthAsync( + this.url("/api/collections/smart_devices/records", { + filter: `system='${escapeFilterValue(systemId)}'`, + }), + ); + const data = (await response.json()) as PocketBaseListResponse; + return data.items; + } + + public async getSystemdServicesAsync(systemId: string): Promise { + const response = await this.fetchWithAuthAsync( + this.url("/api/collections/systemd_services/records", { + filter: `system='${escapeFilterValue(systemId)}'`, + sort: "name", + }), + ); + const data = (await response.json()) as PocketBaseListResponse; + return data.items; + } + + public async getAlertsAsync(systemId?: string): Promise { + const params: Record = { perPage: "500" }; + if (systemId) { + params.filter = `system='${escapeFilterValue(systemId)}'`; + } + const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts/records", params)); + const data = (await response.json()) as PocketBaseListResponse; + return data.items; + } + + public async getAlertHistoryAsync(systemId?: string, perPage = 50): Promise { + const params: Record = { + sort: "-created", + perPage: String(perPage), + }; + if (systemId) { + params.filter = `system='${escapeFilterValue(systemId)}'`; + } + const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts_history/records", params)); + const data = (await response.json()) as PocketBaseListResponse; + return data.items; + } + + public async createAlertAsync(systemId: string, input: CreateAlertInput): Promise { + const session = await this.authenticateAsync(); + const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts/records"), { + method: "POST", + body: JSON.stringify({ + system: systemId, + user: session.userId, + name: input.name, + value: input.value, + min: input.min ?? 0, + triggered: false, + }), + }); + return (await response.json()) as BeszelAlert; + } + + public async updateAlertAsync(alertId: string, input: UpdateAlertInput): Promise { + const response = await this.fetchWithAuthAsync( + this.url(`/api/collections/alerts/records/${alertId}` as `/${string}`), + { + method: "PATCH", + body: JSON.stringify(input), + }, + ); + return (await response.json()) as BeszelAlert; + } + + public async deleteAlertAsync(alertId: string): Promise { + await this.fetchWithAuthAsync(this.url(`/api/collections/alerts/records/${alertId}` as `/${string}`), { + method: "DELETE", + }); + } + + public async pauseSystemAsync(systemId: string): Promise { + await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), { + method: "PATCH", + body: JSON.stringify({ status: "paused" }), + }); + } + + public async resumeSystemAsync(systemId: string): Promise { + await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), { + method: "PATCH", + body: JSON.stringify({ status: "pending" }), + }); + } + + public async deleteSystemAsync(systemId: string): Promise { + await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), { + method: "DELETE", + }); + } + + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identity: this.getSecretValue("username"), + password: this.getSecretValue("password"), + }), + }); + + if (!response.ok) { + return TestConnectionError.StatusResult({ status: response.status, url: response.url }); + } + + const data = (await response.json()) as BeszelAuthResponse; + if (!data.token) { + return TestConnectionError.UnauthorizedResult(401); + } + + return { success: true }; + } +} diff --git a/packages/integrations/src/beszel/beszel-types.ts b/packages/integrations/src/beszel/beszel-types.ts new file mode 100644 index 000000000..52643c701 --- /dev/null +++ b/packages/integrations/src/beszel/beszel-types.ts @@ -0,0 +1,389 @@ +export interface BeszelAuthResponse { + token: string; + record: { + id: string; + email: string; + username: string; + verified: boolean; + }; +} + +export type BeszelSystemStatus = "up" | "down" | "paused" | "pending"; + +/** + * Real-time system info snapshot from Beszel agent. + * Returned as `system.info` on each BeszelSystem record. + * All resource values are percentages or already-scaled summaries. + */ +export interface BeszelSystemInfo { + /** hostname */ + h?: string; + /** CPU usage (%) */ + cpu: number; + /** temperature (°C), overall */ + t?: number; + /** CPU core count */ + c?: number; + /** CPU thread count */ + ct?: number; + /** CPU model string */ + m?: string; + /** load average [1m, 5m, 15m] */ + la?: [number, number, number]; + /** OS identifier string */ + o?: string; + /** uptime (seconds) */ + u: number; + /** memory usage (%) */ + mp: number; + /** disk usage (%) */ + dp: number; + /** battery [level%, charging state] */ + bat?: [number, number]; + /** bandwidth — public interface (Mbps, legacy). Use bb when available */ + b?: number; + /** bandwidth — public interface (bytes/s, newer format). Preferred over b */ + bb?: number; + /** agent version */ + v: string; + /** podman mode */ + p?: boolean; + /** GPU usage (%) */ + g?: number; + /** disk temperature (°C) */ + dt?: number; + /** OS type identifier */ + os?: number; + /** extra filesystem usage (%) keyed by mount path */ + efs?: Record; + /** systemd services [running, total] */ + sv?: [number, number]; +} + +export interface BeszelSystem { + id: string; + name: string; + host: string; + port: string; + status: BeszelSystemStatus; + info: BeszelSystemInfo; + users?: string[]; + created: string; + updated: string; +} + +export interface BeszelSystemDetails { + id: string; + system: string; + hostname: string; + kernel: string; + cores: number; + threads: number; + cpu: string; + os: number; + os_name: string; + arch: string; + memory: number; + podman: boolean; + updated: string; +} + +/** + * Historical system stats record from Beszel PocketBase. + * Stored per-interval (1m, 10m, 20m, 120m, 480m depending on time range). + * + * Units: + * - CPU/GPU/memory/disk percentages: 0–100 + * - Memory/disk absolute values (mu, du, etc.): bytes + * - Disk I/O (dr, dw): bytes/s + * - Network (ns, nr): bytes/s — all interfaces (including loopback, docker bridges) + * - Bandwidth (b): bytes/s [sent, recv] — public interfaces only. Prefer b over ns/nr for user-facing charts + */ +export interface BeszelSystemStats { + /** CPU usage (%) */ + cpu: number; + /** CPU usage max (%) */ + cpum?: number; + /** per-core busy (%) */ + cpub?: number[]; + /** per-core speed (MHz) */ + cpus?: number[]; + /** load average [1m, 5m, 15m] */ + la?: [number, number, number]; + /** total memory (bytes) */ + m: number; + /** memory used (bytes) */ + mu: number; + /** memory usage (%) */ + mp: number; + /** memory buffer/cache (bytes) */ + mb: number; + /** memory max used (bytes) */ + mm?: number; + /** memory zfs arc (bytes) */ + mz?: number; + /** total swap (bytes) */ + s: number; + /** swap used (bytes) */ + su: number; + /** total disk (bytes) */ + d: number; + /** disk used (bytes) */ + du: number; + /** disk usage (%) */ + dp: number; + /** disk read (bytes/s) */ + dr: number; + /** disk write (bytes/s) */ + dw: number; + /** disk read max (bytes/s) */ + drm?: number; + /** disk write max (bytes/s) */ + dwm?: number; + /** disk IOPS [read, write] */ + dio?: [number, number]; + /** disk IOPS max [read, write] */ + diom?: [number, number]; + /** network sent — all interfaces (bytes/s) */ + ns: number; + /** network received — all interfaces (bytes/s) */ + nr: number; + /** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */ + b?: [number, number]; + /** network sent max (bytes/s) */ + nsm?: number; + /** network received max (bytes/s) */ + nrm?: number; + /** bandwidth max [sent, recv] (bytes/s) */ + bm?: [number, number]; + /** temperatures keyed by sensor name (°C) */ + t?: Record; + /** extra filesystem stats keyed by mount path */ + efs?: Record; + /** GPU stats keyed by device id */ + g?: Record; + /** battery [level%, charging state] */ + bat?: [number, number]; + /** network interfaces [rx bytes/s, tx bytes/s, rx max, tx max] */ + ni?: Record; +} + +export interface BeszelExtraFsStats { + /** total disk (bytes) */ + d: number; + /** disk used (bytes) */ + du: number; + /** read (bytes/s) */ + r: number; + /** write (bytes/s) */ + w: number; + /** read max (bytes/s) */ + rm: number; + /** write max (bytes/s) */ + wm: number; + /** read (IOPS) */ + rb: number; + /** write (IOPS) */ + wb: number; + /** read max (IOPS) */ + rbm: number; + /** write max (IOPS) */ + wbm: number; +} + +export interface BeszelGPUData { + /** GPU name/model */ + n: string; + /** memory used (bytes) */ + mu?: number; + /** memory total (bytes) */ + mt?: number; + /** GPU utilization (%) */ + u: number; + /** power draw (W) */ + p?: number; + /** power peak (W) */ + pp?: number; + /** encoder utilization keyed by engine name (%) */ + e?: Record; +} + +export interface BeszelSystemStatsRecord { + id: string; + system: string; + stats: BeszelSystemStats; + type: string; + created: string; + updated: string; +} + +export interface BeszelContainer { + id: string; + system: string; + name: string; + image: string; + status: string; + health: number; + cpu: number; + memory: number; + net: number; + ports?: string; + updated: number; +} + +/** + * Per-container resource stats from Beszel agent. + * One entry per container in a BeszelContainerStatsRecord. + * + * Network fields: b (bandwidth) is preferred when available; fall back to ns/nr. + */ +export interface BeszelContainerStats { + /** container name */ + n: string; + /** CPU usage (%) */ + c: number; + /** memory usage (bytes) */ + m: number; + /** network sent (bytes/s) — legacy, use b when available */ + ns?: number; + /** network received (bytes/s) — legacy, use b when available */ + nr?: number; + /** network bandwidth [sent, recv] (bytes/s) — preferred over ns/nr */ + b?: [number, number]; +} + +export interface BeszelContainerStatsRecord { + id: string; + system: string; + stats: BeszelContainerStats[]; + type: string; + created: string; + updated: string; +} + +export interface BeszelSmartDevice { + id: string; + system: string; + name: string; + model: string; + state: string; + capacity: number; + temp: number; + firmware: string; + serial: string; + type: string; + hours: number; + cycles: number; + attributes: BeszelSmartAttribute[]; + updated: string; +} + +export interface BeszelSmartAttribute { + /** attribute ID */ + id?: number; + /** attribute name */ + n: string; + /** current value */ + v: number; + /** worst value */ + w?: number; + /** threshold */ + t?: number; + /** raw value (numeric) */ + rv?: number; + /** raw value (string) */ + rs?: string; + /** when failed */ + wf?: string; +} + +export interface BeszelSystemdService { + id: string; + system: string; + name: string; + state: number; + sub: number; + cpu: number; + cpuPeak: number; + memory: number; + memPeak: number; + updated: number; +} + +export interface BeszelAlert { + id: string; + user: string; + system: string; + name: string; + triggered: boolean; + value: number; + min: number; +} + +export interface BeszelAlertHistory { + id: string; + alert: string; + user: string; + system: string; + name: string; + val: number; + created: string; + resolved?: string | null; +} + +export interface CreateAlertInput { + name: string; + value: number; + min?: number; +} + +export interface UpdateAlertInput { + value?: number; + min?: number; +} + +export interface PocketBaseListResponse { + page: number; + perPage: number; + totalItems: number; + totalPages: number; + items: T[]; +} + +/** + * Normalized view model for a Beszel-monitored system. + * Constructed by mapToSystemRow() in request-handler from BeszelSystem + BeszelSystemDetails. + * This is the shape consumed by grid/table widgets — all values are pre-scaled for display. + */ +export interface BeszelSystemRow { + id: string; + name: string; + status: BeszelSystemStatus; + /** CPU usage (%) */ + cpu: number; + /** memory usage (%) */ + memory: number; + /** disk usage (%) */ + disk: number; + /** GPU usage (%) */ + gpu: number; + /** load average [1m, 5m, 15m] */ + loadAvg: [number, number, number] | null; + /** public interface bandwidth (bytes/s). From info.bb or info.b * 1M */ + netBytes: number; + /** temperature (°C) */ + temp: number | null; + /** battery [level%, charging state] */ + battery: [number, number] | null; + /** running systemd services count */ + services: number; + /** uptime (seconds) */ + uptime: number; + agentVersion: string; + hostname: string; + cpuModel: string; + cores: number; + /** total memory (bytes) */ + memoryTotal: number; + osName: string; +} diff --git a/packages/integrations/src/beszel/test/beszel-integration.spec.ts b/packages/integrations/src/beszel/test/beszel-integration.spec.ts new file mode 100644 index 000000000..a68e1dbc8 --- /dev/null +++ b/packages/integrations/src/beszel/test/beszel-integration.spec.ts @@ -0,0 +1,226 @@ +// @vitest-environment node +import { beforeAll, describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; + process.env.ENABLE_DNS_CACHING = "false"; +}); + +const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090"; +const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? ""; +const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? ""; + +vi.mock("@homarr/redis", () => ({ + createGetSetChannel: () => ({ + getAsync: vi.fn().mockResolvedValue(null), + setAsync: vi.fn().mockResolvedValue(undefined), + removeAsync: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock("@homarr/core/infrastructure/logs", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + ErrorWithMetadata: class extends Error {}, +})); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init), + createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}), + createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@homarr/core/infrastructure/certificates", () => ({ + getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]), + getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]), +})); + +import { BeszelIntegration } from "../beszel-integration"; + +function createBeszelIntegration() { + return new BeszelIntegration({ + id: "test-beszel", + name: "Test Beszel", + url: BESZEL_URL, + externalUrl: null, + decryptedSecrets: [ + { kind: "username", value: BESZEL_EMAIL }, + { kind: "password", value: BESZEL_PASSWORD }, + ], + }); +} + +const shouldRun = BESZEL_EMAIL && BESZEL_PASSWORD; + +describe.skipIf(!shouldRun)("BeszelIntegration (real instance)", () => { + let integration: BeszelIntegration; + let systemId: string; + + beforeAll(() => { + integration = createBeszelIntegration(); + }); + + describe("getSystemsAsync", () => { + test("returns an array of systems", async () => { + const systems = await integration.getSystemsAsync(); + expect(systems).toBeInstanceOf(Array); + expect(systems.length).toBeGreaterThan(0); + }); + + test("each system has required fields", async () => { + const systems = await integration.getSystemsAsync(); + const system = systems[0]; + if (!system) { + throw new Error("Expected at least one system"); + } + systemId = system.id; + + expect(system.id).toBeDefined(); + expect(system.name).toBeDefined(); + expect(system.status).toMatch(/^(up|down|paused|pending)$/); + expect(system.info).toBeDefined(); + expect(typeof system.info.cpu).toBe("number"); + expect(typeof system.info.mp).toBe("number"); + expect(typeof system.info.dp).toBe("number"); + }); + }); + + describe("getSystemDetailsAsync", () => { + test("returns system details with hardware info", async () => { + const systems = await integration.getSystemsAsync(); + systemId = systems[0]?.id ?? ""; + + const details = await integration.getSystemDetailsAsync(systemId); + + expect(details.hostname).toBeDefined(); + expect(details.cpu).toBeDefined(); + expect(typeof details.cores).toBe("number"); + expect(details.cores).toBeGreaterThan(0); + expect(typeof details.memory).toBe("number"); + expect(details.memory).toBeGreaterThan(0); + expect(details.os_name).toBeDefined(); + }); + }); + + describe("getSystemStatsAsync", () => { + test("returns time-series stats records", async () => { + const systems = await integration.getSystemsAsync(); + systemId = systems[0]?.id ?? ""; + + const stats = await integration.getSystemStatsAsync(systemId, "1m", 5); + + expect(stats).toBeInstanceOf(Array); + if (stats.length === 0) return; + + const record = stats[0]; + expect(record?.stats).toBeDefined(); + expect(typeof record?.stats.cpu).toBe("number"); + expect(typeof record?.stats.mp).toBe("number"); + expect(typeof record?.stats.dp).toBe("number"); + expect(record?.created).toBeDefined(); + }); + }); + + describe("getContainersAsync", () => { + test("returns container list", async () => { + const systems = await integration.getSystemsAsync(); + systemId = systems[0]?.id ?? ""; + + const containers = await integration.getContainersAsync(systemId); + expect(containers).toBeInstanceOf(Array); + + if (containers.length > 0) { + const container = containers[0]; + expect(container?.name).toBeDefined(); + expect(container?.image).toBeDefined(); + expect(container?.status).toBeDefined(); + expect(typeof container?.cpu).toBe("number"); + expect(typeof container?.memory).toBe("number"); + } + }); + }); + + describe("alert CRUD", () => { + let alertId: string; + + test("creates an alert", async () => { + const systems = await integration.getSystemsAsync(); + systemId = systems[0]?.id ?? ""; + + const alert = await integration.createAlertAsync(systemId, { + name: "CPU", + value: 90, + min: 1, + }); + + expect(alert.id).toBeDefined(); + expect(alert.name).toBe("CPU"); + expect(alert.value).toBe(90); + expect(alert.system).toBe(systemId); + alertId = alert.id; + }); + + test("lists alerts including the created one", async () => { + const alerts = await integration.getAlertsAsync(systemId); + const found = alerts.find((a) => a.id === alertId); + expect(found).toBeDefined(); + expect(found?.name).toBe("CPU"); + }); + + test("updates alert threshold", async () => { + const updated = await integration.updateAlertAsync(alertId, { value: 95 }); + expect(updated.value).toBe(95); + }); + + test("deletes alert", async () => { + await integration.deleteAlertAsync(alertId); + const alerts = await integration.getAlertsAsync(systemId); + const found = alerts.find((a) => a.id === alertId); + expect(found).toBeUndefined(); + }); + }); + + describe("system actions", () => { + test("pauses and resumes a system", async () => { + const systems = await integration.getSystemsAsync(); + systemId = systems[0]?.id ?? ""; + + await integration.pauseSystemAsync(systemId); + const afterPause = await integration.getSystemsAsync(); + const paused = afterPause.find((s) => s.id === systemId); + expect(paused?.status).toBe("paused"); + + await integration.resumeSystemAsync(systemId); + const afterResume = await integration.getSystemsAsync(); + const resumed = afterResume.find((s) => s.id === systemId); + expect(resumed?.status).toMatch(/^(up|pending)$/); + }); + }); + + describe("authentication", () => { + test("authenticates with valid credentials", async () => { + const systems = await integration.getSystemsAsync(); + expect(systems.length).toBeGreaterThan(0); + }); + + test("fails with invalid credentials", async () => { + const badIntegration = new BeszelIntegration({ + id: "test-bad", + name: "Bad Beszel", + url: BESZEL_URL, + externalUrl: null, + decryptedSecrets: [ + { kind: "username", value: "wrong@example.com" }, + { kind: "password", value: "wrongpassword" }, + ], + }); + + await expect(badIntegration.getSystemsAsync()).rejects.toThrow(); + }); + }); +}); diff --git a/packages/integrations/src/mock/data/beszel.ts b/packages/integrations/src/mock/data/beszel.ts new file mode 100644 index 000000000..8788cf136 --- /dev/null +++ b/packages/integrations/src/mock/data/beszel.ts @@ -0,0 +1,343 @@ +import type { + BeszelAlert, + BeszelAlertHistory, + BeszelContainerStats, + BeszelContainerStatsRecord, + BeszelSystem, + BeszelSystemDetails, + BeszelSystemStats, + BeszelSystemStatsRecord, +} from "../../beszel/beszel-types"; + +const GB = 1024 * 1024 * 1024; +const MB = 1024 * 1024; +const KB = 1024; + +interface MockSystemDef { + name: string; + host: string; + os: string; + cpu: string; + cores: number; + mem: number; + arch: string; + baseCpu: number; + baseMem: number; + baseDisk: number; + containers: number[]; +} + +const mockSystems: MockSystemDef[] = [ + { + name: "web-server", + host: "192.168.1.10", + os: "Ubuntu 24.04", + cpu: "AMD Ryzen 9 7950X", + cores: 16, + mem: 64 * GB, + arch: "amd64", + baseCpu: 35, + baseMem: 55, + baseDisk: 42, + containers: [0, 1, 2, 3, 5], + }, + { + name: "nas-01", + host: "192.168.1.20", + os: "Debian 12", + cpu: "Intel Xeon E-2388G", + cores: 8, + mem: 32 * GB, + arch: "amd64", + baseCpu: 12, + baseMem: 70, + baseDisk: 68, + containers: [1, 2, 6], + }, + { + name: "pi-cluster-1", + host: "192.168.1.30", + os: "Raspberry Pi OS", + cpu: "ARM Cortex-A76", + cores: 4, + mem: 8 * GB, + arch: "aarch64", + baseCpu: 45, + baseMem: 82, + baseDisk: 35, + containers: [0, 2, 7], + }, + { + name: "media-server", + host: "192.168.1.40", + os: "Proxmox VE 8.2", + cpu: "Intel i7-13700K", + cores: 16, + mem: 128 * GB, + arch: "amd64", + baseCpu: 22, + baseMem: 48, + baseDisk: 55, + containers: [0, 1, 2, 3, 4, 5, 6, 7], + }, + { + name: "backup-node", + host: "192.168.1.50", + os: "TrueNAS SCALE", + cpu: "Intel Xeon E5-2680", + cores: 12, + mem: 48 * GB, + arch: "amd64", + baseCpu: 8, + baseMem: 30, + baseDisk: 78, + containers: [1, 6], + }, +]; + +const containerProfiles: { name: string; cpuBase: number; memMB: number; netKB: number }[] = [ + { name: "nginx", cpuBase: 2, memMB: 120, netKB: 800 }, + { name: "postgres", cpuBase: 8, memMB: 450, netKB: 200 }, + { name: "redis", cpuBase: 3, memMB: 180, netKB: 500 }, + { name: "grafana", cpuBase: 5, memMB: 280, netKB: 150 }, + { name: "prometheus", cpuBase: 6, memMB: 350, netKB: 300 }, + { name: "traefik", cpuBase: 4, memMB: 90, netKB: 1200 }, + { name: "minio", cpuBase: 3, memMB: 200, netKB: 600 }, + { name: "gitea", cpuBase: 4, memMB: 250, netKB: 100 }, +]; + +const rand = (min: number, max: number) => min + Math.random() * (max - min); +const randInt = (min: number, max: number) => Math.floor(rand(min, max)); +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); + +function smoothWalk(base: number, amplitude: number, steps: number, lo: number, hi: number): number[] { + const values: number[] = []; + let current = base + rand(-amplitude * 0.3, amplitude * 0.3); + for (let i = 0; i < steps; i++) { + const spike = Math.random() < 0.08 ? rand(-amplitude * 2, amplitude * 3) : 0; + current += rand(-amplitude, amplitude) + spike; + current = clamp(current, lo, hi); + values.push(current); + } + return values; +} + +function resolveSystem(systemId: string): MockSystemDef { + const idx = Number(systemId.replace("mock-sys-", "")) - 1; + return mockSystems[idx] ?? mockSystems[0] ?? (mockSystems.find(Boolean) as MockSystemDef); +} + +const typeToMinutes: Record = { + "1m": 1, + "10m": 10, + "20m": 20, + "120m": 120, + "480m": 480, +}; + +function generateSystemStatsSeries(count: number, sys: MockSystemDef): BeszelSystemStats[] { + const cpuWalk = smoothWalk(sys.baseCpu, 3, count, 1, 98); + const memPctWalk = smoothWalk(sys.baseMem, 1.5, count, 10, 95); + const diskPctWalk = smoothWalk(sys.baseDisk, 0.2, count, 5, 95); + const diskReadWalk = smoothWalk(5, 3, count, 0, 80); + const diskWriteWalk = smoothWalk(3, 2, count, 0, 50); + const netSendWalk = smoothWalk(500, 200, count, 10, 8000); + const netRecvWalk = smoothWalk(800, 300, count, 10, 12000); + + const memTotal = sys.mem; + const diskTotal = rand(200, 2000) * GB; + + return cpuWalk.map((cpu, i) => { + const memPct = memPctWalk[i] ?? sys.baseMem; + const diskPct = diskPctWalk[i] ?? sys.baseDisk; + return { + cpu, + la: [cpu / 20 + rand(0, 0.5), cpu / 25 + rand(0, 0.3), cpu / 30 + rand(0, 0.2)] as [number, number, number], + m: memTotal, + mu: (memTotal * memPct) / 100, + mp: memPct, + mb: rand(0.5, 4) * GB, + s: 4 * GB, + su: rand(0, 2) * GB, + d: diskTotal, + du: (diskTotal * diskPct) / 100, + dp: diskPct, + dr: (diskReadWalk[i] ?? 5) * MB, + dw: (diskWriteWalk[i] ?? 3) * MB, + ns: (netSendWalk[i] ?? 500) * KB, + nr: (netRecvWalk[i] ?? 800) * KB, + b: [(netSendWalk[i] ?? 500) * KB * 0.7, (netRecvWalk[i] ?? 800) * KB * 0.7] as [number, number], + }; + }); +} + +function generateContainerStatsSeries(count: number, profiles: typeof containerProfiles): BeszelContainerStats[][] { + const walks = profiles.map((p) => ({ + profile: p, + cpuWalk: smoothWalk(p.cpuBase, 1.5, count, 0, 50), + memWalk: smoothWalk(p.memMB, p.memMB * 0.1, count, p.memMB * 0.5, p.memMB * 2), + netWalk: smoothWalk(p.netKB, p.netKB * 0.3, count, 0, p.netKB * 4), + })); + + return Array.from({ length: count }, (_, i) => + walks.map(({ profile, cpuWalk, memWalk, netWalk }) => ({ + n: profile.name, + c: cpuWalk[i] ?? profile.cpuBase, + m: (memWalk[i] ?? profile.memMB) * MB, + b: [(netWalk[i] ?? profile.netKB) * KB * 0.4, (netWalk[i] ?? profile.netKB) * KB * 0.6] as [number, number], + })), + ); +} + +function buildTimeSeries( + systemId: string, + count: number, + intervalMinutes: number, + type: string, + dataPoints: T[], +): (T & { id: string; system: string; type: string; created: string; updated: string })[] { + const now = Date.now(); + const items = dataPoints.slice(0, count).map((data, i) => { + const ts = new Date(now - (count - i) * intervalMinutes * 60_000).toISOString(); + return { id: `mock-stat-${i}`, system: systemId, type, created: ts, updated: ts, ...data }; + }); + return items.toReversed(); +} + +const statusByIndex: Record = { 2: "paused", 4: "down" }; + +export class BeszelMockService { + public async getSystemsAsync(): Promise { + return mockSystems.map((sys, i) => ({ + id: `mock-sys-${i + 1}`, + name: sys.name, + host: sys.host, + port: "45876", + status: statusByIndex[i] ?? "up", + info: { + h: sys.name, + cpu: clamp(sys.baseCpu + rand(-5, 10), 0, 100), + c: sys.cores, + ct: sys.cores * 2, + m: sys.cpu, + la: [sys.baseCpu / 20, sys.baseCpu / 25, sys.baseCpu / 30] as [number, number, number], + o: sys.os, + u: randInt(86400, 2_000_000), + mp: clamp(sys.baseMem + rand(-5, 5), 0, 100), + dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100), + bb: rand(200, 3000) * KB, + v: "0.8.2", + g: i === 3 ? rand(15, 45) : 0, + dt: rand(32, 58), + sv: [randInt(8, 25), randInt(25, 40)] as [number, number], + }, + users: [], + created: new Date().toISOString(), + updated: new Date().toISOString(), + })); + } + + public async getSystemDetailsAsync(systemId: string): Promise { + const sys = resolveSystem(systemId); + return { + id: systemId, + system: systemId, + hostname: sys.name, + kernel: "6.8.0-40-generic", + cores: sys.cores, + threads: sys.cores * 2, + cpu: sys.cpu, + os: 1, + os_name: sys.os, + arch: sys.arch, + memory: sys.mem, + podman: false, + updated: new Date().toISOString(), + }; + } + + public async getSystemStatsAsync( + systemId: string, + type: string, + perPage: number, + ): Promise { + const sys = resolveSystem(systemId); + const intervalMinutes = typeToMinutes[type] ?? 1; + const statsSeries = generateSystemStatsSeries(perPage, sys); + return buildTimeSeries( + systemId, + perPage, + intervalMinutes, + type, + statsSeries.map((stats) => ({ stats })), + ); + } + + public async getContainerStatsAsync( + systemId: string, + type: string, + perPage: number, + ): Promise { + const sys = resolveSystem(systemId); + const intervalMinutes = typeToMinutes[type] ?? 1; + const selectedProfiles = sys.containers + .map((i) => containerProfiles[i]) + .filter(Boolean) as typeof containerProfiles; + const series = generateContainerStatsSeries(perPage, selectedProfiles); + return buildTimeSeries( + systemId, + perPage, + intervalMinutes, + type, + series.map((stats) => ({ stats })), + ); + } + + public async getAlertsAsync(): Promise { + return [ + { id: "mock-alert-1", user: "mock-user", system: "mock-sys-1", name: "CPU", triggered: true, value: 90, min: 5 }, + { + id: "mock-alert-2", + user: "mock-user", + system: "mock-sys-2", + name: "Memory", + triggered: false, + value: 80, + min: 10, + }, + { + id: "mock-alert-3", + user: "mock-user", + system: "mock-sys-1", + name: "Disk", + triggered: false, + value: 85, + min: 15, + }, + { + id: "mock-alert-4", + user: "mock-user", + system: "mock-sys-4", + name: "Temperature", + triggered: true, + value: 75, + min: 3, + }, + ]; + } + + public async getAlertHistoryAsync(_systemId?: string, perPage = 10): Promise { + const now = Date.now(); + const names = ["CPU", "Memory", "Disk", "Temperature", "Bandwidth"]; + return Array.from({ length: Math.min(perPage, 5) }, (_, i) => ({ + id: `mock-history-${i}`, + alert: `mock-alert-${(i % 4) + 1}`, + user: "mock-user", + system: `mock-sys-${(i % 5) + 1}`, + 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, + })); + } +} diff --git a/packages/integrations/src/mock/mock-integration.ts b/packages/integrations/src/mock/mock-integration.ts index b435dbd32..0804267b1 100644 --- a/packages/integrations/src/mock/mock-integration.ts +++ b/packages/integrations/src/mock/mock-integration.ts @@ -29,6 +29,7 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s import { NotificationsMockService } from "./data/notifications"; import { SmartHomeMockService } from "./data/smart-home"; import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring"; +import { BeszelMockService } from "./data/beszel"; import { UpsSummaryMockService } from "./data/ups-summary"; export class MockIntegration @@ -61,6 +62,7 @@ export class MockIntegration private static readonly networkController = new NetworkControllerSummaryMockService(); private static readonly notifications = new NotificationsMockService(); private static readonly smartHome = new SmartHomeMockService(); + private static readonly beszel = new BeszelMockService(); private static readonly upsSummary = new UpsSummaryMockService(); protected async testingAsync(_: IntegrationTestingInput): Promise { @@ -130,4 +132,12 @@ export class MockIntegration // UpsSummaryIntegration getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary); + + // BeszelIntegration + getSystemsAsync = MockIntegration.beszel.getSystemsAsync.bind(MockIntegration.beszel); + getSystemDetailsAsync = MockIntegration.beszel.getSystemDetailsAsync.bind(MockIntegration.beszel); + getSystemStatsAsync = MockIntegration.beszel.getSystemStatsAsync.bind(MockIntegration.beszel); + getContainerStatsAsync = MockIntegration.beszel.getContainerStatsAsync.bind(MockIntegration.beszel); + getAlertsAsync = MockIntegration.beszel.getAlertsAsync.bind(MockIntegration.beszel); + getAlertHistoryAsync = MockIntegration.beszel.getAlertHistoryAsync.bind(MockIntegration.beszel); } diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index fcb8a77ae..e912d026d 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -18,3 +18,4 @@ export * from "./interfaces/timetable/timetable-types"; export * from "./tracearr/tracearr-types"; export * from "./speedtest-tracker/speedtest-tracker-types"; export * from "./umami/umami-types"; +export * from "./beszel/beszel-types"; diff --git a/packages/request-handler/src/beszel.ts b/packages/request-handler/src/beszel.ts new file mode 100644 index 000000000..053edf52b --- /dev/null +++ b/packages/request-handler/src/beszel.ts @@ -0,0 +1,122 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { + BeszelAlert, + BeszelAlertHistory, + BeszelContainerStatsRecord, + BeszelSystem, + BeszelSystemDetails, + BeszelSystemRow, + BeszelSystemStatsRecord, +} from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export type { BeszelSystemRow } from "@homarr/integrations/types"; + +/** + * Maps raw Beszel API responses (BeszelSystem + BeszelSystemDetails) into a + * normalized BeszelSystemRow for widget consumption. + * + * BeszelSystemInfo uses short field names (see beszel-types.ts for full docs): + * cpu=CPU%, mp=memory%, dp=disk%, g=GPU%, la=loadAvg, u=uptime(s), v=version, + * bb=bandwidth(bytes/s), b=bandwidth(Mbps legacy), dt=disk temp, bat=battery, + * sv=[running,total] services, h=hostname, m=CPU model, c=cores, ct=threads + */ +function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | null): BeszelSystemRow { + const info = system.info; + return { + id: system.id, + name: system.name, + status: system.status, + cpu: info.cpu, + memory: info.mp, + disk: info.dp, + gpu: info.g ?? 0, + loadAvg: info.la ?? null, + // bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s) + netBytes: info.bb ?? (info.b ?? 0) * 1_000_000, + temp: info.dt ?? null, + battery: info.bat ?? null, + services: info.sv?.[0] ?? 0, + uptime: info.u, + agentVersion: info.v, + hostname: details?.hostname ?? info.h ?? system.name, + cpuModel: details?.cpu ?? info.m ?? "Unknown", + cores: details?.cores ?? info.ct ?? info.c ?? 1, + memoryTotal: details?.memory ?? 0, + osName: details?.os_name ?? "", + }; +} + +export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler< + BeszelSystemRow[], + "beszel" | "mock", + Record +>({ + async requestAsync(integration) { + const instance = await createIntegrationAsync(integration); + const systems = await instance.getSystemsAsync(); + const enriched = await Promise.all( + systems.map(async (system) => { + const details = await instance.getSystemDetailsAsync(system.id).catch(() => null); + return mapToSystemRow(system, details); + }), + ); + return enriched; + }, + cacheDuration: dayjs.duration(5, "seconds"), + queryKey: "beszelSystems", +}); + +export interface BeszelAlertsData { + alerts: BeszelAlert[]; + history: BeszelAlertHistory[]; +} + +export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler< + BeszelAlertsData, + "beszel" | "mock", + { includeHistory: boolean; maxHistoryItems: number } +>({ + async requestAsync(integration, input) { + const instance = await createIntegrationAsync(integration); + const alerts = await instance.getAlertsAsync(); + const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : []; + return { alerts, history }; + }, + cacheDuration: dayjs.duration(15, "seconds"), + 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 }, +}; + +export interface BeszelStatsData { + systemStats: BeszelSystemStatsRecord[]; + containerStats: BeszelContainerStatsRecord[]; +} + +export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler< + BeszelStatsData, + "beszel" | "mock", + { systemId: string; timePeriod: string; includeDocker: boolean } +>({ + async requestAsync(integration, input) { + const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 }; + const instance = await createIntegrationAsync(integration); + const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage); + const containerStats = input.includeDocker + ? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => []) + : []; + return { systemStats, containerStats }; + }, + cacheDuration: dayjs.duration(30, "seconds"), + queryKey: "beszelStats", +}); diff --git a/packages/request-handler/src/health-monitoring.ts b/packages/request-handler/src/health-monitoring.ts index 98d0cce3d..640eb6b97 100644 --- a/packages/request-handler/src/health-monitoring.ts +++ b/packages/request-handler/src/health-monitoring.ts @@ -8,7 +8,7 @@ import { createCachedIntegrationRequestHandler } from "./lib/cached-integration- export const systemInfoRequestHandler = createCachedIntegrationRequestHandler< SystemHealthMonitoring, - Exclude, "proxmox" | "coolify">, + Exclude, "proxmox" | "coolify" | "beszel">, Record >({ async requestAsync(integration, _input) { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 88f95e6b5..e02d6945b 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3104,6 +3104,219 @@ "status": { "unhealthy": "Unhealthy" } + }, + "beszelSystemTable": { + "name": "Beszel Systems (Table)", + "description": "Table view of all Beszel-monitored systems with sortable columns and status indicators", + "option": { + "statusFilter": { + "label": "Status filter" + }, + "sortBy": { + "label": "Sort by" + }, + "sortDirection": { + "label": "Sort direction" + }, + "showCpu": { + "label": "Show CPU" + }, + "showMemory": { + "label": "Show memory" + }, + "showDisk": { + "label": "Show disk" + }, + "showGpu": { + "label": "Show GPU" + }, + "showLoadAvg": { + "label": "Show load average" + }, + "showNet": { + "label": "Show network" + }, + "showTemp": { + "label": "Show temperature" + }, + "showBattery": { + "label": "Show battery" + }, + "showServices": { + "label": "Show services" + }, + "showUptime": { + "label": "Show uptime" + }, + "showAgent": { + "label": "Show agent version" + } + }, + "noRecords": "No systems found", + "column": { + "system": "System", + "cpu": "CPU", + "memory": "Memory", + "disk": "Disk", + "gpu": "GPU", + "loadAvg": "Load Avg", + "net": "Net", + "temp": "Temp", + "battery": "Bat", + "services": "Services", + "uptime": "Uptime", + "agent": "Agent" + } + }, + "beszelSystemGrid": { + "name": "Beszel Systems (Grid)", + "description": "Card grid view of all Beszel-monitored systems with real-time metrics", + "option": { + "statusFilter": { + "label": "Status filter" + }, + "showCpu": { + "label": "Show CPU" + }, + "showMemory": { + "label": "Show memory" + }, + "showDisk": { + "label": "Show disk" + }, + "showGpu": { + "label": "Show GPU" + }, + "showLoadAvg": { + "label": "Show load average" + }, + "showNet": { + "label": "Show network" + }, + "showTemp": { + "label": "Show temperature" + }, + "showBattery": { + "label": "Show battery" + }, + "showServices": { + "label": "Show services" + }, + "showUptime": { + "label": "Show uptime" + }, + "showAgent": { + "label": "Show agent version" + } + }, + "metric": { + "cpu": "CPU", + "memory": "Memory", + "disk": "Disk", + "gpu": "GPU", + "loadAvg": "Load Avg", + "net": "Net", + "temp": "Temp", + "battery": "Bat", + "services": "Services", + "uptime": "Uptime", + "agent": "Agent" + } + }, + "beszelAlerts": { + "name": "Beszel Alerts", + "description": "View Beszel alert configurations and history", + "option": { + "showHistory": { + "label": "Show alert history" + }, + "maxHistoryItems": { + "label": "Max history items" + } + }, + "empty": "No alerts configured", + "history": "History", + "resolved": "Resolved", + "status": { + "triggered": "Triggered", + "ok": "OK" + } + }, + "beszelSystemStats": { + "name": "Beszel System Stats", + "description": "Time-series charts for CPU, memory, disk, network, and Docker container metrics", + "option": { + "timePeriod": { + "label": "Time period" + }, + "showCpu": { + "label": "Show CPU chart" + }, + "showMemory": { + "label": "Show memory chart" + }, + "showDisk": { + "label": "Show disk chart" + }, + "showDiskIO": { + "label": "Show disk I/O chart" + }, + "showNetwork": { + "label": "Show network chart" + }, + "showDockerCpu": { + "label": "Show Docker CPU chart" + }, + "showDockerMemory": { + "label": "Show Docker memory chart" + }, + "showDockerNetwork": { + "label": "Show Docker network chart" + } + }, + "selectSystem": "Select system", + "chart": { + "cpu": { + "title": "CPU Usage", + "subtitle": "Average system-wide CPU utilization", + "series": "CPU" + }, + "memory": { + "title": "Memory Usage", + "subtitle": "Precise utilization at the recorded time", + "series": "Used", + "cache": "Cache/Buffers" + }, + "disk": { + "title": "Disk Usage", + "subtitle": "Usage of root partition", + "series": "Used" + }, + "diskIO": { + "title": "Disk I/O", + "subtitle": "Throughput of root filesystem", + "read": "Read", + "write": "Write" + }, + "network": { + "title": "Bandwidth", + "subtitle": "Network traffic of public interfaces", + "sent": "Sent", + "recv": "Received" + }, + "dockerCpu": { + "title": "Docker CPU Usage", + "subtitle": "Average CPU utilization of containers" + }, + "dockerMemory": { + "title": "Docker Memory Usage", + "subtitle": "Memory usage of docker containers" + }, + "dockerNetwork": { + "title": "Docker Network I/O", + "subtitle": "Network traffic of docker containers" + } + } } }, "widgetPreview": { diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 9c80d4bd8..37f36daf8 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -70,6 +70,8 @@ "@tiptap/starter-kit": "catalog:", "clsx": "catalog:", "dayjs": "catalog:", + "lucide-react": "catalog:", + "mantine-datatable": "catalog:", "mantine-react-table": "catalog:", "next": "catalog:", "react": "catalog:", diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx new file mode 100644 index 000000000..167b2ce1d --- /dev/null +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useMemo } from "react"; +import { Badge, Box, Divider, Group, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core"; +import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import type { LucideIcon } from "lucide-react"; +import { Activity, Battery, Cpu, HardDrive, MemoryStick, Monitor, Network, Server, Thermometer } from "lucide-react"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; + +const alertIconMap: Record = { + CPU: Cpu, + Memory: MemoryStick, + Disk: HardDrive, + Bandwidth: Network, + Temperature: Thermometer, + "Load Average": Activity, + LoadAvg1: Activity, + LoadAvg5: Activity, + LoadAvg15: Activity, + Status: Server, + GPU: Monitor, + Battery: Battery, +}; + +dayjs.extend(relativeTime); + +export default function BeszelAlertsWidget({ + options, + integrationIds, + isEditMode, +}: WidgetComponentProps<"beszelAlerts">) { + const t = useScopedI18n("widget.beszelAlerts"); + const [results] = clientApi.widget.beszel.getAlerts.useSuspenseQuery({ + integrationIds, + includeHistory: options.showHistory, + maxHistoryItems: options.maxHistoryItems, + }); + 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, + ); + }, + ); + }, + }, + ); + + const systemNameMap = useMemo(() => { + const map: Record = {}; + for (const r of results) { + if (r.systemNameMap) { + Object.assign(map, r.systemNameMap); + } + } + return map; + }, [results]); + + const alerts = useMemo( + () => results.flatMap((r) => r.alerts.map((a) => ({ ...a, _key: `${r.integrationId}:${a.id}` }))), + [results], + ); + + const history = useMemo(() => { + const all = results.flatMap((r) => r.history.map((h) => ({ ...h, _key: `${r.integrationId}:${h.id}` }))); + return all + .toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()) + .slice(0, options.maxHistoryItems); + }, [results, options.maxHistoryItems]); + + const triggeredAlerts = alerts.filter((a) => a.triggered); + const okAlerts = alerts.filter((a) => !a.triggered); + + return ( + + + {alerts.length === 0 && ( + + + + + + {t("empty")} + + + )} + + {triggeredAlerts.length > 0 && ( + + + + + {t("status.triggered")} ({triggeredAlerts.length}) + + + {triggeredAlerts.map((alert) => ( + + ))} + + )} + + {triggeredAlerts.length > 0 && okAlerts.length > 0 && } + + {okAlerts.length > 0 && ( + + + + + {t("status.ok")} ({okAlerts.length}) + + + {okAlerts.map((alert) => ( + + ))} + + )} + + {options.showHistory && history.length > 0 && ( + <> + + + + + + {t("history")} + + + {history.map((entry) => { + const systemName = systemNameMap[entry.system] ?? entry.system; + const isResolved = !!entry.resolved; + const HistoryIcon = alertIconMap[entry.name] ?? Server; + return ( + + + + + + + {entry.name} + + + {systemName} + + + + + + {isResolved ? t("resolved") : t("status.triggered")} + + + {dayjs(entry.created).fromNow()} + + + + ); + })} + + + )} + + + ); +} + +interface AlertRowProps { + name: string; + value: number; + min: number; + systemName: string; + triggered: boolean; +} + +const unitSuffixMap: Record = { + CPU: "%", + Memory: "%", + Disk: "%", + GPU: "%", + Battery: "%", + Temperature: "°", + Bandwidth: " MB/s", + "Load Average": "", + LoadAvg1: "", + LoadAvg5: "", + LoadAvg15: "", + Status: "", +}; + +function formatAlertDescription(name: string, value: number, min: number): string { + const suffix = unitSuffixMap[name] ?? ""; + if (name === "Status") return `down for ${min} min`; + return `exceeds ${value}${suffix} over ${min} min`; +} + +function AlertRow({ name, value, min, systemName, triggered }: AlertRowProps) { + const Icon = alertIconMap[name] ?? Server; + return ( + + + + + + {systemName} + + + · + + + {name} + + + + {formatAlertDescription(name, value, min)} + + + + ); +} diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts new file mode 100644 index 000000000..828c88b27 --- /dev/null +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -0,0 +1,17 @@ +import { IconBell } from "@tabler/icons-react"; +import { z } from "zod/v4"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", { + icon: IconBell, + supportedIntegrations: ["beszel", "mock"], + integrationsRequired: true, + createOptions() { + return optionsBuilder.from((factory) => ({ + showHistory: factory.switch({ defaultValue: true }), + maxHistoryItems: factory.number({ defaultValue: 10, validate: z.number().min(1).max(100) }), + })); + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx new file mode 100644 index 000000000..f0a7c1ee0 --- /dev/null +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core"; +import { + Activity, + Battery, + BatteryCharging, + Cpu, + HardDrive, + MemoryStick, + Monitor, + Network, + Server, + Thermometer, + Wifi, +} from "lucide-react"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import type { BeszelSystemRow } from "../beszel/_shared/types"; +import { statusColorMap, thresholdColor } from "../beszel/_shared/colors"; +import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; +import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks"; + +interface SizeConfig { + iconSize: number; + fontSize: string; + progressSize: "xs" | "sm"; + labelMiw: number; + valueMiw: number; + rowHeight: number; + cardPadding: number; + badgeHeight: number; + badgeSize: "xs" | "sm"; + gap: number; +} + +const defaultSizeConfig: SizeConfig = { + iconSize: 10, + fontSize: "10px", + progressSize: "xs", + labelMiw: 40, + valueMiw: 30, + rowHeight: 18, + cardPadding: 4, + badgeHeight: 20, + badgeSize: "xs", + gap: 2, +}; + +const sizeThresholds: [number, SizeConfig][] = [ + [0, defaultSizeConfig], + [ + 160, + { + iconSize: 12, + fontSize: "xs", + progressSize: "xs", + labelMiw: 50, + valueMiw: 34, + rowHeight: 22, + cardPadding: 6, + badgeHeight: 22, + badgeSize: "xs", + gap: 6, + }, + ], + [ + 280, + { + iconSize: 14, + fontSize: "xs", + progressSize: "sm", + labelMiw: 55, + valueMiw: 38, + rowHeight: 24, + cardPadding: 8, + badgeHeight: 26, + badgeSize: "sm", + gap: 8, + }, + ], +]; + +const getSizeConfig = (cellWidth: number, cellHeight: number): SizeConfig => { + const basis = Math.min(cellWidth, cellHeight); + let config = defaultSizeConfig; + for (const [threshold, cfg] of sizeThresholds) { + if (basis >= threshold) config = cfg; + } + return config; +}; + +const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => { + const available = cellHeight - size.cardPadding * 2 - size.badgeHeight - size.gap; + return Math.max(1, Math.floor(available / size.rowHeight)); +}; + +const MIN_CELL_WIDTH = 120; +const MIN_CELL_HEIGHT = 80; + +const getColCount = (width: number, itemCount: number): number => { + if (itemCount <= 1) return 1; + const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH))); + + let best = 1; + for (let c = 1; c <= maxCols; c++) { + const emptyCells = (c - (itemCount % c)) % c; + const bestEmpty = (best - (itemCount % best)) % best; + if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) { + best = c; + } + } + return best; +}; + +interface MetricRowProps { + icon: React.ReactNode; + label: string; + value: string; + progress?: { value: number; color: string }; + size: SizeConfig; +} + +const MetricValue = ({ value, progress, size }: Pick) => ( + + + {value} + + {progress && ( + + )} + +); + +const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => ( + + {icon} + + {label} + + + +); + +interface SystemCardProps { + system: BeszelSystemRow & { _key: string }; + options: WidgetComponentProps<"beszelSystemGrid">["options"]; + t: ReturnType>; + size: SizeConfig; + maxMetrics: number; +} + +const metricRenderers = [ + { + key: "showCpu", + render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( + } + label={t("metric.cpu")} + value={formatPercent(s.cpu)} + progress={{ value: s.cpu, color: thresholdColor(s.cpu) }} + size={sz} + /> + ), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showCpu, + }, + { + key: "showMemory", + render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( + } + label={t("metric.memory")} + value={formatPercent(s.memory)} + progress={{ value: s.memory, color: thresholdColor(s.memory) }} + size={sz} + /> + ), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showMemory, + }, + { + key: "showDisk", + render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( + } + label={t("metric.disk")} + value={formatPercent(s.disk)} + progress={{ value: s.disk, color: thresholdColor(s.disk) }} + size={sz} + /> + ), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk, + }, + { + key: "showGpu", + render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( + } + label={t("metric.gpu")} + value={formatPercent(s.gpu)} + progress={{ value: s.gpu, color: thresholdColor(s.gpu) }} + size={sz} + /> + ), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showGpu && s.gpu > 0, + }, + { + key: "showLoadAvg", + render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( + } + label={t("metric.loadAvg")} + value={formatLoadAvg(s.loadAvg)} + size={sz} + /> + ), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showLoadAvg && s.loadAvg !== null, + }, + { + key: "showNet", + render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( + } + label={t("metric.net")} + value={formatByteRate(s.netBytes)} + size={sz} + /> + ), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showNet, + }, + { + key: "showTemp", + render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( + } + label={t("metric.temp")} + value={formatTemp(s.temp, false)} + size={sz} + /> + ), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showTemp && s.temp !== null, + }, + { + key: "showBattery", + render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => { + const batteryIcons = [Battery, BatteryCharging] as const; + const Icon = batteryIcons[Math.min(1, s.battery?.[1] ?? 0)] ?? Battery; + return ( + } + label={t("metric.battery")} + value={`${s.battery?.[0] ?? 0}%`} + size={sz} + /> + ); + }, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showBattery && s.battery !== null, + }, + { + key: "showServices", + render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( + } + label={t("metric.services")} + value={String(s.services)} + size={sz} + /> + ), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showServices, + }, + { + key: "showUptime", + render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( + } + label={t("metric.uptime")} + value={formatUptime(s.uptime)} + size={sz} + /> + ), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showUptime, + }, + { + key: "showAgent", + render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( + } + label={t("metric.agent")} + value={s.agentVersion} + size={sz} + /> + ), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showAgent, + }, +] as const; + +const SystemCard = ({ system, options, t, size, maxMetrics }: SystemCardProps) => { + const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics); + + return ( + + + + {system.name} + + + + + {visibleMetrics.map((m) => m.render(system, t, size))} + + + ); +}; + +export default function BeszelSystemGridWidget({ + options, + integrationIds, + isEditMode, + width, + height, +}: WidgetComponentProps<"beszelSystemGrid">) { + const t = useScopedI18n("widget.beszelSystemGrid"); + const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds }); + + useBeszelSystemsSubscription(integrationIds, !isEditMode); + const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); + + const cols = getColCount(width, filteredSystems.length); + const rows = Math.ceil(filteredSystems.length / cols) || 1; + const rawCellHeight = height / rows; + const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT; + const effectiveCellHeight = scrollEnabled ? MIN_CELL_HEIGHT : rawCellHeight; + const cellWidth = width / cols; + const size = getSizeConfig(cellWidth, effectiveCellHeight); + const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size); + + return ( + + {filteredSystems.map((system) => ( + + ))} + + ); +} diff --git a/packages/widgets/src/beszel-system-grid/index.ts b/packages/widgets/src/beszel-system-grid/index.ts new file mode 100644 index 000000000..fba380645 --- /dev/null +++ b/packages/widgets/src/beszel-system-grid/index.ts @@ -0,0 +1,27 @@ +import { IconLayoutGrid } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { statusOptions } from "../beszel/_shared/options"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", { + icon: IconLayoutGrid, + supportedIntegrations: ["beszel", "mock"], + integrationsRequired: true, + createOptions() { + return optionsBuilder.from((factory) => ({ + statusFilter: factory.select({ defaultValue: "all", options: statusOptions }), + showCpu: factory.switch({ defaultValue: true }), + showMemory: factory.switch({ defaultValue: true }), + showDisk: factory.switch({ defaultValue: true }), + showGpu: factory.switch({ defaultValue: true }), + showLoadAvg: factory.switch({ defaultValue: true }), + showNet: factory.switch({ defaultValue: true }), + showTemp: factory.switch({ defaultValue: true }), + showBattery: factory.switch({ defaultValue: true }), + showServices: factory.switch({ defaultValue: true }), + showUptime: factory.switch({ defaultValue: true }), + showAgent: factory.switch({ defaultValue: true }), + })); + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx new file mode 100644 index 000000000..99ee0f732 --- /dev/null +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { ScrollArea, Select, SimpleGrid, Stack } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import { containerColors } from "../beszel/_shared/colors"; +import { + BeszelAreaChart, + ChartPanel, + useContainerNames, + useDockerChartData, + useSystemChartData, +} from "../beszel/_shared/chart"; +import { + chartAxisFormatters, + formatByteRate, + formatGB, + formatPercent, + formatStorageBytes, +} from "../beszel/_shared/format"; +import { makeTooltipProps } from "../beszel/_shared/tooltip"; + +const tooltipPercent = makeTooltipProps(formatPercent); +const tooltipGB = makeTooltipProps(formatGB, true); +const tooltipRate = makeTooltipProps(formatByteRate, true); +const tooltipPercentTotal = makeTooltipProps(formatPercent, true); +const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true); + +const CHART_HEIGHT = 180; + +export default function BeszelSystemStatsWidget({ + options, + integrationIds, + isEditMode, + width, +}: WidgetComponentProps<"beszelSystemStats">) { + const t = useScopedI18n("widget.beszelSystemStats"); + const [systemsResult] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds }); + + const systems = useMemo( + () => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))), + [systemsResult], + ); + + const [selectedSystem, setSelectedSystem] = useState(systems[0]?.value ?? null); + + const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork; + + const [statsResult] = clientApi.widget.beszel.getSystemStats.useSuspenseQuery({ + integrationIds, + systemId: selectedSystem ?? systems[0]?.value ?? "", + timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d", + includeDocker: showDocker, + }); + + // BeszelSystemStats fields are documented in beszel-types.ts. + // mu/du = bytes, dr/dw = bytes/s, b = public bandwidth [sent,recv] bytes/s, ns/nr = all-interface bytes/s + const BYTES_TO_GB = 1024 * 1024 * 1024; + + 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 / BYTES_TO_GB, + [t("chart.memory.cache")]: (s.mb ?? 0) / BYTES_TO_GB, + }), + [t], + ); + const diskMap = useCallback((s: { du: number }) => ({ [t("chart.disk.series")]: s.du / BYTES_TO_GB }), [t]); + const diskIOMap = useCallback( + (s: { dr?: number; dw?: number }) => ({ + [t("chart.diskIO.read")]: s.dr ?? 0, + [t("chart.diskIO.write")]: s.dw ?? 0, + }), + [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, + }), + [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 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 containerSeries = containerNames.map((name, i) => ({ + name, + color: containerColors[i % containerColors.length] as string, + })); + + const cols = width > 600 ? 2 : 1; + + return ( + + +