feat(truenas): project aggregate disk temperatures

This commit is contained in:
rcourtman
2026-03-29 21:02:52 +01:00
parent 8590a7b17c
commit eebf5bd678
13 changed files with 528 additions and 71 deletions
@@ -233,6 +233,12 @@ That same monitoring boundary now also owns live TrueNAS disk temperatures.
project those readings into the canonical physical-disk model and risk path
instead of leaving temperature telemetry agent-only or adding a TrueNAS-local
presentation shim.
That same boundary now also owns recent aggregate TrueNAS disk temperature
history. `internal/truenas/client.go` must ingest `disk.temperature_agg`, and
`internal/truenas/provider.go` must project the returned min/avg/max readings
onto the shared `physicalDisk.temperatureAggregate` contract so disk-health
consumers can reuse one canonical metadata shape instead of inventing a
TrueNAS-only history payload.
That same boundary now also owns the canonical disk-history write path for
API-backed disks. `internal/monitoring/monitor.go` must sync non-native
physical-disk resources such as TrueNAS disks into the shared `disk`
@@ -164,6 +164,11 @@ populate canonical `physicalDisk.temperature` and reuse the shared
physical-disk risk semantics, so infrastructure, storage, charts, and AI read
the same disk-health contract instead of inventing a provider-local temperature
surface.
That same canonical disk contract now also owns recent aggregate temperature
history. When a provider such as TrueNAS can supply `disk.temperature_agg`
min/avg/max readings, it must project those onto
`physicalDisk.temperatureAggregate` instead of introducing a provider-local
history blob or a parallel disk-temperature presentation model.
TrueNAS-managed applications now follow the same canonical workload rule. One
TrueNAS app instance from `app.query` must project as one canonical
`app-container` resource under `SourceTrueNAS`, reusing the shared workload and
+194 -11
View File
@@ -29,6 +29,8 @@ const defaultRealtimeIntervalSeconds = 2
const defaultAppStatsIntervalSeconds = defaultRealtimeIntervalSeconds
const defaultDiskTemperatureAggregateWindowDays = 7
const defaultAppLogInitialWait = 2 * time.Second
const defaultAppLogIdleWait = 250 * time.Millisecond
@@ -295,10 +297,15 @@ func (c *Client) GetDisks(ctx context.Context) ([]Disk, error) {
if err := c.getJSON(ctx, http.MethodGet, "/disk", &response); err != nil {
return nil, err
}
temperatures, err := c.getDiskTemperaturesWithFallback(ctx, diskReportingIdentifiers(response))
identifiers := diskReportingIdentifiers(response)
temperatures, err := c.getDiskTemperaturesWithFallback(ctx, identifiers)
if err != nil {
temperatures = nil
}
aggregates, err := c.getDiskTemperatureAggregates(ctx, identifiers, defaultDiskTemperatureAggregateWindowDays)
if err != nil {
aggregates = nil
}
disks := make([]Disk, 0, len(response))
for _, item := range response {
@@ -319,16 +326,17 @@ func (c *Client) GetDisks(ctx context.Context) ([]Disk, error) {
}
disks = append(disks, Disk{
ID: diskID,
Name: strings.TrimSpace(item.Name),
Pool: strings.TrimSpace(item.Pool),
Status: strings.TrimSpace(item.Status),
Model: strings.TrimSpace(item.Model),
Serial: strings.TrimSpace(item.Serial),
SizeBytes: item.Size,
Temperature: temperatureForTrueNASDisk(temperatures, item),
Transport: strings.ToLower(strings.TrimSpace(item.Bus)),
Rotational: rotational,
ID: diskID,
Name: strings.TrimSpace(item.Name),
Pool: strings.TrimSpace(item.Pool),
Status: strings.TrimSpace(item.Status),
Model: strings.TrimSpace(item.Model),
Serial: strings.TrimSpace(item.Serial),
SizeBytes: item.Size,
Temperature: temperatureForTrueNASDisk(temperatures, item),
TemperatureAggregate: temperatureAggregateForTrueNASDisk(aggregates, item),
Transport: strings.ToLower(strings.TrimSpace(item.Bus)),
Rotational: rotational,
})
}
@@ -413,6 +421,25 @@ func (c *Client) getDiskTemperaturesFromReporting(ctx context.Context, identifie
return rpc.getDiskTemperatures(ctx, identifiers)
}
func (c *Client) getDiskTemperatureAggregates(ctx context.Context, identifiers []string, windowDays int) (map[string]DiskTemperatureAggregate, error) {
identifiers = dedupeStrings(identifiers)
if len(identifiers) == 0 {
return nil, fmt.Errorf("truenas disk temperature aggregates require disk identifiers")
}
conn, err := c.dialRPC(ctx)
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
rpc := &trueNASRPCClient{conn: conn, nextID: 1}
if err := rpc.authenticate(ctx, c.config); err != nil {
return nil, err
}
return rpc.getDiskTemperatureAggregates(ctx, identifiers, windowDays)
}
// GetAlerts returns active and dismissed TrueNAS alerts.
func (c *Client) GetAlerts(ctx context.Context) ([]Alert, error) {
var response []alertResponse
@@ -838,6 +865,26 @@ func temperatureForTrueNASDisk(temperatures map[string]int, item diskResponse) i
return 0
}
func temperatureAggregateForTrueNASDisk(aggregates map[string]DiskTemperatureAggregate, item diskResponse) DiskTemperatureAggregate {
if len(aggregates) == 0 {
return DiskTemperatureAggregate{}
}
keys := []string{
strings.TrimSpace(item.Name),
strings.TrimSpace(item.Identifier),
strings.TrimSpace(item.Serial),
}
for _, key := range keys {
if key == "" {
continue
}
if aggregate, ok := aggregates[key]; ok {
return aggregate
}
}
return DiskTemperatureAggregate{}
}
func parseDiskTemperatures(raw any) map[string]int {
switch typed := raw.(type) {
case nil:
@@ -1312,6 +1359,25 @@ func (c *trueNASRPCClient) getDiskTemperatures(ctx context.Context, identifiers
return parseReportingDiskTemperatures(response), nil
}
func (c *trueNASRPCClient) getDiskTemperatureAggregates(ctx context.Context, identifiers []string, windowDays int) (map[string]DiskTemperatureAggregate, error) {
if c == nil || c.conn == nil {
return nil, fmt.Errorf("truenas rpc connection is nil")
}
identifiers = dedupeStrings(identifiers)
if len(identifiers) == 0 {
return nil, fmt.Errorf("truenas rpc disk temperature aggregate query requires at least one identifier")
}
if windowDays <= 0 {
windowDays = defaultDiskTemperatureAggregateWindowDays
}
var response any
if err := c.call(ctx, "disk.temperature_agg", []any{identifiers, windowDays}, &response); err != nil {
return nil, err
}
return parseDiskTemperatureAggregates(response, windowDays), nil
}
func (c *trueNASRPCClient) getReportingData(ctx context.Context, graphs []map[string]any) ([]trueNASReportingGetDataResponse, error) {
if c == nil || c.conn == nil {
return nil, fmt.Errorf("truenas rpc connection is nil")
@@ -1549,6 +1615,123 @@ func parseReportingDiskTemperatures(responses []trueNASReportingGetDataResponse)
return temperatures
}
func parseDiskTemperatureAggregates(raw any, defaultWindowDays int) map[string]DiskTemperatureAggregate {
if defaultWindowDays <= 0 {
defaultWindowDays = defaultDiskTemperatureAggregateWindowDays
}
aggregates := make(map[string]DiskTemperatureAggregate)
switch typed := raw.(type) {
case map[string]any:
for identifier, entry := range typed {
aggregate, ok := parseDiskTemperatureAggregateEntry(entry, defaultWindowDays)
if !ok {
continue
}
aggregates[strings.TrimSpace(identifier)] = aggregate
}
case []any:
for _, entry := range typed {
record, ok := entry.(map[string]any)
if !ok {
continue
}
identifier := readStringAny(record, "identifier", "name", "disk", "disk_name", "diskName")
if identifier == "" {
continue
}
aggregate, ok := parseDiskTemperatureAggregateEntry(record, defaultWindowDays)
if !ok {
continue
}
aggregates[identifier] = aggregate
}
}
if len(aggregates) == 0 {
return nil
}
return aggregates
}
func parseDiskTemperatureAggregateEntry(raw any, defaultWindowDays int) (DiskTemperatureAggregate, bool) {
record, ok := raw.(map[string]any)
if !ok {
return DiskTemperatureAggregate{}, false
}
aggRecord := record
for _, key := range []string{"aggregations", "aggregation", "stats", "temperature_agg", "temperatureAgg"} {
if nested := readMapAny(record, key); len(nested) > 0 {
aggRecord = nested
break
}
}
minimum, okMinimum := readFloatValueAny(aggRecord, "min", "minimum", "low")
average, okAverage := readFloatValueAny(aggRecord, "avg", "average", "mean")
maximum, okMaximum := readFloatValueAny(aggRecord, "max", "maximum", "high")
if !okMinimum && !okAverage && !okMaximum {
return DiskTemperatureAggregate{}, false
}
windowDays := readIntValueAny(record, "days", "window_days", "windowDays")
if windowDays <= 0 {
windowDays = readIntValueAny(aggRecord, "days", "window_days", "windowDays")
}
if windowDays <= 0 {
windowDays = defaultWindowDays
}
return DiskTemperatureAggregate{
WindowDays: windowDays,
MinCelsius: minimum,
AvgCelsius: average,
MaxCelsius: maximum,
}, true
}
func readFloatValueAny(record map[string]any, keys ...string) (float64, bool) {
for _, key := range keys {
value, ok := record[key]
if !ok {
continue
}
if parsed, ok := parseFloat64Any(value); ok {
return parsed, true
}
}
return 0, false
}
func readIntValueAny(record map[string]any, keys ...string) int {
for _, key := range keys {
value, ok := record[key]
if !ok {
continue
}
switch typed := value.(type) {
case int:
return typed
case int64:
return int(typed)
case float64:
return int(math.Round(typed))
case json.Number:
if integer, err := typed.Int64(); err == nil {
return int(integer)
}
if floatValue, err := typed.Float64(); err == nil {
return int(math.Round(floatValue))
}
case string:
if parsed, err := strconv.Atoi(strings.TrimSpace(typed)); err == nil {
return parsed
}
}
}
return 0
}
func extractReportingLegendFloatValues(raw any, legends []string) map[string]float64 {
if raw == nil || len(legends) == 0 {
return nil
+104 -16
View File
@@ -627,6 +627,7 @@ func TestGetDiskTemperaturesFallsBackToReportingRPC(t *testing.T) {
}
func TestGetDisksFallsBackToReportingRPCWhenTemperatureEndpointUnavailable(t *testing.T) {
connectionCount := 0
server := newMockServerWithRPC(t, map[string]apiResponse{
"/api/v2.0/disk": {
body: `[{"identifier":"{disk-1}","name":"sda","serial":"SER-A","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE"}]`,
@@ -642,23 +643,41 @@ func TestGetDisksFallsBackToReportingRPCWhenTemperatureEndpointUnavailable(t *te
}
writeRPCResult(t, conn, authReq.ID, true)
temperatureReq := readRPCRequest(t, conn)
if temperatureReq.Method != "reporting.get_data" {
t.Fatalf("expected reporting.get_data, got %q", temperatureReq.Method)
}
writeRPCResult(t, conn, temperatureReq.ID, []map[string]any{{
"name": "disktemp",
"identifier": "sda",
"legend": []string{"temperature"},
"aggregations": map[string]any{
"mean": map[string]any{
"temperature": 43.2,
connectionCount++
request := readRPCRequest(t, conn)
switch connectionCount {
case 1:
if request.Method != "reporting.get_data" {
t.Fatalf("expected reporting.get_data, got %q", request.Method)
}
writeRPCResult(t, conn, request.ID, []map[string]any{{
"name": "disktemp",
"identifier": "sda",
"legend": []string{"temperature"},
"aggregations": map[string]any{
"mean": map[string]any{
"temperature": 43.2,
},
},
},
"data": []any{},
"start": time.Now().Add(-5 * time.Minute).Unix(),
"end": time.Now().Unix(),
}})
"data": []any{},
"start": time.Now().Add(-5 * time.Minute).Unix(),
"end": time.Now().Unix(),
}})
case 2:
if request.Method != "disk.temperature_agg" {
t.Fatalf("expected disk.temperature_agg, got %q", request.Method)
}
writeRPCResult(t, conn, request.ID, map[string]any{
"sda": map[string]any{
"min": 39.0,
"avg": 41.6,
"max": 45.0,
"window_days": 7,
},
})
default:
t.Fatalf("unexpected extra websocket connection %d", connectionCount)
}
})
t.Cleanup(server.Close)
@@ -673,6 +692,75 @@ func TestGetDisksFallsBackToReportingRPCWhenTemperatureEndpointUnavailable(t *te
if got := disks[0].Temperature; got != 43 {
t.Fatalf("expected reporting fallback temperature 43, got %+v", disks[0])
}
if got := disks[0].TemperatureAggregate.MaxCelsius; got != 45.0 {
t.Fatalf("expected aggregate max 45.0, got %+v", disks[0].TemperatureAggregate)
}
}
func TestGetDisksIncludesDiskTemperatureAggregatesFromRPC(t *testing.T) {
server := newMockServerWithRPC(t, map[string]apiResponse{
"/api/v2.0/disk": {
body: `[{"identifier":"{disk-1}","name":"sda","serial":"SER-A","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE"}]`,
},
"/api/v2.0/disk/temperatures": {
body: `{"sda":34}`,
},
}, nil, func(t *testing.T, conn *websocket.Conn) {
authReq := readRPCRequest(t, conn)
if authReq.Method != "auth.login_with_api_key" {
t.Fatalf("expected api-key auth method, got %q", authReq.Method)
}
writeRPCResult(t, conn, authReq.ID, true)
aggregateReq := readRPCRequest(t, conn)
if aggregateReq.Method != "disk.temperature_agg" {
t.Fatalf("expected disk.temperature_agg, got %q", aggregateReq.Method)
}
params, ok := aggregateReq.Params.([]any)
if !ok || len(params) != 2 {
t.Fatalf("unexpected aggregate params: %#v", aggregateReq.Params)
}
identifiers, ok := params[0].([]any)
if !ok || len(identifiers) != 1 {
t.Fatalf("unexpected aggregate identifiers: %#v", params[0])
}
if got := strings.TrimSpace(fmt.Sprint(identifiers[0])); got != "sda" {
t.Fatalf("expected sda identifier, got %q", got)
}
if got := int(readFloatAny(map[string]any{"value": params[1]}, "value")); got != defaultDiskTemperatureAggregateWindowDays {
t.Fatalf("expected window %d, got %#v", defaultDiskTemperatureAggregateWindowDays, params[1])
}
writeRPCResult(t, conn, aggregateReq.ID, map[string]any{
"sda": map[string]any{
"min": 29.0,
"avg": 32.8,
"max": 38.0,
"window_days": 7,
},
})
})
t.Cleanup(server.Close)
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
disks, err := client.GetDisks(context.Background())
if err != nil {
t.Fatalf("GetDisks() error = %v", err)
}
if len(disks) != 1 {
t.Fatalf("expected 1 disk, got %d", len(disks))
}
if got := disks[0].TemperatureAggregate.WindowDays; got != 7 {
t.Fatalf("expected aggregate window 7, got %+v", disks[0].TemperatureAggregate)
}
if got := disks[0].TemperatureAggregate.MinCelsius; got != 29.0 {
t.Fatalf("expected aggregate min 29.0, got %+v", disks[0].TemperatureAggregate)
}
if got := disks[0].TemperatureAggregate.AvgCelsius; got != 32.8 {
t.Fatalf("expected aggregate avg 32.8, got %+v", disks[0].TemperatureAggregate)
}
if got := disks[0].TemperatureAggregate.MaxCelsius; got != 38.0 {
t.Fatalf("expected aggregate max 38.0, got %+v", disks[0].TemperatureAggregate)
}
}
func TestClientHandlesHTTPAndDecodeErrors(t *testing.T) {
+32 -8
View File
@@ -108,8 +108,14 @@ func DefaultFixtures() FixtureSnapshot {
Serial: "ZL0A1234",
SizeBytes: 16 * 1024 * 1024 * 1024 * 1024,
Temperature: 34,
Transport: "sata",
Rotational: true,
TemperatureAggregate: DiskTemperatureAggregate{
WindowDays: 7,
MinCelsius: 29.0,
AvgCelsius: 32.7,
MaxCelsius: 38.0,
},
Transport: "sata",
Rotational: true,
},
{
ID: "disk-sdb",
@@ -120,8 +126,14 @@ func DefaultFixtures() FixtureSnapshot {
Serial: "ZL0A1235",
SizeBytes: 16 * 1024 * 1024 * 1024 * 1024,
Temperature: 36,
Transport: "sata",
Rotational: true,
TemperatureAggregate: DiskTemperatureAggregate{
WindowDays: 7,
MinCelsius: 31.0,
AvgCelsius: 34.5,
MaxCelsius: 40.0,
},
Transport: "sata",
Rotational: true,
},
{
ID: "disk-nvme0n1",
@@ -132,8 +144,14 @@ func DefaultFixtures() FixtureSnapshot {
Serial: "S65ANX0R123456",
SizeBytes: 2 * 1024 * 1024 * 1024 * 1024,
Temperature: 48,
Transport: "nvme",
Rotational: false,
TemperatureAggregate: DiskTemperatureAggregate{
WindowDays: 7,
MinCelsius: 41.0,
AvgCelsius: 45.8,
MaxCelsius: 52.0,
},
Transport: "nvme",
Rotational: false,
},
{
ID: "disk-sdc",
@@ -144,8 +162,14 @@ func DefaultFixtures() FixtureSnapshot {
Serial: "WD-WX12A3456",
SizeBytes: 20 * 1024 * 1024 * 1024 * 1024,
Temperature: 63,
Transport: "sas",
Rotational: true,
TemperatureAggregate: DiskTemperatureAggregate{
WindowDays: 7,
MinCelsius: 52.0,
AvgCelsius: 58.9,
MaxCelsius: 66.0,
},
Transport: "sas",
Rotational: true,
},
},
Alerts: []Alert{
+24 -10
View File
@@ -490,16 +490,17 @@ func (p *Provider) Records() []unifiedresources.IngestRecord {
LastSeen: collectedAt,
UpdatedAt: collectedAt,
PhysicalDisk: &unifiedresources.PhysicalDiskMeta{
DevPath: "/dev/" + disk.Name,
Model: disk.Model,
Serial: disk.Serial,
DiskType: disk.Transport,
SizeBytes: disk.SizeBytes,
Health: healthFromDisk(disk),
Temperature: disk.Temperature,
Wearout: -1,
RPM: rpmFromDisk(disk),
Risk: unifiedresources.PhysicalDiskRiskFromAssessment(assessment),
DevPath: "/dev/" + disk.Name,
Model: disk.Model,
Serial: disk.Serial,
DiskType: disk.Transport,
SizeBytes: disk.SizeBytes,
Health: healthFromDisk(disk),
Temperature: disk.Temperature,
TemperatureAggregate: temperatureAggregateMetaFromTrueNASDisk(disk),
Wearout: -1,
RPM: rpmFromDisk(disk),
Risk: unifiedresources.PhysicalDiskRiskFromAssessment(assessment),
},
Tags: []string{"truenas", "disk", disk.Transport},
Incidents: incidents,
@@ -1322,6 +1323,19 @@ func rpmFromDisk(disk Disk) int {
return 0
}
func temperatureAggregateMetaFromTrueNASDisk(disk Disk) *unifiedresources.TemperatureAggregateMeta {
aggregate := disk.TemperatureAggregate
if aggregate.WindowDays <= 0 && aggregate.MinCelsius <= 0 && aggregate.AvgCelsius <= 0 && aggregate.MaxCelsius <= 0 {
return nil
}
return &unifiedresources.TemperatureAggregateMeta{
WindowDays: aggregate.WindowDays,
MinCelsius: aggregate.MinCelsius,
AvgCelsius: aggregate.AvgCelsius,
MaxCelsius: aggregate.MaxCelsius,
}
}
func parentPoolFromDataset(datasetName string) string {
parts := strings.SplitN(strings.TrimSpace(datasetName), "/", 2)
if len(parts) == 0 {
+33
View File
@@ -725,3 +725,36 @@ func TestRecordsElevateOnlineDiskWhenTemperatureCritical(t *testing.T) {
t.Fatalf("expected hot disk critical risk, got %+v", diskRecord.Resource.PhysicalDisk.Risk)
}
}
func TestRecordsProjectDiskTemperatureAggregatesIntoCanonicalMetadata(t *testing.T) {
previous := IsFeatureEnabled()
SetFeatureEnabled(true)
t.Cleanup(func() {
SetFeatureEnabled(previous)
})
provider := NewProvider(DefaultFixtures())
records := provider.Records()
if len(records) == 0 {
t.Fatal("expected fixture records from provider")
}
for _, record := range records {
if record.Resource.Type != unifiedresources.ResourceTypePhysicalDisk || record.Resource.Name != "sda" {
continue
}
if record.Resource.PhysicalDisk == nil {
t.Fatal("expected canonical physical-disk metadata")
}
aggregate := record.Resource.PhysicalDisk.TemperatureAggregate
if aggregate == nil {
t.Fatalf("expected temperature aggregate on canonical physical disk: %+v", record.Resource.PhysicalDisk)
}
if aggregate.WindowDays != 7 || aggregate.MinCelsius != 29.0 || aggregate.AvgCelsius != 32.7 || aggregate.MaxCelsius != 38.0 {
t.Fatalf("unexpected canonical disk temperature aggregate: %+v", aggregate)
}
return
}
t.Fatal("expected sda physical disk record")
}
+20 -10
View File
@@ -59,16 +59,26 @@ type Dataset struct {
// Disk mirrors a TrueNAS disk listing entry.
type Disk struct {
ID string
Name string
Pool string
Status string
Model string
Serial string
SizeBytes int64
Temperature int
Transport string
Rotational bool
ID string
Name string
Pool string
Status string
Model string
Serial string
SizeBytes int64
Temperature int
TemperatureAggregate DiskTemperatureAggregate
Transport string
Rotational bool
}
// DiskTemperatureAggregate stores recent aggregate disk-temperature history
// derived from the native TrueNAS disk.temperature_agg API.
type DiskTemperatureAggregate struct {
WindowDays int
MinCelsius float64
AvgCelsius float64
MaxCelsius float64
}
// Alert mirrors a TrueNAS alert listing entry.
@@ -125,6 +125,23 @@ func TestResourceRelationshipFieldsDefaultToNil(t *testing.T) {
}
}
func TestPhysicalDiskTemperatureAggregateDefaultsToNil(t *testing.T) {
meta := PhysicalDiskMeta{}
if meta.TemperatureAggregate != nil {
t.Fatalf("TemperatureAggregate should default to nil, got %+v", meta.TemperatureAggregate)
}
meta.TemperatureAggregate = &TemperatureAggregateMeta{
WindowDays: 7,
MinCelsius: 29.0,
AvgCelsius: 32.7,
MaxCelsius: 38.0,
}
if meta.TemperatureAggregate.WindowDays != 7 || meta.TemperatureAggregate.MaxCelsius != 38.0 {
t.Fatalf("unexpected temperature aggregate assignment: %+v", meta.TemperatureAggregate)
}
}
func TestIsUnsupportedLegacyResourceIDAlias(t *testing.T) {
tests := []struct {
name string
+9
View File
@@ -289,11 +289,20 @@ func clonePhysicalDiskMeta(in *PhysicalDiskMeta) *PhysicalDiskMeta {
return nil
}
out := *in
out.TemperatureAggregate = cloneTemperatureAggregateMeta(in.TemperatureAggregate)
out.SMART = cloneSMARTMeta(in.SMART)
out.Risk = clonePhysicalDiskRisk(in.Risk)
return &out
}
func cloneTemperatureAggregateMeta(in *TemperatureAggregateMeta) *TemperatureAggregateMeta {
if in == nil {
return nil
}
out := *in
return &out
}
func cloneHostUnraidMeta(in *HostUnraidMeta) *HostUnraidMeta {
if in == nil {
return nil
+3
View File
@@ -1056,6 +1056,9 @@ func mergePhysicalDiskData(existing *PhysicalDiskMeta, incoming *PhysicalDiskMet
if incoming.Temperature > 0 && (merged.Temperature == 0 || incoming.SMART != nil || merged.SMART == nil) {
merged.Temperature = incoming.Temperature
}
if incoming.TemperatureAggregate != nil {
merged.TemperatureAggregate = cloneTemperatureAggregateMeta(incoming.TemperatureAggregate)
}
if incoming.RPM > 0 {
merged.RPM = incoming.RPM
}
@@ -99,6 +99,61 @@ func TestResourceRegistry_ListByType_Empty(t *testing.T) {
}
}
func TestResourceRegistry_MergesPhysicalDiskTemperatureAggregate(t *testing.T) {
rr := NewRegistry(nil)
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
rr.IngestRecords(SourceTrueNAS, []IngestRecord{
{
SourceID: "disk-sda",
Resource: Resource{
Type: ResourceTypePhysicalDisk,
Name: "sda",
Status: StatusOnline,
LastSeen: now,
PhysicalDisk: &PhysicalDiskMeta{
DevPath: "/dev/sda",
Model: "Seagate Exos X18",
Serial: "SER-A",
DiskType: "sata",
SizeBytes: 1_000_000,
Health: "PASSED",
Temperature: 34,
Wearout: -1,
TemperatureAggregate: &TemperatureAggregateMeta{
WindowDays: 7,
MinCelsius: 29.0,
AvgCelsius: 32.7,
MaxCelsius: 38.0,
},
},
},
Identity: ResourceIdentity{MachineID: "SER-A"},
},
})
disks := rr.ListByType(ResourceTypePhysicalDisk)
if len(disks) != 1 {
t.Fatalf("expected 1 physical disk, got %d", len(disks))
}
aggregate := disks[0].PhysicalDisk.TemperatureAggregate
if aggregate == nil {
t.Fatalf("expected temperature aggregate on merged disk record: %+v", disks[0].PhysicalDisk)
}
if aggregate.WindowDays != 7 || aggregate.MinCelsius != 29.0 || aggregate.AvgCelsius != 32.7 || aggregate.MaxCelsius != 38.0 {
t.Fatalf("unexpected merged temperature aggregate: %+v", aggregate)
}
aggregate.MaxCelsius = 99.0
got, ok := rr.Get(disks[0].ID)
if !ok || got == nil || got.PhysicalDisk == nil || got.PhysicalDisk.TemperatureAggregate == nil {
t.Fatalf("expected stored physical disk record, got %+v", got)
}
if got.PhysicalDisk.TemperatureAggregate.MaxCelsius != 38.0 {
t.Fatalf("expected registry clone isolation for temperature aggregate, got %+v", got.PhysicalDisk.TemperatureAggregate)
}
}
func TestResourceRegistryClonesCarryPolicyMetadata(t *testing.T) {
rr := NewRegistry(nil)
now := time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC)
+26 -16
View File
@@ -315,22 +315,32 @@ type ResourceIncident struct {
// PhysicalDiskMeta contains physical disk-specific metadata.
type PhysicalDiskMeta struct {
DevPath string `json:"devPath"`
Model string `json:"model,omitempty"`
Serial string `json:"serial,omitempty"`
WWN string `json:"wwn,omitempty"`
DiskType string `json:"diskType"` // nvme, sata, sas
SizeBytes int64 `json:"sizeBytes"`
Health string `json:"health"` // PASSED, FAILED, UNKNOWN
Wearout int `json:"wearout"` // 0-100, -1 unavailable
Temperature int `json:"temperature"` // Celsius
RPM int `json:"rpm"`
Used string `json:"used,omitempty"`
StorageRole string `json:"storageRole,omitempty"`
StorageGroup string `json:"storageGroup,omitempty"`
StorageState string `json:"storageState,omitempty"`
SMART *SMARTMeta `json:"smart,omitempty"`
Risk *PhysicalDiskRisk `json:"risk,omitempty"`
DevPath string `json:"devPath"`
Model string `json:"model,omitempty"`
Serial string `json:"serial,omitempty"`
WWN string `json:"wwn,omitempty"`
DiskType string `json:"diskType"` // nvme, sata, sas
SizeBytes int64 `json:"sizeBytes"`
Health string `json:"health"` // PASSED, FAILED, UNKNOWN
Wearout int `json:"wearout"` // 0-100, -1 unavailable
Temperature int `json:"temperature"` // Celsius
TemperatureAggregate *TemperatureAggregateMeta `json:"temperatureAggregate,omitempty"`
RPM int `json:"rpm"`
Used string `json:"used,omitempty"`
StorageRole string `json:"storageRole,omitempty"`
StorageGroup string `json:"storageGroup,omitempty"`
StorageState string `json:"storageState,omitempty"`
SMART *SMARTMeta `json:"smart,omitempty"`
Risk *PhysicalDiskRisk `json:"risk,omitempty"`
}
// TemperatureAggregateMeta stores recent aggregate temperature history for a
// resource sensor where the provider can supply min/avg/max readings.
type TemperatureAggregateMeta struct {
WindowDays int `json:"windowDays,omitempty"`
MinCelsius float64 `json:"minCelsius,omitempty"`
AvgCelsius float64 `json:"avgCelsius,omitempty"`
MaxCelsius float64 `json:"maxCelsius,omitempty"`
}
type StorageRisk struct {