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
This commit is contained in:
Daniel authored and GitHub committed 2026-03-27 20:22:41 +01:00
1 parent d7f0596db8
commit 003dac4744
15 files changed
+231 -2

No files matched your search

@@ -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<z.infer<typeof cpuLoadPerCoreApiList>>(
`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(),
}),
),
});
@@ -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([]),
});
@@ -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
@@ -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),
},
],
});
}
}
@@ -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.
};
}
@@ -297,6 +297,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
version: systemInformation.version,
cpuModelName: systemInformation.model,
rebootRequired: false,
gpu: [],
};
}
@@ -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: [],
};
}
@@ -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;
+5
View File
@@ -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"
@@ -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,
}),
@@ -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 (
<Tooltip
label={
<Stack gap={2}>
<Text size="xs" fw={600}>
{gpu.name}
</Text>
<Text size="xs">VRAM: {gpu.memoryUtilization.toFixed(1)}%</Text>
{tempDisplay && <Text size="xs">Temp: {tempDisplay}</Text>}
{gpu.fanSpeed != null && <Text size="xs">Fan: {gpu.fanSpeed} RPM</Text>}
</Stack>
}
multiline
>
<RingProgress
className={`health-monitoring-gpu health-monitoring-gpu-${gpu.gpuId}`}
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text
className="health-monitoring-gpu-utilization-value"
size={isTiny ? "8px" : "xs"}
>{`${gpu.processorUtilization.toFixed(0)}%`}</Text>
<IconDeviceDesktop className="health-monitoring-gpu-utilization-icon" size={isTiny ? 8 : 16} />
</Center>
}
sections={[
{
value: Number(gpu.processorUtilization.toFixed(2)),
color: progressColor(Number(gpu.processorUtilization.toFixed(2))),
},
]}
/>
</Tooltip>
);
};
@@ -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) => (
<GpuRing key={gpu.gpuId} gpu={gpu} isTiny={isTiny} fahrenheit={options.fahrenheit} />
))}
</Flex>
{
<Text className="health-monitoring-status-update-time" c="dimmed" size="xs" ta="center">
@@ -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 (
<CommonChart
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "grape.5" }]}
title={t("gpu")}
icon={IconDeviceDesktop}
lastValue={
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
gpuUsageOverTime.length > 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 (
<Paper px={3} py={2} withBorder shadow="md" radius="md">
<Text c="dimmed" size="xs">
{value.toFixed(0)}%
</Text>
</Paper>
);
},
}}
/>
);
};
@@ -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
/>
</Box>
)}
{options.visibleCharts.includes("gpu") && (
<Box h={rowHeight}>
<SystemResourceGPUChart
gpuUsageOverTime={items.map((item) => item.gpu)}
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
/>
</Box>
)}
{showNetwork &&
(width > 256 ? (
<Group h={rowHeight} gap="xs" grow>
@@ -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}`),
})),