From a46e322bde6280eaa7c77a1d19d13bd708e9aaba Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:00:47 +0100 Subject: [PATCH] Expose TrueNAS SMART alert counters --- docs/TRUENAS.md | 15 ++-- .../subsystems/deployment-installability.md | 7 ++ .../v6/internal/subsystems/monitoring.md | 7 +- docs/releases/RELEASE_NOTES_v6.4.0-rc.13.md | 2 +- docs/releases/V6_CHANGELOG_v6.4.0-rc.13.md | 2 +- internal/truenas/client.go | 71 ++++++++++++++----- internal/truenas/client_test.go | 49 +++++++++++-- internal/truenas/provider.go | 48 ++++++++++--- internal/truenas/provider_test.go | 50 ++++++++++--- internal/truenas/types.go | 22 +++--- .../render_release_body_test.py | 11 +++ 11 files changed, 226 insertions(+), 58 deletions(-) diff --git a/docs/TRUENAS.md b/docs/TRUENAS.md index 7eb4400b3..880dfe1de 100644 --- a/docs/TRUENAS.md +++ b/docs/TRUENAS.md @@ -18,9 +18,11 @@ On your TrueNAS system: 2. Click **Add** and create a new key. 3. Copy the key value and paste it into Pulse. -> **Tip**: A read-only key is sufficient for monitoring on most TrueNAS versions. Native app control actions require a key with the corresponding TrueNAS app permissions. -> -> **TrueNAS SCALE 25.10**: API keys are linked to a user, and keys for users with the Readonly Admin role can be rejected with 403 on endpoints Pulse polls (TrueNAS serves these through its deprecated REST bridge). Until Pulse moves to the TrueNAS WebSocket API, use a key linked to a Full Admin user on 25.10. +> **Tip**: Pulse uses the supported JSON-RPC WebSocket API on TrueNAS 25.04 +> and later. API keys inherit the linked user's roles, so the user must be able +> to read the methods Pulse polls. Native app control actions require the +> corresponding TrueNAS app permissions. Legacy releases continue to use the +> version-gated REST compatibility path. ## What Gets Monitored @@ -31,7 +33,7 @@ On your TrueNAS system: | Apps | TrueNAS Overview | Native app state, image/version, ports, volumes, networks, and runtime container details | | ZFS Pools | Storage | Total/used/free capacity, pool status (ONLINE/DEGRADED/FAULTED) | | ZFS Datasets | Storage | Used/available space, mount status, read-only flag | -| Physical Disks | Storage | Model, serial, size, transport type, rotational flag, temperature, and native SMART failure evidence | +| Physical Disks | Storage | Model, serial, size, transport type, rotational flag, temperature, and native SMART failure/counter evidence when TrueNAS reports it | | ZFS Snapshots | Recovery | Dataset, creation time, size, referenced data | | Replication Tasks | Recovery | Source/target datasets, direction, last run status | | TrueNAS Alerts | Alerts | Native TrueNAS alert messages and severity levels | @@ -51,7 +53,10 @@ TrueNAS drive-health alerts that identify a specific disk remain disk-health evidence after they are dismissed in TrueNAS. Dismissal acknowledges the notification; Pulse continues to show the affected disk risk while TrueNAS continues to report the underlying SMART condition. Other dismissed TrueNAS -alerts remain suppressed. +alerts remain suppressed. When a supported SMART alert includes TrueNAS's +typed uncorrectable-error or spare-reserve argument, Pulse also projects that +value into the disk's SMART details. Pulse does not infer counters from alert +text, and current TrueNAS APIs do not expose every raw SMART attribute. Resources from TrueNAS can be filtered using the **source** filter on any page. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 1085b5e9a..3825eefaa 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2740,6 +2740,13 @@ release-body verification: the exact claim is pinned in must still pass canonical shape validation. The `v6.4.0-rc.2` packet therefore binds the non-running container stale-health correction under **Fixes** without changing candidate artifact identity or dispatch authority. +The `v6.4.0-rc.13` packet may continue accumulating compatible fixes while its +public observation window prevents publication. When that happens, customer +notes and the categorized changelog must be refreshed in the same commit as +the added fix, and release-body proof must pin the new customer-facing claim. +The TrueNAS SMART follow-up is therefore bound to its supported typed-counter +claim; packet proof must reject notes that drop that claim or cease to pass +canonical shape validation. Release-note transport is file-backed and fail-closed: operator helpers must send the Markdown through JSON input rather than multiline form-field substitution, and every `gh workflow run --json` input value must be encoded as diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 7c50c14d3..cd32f8d01 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -2634,7 +2634,12 @@ dismissal acknowledges notification state but does not erase monotonic hardware evidence. Other dismissed alerts, and dismissed SMART alerts with missing or ambiguous disk identity, remain suppressed. Native uncorrectable-error, failed self-test, and low-spare-block classes map to critical canonical disk risk even -when TrueNAS labels the source alert as a warning. +when TrueNAS labels the source alert as a warning. For the corresponding native +classes, typed `ue` and `sb` arguments from `alert.list` project onto canonical +media-error and available-spare SMART fields after exact disk resolution. The +provider must reject negative or out-of-range values, retain the worst value +when duplicate evidence is present, and never derive a counter from formatted +alert text. The same boundary owns TrueNAS `smart_status` normalization. `internal/truenas/client.go` must parse REST and RPC SMART status separately from native disk state, and `internal/truenas/disk_health.go` plus `internal/truenas/provider.go` must map diff --git a/docs/releases/RELEASE_NOTES_v6.4.0-rc.13.md b/docs/releases/RELEASE_NOTES_v6.4.0-rc.13.md index 6b3bbe793..25e223069 100644 --- a/docs/releases/RELEASE_NOTES_v6.4.0-rc.13.md +++ b/docs/releases/RELEASE_NOTES_v6.4.0-rc.13.md @@ -10,7 +10,7 @@ - **Accurate Unraid empty-slot handling** - Sentinel entries used for unassigned array slots remain neutral across agent collection and monitoring, preventing empty slots from being reported as real disks or degraded storage. - **Atomic credential lifecycle** - API token creation, agent enrollment, and agent removal roll back live state when durable persistence fails, preventing undisclosed credentials, accidental eviction, or revocations that reverse after restart. - **Accurate Anthropic budgets** - Current Opus and Haiku versions use version-specific first-party rates, so stale estimates no longer stop Patrol early or understate current Haiku usage. -- **Stronger disk identity and risk** - PVE and agent observations of the same RAID volume merge across normalized serial and WWN forms, while dismissed TrueNAS SMART alerts continue to flag uniquely identified disks with critical hardware evidence. +- **Stronger disk identity and risk** - Duplicate RAID volumes merge across normalized serial and WWN forms. Dismissed TrueNAS SMART alerts still flag uniquely identified disks and expose supported uncorrectable-error and spare-reserve values. - **More stable incident investigation** - Mobile alert timelines keep context and actions together, history charts expose readable axes, grouped charts synchronize hover timestamps, and View preferences expand inline without covering tables. - **Durable alert lifecycles** - Alert history and active state rebuild from the event log after restarts, with persisted identities migrated automatically and false all-clear states prevented during hydration. - **Better notification control** - Alerts can be snoozed individually, maintenance can recur by scope, escalation repeats can target specific destinations, delivery routes can filter by severity, and informational events remain distinct from warnings. diff --git a/docs/releases/V6_CHANGELOG_v6.4.0-rc.13.md b/docs/releases/V6_CHANGELOG_v6.4.0-rc.13.md index a96451a84..bfa8c348c 100644 --- a/docs/releases/V6_CHANGELOG_v6.4.0-rc.13.md +++ b/docs/releases/V6_CHANGELOG_v6.4.0-rc.13.md @@ -42,7 +42,7 @@ This changelog describes the changes since `v6.4.0-rc.12`. The candidate also re - Failed token persistence no longer leaves an undisclosed new token active, evicts an older valid token, or allows an apparent agent-token revocation to reverse after restart. - Current Anthropic Opus usage is no longer overestimated at legacy rates, and Haiku 4.5 is no longer underestimated at Haiku 3 rates. - PVE and agent observations of the same RAID-controller volume no longer render duplicate resources when one source frames the NAA identity as a serial and the other as a WWN. -- Dismissed TrueNAS SMART alerts retain uniquely resolved critical disk evidence instead of allowing a damaged disk to return to healthy presentation. +- Dismissed TrueNAS SMART alerts retain uniquely resolved critical disk evidence and typed uncorrectable-error or spare-reserve values instead of allowing a damaged disk to return to healthy presentation. - Mobile alert timelines remain scroll-stable, selected incident history revalidates correctly, chart axis labels remain readable, and grouped hover timestamps stay aligned. - Restart recovery, history queries, and mock alert timelines preserve lifecycle order, observation time, and complete incident evidence. - Fresh rolling-window metric data remains authoritative, including when older samples or counter resets are present. diff --git a/internal/truenas/client.go b/internal/truenas/client.go index 1fa5a4a83..dd881901a 100644 --- a/internal/truenas/client.go +++ b/internal/truenas/client.go @@ -1225,16 +1225,23 @@ func (c *Client) getAlertsRPC(ctx context.Context) ([]Alert, error) { if t := readTimeAny(item, "datetime", "last_occurrence", "lastOccurrence"); t != nil { datetime = *t } + uncorrected, uncorrectedReported, availableSpare, availableReported := smartAlertEvidenceFromArgs( + readStringAny(item, "klass", "class"), args, + ) alerts = append(alerts, Alert{ - ID: id, - Level: strings.TrimSpace(readStringAny(item, "level")), - Message: strings.TrimSpace(readStringAny(item, "formatted", "text", "message", "klass")), - Source: strings.TrimSpace(readStringAny(item, "source", "node")), - Class: strings.TrimSpace(readStringAny(item, "klass", "class")), - DiskName: strings.TrimSpace(readStringAny(args, "name", "disk", "device")), - DiskSerial: strings.TrimSpace(readStringAny(args, "serial", "serial_number", "serialNumber")), - Dismissed: readBoolAny(item, "dismissed"), - Datetime: datetime, + ID: id, + Level: strings.TrimSpace(readStringAny(item, "level")), + Message: strings.TrimSpace(readStringAny(item, "formatted", "text", "message", "klass")), + Source: strings.TrimSpace(readStringAny(item, "source", "node")), + Class: strings.TrimSpace(readStringAny(item, "klass", "class")), + DiskName: strings.TrimSpace(readStringAny(args, "name", "disk", "device")), + DiskSerial: strings.TrimSpace(readStringAny(args, "serial", "serial_number", "serialNumber")), + SMARTUncorrectedErrors: uncorrected, + SMARTUncorrectedReported: uncorrectedReported, + SMARTAvailableSpare: availableSpare, + SMARTAvailableSpareReported: availableReported, + Dismissed: readBoolAny(item, "dismissed"), + Datetime: datetime, }) } return alerts, nil @@ -1258,22 +1265,50 @@ func (c *Client) getAlertsREST(ctx context.Context) ([]Alert, error) { return nil, fmt.Errorf("parse alert %q datetime: %w", id, err) } + uncorrected, uncorrectedReported, availableSpare, availableReported := smartAlertEvidenceFromArgs(item.Class, item.Args) alerts = append(alerts, Alert{ - ID: id, - Level: strings.TrimSpace(item.Level), - Message: strings.TrimSpace(item.Formatted), - Source: strings.TrimSpace(item.Source), - Class: strings.TrimSpace(item.Class), - DiskName: strings.TrimSpace(readStringAny(item.Args, "name", "disk", "device")), - DiskSerial: strings.TrimSpace(readStringAny(item.Args, "serial", "serial_number", "serialNumber")), - Dismissed: item.Dismissed, - Datetime: time.UnixMilli(ms).UTC(), + ID: id, + Level: strings.TrimSpace(item.Level), + Message: strings.TrimSpace(item.Formatted), + Source: strings.TrimSpace(item.Source), + Class: strings.TrimSpace(item.Class), + DiskName: strings.TrimSpace(readStringAny(item.Args, "name", "disk", "device")), + DiskSerial: strings.TrimSpace(readStringAny(item.Args, "serial", "serial_number", "serialNumber")), + SMARTUncorrectedErrors: uncorrected, + SMARTUncorrectedReported: uncorrectedReported, + SMARTAvailableSpare: availableSpare, + SMARTAvailableSpareReported: availableReported, + Dismissed: item.Dismissed, + Datetime: time.UnixMilli(ms).UTC(), }) } return alerts, nil } +// smartAlertEvidenceFromArgs reads only the typed arguments published by the +// supported TrueNAS SMART alert classes. Current JSON-RPC releases do not +// expose raw SMART attributes through disk.query, so alert.list is the native +// source for these two values. Do not infer counters from formatted text. +func smartAlertEvidenceFromArgs(class string, args map[string]any) (int64, bool, int, bool) { + switch strings.ToLower(strings.TrimSpace(class)) { + case "smartuncorrectederrors", "smartuncorrectederrorsalert": + value := readInt64PtrAny(args, "ue") + if value != nil && *value >= 0 { + return *value, true, 0, false + } + case "smartspareblockcount", "smartspareblockcountalert": + value := readInt64PtrAny(args, "sb") + // TrueNAS publishes the normalized spare-block reserve as a percentage. + // Reject malformed/out-of-range values rather than projecting them onto + // the canonical percentage field. + if value != nil && *value >= 0 && *value <= 100 { + return 0, false, int(*value), true + } + } + return 0, false, 0, false +} + // GetServices returns the native TrueNAS system service inventory. func (c *Client) GetServices(ctx context.Context) ([]Service, error) { legacy, err := c.useLegacyREST(ctx) diff --git a/internal/truenas/client_test.go b/internal/truenas/client_test.go index 152a5e9bb..655dd5294 100644 --- a/internal/truenas/client_test.go +++ b/internal/truenas/client_test.go @@ -298,8 +298,8 @@ func TestGetPoolsDatasetsAndAlertsUseNativeQueryShapes(t *testing.T) { t.Fatalf("expected alert.list to use no params, got %#v", request.Params) } writeRPCResult(t, conn, request.ID, []map[string]any{{ - "id": "a1", "level": "WARNING", "formatted": "Disk temp high", "source": "DiskService", - "klass": "DiskTemperatureAlert", "args": map[string]any{"name": "/dev/sda", "serial": "SER-A"}, + "id": "a1", "level": "WARNING", "formatted": "53 uncorrectable errors reported", "source": "SMART", + "klass": "SMARTUncorrectedErrorsAlert", "args": map[string]any{"ue": 53, "name": "/dev/sda", "serial": "SER-A"}, "dismissed": false, "datetime": map[string]any{"$date": 1707400000000}, }}) default: @@ -330,7 +330,8 @@ func TestGetPoolsDatasetsAndAlertsUseNativeQueryShapes(t *testing.T) { t.Fatalf("GetAlerts() error = %v", err) } if len(alerts) != 1 || alerts[0].ID != "a1" || alerts[0].Level != "WARNING" || - alerts[0].Class != "DiskTemperatureAlert" || alerts[0].DiskName != "/dev/sda" || alerts[0].DiskSerial != "SER-A" { + alerts[0].Class != "SMARTUncorrectedErrorsAlert" || alerts[0].DiskName != "/dev/sda" || alerts[0].DiskSerial != "SER-A" || + !alerts[0].SMARTUncorrectedReported || alerts[0].SMARTUncorrectedErrors != 53 { t.Fatalf("unexpected native alert mapping: %+v", alerts) } } @@ -338,7 +339,7 @@ func TestGetPoolsDatasetsAndAlertsUseNativeQueryShapes(t *testing.T) { func TestGetAlertsParsesLegacyDiskIdentityArguments(t *testing.T) { responses := defaultAPIResponses() responses["/api/v2.0/alert/list"] = apiResponse{ - body: `[{"id":"smart-1","level":"WARNING","formatted":"53 uncorrectable errors reported for /dev/sda (SER-A).","source":"SMART","klass":"SMARTUncorrectedErrorsAlert","args":{"name":"/dev/sda","serial":"SER-A"},"dismissed":true,"datetime":{"$date":1707400000000}}]`, + body: `[{"id":"smart-1","level":"WARNING","formatted":"53 uncorrectable errors reported for /dev/sda (SER-A).","source":"SMART","klass":"SMARTUncorrectedErrorsAlert","args":{"ue":53,"name":"/dev/sda","serial":"SER-A"},"dismissed":true,"datetime":{"$date":1707400000000}}]`, } server := newMockServer(t, responses, nil) t.Cleanup(server.Close) @@ -349,11 +350,49 @@ func TestGetAlertsParsesLegacyDiskIdentityArguments(t *testing.T) { t.Fatalf("GetAlerts() error = %v", err) } if len(alerts) != 1 || alerts[0].Class != "SMARTUncorrectedErrorsAlert" || - alerts[0].DiskName != "/dev/sda" || alerts[0].DiskSerial != "SER-A" || !alerts[0].Dismissed { + alerts[0].DiskName != "/dev/sda" || alerts[0].DiskSerial != "SER-A" || + !alerts[0].SMARTUncorrectedReported || alerts[0].SMARTUncorrectedErrors != 53 || !alerts[0].Dismissed { t.Fatalf("unexpected legacy SMART alert mapping: %+v", alerts) } } +func TestSMARTAlertEvidenceFromArgsUsesOnlyTypedNativeFields(t *testing.T) { + tests := []struct { + name string + class string + args map[string]any + wantUncorrected int64 + wantUncorrectedSeen bool + wantSpare int + wantSpareSeen bool + }{ + { + name: "uncorrected counter", class: "SMARTUncorrectedErrors", + args: map[string]any{"ue": float64(53)}, wantUncorrected: 53, wantUncorrectedSeen: true, + }, + { + name: "spare reserve", class: "SMARTSpareBlockCountAlert", + args: map[string]any{"sb": "8"}, wantSpare: 8, wantSpareSeen: true, + }, + {name: "negative counter rejected", class: "SMARTUncorrectedErrors", args: map[string]any{"ue": -1}}, + {name: "out of range percentage rejected", class: "SMARTSpareBlockCount", args: map[string]any{"sb": 101}}, + {name: "formatted text is not parsed", class: "SMARTUncorrectedErrors", args: map[string]any{"text": "53 errors"}}, + {name: "unrelated alert cannot inject smart data", class: "DiskTemperatureAlert", args: map[string]any{"ue": 53, "sb": 8}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + uncorrected, uncorrectedSeen, spare, spareSeen := smartAlertEvidenceFromArgs(tt.class, tt.args) + if uncorrected != tt.wantUncorrected || uncorrectedSeen != tt.wantUncorrectedSeen || + spare != tt.wantSpare || spareSeen != tt.wantSpareSeen { + t.Fatalf("smartAlertEvidenceFromArgs() = (%d,%v,%d,%v), want (%d,%v,%d,%v)", + uncorrected, uncorrectedSeen, spare, spareSeen, + tt.wantUncorrected, tt.wantUncorrectedSeen, tt.wantSpare, tt.wantSpareSeen) + } + }) + } +} + func TestGetServicesUsesNativeServiceQueryShape(t *testing.T) { server := newMockServerWithRPC(t, map[string]apiResponse{ "/api/v2.0/service": {status: http.StatusInternalServerError, body: `{"error":"legacy service endpoint should not be used"}`}, diff --git a/internal/truenas/provider.go b/internal/truenas/provider.go index 104d7aef3..23f2a9e49 100644 --- a/internal/truenas/provider.go +++ b/internal/truenas/provider.go @@ -795,6 +795,7 @@ func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, connectionID string, for _, disk := range snapshot.Disks { assessment := assessDisk(disk) incidents := incidentAssignments.Disks[strings.TrimSpace(disk.Name)] + smart := incidentAssignments.DiskSMART[strings.TrimSpace(disk.Name)] diskIdentity := unifiedresources.ResourceIdentity{ Hostnames: []string{snapshot.System.Hostname}, } @@ -835,6 +836,7 @@ func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, connectionID string, RPM: rpmFromDisk(disk), StorageGroup: strings.TrimSpace(disk.Pool), StorageState: normalizedDiskStatus(disk), + SMART: smart, Risk: unifiedresources.PhysicalDiskRiskFromAssessmentAndIncidents(assessment, incidents), }, Tags: []string{"truenas", "disk", disk.Transport}, @@ -1157,11 +1159,12 @@ func enrichAppStatsFromPreviousSnapshot(current *FixtureSnapshot, previous *Fixt } type trueNASIncidentAssignments struct { - System []unifiedresources.ResourceIncident - Pools map[string][]unifiedresources.ResourceIncident - Datasets map[string][]unifiedresources.ResourceIncident - Disks map[string][]unifiedresources.ResourceIncident - Apps map[string][]unifiedresources.ResourceIncident + System []unifiedresources.ResourceIncident + Pools map[string][]unifiedresources.ResourceIncident + Datasets map[string][]unifiedresources.ResourceIncident + Disks map[string][]unifiedresources.ResourceIncident + DiskSMART map[string]*unifiedresources.SMARTMeta + Apps map[string][]unifiedresources.ResourceIncident } type poolIncidentProjection struct { @@ -1171,10 +1174,11 @@ type poolIncidentProjection struct { func buildIncidentAssignments(snapshot *FixtureSnapshot, observedAt time.Time) trueNASIncidentAssignments { assignments := trueNASIncidentAssignments{ - Pools: make(map[string][]unifiedresources.ResourceIncident), - Datasets: make(map[string][]unifiedresources.ResourceIncident), - Disks: make(map[string][]unifiedresources.ResourceIncident), - Apps: make(map[string][]unifiedresources.ResourceIncident), + Pools: make(map[string][]unifiedresources.ResourceIncident), + Datasets: make(map[string][]unifiedresources.ResourceIncident), + Disks: make(map[string][]unifiedresources.ResourceIncident), + DiskSMART: make(map[string]*unifiedresources.SMARTMeta), + Apps: make(map[string][]unifiedresources.ResourceIncident), } if snapshot == nil { return assignments @@ -1234,6 +1238,7 @@ func buildIncidentAssignments(snapshot *FixtureSnapshot, observedAt time.Time) t if diskName != "" { assignments.Disks[diskName] = append(assignments.Disks[diskName], incident) + mergeTrueNASSMARTAlertEvidence(assignments.DiskSMART, diskName, alert) if poolName := diskPools[diskName]; poolName != "" { assignments.Pools[poolName] = append(assignments.Pools[poolName], incident) } @@ -1269,6 +1274,31 @@ func buildIncidentAssignments(snapshot *FixtureSnapshot, observedAt time.Time) t return assignments } +func mergeTrueNASSMARTAlertEvidence(byDisk map[string]*unifiedresources.SMARTMeta, diskName string, alert Alert) { + diskName = strings.TrimSpace(diskName) + if diskName == "" || byDisk == nil { + return + } + if !alert.SMARTUncorrectedReported && !alert.SMARTAvailableSpareReported { + return + } + meta := byDisk[diskName] + if meta == nil { + meta = &unifiedresources.SMARTMeta{} + byDisk[diskName] = meta + } + if alert.SMARTUncorrectedReported && alert.SMARTUncorrectedErrors >= 0 && + (meta.MediaErrors == nil || alert.SMARTUncorrectedErrors > *meta.MediaErrors) { + value := alert.SMARTUncorrectedErrors + meta.MediaErrors = &value + } + if alert.SMARTAvailableSpareReported && alert.SMARTAvailableSpare >= 0 && alert.SMARTAvailableSpare <= 100 && + (meta.AvailableSpare == nil || alert.SMARTAvailableSpare < *meta.AvailableSpare) { + value := alert.SMARTAvailableSpare + meta.AvailableSpare = &value + } +} + func assessSystemStorage(snapshot *FixtureSnapshot) storagehealth.Assessment { if snapshot == nil { return storagehealth.Assessment{Level: storagehealth.RiskHealthy} diff --git a/internal/truenas/provider_test.go b/internal/truenas/provider_test.go index 6644f373a..2b9474aa3 100644 --- a/internal/truenas/provider_test.go +++ b/internal/truenas/provider_test.go @@ -1085,15 +1085,17 @@ func TestRecordsRetainDismissedTrueNASSMARTFailureAsDiskRisk(t *testing.T) { }}, Alerts: []Alert{ { - ID: "smart-uncorrected-sda", - Level: "WARNING", - Message: "53 uncorrectable errors reported for /dev/sda (SER-SDA).", - Source: "SMART", - Class: "SMARTUncorrectedErrorsAlert", - DiskName: "/dev/sda", - DiskSerial: "SER-SDA", - Dismissed: true, - Datetime: time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC), + ID: "smart-uncorrected-sda", + Level: "WARNING", + Message: "53 uncorrectable errors reported for /dev/sda (SER-SDA).", + Source: "SMART", + Class: "SMARTUncorrectedErrorsAlert", + DiskName: "/dev/sda", + DiskSerial: "SER-SDA", + SMARTUncorrectedErrors: 53, + SMARTUncorrectedReported: true, + Dismissed: true, + Datetime: time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC), }, { ID: "dismissed-generic", @@ -1130,6 +1132,36 @@ func TestRecordsRetainDismissedTrueNASSMARTFailureAsDiskRisk(t *testing.T) { !containsRiskReason(risk.Reasons, "truenas_smart") { t.Fatalf("expected retained SMART incident in critical physical-disk risk, got %+v", risk) } + if smart := diskRecord.Resource.PhysicalDisk.SMART; smart == nil || smart.MediaErrors == nil || *smart.MediaErrors != 53 { + t.Fatalf("expected native uncorrectable count in canonical SMART evidence, got %+v", smart) + } +} + +func TestMergeTrueNASSMARTAlertEvidenceKeepsWorstTypedValues(t *testing.T) { + byDisk := make(map[string]*unifiedresources.SMARTMeta) + mergeTrueNASSMARTAlertEvidence(byDisk, "sda", Alert{ + SMARTUncorrectedErrors: 12, SMARTUncorrectedReported: true, + SMARTAvailableSpare: 40, SMARTAvailableSpareReported: true, + }) + mergeTrueNASSMARTAlertEvidence(byDisk, "sda", Alert{ + SMARTUncorrectedErrors: 53, SMARTUncorrectedReported: true, + SMARTAvailableSpare: 8, SMARTAvailableSpareReported: true, + }) + mergeTrueNASSMARTAlertEvidence(byDisk, "sda", Alert{ + SMARTUncorrectedErrors: 2, SMARTUncorrectedReported: true, + SMARTAvailableSpare: 80, SMARTAvailableSpareReported: true, + }) + + meta := byDisk["sda"] + if meta == nil || meta.MediaErrors == nil || *meta.MediaErrors != 53 || + meta.AvailableSpare == nil || *meta.AvailableSpare != 8 { + t.Fatalf("expected worst native SMART evidence, got %+v", meta) + } + mergeTrueNASSMARTAlertEvidence(byDisk, "", Alert{SMARTUncorrectedErrors: 99, SMARTUncorrectedReported: true}) + mergeTrueNASSMARTAlertEvidence(byDisk, "sdb", Alert{}) + if len(byDisk) != 1 { + t.Fatalf("empty identity or evidence must not create SMART rows: %+v", byDisk) + } } func TestDismissedTrueNASSMARTAlertRequiresExactDiskIdentity(t *testing.T) { diff --git a/internal/truenas/types.go b/internal/truenas/types.go index f12d8567c..dfbf41158 100644 --- a/internal/truenas/types.go +++ b/internal/truenas/types.go @@ -200,15 +200,19 @@ type SystemMetricHistory struct { // Alert mirrors a TrueNAS alert listing entry. type Alert struct { - ID string - Level string - Message string - Source string - Class string - DiskName string - DiskSerial string - Dismissed bool - Datetime time.Time + ID string + Level string + Message string + Source string + Class string + DiskName string + DiskSerial string + SMARTUncorrectedErrors int64 + SMARTUncorrectedReported bool + SMARTAvailableSpare int + SMARTAvailableSpareReported bool + Dismissed bool + Datetime time.Time } // Service mirrors the service.query system service inventory returned by diff --git a/scripts/release_control/render_release_body_test.py b/scripts/release_control/render_release_body_test.py index 3310142fa..3b9beaff8 100644 --- a/scripts/release_control/render_release_body_test.py +++ b/scripts/release_control/render_release_body_test.py @@ -29,6 +29,17 @@ def _discover_rc_draft_packet_paths() -> tuple[str, ...]: class RenderReleaseBodyTest(unittest.TestCase): + def test_rc13_packet_keeps_typed_truenas_smart_evidence_visible(self) -> None: + notes = ( + _REPO_ROOT / "docs" / "releases" / "RELEASE_NOTES_v6.4.0-rc.13.md" + ).read_text(encoding="utf-8") + + self.assertIn( + "expose supported uncorrectable-error and spare-reserve values", + notes, + ) + render_release_body.validate_release_notes_shape(notes, "6.4.0-rc.13") + def test_highlights_are_a_small_plain_language_overview(self) -> None: notes = """# Pulse v6.2.0 Release Notes