Expose TrueNAS SMART alert counters

This commit is contained in:
pulse-triage[bot]
2026-08-28 23:00:47 +01:00
parent 5570ac1e2e
commit a46e322bde
11 changed files with 226 additions and 58 deletions
+10 -5
View File
@@ -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.
@@ -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
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+53 -18
View File
@@ -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)
+44 -5
View File
@@ -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"}`},
+39 -9
View File
@@ -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}
+41 -9
View File
@@ -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) {
+13 -9
View File
@@ -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
@@ -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