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 000000000..4db8e9520 Binary files /dev/null and b/packages/integrations/test/volumes/beszel/data.db differ 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 000000000..9c46c82fc Binary files /dev/null and b/packages/integrations/test/volumes/beszel/data.db-shm differ diff --git a/packages/integrations/test/volumes/beszel/data.db-wal b/packages/integrations/test/volumes/beszel/data.db-wal new file mode 100644 index 000000000..832b4e6c3 Binary files /dev/null and b/packages/integrations/test/volumes/beszel/data.db-wal differ diff --git a/packages/integrations/test/volumes/beszel/id_ed25519 b/packages/integrations/test/volumes/beszel/id_ed25519 new file mode 100644 index 000000000..de9d6bef5 --- /dev/null +++ b/packages/integrations/test/volumes/beszel/id_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz +c2gtZWQyNTUxOQAAACA+TxbrdHphaEEOdy5SxnxWAP1NTaHulj6mDf/eyyU70QAA +AIgMmaMFDJmjBQAAAAtzc2gtZWQyNTUxOQAAACA+TxbrdHphaEEOdy5SxnxWAP1N +TaHulj6mDf/eyyU70QAAAEBgdIVB4OrfE2cUkVn7lpqtyq+XRq2rxzq01U1DPHB+ +fj5PFut0emFoQQ53LlLGfFYA/U1Noe6WPqYN/97LJTvRAAAAAAECAwQF +-----END OPENSSH PRIVATE KEY----- diff --git a/packages/request-handler/src/system-usage.ts b/packages/request-handler/src/system-usage.ts new file mode 100644 index 000000000..649cf7fed --- /dev/null +++ b/packages/request-handler/src/system-usage.ts @@ -0,0 +1,20 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { createIntegrationAsync } from "@homarr/integrations"; +import type { System } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const systemUsageRequestHandler = createCachedIntegrationRequestHandler< + System, + IntegrationKindByCategory<"systemUsage">, + { 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} + + +);