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 }