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) ??