Fix TrueNAS unavailable SMART disk health

Refs #1474

Separate TrueNAS native disk state from SMART health so null or unavailable smart_status projects as UNKNOWN without replacement risk, while explicit SMART failure and native failure states still alert.

Proof:

- go test ./internal/truenas ./internal/unifiedresources ./internal/storagehealth

- npm --prefix frontend-modern test -- src/features/storageBackups/__tests__/diskPresentation.test.ts
This commit is contained in:
rcourtman 2026-05-28 12:53:46 +01:00
parent d8389d9adc
commit c05eed4306
11 changed files with 567 additions and 21 deletions

View file

@ -0,0 +1,51 @@
# Known RC Issue Closure For GA TrueNAS SMART Unavailable Record
- Date: `2026-05-28`
- Gate: `known-rc-issue-closure-for-ga`
- Lane: `L13`
- Issue: `https://github.com/rcourtman/Pulse/issues/1474`
- Result: `fixed-local-proof`
## Context
Issue `#1474` reports Pulse v6.0.0-rc.5 showing every TrueNAS passed-through
disk as `Replace Now - Disk reports health status FAILED` when TrueNAS cannot
read SMART data inside a Proxmox-hosted VM. The user-visible failure is not
that TrueNAS lacks SMART in this topology. The failure is that unavailable
SMART telemetry is projected as a replacement-required disk failure.
The canonical v6 fix belongs in TrueNAS ingestion and physical-disk projection,
not in the frontend. The frontend should keep treating real `FAILED` health or
critical disk-risk evidence as replacement-required.
## Disposition
The v6 TrueNAS disk path now separates native disk state from explicit SMART
health:
- REST `/disk` and RPC `disk.query` payloads parse `smart_status` into an
explicit disk-health channel.
- `smart_status: null`, empty, unknown, unavailable, `N/A`, and equivalent
unavailable values normalize to `UNKNOWN`.
- Explicit SMART failures still normalize to `FAILED` and produce a canonical
disk-health risk.
- Native TrueNAS disk states such as `FAULTED`, `FAILED`, `OFFLINE`,
`REMOVED`, and `UNAVAIL` still produce critical `truenas_disk_state` risk.
- Unknown or unavailable SMART telemetry no longer creates replacement-required
physical-disk risk or incidents.
This keeps the root ownership in the provider and shared physical-disk contract
while preserving the frontend's existing replacement-warning semantics for real
failures.
## Proof
- `go test ./internal/truenas ./internal/unifiedresources ./internal/storagehealth`
- `npm --prefix frontend-modern test -- src/features/storageBackups/__tests__/diskPresentation.test.ts`
## Outcome
The v6 code path for the reported false replacement alert is fixed with local
automated proof. No public GitHub comment, issue retitle, label change, or issue
closure was made during this work. Public issue closure should wait for the
maintainer's normal issue hygiene or a current TrueNAS/proxmox topology retest.

View file

@ -6018,6 +6018,12 @@
"kind": "file",
"evidence_tier": "managed-runtime-exercise"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-truenas-smart-unavailable-2026-05-28.md",
"kind": "file",
"evidence_tier": "test-proof"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-v5-129-delta-2026-05-01.md",

View file

@ -67,6 +67,10 @@ truth for live infrastructure data.
43. `internal/kubernetesagent/agent.go`
44. `pkg/agents/kubernetes/report.go`
45. `internal/monitoring/temperature.go`
46. `internal/truenas/client.go`
47. `internal/truenas/disk_health.go`
48. `internal/truenas/provider.go`
49. `internal/truenas/types.go`
## Shared Boundaries
@ -1055,6 +1059,13 @@ projection. When TrueNAS raises disk-local SMART alerts such as
into the canonical physical-disk risk payload instead of leaving SMART failure
state trapped in incident/status-only decorations that storage consumers do
not read.
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
null, empty, missing, unknown, or unavailable SMART telemetry to canonical
`UNKNOWN` health with no replacement-required risk. Explicit SMART failure and
native failure states such as `FAULTED`, `FAILED`, `OFFLINE`, `REMOVED`, and
`UNAVAIL` must continue to produce canonical disk-health risk.
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

View file

@ -3560,6 +3560,7 @@
"match_prefixes": [],
"match_files": [
"internal/truenas/client.go",
"internal/truenas/disk_health.go",
"internal/truenas/fixtures.go",
"internal/truenas/provider.go",
"internal/truenas/types.go"

View file

@ -448,12 +448,15 @@ func (c *Client) getDisksREST(ctx context.Context) ([]Disk, error) {
if diskID == "" {
diskID = strings.TrimSpace(item.Name)
}
health, healthPresent := diskSMARTHealthFromRaw(item.SmartStatus)
disks = append(disks, Disk{
ID: diskID,
Name: strings.TrimSpace(item.Name),
Pool: strings.TrimSpace(item.Pool),
Status: strings.TrimSpace(item.Status),
Health: health,
HealthStatusPresent: healthPresent,
Model: strings.TrimSpace(item.Model),
Serial: strings.TrimSpace(item.Serial),
SizeBytes: item.Size,
@ -497,11 +500,14 @@ func (c *Client) disksFromMaps(ctx context.Context, response []map[string]any) (
diskID = strings.TrimSpace(readStringAny(item, "name", "devname"))
}
name := strings.TrimSpace(readStringAny(item, "name", "devname"))
health, healthPresent := diskSMARTHealthFromMap(item)
disk := Disk{
ID: diskID,
Name: name,
Pool: strings.TrimSpace(readStringAny(item, "pool", "pool_name", "poolName")),
Status: strings.TrimSpace(readStringAny(item, "status")),
Health: health,
HealthStatusPresent: healthPresent,
Model: strings.TrimSpace(readStringAny(item, "model")),
Serial: strings.TrimSpace(readStringAny(item, "serial")),
SizeBytes: readInt64Any(item, "size", "size_bytes", "sizeBytes"),
@ -3699,16 +3705,17 @@ type datasetResponse struct {
}
type diskResponse struct {
Identifier string `json:"identifier"`
Name string `json:"name"`
Serial string `json:"serial"`
Size int64 `json:"size"`
Model string `json:"model"`
Type string `json:"type"`
Pool string `json:"pool"`
Bus string `json:"bus"`
Status string `json:"status"`
RotationRate int `json:"rotationrate"`
Identifier string `json:"identifier"`
Name string `json:"name"`
Serial string `json:"serial"`
Size int64 `json:"size"`
Model string `json:"model"`
Type string `json:"type"`
Pool string `json:"pool"`
Bus string `json:"bus"`
Status string `json:"status"`
SmartStatus json.RawMessage `json:"smart_status"`
RotationRate int `json:"rotationrate"`
}
type alertResponse struct {

View file

@ -412,7 +412,9 @@ func TestGetDisksUsesNativeDiskQueryShape(t *testing.T) {
if !ok || len(params) != 2 {
t.Fatalf("expected disk.query filters/options params, got %#v", request.Params)
}
writeRPCResult(t, conn, request.ID, defaultRoutePayloadMaps(t, "/api/v2.0/disk")[:1])
payload := defaultRoutePayloadMaps(t, "/api/v2.0/disk")
payload[0]["smart_status"] = "FAILED"
writeRPCResult(t, conn, request.ID, payload[:1])
case 2:
if request.Method != "disk.temperature_agg" {
t.Fatalf("expected disk.temperature_agg, got %q", request.Method)
@ -439,11 +441,59 @@ func TestGetDisksUsesNativeDiskQueryShape(t *testing.T) {
if len(disks) != 1 {
t.Fatalf("expected 1 disk, got %d", len(disks))
}
if disks[0].Name != "sda" || disks[0].Temperature != 34 || disks[0].TemperatureAggregate.MaxCelsius != 38 {
if disks[0].Name != "sda" || disks[0].Temperature != 34 || disks[0].TemperatureAggregate.MaxCelsius != 38 || disks[0].Health != "FAILED" || !disks[0].HealthStatusPresent {
t.Fatalf("unexpected native disk mapping: %+v", disks[0])
}
}
func TestGetDisksParsesRESTSmartStatus(t *testing.T) {
server := newMockServer(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","smart_status":null},
{"identifier":"{disk-2}","name":"sdb","serial":"SER-B","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE","smart_status":""},
{"identifier":"{disk-3}","name":"sdc","serial":"SER-C","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE","smart_status":"FAILED"},
{"identifier":"{disk-4}","name":"sdd","serial":"SER-D","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE"}
]`,
},
"/api/v2.0/disk/temperatures": {
body: `{}`,
},
}, nil)
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)
}
byName := make(map[string]Disk, len(disks))
for _, disk := range disks {
byName[disk.Name] = disk
}
tests := []struct {
name string
wantHealth string
wantPresent bool
}{
{name: "sda", wantHealth: "UNKNOWN", wantPresent: true},
{name: "sdb", wantHealth: "UNKNOWN", wantPresent: true},
{name: "sdc", wantHealth: "FAILED", wantPresent: true},
{name: "sdd", wantHealth: "", wantPresent: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
disk, ok := byName[tt.name]
if !ok {
t.Fatalf("missing disk %q in %+v", tt.name, disks)
}
if disk.Health != tt.wantHealth || disk.HealthStatusPresent != tt.wantPresent {
t.Fatalf("disk %s health=(%q,%v), want (%q,%v)", tt.name, disk.Health, disk.HealthStatusPresent, tt.wantHealth, tt.wantPresent)
}
})
}
}
func TestGetVMsParsesNativeVMQueryShape(t *testing.T) {
server := newMockServerWithRPC(t, map[string]apiResponse{}, nil, func(t *testing.T, conn *websocket.Conn) {
authReq := readRPCRequest(t, conn)

View file

@ -0,0 +1,82 @@
package truenas
import (
"encoding/json"
"fmt"
"strings"
)
func diskSMARTHealthFromRaw(raw json.RawMessage) (string, bool) {
if len(raw) == 0 {
return "", false
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return "UNKNOWN", true
}
return normalizeExplicitDiskHealth(value), true
}
func diskSMARTHealthFromMap(record map[string]any) (string, bool) {
if record == nil {
return "", false
}
for _, key := range []string{"smart_status", "smartStatus", "smartstatus"} {
value, ok := record[key]
if ok {
return normalizeExplicitDiskHealth(value), true
}
}
return "", false
}
func normalizeExplicitDiskHealth(value any) string {
switch typed := value.(type) {
case nil:
return "UNKNOWN"
case string:
return normalizeDiskHealthText(typed)
case bool:
if typed {
return "PASSED"
}
return "FAILED"
case json.Number:
return normalizeDiskHealthText(typed.String())
case float64:
return normalizeDiskHealthText(fmt.Sprintf("%g", typed))
case int:
return normalizeDiskHealthText(fmt.Sprintf("%d", typed))
case int64:
return normalizeDiskHealthText(fmt.Sprintf("%d", typed))
case map[string]any:
for _, key := range []string{"passed", "healthy"} {
if nested, ok := typed[key]; ok {
return normalizeExplicitDiskHealth(nested)
}
}
for _, key := range []string{"status", "health", "value", "rawvalue", "parsed", "raw"} {
if nested, ok := typed[key]; ok {
return normalizeExplicitDiskHealth(nested)
}
}
return "UNKNOWN"
default:
return normalizeDiskHealthText(fmt.Sprint(typed))
}
}
func normalizeDiskHealthText(health string) string {
switch strings.ToUpper(strings.TrimSpace(health)) {
case "PASSED", "PASS", "OK", "ONLINE", "HEALTHY", "TRUE", "1":
return "PASSED"
case "FAILED", "FAIL", "FAILING", "UNHEALTHY", "FALSE", "0":
return "FAILED"
case "DEGRADED":
return "DEGRADED"
case "", "UNKNOWN", "UNAVAILABLE", "N/A", "NA", "NOT AVAILABLE", "SMART UNAVAILABLE", "UNSUPPORTED", "NULL", "NONE":
return "UNKNOWN"
default:
return "UNKNOWN"
}
}

View file

@ -1156,7 +1156,7 @@ func assessDisk(disk Disk) storagehealth.Assessment {
Wearout: -1,
})
stateUpper := strings.ToUpper(strings.TrimSpace(disk.Status))
stateUpper := normalizedDiskStatus(disk)
stateAssessment := storagehealth.Assessment{Level: storagehealth.RiskHealthy}
switch stateUpper {
case "DEGRADED":
@ -1168,7 +1168,7 @@ func assessDisk(disk Disk) storagehealth.Assessment {
Summary: fmt.Sprintf("TrueNAS disk %s is DEGRADED", strings.TrimSpace(disk.Name)),
}},
}
case "FAULTED", "OFFLINE", "REMOVED", "UNAVAIL":
case "FAULTED", "FAILED", "OFFLINE", "REMOVED", "UNAVAIL":
stateAssessment = storagehealth.Assessment{
Level: storagehealth.RiskCritical,
Reasons: []storagehealth.Reason{{
@ -1935,12 +1935,12 @@ func dockerNetworksFromTrueNASApp(app App) []unifiedresources.DockerNetworkMeta
}
func statusFromDisk(disk Disk) unifiedresources.ResourceStatus {
switch strings.ToUpper(strings.TrimSpace(disk.Status)) {
case "ONLINE":
switch normalizedDiskStatus(disk) {
case "ONLINE", "OK", "PASSED", "HEALTHY":
return unifiedresources.StatusOnline
case "DEGRADED":
return unifiedresources.StatusWarning
case "FAULTED", "OFFLINE", "REMOVED", "UNAVAIL":
case "FAULTED", "FAILED", "OFFLINE", "REMOVED", "UNAVAIL":
return unifiedresources.StatusOffline
default:
return unifiedresources.StatusUnknown
@ -1948,18 +1948,28 @@ func statusFromDisk(disk Disk) unifiedresources.ResourceStatus {
}
func healthFromDisk(disk Disk) string {
switch strings.ToUpper(strings.TrimSpace(disk.Status)) {
case "ONLINE":
if health, ok := explicitDiskHealth(disk); ok {
return health
}
switch normalizedDiskStatus(disk) {
case "ONLINE", "OK", "PASSED", "HEALTHY":
return "PASSED"
case "DEGRADED":
return "DEGRADED"
default:
case "FAULTED", "FAILED", "OFFLINE", "REMOVED", "UNAVAIL":
return "FAILED"
default:
// TrueNAS can omit disk health/status when SMART data is unavailable.
// Missing or provider-unknown telemetry is not a disk failure signal.
return "UNKNOWN"
}
}
func healthForAssessment(disk Disk) string {
switch strings.ToUpper(strings.TrimSpace(disk.Status)) {
if health, ok := explicitDiskHealth(disk); ok {
return health
}
switch normalizedDiskStatus(disk) {
case "DEGRADED":
return ""
default:
@ -1967,6 +1977,17 @@ func healthForAssessment(disk Disk) string {
}
}
func normalizedDiskStatus(disk Disk) string {
return strings.ToUpper(strings.TrimSpace(disk.Status))
}
func explicitDiskHealth(disk Disk) (string, bool) {
if !disk.HealthStatusPresent && strings.TrimSpace(disk.Health) == "" {
return "", false
}
return normalizeExplicitDiskHealth(disk.Health), true
}
func rpmFromDisk(disk Disk) int {
if disk.Rotational {
return 7200

View file

@ -4,6 +4,7 @@ import (
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
@ -60,6 +61,122 @@ func TestStatusFromDatasetStates(t *testing.T) {
}
}
func TestDiskHealthMappingDistinguishesUnavailableTelemetryFromFailure(t *testing.T) {
tests := []struct {
name string
status string
health string
hasHealth bool
wantHealth string
wantStatus unifiedresources.ResourceStatus
wantLevel storagehealth.RiskLevel
}{
{
name: "online passed",
status: "ONLINE",
wantHealth: "PASSED",
wantStatus: unifiedresources.StatusOnline,
wantLevel: storagehealth.RiskHealthy,
},
{
name: "online explicit smart unavailable",
status: "ONLINE",
health: "UNAVAILABLE",
hasHealth: true,
wantHealth: "UNKNOWN",
wantStatus: unifiedresources.StatusOnline,
wantLevel: storagehealth.RiskHealthy,
},
{
name: "online explicit smart failed",
status: "ONLINE",
health: "FAILED",
hasHealth: true,
wantHealth: "FAILED",
wantStatus: unifiedresources.StatusOnline,
wantLevel: storagehealth.RiskCritical,
},
{
name: "degraded warning",
status: "DEGRADED",
wantHealth: "DEGRADED",
wantStatus: unifiedresources.StatusWarning,
wantLevel: storagehealth.RiskWarning,
},
{
name: "failed state",
status: "FAILED",
wantHealth: "FAILED",
wantStatus: unifiedresources.StatusOffline,
wantLevel: storagehealth.RiskCritical,
},
{
name: "faulted state",
status: "FAULTED",
wantHealth: "FAILED",
wantStatus: unifiedresources.StatusOffline,
wantLevel: storagehealth.RiskCritical,
},
{
name: "zfs unavailable state",
status: "UNAVAIL",
wantHealth: "FAILED",
wantStatus: unifiedresources.StatusOffline,
wantLevel: storagehealth.RiskCritical,
},
{
name: "null status decoded as empty",
status: "",
wantHealth: "UNKNOWN",
wantStatus: unifiedresources.StatusUnknown,
wantLevel: storagehealth.RiskHealthy,
},
{
name: "unknown status",
status: "UNKNOWN",
wantHealth: "UNKNOWN",
wantStatus: unifiedresources.StatusUnknown,
wantLevel: storagehealth.RiskHealthy,
},
{
name: "smart unavailable",
status: "UNAVAILABLE",
wantHealth: "UNKNOWN",
wantStatus: unifiedresources.StatusUnknown,
wantLevel: storagehealth.RiskHealthy,
},
{
name: "not available",
status: "N/A",
wantHealth: "UNKNOWN",
wantStatus: unifiedresources.StatusUnknown,
wantLevel: storagehealth.RiskHealthy,
},
{
name: "provider-specific unavailable text",
status: "SMART unavailable",
wantHealth: "UNKNOWN",
wantStatus: unifiedresources.StatusUnknown,
wantLevel: storagehealth.RiskHealthy,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
disk := Disk{Name: "sda", Status: tt.status, Health: tt.health, HealthStatusPresent: tt.hasHealth, Model: "Generic SATA"}
if got := healthFromDisk(disk); got != tt.wantHealth {
t.Fatalf("healthFromDisk(%q) = %q, want %q", tt.status, got, tt.wantHealth)
}
if got := statusFromDisk(disk); got != tt.wantStatus {
t.Fatalf("statusFromDisk(%q) = %q, want %q", tt.status, got, tt.wantStatus)
}
if got := assessDisk(disk).Level; got != tt.wantLevel {
t.Fatalf("assessDisk(%q).Level = %q, want %q", tt.status, got, tt.wantLevel)
}
})
}
}
func TestDatasetStateTag(t *testing.T) {
tests := []struct {
name string

View file

@ -887,6 +887,167 @@ func TestRecordsElevateOnlineDiskWhenTemperatureCritical(t *testing.T) {
}
}
func TestRecordsProjectUnavailableDiskTelemetryAsUnknownWithoutRisk(t *testing.T) {
for _, status := range []string{"", "UNKNOWN", "UNAVAILABLE", "N/A", "SMART unavailable"} {
t.Run(status, func(t *testing.T) {
record := truenasDiskRecordForStatus(t, status)
if record.Resource.Status != unifiedresources.StatusUnknown {
t.Fatalf("status %q projected resource status %q, want %q", status, record.Resource.Status, unifiedresources.StatusUnknown)
}
if record.Resource.PhysicalDisk == nil {
t.Fatal("expected physical disk metadata")
}
if record.Resource.PhysicalDisk.Health != "UNKNOWN" {
t.Fatalf("status %q projected health %q, want UNKNOWN", status, record.Resource.PhysicalDisk.Health)
}
if record.Resource.PhysicalDisk.Risk != nil {
t.Fatalf("status %q should not project replacement risk, got %+v", status, record.Resource.PhysicalDisk.Risk)
}
if len(record.Resource.Incidents) != 0 {
t.Fatalf("status %q should not project incidents, got %+v", status, record.Resource.Incidents)
}
})
}
}
func TestRecordsProjectExplicitUnavailableSMARTAsUnknownWithoutRisk(t *testing.T) {
record := truenasDiskRecordForDisk(t, Disk{
ID: "disk-sda",
Name: "sda",
Pool: "tank",
Status: "ONLINE",
Health: "UNAVAILABLE",
HealthStatusPresent: true,
Model: "Generic SATA",
Serial: "SER-SDA",
SizeBytes: 1_000_000,
Transport: "sata",
Rotational: true,
Temperature: 34,
})
if record.Resource.Status != unifiedresources.StatusUnknown {
t.Fatalf("projected resource status %q, want %q", record.Resource.Status, unifiedresources.StatusUnknown)
}
if record.Resource.PhysicalDisk == nil {
t.Fatal("expected physical disk metadata")
}
if record.Resource.PhysicalDisk.Health != "UNKNOWN" {
t.Fatalf("projected health %q, want UNKNOWN", record.Resource.PhysicalDisk.Health)
}
if record.Resource.PhysicalDisk.Risk != nil {
t.Fatalf("unavailable SMART should not project replacement risk, got %+v", record.Resource.PhysicalDisk.Risk)
}
if len(record.Resource.Incidents) != 0 {
t.Fatalf("unavailable SMART should not project incidents, got %+v", record.Resource.Incidents)
}
}
func TestRecordsProjectExplicitFailedSMARTAsReplacementRisk(t *testing.T) {
record := truenasDiskRecordForDisk(t, Disk{
ID: "disk-sda",
Name: "sda",
Pool: "tank",
Status: "ONLINE",
Health: "FAILED",
HealthStatusPresent: true,
Model: "Generic SATA",
Serial: "SER-SDA",
SizeBytes: 1_000_000,
Transport: "sata",
Rotational: true,
Temperature: 34,
})
if record.Resource.Status != unifiedresources.StatusWarning {
t.Fatalf("projected resource status %q, want %q", record.Resource.Status, unifiedresources.StatusWarning)
}
if record.Resource.PhysicalDisk == nil {
t.Fatal("expected physical disk metadata")
}
if record.Resource.PhysicalDisk.Health != "FAILED" {
t.Fatalf("projected health %q, want FAILED", record.Resource.PhysicalDisk.Health)
}
if record.Resource.PhysicalDisk.Risk == nil {
t.Fatal("explicit failed SMART should project replacement risk")
}
if !containsRiskReason(record.Resource.PhysicalDisk.Risk.Reasons, "health_status") {
t.Fatalf("missing health_status risk, got %+v", record.Resource.PhysicalDisk.Risk.Reasons)
}
}
func TestRecordsPreservePassedDegradedAndFailedDiskProjection(t *testing.T) {
tests := []struct {
name string
status string
wantHealth string
wantStatus unifiedresources.ResourceStatus
wantRiskLevel storagehealth.RiskLevel
wantRiskReason string
wantRiskNil bool
}{
{
name: "passed",
status: "ONLINE",
wantHealth: "PASSED",
wantStatus: unifiedresources.StatusOnline,
wantRiskNil: true,
},
{
name: "degraded",
status: "DEGRADED",
wantHealth: "DEGRADED",
wantStatus: unifiedresources.StatusWarning,
wantRiskLevel: storagehealth.RiskWarning,
wantRiskReason: "truenas_disk_state",
},
{
name: "failed",
status: "FAILED",
wantHealth: "FAILED",
wantStatus: unifiedresources.StatusWarning,
wantRiskLevel: storagehealth.RiskCritical,
wantRiskReason: "truenas_disk_state",
},
{
name: "faulted",
status: "FAULTED",
wantHealth: "FAILED",
wantStatus: unifiedresources.StatusWarning,
wantRiskLevel: storagehealth.RiskCritical,
wantRiskReason: "truenas_disk_state",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
record := truenasDiskRecordForStatus(t, tt.status)
if record.Resource.Status != tt.wantStatus {
t.Fatalf("status %q projected resource status %q, want %q", tt.status, record.Resource.Status, tt.wantStatus)
}
if record.Resource.PhysicalDisk == nil {
t.Fatal("expected physical disk metadata")
}
if record.Resource.PhysicalDisk.Health != tt.wantHealth {
t.Fatalf("status %q projected health %q, want %q", tt.status, record.Resource.PhysicalDisk.Health, tt.wantHealth)
}
if tt.wantRiskNil {
if record.Resource.PhysicalDisk.Risk != nil {
t.Fatalf("status %q should not project risk, got %+v", tt.status, record.Resource.PhysicalDisk.Risk)
}
return
}
if record.Resource.PhysicalDisk.Risk == nil {
t.Fatalf("status %q should project risk", tt.status)
}
if record.Resource.PhysicalDisk.Risk.Level != tt.wantRiskLevel {
t.Fatalf("status %q projected risk level %q, want %q", tt.status, record.Resource.PhysicalDisk.Risk.Level, tt.wantRiskLevel)
}
if !containsRiskReason(record.Resource.PhysicalDisk.Risk.Reasons, tt.wantRiskReason) {
t.Fatalf("status %q missing risk reason %q, got %+v", tt.status, tt.wantRiskReason, record.Resource.PhysicalDisk.Risk.Reasons)
}
})
}
}
func TestRecordsProjectDiskTemperatureAggregatesIntoCanonicalMetadata(t *testing.T) {
previous := IsFeatureEnabled()
SetFeatureEnabled(true)
@ -920,6 +1081,43 @@ func TestRecordsProjectDiskTemperatureAggregatesIntoCanonicalMetadata(t *testing
t.Fatal("expected sda physical disk record")
}
func truenasDiskRecordForStatus(t *testing.T, status string) unifiedresources.IngestRecord {
t.Helper()
return truenasDiskRecordForDisk(t, Disk{
ID: "disk-sda",
Name: "sda",
Pool: "tank",
Status: status,
Model: "Generic SATA",
Serial: "SER-SDA",
SizeBytes: 1_000_000,
Transport: "sata",
Rotational: true,
Temperature: 34,
})
}
func truenasDiskRecordForDisk(t *testing.T, disk Disk) unifiedresources.IngestRecord {
t.Helper()
records := FixtureRecords(FixtureSnapshot{
CollectedAt: time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC),
System: SystemInfo{
Hostname: "truenas-test",
Healthy: true,
},
Disks: []Disk{disk},
})
for _, record := range records {
if record.Resource.Type == unifiedresources.ResourceTypePhysicalDisk {
return record
}
}
t.Fatal("expected physical disk record")
return unifiedresources.IngestRecord{}
}
func TestProviderPhysicalDiskTemperatureHistoryUsesCanonicalMetricIDs(t *testing.T) {
fixtures := DefaultFixtures()
now := time.Date(2026, 3, 29, 20, 0, 0, 0, time.UTC)

View file

@ -66,6 +66,8 @@ type Disk struct {
Name string
Pool string
Status string
Health string
HealthStatusPresent bool
Model string
Serial string
SizeBytes int64