From ae294b9d88700fe57e8a945b0856303525e6407c Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 8 Jun 2026 12:39:31 +0200 Subject: [PATCH] feat(integrations): add beszel system usage integration --- packages/api/src/middlewares/integration.ts | 14 +- packages/api/src/router/widgets/index.ts | 1 + .../api/src/router/widgets/system-usage.ts | 49 ++++ packages/cron-jobs/src/index.ts | 2 + .../src/jobs/integrations/system-usage.ts | 20 ++ .../src/docs/homarr-docs-sitemap.ts | 1 + packages/definitions/src/integration.ts | 9 + packages/definitions/src/widget.ts | 1 + packages/integrations/src/base/creator.ts | 2 + .../src/beszel/beszel-integration.ts | 143 +++++++++++ .../system-usage/system-usage-integration.ts | 6 + .../system-usage/system-usage-types.ts | 31 +++ .../src/mock/data/system-usage.ts | 38 +++ .../integrations/src/mock/mock-integration.ts | 8 + packages/integrations/src/types.ts | 1 + packages/integrations/test/beszel.spec.ts | 176 ++++++++++++++ .../integrations/test/volumes/beszel/data.db | Bin 0 -> 4096 bytes .../test/volumes/beszel/data.db-shm | Bin 0 -> 32768 bytes .../test/volumes/beszel/data.db-wal | Bin 0 -> 531512 bytes .../test/volumes/beszel/id_ed25519 | 7 + packages/request-handler/src/system-usage.ts | 20 ++ packages/translation/src/lang/en.json | 48 +++- packages/ui/src/icons/index.ts | 42 ++++ packages/widgets/src/index.tsx | 2 + .../widgets/src/system-usage/component.tsx | 228 ++++++++++++++++++ .../errors/no-system-selected-error.ts | 19 ++ packages/widgets/src/system-usage/index.ts | 43 ++++ packages/widgets/src/system-usage/item.tsx | 46 ++++ 28 files changed, 955 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/router/widgets/system-usage.ts create mode 100644 packages/cron-jobs/src/jobs/integrations/system-usage.ts create mode 100644 packages/integrations/src/beszel/beszel-integration.ts create mode 100644 packages/integrations/src/interfaces/system-usage/system-usage-integration.ts create mode 100644 packages/integrations/src/interfaces/system-usage/system-usage-types.ts create mode 100644 packages/integrations/src/mock/data/system-usage.ts create mode 100644 packages/integrations/test/beszel.spec.ts create mode 100644 packages/integrations/test/volumes/beszel/data.db create mode 100644 packages/integrations/test/volumes/beszel/data.db-shm create mode 100644 packages/integrations/test/volumes/beszel/data.db-wal create mode 100644 packages/integrations/test/volumes/beszel/id_ed25519 create mode 100644 packages/request-handler/src/system-usage.ts create mode 100644 packages/widgets/src/system-usage/component.tsx create mode 100644 packages/widgets/src/system-usage/errors/no-system-selected-error.ts create mode 100644 packages/widgets/src/system-usage/index.ts create mode 100644 packages/widgets/src/system-usage/item.tsx diff --git a/packages/api/src/middlewares/integration.ts b/packages/api/src/middlewares/integration.ts index 320cc7f66..2ade65537 100644 --- a/packages/api/src/middlewares/integration.ts +++ b/packages/api/src/middlewares/integration.ts @@ -13,7 +13,7 @@ import type { IntegrationKind } from "@homarr/definitions"; import { publicProcedure } from "../trpc"; -export type IntegrationAction = "query" | "interact"; +export type IntegrationAction = "query" | "interact" | "use"; /** * Creates a middleware that provides the integration in the context that is of the specified kinds @@ -167,6 +167,18 @@ const throwIfActionIsNotAllowedAsync = async ( }); } + if (action === "use") { + const haveAllUseAccess = integrations + .map((integration) => constructIntegrationPermissions(integration, session)) + .every(({ hasUseAccess }) => hasUseAccess); + if (haveAllUseAccess) return; + + throw new TRPCError({ + code: "FORBIDDEN", + message: "User does not have permission to use at least one of the specified integrations", + }); + } + const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session); if (hasQueryAccess) return; diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 15f0963b4..38688e626 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -32,4 +32,5 @@ export const widgetRouter = createTRPCRouter({ speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)), umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)), ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)), + systemUsage: lazy(() => import("./system-usage").then((mod) => mod.systemUsageRouter)), }); diff --git a/packages/api/src/router/widgets/system-usage.ts b/packages/api/src/router/widgets/system-usage.ts new file mode 100644 index 000000000..d7695d0be --- /dev/null +++ b/packages/api/src/router/widgets/system-usage.ts @@ -0,0 +1,49 @@ +import { observable } from "@trpc/server/observable"; +import z from "zod"; + +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { createIntegrationAsync } from "@homarr/integrations"; +import type { System } from "@homarr/integrations/types"; +import { systemUsageRequestHandler } from "@homarr/request-handler/system-usage"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; + +const supportedIntegrations = getIntegrationKindsByCategory("systemUsage"); + +export const systemUsageRouter = createTRPCRouter({ + listSystems: protectedProcedure + .concat(createOneIntegrationMiddleware("use", ...supportedIntegrations)) + .query(async ({ ctx: { integration } }) => { + const instance = await createIntegrationAsync(integration); + + return await instance.getSystemsAsync(); + }), + get: publicProcedure + .input(z.object({ systemId: z.string() })) + .concat(createOneIntegrationMiddleware("query", ...supportedIntegrations)) + .query(async ({ ctx: { integration }, input }) => { + const innerHandler = systemUsageRequestHandler.handler(integration, { systemId: input.systemId }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data; + }), + subscribe: publicProcedure + .concat(createOneIntegrationMiddleware("query", ...supportedIntegrations)) + .input(z.object({ systemId: z.string() })) + .subscription(({ input, ctx }) => { + return observable<{ + system: System; + }>((emit) => { + const innerHandler = systemUsageRequestHandler.handler(ctx.integration, { + systemId: input.systemId, + }); + const unsubscribe = innerHandler.subscribe((system) => { + emit.next({ system }); + }); + + return () => { + unsubscribe(); + }; + }); + }), +}); diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index 576e001c9..d245283d2 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -18,6 +18,7 @@ import { mediaServerJob } from "./jobs/integrations/media-server"; import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding"; import { networkControllerJob } from "./jobs/integrations/network-controller"; import { refreshNotificationsJob } from "./jobs/integrations/notifications"; +import { systemUsageJob } from "./jobs/integrations/system-usage"; import { timetableJob } from "./jobs/integrations/timetable"; import { tracearrJob } from "./jobs/integrations/tracearr"; import { umamiActiveVisitorsJob } from "./jobs/integrations/umami"; @@ -55,6 +56,7 @@ const getJobGroup = () => { refreshNotifications: refreshNotificationsJob, weather: weatherJob, timetable: timetableJob, + systemUsage: systemUsageJob, tracearr: tracearrJob, umamiActiveVisitors: umamiActiveVisitorsJob, }); diff --git a/packages/cron-jobs/src/jobs/integrations/system-usage.ts b/packages/cron-jobs/src/jobs/integrations/system-usage.ts new file mode 100644 index 000000000..91c4bed72 --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/system-usage.ts @@ -0,0 +1,20 @@ +import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; +import { systemUsageRequestHandler } from "@homarr/request-handler/system-usage"; + +import { createCronJob } from "../../lib"; + +export const systemUsageJob = createCronJob("systemUsage", EVERY_MINUTE).withCallback( + createRequestIntegrationJobHandler(systemUsageRequestHandler.handler, { + widgetKinds: ["systemUsage"], + getInput: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + systemUsage: (options) => + options.systemId + ? { + systemId: options.systemId.value, + } + : [], + }, + }), +); diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 348bbf78d..9c2f17bcc 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -156,6 +156,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/adguard-home" | "/docs/integrations/anchor" | "/docs/integrations/aria2" + | "/docs/integrations/beszel" | "/docs/integrations/codeberg" | "/docs/integrations/coolify" | "/docs/integrations/dash-dot" diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index f43d821bf..95c6cf44f 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -370,6 +370,13 @@ export const integrationDefs = { documentationUrl: createDocumentationLink("/docs/integrations/truenas"), defaultPort: 80, }, + beszel: { + name: "Beszel", + secretKinds: [["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/beszel.svg", + category: ["systemUsage"], + documentationUrl: createDocumentationLink("/docs/integrations/beszel"), + }, unraid: { name: "Unraid", secretKinds: [["apiKey"]], @@ -451,6 +458,7 @@ export const integrationDefs = { "networkController", "notifications", "smartHomeServer", + "systemUsage", "ups", ], documentationUrl: null, @@ -539,6 +547,7 @@ export const integrationCategories = [ "mediaMonitoring", "speedtest", "analytics", + "systemUsage", "ups", ] as const; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index fec9eb719..442633f1c 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -30,6 +30,7 @@ export const widgetKinds = [ "firewall", "notifications", "systemResources", + "systemUsage", "coolify", "systemDisks", "timetable", diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 20688b708..5fbcc60bb 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -2,6 +2,7 @@ import type { IntegrationKind } from "@homarr/definitions"; import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; import { AnchorIntegration } from "../anchor/anchor-integration"; +import { BeszelIntegration } from "../beszel/beszel-integration"; import { CodebergIntegration } from "../codeberg/codeberg-integration"; import { CoolifyIntegration } from "../coolify/coolify-integration"; import { DashDotIntegration } from "../dashdot/dashdot-integration"; @@ -116,6 +117,7 @@ export const integrationCreators = { ntfy: NTFYIntegration, mock: MockIntegration, truenas: TrueNasIntegration, + beszel: BeszelIntegration, unraid: UnraidIntegration, coolify: CoolifyIntegration, tracearr: TracearrIntegration, diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts new file mode 100644 index 000000000..02727b887 --- /dev/null +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -0,0 +1,143 @@ +import type { fetch as undiciFetch } from "undici"; +import z from "zod"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { ResponseError } from "@homarr/common/server"; + +import { HandleIntegrationErrors } from "../base/errors/decorator"; +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { ISystemUsageIntegration } from "../interfaces/system-usage/system-usage-integration"; +import type { AgentConnectionType, System, SystemLoadStatus } from "../interfaces/system-usage/system-usage-types"; + +@HandleIntegrationErrors([]) +export class BeszelIntegration extends Integration implements ISystemUsageIntegration { + protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise { + await this.authenticateAsync(fetchAsync); + return { success: true as const }; + } + + public async getSystemsAsync() { + const token = await this.authenticateAsync(); + const response = await this.fetchApi("/api/collections/systems/records?perPage=500", token); + const data = z.object({ items: z.array(systemSchema) }).parse(response); + return data.items.map((system) => ({ + id: system.id, + name: system.name, + })); + } + + public async getSystemDetailsAsync(id: string): Promise { + const token = await this.authenticateAsync(); + const response = await this.fetchApi(`/api/collections/systems/records/${id}`, token); + const system = systemSchema.parse(response); + + return { + id: system.id, + name: system.name, + status: system.status, + agent: { + connectionType: system.info.ct !== undefined ? (connectionTypeByCode[system.info.ct] ?? null) : null, + version: system.info.v, + }, + usage: { + cpuPercentage: system.info.cpu, + memoryPercentage: system.info.mp, + diskPercentage: system.info.dp, + gpuPercentage: system.info.g ?? null, + load: { + status: this.calculateLoadStatus(system.status, system.info.la, system.info.t), + averages: { + one: system.info.la[0], + five: system.info.la[1], + fifteen: system.info.la[2], + }, + }, + networkBytes: system.info.bb, + temperature: system.info.dt ?? null, + }, + }; + } + + private calculateLoadStatus( + systemStatus: BeszelSystem["status"], + loadAverages: [number, number, number], + threatCount: number | undefined, + ): SystemLoadStatus { + if (systemStatus !== "up") return "unknown"; + + const maxLoad = Math.max(...loadAverages); + const loadPerThread = maxLoad / (threatCount ?? 1); + const meter = loadPerThread * 100; + if (meter >= 90) return "critical"; + if (meter >= 65) return "warning"; + return "good"; + } + + private async fetchApi( + path: string, + token: string, + customFetch: typeof undiciFetch = fetchWithTrustedCertificatesAsync, + ) { + const response = await customFetch(this.url(path).toString(), { + headers: { Authorization: token }, + }); + if (!response.ok) { + throw new ResponseError(response); + } + return await response.json(); + } + + private async authenticateAsync(customFetch: typeof undiciFetch = fetchWithTrustedCertificatesAsync) { + const response = await customFetch(this.url("/api/collections/users/auth-with-password").toString(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identity: this.getSecretValue("username"), + password: this.getSecretValue("password"), + }), + }); + if (!response.ok) { + // PocketBase returns 400 for failed auth — remap to 401 for proper authorization error classification + const status = response.status === 400 ? 401 : response.status; + throw new ResponseError({ status, url: response.url }); + } + const data = authResponseSchema.parse(await response.json()); + return data.token; + } +} + +const connectionTypeByCode: Record = { + 1: "ssh", + 2: "webSocket", +}; + +const authResponseSchema = z.object({ + token: z.string(), +}); + +const systemStatus = ["up", "down", "paused", "pending"] as const; + +/** + * See https://github.com/henrygd/beszel/blob/cca7b360395e3e7e4ef870005efddc3bd75b86c4/internal/entities/system/system.go#L104 + */ +const systemSchema = z.object({ + id: z.string(), + info: z.object({ + cpu: z.number(), + mp: z.number(), + dp: z.number(), + g: z.number().optional(), + la: z.tuple([z.number(), z.number(), z.number()]), + bb: z.number(), + dt: z.number().optional(), + v: z.string(), + ct: z.number().optional(), + t: z.number().optional(), + }), + name: z.string(), + status: z.enum(systemStatus), +}); + +type BeszelSystem = z.infer; diff --git a/packages/integrations/src/interfaces/system-usage/system-usage-integration.ts b/packages/integrations/src/interfaces/system-usage/system-usage-integration.ts new file mode 100644 index 000000000..ecedc2f6a --- /dev/null +++ b/packages/integrations/src/interfaces/system-usage/system-usage-integration.ts @@ -0,0 +1,6 @@ +import type { System } from "./system-usage-types"; + +export interface ISystemUsageIntegration { + getSystemsAsync(): Promise[]>; + getSystemDetailsAsync(id: System["id"]): Promise; +} diff --git a/packages/integrations/src/interfaces/system-usage/system-usage-types.ts b/packages/integrations/src/interfaces/system-usage/system-usage-types.ts new file mode 100644 index 000000000..225ce1487 --- /dev/null +++ b/packages/integrations/src/interfaces/system-usage/system-usage-types.ts @@ -0,0 +1,31 @@ +export type SystemLoadStatus = "good" | "warning" | "critical" | "unknown"; +export type SystemStatus = "up" | "down" | "paused" | "pending"; +export type AgentConnectionType = "ssh" | "webSocket"; + +export interface System { + id: string; + name: string; + status: SystemStatus; + usage: SystemUsage; + agent: { + connectionType: AgentConnectionType | null; + version: string; + }; +} + +export interface SystemUsage { + cpuPercentage: number; + memoryPercentage: number; + diskPercentage: number; + gpuPercentage: number | null; + load: { + status: SystemLoadStatus; + averages: { + one: number; + five: number; + fifteen: number; + }; + }; + networkBytes: number; + temperature: number | null; +} diff --git a/packages/integrations/src/mock/data/system-usage.ts b/packages/integrations/src/mock/data/system-usage.ts new file mode 100644 index 000000000..d1cb5ae68 --- /dev/null +++ b/packages/integrations/src/mock/data/system-usage.ts @@ -0,0 +1,38 @@ +import type { ISystemUsageIntegration } from "../../interfaces/system-usage/system-usage-integration"; +import type { System } from "../../types"; + +export class SystemUsageMockService implements ISystemUsageIntegration { + async getSystemsAsync(): Promise[]> { + return await Promise.resolve([ + { id: "1", name: "Mock System 1" }, + { id: "2", name: "Mock System 2" }, + ]); + } + async getSystemDetailsAsync(id: System["id"]): Promise { + return await Promise.resolve({ + id, + name: `Mock System ${id}`, + status: "up", + agent: { + connectionType: "webSocket", + version: "1.0.0", + }, + usage: { + cpuPercentage: 25, + memoryPercentage: 50, + diskPercentage: 75, + gpuPercentage: null, + load: { + status: "good", + averages: { + one: 0.5, + five: 0.7, + fifteen: 0.9, + }, + }, + networkBytes: Math.floor(2 ** 24.5), + temperature: 55, + }, + }); + } +} diff --git a/packages/integrations/src/mock/mock-integration.ts b/packages/integrations/src/mock/mock-integration.ts index b435dbd32..ab8b67389 100644 --- a/packages/integrations/src/mock/mock-integration.ts +++ b/packages/integrations/src/mock/mock-integration.ts @@ -15,6 +15,7 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration"; import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration"; import type { ISmartHomeIntegration } from "../interfaces/smart-home/smart-home-integration"; +import type { ISystemUsageIntegration } from "../interfaces/system-usage/system-usage-integration"; import type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration"; import { CalendarMockService } from "./data/calendar"; import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monitoring"; @@ -29,6 +30,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 { SystemUsageMockService } from "./data/system-usage"; import { UpsSummaryMockService } from "./data/ups-summary"; export class MockIntegration @@ -46,6 +48,7 @@ export class MockIntegration IMediaTranscodingIntegration, NetworkControllerSummaryIntegration, ISmartHomeIntegration, + ISystemUsageIntegration, IUpsSummaryIntegration { private static readonly dnsHole = new DnsHoleMockService(); @@ -61,6 +64,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 systemUsage = new SystemUsageMockService(); private static readonly upsSummary = new UpsSummaryMockService(); protected async testingAsync(_: IntegrationTestingInput): Promise { @@ -130,4 +134,8 @@ export class MockIntegration // UpsSummaryIntegration getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary); + + // SystemUsageIntegration + getSystemsAsync = MockIntegration.systemUsage.getSystemsAsync.bind(MockIntegration.systemUsage); + getSystemDetailsAsync = MockIntegration.systemUsage.getSystemDetailsAsync.bind(MockIntegration.systemUsage); } diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index fcb8a77ae..25dbf0f25 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 "./interfaces/system-usage/system-usage-types"; diff --git a/packages/integrations/test/beszel.spec.ts b/packages/integrations/test/beszel.spec.ts new file mode 100644 index 000000000..5fc62fcbe --- /dev/null +++ b/packages/integrations/test/beszel.spec.ts @@ -0,0 +1,176 @@ +import { rmdir } from "fs/promises"; +import { join } from "path"; +import type { StartedTestContainer } from "testcontainers"; +import { GenericContainer, Wait } from "testcontainers"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; + +import { createId } from "@homarr/common"; +import { createDb } from "@homarr/db/test"; + +import { BeszelIntegration } from "../src/beszel/beszel-integration"; + +vi.mock("@homarr/db", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + db: createDb(), + }; +}); + +const email = "e2e@homarr.dev"; +const password = "12341234"; +const publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID5PFut0emFoQQ53LlLGfFYA/U1Noe6WPqYN/97LJTvR"; +const token = "4af8b512-afdc-4e42-a478-d0a3bfbbb4a8"; +const IMAGE_NAME = "henrygd/beszel:latest"; +const AGENT_IMAGE_NAME = "henrygd/beszel-agent:latest"; +const systemId = "nj259tge7e4h7xy"; + +describe("Beszel integration", () => { + let socketPathId: string; + let beszelContainer: StartedTestContainer; + let beszelAgentContainer: StartedTestContainer; + + beforeAll(async () => { + socketPathId = createId(); + beszelContainer = await createBeszelContainer(socketPathId).start(); + beszelAgentContainer = await createBeszelAgentContainer(socketPathId, beszelContainer.getMappedPort(8090)).start(); + + await new Promise((resolve) => setTimeout(resolve, 5_000)); // Wait for Beszel to be ready and have some system_data + }, 20_000); + + afterAll(async () => { + await beszelContainer.stop(); + await beszelAgentContainer.stop(); + await rmdir(createSocketPath(socketPathId), { recursive: true }); + }, 5_000); + + test("Test connection is successful", async () => { + // Arrange + const integration = createIntegration(beszelContainer.getMappedPort(8090)); + + // Act + const result = await integration.testConnectionAsync(); + + // Assert + expect(result.success).toBe(true); + }); + + test("Test connection fails with invalid credentials", async () => { + // Arrange + const integration = createIntegration(beszelContainer.getMappedPort(8090), { + password: "wrongpassword", + }); + + // Act + const result = await integration.testConnectionAsync(); + + // Assert + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.type).toBe("authorization"); + }); + + test("Can get systems", async () => { + // Arrange + const integration = createIntegration(beszelContainer.getMappedPort(8090)); + + // Act + const systems = await integration.getSystemsAsync(); + + // Assert + expect(systems.length).toBe(1); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(systems[0]!.name).toBe("localhost"); + }); + + test("Can get system details", async () => { + // Arrange + const integration = createIntegration(beszelContainer.getMappedPort(8090)); + + // Act + const system = await integration.getSystemDetailsAsync(systemId); + + // Assert + expect(system.id).toBe(systemId); + expect(system.name).toBe("localhost"); + expect(system.agent.connectionType).toBe("webSocket"); + expect(system.agent.version).toBeDefined(); + expect(system.status).toBe("up"); + expect(system.usage.cpuPercentage).toBeGreaterThanOrEqual(0); + expect(system.usage.memoryPercentage).toBeGreaterThanOrEqual(0); + expect(system.usage.diskPercentage).toBeGreaterThanOrEqual(0); + expect(system.usage.load.averages.one).toBeGreaterThanOrEqual(0); + expect(system.usage.networkBytes).toBeGreaterThanOrEqual(0); + }); +}); + +const createIntegration = (port: number, overrides: { hostname?: string; password?: string } = {}) => + new BeszelIntegration({ + id: createId(), + name: "Beszel E2E", + url: `http://${overrides.hostname ?? "localhost"}:${port}`, + externalUrl: null, + decryptedSecrets: [ + { + kind: "username", + value: email, + }, + { + kind: "password", + value: overrides.password ?? password, + }, + ], + }); + +const files = ["data.db", "data.db-shm", "data.db-wal", "id_ed25519"]; + +const createBeszelContainer = (id: string) => { + return new GenericContainer(IMAGE_NAME) + .withCopyFilesToContainer( + files.map((file) => ({ + source: join(__dirname, `/volumes/beszel/${file}`), + target: `/beszel_data/${file}`, + })), + ) + .withExposedPorts(8090) + .withBindMounts([ + { + source: createSocketPath(id), + target: "/beszel_socket", + mode: "rw", + }, + ]) + .withWaitStrategy(Wait.forHttp("/", 8090)); +}; + +const createBeszelAgentContainer = (id: string, port: number) => { + return new GenericContainer(AGENT_IMAGE_NAME) + .withBindMounts([ + { + source: "/var/run/docker.sock", + target: "/var/run/docker.sock", + mode: "ro", + }, + { + source: createSocketPath(id), + target: "/beszel_socket", + mode: "rw", + }, + ]) + .withEnvironment({ + LISTEN: "/beszel_socket/beszel.sock", + HUB_URL: `http://host.docker.internal:${port}`, + TOKEN: token, + KEY: publicKey, + }) + .withExtraHosts([ + { + // This enabled the usage of host.docker.internal as hostname in the container + host: "host.docker.internal", + ipAddress: "host-gateway", + }, + ]); +}; + +const createSocketPath = (id: string) => join(__dirname, `/volumes/beszel/socket-${id}`); diff --git a/packages/integrations/test/volumes/beszel/data.db b/packages/integrations/test/volumes/beszel/data.db new file mode 100644 index 0000000000000000000000000000000000000000..4db8e9520220e3b0bef7a66e0a5e6d84a20a2a9c GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYFsp<^^dNAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo*T>J*E literal 0 HcmV?d00001 diff --git a/packages/integrations/test/volumes/beszel/data.db-shm b/packages/integrations/test/volumes/beszel/data.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..9c46c82fc67b4a8fc5828538975232f76d8a64a2 GIT binary patch literal 32768 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 G1^yo, + { systemId: string } +>({ + async requestAsync(integration, input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getSystemDetailsAsync(input.systemId); + }, + cacheDuration: dayjs.duration(5, "seconds"), + queryKey: "systemUsage", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 2a2de758b..f2e78a1c5 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -811,7 +811,9 @@ "notes": "Notes", "mediaMonitoring": "Monitoring", "speedtest": "Speed Test", - "analytics": "Analytics" + "analytics": "Analytics", + "systemUsage": "System Usage", + "ups": "UPS" }, "testConnection": { "action": { @@ -3037,6 +3039,47 @@ "internalServerError": "Failed to fetch speedtest data" } }, + "systemUsage": { + "name": "System usage", + "description": "CPU, Memory, Disk and other hardware usage of your system", + "option": { + "systemId": { + "label": "Choose system" + }, + "visibleItems": { + "label": "Visible items" + } + }, + "item": { + "cpu": { + "label": "CPU" + }, + "memory": { + "label": "Memory" + }, + "disk": { + "label": "Disk" + }, + "gpu": { + "label": "GPU" + }, + "load": { + "label": "Load Avg" + }, + "network": { + "label": "Net" + }, + "temperature": { + "label": "Temp" + }, + "agent": { + "label": "Agent" + } + }, + "error": { + "noSystem": "No system selected" + } + }, "systemResources": { "name": "System resources", "description": "CPU, Memory, Disk and other hardware usage of your system", @@ -3856,6 +3899,9 @@ "timetable": { "label": "Timetable" }, + "systemUsage": { + "label": "System Usage" + }, "iconsUpdater": { "label": "Icons Updater" }, diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index 2b6d35338..e3749451b 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -10,3 +10,45 @@ export const IconPowerOff = createCustomIcon({ type: "outline", name: "power-off", }); + +export const IconWebsocket = createCustomIcon({ + svgContent: ` + +`, + type: "filled", + name: "websocket", +}); + +export const IconMemoryStick = createCustomIcon({ + svgContent: ` + + + + + + + + + + `, + type: "outline", + name: "memory-stick", +}); + +export const IconHardDrive = createCustomIcon({ + svgContent: ` + + + `, + type: "outline", + name: "hard-drive", +}); + +export const IconGpu = createCustomIcon({ + svgContent: ` + + + `, + type: "outline", + name: "gpu", +}); diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 5e66efc4f..22fab49d4 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -44,6 +44,7 @@ import * as speedtestTracker from "./speedtest-tracker"; import * as stockPrice from "./stocks"; import * as systemDisks from "./system-disks"; import * as systemResources from "./system-resources"; +import * as systemUsage from "./system-usage"; import * as timetable from "./timetable"; import * as tracearr from "./tracearr"; import * as umami from "./umami"; @@ -86,6 +87,7 @@ export const widgetImports = { notifications, mediaReleases, systemResources, + systemUsage, coolify, systemDisks, timetable, diff --git a/packages/widgets/src/system-usage/component.tsx b/packages/widgets/src/system-usage/component.tsx new file mode 100644 index 000000000..5205814bc --- /dev/null +++ b/packages/widgets/src/system-usage/component.tsx @@ -0,0 +1,228 @@ +"use client"; + +import type { MantineColor } from "@mantine/core"; +import { Divider, Group, Stack, Text } from "@mantine/core"; +import { + IconCpu, + IconHourglass, + IconNetwork, + IconSquareChevronRight, + IconThermometer, + IconWifi, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { humanFileSize, objectEntries } from "@homarr/common"; +import type { System, SystemLoadStatus, SystemStatus } from "@homarr/integrations/types"; +import { useI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; +import { IconGpu, IconHardDrive, IconMemoryStick, IconWebsocket } from "@homarr/ui/icons"; + +import type { WidgetComponentProps } from "../definition"; +import { NoIntegrationSelectedError } from "../errors"; +import { NoSystemSelectedError } from "./errors/no-system-selected-error"; +import { Dot, Item, ProgressValue } from "./item"; + +export default function SystemUsage({ integrationIds, options, isEditMode }: WidgetComponentProps<"systemUsage">) { + if (isEditMode) { + return ; + } + + const integrationId = integrationIds.at(0); + if (!integrationId) { + throw new NoIntegrationSelectedError(); + } + + if (!options.systemId) { + throw new NoSystemSelectedError(); + } + + return ( + + ); +} + +interface SystemUsagePreviewProps { + visibleItems: (keyof typeof items)[]; +} + +const progressItems = new Set(["cpu", "memory", "disk", "gpu"]); + +const SystemUsagePreview = ({ visibleItems }: SystemUsagePreviewProps) => { + const t = useI18n(); + + return ( + + + + + System Name + + + + + {objectEntries(items).map(([key, item]) => + ("hidden" in item && item.hidden({ usage: { gpuPercentage: null, temperature: null } } as System)) || + !visibleItems.includes(key) ? null : ( + + {progressItems.has(key) ? : —} + + ), + )} + + ); +}; + +interface SystemUsageContentProps { + systemId: string; + integrationId: string; + visibleItems: (keyof typeof items)[]; +} + +const SystemUsageContent = ({ systemId, integrationId, visibleItems }: SystemUsageContentProps) => { + const [systemDetails] = clientApi.widget.systemUsage.get.useSuspenseQuery( + { systemId, integrationId }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ); + const utils = clientApi.useUtils(); + clientApi.widget.systemUsage.subscribe.useSubscription( + { systemId, integrationId }, + { + onData(data) { + utils.widget.systemUsage.get.setData({ systemId, integrationId }, data.system); + }, + }, + ); + const t = useI18n(); + + return ( + + + + + {systemDetails.name} + + + + + {objectEntries(items).map(([key, item]) => + ("hidden" in item && item.hidden(systemDetails)) || !visibleItems.includes(key) ? null : ( + + + + ), + )} + + ); +}; + +const systemStatusColors = { + up: "green", + down: "red", + paused: "gray", + pending: "yellow", +} satisfies Record; + +const loadStatusColors = { + good: "green", + warning: "yellow", + critical: "red", + unknown: "gray", +} satisfies Record; + +const items = { + cpu: { + icon: IconCpu, + component({ system }) { + return ; + }, + }, + memory: { + icon: IconMemoryStick, + component({ system }) { + return ; + }, + }, + disk: { + icon: IconHardDrive, + component({ system }) { + return ; + }, + }, + gpu: { + icon: IconGpu, + component({ system }) { + return ; + }, + hidden(system) { + return system.usage.gpuPercentage === null; + }, + }, + load: { + icon: IconHourglass, + component({ system }) { + return ( + + + + {system.usage.load.averages.one} + + + {system.usage.load.averages.five} + + + {system.usage.load.averages.fifteen} + + + ); + }, + }, + network: { + icon: IconNetwork, + component({ system }) { + return ( + + {humanFileSize(system.usage.networkBytes, "/s")} + + ); + }, + }, + temperature: { + icon: IconThermometer, + component({ system }) { + return ( + + {system.usage.temperature ?? "N/A"} °C + + ); + }, + hidden(system) { + return system.usage.temperature === null; + }, + }, + agent: { + icon: IconWifi, + component({ system }) { + return ( + + {system.agent.connectionType === "ssh" ? : null} + {system.agent.connectionType === "webSocket" ? : null} + + {system.agent.version} + + + ); + }, + }, +} satisfies Record< + string, + { icon: TablerIcon; component: (props: { system: System }) => React.ReactNode; hidden?: (system: System) => boolean } +>; diff --git a/packages/widgets/src/system-usage/errors/no-system-selected-error.ts b/packages/widgets/src/system-usage/errors/no-system-selected-error.ts new file mode 100644 index 000000000..c65b16d2c --- /dev/null +++ b/packages/widgets/src/system-usage/errors/no-system-selected-error.ts @@ -0,0 +1,19 @@ +import { IconServerOff } from "@tabler/icons-react"; + +import type { TranslationFunction } from "@homarr/translation"; + +import { ErrorBoundaryError } from "../../errors"; + +export class NoSystemSelectedError extends ErrorBoundaryError { + constructor() { + super("No system selected"); + } + + public getErrorBoundaryData() { + return { + icon: IconServerOff, + message: (t: TranslationFunction) => t("widget.systemUsage.error.noSystem"), + showLogsLink: false, + }; + } +} diff --git a/packages/widgets/src/system-usage/index.ts b/packages/widgets/src/system-usage/index.ts new file mode 100644 index 000000000..fbf4fc320 --- /dev/null +++ b/packages/widgets/src/system-usage/index.ts @@ -0,0 +1,43 @@ +import { IconServer } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; + +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("systemUsage", { + icon: IconServer, + supportedIntegrations: getIntegrationKindsByCategory("systemUsage"), + integrationsRequired: false, + createOptions() { + return optionsBuilder.from((factory) => ({ + systemId: factory.dynamicSelect({ + useOptions(_query, integrationIds) { + const { data: systems, isPending } = clientApi.widget.systemUsage.listSystems.useQuery( + { integrationId: integrationIds[0] ?? "" }, + { + enabled: integrationIds.length > 0, + }, + ); + + return { + isPending, + options: + systems?.map((system) => ({ + label: system.name, + value: system.id, + })) ?? [], + }; + }, + }), + visibleItems: factory.multiSelect({ + options: (["cpu", "memory", "disk", "gpu", "load", "network", "temperature", "agent"] as const).map((key) => ({ + value: key, + label: (t) => t(`widget.systemUsage.item.${key}.label`), + })), + defaultValue: ["cpu", "memory", "disk", "gpu", "load", "network", "temperature", "agent"], + }), + })); + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/system-usage/item.tsx b/packages/widgets/src/system-usage/item.tsx new file mode 100644 index 000000000..9129693ba --- /dev/null +++ b/packages/widgets/src/system-usage/item.tsx @@ -0,0 +1,46 @@ +import type { MantineColor } from "@mantine/core"; +import { Box, Flex, Group, Progress, Text } from "@mantine/core"; + +import type { TablerIcon } from "@homarr/ui"; + +export const ProgressValue = ({ value }: { value: number }) => ( + + + {value}% + + + +); + +const progressColor = (value: number): MantineColor => { + if (value < 50) return "green"; + if (value < 75) return "yellow"; + return "red"; +}; + +interface DotProps { + color: MantineColor; +} + +export const Dot = ({ color }: DotProps) => ; + +interface ItemProps { + icon: TablerIcon; + label: string; + children: React.ReactNode; +} + +export const Item = (props: ItemProps) => ( + + + + + {props.label}: + + + + + {props.children} + + +);