Compare commits

...

2 Commits

Author SHA1 Message Date
5b98005d5d storage report: add new-vs-used disk verdict, human-readable data units, collect smartctl -i
Disk report now ends with a Conclusion section judging a drive NEW/USED
against loose thresholds (<110% capacity written, <210% read, <7d
uptime, <30 power cycles), listing which ones tripped. Data
Written/Read in the Usage section now scale to TB/PB via
formatBytesHuman instead of always printing raw GB. storageSATCommands
now runs smartctl with -i so SATA/SAS reports get Model/Serial/
Firmware/Capacity, which the Conclusion needs to evaluate the
write/read criteria (previously only -H -A was collected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 12:14:20 +03:00
33bc275da2 storage SAT: fix NVMe SMART counters showing 0 for power-on hours/read/write
nvme-cli emits large 64-bit counters as JSON-quoted strings on some
versions; the disk-report text generator only handled bare numbers and
{lo,hi} objects, so power_on_hours/data_units_read/data_units_written
etc. silently parsed as 0 while the structured collector path already
handled this correctly. Unify both paths on a single exported
JSONInt64/NVMeSmartLog/NVMeIDCtrl type in collector/storage.go instead
of keeping two independent nvme-cli JSON parsers in sync.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 11:58:37 +03:00
7 changed files with 198 additions and 123 deletions

View File

@@ -766,7 +766,7 @@ func parseMDAdmPlatformLicense(raw string) *string {
func queryDeviceSerial(devPath string) string { func queryDeviceSerial(devPath string) string {
if out, err := exec.Command("nvme", "id-ctrl", devPath, "-o", "json").Output(); err == nil { if out, err := exec.Command("nvme", "id-ctrl", devPath, "-o", "json").Output(); err == nil {
var ctrl nvmeIDCtrl var ctrl NVMeIDCtrl
if json.Unmarshal(out, &ctrl) == nil { if json.Unmarshal(out, &ctrl) == nil {
if v := cleanDMIValue(strings.TrimSpace(ctrl.SerialNumber)); v != "" { if v := cleanDMIValue(strings.TrimSpace(ctrl.SerialNumber)); v != "" {
return v return v

View File

@@ -84,16 +84,19 @@ func collectStorage() []schema.HardwareStorage {
return result return result
} }
// jsonInt64 accepts both a bare JSON number and a JSON-quoted number string. // JSONInt64 accepts a bare JSON number (512), a JSON-quoted number string
// lsblk -J emits LOG-SEC / PHY-SEC as integers on util-linux 2.37 (Debian 12) // ("512" — lsblk -J on util-linux < 2.37, and nvme-cli for large 64-bit
// but older versions emit them as strings. This type handles both. // counters that would lose precision as JS numbers), or a {"lo":n,"hi":n}
type jsonInt64 int64 // object (128-bit NVMe counters on some nvme-cli versions; hi is ignored as
// no real counter exceeds 64 bits). Shared by lsblk and nvme-cli JSON output
// across the collector and the human-readable disk report.
type JSONInt64 int64
func (j *jsonInt64) UnmarshalJSON(data []byte) error { func (j *JSONInt64) UnmarshalJSON(data []byte) error {
// bare number: 512 // bare number: 512
var n int64 var n int64
if err := json.Unmarshal(data, &n); err == nil { if err := json.Unmarshal(data, &n); err == nil {
*j = jsonInt64(n) *j = JSONInt64(n)
return nil return nil
} }
// quoted string: "512" // quoted string: "512"
@@ -101,24 +104,32 @@ func (j *jsonInt64) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &s); err == nil { if err := json.Unmarshal(data, &s); err == nil {
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64) n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err == nil { if err == nil {
*j = jsonInt64(n) *j = JSONInt64(n)
} }
return nil return nil
} }
// {"lo":n,"hi":n} 128-bit counter object
var obj struct {
Lo int64 `json:"lo"`
}
if err := json.Unmarshal(data, &obj); err == nil {
*j = JSONInt64(obj.Lo)
return nil
}
return nil // null or unexpected type — leave zero return nil // null or unexpected type — leave zero
} }
// lsblkDevice is a minimal lsblk JSON record. // lsblkDevice is a minimal lsblk JSON record.
type lsblkDevice struct { type lsblkDevice struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Size string `json:"size"` Size string `json:"size"`
Serial string `json:"serial"` Serial string `json:"serial"`
Model string `json:"model"` Model string `json:"model"`
Tran string `json:"tran"` Tran string `json:"tran"`
Hctl string `json:"hctl"` Hctl string `json:"hctl"`
LogSec jsonInt64 `json:"log-sec"` LogSec JSONInt64 `json:"log-sec"`
PhySec jsonInt64 `json:"phy-sec"` PhySec JSONInt64 `json:"phy-sec"`
} }
type lsblkRoot struct { type lsblkRoot struct {
@@ -423,32 +434,36 @@ func enrichWithSmartctl(dev lsblkDevice) schema.HardwareStorage {
return s return s
} }
// nvmeSmartLog is the subset of `nvme smart-log -o json` output we care about. // NVMeSmartLog is the subset of `nvme smart-log -o json` output shared by the
// nvme-cli emits most counters as JSON strings (e.g. "power_on_hours":"49"), // structured collector and the human-readable disk report. nvme-cli emits
// so all numeric fields use jsonInt64 which accepts both bare numbers and // most counters as JSON strings (e.g. "power_on_hours":"49") or, on some
// quoted strings. Field names match nvme-cli JSON output, not NVMe spec prose. // versions, as {"lo":n,"hi":n} objects — all numeric fields use JSONInt64,
type nvmeSmartLog struct { // which accepts bare numbers, quoted strings, and lo/hi objects. Field names
CriticalWarning jsonInt64 `json:"critical_warning"` // match nvme-cli JSON output, not NVMe spec prose.
PercentageUsed jsonInt64 `json:"percent_used"` type NVMeSmartLog struct {
AvailableSpare jsonInt64 `json:"avail_spare"` CriticalWarning JSONInt64 `json:"critical_warning"`
SpareThreshold jsonInt64 `json:"spare_thresh"` PercentageUsed JSONInt64 `json:"percent_used"`
Temperature jsonInt64 `json:"temperature"` AvailableSpare JSONInt64 `json:"avail_spare"`
PowerOnHours jsonInt64 `json:"power_on_hours"` SpareThreshold JSONInt64 `json:"spare_thresh"`
PowerCycles jsonInt64 `json:"power_cycles"` Temperature JSONInt64 `json:"temperature"`
UnsafeShutdowns jsonInt64 `json:"unsafe_shutdowns"` PowerOnHours JSONInt64 `json:"power_on_hours"`
DataUnitsRead jsonInt64 `json:"data_units_read"` PowerCycles JSONInt64 `json:"power_cycles"`
DataUnitsWritten jsonInt64 `json:"data_units_written"` UnsafeShutdowns JSONInt64 `json:"unsafe_shutdowns"`
ControllerBusy jsonInt64 `json:"controller_busy_time"` DataUnitsRead JSONInt64 `json:"data_units_read"`
MediaErrors jsonInt64 `json:"media_errors"` DataUnitsWritten JSONInt64 `json:"data_units_written"`
NumErrLogEntries jsonInt64 `json:"num_err_log_entries"` ControllerBusy JSONInt64 `json:"controller_busy_time"`
MediaErrors JSONInt64 `json:"media_errors"`
NumErrLogEntries JSONInt64 `json:"num_err_log_entries"`
} }
// nvmeIDCtrl is the subset of `nvme id-ctrl -o json` output. // NVMeIDCtrl is the subset of `nvme id-ctrl -o json` output shared by the
type nvmeIDCtrl struct { // structured collector and the human-readable disk report.
ModelNumber string `json:"mn"` type NVMeIDCtrl struct {
SerialNumber string `json:"sn"` ModelNumber string `json:"mn"`
FirmwareRev string `json:"fr"` SerialNumber string `json:"sn"`
TotalCapacity int64 `json:"tnvmcap"` FirmwareRev string `json:"fr"`
TotalCapacity JSONInt64 `json:"tnvmcap"`
NVMCapacity JSONInt64 `json:"nvmcap"`
} }
func enrichWithNVMe(dev lsblkDevice) schema.HardwareStorage { func enrichWithNVMe(dev lsblkDevice) schema.HardwareStorage {
@@ -481,7 +496,7 @@ func enrichWithNVMe(dev lsblkDevice) schema.HardwareStorage {
// id-ctrl: model, serial, firmware, capacity // id-ctrl: model, serial, firmware, capacity
if out, err := exec.Command("nvme", "id-ctrl", devPath, "-o", "json").Output(); err == nil { if out, err := exec.Command("nvme", "id-ctrl", devPath, "-o", "json").Output(); err == nil {
var ctrl nvmeIDCtrl var ctrl NVMeIDCtrl
if json.Unmarshal(out, &ctrl) == nil { if json.Unmarshal(out, &ctrl) == nil {
if v := cleanDMIValue(strings.TrimSpace(ctrl.ModelNumber)); v != "" { if v := cleanDMIValue(strings.TrimSpace(ctrl.ModelNumber)); v != "" {
s.Model = &v s.Model = &v
@@ -502,7 +517,7 @@ func enrichWithNVMe(dev lsblkDevice) schema.HardwareStorage {
// smart-log: wear telemetry // smart-log: wear telemetry
if out, err := exec.Command("nvme", "smart-log", devPath, "-o", "json").Output(); err == nil { if out, err := exec.Command("nvme", "smart-log", devPath, "-o", "json").Output(); err == nil {
var log nvmeSmartLog var log NVMeSmartLog
if json.Unmarshal(out, &log) == nil { if json.Unmarshal(out, &log) == nil {
if log.PowerOnHours > 0 { if log.PowerOnHours > 0 {
v := int64(log.PowerOnHours) v := int64(log.PowerOnHours)

View File

@@ -56,7 +56,7 @@ func TestJsonInt64UnmarshalBothFormats(t *testing.T) {
{`null`, 0}, {`null`, 0},
} }
for _, tc := range cases { for _, tc := range cases {
var v jsonInt64 var v JSONInt64
if err := v.UnmarshalJSON([]byte(tc.json)); err != nil { if err := v.UnmarshalJSON([]byte(tc.json)); err != nil {
t.Fatalf("UnmarshalJSON(%s): unexpected error %v", tc.json, err) t.Fatalf("UnmarshalJSON(%s): unexpected error %v", tc.json, err)
} }

View File

@@ -9,7 +9,7 @@ import (
// TestNVMeSmartLogUnmarshal verifies that nvme-cli JSON output (where most // TestNVMeSmartLogUnmarshal verifies that nvme-cli JSON output (where most
// counters are quoted strings and field names differ from NVMe spec prose) // counters are quoted strings and field names differ from NVMe spec prose)
// is correctly parsed into nvmeSmartLog. // is correctly parsed into NVMeSmartLog.
func TestNVMeSmartLogUnmarshal(t *testing.T) { func TestNVMeSmartLogUnmarshal(t *testing.T) {
t.Parallel() t.Parallel()
@@ -30,7 +30,7 @@ func TestNVMeSmartLogUnmarshal(t *testing.T) {
"media_errors": "0", "media_errors": "0",
"num_err_log_entries": "0" "num_err_log_entries": "0"
}` }`
var log nvmeSmartLog var log NVMeSmartLog
if err := json.Unmarshal([]byte(raw), &log); err != nil { if err := json.Unmarshal([]byte(raw), &log); err != nil {
t.Fatalf("json.Unmarshal failed: %v", err) t.Fatalf("json.Unmarshal failed: %v", err)
} }

View File

@@ -1259,7 +1259,7 @@ func storageSATCommands(devPath string, extended bool) []satJob {
return jobs return jobs
} }
jobs := []satJob{ jobs := []satJob{
{name: "smartctl-health", cmd: []string{"smartctl", "-H", "-A", devPath}}, {name: "smartctl-health", cmd: []string{"smartctl", "-H", "-A", "-i", devPath}},
} }
if extended { if extended {
jobs = append(jobs, satJob{name: "smartctl-self-test-short", cmd: []string{"smartctl", "-t", "short", devPath}}) jobs = append(jobs, satJob{name: "smartctl-self-test-short", cmd: []string{"smartctl", "-t", "short", devPath}})

View File

@@ -1,6 +1,7 @@
package platform package platform
import ( import (
"bee/audit/internal/collector"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
@@ -39,65 +40,22 @@ func GenerateDiskReportText(index int, devPath string, outputs map[string][]byte
// ── NVMe ───────────────────────────────────────────────────────────────────── // ── 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) { func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) {
// id-ctrl // id-ctrl
var ctrl nvmeIdCtrl var ctrl collector.NVMeIDCtrl
if data := outputs["nvme-id-ctrl"]; len(data) > 0 { if data := outputs["nvme-id-ctrl"]; len(data) > 0 {
_ = json.Unmarshal(data, &ctrl) _ = json.Unmarshal(data, &ctrl)
} }
model := strings.TrimSpace(ctrl.ModelNumber) model := strings.TrimSpace(ctrl.ModelNumber)
serial := strings.TrimSpace(ctrl.SerialNumber) serial := strings.TrimSpace(ctrl.SerialNumber)
firmware := strings.TrimSpace(ctrl.Firmware) firmware := strings.TrimSpace(ctrl.FirmwareRev)
capacityGB := "" capacityGB := ""
if ctrl.TotalCap > 0 { if ctrl.TotalCapacity > 0 {
capacityGB = formatCapacityGB(ctrl.TotalCap) capacityGB = formatCapacityGB(uint64(ctrl.TotalCapacity))
} else if ctrl.NVMCap > 0 { } else if ctrl.NVMCapacity > 0 {
capacityGB = formatCapacityGB(ctrl.NVMCap) capacityGB = formatCapacityGB(uint64(ctrl.NVMCapacity))
} }
writeField(b, "Model", model) writeField(b, "Model", model)
@@ -113,67 +71,69 @@ func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) {
b.WriteString("\n(no SMART data)\n") b.WriteString("\n(no SMART data)\n")
return return
} }
var sl nvmeSmartLogRaw var sl collector.NVMeSmartLog
if err := json.Unmarshal(data, &sl); err != nil { if err := json.Unmarshal(data, &sl); err != nil {
fmt.Fprintf(b, "\n(SMART parse error: %v)\n", err) fmt.Fprintf(b, "\n(SMART parse error: %v)\n", err)
return return
} }
tempK := nvmeU64(sl.Temperature) tempC := int(sl.Temperature) - 273
tempC := int(tempK) - 273
if tempC < 0 { if tempC < 0 {
tempC = 0 tempC = 0
} }
critWarn := sl.CriticalWarning
critWarnStr := "OK" critWarnStr := "OK"
if critWarn != 0 { if sl.CriticalWarning != 0 {
critWarnStr = fmt.Sprintf("0x%02X", critWarn) critWarnStr = fmt.Sprintf("0x%02X", sl.CriticalWarning)
} }
poh := nvmeU64(sl.PowerOnHours) poh := uint64(sl.PowerOnHours)
pc := nvmeU64(sl.PowerCycles) pc := uint64(sl.PowerCycles)
us := nvmeU64(sl.UnsafeShutdowns) us := uint64(sl.UnsafeShutdowns)
me := nvmeU64(sl.MediaErrors) me := uint64(sl.MediaErrors)
nel := nvmeU64(sl.NumErrLogEntries) nel := uint64(sl.NumErrLogEntries)
// data_units are in 1000 × 512-byte sectors = 512,000 bytes each // data_units are in 1000 × 512-byte sectors = 512,000 bytes each
dataRead := float64(nvmeU64(sl.DataUnitsRead)) * 512000 / 1e9 readBytes := uint64(sl.DataUnitsRead) * 512000
dataWritten := float64(nvmeU64(sl.DataUnitsWritten)) * 512000 / 1e9 writtenBytes := uint64(sl.DataUnitsWritten) * 512000
writeSectionHeader(b, "Health") writeSectionHeader(b, "Health")
writeField(b, "Temperature", fmt.Sprintf("%d °C", tempC)) writeField(b, "Temperature", fmt.Sprintf("%d °C", tempC))
writeField(b, "Critical Warning", critWarnStr) writeField(b, "Critical Warning", critWarnStr)
writeField(b, "Percentage Used", fmt.Sprintf("%d %%", sl.PercentUsed)) writeField(b, "Percentage Used", fmt.Sprintf("%d %%", sl.PercentageUsed))
writeField(b, "Available Spare", fmt.Sprintf("%d %% (threshold: %d %%)", sl.AvailSpare, sl.SpareThresh)) writeField(b, "Available Spare", fmt.Sprintf("%d %% (threshold: %d %%)", sl.AvailableSpare, sl.SpareThreshold))
writeSectionHeader(b, "Usage") writeSectionHeader(b, "Usage")
writeField(b, "Power On Hours", fmt.Sprintf("%s h", formatUint(poh))) writeField(b, "Power On Hours", fmt.Sprintf("%s h", formatUint(poh)))
writeField(b, "Power Cycles", formatUint(pc)) writeField(b, "Power Cycles", formatUint(pc))
writeField(b, "Unsafe Shutdowns", formatUint(us)) writeField(b, "Unsafe Shutdowns", formatUint(us))
writeField(b, "Data Written", fmt.Sprintf("%.1f GB", dataWritten)) writeField(b, "Data Written", formatBytesHuman(float64(writtenBytes)))
writeField(b, "Data Read", fmt.Sprintf("%.1f GB", dataRead)) writeField(b, "Data Read", formatBytesHuman(float64(readBytes)))
writeSectionHeader(b, "Errors") writeSectionHeader(b, "Errors")
writeField(b, "Media Errors", formatUint(me)) writeField(b, "Media Errors", formatUint(me))
writeField(b, "Error Log Entries", formatUint(nel)) writeField(b, "Error Log Entries", formatUint(nel))
capacityBytes := ctrl.TotalCap capacityBytes := uint64(ctrl.TotalCapacity)
if capacityBytes == 0 { if capacityBytes == 0 {
capacityBytes = ctrl.NVMCap capacityBytes = uint64(ctrl.NVMCapacity)
} }
writeResourceSection(b, resourceInfo{ ri := resourceInfo{
powerOnHours: poh, powerOnHours: poh,
writtenBytes: uint64(nvmeU64(sl.DataUnitsWritten)) * 512000, powerCycles: pc,
readBytes: uint64(nvmeU64(sl.DataUnitsRead)) * 512000, writtenBytes: writtenBytes,
readBytes: readBytes,
capacityBytes: capacityBytes, capacityBytes: capacityBytes,
}) }
writeResourceSection(b, ri)
if selfTest := outputs["nvme-device-self-test"]; len(selfTest) > 0 { if selfTest := outputs["nvme-device-self-test"]; len(selfTest) > 0 {
writeSectionHeader(b, "Self-Test") writeSectionHeader(b, "Self-Test")
result := parseSelfTestResult(string(selfTest)) result := parseSelfTestResult(string(selfTest))
writeField(b, "Result", result) writeField(b, "Result", result)
} }
writeConclusionSection(b, ri)
} }
// ── SATA / SAS (smartctl) ──────────────────────────────────────────────────── // ── SATA / SAS (smartctl) ────────────────────────────────────────────────────
@@ -246,13 +206,15 @@ func writeSATAReport(b *strings.Builder, outputs map[string][]byte) {
} }
} }
var poh, writtenLBAs, readLBAs uint64 var poh, pc, writtenLBAs, readLBAs uint64
var readValue int var readValue int
hasReadValue := false hasReadValue := false
for _, a := range attrs { for _, a := range attrs {
switch a.ID { switch a.ID {
case 9: // Power_On_Hours case 9: // Power_On_Hours
poh = parseLeadingUint(a.Raw) poh = parseLeadingUint(a.Raw)
case 12: // Power_Cycle_Count
pc = parseLeadingUint(a.Raw)
case 241: // Total_LBAs_Written case 241: // Total_LBAs_Written
writtenLBAs = parseLeadingUint(a.Raw) writtenLBAs = parseLeadingUint(a.Raw)
case 242: // Total_LBAs_Read case 242: // Total_LBAs_Read
@@ -262,14 +224,16 @@ func writeSATAReport(b *strings.Builder, outputs map[string][]byte) {
} }
} }
const sataSectorBytes = 512 const sataSectorBytes = 512
writeResourceSection(b, resourceInfo{ ri := resourceInfo{
powerOnHours: poh, powerOnHours: poh,
powerCycles: pc,
writtenBytes: writtenLBAs * sataSectorBytes, writtenBytes: writtenLBAs * sataSectorBytes,
readBytes: readLBAs * sataSectorBytes, readBytes: readLBAs * sataSectorBytes,
capacityBytes: capacityBytes, capacityBytes: capacityBytes,
readPercent: 100 - readValue, readPercent: 100 - readValue,
hasReadPercent: hasReadValue, hasReadPercent: hasReadValue,
}) }
writeResourceSection(b, ri)
selfTest := outputs["smartctl-self-test-status"] selfTest := outputs["smartctl-self-test-status"]
if len(selfTest) == 0 { if len(selfTest) == 0 {
@@ -280,6 +244,8 @@ func writeSATAReport(b *strings.Builder, outputs map[string][]byte) {
result := parseSelfTestResult(string(selfTest)) result := parseSelfTestResult(string(selfTest))
writeField(b, "Result", result) writeField(b, "Result", result)
} }
writeConclusionSection(b, ri)
} }
func parseSMARTAttrs(text string) []smartAttr { func parseSMARTAttrs(text string) []smartAttr {
@@ -375,6 +341,7 @@ const (
type resourceInfo struct { type resourceInfo struct {
powerOnHours uint64 powerOnHours uint64
powerCycles uint64
writtenBytes uint64 writtenBytes uint64
readBytes uint64 readBytes uint64
capacityBytes uint64 capacityBytes uint64
@@ -407,6 +374,70 @@ func writeResourceSection(b *strings.Builder, r resourceInfo) {
} }
} }
// ── Conclusion (new-vs-used verdict) ────────────────────────────────────────
// Thresholds for treating a drive as "new": less than one full drive-write
// (110% of capacity, headroom for provisioning/overprovisioning rounding),
// less than a bit over two full drive-reads (210% of capacity), under a
// week of power-on time, and under 30 power cycles. Any one violation is
// enough to call the drive used — these are deliberately loose bounds, not
// a wear/endurance judgment (see -- Resource -- for that).
const (
newDiskMaxWrittenFrac = 1.10
newDiskMaxReadFrac = 2.10
newDiskMaxUptimeHours = 7 * 24
newDiskMaxPowerCycles = 30
)
func writeConclusionSection(b *strings.Builder, r resourceInfo) {
writeSectionHeader(b, "Conclusion")
var reasons, notes []string
isNew := true
if r.capacityBytes > 0 {
writtenFrac := float64(r.writtenBytes) / float64(r.capacityBytes)
readFrac := float64(r.readBytes) / float64(r.capacityBytes)
if writtenFrac >= newDiskMaxWrittenFrac {
isNew = false
reasons = append(reasons, fmt.Sprintf(
"data written %s (%s of capacity)",
formatBytesHuman(float64(r.writtenBytes)), formatPercent(writtenFrac*100)))
}
if readFrac >= newDiskMaxReadFrac {
isNew = false
reasons = append(reasons, fmt.Sprintf(
"data read %s (%s of capacity)",
formatBytesHuman(float64(r.readBytes)), formatPercent(readFrac*100)))
}
} else {
notes = append(notes, "capacity unknown — write/read criteria not evaluated")
}
if r.powerOnHours >= newDiskMaxUptimeHours {
isNew = false
reasons = append(reasons, fmt.Sprintf("uptime %s", formatHoursHuman(r.powerOnHours)))
}
if r.powerCycles >= newDiskMaxPowerCycles {
isNew = false
reasons = append(reasons, fmt.Sprintf("power cycles %s", formatUint(r.powerCycles)))
}
if isNew {
writeField(b, "Disk Condition", "NEW")
} else {
writeField(b, "Disk Condition", "USED")
b.WriteString(" Reason:\n")
for _, reason := range reasons {
fmt.Fprintf(b, " - %s\n", reason)
}
}
for _, note := range notes {
fmt.Fprintf(b, " Note: %s\n", note)
}
}
// progressBar renders a fixed-width pseudographic bar, e.g. "[######------]". // progressBar renders a fixed-width pseudographic bar, e.g. "[######------]".
func progressBar(frac float64, width int) string { func progressBar(frac float64, width int) string {
if math.IsNaN(frac) || frac < 0 { if math.IsNaN(frac) || frac < 0 {

View File

@@ -83,7 +83,36 @@ func TestGenerateDiskReportNVMe(t *testing.T) {
assertContains(t, report, "1,234 h") // power_on_hours with separator assertContains(t, report, "1,234 h") // power_on_hours with separator
assertContains(t, report, "32") // power_cycles assertContains(t, report, "32") // power_cycles
assertContains(t, report, "3") // unsafe_shutdowns assertContains(t, report, "3") // unsafe_shutdowns
assertContains(t, report, "378.0 GB") // data_units_written * 512000 / 1e9 assertContains(t, report, "378.00 GB") // data_units_written * 512000, human-scaled
}
// TestGenerateDiskReportNVMeDataUnitsScaleToTB verifies that heavy write/read
// counters render in the "-- Usage --" section as TB/PB, not raw GB, matching
// the "-- Resource --" section which already used formatBytesHuman.
func TestGenerateDiskReportNVMeDataUnitsScaleToTB(t *testing.T) {
t.Parallel()
heavy := []byte(`{
"critical_warning": 0,
"temperature": 307,
"avail_spare": 100,
"spare_thresh": 10,
"percent_used": 0,
"data_units_read": "252420478",
"data_units_written": "103834055",
"power_cycles": "45",
"power_on_hours": "45",
"unsafe_shutdowns": "35",
"media_errors": "0",
"num_err_log_entries": "0"
}`)
outputs := map[string][]byte{
"nvme-id-ctrl": testNVMeIdCtrl,
"nvme-smart-log": heavy,
}
report := GenerateDiskReportText(1, "/dev/nvme0n1", outputs, time.Unix(0, 0).UTC())
assertContains(t, report, "Data Written : 53.16 TB")
assertContains(t, report, "Data Read : 129.24 TB")
} }
func TestGenerateDiskReportNVMeLoHi(t *testing.T) { func TestGenerateDiskReportNVMeLoHi(t *testing.T) {