From 91846bc000b419b03925acc4b447efcc696ff009 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:57:29 +0100 Subject: [PATCH] feat: add glances integration (#5102) Co-authored-by: Meier Lukas --- .../src/router/widgets/health-monitoring.ts | 8 +- packages/definitions/src/integration.ts | 7 + packages/integrations/src/base/creator.ts | 2 + .../src/glances/glances-integration.ts | 142 ++++++++++++++++++ packages/integrations/src/index.ts | 1 + .../widgets/src/system-resources/index.ts | 2 +- 6 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 packages/integrations/src/glances/glances-integration.ts diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 14ab160f7..46741ae1c 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -9,7 +9,9 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; export const healthMonitoringRouter = createTRPCRouter({ getSystemHealthStatus: publicProcedure - .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock")) + .concat( + createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"), + ) .query(async ({ ctx }) => { return await Promise.all( ctx.integrations.map(async (integration) => { @@ -26,7 +28,9 @@ export const healthMonitoringRouter = createTRPCRouter({ ); }), subscribeSystemHealthStatus: publicProcedure - .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock")) + .concat( + createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"), + ) .subscription(({ ctx }) => { return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => { const unsubscribes: (() => void)[] = []; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index d97b0c31b..c29c89f06 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -178,6 +178,13 @@ export const integrationDefs = { category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/dash-dot"), }, + glances: { + name: "Glances", + secretKinds: [[]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg", + category: ["healthMonitoring"], + documentationUrl: null, + }, tdarr: { name: "Tdarr", secretKinds: [[], ["apiKey"]], diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index d1ad60ad0..3a48e2c42 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -15,6 +15,7 @@ import { EmbyIntegration } from "../emby/emby-integration"; import { GitHubContainerRegistryIntegration } from "../github-container-registry/github-container-registry-integration"; import { GithubIntegration } from "../github/github-integration"; import { GitlabIntegration } from "../gitlab/gitlab-integration"; +import { GlancesIntegration } from "../glances/glances-integration"; import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; import { ICalIntegration } from "../ical/ical-integration"; import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; @@ -105,6 +106,7 @@ export const integrationCreators = { truenas: TrueNasIntegration, unraid: UnraidIntegration, coolify: CoolifyIntegration, + glances: GlancesIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/glances/glances-integration.ts b/packages/integrations/src/glances/glances-integration.ts new file mode 100644 index 000000000..e4e859151 --- /dev/null +++ b/packages/integrations/src/glances/glances-integration.ts @@ -0,0 +1,142 @@ +import dayjs from "dayjs"; +import { z } from "zod/v4"; + +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 { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration"; +import type { SystemHealthMonitoring } from "../types"; + +export class GlancesIntegration extends Integration implements ISystemHealthMonitoringIntegration { + private readonly sessionStore: SessionStore<{ version: string }>; + + constructor(integration: IntegrationInput) { + super(integration); + this.sessionStore = createSessionStore(integration); + } + + public async getSystemInfoAsync(): Promise { + let session = await this.sessionStore.getAsync(); + + if (session == null) { + await this.sessionStore.setAsync({ + version: await this.getGlancesVersionAsync(), + }); + + // update the store to re-use the values below. + session = await this.sessionStore.getAsync(); + } + + if (session == null) throw new Error("Session was unexpectitly null"); + + const stats = await this.getAllStatsAsync(); + + return { + cpuUtilization: stats.cpu.total, + memUsedInBytes: stats.mem.used, + memAvailableInBytes: stats.mem.total - stats.mem.used, + network: { + down: stats.network.reduce((acc, net) => net.bytes_recv_rate_per_sec + acc, 0), + up: stats.network.reduce((acc, net) => net.bytes_sent_rate_per_sec + acc, 0), + }, + availablePkgUpdates: 0, + version: session.version, + fileSystem: stats.fs.map((fileSystem) => ({ + deviceName: fileSystem.device_name, + used: `${fileSystem.used}`, + available: `${fileSystem.free}`, + percentage: fileSystem.percent, + })), + uptime: stats.uptime.as("seconds"), + rebootRequired: false, + cpuModelName: stats.quicklook.cpu_name, + loadAverage: null, + smart: [], + cpuTemp: undefined, + }; + } + + private async getGlancesVersionAsync() { + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/version")); + + if (!response.ok) { + throw new ResponseError(response); + } + + return await response.text(); + } + + private async getAllStatsAsync() { + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/all")); + + if (!response.ok) { + throw new ResponseError(response); + } + + return allSchema.parseAsync(await response.json()); + } + + protected async testingAsync(_: IntegrationTestingInput): Promise { + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/status"), { + headers: { "User-Agent": "Homarr" }, + }); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + return { success: true }; + } +} + +const regex = /^(?:(?\d+) days, )?(?\d+):(?\d+):(?\d+)$/m; + +const allSchema = z.object({ + cpu: z.object({ + total: z.number().min(0).max(100), + }), + mem: z.object({ + total: z.number().min(0), + used: z.number().min(0), + free: z.number().min(0), + }), + network: z.array( + z.object({ + bytes_sent_rate_per_sec: z.number().min(0), + bytes_recv_rate_per_sec: z.number().min(0), + }), + ), + fs: z.array( + z.object({ + device_name: z.string(), + used: z.number().min(0), + free: z.number().min(0), + percent: z.number().min(0).max(100), + }), + ), + uptime: z + .string() + .regex(new RegExp(regex)) + .transform((uptime) => { + const match = regex.exec(uptime); + if (!match?.groups) { + throw new Error(`Unable to parse uptime value '${uptime}' with regex.`); + } + + const days = "days" in match.groups ? Number(match.groups.days) : undefined; + const hours = "hours" in match.groups ? Number(match.groups.hours) : undefined; + const minutes = "minutes" in match.groups ? Number(match.groups.minutes) : undefined; + const seconds = "seconds" in match.groups ? Number(match.groups.seconds) : undefined; + + return dayjs.duration({ days, hours, minutes, seconds }); + }), + quicklook: z.object({ + cpu_name: z.string(), + }), +}); diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index a2f1287df..7592a1b0f 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -16,6 +16,7 @@ export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration"; export { NextcloudIntegration } from "./nextcloud/nextcloud.integration"; export { NTFYIntegration } from "./ntfy/ntfy-integration"; export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration"; +export { GlancesIntegration } from "./glances/glances-integration"; export { OverseerrIntegration } from "./overseerr/overseerr-integration"; export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5"; export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6"; diff --git a/packages/widgets/src/system-resources/index.ts b/packages/widgets/src/system-resources/index.ts index d89d6952f..8f7ade4c4 100644 --- a/packages/widgets/src/system-resources/index.ts +++ b/packages/widgets/src/system-resources/index.ts @@ -14,7 +14,7 @@ const labelDisplayModeOptions = { export const { definition, componentLoader } = createWidgetDefinition("systemResources", { icon: IconGraphFilled, - supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid"], + supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances"], createOptions() { return optionsBuilder.from((factory) => ({ hasShadow: factory.switch({ defaultValue: true }),