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>
This commit is contained in:
@@ -94,8 +94,8 @@ func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) {
|
||||
nel := uint64(sl.NumErrLogEntries)
|
||||
|
||||
// data_units are in 1000 × 512-byte sectors = 512,000 bytes each
|
||||
dataRead := float64(sl.DataUnitsRead) * 512000 / 1e9
|
||||
dataWritten := float64(sl.DataUnitsWritten) * 512000 / 1e9
|
||||
readBytes := uint64(sl.DataUnitsRead) * 512000
|
||||
writtenBytes := uint64(sl.DataUnitsWritten) * 512000
|
||||
|
||||
writeSectionHeader(b, "Health")
|
||||
writeField(b, "Temperature", fmt.Sprintf("%d °C", tempC))
|
||||
@@ -107,8 +107,8 @@ func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) {
|
||||
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))
|
||||
writeField(b, "Data Written", formatBytesHuman(float64(writtenBytes)))
|
||||
writeField(b, "Data Read", formatBytesHuman(float64(readBytes)))
|
||||
|
||||
writeSectionHeader(b, "Errors")
|
||||
writeField(b, "Media Errors", formatUint(me))
|
||||
@@ -118,18 +118,22 @@ func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) {
|
||||
if capacityBytes == 0 {
|
||||
capacityBytes = uint64(ctrl.NVMCapacity)
|
||||
}
|
||||
writeResourceSection(b, resourceInfo{
|
||||
ri := resourceInfo{
|
||||
powerOnHours: poh,
|
||||
writtenBytes: uint64(sl.DataUnitsWritten) * 512000,
|
||||
readBytes: uint64(sl.DataUnitsRead) * 512000,
|
||||
powerCycles: pc,
|
||||
writtenBytes: writtenBytes,
|
||||
readBytes: readBytes,
|
||||
capacityBytes: capacityBytes,
|
||||
})
|
||||
}
|
||||
writeResourceSection(b, ri)
|
||||
|
||||
if selfTest := outputs["nvme-device-self-test"]; len(selfTest) > 0 {
|
||||
writeSectionHeader(b, "Self-Test")
|
||||
result := parseSelfTestResult(string(selfTest))
|
||||
writeField(b, "Result", result)
|
||||
}
|
||||
|
||||
writeConclusionSection(b, ri)
|
||||
}
|
||||
|
||||
// ── SATA / SAS (smartctl) ────────────────────────────────────────────────────
|
||||
@@ -202,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
|
||||
hasReadValue := false
|
||||
for _, a := range attrs {
|
||||
switch a.ID {
|
||||
case 9: // Power_On_Hours
|
||||
poh = parseLeadingUint(a.Raw)
|
||||
case 12: // Power_Cycle_Count
|
||||
pc = parseLeadingUint(a.Raw)
|
||||
case 241: // Total_LBAs_Written
|
||||
writtenLBAs = parseLeadingUint(a.Raw)
|
||||
case 242: // Total_LBAs_Read
|
||||
@@ -218,14 +224,16 @@ func writeSATAReport(b *strings.Builder, outputs map[string][]byte) {
|
||||
}
|
||||
}
|
||||
const sataSectorBytes = 512
|
||||
writeResourceSection(b, resourceInfo{
|
||||
ri := resourceInfo{
|
||||
powerOnHours: poh,
|
||||
powerCycles: pc,
|
||||
writtenBytes: writtenLBAs * sataSectorBytes,
|
||||
readBytes: readLBAs * sataSectorBytes,
|
||||
capacityBytes: capacityBytes,
|
||||
readPercent: 100 - readValue,
|
||||
hasReadPercent: hasReadValue,
|
||||
})
|
||||
}
|
||||
writeResourceSection(b, ri)
|
||||
|
||||
selfTest := outputs["smartctl-self-test-status"]
|
||||
if len(selfTest) == 0 {
|
||||
@@ -236,6 +244,8 @@ func writeSATAReport(b *strings.Builder, outputs map[string][]byte) {
|
||||
result := parseSelfTestResult(string(selfTest))
|
||||
writeField(b, "Result", result)
|
||||
}
|
||||
|
||||
writeConclusionSection(b, ri)
|
||||
}
|
||||
|
||||
func parseSMARTAttrs(text string) []smartAttr {
|
||||
@@ -331,6 +341,7 @@ const (
|
||||
|
||||
type resourceInfo struct {
|
||||
powerOnHours uint64
|
||||
powerCycles uint64
|
||||
writtenBytes uint64
|
||||
readBytes uint64
|
||||
capacityBytes uint64
|
||||
@@ -363,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. "[######------]".
|
||||
func progressBar(frac float64, width int) string {
|
||||
if math.IsNaN(frac) || frac < 0 {
|
||||
|
||||
Reference in New Issue
Block a user