Carry host power sensor readings

This commit is contained in:
rcourtman 2026-06-30 10:02:49 +01:00
parent d393ccf310
commit 7c75b13d2c
23 changed files with 322 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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');

View file

@ -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', () => {

View file

@ -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<HostSensorSummary['gpu']>[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<string>();
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

View file

@ -483,6 +483,7 @@ export interface HostNetworkInterface {
export interface HostSensorSummary {
temperatureCelsius?: Record<string, number>;
fanRpm?: Record<string, number>;
powerWatts?: Record<string, number>;
additional?: Record<string, number>;
gpu?: HostGPUSensor[];
thermalState?: HostThermalState;

View file

@ -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)
}

View file

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

View file

@ -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),

View file

@ -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{}
}

View file

@ -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{}
}

View file

@ -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),

View file

@ -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 {

View file

@ -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),
},

View file

@ -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()

View file

@ -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 {

View file

@ -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",

View file

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

View file

@ -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")

View file

@ -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"`