diff --git a/audit/internal/platform/sat.go b/audit/internal/platform/sat.go
index 21387f6..924a315 100644
--- a/audit/internal/platform/sat.go
+++ b/audit/internal/platform/sat.go
@@ -730,12 +730,14 @@ func (s *System) RunStorageAcceptancePack(ctx context.Context, baseDir string, e
}
prefix := fmt.Sprintf("%02d-%s", index+1, filepath.Base(devPath))
commands := storageSATCommands(devPath, extended)
+ deviceOutputs := make(map[string][]byte, len(commands))
for cmdIndex, job := range commands {
if ctx.Err() != nil {
break
}
name := fmt.Sprintf("%s-%02d-%s.log", prefix, cmdIndex+1, job.name)
out, err := runSATCommandCtx(ctx, verboseLog, job.name, job.cmd, nil, logFunc)
+ deviceOutputs[job.name] = out
if writeErr := os.WriteFile(filepath.Join(runDir, name), out, 0644); writeErr != nil {
return "", writeErr
}
@@ -745,6 +747,8 @@ func (s *System) RunStorageAcceptancePack(ctx context.Context, baseDir string, e
fmt.Fprintf(&summary, "%s_rc=%d\n", key, rc)
fmt.Fprintf(&summary, "%s_status=%s\n", key, status)
}
+ reportText := GenerateDiskReportText(index+1, devPath, deviceOutputs, time.Now().UTC())
+ _ = os.WriteFile(filepath.Join(runDir, "disk-"+prefix+"-report.txt"), []byte(reportText), 0644)
}
writeSATStats(&summary, stats)
@@ -1185,26 +1189,27 @@ func listStorageDevices() ([]string, error) {
return parseStorageDevices(string(out)), nil
}
+// storageSATCommands returns the commands to run for a single storage device.
+// extended=false (Check): read-only SMART/NVMe data collection, no self-test.
+// extended=true (Load): data collection + short self-test.
func storageSATCommands(devPath string, extended bool) []satJob {
if strings.Contains(filepath.Base(devPath), "nvme") {
- selfTestLevel := "1"
- if extended {
- selfTestLevel = "2"
- }
- return []satJob{
+ jobs := []satJob{
{name: "nvme-id-ctrl", cmd: []string{"nvme", "id-ctrl", devPath, "-o", "json"}},
{name: "nvme-smart-log", cmd: []string{"nvme", "smart-log", devPath, "-o", "json"}},
- {name: "nvme-device-self-test", cmd: []string{"nvme", "device-self-test", devPath, "-s", selfTestLevel, "--wait"}},
}
+ if extended {
+ jobs = append(jobs, satJob{name: "nvme-device-self-test", cmd: []string{"nvme", "device-self-test", devPath, "-s", "1", "--wait"}})
+ }
+ return jobs
}
- smartTestType := "short"
- if extended {
- smartTestType = "long"
- }
- return []satJob{
+ jobs := []satJob{
{name: "smartctl-health", cmd: []string{"smartctl", "-H", "-A", devPath}},
- {name: "smartctl-self-test-short", cmd: []string{"smartctl", "-t", smartTestType, devPath}},
}
+ if extended {
+ jobs = append(jobs, satJob{name: "smartctl-self-test-short", cmd: []string{"smartctl", "-t", "short", devPath}})
+ }
+ return jobs
}
func (s *satStats) Add(status string) {
diff --git a/audit/internal/platform/sat_test.go b/audit/internal/platform/sat_test.go
index 06ad947..ca75fe2 100644
--- a/audit/internal/platform/sat_test.go
+++ b/audit/internal/platform/sat_test.go
@@ -14,14 +14,42 @@ import (
func TestStorageSATCommands(t *testing.T) {
t.Parallel()
- nvme := storageSATCommands("/dev/nvme0n1", false)
- if len(nvme) != 3 || nvme[2].cmd[0] != "nvme" {
- t.Fatalf("unexpected nvme commands: %#v", nvme)
+ // Check mode (extended=false): read-only collection, no self-test.
+ nvmeCheck := storageSATCommands("/dev/nvme0n1", false)
+ if len(nvmeCheck) != 2 {
+ t.Fatalf("check nvme: want 2 commands, got %d: %#v", len(nvmeCheck), nvmeCheck)
+ }
+ if nvmeCheck[0].name != "nvme-id-ctrl" || nvmeCheck[1].name != "nvme-smart-log" {
+ t.Fatalf("check nvme: unexpected command names: %#v", nvmeCheck)
}
- sata := storageSATCommands("/dev/sda", false)
- if len(sata) != 2 || sata[0].cmd[0] != "smartctl" {
- t.Fatalf("unexpected sata commands: %#v", sata)
+ sataCheck := storageSATCommands("/dev/sda", false)
+ if len(sataCheck) != 1 || sataCheck[0].cmd[0] != "smartctl" {
+ t.Fatalf("check sata: want 1 smartctl command, got %#v", sataCheck)
+ }
+
+ // Load mode (extended=true): collection + short self-test.
+ nvmeLoad := storageSATCommands("/dev/nvme0n1", true)
+ if len(nvmeLoad) != 3 || nvmeLoad[2].name != "nvme-device-self-test" {
+ t.Fatalf("load nvme: want 3 commands with self-test last, got %#v", nvmeLoad)
+ }
+ if got := nvmeLoad[2].cmd[len(nvmeLoad[2].cmd)-3]; got != "-s" {
+ t.Fatalf("load nvme: want -s flag, got %q", got)
+ }
+ if got := nvmeLoad[2].cmd[len(nvmeLoad[2].cmd)-2]; got != "1" {
+ t.Fatalf("load nvme: want self-test level 1, got %q", got)
+ }
+
+ sataLoad := storageSATCommands("/dev/sda", true)
+ if len(sataLoad) != 2 || sataLoad[1].name != "smartctl-self-test-short" {
+ t.Fatalf("load sata: want 2 commands with short self-test last, got %#v", sataLoad)
+ }
+ // cmd is: smartctl -t short /dev/sda
+ if got := sataLoad[1].cmd[1]; got != "-t" {
+ t.Fatalf("load sata: want -t flag at index 1, got %q", got)
+ }
+ if got := sataLoad[1].cmd[2]; got != "short" {
+ t.Fatalf("load sata: want short at index 2, got %q", got)
}
}
diff --git a/audit/internal/platform/storage_report.go b/audit/internal/platform/storage_report.go
new file mode 100644
index 0000000..28b942b
--- /dev/null
+++ b/audit/internal/platform/storage_report.go
@@ -0,0 +1,350 @@
+package platform
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// GenerateDiskReportText builds a human-readable text report for one storage
+// device from the raw command outputs collected during storage SAT.
+//
+// outputs keys match satJob.name: "nvme-id-ctrl", "nvme-smart-log",
+// "smartctl-health", "smartctl-self-test-short".
+func GenerateDiskReportText(index int, devPath string, outputs map[string][]byte, ts time.Time) string {
+ var b strings.Builder
+ devName := filepath.Base(devPath)
+ line := strings.Repeat("=", 80)
+ b.WriteString(line + "\n")
+ fmt.Fprintf(&b, "Disk %-3d %s\n", index, devPath)
+ b.WriteString(line + "\n")
+
+ isNVMe := strings.Contains(devName, "nvme")
+ if isNVMe {
+ writeNVMeReport(&b, outputs)
+ } else {
+ writeSATAReport(&b, outputs)
+ }
+
+ b.WriteString("\n")
+ fmt.Fprintf(&b, "Collected : %s\n", ts.UTC().Format("2006-01-02 15:04:05 UTC"))
+ b.WriteString(line + "\n")
+ return b.String()
+}
+
+// ── NVMe ─────────────────────────────────────────────────────────────────────
+
+type nvmeIdCtrl struct {
+ ModelNumber string `json:"mn"`
+ SerialNumber string `json:"sn"`
+ Firmware string `json:"fr"`
+ TotalCap uint64 `json:"tnvmcap"`
+ NVMCap uint64 `json:"nvmcap"`
+}
+
+// nvmeU64 handles both plain JSON numbers and {"lo":n,"hi":n} objects that
+// some nvme-cli versions emit for 128-bit counters.
+func nvmeU64(raw json.RawMessage) uint64 {
+ if len(raw) == 0 {
+ return 0
+ }
+ var n uint64
+ if json.Unmarshal(raw, &n) == nil {
+ return n
+ }
+ var obj struct {
+ Lo uint64 `json:"lo"`
+ Hi uint64 `json:"hi"`
+ }
+ if json.Unmarshal(raw, &obj) == nil {
+ return obj.Lo
+ }
+ return 0
+}
+
+type nvmeSmartLogRaw struct {
+ CriticalWarning uint64 `json:"critical_warning"`
+ Temperature json.RawMessage `json:"temperature"`
+ AvailSpare uint64 `json:"avail_spare"`
+ SpareThresh uint64 `json:"spare_thresh"`
+ PercentUsed uint64 `json:"percent_used"`
+ DataUnitsRead json.RawMessage `json:"data_units_read"`
+ DataUnitsWritten json.RawMessage `json:"data_units_written"`
+ PowerCycles json.RawMessage `json:"power_cycles"`
+ PowerOnHours json.RawMessage `json:"power_on_hours"`
+ UnsafeShutdowns json.RawMessage `json:"unsafe_shutdowns"`
+ MediaErrors json.RawMessage `json:"media_errors"`
+ NumErrLogEntries json.RawMessage `json:"num_err_log_entries"`
+}
+
+func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) {
+ // id-ctrl
+ var ctrl nvmeIdCtrl
+ if data := outputs["nvme-id-ctrl"]; len(data) > 0 {
+ _ = json.Unmarshal(data, &ctrl)
+ }
+
+ model := strings.TrimSpace(ctrl.ModelNumber)
+ serial := strings.TrimSpace(ctrl.SerialNumber)
+ firmware := strings.TrimSpace(ctrl.Firmware)
+
+ capacityGB := ""
+ if ctrl.TotalCap > 0 {
+ capacityGB = formatCapacityGB(ctrl.TotalCap)
+ } else if ctrl.NVMCap > 0 {
+ capacityGB = formatCapacityGB(ctrl.NVMCap)
+ }
+
+ writeField(b, "Model", model)
+ writeField(b, "Serial", serial)
+ writeField(b, "Firmware", firmware)
+ if capacityGB != "" {
+ writeField(b, "Capacity", capacityGB)
+ }
+
+ // smart-log
+ data := outputs["nvme-smart-log"]
+ if len(data) == 0 {
+ b.WriteString("\n(no SMART data)\n")
+ return
+ }
+ var sl nvmeSmartLogRaw
+ if err := json.Unmarshal(data, &sl); err != nil {
+ fmt.Fprintf(b, "\n(SMART parse error: %v)\n", err)
+ return
+ }
+
+ tempK := nvmeU64(sl.Temperature)
+ tempC := int(tempK) - 273
+ if tempC < 0 {
+ tempC = 0
+ }
+
+ critWarn := sl.CriticalWarning
+ critWarnStr := "OK"
+ if critWarn != 0 {
+ critWarnStr = fmt.Sprintf("0x%02X", critWarn)
+ }
+
+ poh := nvmeU64(sl.PowerOnHours)
+ pc := nvmeU64(sl.PowerCycles)
+ us := nvmeU64(sl.UnsafeShutdowns)
+ me := nvmeU64(sl.MediaErrors)
+ nel := nvmeU64(sl.NumErrLogEntries)
+
+ // data_units are in 1000 × 512-byte sectors = 512,000 bytes each
+ dataRead := float64(nvmeU64(sl.DataUnitsRead)) * 512000 / 1e9
+ dataWritten := float64(nvmeU64(sl.DataUnitsWritten)) * 512000 / 1e9
+
+ writeSectionHeader(b, "Health")
+ writeField(b, "Temperature", fmt.Sprintf("%d °C", tempC))
+ writeField(b, "Critical Warning", critWarnStr)
+ writeField(b, "Percentage Used", fmt.Sprintf("%d %%", sl.PercentUsed))
+ writeField(b, "Available Spare", fmt.Sprintf("%d %% (threshold: %d %%)", sl.AvailSpare, sl.SpareThresh))
+
+ writeSectionHeader(b, "Usage")
+ writeField(b, "Power On Hours", fmt.Sprintf("%s h", formatUint(poh)))
+ writeField(b, "Power Cycles", formatUint(pc))
+ writeField(b, "Unsafe Shutdowns", formatUint(us))
+ writeField(b, "Data Written", fmt.Sprintf("%.1f GB", dataWritten))
+ writeField(b, "Data Read", fmt.Sprintf("%.1f GB", dataRead))
+
+ writeSectionHeader(b, "Errors")
+ writeField(b, "Media Errors", formatUint(me))
+ writeField(b, "Error Log Entries", formatUint(nel))
+
+ if selfTest := outputs["nvme-device-self-test"]; len(selfTest) > 0 {
+ writeSectionHeader(b, "Self-Test")
+ result := parseSelfTestResult(string(selfTest))
+ writeField(b, "Result", result)
+ }
+}
+
+// ── SATA / SAS (smartctl) ────────────────────────────────────────────────────
+
+var (
+ smartHealthRE = regexp.MustCompile(`(?i)SMART overall-health self-assessment test result:\s*(\S+)`)
+ smartAttrLineRE = regexp.MustCompile(
+ `^\s*(\d{1,3})\s+(\S+)\s+0x[0-9a-fA-F]+\s+(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})\s+\S+\s+\S+\s+\S+\s+(.+?)\s*$`,
+ )
+ smartModelRE = regexp.MustCompile(`(?im)^Device Model:\s*(.+)$`)
+ smartSerialRE = regexp.MustCompile(`(?im)^Serial Number:\s*(.+)$`)
+ smartFirmwareRE = regexp.MustCompile(`(?im)^Firmware Version:\s*(.+)$`)
+ smartCapacityRE = regexp.MustCompile(`(?im)^User Capacity:\s*(.+)$`)
+)
+
+type smartAttr struct {
+ ID int
+ Name string
+ Value int
+ Worst int
+ Threshold int
+ Raw string
+}
+
+func writeSATAReport(b *strings.Builder, outputs map[string][]byte) {
+ data := outputs["smartctl-health"]
+ if len(data) == 0 {
+ b.WriteString("\n(no SMART data)\n")
+ return
+ }
+ text := string(data)
+
+ // Identity
+ if m := smartModelRE.FindStringSubmatch(text); m != nil {
+ writeField(b, "Model", strings.TrimSpace(m[1]))
+ }
+ if m := smartSerialRE.FindStringSubmatch(text); m != nil {
+ writeField(b, "Serial", strings.TrimSpace(m[1]))
+ }
+ if m := smartFirmwareRE.FindStringSubmatch(text); m != nil {
+ writeField(b, "Firmware", strings.TrimSpace(m[1]))
+ }
+ if m := smartCapacityRE.FindStringSubmatch(text); m != nil {
+ cap := strings.TrimSpace(m[1])
+ // trim everything after "[" if present (e.g. "500,107,862,016 bytes [500 GB]")
+ if idx := strings.Index(cap, "["); idx > 0 {
+ cap = strings.TrimSpace(cap[idx+1:])
+ cap = strings.TrimSuffix(cap, "]")
+ }
+ writeField(b, "Capacity", cap)
+ }
+
+ writeSectionHeader(b, "Health")
+ health := "unknown"
+ if m := smartHealthRE.FindStringSubmatch(text); m != nil {
+ health = strings.TrimSpace(m[1])
+ }
+ writeField(b, "SMART Overall Health", health)
+
+ attrs := parseSMARTAttrs(text)
+ if len(attrs) > 0 {
+ writeSectionHeader(b, "SMART Attributes")
+ fmt.Fprintf(b, " %-4s %-32s %5s %5s %5s %s\n", "ID", "Attribute", "Value", "Worst", "Thresh", "Raw")
+ b.WriteString(" " + strings.Repeat("-", 72) + "\n")
+ for _, a := range attrs {
+ fmt.Fprintf(b, " %-4d %-32s %5d %5d %5d %s\n",
+ a.ID, a.Name, a.Value, a.Worst, a.Threshold, a.Raw)
+ }
+ }
+
+ if selfTest := outputs["smartctl-self-test-short"]; len(selfTest) > 0 {
+ writeSectionHeader(b, "Self-Test")
+ result := parseSelfTestResult(string(selfTest))
+ writeField(b, "Result", result)
+ }
+}
+
+func parseSMARTAttrs(text string) []smartAttr {
+ var attrs []smartAttr
+ inTable := false
+ for _, line := range strings.Split(text, "\n") {
+ if strings.Contains(line, "ATTRIBUTE_NAME") {
+ inTable = true
+ continue
+ }
+ if !inTable {
+ continue
+ }
+ m := smartAttrLineRE.FindStringSubmatch(line)
+ if m == nil {
+ if strings.TrimSpace(line) == "" {
+ inTable = false
+ }
+ continue
+ }
+ id, _ := strconv.Atoi(m[1])
+ val, _ := strconv.Atoi(m[3])
+ worst, _ := strconv.Atoi(m[4])
+ thresh, _ := strconv.Atoi(m[5])
+ attrs = append(attrs, smartAttr{
+ ID: id,
+ Name: m[2],
+ Value: val,
+ Worst: worst,
+ Threshold: thresh,
+ Raw: strings.TrimSpace(m[6]),
+ })
+ }
+ return attrs
+}
+
+// parseSelfTestResult extracts a one-line summary from nvme device-self-test
+// or smartctl -t short output.
+func parseSelfTestResult(text string) string {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return "no output"
+ }
+ // nvme device-self-test: look for "Short Device Self-Test Status : 0x0" or similar
+ for _, line := range strings.Split(text, "\n") {
+ l := strings.ToLower(line)
+ if strings.Contains(l, "self-test status") || strings.Contains(l, "self test status") {
+ return strings.TrimSpace(line)
+ }
+ }
+ // smartctl -t short: "Testing has begun" or "Short BGST started"
+ for _, line := range strings.Split(text, "\n") {
+ l := strings.ToLower(line)
+ if strings.Contains(l, "testing has begun") || strings.Contains(l, "started") || strings.Contains(l, "complete") {
+ return strings.TrimSpace(line)
+ }
+ }
+ // fallback: last non-empty line
+ lines := strings.Split(strings.TrimSpace(text), "\n")
+ for i := len(lines) - 1; i >= 0; i-- {
+ if s := strings.TrimSpace(lines[i]); s != "" {
+ return s
+ }
+ }
+ return "done"
+}
+
+// ── Formatting helpers ────────────────────────────────────────────────────────
+
+func writeSectionHeader(b *strings.Builder, title string) {
+ b.WriteString("\n")
+ header := "-- " + title + " "
+ header += strings.Repeat("-", max(0, 76-len(header)))
+ b.WriteString(header + "\n")
+}
+
+func writeField(b *strings.Builder, label, value string) {
+ fmt.Fprintf(b, " %-20s : %s\n", label, value)
+}
+
+func formatCapacityGB(bytes uint64) string {
+ gb := float64(bytes) / 1e9
+ if gb >= 1000 {
+ return fmt.Sprintf("%.2g TB", gb/1000)
+ }
+ return fmt.Sprintf("%.0f GB", math.Round(gb))
+}
+
+func formatUint(n uint64) string {
+ if n == 0 {
+ return "0"
+ }
+ s := strconv.FormatUint(n, 10)
+ // insert thousand separators
+ var out []byte
+ for i, c := range s {
+ if i > 0 && (len(s)-i)%3 == 0 {
+ out = append(out, ',')
+ }
+ out = append(out, byte(c))
+ }
+ return string(out)
+}
+
+func max(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
diff --git a/audit/internal/platform/storage_report_test.go b/audit/internal/platform/storage_report_test.go
new file mode 100644
index 0000000..700f481
--- /dev/null
+++ b/audit/internal/platform/storage_report_test.go
@@ -0,0 +1,122 @@
+package platform
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+var testNVMeIdCtrl = []byte(`{
+ "mn": "SAMSUNG MZ1L2960HCJR-00A07 ",
+ "sn": "S665NN0X415495",
+ "fr": "GDC7602Q",
+ "tnvmcap": 960197124096
+}`)
+
+var testNVMeSmartLog = []byte(`{
+ "critical_warning": 0,
+ "temperature": 311,
+ "avail_spare": 100,
+ "spare_thresh": 10,
+ "percent_used": 0,
+ "data_units_read": 1023456,
+ "data_units_written": 738281,
+ "power_cycles": 32,
+ "power_on_hours": 1234,
+ "unsafe_shutdowns": 3,
+ "media_errors": 0,
+ "num_err_log_entries": 0
+}`)
+
+// lo/hi variant emitted by some nvme-cli versions
+var testNVMeSmartLogLoHi = []byte(`{
+ "critical_warning": 0,
+ "temperature": {"lo": 311, "hi": 0},
+ "avail_spare": 100,
+ "spare_thresh": 10,
+ "percent_used": 0,
+ "data_units_read": {"lo": 1023456, "hi": 0},
+ "data_units_written": {"lo": 738281, "hi": 0},
+ "power_cycles": {"lo": 32, "hi": 0},
+ "power_on_hours": {"lo": 1234, "hi": 0},
+ "unsafe_shutdowns": {"lo": 3, "hi": 0},
+ "media_errors": {"lo": 0, "hi": 0},
+ "num_err_log_entries": {"lo": 0, "hi": 0}
+}`)
+
+var testSmartCtlHealth = []byte(`
+smartctl 7.3 2022-02-28 r5338 [x86_64-linux-5.15.0] (local build)
+Copyright (C) 2002-22, Bruce Allen, Christian Franke, www.smartmontools.org
+
+=== START OF INFORMATION SECTION ===
+Device Model: SAMSUNG MZ1L2960HCJR-00A07
+Serial Number: S665NN0X415495
+Firmware Version: GDC7602Q
+User Capacity: 960,197,124,096 bytes [960 GB]
+
+=== START OF READ SMART DATA SECTION ===
+SMART overall-health self-assessment test result: PASSED
+
+SMART Attributes Data Structure revision number: 1
+Vendor Specific SMART Attributes with Thresholds:
+ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
+ 5 Reallocated_Sector_Ct 0x0032 100 100 000 Old_age Always - 0
+ 9 Power_On_Hours 0x0032 100 100 000 Old_age Always - 1234
+ 12 Power_Cycle_Count 0x0032 100 100 000 Old_age Always - 45
+177 Wear_Leveling_Count 0x0013 097 097 000 Pre-fail Always - 30
+190 Airflow_Temperature_Cel 0x0032 063 045 000 Old_age Always - 37
+`)
+
+func TestGenerateDiskReportNVMe(t *testing.T) {
+ t.Parallel()
+ outputs := map[string][]byte{
+ "nvme-id-ctrl": testNVMeIdCtrl,
+ "nvme-smart-log": testNVMeSmartLog,
+ }
+ report := GenerateDiskReportText(1, "/dev/nvme0n1", outputs, time.Unix(0, 0).UTC())
+
+ assertContains(t, report, "Disk 1", "/dev/nvme0n1")
+ assertContains(t, report, "SAMSUNG MZ1L2960HCJR-00A07")
+ assertContains(t, report, "S665NN0X415495")
+ assertContains(t, report, "GDC7602Q")
+ assertContains(t, report, "38 °C") // 311 K - 273
+ assertContains(t, report, "1,234 h") // power_on_hours with separator
+ assertContains(t, report, "32") // power_cycles
+ assertContains(t, report, "3") // unsafe_shutdowns
+ assertContains(t, report, "378.0 GB") // data_units_written * 512000 / 1e9
+}
+
+func TestGenerateDiskReportNVMeLoHi(t *testing.T) {
+ t.Parallel()
+ outputs := map[string][]byte{
+ "nvme-id-ctrl": testNVMeIdCtrl,
+ "nvme-smart-log": testNVMeSmartLogLoHi,
+ }
+ report := GenerateDiskReportText(1, "/dev/nvme0n1", outputs, time.Unix(0, 0).UTC())
+ assertContains(t, report, "38 °C")
+ assertContains(t, report, "1,234 h")
+}
+
+func TestGenerateDiskReportSATA(t *testing.T) {
+ t.Parallel()
+ outputs := map[string][]byte{
+ "smartctl-health": testSmartCtlHealth,
+ }
+ report := GenerateDiskReportText(2, "/dev/sda", outputs, time.Unix(0, 0).UTC())
+
+ assertContains(t, report, "Disk 2", "/dev/sda")
+ assertContains(t, report, "SAMSUNG MZ1L2960HCJR-00A07")
+ assertContains(t, report, "S665NN0X415495")
+ assertContains(t, report, "PASSED")
+ assertContains(t, report, "Reallocated_Sector_Ct")
+ assertContains(t, report, "Power_On_Hours")
+}
+
+func assertContains(t *testing.T, text string, needles ...string) {
+ t.Helper()
+ for _, needle := range needles {
+ if !strings.Contains(text, needle) {
+ t.Errorf("report missing %q\nreport:\n%s", needle, text)
+ }
+ }
+}
diff --git a/audit/internal/webui/page_validate.go b/audit/internal/webui/page_validate.go
index d9c420a..226232f 100644
--- a/audit/internal/webui/page_validate.go
+++ b/audit/internal/webui/page_validate.go
@@ -143,9 +143,9 @@ func renderValidateMode(opts HandlerOptions, stressDefault bool) string {
)) +
renderSATCard("storage", "Storage", "runSAT('storage')", "", renderValidateCardBody(
inv.Storage,
- `Scans all storage devices and runs the matching health or self-test path for each device type.`,
- `lsblk; NVMe: nvme; SATA/SAS: smartctl`,
- `Seconds in Validate (NVMe: instant device query; SATA/SAS: short self-test). Up to ~1 h per device in Stress (extended self-test, device-dependent).`,
+ `Collects SMART data and runs a short self-test on each storage device.`,
+ `lsblk; NVMe: nvme id-ctrl, nvme smart-log, nvme device-self-test -s 1; SATA/SAS: smartctl -H -A, smartctl -t short`,
+ `~2 min per device (NVMe short self-test; SATA/SAS short self-test — duration device-dependent).`,
)) +
`
lsblk; NVMe: nvme; SATA/SAS: smartctl`,
- `Seconds (NVMe: instant device query; SATA/SAS: short self-test).`,
+ `Collects SMART health and attributes for each storage device. No self-test is triggered — read-only query only.`,
+ `lsblk; NVMe: nvme id-ctrl, nvme smart-log; SATA/SAS: smartctl -H -A`,
+ `Seconds — instantaneous device query, no wear counters incremented.`,
)) +
`
diff --git a/audit/internal/webui/server_test.go b/audit/internal/webui/server_test.go
index 7f21502..80be714 100644
--- a/audit/internal/webui/server_test.go
+++ b/audit/internal/webui/server_test.go
@@ -1227,7 +1227,7 @@ func TestDashboardRendersRuntimeHealthTable(t *testing.T) {
],
"services":[
{"name":"bee-web","status":"active"},
- {"name":"bee-nvidia","status":"inactive"}
+ {"name":"bee-nvidia","status":"failed"}
]
}`
if err := os.WriteFile(filepath.Join(exportDir, "runtime-health.json"), []byte(health), 0644); err != nil {
@@ -1281,7 +1281,7 @@ func TestDashboardRendersRuntimeHealthTable(t *testing.T) {
`Bee Services`,
`CUDA runtime is not ready for GPU SAT.`,
`Missing: nvidia-smi`,
- `bee-nvidia=inactive`,
+ `bee-nvidia=failed`,
// Hardware Summary card — component health badges
`Hardware Summary`,
`>CPU<`,
diff --git a/audit/internal/webui/task_report.go b/audit/internal/webui/task_report.go
index 90b93f4..fab59ac 100644
--- a/audit/internal/webui/task_report.go
+++ b/audit/internal/webui/task_report.go
@@ -232,6 +232,9 @@ func renderTaskReportFragment(report taskReport, charts map[string]string, logTe
if powerCard := renderTaskPowerResultsCard(report.Target, logText); powerCard != "" {
b.WriteString(powerCard)
}
+ if report.Target == "storage" {
+ b.WriteString(renderStorageDiskReportCards(logText))
+ }
if len(report.Charts) > 0 {
for _, chart := range report.Charts {
@@ -369,3 +372,60 @@ func formatTaskDuration(sec int) string {
}
return fmt.Sprintf("%dh %02dm %02ds", sec/3600, (sec%3600)/60, sec%60)
}
+
+// renderStorageDiskReportCards reads disk-*-report.txt files from the storage
+// SAT run directory and renders one card per disk.
+func renderStorageDiskReportCards(logText string) string {
+ runDir := taskStorageRunDirFromLog(logText)
+ if runDir == "" {
+ return ""
+ }
+ entries, err := os.ReadDir(runDir)
+ if err != nil {
+ return ""
+ }
+
+ var cards []string
+ for _, entry := range entries {
+ name := entry.Name()
+ if !strings.HasPrefix(name, "disk-") || !strings.HasSuffix(name, "-report.txt") {
+ continue
+ }
+ data, err := os.ReadFile(filepath.Join(runDir, name))
+ if err != nil || len(data) == 0 {
+ continue
+ }
+ // Extract disk label from filename: "disk-01-nvme0n1-report.txt" → "Disk 01 — nvme0n1"
+ stem := strings.TrimPrefix(strings.TrimSuffix(name, "-report.txt"), "disk-")
+ // stem is like "01-nvme0n1"
+ parts := strings.SplitN(stem, "-", 2)
+ title := "Disk " + stem
+ if len(parts) == 2 {
+ title = "Disk " + parts[0] + " — " + parts[1]
+ }
+ card := `` + + html.EscapeString(string(data)) + + `