mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Surface GPU telemetry on Proxmox nodes
This commit is contained in:
+5
-20
@@ -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' }],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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) ??
|
||||
|
||||
Reference in New Issue
Block a user