From e9be94e32bb55c7b40997eab99a263802aa7ffc0 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Fri, 18 Sep 2026 11:37:49 -0700 Subject: [PATCH] SMART: detect drives whose firmware rejects the self-test log (WD SN580) and hide the test buttons with an explanation Co-Authored-By: Claude Fable 5.1 --- internal/storage/storage.go | 29 +++++++++++++++++++++++++++++ web/src/Disks.tsx | 5 +++-- web/src/api.ts | 2 +- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 3ccb6b6..ec69e3a 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -178,6 +178,8 @@ type Smart struct { NVMe *NVMeLog `json:"nvme,omitempty"` SelfTest string `json:"self_test,omitempty"` SelfTestPct int `json:"self_test_remaining,omitempty"` + SelfTestOK bool `json:"self_test_supported"` + SelfTestNote string `json:"self_test_note,omitempty"` Warnings []string `json:"warnings"` Error string `json:"error,omitempty"` FetchedAt int64 `json:"fetched_at"` @@ -381,6 +383,30 @@ func (m *Manager) Smart(ctx context.Context, dev string, force bool) Smart { s.Warnings = append(s.Warnings, "endurance "+strconv.Itoa(l.PercentageUsed)+"% used") } } + // Can this drive run self-tests from here? ATA says so outright; NVMe drives prove it + // by exposing the self-test log, and some (WD SN580 among them) advertise the command + // but reject the log page, which makes the whole feature unusable. + if d, ok := raw["ata_smart_data"].(map[string]any); ok { + if caps, ok := d["capabilities"].(map[string]any); ok { + s.SelfTestOK = caps["self_tests_supported"] == true + if !s.SelfTestOK { + s.SelfTestNote = "drive reports no self-test support" + } + } + } + if _, ok := raw["nvme_self_test_log"]; ok { + s.SelfTestOK = true + } else if s.NVMe != nil { + s.SelfTestNote = "this drive's firmware doesn't expose the NVMe self-test log, so tests can't be run or read from here" + } + if msgs, ok := raw["smartctl"].(map[string]any); ok { + for _, mm := range asList(msgs["messages"]) { + if strings.Contains(asString(mm["string"]), "Self-test Log failed") { + s.SelfTestOK = false + s.SelfTestNote = "firmware rejects the self-test log page (" + asString(mm["string"]) + ")" + } + } + } if d, ok := raw["ata_smart_data"].(map[string]any); ok { if st, ok := d["self_test"].(map[string]any); ok { if ss, ok := st["status"].(map[string]any); ok { @@ -438,6 +464,9 @@ func (m *Manager) SelfTest(ctx context.Context, dev, kind string) error { if !m.smartctl { return errors.New("smartctl is not installed") } + if sm := m.Smart(ctx, dev, false); sm.Supported && !sm.SelfTestOK { + return errors.New("self-tests aren't available on this drive: " + sm.SelfTestNote) + } out, code, err := m.run(ctx, "smartctl", "-t", kind, "/dev/"+dev) if err != nil { return err diff --git a/web/src/Disks.tsx b/web/src/Disks.tsx index 98bd664..2b426d8 100644 --- a/web/src/Disks.tsx +++ b/web/src/Disks.tsx @@ -62,10 +62,11 @@ function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) { )} -
+ {!s.self_test_supported &&
Self-tests: {s.self_test_note || 'not available on this drive'}.
} + {s.self_test_supported &&
{ if (await api.smartTest(dev, 'short').then(() => true, (e) => { toast(String(e.message ?? e), 'err'); return false })) { toast('short test started'); load(true) } }}>Short test { if (await api.smartTest(dev, 'long').then(() => true, (e) => { toast(String(e.message ?? e), 'err'); return false })) { toast('long test started'); load(true) } }}>Long test -
+
} ) } diff --git a/web/src/api.ts b/web/src/api.ts index a335338..6dab538 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -83,7 +83,7 @@ export interface UpdatesStatus { manager: string; can_upgrade: boolean; packages export interface StoragePart { name: string; type: string; fstype?: string; mount?: string; label?: string; size: number; used?: number; fs_size?: number } export interface StorageDevice { name: string; kind: string; model?: string; vendor?: string; serial?: string; tran?: string; size: number; rotational: boolean; removable: boolean; temp?: number; parts: StoragePart[] } export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean } -export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; self_test?: string; self_test_remaining?: number; warnings: string[]; error?: string; fetched_at: number } +export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number } export interface ZFSSnapshot { name: string; used: number; created: number } export interface ZFSInfo { available: boolean; message?: string; pools: { name: string; size: number; alloc: number; free: number; frag: number; cap: number; health: string; dedup: string; scan?: string; errors?: string; config?: string }[]; datasets: { name: string; type: string; used: number; avail: number; refer: number; used_by_snapshots: number; mount?: string; compressratio: string; snapshots: number }[] }