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).`, )) + `
@@ -672,9 +672,9 @@ func renderCheck(opts HandlerOptions) string { )) + renderSATCard("storage", "Storage", "runSAT('storage')", "", renderValidateCardBody( inv.Storage, - `Scans all storage devices and runs the matching health or self-test path for each.`, - `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(title) + `
` + + `
` + + `
` +
+			html.EscapeString(string(data)) +
+			`
` + cards = append(cards, card) + } + return strings.Join(cards, "\n") +} + +// taskStorageRunDirFromLog finds the storage SAT run directory path logged as +// "Archive: /path/to/storage-YYYYMMDD-HHMMSS". +func taskStorageRunDirFromLog(logText string) string { + for _, line := range strings.Split(logText, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "Archive:") { + continue + } + path := strings.TrimSpace(strings.TrimPrefix(line, "Archive:")) + if strings.Contains(filepath.Base(path), "storage-") && !strings.HasSuffix(path, ".tar.gz") { + return path + } + } + return "" +}