mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Carry host power sensor readings
This commit is contained in:
parent
d393ccf310
commit
7c75b13d2c
23 changed files with 322 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue