From 003dac4744f2e23535f1cbb7494187201af4b164 Mon Sep 17 00:00:00 2001 From: Daniel <12952156+dgshue@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:22:41 -0400 Subject: [PATCH] feat: add GPU monitoring support via Glances integration (#5255) * feat: add GPU monitoring support via Glances integration - Add GPU fields to SystemHealthMonitoring interface (gpuId, name, memoryUtilization, processorUtilization, temperature, fanSpeed) - Extend Glances integration to parse GPU data from /api/4/all endpoint (supports NVIDIA, AMD, and Intel GPUs via Glances) - Add GPU utilization ring to Health Monitoring widget with tooltip showing VRAM usage, temperature, and fan speed - Add GPU time-series chart to System Resources widget (grape color) - Add GPU toggle option to both widgets - Add English translations for GPU labels - Add gpu: [] to all other integrations (dashdot, truenas, unraid, openmediavault) for backward compatibility - Add GPU mock data for development/testing Closes community request from PR #5102 for GPU monitoring support. * fix: implement GPU monitoring for dashdot integration and clarify OMV comment - Add GPU load fetching via dashdot /load/gpu endpoint - Add GPU info parsing from dashdot /info endpoint - Add gpuLoadApi and gpu field to internalServerInfoApi schemas - Add getCurrentGpuLoadAsync method to DashDotIntegration - Map GPU load data to SystemHealthMonitoring gpu array - Add explanatory comment for OMV's empty gpu array Addresses review comments from manuel-rw on PR #5255 * Delete manifest.json --- .../src/dashdot/dashdot-integration.ts | 38 +++++++++++ .../src/glances/glances-integration.ts | 20 ++++++ .../health-monitoring-types.ts | 8 +++ .../src/mock/data/system-health-monitoring.ts | 10 +++ .../openmediavault-integration.ts | 1 + .../src/truenas/truenas-integration.ts | 1 + .../src/unraid/unraid-integration.ts | 1 + packages/old-import/src/widgets/options.ts | 1 + packages/translation/src/lang/en.json | 5 ++ .../widgets/src/health-monitoring/index.ts | 3 + .../src/health-monitoring/rings/gpu-ring.tsx | 64 +++++++++++++++++++ .../src/health-monitoring/system-health.tsx | 5 ++ .../src/system-resources/chart/gpu-chart.tsx | 52 +++++++++++++++ .../src/system-resources/component.tsx | 22 ++++++- .../widgets/src/system-resources/index.ts | 2 +- 15 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 packages/widgets/src/health-monitoring/rings/gpu-ring.tsx create mode 100644 packages/widgets/src/system-resources/chart/gpu-chart.tsx diff --git a/packages/integrations/src/dashdot/dashdot-integration.ts b/packages/integrations/src/dashdot/dashdot-integration.ts index fb5d9070e..0de41a55a 100644 --- a/packages/integrations/src/dashdot/dashdot-integration.ts +++ b/packages/integrations/src/dashdot/dashdot-integration.ts @@ -33,6 +33,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni const memoryLoad = await this.getCurrentMemoryLoadAsync(); const storageLoad = await this.getCurrentStorageLoadAsync(); const networkLoad = await this.getCurrentNetworkLoadAsync(); + const gpuLoad = await this.getCurrentGpuLoadAsync(); const channel = this.getChannel(); const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate()); @@ -56,6 +57,14 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni availablePkgUpdates: 0, rebootRequired: false, smart: [], // API endpoint does not provide S.M.A.R.T data. + gpu: gpuLoad.map((gpu, index) => ({ + gpuId: `gpu-${index}`, + name: info.gpuNames[index] ?? `GPU ${index}`, + memoryUtilization: gpu.memory ?? 0, + processorUtilization: gpu.load ?? 0, + temperature: null, + fanSpeed: null, + })), uptime: info.uptime, version: `${info.operatingSystemVersion}`, loadAverage: { @@ -76,6 +85,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni cpuModel: serverInfo.cpu.model, operatingSystemVersion: `${serverInfo.os.distro} ${serverInfo.os.release} (${serverInfo.os.kernel})`, uptime: serverInfo.os.uptime, + gpuNames: serverInfo.gpu.map((gpu) => gpu.brand), }; } @@ -150,6 +160,17 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni return await networkLoadApi.parseAsync(JSON.parse(result)); } + private async getCurrentGpuLoadAsync() { + const response = await fetchWithTrustedCertificatesAsync(this.url("/load/gpu")); + const result = await response.text(); + + // response is either valid json or empty if gpu widget is disabled. + if (result.length === 0) return []; + + const data = await gpuLoadApi.parseAsync(JSON.parse(result)); + return data.layout; + } + private getChannel() { return createChannelEventHistoryOld>( `integration:${this.integration.id}:history:cpu`, @@ -198,6 +219,23 @@ const internalServerInfoApi = z.object({ ), }), ), + gpu: z + .array( + z.object({ + brand: z.string(), + model: z.string().optional(), + }), + ) + .default([]), }); const cpuLoadPerCoreApiList = z.array(cpuLoadPerCoreApi); + +const gpuLoadApi = z.object({ + layout: z.array( + z.object({ + load: z.number().min(0).optional(), + memory: z.number().min(0).optional(), + }), + ), +}); diff --git a/packages/integrations/src/glances/glances-integration.ts b/packages/integrations/src/glances/glances-integration.ts index 2d1f8601f..7b6ae3b79 100644 --- a/packages/integrations/src/glances/glances-integration.ts +++ b/packages/integrations/src/glances/glances-integration.ts @@ -59,6 +59,14 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni loadAverage: null, smart: [], cpuTemp: undefined, + 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, + })), }; } @@ -150,4 +158,16 @@ const allSchema = z.object({ quicklook: z.object({ cpu_name: z.string(), }), + 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([]), }); diff --git a/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts b/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts index 08b5b4100..731f1c577 100644 --- a/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts +++ b/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts @@ -31,6 +31,14 @@ export interface SystemHealthMonitoring { overallStatus: string; healthy: boolean; }[]; + gpu: { + gpuId: string; + name: string; + memoryUtilization: number; + processorUtilization: number; + temperature: number | null; + fanSpeed: number | null; + }[]; } // TODO: in the future decouple this from the Proxmox integration diff --git a/packages/integrations/src/mock/data/system-health-monitoring.ts b/packages/integrations/src/mock/data/system-health-monitoring.ts index bafccd7c5..e209ff8c7 100644 --- a/packages/integrations/src/mock/data/system-health-monitoring.ts +++ b/packages/integrations/src/mock/data/system-health-monitoring.ts @@ -36,6 +36,16 @@ export class SystemHealthMonitoringMockService implements ISystemHealthMonitorin healthy: true, }, ], + gpu: [ + { + gpuId: "mock0", + name: "Mock GPU RTX 4090", + memoryUtilization: Math.floor(Math.random() * 100), + processorUtilization: Math.floor(Math.random() * 100), + temperature: Math.floor(Math.random() * 90), + fanSpeed: Math.floor(Math.random() * 2000), + }, + ], }); } } diff --git a/packages/integrations/src/openmediavault/openmediavault-integration.ts b/packages/integrations/src/openmediavault/openmediavault-integration.ts index 6449ee48c..382f211e6 100644 --- a/packages/integrations/src/openmediavault/openmediavault-integration.ts +++ b/packages/integrations/src/openmediavault/openmediavault-integration.ts @@ -86,6 +86,7 @@ export class OpenMediaVaultIntegration extends Integration implements ISystemHea cpuTemp: cpuTempResult.success ? cpuTempResult.data.response.cputemp : undefined, fileSystem, smart, + gpu: [], // OpenMediaVault does not expose GPU monitoring via its RPC API. }; } diff --git a/packages/integrations/src/truenas/truenas-integration.ts b/packages/integrations/src/truenas/truenas-integration.ts index a712bc076..8d64f848c 100644 --- a/packages/integrations/src/truenas/truenas-integration.ts +++ b/packages/integrations/src/truenas/truenas-integration.ts @@ -297,6 +297,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni version: systemInformation.version, cpuModelName: systemInformation.model, rebootRequired: false, + gpu: [], }; } diff --git a/packages/integrations/src/unraid/unraid-integration.ts b/packages/integrations/src/unraid/unraid-integration.ts index 577d34a4a..38b12c31d 100644 --- a/packages/integrations/src/unraid/unraid-integration.ts +++ b/packages/integrations/src/unraid/unraid-integration.ts @@ -70,6 +70,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit // See ArrayDiskStatus from https://studio.apollographql.com/public/Unraid-API/variant/current/explorer healthy: disk.status === "DISK_OK", })), + gpu: [], }; } diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index 194d21d21..b3e5f0b9f 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -162,6 +162,7 @@ const optionMapping: OptionMapping = { sectionIndicatorRequirement: (oldOptions) => "sectionIndicatorColor" in oldOptions ? oldOptions.sectionIndicatorColor : undefined, showUptime: () => undefined, + gpu: () => undefined, visibleClusterSections: (oldOptions) => { if (!("showNode" in oldOptions)) return undefined; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 29fb9b979..75812b45e 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1861,6 +1861,9 @@ "memory": { "label": "Show Memory Info" }, + "gpu": { + "label": "Show GPU Info" + }, "showUptime": { "label": "Show Uptime" }, @@ -2728,6 +2731,7 @@ "option": { "cpu": "CPU", "memory": "Memory", + "gpu": "GPU", "network": "Network" } }, @@ -2744,6 +2748,7 @@ "card": { "cpu": "CPU", "memory": "MEM", + "gpu": "GPU", "network": "NET", "up": "UP", "down": "DOWN" diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts index 8ed669750..698446c28 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -19,6 +19,9 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon memory: factory.switch({ defaultValue: true, }), + gpu: factory.switch({ + defaultValue: true, + }), showUptime: factory.switch({ defaultValue: true, }), diff --git a/packages/widgets/src/health-monitoring/rings/gpu-ring.tsx b/packages/widgets/src/health-monitoring/rings/gpu-ring.tsx new file mode 100644 index 000000000..6cee021b9 --- /dev/null +++ b/packages/widgets/src/health-monitoring/rings/gpu-ring.tsx @@ -0,0 +1,64 @@ +import { Center, RingProgress, Stack, Text, Tooltip } from "@mantine/core"; +import { IconDeviceDesktop } from "@tabler/icons-react"; + +import { progressColor } from "../system-health"; + +interface GpuRingProps { + gpu: { + gpuId: string; + name: string; + memoryUtilization: number; + processorUtilization: number; + temperature: number | null; + fanSpeed: number | null; + }; + isTiny: boolean; + fahrenheit: boolean; +} + +export const GpuRing = ({ gpu, isTiny, fahrenheit }: GpuRingProps) => { + const tempDisplay = + gpu.temperature != null + ? fahrenheit + ? `${(gpu.temperature * 1.8 + 32).toFixed(0)}°F` + : `${gpu.temperature}°C` + : null; + + return ( + + + {gpu.name} + + VRAM: {gpu.memoryUtilization.toFixed(1)}% + {tempDisplay && Temp: {tempDisplay}} + {gpu.fanSpeed != null && Fan: {gpu.fanSpeed} RPM} + + } + multiline + > + + {`${gpu.processorUtilization.toFixed(0)}%`} + + + } + sections={[ + { + value: Number(gpu.processorUtilization.toFixed(2)), + color: progressColor(Number(gpu.processorUtilization.toFixed(2))), + }, + ]} + /> + + ); +}; diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx index 7a5d4e9d6..6e7d2965c 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -39,6 +39,7 @@ import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { CpuRing } from "./rings/cpu-ring"; import { CpuTempRing } from "./rings/cpu-temp-ring"; +import { GpuRing } from "./rings/gpu-ring"; import { formatMemoryUsage, MemoryRing } from "./rings/memory-ring"; import classes from "./system-health.module.css"; @@ -187,6 +188,10 @@ export const SystemHealthMonitoring = ({ isTiny={isTiny} /> )} + {options.gpu && + healthInfo.gpu.map((gpu) => ( + + ))} { diff --git a/packages/widgets/src/system-resources/chart/gpu-chart.tsx b/packages/widgets/src/system-resources/chart/gpu-chart.tsx new file mode 100644 index 000000000..e1f7daa2c --- /dev/null +++ b/packages/widgets/src/system-resources/chart/gpu-chart.tsx @@ -0,0 +1,52 @@ +import { Paper, Text } from "@mantine/core"; +import { IconDeviceDesktop } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { LabelDisplayModeOption } from ".."; +import { CommonChart } from "./common-chart"; + +export const SystemResourceGPUChart = ({ + gpuUsageOverTime, + hasShadow, + labelDisplayMode, +}: { + gpuUsageOverTime: number[]; + hasShadow: boolean; + labelDisplayMode: LabelDisplayModeOption; +}) => { + const chartData = gpuUsageOverTime.map((usage, index) => ({ index, usage })); + const t = useScopedI18n("widget.systemResources.card"); + + return ( + 0 ? `${Math.round(gpuUsageOverTime[gpuUsageOverTime.length - 1]!)}%` : undefined + } + chartType={hasShadow ? "area" : "line"} + yAxisProps={{ domain: [0, 100] }} + labelDisplayMode={labelDisplayMode} + tooltipProps={{ + content: ({ payload }) => { + if (!payload) { + return null; + } + const value = payload[0] ? Number(payload[0].value) : 0; + return ( + + + {value.toFixed(0)}% + + + ); + }, + }} + /> + ); +}; diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx index 46237a27c..d1d54ff85 100644 --- a/packages/widgets/src/system-resources/component.tsx +++ b/packages/widgets/src/system-resources/component.tsx @@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client"; import type { WidgetComponentProps } from "../definition"; import { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic"; import { SystemResourceCPUChart } from "./chart/cpu-chart"; +import { SystemResourceGPUChart } from "./chart/gpu-chart"; import { SystemResourceMemoryChart } from "./chart/memory-chart"; import { NetworkTrafficChart } from "./chart/network-traffic"; @@ -23,10 +24,16 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo const memoryCapacityInBytes = (data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0); - const [items, setItems] = useState<{ cpu: number; memory: number; network: { up: number; down: number } | null }[]>( + const [items, setItems] = useState< + { cpu: number; memory: number; gpu: number; network: { up: number; down: number } | null }[] + >( data.map((item) => ({ cpu: item.healthInfo.cpuUtilization, memory: item.healthInfo.memUsedInBytes, + gpu: + item.healthInfo.gpu.length > 0 + ? item.healthInfo.gpu.reduce((acc, g) => acc + g.processorUtilization, 0) / item.healthInfo.gpu.length + : 0, network: item.healthInfo.network, })), ); @@ -41,6 +48,10 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo const next = { cpu: data.healthInfo.cpuUtilization, memory: data.healthInfo.memUsedInBytes, + gpu: + data.healthInfo.gpu.length > 0 + ? data.healthInfo.gpu.reduce((acc, g) => acc + g.processorUtilization, 0) / data.healthInfo.gpu.length + : 0, network: data.healthInfo.network, }; @@ -75,6 +86,15 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo /> )} + {options.visibleCharts.includes("gpu") && ( + + item.gpu)} + hasShadow={options.hasShadow} + labelDisplayMode={options.labelDisplayMode} + /> + + )} {showNetwork && (width > 256 ? ( diff --git a/packages/widgets/src/system-resources/index.ts b/packages/widgets/src/system-resources/index.ts index 8f7ade4c4..68fffd4ed 100644 --- a/packages/widgets/src/system-resources/index.ts +++ b/packages/widgets/src/system-resources/index.ts @@ -19,7 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemRes return optionsBuilder.from((factory) => ({ hasShadow: factory.switch({ defaultValue: true }), visibleCharts: factory.multiSelect({ - options: (["cpu", "memory", "network"] as const).map((key) => ({ + options: (["cpu", "memory", "gpu", "network"] as const).map((key) => ({ value: key, label: (t) => t(`widget.systemResources.option.visibleCharts.option.${key}`), })),