diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts
index a42d67402..17767e0b7 100644
--- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts
+++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts
@@ -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;
};
diff --git a/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx b/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx
index 7a77d23da..8b289b193 100644
--- a/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx
+++ b/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx
@@ -205,6 +205,36 @@ describe('NodeDrawer', () => {
);
});
+ it('shows typed NVIDIA telemetry reported by the linked PVE agent', () => {
+ render(() => (
+
+ ));
+
+ 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(() => (
{
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,
+ ),
+ ),
},
]);
diff --git a/frontend-modern/src/components/Workloads/__tests__/nodeDrawerModel.branchcov2.test.ts b/frontend-modern/src/components/Workloads/__tests__/nodeDrawerModel.branchcov2.test.ts
index 111a078c7..d81e40e94 100644
--- a/frontend-modern/src/components/Workloads/__tests__/nodeDrawerModel.branchcov2.test.ts
+++ b/frontend-modern/src/components/Workloads/__tests__/nodeDrawerModel.branchcov2.test.ts
@@ -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,
+ });
+ });
});
});
diff --git a/frontend-modern/src/components/Workloads/nodeDrawerModel.ts b/frontend-modern/src/components/Workloads/nodeDrawerModel.ts
index 6a3a7eba2..caeb68200 100644
--- a/frontend-modern/src/components/Workloads/nodeDrawerModel.ts
+++ b/frontend-modern/src/components/Workloads/nodeDrawerModel.ts
@@ -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 => ({
- 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 => {
+ const metrics: Record = {
+ 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;
+};
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
index f2db7ff61..98463247f 100644
--- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
+++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
@@ -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', () => {
diff --git a/frontend-modern/src/components/shared/hostMetricsHistoryModel.ts b/frontend-modern/src/components/shared/hostMetricsHistoryModel.ts
index 125df8332..9dd6a9e96 100644
--- a/frontend-modern/src/components/shared/hostMetricsHistoryModel.ts
+++ b/frontend-modern/src/components/shared/hostMetricsHistoryModel.ts
@@ -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' }],
+ },
+];
diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts
index 76054871b..0498bd157 100644
--- a/frontend-modern/src/types/api.ts
+++ b/frontend-modern/src/types/api.ts
@@ -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
diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts
index 06a911b2a..55a0b948d 100644
--- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts
+++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts
@@ -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', {
diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts
index 6d1c15bdf..16845039f 100644
--- a/frontend-modern/src/utils/resourceStateAdapters.ts
+++ b/frontend-modern/src/utils/resourceStateAdapters.ts
@@ -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) ??