From eebf5bd67849854eceed2aac9370739ce7501549 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 29 Mar 2026 21:02:52 +0100 Subject: [PATCH] feat(truenas): project aggregate disk temperatures --- .../v6/internal/subsystems/monitoring.md | 6 + .../internal/subsystems/unified-resources.md | 5 + internal/truenas/client.go | 205 +++++++++++++++++- internal/truenas/client_test.go | 120 ++++++++-- internal/truenas/fixtures.go | 40 +++- internal/truenas/provider.go | 34 ++- internal/truenas/provider_test.go | 33 +++ internal/truenas/types.go | 30 ++- .../canonical_ids_types_test.go | 17 ++ internal/unifiedresources/clone.go | 9 + internal/unifiedresources/registry.go | 3 + internal/unifiedresources/registry_test.go | 55 +++++ internal/unifiedresources/types.go | 42 ++-- 13 files changed, 528 insertions(+), 71 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 6257dcf36..4602ff992 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -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` diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 06d0d7338..4265e7792 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/internal/truenas/client.go b/internal/truenas/client.go index 9462b7151..b3ef8cff1 100644 --- a/internal/truenas/client.go +++ b/internal/truenas/client.go @@ -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 diff --git a/internal/truenas/client_test.go b/internal/truenas/client_test.go index 48d146ba2..95c2d67f3 100644 --- a/internal/truenas/client_test.go +++ b/internal/truenas/client_test.go @@ -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) { diff --git a/internal/truenas/fixtures.go b/internal/truenas/fixtures.go index 343ac73ae..627e0a275 100644 --- a/internal/truenas/fixtures.go +++ b/internal/truenas/fixtures.go @@ -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{ diff --git a/internal/truenas/provider.go b/internal/truenas/provider.go index 9e05a74bc..df181d023 100644 --- a/internal/truenas/provider.go +++ b/internal/truenas/provider.go @@ -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 { diff --git a/internal/truenas/provider_test.go b/internal/truenas/provider_test.go index 7da3dbeea..d82bf6907 100644 --- a/internal/truenas/provider_test.go +++ b/internal/truenas/provider_test.go @@ -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") +} diff --git a/internal/truenas/types.go b/internal/truenas/types.go index c3a479c5a..c8074d191 100644 --- a/internal/truenas/types.go +++ b/internal/truenas/types.go @@ -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. diff --git a/internal/unifiedresources/canonical_ids_types_test.go b/internal/unifiedresources/canonical_ids_types_test.go index f5234124b..0811ddb91 100644 --- a/internal/unifiedresources/canonical_ids_types_test.go +++ b/internal/unifiedresources/canonical_ids_types_test.go @@ -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 diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index 187f09319..22a1a10be 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -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 diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index fcdf8530c..972ca6716 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -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 } diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index 10286beb0..4b8798fc4 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -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) diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index b7028d669..e1377b578 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -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 {