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, cpuTemp] = await Promise.all([this.getAllStatsAsync(), this.getCpuTempAsync()]); 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 ?? "Unknown", loadAverage: null, smart: [], cpuTemp, gpu: stats.gpu.map((gpu) => ({ gpuId: gpu.gpu_id, name: gpu.name, memoryUtilization: gpu.mem ?? 0, processorUtilization: gpu.proc ?? 0, temperature: gpu.temperature ?? null, fanSpeed: gpu.fan_speed ?? null, })), }; } 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 getCpuTempAsync(): Promise { // CPU temperature is optional data, so a transport error or payload drift on // /api/4/sensors must not fail the whole getSystemInfoAsync() call. try { const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/sensors")); if (!response.ok) { return undefined; } const sensors = await sensorsSchema.parseAsync(await response.json()); return parseGlancesCpuTempFromSensors(sensors); } catch { return undefined; } } 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 }; } } // Glances is written in python and uses the following code to format uptime: // from datetime import datetime // uptime = datetime.now() - datetime.fromtimestamp(1773395580) // str(uptime).split(".")[0] // This results in one of the following formats: // - 71 days, 9:51:35 // - 1 day, 9:50:23 // - 9:51:24 // - 0:22:02 // - 0:00:17 // See https://github.com/nicolargo/glances/blob/4139b10c5c3a98afc67a19ae67d66d2d94d7db6a/glances/plugins/uptime/__init__.py#L58 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(), }) .optional(), gpu: z .array( z.object({ gpu_id: z.string(), name: z.string(), mem: z.number().nullable().optional(), proc: z.number().nullable().optional(), temperature: z.number().nullable().optional(), fan_speed: z.number().nullable().optional(), }), ) .default([]), }); const sensorsSchema = z.array( z.object({ label: z.string(), value: z.number(), type: z.string().optional(), }), ); const cpuTempLabelPriority = ["CPU", "Package id 0"] as const; export const parseGlancesCpuTempFromSensors = (sensors: z.infer): number | undefined => { const temperatureSensors = sensors.filter( (sensor) => sensor.type === "temperature_core" || sensor.type === "temperature", ); for (const label of cpuTempLabelPriority) { const match = temperatureSensors.find((sensor) => sensor.label === label); if (match) { return match.value; } } const firstCore = temperatureSensors[0]; if (firstCore) { return firstCore.value; } return undefined; };