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/development/beszel.docker-compose.yml b/development/beszel.docker-compose.yml new file mode 100644 index 000000000..252bb35f5 --- /dev/null +++ b/development/beszel.docker-compose.yml @@ -0,0 +1,27 @@ +services: + beszel: + image: henrygd/beszel:latest + container_name: beszel-dev + restart: unless-stopped + environment: + APP_URL: http://localhost:8090 + ports: + - 8090:8090 + volumes: + - ./beszel_data:/beszel_data + - ./beszel_socket:/beszel_socket + + beszel-agent: + image: henrygd/beszel-agent:latest + container_name: beszel-agent-dev + restart: unless-stopped + network_mode: host + volumes: + - ./beszel_agent_data:/var/lib/beszel-agent + - ./beszel_socket:/beszel_socket + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + LISTEN: /beszel_socket/beszel.sock + HUB_URL: http://localhost:8090 + TOKEN: "${BESZEL_AGENT_TOKEN}" + KEY: "${BESZEL_AGENT_KEY}" diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 495d410c7..286b16b23 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -1604,22 +1604,24 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use collapsed: collapseStates.at(0)?.collapsed ?? false, }), ), - items: items.map(({ integrations: itemIntegrations, ...item }) => - parseItem({ - ...item, - layouts: item.layouts.map((layout) => ({ - xOffset: layout.xOffset, - yOffset: layout.yOffset, - width: layout.width, - height: layout.height, - layoutId: layout.layoutId, - sectionId: layout.sectionId, - })), - integrationIds: itemIntegrations.map((item) => item.integration.id), - advancedOptions: superjson.parse(item.advancedOptions), - options: superjson.parse>(item.options), - }), - ), + items: items + .map(({ integrations: itemIntegrations, ...item }) => + parseItem({ + ...item, + layouts: item.layouts.map((layout) => ({ + xOffset: layout.xOffset, + yOffset: layout.yOffset, + width: layout.width, + height: layout.height, + layoutId: layout.layoutId, + sectionId: layout.sectionId, + })), + integrationIds: itemIntegrations.map((item) => item.integration.id), + advancedOptions: superjson.parse(item.advancedOptions), + options: superjson.parse>(item.options), + }), + ) + .filter((item) => item !== null), }; }; @@ -1635,7 +1637,7 @@ const parseItem = (item: unknown) => { const result = outputItemSchema.safeParse(item); if (!result.success) { - throw new Error(result.error.message); + return null; } return result.data; }; diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts new file mode 100644 index 000000000..e137dd069 --- /dev/null +++ b/packages/api/src/router/widgets/beszel.ts @@ -0,0 +1,137 @@ +import { observable } from "@trpc/server/observable"; +import { z } from "zod/v4"; + +import { + beszelAlertsRequestHandler, + beszelStatsRequestHandler, + beszelSystemsRequestHandler, + type BeszelAlertsData, + type BeszelStatsData, + type 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")) + .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")) + .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")) + .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")) + .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")) + .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/definitions/src/integration.ts b/packages/definitions/src/integration.ts index f43d821bf..9cb85329b 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..aee441797 --- /dev/null +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -0,0 +1,296 @@ +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, "\\'"); + +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..ed77f3fec --- /dev/null +++ b/packages/integrations/src/beszel/beszel-types.ts @@ -0,0 +1,244 @@ +export interface BeszelAuthResponse { + token: string; + record: { + id: string; + email: string; + username: string; + verified: boolean; + }; +} + +export type BeszelSystemStatus = "up" | "down" | "paused" | "pending"; + +export interface BeszelSystemInfo { + h?: string; + cpu: number; + t?: number; + c?: number; + ct?: number; + m?: string; + la?: [number, number, number]; + o?: string; + u: number; + mp: number; + dp: number; + bat?: [number, number]; + b?: number; + bb?: number; + v: string; + p?: boolean; + g?: number; + dt?: number; + os?: number; + efs?: Record; + 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; +} + +export interface BeszelSystemStats { + cpu: number; + cpum?: number; + cpub?: number[]; + cpus?: number[]; + la?: [number, number, number]; + m: number; + mu: number; + mp: number; + mb: number; + mm?: number; + mz?: number; + s: number; + su: number; + d: number; + du: number; + dp: number; + dr: number; + dw: number; + drm?: number; + dwm?: number; + dio?: [number, number]; + diom?: [number, number]; + ns: number; + nr: number; + b?: [number, number]; + nsm?: number; + nrm?: number; + bm?: [number, number]; + t?: Record; + efs?: Record; + g?: Record; + bat?: [number, number]; + ni?: Record; +} + +export interface BeszelExtraFsStats { + d: number; + du: number; + r: number; + w: number; + rm: number; + wm: number; + rb: number; + wb: number; + rbm: number; + wbm: number; +} + +export interface BeszelGPUData { + n: string; + mu?: number; + mt?: number; + u: number; + p?: number; + pp?: number; + 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; +} + +export interface BeszelContainerStats { + n: string; + c: number; + m: number; + ns?: number; + nr?: number; + 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 { + id?: number; + n: string; + v: number; + w?: number; + t?: number; + rv?: number; + rs?: string; + 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[]; +} 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..2320b214b --- /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"; +}); + +import type { BeszelSystem } from "../beszel-types"; + +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]!; + 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/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..9af2a9a53 --- /dev/null +++ b/packages/request-handler/src/beszel.ts @@ -0,0 +1,134 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { + BeszelAlert, + BeszelAlertHistory, + BeszelContainerStatsRecord, + BeszelSystem, + BeszelSystemDetails, + BeszelSystemStatsRecord, +} from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export interface BeszelSystemRow { + id: string; + name: string; + status: "up" | "down" | "paused" | "pending"; + cpu: number; + memory: number; + disk: number; + gpu: number; + loadAvg: [number, number, number] | null; + netBytes: number; + temp: number | null; + battery: [number, number] | null; + services: number; + uptime: number; + agentVersion: string; + hostname: string; + cpuModel: string; + cores: number; + memoryTotal: number; + osName: string; +} + +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, + 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", + 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", + { 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", + { systemId: string; timePeriod: string; includeDocker: boolean } +>({ + async requestAsync(integration, input) { + const config = timePeriodConfig[input.timePeriod] ?? timePeriodConfig["1h"]!; + 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 2a2de758b..a58d85b93 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3101,6 +3101,217 @@ "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", + "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" + }, + "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/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx new file mode 100644 index 000000000..e265a6286 --- /dev/null +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useMemo } from "react"; +import { Badge, Card, Group, Indicator, ScrollArea, Stack, Text } from "@mantine/core"; +import { IconAlertTriangle, IconBell, IconCheck } from "@tabler/icons-react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; + +dayjs.extend(relativeTime); + +const alertColorMap: Record = { + triggered: "red", + ok: "green", +}; + +export default function BeszelAlertsWidget({ + options, + integrationIds, +}: 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 }, + { + 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.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()).slice(0, options.maxHistoryItems); + }, [results, options.maxHistoryItems]); + + return ( + + + {alerts.length === 0 && ( + + + {t("empty")} + + )} + + {alerts.map((alert) => { + const state = alert.triggered ? "triggered" : "ok"; + const systemName = systemNameMap[alert.system] ?? alert.system; + return ( + + + + + {alert.triggered ? : } + + + {alert.name} > {alert.value} + {systemName} + + + + {t(`status.${state}`)} + + + + ); + })} + + {options.showHistory && history.length > 0 && ( + <> + {t("history")} + {history.map((entry) => { + const systemName = systemNameMap[entry.system] ?? entry.system; + return ( + + + + {entry.name} + {systemName} + + {dayjs(entry.created).fromNow()} + + + ); + })} + + )} + + + ); +} diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts new file mode 100644 index 000000000..9052d83ba --- /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"], + 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..ccf82da9a --- /dev/null +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useMemo } from "react"; +import { Badge, Card, Group, Progress, ScrollArea, SimpleGrid, 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, loadAvgColor } from "../beszel/_shared/colors"; +import { formatBytes, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; + +const ICON_SIZE = 14; + +interface MetricRowProps { + icon: React.ReactNode; + label: string; + value: string; + progress?: { value: number; color: string }; +} + +const MetricRow = ({ icon, label, value, progress }: MetricRowProps) => ( + + {icon} + + {label} + + {progress ? ( + + + {value} + + + + ) : ( + + {value} + + )} + +); + +const SystemCard = ({ system, options, t }: { system: BeszelSystemRow & { _key: string }; options: WidgetComponentProps<"beszelSystemGrid">["options"]; t: ReturnType> }) => ( + + + + {system.name} + + + + + {options.showCpu && ( + } + label={t("metric.cpu")} + value={formatPercent(system.cpu)} + progress={{ value: system.cpu, color: thresholdColor(system.cpu) }} + /> + )} + {options.showMemory && ( + } + label={t("metric.memory")} + value={formatPercent(system.memory)} + progress={{ value: system.memory, color: thresholdColor(system.memory) }} + /> + )} + {options.showDisk && ( + } + label={t("metric.disk")} + value={formatPercent(system.disk)} + progress={{ value: system.disk, color: thresholdColor(system.disk) }} + /> + )} + {options.showGpu && system.gpu > 0 && ( + } + label={t("metric.gpu")} + value={formatPercent(system.gpu)} + progress={{ value: system.gpu, color: thresholdColor(system.gpu) }} + /> + )} + {options.showLoadAvg && system.loadAvg && ( + } + label={t("metric.loadAvg")} + value={formatLoadAvg(system.loadAvg)} + /> + )} + {options.showNet && ( + } + label={t("metric.net")} + value={formatBytes(system.netBytes)} + /> + )} + {options.showTemp && system.temp !== null && ( + } + label={t("metric.temp")} + value={formatTemp(system.temp, false)} + /> + )} + {options.showBattery && system.battery && ( + 0 ? : } + label={t("metric.battery")} + value={`${system.battery[0]}%`} + /> + )} + {options.showServices && ( + } + label={t("metric.services")} + value={String(system.services)} + /> + )} + {options.showUptime && ( + } + label={t("metric.uptime")} + value={formatUptime(system.uptime)} + /> + )} + {options.showAgent && ( + } + label={t("metric.agent")} + value={system.agentVersion} + /> + )} + + +); + +export default function BeszelSystemGridWidget({ + options, + integrationIds, + width, +}: WidgetComponentProps<"beszelSystemGrid">) { + const t = useScopedI18n("widget.beszelSystemGrid"); + const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds }); + const utils = clientApi.useUtils(); + + clientApi.widget.beszel.subscribeSystems.useSubscription( + { integrationIds }, + { + onData(data) { + utils.widget.beszel.getSystems.setData({ integrationIds }, (prev) => { + if (!prev) return prev; + return prev.map((r) => + r.integrationId === data.integrationId ? { ...r, systems: data.systems, updatedAt: data.timestamp } : r, + ); + }); + }, + }, + ); + + const allSystems = useMemo( + () => results.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))), + [results], + ); + + const filteredSystems = useMemo(() => { + if (options.statusFilter === "all") return allSystems; + return allSystems.filter((s) => s.status === options.statusFilter); + }, [allSystems, options.statusFilter]); + + const getColCount = (w: number) => { + if (w > 1200) return 4; + if (w > 800) return 3; + if (w > 500) return 2; + return 1; + }; + + 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..f5fab8a5a --- /dev/null +++ b/packages/widgets/src/beszel-system-grid/index.ts @@ -0,0 +1,35 @@ +import { IconLayoutGrid } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +const statusOptions = [ + { value: "all", label: "All Systems" }, + { value: "up", label: "Up" }, + { value: "down", label: "Down" }, + { value: "paused", label: "Paused" }, +]; + +export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", { + icon: IconLayoutGrid, + supportedIntegrations: ["beszel"], + 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..67ed9db40 --- /dev/null +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -0,0 +1,361 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Group, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core"; +import { AreaChart } from "@mantine/charts"; +import dayjs from "dayjs"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; + +const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm"); +const formatBytes = (bytes: number) => { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +}; +const formatBytesPerSec = (bytes: number) => `${formatBytes(bytes)}/s`; +const formatPercent = (v: number) => `${v.toFixed(1)}%`; +const formatGB = (v: number) => `${v.toFixed(2)} GB`; +const formatMB = (v: number) => `${v.toFixed(1)} MB`; +const formatMBs = (v: number) => `${v.toFixed(2)} MB/s`; + +const compactTooltipProps = { + wrapperStyle: { zIndex: 1000, pointerEvents: "none" as const }, + contentStyle: { padding: "4px 8px", fontSize: 11, lineHeight: 1.3 }, + labelStyle: { fontSize: 11, marginBottom: 2 }, + itemStyle: { padding: 0, fontSize: 11 }, +}; + +interface ChartPanelProps { + title: string; + subtitle?: string; + children: React.ReactNode; +} + +const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => ( + + + {title} + {subtitle && {subtitle}} + + {children} + +); + +export default function BeszelSystemStatsWidget({ + options, + integrationIds, + 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 [statsResult] = clientApi.widget.beszel.getSystemStats.useSuspenseQuery({ + integrationIds, + systemId: selectedSystem ?? systems[0]?.value ?? "", + timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d", + includeDocker: options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork, + }); + + const chartHeight = 180; + const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork; + + const cpuData = useMemo(() => { + if (!statsResult?.systemStats) return []; + return [...statsResult.systemStats].reverse().map((r) => ({ + time: formatTime(r.created), + [t("chart.cpu.series")]: r.stats.cpu, + })); + }, [statsResult, t]); + + const memoryData = useMemo(() => { + if (!statsResult?.systemStats) return []; + return [...statsResult.systemStats].reverse().map((r) => ({ + time: formatTime(r.created), + [t("chart.memory.series")]: r.stats.mu / (1024 * 1024 * 1024), + })); + }, [statsResult, t]); + + const diskData = useMemo(() => { + if (!statsResult?.systemStats) return []; + return [...statsResult.systemStats].reverse().map((r) => ({ + time: formatTime(r.created), + [t("chart.disk.series")]: r.stats.du / (1024 * 1024 * 1024), + })); + }, [statsResult, t]); + + const diskIOData = useMemo(() => { + if (!statsResult?.systemStats) return []; + return [...statsResult.systemStats].reverse().map((r) => ({ + time: formatTime(r.created), + [t("chart.diskIO.read")]: r.stats.dr / (1024 * 1024), + [t("chart.diskIO.write")]: r.stats.dw / (1024 * 1024), + })); + }, [statsResult, t]); + + const networkData = useMemo(() => { + if (!statsResult?.systemStats) return []; + return [...statsResult.systemStats].reverse().map((r) => ({ + time: formatTime(r.created), + [t("chart.network.sent")]: r.stats.ns, + [t("chart.network.recv")]: r.stats.nr, + })); + }, [statsResult, t]); + + const containerNames = useMemo(() => { + if (!statsResult?.containerStats?.length) return []; + const names = new Set(); + for (const record of statsResult.containerStats) { + for (const c of record.stats) { + names.add(c.n); + } + } + return [...names].slice(0, 15); + }, [statsResult]); + + const dockerCpuData = useMemo(() => { + if (!statsResult?.containerStats?.length) return []; + return [...statsResult.containerStats].reverse().map((record) => { + const point: Record = { time: formatTime(record.created) }; + for (const name of containerNames) { + const container = record.stats.find((c) => c.n === name); + point[name] = container?.c ?? 0; + } + return point; + }); + }, [statsResult, containerNames]); + + const dockerMemData = useMemo(() => { + if (!statsResult?.containerStats?.length) return []; + return [...statsResult.containerStats].reverse().map((record) => { + const point: Record = { time: formatTime(record.created) }; + for (const name of containerNames) { + const container = record.stats.find((c) => c.n === name); + point[name] = (container?.m ?? 0) / (1024 * 1024); + } + return point; + }); + }, [statsResult, containerNames]); + + const dockerNetData = useMemo(() => { + if (!statsResult?.containerStats?.length) return []; + return [...statsResult.containerStats].reverse().map((record) => { + const point: Record = { time: formatTime(record.created) }; + for (const name of containerNames) { + const container = record.stats.find((c) => c.n === name); + const netTotal = (container?.ns ?? 0) + (container?.nr ?? 0); + point[name] = netTotal / 1024; + } + return point; + }); + }, [statsResult, containerNames]); + + const containerColors = [ + "blue.6", "teal.6", "orange.6", "grape.6", "cyan.6", + "pink.6", "lime.6", "violet.6", "indigo.6", "yellow.6", + "red.6", "green.6", "blue.4", "teal.4", "orange.4", + ]; + + const containerSeries = containerNames.map((name, i) => ({ + name, + color: containerColors[i % containerColors.length]!, + })); + + const cols = width > 600 ? 2 : 1; + + return ( + + +