diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 7dc50040e..33006709f 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3076,7 +3076,12 @@ temperature, utilization, used VRAM, and total VRAM. Backend API payloads and `frontend-modern/src/types/api.ts` must preserve that optional collection without making it a required compatibility field, and clients must keep it as descriptive host telemetry rather than a separate resource or process/workload -inventory contract. +identity or inventory contract. +Host sensor summaries may also carry named power readings in `powerWatts`. +Backend API payloads and `frontend-modern/src/types/api.ts` must preserve that +optional map without making it a required compatibility field, and clients must +keep it as descriptive host telemetry rather than a temperature metric, +resource identity, alert metric, or storage/recovery signal. `aicontracts.Finding` (the shape Patrol hands the investigation orchestrator) carries optional `OperatorContext` and diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index edf48dd96..32fe1348c 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -610,6 +610,11 @@ and VRAM readings from agent reports through models, read-state projection, and frontend conversion, while still using only real Celsius readings for `agent.temperature` or `metric=temperature` and without promoting GPU workload or process inventory into monitoring state. +Host-agent power sensor summaries follow the same descriptive-host-telemetry +path. Monitoring must preserve `sensors.powerWatts` readings from agent reports +through models, read-state projection, and frontend conversion without +promoting wattage into temperature history, resource lifecycle, storage health, +or alert metrics unless a separate governed contract adds that metric. That same monitoring owner also owns canonical unified-resource publication on `/api/state` and the websocket `state.resources` hydrate path. Monitoring must publish those resources from the same canonical unified snapshot that diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 621f4f836..898df884b 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -807,6 +807,12 @@ drawer-only rule. The mapper may render typed GPU sensor values already present on the selected resource payload, but it must not add GPU-specific history reads, per-row polling, browser-side `nvidia-smi` assumptions, or table-wide aggregation work. +Host-agent fan RPM, additional thermal readings, and power wattage rows in +`resourceDetailMappers.ts` follow the same drawer-only rule. The mapper may +render those sensor values already present on the selected resource payload, +but it must not add host powercap reads, sensor-specific history reads, +per-row polling, browser-side command assumptions, or table-wide aggregation +work. The Proxmox node drawer overview should follow the existing guest drawer compact detail-card pattern and expose node-specific context such as platform, kernel, hardware, raw capacity, telemetry, and thermal facts rather than diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 3f09119f0..3e62edc5f 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1978,6 +1978,11 @@ That same boundary applies to typed GPU host sensor metadata carried through readings may appear as descriptive host context, but storage and recovery must not reinterpret those values as disk cache, storage-tier health, backup freshness, restore evidence, or protection readiness. +That same boundary applies to host-agent `powerWatts` metadata carried through +`internal/unifiedresources/types.go`: wattage readings may appear as +descriptive host context, but storage and recovery must not reinterpret those +values as disk cache, storage-tier health, backup freshness, restore evidence, +power-protection evidence, or protection readiness. Storage and recovery still consume the shared unified-resource contract, but they do not own the timeline store itself. The canonical resource-change diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 5b2db41c4..2d7908a2b 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -327,6 +327,13 @@ readings through clone, merge, transport, and frontend decode paths, but those readings remain descriptive host context. Unified-resource consumers must not promote GPU sensors into separate hardware resources, lifecycle state, workload identity, or history targets beyond direct numeric temperature compatibility. +Host-agent wattage follows that same canonical sensor route. +`HostSensorMeta.powerWatts` may carry named power readings through clone, +merge, transport, and frontend decode paths, but those readings remain +descriptive host context. Unified-resource consumers must not promote power +sensors into separate hardware resources, lifecycle state, workload identity, +storage health, alert metrics, or history targets without a separate governed +metric contract. Metric bar fallbacks follow that split as well: unified-resource consumers own which CPU or memory value is selected, plus any source-specific fallback reason such as outdated standalone agent telemetry, while platform tables must use diff --git a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx index f537ca73f..89a620d4e 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx @@ -628,6 +628,34 @@ describe('UnifiedResourceTable performance contract', () => { expect(resourceDetailMappersSource).not.toContain('fetch('); }); + it('keeps power and fan detail rows bounded to the selected resource payload', () => { + expect( + buildTemperatureRows({ + additional: { vrm_temp: 55.6 }, + fanRpm: { chassis_fan: 1199.6 }, + powerWatts: { cpu_package: 82.4 }, + }), + ).toEqual([ + { + label: 'VRM Temp', + value: '56°C', + valueTitle: '55.6°C', + }, + { + label: 'Chassis Fan', + value: '1,200 RPM', + valueTitle: 'Chassis Fan 1,200 RPM', + }, + { + label: 'CPU Package Power', + value: '82.4 W', + valueTitle: 'CPU Package Power 82.4 W', + }, + ]); + expect(resourceDetailMappersSource).not.toContain('powercap'); + expect(resourceDetailMappersSource).not.toContain('fetch('); + }); + it('keeps hot-path table state and windowing in the shared table state owner', () => { expect(unifiedResourceTableSource).toContain('useUnifiedResourceTableState'); expect(unifiedResourceTableSource).toContain('UnifiedResourceHostTableCard'); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailMappers.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailMappers.test.ts index 78e9967f3..2b050da5f 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailMappers.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailMappers.test.ts @@ -154,6 +154,52 @@ describe('resourceDetailMappers', () => { }, ]); }); + + it('surfaces host power, fan, and additional sensor rows', () => { + const rows = buildTemperatureRows({ + temperatureCelsius: { + cpu_package: 41, + }, + additional: { + vrm_temp: 55.6, + }, + fanRpm: { + chassis_fan: 1199.6, + }, + powerWatts: { + cpu_package: 82.4, + dram: 13.2, + }, + }); + + expect(rows).toEqual([ + { + label: 'Package', + value: '41°C', + valueTitle: '41.0°C', + }, + { + label: 'VRM Temp', + value: '56°C', + valueTitle: '55.6°C', + }, + { + label: 'Chassis Fan', + value: '1,200 RPM', + valueTitle: 'Chassis Fan 1,200 RPM', + }, + { + label: 'CPU Package Power', + value: '82.4 W', + valueTitle: 'CPU Package Power 82.4 W', + }, + { + label: 'DRAM Power', + value: '13.2 W', + valueTitle: 'DRAM Power 13.2 W', + }, + ]); + }); }); describe('toNodeFromProxmox', () => { diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts b/frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts index 1ebac8ef4..b8d5a1364 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts @@ -325,6 +325,25 @@ export const formatSensorName = (name: string) => { return titleCaseDelimitedLabel(clean); }; +const formatSensorHardwareName = (name: string) => { + return titleCaseDelimitedLabel(name) + .replace(/\bCpu\b/g, 'CPU') + .replace(/\bDram\b/g, 'DRAM') + .replace(/\bGpu\b/g, 'GPU') + .replace(/\bVram\b/g, 'VRAM') + .replace(/\bVrm\b/g, 'VRM'); +}; + +const formatPowerSensorLabel = (name: string) => { + const label = formatSensorHardwareName(name); + return /\b(power|watts?|wattage)\b/i.test(label) ? label : `${label} Power`; +}; + +const formatFanSensorLabel = (name: string) => { + const label = formatSensorHardwareName(name); + return /\bfan\b/i.test(label) ? label : `${label} Fan`; +}; + const formatGPUStatsLabel = (id: string | undefined, index: number): string => { const trimmed = (id || '').trim(); return trimmed ? `GPU ${trimmed}` : `GPU ${index + 1}`; @@ -353,12 +372,24 @@ const formatGPUStatsValue = (gpu: NonNullable[number]) return parts.join(' · '); }; +const formatPowerWatts = (value: number) => { + if (!Number.isFinite(value)) return ''; + if (Math.abs(value) >= 100) return `${Math.round(value).toLocaleString()} W`; + return `${value.toFixed(1)} W`; +}; + +const formatFanRPM = (value: number) => { + if (!Number.isFinite(value)) return ''; + return `${Math.round(Math.max(0, value)).toLocaleString()} RPM`; +}; + const buildTypedGPUTemperatureKeys = (gpus?: HostSensorSummary['gpu']) => { const keys = new Set(); gpus?.forEach((gpu) => { const id = (gpu.id || '').trim(); if (!id) return; - if (typeof gpu.temperatureCelsius !== 'number' || !Number.isFinite(gpu.temperatureCelsius)) return; + if (typeof gpu.temperatureCelsius !== 'number' || !Number.isFinite(gpu.temperatureCelsius)) + return; keys.add(`gpu_nvidia_${id}`); }); return keys; @@ -406,6 +437,7 @@ export const buildTemperatureRows = (sensors?: HostSensorSummary) => { if (temps) { const entries = Object.entries(temps) .filter(([name]) => !typedGPUTemperatureKeys.has(name)) + .filter(([, temp]) => Number.isFinite(temp)) .sort(([a], [b]) => a.localeCompare(b)); entries.forEach(([name, temp]) => { rows.push({ @@ -416,6 +448,53 @@ export const buildTemperatureRows = (sensors?: HostSensorSummary) => { }); } + const additional = sensors?.additional; + if (additional) { + Object.entries(additional) + .filter(([, value]) => Number.isFinite(value)) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([name, value]) => { + const label = formatSensorHardwareName(name); + rows.push({ + label, + value: formatTemperature(value), + valueTitle: `${value.toFixed(1)}°C`, + }); + }); + } + + const fans = sensors?.fanRpm; + if (fans) { + Object.entries(fans) + .map(([name, value]) => [name, formatFanRPM(value)] as const) + .filter(([, value]) => value) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([name, value]) => { + const label = formatFanSensorLabel(name); + rows.push({ + label, + value, + valueTitle: `${label} ${value}`, + }); + }); + } + + const power = sensors?.powerWatts; + if (power) { + Object.entries(power) + .map(([name, value]) => [name, formatPowerWatts(value)] as const) + .filter(([, value]) => value) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([name, value]) => { + const label = formatPowerSensorLabel(name); + rows.push({ + label, + value, + valueTitle: `${label} ${value}`, + }); + }); + } + const smart = sensors?.smart; if (smart) { smart diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index f2e709d79..cfbdf2dfc 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -483,6 +483,7 @@ export interface HostNetworkInterface { export interface HostSensorSummary { temperatureCelsius?: Record; fanRpm?: Record; + powerWatts?: Record; additional?: Record; gpu?: HostGPUSensor[]; thermalState?: HostThermalState; diff --git a/internal/models/converters.go b/internal/models/converters.go index 3ada62a8e..9aab2e2c0 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -839,7 +839,7 @@ func (s DockerSwarmInfo) ToFrontend() DockerSwarmFrontend { } func hostSensorSummaryToFrontend(src HostSensorSummary) *HostSensorSummaryFrontend { - if len(src.TemperatureCelsius) == 0 && len(src.FanRPM) == 0 && len(src.Additional) == 0 && len(src.GPU) == 0 && src.ThermalState == nil && len(src.SMART) == 0 { + if len(src.TemperatureCelsius) == 0 && len(src.FanRPM) == 0 && len(src.PowerWatts) == 0 && len(src.Additional) == 0 && len(src.GPU) == 0 && src.ThermalState == nil && len(src.SMART) == 0 { return nil } @@ -850,6 +850,9 @@ func hostSensorSummaryToFrontend(src HostSensorSummary) *HostSensorSummaryFronte if len(src.FanRPM) > 0 { dest.FanRPM = copyStringFloatMap(src.FanRPM) } + if len(src.PowerWatts) > 0 { + dest.PowerWatts = copyStringFloatMap(src.PowerWatts) + } if len(src.Additional) > 0 { dest.Additional = copyStringFloatMap(src.Additional) } diff --git a/internal/models/converters_test.go b/internal/models/converters_test.go index 2648e66d1..1d37e6d22 100644 --- a/internal/models/converters_test.go +++ b/internal/models/converters_test.go @@ -611,6 +611,13 @@ func TestHostSensorSummaryToFrontend(t *testing.T) { }, nilCheck: false, }, + { + name: "with power watts", + input: HostSensorSummary{ + PowerWatts: map[string]float64{"cpu_package": 82.4}, + }, + nilCheck: false, + }, { name: "with additional", input: HostSensorSummary{ @@ -643,6 +650,12 @@ func TestHostSensorSummaryToFrontend(t *testing.T) { if result == nil { t.Error("hostSensorSummaryToFrontend should not return nil for non-empty sensors") } + if len(tc.input.PowerWatts) > 0 { + result.PowerWatts["cpu_package"] = 1 + if tc.input.PowerWatts["cpu_package"] != 82.4 { + t.Fatal("frontend power sensors should not share map with source") + } + } if tc.input.ThermalState != nil { if result.ThermalState == nil || result.ThermalState.Pressure != tc.input.ThermalState.Pressure { t.Fatalf("thermal state not copied to frontend: %+v", result.ThermalState) diff --git a/internal/models/deepcopy.go b/internal/models/deepcopy.go index 1d0db9418..3eac9f6a5 100644 --- a/internal/models/deepcopy.go +++ b/internal/models/deepcopy.go @@ -246,6 +246,7 @@ func cloneHostSensorSummary(src HostSensorSummary) HostSensorSummary { return HostSensorSummary{ TemperatureCelsius: cloneStringFloat64Map(src.TemperatureCelsius), FanRPM: cloneStringFloat64Map(src.FanRPM), + PowerWatts: cloneStringFloat64Map(src.PowerWatts), Additional: cloneStringFloat64Map(src.Additional), GPU: cloneHostGPUSensors(src.GPU), ThermalState: cloneHostThermalState(src.ThermalState), diff --git a/internal/models/models.go b/internal/models/models.go index 0fd587dda..7db8359bb 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -353,6 +353,7 @@ func (i HostNetworkInterface) NormalizeCollections() HostNetworkInterface { type HostSensorSummary struct { TemperatureCelsius map[string]float64 `json:"temperatureCelsius,omitempty"` FanRPM map[string]float64 `json:"fanRpm,omitempty"` + PowerWatts map[string]float64 `json:"powerWatts,omitempty"` Additional map[string]float64 `json:"additional,omitempty"` GPU []HostGPUSensor `json:"gpu,omitempty"` ThermalState *HostThermalState `json:"thermalState,omitempty"` @@ -384,6 +385,9 @@ func (s HostSensorSummary) NormalizeCollections() HostSensorSummary { if s.FanRPM == nil { s.FanRPM = map[string]float64{} } + if s.PowerWatts == nil { + s.PowerWatts = map[string]float64{} + } if s.Additional == nil { s.Additional = map[string]float64{} } diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index e820e6f93..4308daec2 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -749,6 +749,7 @@ func (h HostFrontend) NormalizeCollections() HostFrontend { type HostSensorSummaryFrontend struct { TemperatureCelsius map[string]float64 `json:"temperatureCelsius"` FanRPM map[string]float64 `json:"fanRpm"` + PowerWatts map[string]float64 `json:"powerWatts"` Additional map[string]float64 `json:"additional"` GPU []HostGPUSensorFrontend `json:"gpu"` ThermalState *HostThermalState `json:"thermalState,omitempty"` @@ -771,6 +772,9 @@ func (s HostSensorSummaryFrontend) NormalizeCollections() HostSensorSummaryFront if s.FanRPM == nil { s.FanRPM = map[string]float64{} } + if s.PowerWatts == nil { + s.PowerWatts = map[string]float64{} + } if s.Additional == nil { s.Additional = map[string]float64{} } diff --git a/internal/monitoring/host_agent_temps.go b/internal/monitoring/host_agent_temps.go index da86e7e60..4604d9c82 100644 --- a/internal/monitoring/host_agent_temps.go +++ b/internal/monitoring/host_agent_temps.go @@ -120,6 +120,7 @@ func convertUnifiedHostSensorsToTemperature(sensors *unifiedresources.HostSensor return convertHostSensorsToTemperature(models.HostSensorSummary{ TemperatureCelsius: cloneStringFloatMap(sensors.TemperatureCelsius), FanRPM: cloneStringFloatMap(sensors.FanRPM), + PowerWatts: cloneStringFloatMap(sensors.PowerWatts), Additional: cloneStringFloatMap(sensors.Additional), GPU: convertUnifiedHostGPU(sensors.GPU), SMART: convertUnifiedHostSMART(sensors.SMART), diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 53147043d..6971f79ac 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -3377,6 +3377,12 @@ func hostSensorsFromReadStateView(sensors *unifiedresources.HostSensorMeta) mode out.FanRPM[k] = v } } + if len(sensors.PowerWatts) > 0 { + out.PowerWatts = make(map[string]float64, len(sensors.PowerWatts)) + for k, v := range sensors.PowerWatts { + out.PowerWatts[k] = v + } + } if len(sensors.Additional) > 0 { out.Additional = make(map[string]float64, len(sensors.Additional)) for k, v := range sensors.Additional { diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index e53474fb5..0c45478e0 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -1936,6 +1936,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. Sensors: models.HostSensorSummary{ TemperatureCelsius: cloneStringFloatMap(report.Sensors.TemperatureCelsius), FanRPM: cloneStringFloatMap(report.Sensors.FanRPM), + PowerWatts: cloneStringFloatMap(report.Sensors.PowerWatts), Additional: cloneStringFloatMap(report.Sensors.Additional), GPU: convertAgentGPUToModels(report.Sensors.GPU), ThermalState: convertAgentThermalStateToModels(report.Sensors.ThermalState), @@ -2477,6 +2478,7 @@ func (m *Monitor) applyClusterSensors(entries []agentshost.ClusterNodeSensors, r sensors: models.HostSensorSummary{ TemperatureCelsius: cloneStringFloatMap(entry.Sensors.TemperatureCelsius), FanRPM: cloneStringFloatMap(entry.Sensors.FanRPM), + PowerWatts: cloneStringFloatMap(entry.Sensors.PowerWatts), Additional: cloneStringFloatMap(entry.Sensors.Additional), ThermalState: convertAgentThermalStateToModels(entry.Sensors.ThermalState), }, diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index b227953fe..e7f346ac6 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -1756,6 +1756,56 @@ func TestApplyHostReportPreservesGPUSensorSummary(t *testing.T) { } } +func TestApplyHostReportPreservesPowerSensorSummary(t *testing.T) { + monitor := &Monitor{ + state: models.NewState(), + alertManager: alerts.NewManager(), + hostTokenBindings: make(map[string]string), + config: &config.Config{}, + rateTracker: NewRateTracker(), + } + t.Cleanup(func() { monitor.alertManager.Stop() }) + + report := agentshost.Report{ + Agent: agentshost.AgentInfo{ + ID: "agent-power", + Version: "1.0.0", + IntervalSeconds: 30, + }, + Host: agentshost.HostInfo{ + ID: "machine-power", + Hostname: "power-node", + MachineID: "machine-power", + }, + Metrics: agentshost.Metrics{ + Memory: agentshost.MemoryMetric{TotalBytes: 1024, UsedBytes: 512, FreeBytes: 512, Usage: 50}, + }, + Sensors: agentshost.Sensors{ + PowerWatts: map[string]float64{"cpu_package": 82.4, "dram": 13.2}, + }, + Timestamp: time.Now().UTC(), + } + + host, err := monitor.ApplyHostReport(report, nil) + if err != nil { + t.Fatalf("ApplyHostReport: %v", err) + } + if got := host.Sensors.PowerWatts["cpu_package"]; got != 82.4 { + t.Fatalf("host power sensor = %.1f, want 82.4", got) + } + report.Sensors.PowerWatts["cpu_package"] = 1 + if got := host.Sensors.PowerWatts["cpu_package"]; got != 82.4 { + t.Fatalf("host power sensors share report map, got %.1f want 82.4", got) + } + + projected := hostSensorsFromReadStateView(&unifiedresources.HostSensorMeta{ + PowerWatts: map[string]float64{"cpu_package": 82.4}, + }) + if got := projected.PowerWatts["cpu_package"]; got != 82.4 { + t.Fatalf("read-state power projection = %.1f, want 82.4", got) + } +} + func TestApplyHostReportPersistsSMARTMetricsForAgentDisksWithFallbackID(t *testing.T) { t.Helper() diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 677bde24c..f9f67f400 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -168,7 +168,7 @@ func resourceFromHost(host models.Host) (Resource, ResourceIdentity) { storageAssessments := make([]storagehealth.Assessment, 0, len(host.RAID)+1) // Populate sensors - if len(host.Sensors.TemperatureCelsius) > 0 || len(host.Sensors.FanRPM) > 0 || len(host.Sensors.Additional) > 0 || len(host.Sensors.GPU) > 0 || host.Sensors.ThermalState != nil || len(host.Sensors.SMART) > 0 { + if len(host.Sensors.TemperatureCelsius) > 0 || len(host.Sensors.FanRPM) > 0 || len(host.Sensors.PowerWatts) > 0 || len(host.Sensors.Additional) > 0 || len(host.Sensors.GPU) > 0 || host.Sensors.ThermalState != nil || len(host.Sensors.SMART) > 0 { sensorMeta := &HostSensorMeta{} if len(host.Sensors.TemperatureCelsius) > 0 { sensorMeta.TemperatureCelsius = make(map[string]float64, len(host.Sensors.TemperatureCelsius)) @@ -182,6 +182,12 @@ func resourceFromHost(host models.Host) (Resource, ResourceIdentity) { sensorMeta.FanRPM[k] = v } } + if len(host.Sensors.PowerWatts) > 0 { + sensorMeta.PowerWatts = make(map[string]float64, len(host.Sensors.PowerWatts)) + for k, v := range host.Sensors.PowerWatts { + sensorMeta.PowerWatts[k] = v + } + } if len(host.Sensors.Additional) > 0 { sensorMeta.Additional = make(map[string]float64, len(host.Sensors.Additional)) for k, v := range host.Sensors.Additional { diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go index e20f4e7fe..ff7c61329 100644 --- a/internal/unifiedresources/adapters_test.go +++ b/internal/unifiedresources/adapters_test.go @@ -602,6 +602,32 @@ func TestResourceFromHostProjectsTypedGPUSensors(t *testing.T) { } } +func TestResourceFromHostProjectsPowerSensors(t *testing.T) { + host := models.Host{ + ID: "power-host", + Hostname: "power-node", + Platform: "linux", + Status: "online", + Sensors: models.HostSensorSummary{ + PowerWatts: map[string]float64{ + "cpu_package": 82.4, + "dram": 13.2, + }, + }, + } + + resource, _ := resourceFromHost(host) + if resource.Agent == nil || resource.Agent.Sensors == nil { + t.Fatalf("expected agent sensor payload, got %+v", resource.Agent) + } + if got := resource.Agent.Sensors.PowerWatts["cpu_package"]; got != 82.4 { + t.Fatalf("power sensor = %.1f, want 82.4", got) + } + if resource.Agent.Temperature != nil || len(resource.Agent.Sensors.TemperatureCelsius) != 0 { + t.Fatalf("power-only sensors must not create Celsius values: agent=%+v sensors=%+v", resource.Agent.Temperature, resource.Agent.Sensors) + } +} + func TestResourceFromVMPreservesProxmoxPool(t *testing.T) { vm := models.VM{ ID: "cluster-a:pve-a:101", diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index b23786908..b62ee3ab7 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -688,6 +688,7 @@ func cloneHostSensorMeta(in *HostSensorMeta) *HostSensorMeta { out := *in out.TemperatureCelsius = cloneStringFloat64Map(in.TemperatureCelsius) out.FanRPM = cloneStringFloat64Map(in.FanRPM) + out.PowerWatts = cloneStringFloat64Map(in.PowerWatts) out.Additional = cloneStringFloat64Map(in.Additional) out.GPU = cloneHostGPUSensors(in.GPU) out.ThermalState = cloneHostThermalState(in.ThermalState) diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 1ef35d292..100ed0b95 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -2225,6 +2225,24 @@ func TestCloneHostSensorMetaKeepsGPUSensorsIsolated(t *testing.T) { } } +func TestCloneHostSensorMetaKeepsPowerSensorsIsolated(t *testing.T) { + source := &HostSensorMeta{ + PowerWatts: map[string]float64{ + "cpu_package": 82.4, + "dram": 13.2, + }, + } + + clone := cloneHostSensorMeta(source) + if clone == nil { + t.Fatal("expected host sensor clone") + } + source.PowerWatts["cpu_package"] = 1 + if got := clone.PowerWatts["cpu_package"]; got != 82.4 { + t.Fatalf("power sensor clone = %.1f, want 82.4", got) + } +} + func TestCloneVMwareDataKeepsNestedRuntimeDetailsIsolated(t *testing.T) { repoRoot := filepath.Join("..", "..") clonePath := filepath.Join(repoRoot, "internal", "unifiedresources", "clone.go") diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index a1ebb8276..cdd983616 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -534,6 +534,7 @@ type SMARTMeta struct { type HostSensorMeta struct { TemperatureCelsius map[string]float64 `json:"temperatureCelsius,omitempty"` FanRPM map[string]float64 `json:"fanRpm,omitempty"` + PowerWatts map[string]float64 `json:"powerWatts,omitempty"` Additional map[string]float64 `json:"additional,omitempty"` GPU []HostGPUSensor `json:"gpu,omitempty"` ThermalState *HostThermalState `json:"thermalState,omitempty"`