Surface GPU telemetry on Proxmox nodes

This commit is contained in:
pulse-triage[bot]
2026-08-30 06:24:50 +01:00
parent 0d32dac16e
commit 71c16e8da9
10 changed files with 235 additions and 31 deletions
@@ -1,5 +1,8 @@
import type { ResourceType as MetricsHistoryResourceType } from '@/api/charts';
import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel';
import {
GPU_METRICS_HISTORY_GROUPS,
HOST_METRICS_HISTORY_GROUPS,
} from '@/components/shared/hostMetricsHistoryModel';
import type { GuestDrawerHistoryTarget } from '@/components/Workloads/guestDrawerModel';
import {
GUEST_DRAWER_HISTORY_GROUPS,
@@ -50,24 +53,6 @@ export const getResourceGPUTemperatureCelsius = (resource: Resource): number | u
: undefined;
});
const GPU_HISTORY_GROUPS: GuestDrawerHistoryGroupConfig[] = [
{
id: 'gpu-utilization',
label: 'GPU Utilization',
unit: '%',
series: [
{ metric: 'gpu', label: 'Core', unit: '%', color: '#06b6d4' },
{ metric: 'gpu_memory', label: 'VRAM', unit: '%', color: '#6366f1' },
],
},
{
id: 'gpu-thermal',
label: 'GPU Thermal',
unit: 'C',
series: [{ metric: 'gpu_temperature', label: 'GPU', unit: 'C', color: '#ef4444' }],
},
];
const NODE_METRICS_HISTORY_GROUPS = HOST_METRICS_HISTORY_GROUPS.filter(
(group) => group.id !== 'disk-io',
);
@@ -133,7 +118,7 @@ export const getResourceMetricsHistoryGroups = (
target?.resourceType === 'docker-host' ||
target?.resourceType === 'node';
return supportsGPUHistory && (resource.agent?.sensors?.gpu?.length ?? 0) > 0
? [...baseGroups, ...GPU_HISTORY_GROUPS]
? [...baseGroups, ...GPU_METRICS_HISTORY_GROUPS]
: baseGroups;
};
@@ -205,6 +205,36 @@ describe('NodeDrawer', () => {
);
});
it('shows typed NVIDIA telemetry reported by the linked PVE agent', () => {
render(() => (
<NodeDrawer
node={makeNode({
linkedAgentId: 'pve-agent',
sensors: {
temperatureCelsius: { gpu_nvidia_0: 63 },
gpu: [
{
id: '0',
name: 'NVIDIA RTX A6000',
temperatureCelsius: 63,
utilizationPercent: 42,
memoryUsedBytes: 8 * 1024 * 1024 * 1024,
memoryTotalBytes: 48 * 1024 * 1024 * 1024,
},
],
},
})}
/>
));
const technical = openTechnicalDetails();
expect(technical.getByText('GPU')).toBeInTheDocument();
expect(technical.getByText('GPU 0')).toBeInTheDocument();
expect(
technical.getByText('NVIDIA RTX A6000 · 63°C · 42% · 8.00 GB / 48.0 GB'),
).toBeInTheDocument();
});
it('puts the active alert problem before context and visible inventory', () => {
render(() => (
<NodeDrawer
@@ -7,7 +7,7 @@ import {
type DetailRow,
type DetailValueTone,
} from '@/components/shared/DetailSectionTable';
import type { Alert, Disk, Node, Temperature } from '@/types/api';
import type { Alert, Disk, HostGPUSensor, Node, Temperature } from '@/types/api';
import { alertTypeDisplayLabel } from '@/features/alerts/helpers';
import { formatBytes, normalizeDiskArray } from '@/utils/format';
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
@@ -78,6 +78,7 @@ const pushTemperature = (
const getThermalRows = (
temperature: Temperature | undefined,
thresholds: MetricDisplayThresholds | null | undefined,
typedGPUAvailable = false,
): NodeOverviewRow[] => {
if (!temperature?.available) return [];
@@ -98,7 +99,7 @@ const getThermalRows = (
});
}
if (temperature.gpu?.length) {
if (!typedGPUAvailable && temperature.gpu?.length) {
const gpuLabels = temperature.gpu
.map((gpu) => {
const value = [gpu.edge, gpu.junction, gpu.mem].find(hasPositiveNumber);
@@ -118,6 +119,48 @@ const getThermalRows = (
return rows;
};
const formatGPUValue = (gpu: HostGPUSensor): string => {
const parts: string[] = [];
const name = cleanText(gpu.name);
if (name) parts.push(name);
if (
typeof gpu.temperatureCelsius === 'number' &&
Number.isFinite(gpu.temperatureCelsius) &&
gpu.temperatureCelsius > 0
) {
parts.push(formatTemperature(gpu.temperatureCelsius));
}
if (
typeof gpu.utilizationPercent === 'number' &&
Number.isFinite(gpu.utilizationPercent) &&
gpu.utilizationPercent >= 0
) {
parts.push(`${Math.round(Math.min(100, gpu.utilizationPercent))}%`);
}
if (
typeof gpu.memoryTotalBytes === 'number' &&
Number.isFinite(gpu.memoryTotalBytes) &&
gpu.memoryTotalBytes > 0
) {
const used =
typeof gpu.memoryUsedBytes === 'number' &&
Number.isFinite(gpu.memoryUsedBytes) &&
gpu.memoryUsedBytes >= 0
? gpu.memoryUsedBytes
: 0;
parts.push(`${formatBytes(used)} / ${formatBytes(gpu.memoryTotalBytes)}`);
}
return parts.join(' · ');
};
const getGPUTelemetryRows = (node: Node): NodeOverviewRow[] =>
(node.sensors?.gpu ?? []).flatMap((gpu, index) => {
const value = formatGPUValue(gpu);
if (!value) return [];
const id = cleanText(gpu.id);
return [{ label: id ? `GPU ${id}` : `GPU ${index + 1}`, value, title: value }];
});
const getDetailTone = (valueClass: string | undefined): DetailValueTone => {
if (valueClass?.includes('red') || valueClass?.includes('rose')) return 'danger';
if (valueClass?.includes('yellow') || valueClass?.includes('amber')) return 'warning';
@@ -276,9 +319,16 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) {
: toDetailRows(storageRows()),
},
{ label: 'Telemetry', rows: toDetailRows(telemetryRows()) },
{ label: 'GPU', rows: toDetailRows(getGPUTelemetryRows(props.node)) },
{
label: 'Thermals',
rows: toDetailRows(getThermalRows(props.node.temperature, props.temperatureThresholds)),
rows: toDetailRows(
getThermalRows(
props.node.temperature,
props.temperatureThresholds,
(props.node.sensors?.gpu?.length ?? 0) > 0,
),
),
},
]);
@@ -183,6 +183,23 @@ describe('nodeDrawerModel (branch coverage 2)', () => {
const groups = getNodeDrawerHistoryGroups(makeNode({ linkedAgentId: '' }));
expect(groups.map((group) => group.id)).toStrictEqual(['utilization', 'network', 'thermals']);
});
it('adds GPU history only when the linked agent reports typed GPU telemetry', () => {
const groups = getNodeDrawerHistoryGroups(
makeNode({
linkedAgentId: 'host-1',
sensors: { gpu: [{ id: '0', utilizationPercent: 42 }] },
}),
);
expect(groups.map((group) => group.id)).toStrictEqual([
'utilization',
'network',
'disk-io',
'thermals',
'gpu-utilization',
'gpu-thermal',
]);
});
});
describe('getNodeDrawerCurrentMetrics', () => {
@@ -292,5 +309,35 @@ describe('nodeDrawerModel (branch coverage 2)', () => {
temperature: 55,
});
});
it('projects bounded aggregate GPU values into current history metrics', () => {
const node = makeNode({
sensors: {
gpu: [
{
id: '0',
utilizationPercent: 42,
memoryUsedBytes: 8 * 1024,
memoryTotalBytes: 32 * 1024,
temperatureCelsius: 63,
},
{
id: '1',
utilizationPercent: 75,
memoryUsedBytes: 24 * 1024,
memoryTotalBytes: 32 * 1024,
temperatureCelsius: 70,
},
],
},
});
expect(getNodeDrawerCurrentMetrics(node)).toStrictEqual({
temperature: 65,
gpu: 75,
gpu_memory: 75,
gpu_temperature: 70,
});
});
});
});
@@ -1,6 +1,9 @@
import type { ResourceType as HistoryResourceType } from '@/api/charts';
import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel';
import type { Node } from '@/types/api';
import {
GPU_METRICS_HISTORY_GROUPS,
HOST_METRICS_HISTORY_GROUPS,
} from '@/components/shared/hostMetricsHistoryModel';
import type { HostGPUSensor, Node } from '@/types/api';
import { getCpuTemperature } from '@/utils/temperature';
import type { GuestDrawerHistoryGroupConfig, GuestDrawerHistoryTarget } from './guestDrawerModel';
@@ -33,9 +36,49 @@ export const getNodeDrawerHistoryTarget = (node: Node): NodeDrawerHistoryTarget
return { resourceType: 'node', resourceId };
};
export const getNodeDrawerHistoryGroups = (node: Node): GuestDrawerHistoryGroupConfig[] =>
node.linkedAgentId?.trim() ? NODE_DRAWER_HISTORY_GROUPS : PVE_API_NODE_HISTORY_GROUPS;
export const getNodeDrawerHistoryGroups = (node: Node): GuestDrawerHistoryGroupConfig[] => {
const baseGroups = node.linkedAgentId?.trim()
? NODE_DRAWER_HISTORY_GROUPS
: PVE_API_NODE_HISTORY_GROUPS;
return (node.sensors?.gpu?.length ?? 0) > 0
? [...baseGroups, ...GPU_METRICS_HISTORY_GROUPS]
: baseGroups;
};
export const getNodeDrawerCurrentMetrics = (node: Node): Record<string, number | undefined> => ({
temperature: getCpuTemperature(node.temperature) ?? undefined,
});
const maxGPUValue = (
node: Node,
select: (gpu: HostGPUSensor) => number | undefined,
): number | undefined => {
let maximum: number | undefined;
for (const gpu of node.sensors?.gpu ?? []) {
const value = select(gpu);
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
maximum = maximum === undefined ? value : Math.max(maximum, value);
}
return maximum;
};
export const getNodeDrawerCurrentMetrics = (node: Node): Record<string, number | undefined> => {
const metrics: Record<string, number | undefined> = {
temperature: getCpuTemperature(node.temperature) ?? undefined,
};
if ((node.sensors?.gpu?.length ?? 0) === 0) return metrics;
metrics.gpu = maxGPUValue(node, (gpu) => {
const value = gpu.utilizationPercent;
return typeof value === 'number' && value >= 0 && value <= 100 ? value : undefined;
});
metrics.gpu_memory = maxGPUValue(node, (gpu) => {
const used = gpu.memoryUsedBytes;
const total = gpu.memoryTotalBytes;
if (typeof used !== 'number' || typeof total !== 'number' || used < 0 || total <= 0) {
return undefined;
}
return Math.min(100, Math.max(0, (used / total) * 100));
});
metrics.gpu_temperature = maxGPUValue(node, (gpu) => {
const value = gpu.temperatureCelsius;
return typeof value === 'number' && value > 0 && value <= 150 ? value : undefined;
});
return metrics;
};
@@ -49,7 +49,10 @@ import historyChartOverlaySource from '@/components/shared/HistoryChartOverlay.t
import historyChartSource from '@/components/shared/HistoryChart.tsx?raw';
import historyChartModelSource from '@/components/shared/historyChartModel.ts?raw';
import historyChartTooltipSource from '@/components/shared/HistoryChartTooltip.tsx?raw';
import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel';
import {
GPU_METRICS_HISTORY_GROUPS,
HOST_METRICS_HISTORY_GROUPS,
} from '@/components/shared/hostMetricsHistoryModel';
import horizontalRailVisibilityModelSource from '@/components/shared/horizontalRailVisibilityModel.ts?raw';
import mobileNavBarSource from '@/components/shared/MobileNavBar.tsx?raw';
import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?raw';
@@ -353,6 +356,10 @@ describe('shared primitive guardrails', () => {
unit: 'C',
series: [{ metric: 'temperature', label: 'CPU', unit: 'C' }],
});
expect(GPU_METRICS_HISTORY_GROUPS.map((group) => group.id)).toEqual([
'gpu-utilization',
'gpu-thermal',
]);
});
it('limits raw Table composition inside shared primitives to the canonical allowlist', () => {
@@ -38,3 +38,24 @@ export const HOST_METRICS_HISTORY_GROUPS = [
series: [{ metric: 'temperature', label: 'CPU', unit: 'C', color: '#ef4444' }],
},
];
// GPU groups are conditional because many hosts do not report a GPU. Keep the
// vocabulary beside the base host catalog so every host presentation appends
// the same persisted metric series when typed GPU telemetry is available.
export const GPU_METRICS_HISTORY_GROUPS = [
{
id: 'gpu-utilization',
label: 'GPU Utilization',
unit: '%',
series: [
{ metric: 'gpu', label: 'Core', unit: '%', color: '#06b6d4' },
{ metric: 'gpu_memory', label: 'VRAM', unit: '%', color: '#6366f1' },
],
},
{
id: 'gpu-thermal',
label: 'GPU Thermal',
unit: 'C',
series: [{ metric: 'gpu_temperature', label: 'GPU', unit: 'C', color: '#ef4444' }],
},
];
+2
View File
@@ -168,6 +168,8 @@ export interface Node {
kernelVersion: string;
pveVersion: string;
cpuInfo: CPUInfo;
/** Typed telemetry from a linked Unified Agent, when this PVE node is hybrid. */
sensors?: HostSensorSummary;
temperature?: Temperature; // CPU/NVMe temperatures
temperatureMonitoringEnabled?: boolean | null; // Per-node temperature monitoring override
pendingUpdates?: number; // Number of pending apt updates
@@ -209,6 +209,24 @@ describe('resourceStateAdapters nodeFromResource', () => {
]);
});
it('preserves linked-agent GPU telemetry for the Proxmox node drawer', () => {
const gpu = {
id: '0',
name: 'NVIDIA RTX A6000',
temperatureCelsius: 63,
utilizationPercent: 42,
memoryUsedBytes: 8 * 1024 * 1024 * 1024,
memoryTotalBytes: 48 * 1024 * 1024 * 1024,
};
const node = nodeFromResource({
...createNodeResource({}),
proxmox: { nodeName: 'pve-node-2' },
agent: { agentId: 'pve-agent', sensors: { gpu: [gpu] } },
} as Resource);
expect(node?.sensors?.gpu).toEqual([gpu]);
});
it('maps PBS display and host identity through shared resource helpers', () => {
const instance = pbsInstanceFromResource(
createServiceResource('pbs', {
@@ -1332,6 +1332,7 @@ export const nodeFromResource = (resource: Resource): Node | null => {
sockets: asNumber(cpuInfo?.sockets) ?? 0,
mhz: asString(cpuInfo?.mhz) || '0',
},
sensors: agentFacet?.sensors,
temperature: buildTemperature(resource, proxmox),
temperatureMonitoringEnabled:
asBoolean(platform?.temperatureMonitoringEnabled) ??