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:
Mikhail Chusavitin
2026-06-30 19:07:36 +03:00
parent ea68318744
commit 386c0738ee
7 changed files with 591 additions and 26 deletions
+350
View 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
}