Add AMD GPU temperature monitoring support

Related to #600

- Add GPU field to Temperature model with edge, junction, and mem sensors
- Add amdgpu chip recognition to temperature parser
- Implement parseGPUTemps() to extract AMD GPU temperature data
- Update frontend TypeScript types to include GPU temperatures
- Display GPU temps in node table tooltip alongside CPU temps
- Set hasGPU flag when GPU data is available

This enables temperature monitoring for AMD GPUs (amdgpu sensors)
that was previously being collected via SSH but silently discarded
during parsing.
This commit is contained in:
rcourtman 2025-11-06 00:19:04 +00:00
parent 5b89b2371a
commit d62259ffa7
4 changed files with 94 additions and 4 deletions

View file

@ -628,7 +628,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
online &&
isPVE &&
cpuTemperatureValue !== null &&
(node!.temperature?.hasCPU ?? node!.temperature?.available) &&
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
isTemperatureMonitoringEnabled(node!)
}
fallback={
@ -653,7 +653,10 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
typeof cpuMax === 'number' &&
cpuMax > 0;
if (hasMinMax) {
const gpus = temp?.gpu ?? [];
const hasGPU = gpus.length > 0;
if (hasMinMax || hasGPU) {
const min = Math.round(cpuMin);
const max = Math.round(cpuMax);
@ -669,7 +672,22 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
{value}°C
</span>
<span class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs whitespace-nowrap bg-gray-900 dark:bg-gray-700 text-white rounded shadow-lg z-50 pointer-events-none">
<span class="text-gray-300">Range:</span> <span class={getTooltipColor(min)}>{min}</span>-<span class={getTooltipColor(max)}>{max}</span>°C
{hasMinMax && (
<div>
<span class="text-gray-300">CPU:</span> <span class={getTooltipColor(min)}>{min}</span>-<span class={getTooltipColor(max)}>{max}</span>°C
</div>
)}
{hasGPU && gpus.map((gpu) => {
const gpuTemp = gpu.edge ?? gpu.junction ?? gpu.mem ?? 0;
return (
<div>
<span class="text-gray-300">GPU:</span> <span class={getTooltipColor(gpuTemp)}>{Math.round(gpuTemp)}</span>°C
{gpu.edge && ` E:${Math.round(gpu.edge)}`}
{gpu.junction && ` J:${Math.round(gpu.junction)}`}
{gpu.mem && ` M:${Math.round(gpu.mem)}`}
</div>
);
})}
</span>
</span>
);

View file

@ -752,9 +752,11 @@ export interface Temperature {
minRecorded?: string; // When minimum temperature was recorded
maxRecorded?: string; // When maximum temperature was recorded
cores?: CoreTemp[]; // Individual core temperatures
gpu?: GPUTemp[]; // GPU temperatures
nvme?: NVMeTemp[]; // NVMe drive temperatures
available: boolean; // Whether any temperature data is available
hasCPU?: boolean; // Whether CPU temperature data is available
hasGPU?: boolean; // Whether GPU temperature data is available
hasNVMe?: boolean; // Whether NVMe temperature data is available
lastUpdate: string; // When this data was collected
}
@ -764,6 +766,13 @@ export interface CoreTemp {
temp: number;
}
export interface GPUTemp {
device: string; // GPU device identifier (e.g., "amdgpu-pci-0400")
edge?: number; // Edge temperature
junction?: number; // Junction/hotspot temperature
mem?: number; // Memory temperature
}
export interface NVMeTemp {
device: string;
temp: number;

View file

@ -773,9 +773,11 @@ type Temperature struct {
MinRecorded time.Time `json:"minRecorded,omitempty"` // When minimum temperature was recorded
MaxRecorded time.Time `json:"maxRecorded,omitempty"` // When maximum temperature was recorded
Cores []CoreTemp `json:"cores,omitempty"` // Individual core temperatures
GPU []GPUTemp `json:"gpu,omitempty"` // GPU temperatures
NVMe []NVMeTemp `json:"nvme,omitempty"` // NVMe drive temperatures
Available bool `json:"available"` // Whether any temperature data is available
HasCPU bool `json:"hasCPU"` // Whether CPU temperature data is available
HasGPU bool `json:"hasGPU"` // Whether GPU temperature data is available
HasNVMe bool `json:"hasNVMe"` // Whether NVMe temperature data is available
LastUpdate time.Time `json:"lastUpdate"` // When this data was collected
}
@ -786,6 +788,14 @@ type CoreTemp struct {
Temp float64 `json:"temp"`
}
// GPUTemp represents a GPU temperature sensor
type GPUTemp struct {
Device string `json:"device"` // GPU device identifier (e.g., "amdgpu-pci-0400")
Edge float64 `json:"edge,omitempty"` // Edge temperature
Junction float64 `json:"junction,omitempty"` // Junction/hotspot temperature
Mem float64 `json:"mem,omitempty"` // Memory temperature
}
// NVMeTemp represents an NVMe drive temperature
type NVMeTemp struct {
Device string `json:"device"`

View file

@ -364,6 +364,14 @@ func (tc *TemperatureCollector) parseSensorsJSON(jsonStr string) (*models.Temper
if strings.Contains(chipName, "nvme") {
tc.parseNVMeTemps(chipName, chipMap, temp)
}
// Handle GPU temperature sensors
if strings.Contains(chipLower, "amdgpu") {
log.Debug().
Str("chip", chipName).
Msg("Detected AMD GPU temperature chip")
tc.parseGPUTemps(chipName, chipMap, temp)
}
}
// If we got CPU temps, calculate max from cores if package not available
@ -379,9 +387,10 @@ func (tc *TemperatureCollector) parseSensorsJSON(jsonStr string) (*models.Temper
// This prevents false negatives when sensors report 0°C during resets or temporarily
temp.HasCPU = foundCPUChip
temp.HasNVMe = len(temp.NVMe) > 0
temp.HasGPU = len(temp.GPU) > 0
// Available means any temperature data exists (backward compatibility)
temp.Available = temp.HasCPU || temp.HasNVMe
temp.Available = temp.HasCPU || temp.HasNVMe || temp.HasGPU
// Log summary of what was detected
if !foundCPUChip {
@ -397,10 +406,12 @@ func (tc *TemperatureCollector) parseSensorsJSON(jsonStr string) (*models.Temper
log.Debug().
Bool("hasCPU", temp.HasCPU).
Bool("hasNVMe", temp.HasNVMe).
Bool("hasGPU", temp.HasGPU).
Float64("cpuPackage", temp.CPUPackage).
Float64("cpuMax", temp.CPUMax).
Int("coreCount", len(temp.Cores)).
Int("nvmeCount", len(temp.NVMe)).
Int("gpuCount", len(temp.GPU)).
Msg("Temperature data parsed successfully")
}
@ -551,6 +562,48 @@ func (tc *TemperatureCollector) parseNVMeTemps(chipName string, chipMap map[stri
}
}
// parseGPUTemps extracts GPU temperature data from a sensor chip
func (tc *TemperatureCollector) parseGPUTemps(chipName string, chipMap map[string]interface{}, temp *models.Temperature) {
gpuTemp := models.GPUTemp{
Device: chipName,
}
// AMD GPU sensors typically have: edge, junction (hotspot), mem
for sensorName, sensorData := range chipMap {
sensorMap, ok := sensorData.(map[string]interface{})
if !ok {
continue
}
sensorLower := strings.ToLower(sensorName)
tempVal := extractTempInput(sensorMap)
if math.IsNaN(tempVal) || tempVal <= 0 {
continue
}
// Map sensor names to struct fields
if strings.Contains(sensorLower, "edge") {
gpuTemp.Edge = tempVal
} else if strings.Contains(sensorLower, "junction") || strings.Contains(sensorLower, "hotspot") {
gpuTemp.Junction = tempVal
} else if strings.Contains(sensorLower, "mem") {
gpuTemp.Mem = tempVal
}
}
// Only add GPU entry if we got at least one valid temperature
if gpuTemp.Edge > 0 || gpuTemp.Junction > 0 || gpuTemp.Mem > 0 {
temp.GPU = append(temp.GPU, gpuTemp)
log.Debug().
Str("device", chipName).
Float64("edge", gpuTemp.Edge).
Float64("junction", gpuTemp.Junction).
Float64("mem", gpuTemp.Mem).
Msg("Parsed GPU temperatures")
}
}
// extractTempInput extracts temperature value from sensor data
func extractTempInput(sensorMap map[string]interface{}) float64 {
// Look for temp*_input fields