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:
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;
|
||||
|
||||
|
||||
@@ -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}`),
|
||||
})),
|
||||
|
||||
Reference in new issue
Block a user