storage SAT: split collect/self-test modes, add per-disk text reports
Check mode: read-only SMART/NVMe data collection, no self-test. Load mode: same collection + short self-test (nvme device-self-test -s 1, smartctl -t short). Card descriptions updated accordingly. After each storage SAT run, a disk-N-devname-report.txt is written per device into the runDir (auto-included in support bundles). Web UI task page renders one card per disk directly below Task Report. Also fixes pre-existing TestDashboardRendersRuntimeHealthTable failure: test fixture used "inactive" status but code now treats inactive as OK for completed oneshot services; updated to "failed" to match intent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
350
audit/internal/platform/storage_report.go
Normal file
350
audit/internal/platform/storage_report.go
Normal file
@@ -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
|
||||
}
|
||||
122
audit/internal/platform/storage_report_test.go
Normal file
122
audit/internal/platform/storage_report_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.`,
|
||||
`<code>lsblk</code>; NVMe: <code>nvme</code>; SATA/SAS: <code>smartctl</code>`,
|
||||
`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.`,
|
||||
`<code>lsblk</code>; NVMe: <code>nvme id-ctrl</code>, <code>nvme smart-log</code>, <code>nvme device-self-test -s 1</code>; SATA/SAS: <code>smartctl -H -A</code>, <code>smartctl -t short</code>`,
|
||||
`~2 min per device (NVMe short self-test; SATA/SAS short self-test — duration device-dependent).`,
|
||||
)) +
|
||||
`</div>
|
||||
<div style="height:1px;background:var(--border);margin:16px 0"></div>
|
||||
@@ -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.`,
|
||||
`<code>lsblk</code>; NVMe: <code>nvme</code>; SATA/SAS: <code>smartctl</code>`,
|
||||
`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.`,
|
||||
`<code>lsblk</code>; NVMe: <code>nvme id-ctrl</code>, <code>nvme smart-log</code>; SATA/SAS: <code>smartctl -H -A</code>`,
|
||||
`Seconds — instantaneous device query, no wear counters incremented.`,
|
||||
)) +
|
||||
`</div>
|
||||
<div style="height:1px;background:var(--border);margin:16px 0"></div>
|
||||
|
||||
@@ -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 := `<div class="card">` +
|
||||
`<div class="card-head">` + html.EscapeString(title) + `</div>` +
|
||||
`<div class="card-body" style="padding:0">` +
|
||||
`<pre style="margin:0;padding:16px;font-size:12px;line-height:1.6;overflow-x:auto;white-space:pre">` +
|
||||
html.EscapeString(string(data)) +
|
||||
`</pre></div></div>`
|
||||
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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user