refactor(agent): simplify SMART collection with per-device smartctl attempts (#2620)

Rebuild the SMART device list on every refresh instead of keeping
per-device type and parser state across rescans. Each device gets a
short ordered list of smartctl queries, and the three parsers are merged
into one that rejects output without health data so the next query runs.

- scsi devices try auto-detect, then -d sat (#2295)
- NVMe controllers fall back to their n1 namespace
- disks in standby reuse their last reading; a disk not yet collected
  is woken once per agent run
- the last good reading survives temporary read failures
- failed or empty rescans keep the previous device list
- a missing SMART status is UNKNOWN instead of FAILED
- the reported disk type is the type that worked (e.g. sat)
- disks seen through two paths are reported once

---------

Co-authored-by: hank <hank@henrygd.me>
This commit is contained in:
Sven van Ginkel
2026-10-19 10:57:25 -04:00
committed by GitHub
co-authored by hank
parent e984ee1208
commit e233d663c3
14 changed files with 1966 additions and 2320 deletions
+38 -68
View File
@@ -6,7 +6,6 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/smart"
@@ -25,99 +24,70 @@ type emmcHealth struct {
lifeB uint8
}
func scanEmmcDevices() []*DeviceInfo {
// scanEmmcDevices returns the /dev paths of eMMC devices that expose health
// fields in sysfs.
func scanEmmcDevices() []string {
blockDir := filepath.Join(emmcSysfsRoot, "class", "block")
entries, err := os.ReadDir(blockDir)
if err != nil {
return nil
}
devices := make([]*DeviceInfo, 0, 2)
var devices []string
for _, ent := range entries {
name := ent.Name()
if !isEmmcBlockName(name) {
continue
}
deviceDir := filepath.Join(blockDir, name, "device")
if !hasEmmcHealthFiles(deviceDir) {
if !hasEmmcHealthFiles(filepath.Join(blockDir, name, "device")) {
continue
}
devPath := filepath.Join("/dev", name)
devices = append(devices, &DeviceInfo{
Name: devPath,
Type: "emmc",
InfoName: devPath + " [eMMC]",
Protocol: "MMC",
})
devices = append(devices, filepath.Join("/dev", name))
}
return devices
}
func (sm *SmartManager) collectEmmcHealth(deviceInfo *DeviceInfo) (bool, error) {
if deviceInfo == nil || deviceInfo.Name == "" {
return false, nil
}
base := filepath.Base(deviceInfo.Name)
if !isEmmcBlockName(base) && !strings.EqualFold(deviceInfo.Type, "emmc") && !strings.EqualFold(deviceInfo.Type, "mmc") {
return false, nil
}
// readEmmcSmartData reads eMMC wear / EOL indicators from sysfs. It returns the
// data map key (serial, or the device path when there is none).
func readEmmcSmartData(path string) (string, *smart.SmartData, bool) {
base := filepath.Base(path)
health, ok := readEmmcHealth(base)
if !ok {
return false, nil
return "", nil, false
}
// Normalize the device type to keep pruning logic stable across refreshes.
deviceInfo.Type = "emmc"
devPath := filepath.Join("/dev", base)
key := health.serial
if key == "" {
key = filepath.Join("/dev", base)
key = devPath
}
status := emmcSmartStatus(health.preEOL)
attrs := []*smart.SmartAttribute{
{
Name: "PreEOLInfo",
RawValue: uint64(health.preEOL),
RawString: emmcPreEOLString(health.preEOL),
return key, &smart.SmartData{
ModelName: health.model,
SerialNumber: health.serial,
FirmwareVersion: health.revision,
Capacity: health.capacity,
SmartStatus: emmcSmartStatus(health.preEOL),
DiskName: devPath,
DiskType: "emmc",
Attributes: []*smart.SmartAttribute{
{
Name: "PreEOLInfo",
RawValue: uint64(health.preEOL),
RawString: emmcPreEOLString(health.preEOL),
},
{
Name: "DeviceLifeTimeEstA",
RawValue: uint64(health.lifeA),
RawString: emmcLifeTimeString(health.lifeA),
},
{
Name: "DeviceLifeTimeEstB",
RawValue: uint64(health.lifeB),
RawString: emmcLifeTimeString(health.lifeB),
},
},
{
Name: "DeviceLifeTimeEstA",
RawValue: uint64(health.lifeA),
RawString: emmcLifeTimeString(health.lifeA),
},
{
Name: "DeviceLifeTimeEstB",
RawValue: uint64(health.lifeB),
RawString: emmcLifeTimeString(health.lifeB),
},
}
sm.Lock()
defer sm.Unlock()
if _, exists := sm.SmartDataMap[key]; !exists {
sm.SmartDataMap[key] = &smart.SmartData{}
}
data := sm.SmartDataMap[key]
data.ModelName = health.model
data.SerialNumber = health.serial
data.FirmwareVersion = health.revision
data.Capacity = health.capacity
data.Temperature = 0
data.SmartStatus = status
data.DiskName = filepath.Join("/dev", base)
data.DiskType = "emmc"
data.Attributes = attrs
return true, nil
}, true
}
func readEmmcHealth(blockName string) (emmcHealth, bool) {
+9 -18
View File
@@ -6,8 +6,6 @@ import (
"os"
"path/filepath"
"testing"
"github.com/henrygd/beszel/internal/entities/smart"
)
func TestEmmcMockSysfsScanAndCollect(t *testing.T) {
@@ -45,26 +43,19 @@ func TestEmmcMockSysfsScanAndCollect(t *testing.T) {
if len(devs) != 1 {
t.Fatalf("scanEmmcDevices() = %d devices, want 1", len(devs))
}
if devs[0].Name != "/dev/mmcblk0" || devs[0].Type != "emmc" {
t.Fatalf("scanEmmcDevices()[0] = %+v, want Name=/dev/mmcblk0 Type=emmc", devs[0])
if devs[0] != "/dev/mmcblk0" {
t.Fatalf("scanEmmcDevices()[0] = %q, want /dev/mmcblk0", devs[0])
}
sm := &SmartManager{SmartDataMap: map[string]*smart.SmartData{}}
ok, err := sm.collectEmmcHealth(devs[0])
if err != nil || !ok {
t.Fatalf("collectEmmcHealth() = (ok=%v, err=%v), want (true,nil)", ok, err)
// sysfs targets are read directly instead of through smartctl.
result, err := (&SmartManager{}).collectTarget(smartTarget{name: devs[0], dtype: "emmc"})
if err != nil {
t.Fatalf("collectTarget() err = %v", err)
}
if len(sm.SmartDataMap) != 1 {
t.Fatalf("SmartDataMap len=%d, want 1", len(sm.SmartDataMap))
}
var got *smart.SmartData
for _, v := range sm.SmartDataMap {
got = v
break
}
if got == nil {
t.Fatalf("SmartDataMap value nil")
if result.key != "01234567" {
t.Fatalf("key = %q, want 01234567", result.key)
}
got := result.data
if got.DiskType != "emmc" || got.DiskName != "/dev/mmcblk0" {
t.Fatalf("disk fields = (type=%q name=%q), want (emmc,/dev/mmcblk0)", got.DiskType, got.DiskName)
}
+5 -4
View File
@@ -2,13 +2,14 @@
package agent
import "github.com/henrygd/beszel/internal/entities/smart"
// Non-Linux builds: eMMC health via sysfs is not available.
func scanEmmcDevices() []*DeviceInfo {
func scanEmmcDevices() []string {
return nil
}
func (sm *SmartManager) collectEmmcHealth(deviceInfo *DeviceInfo) (bool, error) {
return false, nil
func readEmmcSmartData(path string) (string, *smart.SmartData, bool) {
return "", nil, false
}
+20 -47
View File
@@ -30,57 +30,37 @@ type mdraidHealth struct {
capacity uint64
}
// scanMdraidDevices discovers Linux md arrays exposed in sysfs.
func scanMdraidDevices() []*DeviceInfo {
// scanMdraidDevices returns the /dev paths of Linux md arrays exposed in sysfs.
func scanMdraidDevices() []string {
blockDir := filepath.Join(mdraidSysfsRoot, "block")
entries, err := os.ReadDir(blockDir)
if err != nil {
return nil
}
devices := make([]*DeviceInfo, 0, 2)
var devices []string
for _, ent := range entries {
name := ent.Name()
if !isMdraidBlockName(name) {
continue
}
mdDir := filepath.Join(blockDir, name, "md")
if !utils.FileExists(filepath.Join(mdDir, "array_state")) {
if !utils.FileExists(filepath.Join(blockDir, name, "md", "array_state")) {
continue
}
devPath := filepath.Join("/dev", name)
devices = append(devices, &DeviceInfo{
Name: devPath,
Type: "mdraid",
InfoName: devPath + " [mdraid]",
Protocol: "MD",
})
devices = append(devices, filepath.Join("/dev", name))
}
return devices
}
// collectMdraidHealth reads mdraid health and stores it in SmartDataMap.
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
if deviceInfo == nil || deviceInfo.Name == "" {
return false, nil
}
base := filepath.Base(deviceInfo.Name)
if !isMdraidBlockName(base) && !strings.EqualFold(deviceInfo.Type, "mdraid") {
return false, nil
}
// readMdraidSmartData reads mdraid health from sysfs. It returns the data map
// key ("mdraid:<name>").
func readMdraidSmartData(path string) (string, *smart.SmartData, bool) {
base := filepath.Base(path)
health, ok := readMdraidHealth(base)
if !ok {
return false, nil
return "", nil, false
}
deviceInfo.Type = "mdraid"
key := fmt.Sprintf("mdraid:%s", base)
status := mdraidSmartStatus(health)
attrs := make([]*smart.SmartAttribute, 0, 10)
if health.arrayState != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "ArrayState", RawString: health.arrayState})
@@ -110,25 +90,18 @@ func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error
attrs = append(attrs, &smart.SmartAttribute{Name: "MismatchCount", RawValue: health.mismatchCnt})
}
sm.Lock()
defer sm.Unlock()
if _, exists := sm.SmartDataMap[key]; !exists {
sm.SmartDataMap[key] = &smart.SmartData{}
}
data := sm.SmartDataMap[key]
data.ModelName = "Linux MD RAID"
model := "Linux MD RAID"
if health.level != "" {
data.ModelName = "Linux MD RAID (" + health.level + ")"
model = "Linux MD RAID (" + health.level + ")"
}
data.Capacity = health.capacity
data.SmartStatus = status
data.DiskName = filepath.Join("/dev", base)
data.DiskType = "mdraid"
data.Attributes = attrs
return true, nil
return fmt.Sprintf("mdraid:%s", base), &smart.SmartData{
ModelName: model,
Capacity: health.capacity,
SmartStatus: mdraidSmartStatus(health),
DiskName: filepath.Join("/dev", base),
DiskType: "mdraid",
Attributes: attrs,
}, true
}
// readMdraidHealth reads md array health fields from sysfs.
+9 -18
View File
@@ -6,8 +6,6 @@ import (
"os"
"path/filepath"
"testing"
"github.com/henrygd/beszel/internal/entities/smart"
)
func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
@@ -56,26 +54,19 @@ func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
if len(devs) != 1 {
t.Fatalf("scanMdraidDevices() = %d devices, want 1", len(devs))
}
if devs[0].Name != "/dev/md0" || devs[0].Type != "mdraid" {
t.Fatalf("scanMdraidDevices()[0] = %+v, want Name=/dev/md0 Type=mdraid", devs[0])
if devs[0] != "/dev/md0" {
t.Fatalf("scanMdraidDevices()[0] = %q, want /dev/md0", devs[0])
}
sm := &SmartManager{SmartDataMap: map[string]*smart.SmartData{}}
ok, err := sm.collectMdraidHealth(devs[0])
if err != nil || !ok {
t.Fatalf("collectMdraidHealth() = (ok=%v, err=%v), want (true,nil)", ok, err)
// sysfs targets are read directly instead of through smartctl.
result, err := (&SmartManager{}).collectTarget(smartTarget{name: devs[0], dtype: "mdraid"})
if err != nil {
t.Fatalf("collectTarget() err = %v", err)
}
if len(sm.SmartDataMap) != 1 {
t.Fatalf("SmartDataMap len=%d, want 1", len(sm.SmartDataMap))
}
var got *smart.SmartData
for _, v := range sm.SmartDataMap {
got = v
break
}
if got == nil {
t.Fatalf("SmartDataMap value nil")
if result.key != "mdraid:md0" {
t.Fatalf("key = %q, want mdraid:md0", result.key)
}
got := result.data
if got.DiskType != "mdraid" || got.DiskName != "/dev/md0" {
t.Fatalf("disk fields = (type=%q name=%q), want (mdraid,/dev/md0)", got.DiskType, got.DiskName)
}
+5 -3
View File
@@ -2,10 +2,12 @@
package agent
func scanMdraidDevices() []*DeviceInfo {
import "github.com/henrygd/beszel/internal/entities/smart"
func scanMdraidDevices() []string {
return nil
}
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
return false, nil
func readMdraidSmartData(path string) (string, *smart.SmartData, bool) {
return "", nil, false
}
+393 -1051
View File
File diff suppressed because it is too large Load Diff
+333
View File
@@ -0,0 +1,333 @@
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/smart"
)
var errNoHealthData = errors.New("no SMART health data in smartctl output")
// parseSmartctl turns `smartctl -a --json` output into SmartData and returns
// the protocol it was parsed as ("nvme", "scsi" or "ata"). dtype is the -d
// value used for the query; when set it is reported as the disk type.
//
// The protocol comes from smartctl's device.protocol, falling back to the
// sections present. Output that identifies a disk but holds no health data for
// its protocol is rejected, so the next smartctl attempt can run instead of a
// record of zeros being stored (#2295, #1504).
func (sm *SmartManager) parseSmartctl(output []byte, dtype string) (*smart.SmartData, string, error) {
data, err := decodeSmartctlOutput(output)
if err != nil {
return nil, "", err
}
protocol := smartProtocol(data)
switch protocol {
case "nvme":
log := data.NVMeSmartHealthInformationLog
if log == nil {
return nil, protocol, errNoHealthData
}
smartData := newSmartData(data, dtype, data.ModelName, data.FirmwareVersion, log.Temperature)
if smartData.Capacity == 0 {
smartData.Capacity = data.NVMeTotalCapacity
}
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
}
smartData.Attributes = nvmeAttributes(log, smartData.Temperature)
return smartData, protocol, nil
case "scsi":
if data.SmartStatus.Passed == nil && data.ScsiErrorCounterLog == nil && data.Temperature.Current == 0 {
return nil, protocol, errNoHealthData
}
smartData := newSmartData(data, dtype, data.ScsiModelName, data.ScsiRevision, data.Temperature.Current)
smartData.Attributes = scsiAttributes(data)
return smartData, protocol, nil
default: // ata
var table []smart.AtaSmartAttribute
if data.AtaSmartAttributes != nil {
table = data.AtaSmartAttributes.Table
}
if len(table) == 0 && data.SmartStatus.Passed == nil && data.Temperature.Current == 0 && !hasJSONValue(data.AtaDeviceStatistics) {
return nil, protocol, errNoHealthData
}
smartData := newSmartData(data, dtype, data.ModelName, data.FirmwareVersion, data.Temperature.Current)
if smartData.Temperature == 0 {
var ataDeviceStats smart.AtaDeviceStatistics
if temp := findAtaDeviceStatisticsValue(data, &ataDeviceStats, 5, "Current Temperature", 0, 255); temp != nil {
smartData.Temperature = uint8(*temp)
}
}
smartData.Attributes = ataAttributes(table)
return smartData, protocol, nil
}
}
// smartProtocol returns "nvme", "scsi" or "ata" for a smartctl response.
func smartProtocol(data *smart.SmartctlOutput) string {
switch strings.ToUpper(data.Device.Protocol) {
case "NVME":
return "nvme"
case "SCSI":
return "scsi"
case "ATA":
return "ata"
}
switch {
case data.NVMeSmartHealthInformationLog != nil, strings.EqualFold(data.Device.Type, "nvme"):
return "nvme"
case data.AtaSmartAttributes != nil:
return "ata"
case data.ScsiErrorCounterLog != nil, data.ScsiModelName != "":
return "scsi"
default:
return "ata"
}
}
// hasJSONValue reports whether a raw JSON value is present and not null.
func hasJSONValue(raw json.RawMessage) bool {
trimmed := strings.TrimSpace(string(raw))
return trimmed != "" && trimmed != "null"
}
// isVirtualDevice checks if a device is a virtual disk that should be filtered out
// (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
func isVirtualDevice(data *smart.SmartctlOutput) bool {
for _, field := range []string{data.ScsiVendor, data.ScsiProduct, data.ModelName, data.ScsiModelName} {
field = strings.ToUpper(field)
switch {
case strings.Contains(field, "IET"), // iSCSI Enterprise Target
strings.Contains(field, "VIRTUAL"),
strings.Contains(field, "QEMU"),
strings.Contains(field, "VBOX"),
strings.Contains(field, "VMWARE"),
strings.Contains(field, "MSFT"): // Microsoft Hyper-V
return true
}
}
return false
}
// decodeSmartctlOutput unmarshals smartctl JSON output and rejects devices
// without a serial number and virtual devices.
func decodeSmartctlOutput(output []byte) (*smart.SmartctlOutput, error) {
var data smart.SmartctlOutput
if err := json.Unmarshal(output, &data); err != nil {
return nil, fmt.Errorf("invalid smartctl JSON: %w", err)
}
if data.SerialNumber == "" {
return nil, errors.New("no serial number")
}
if isVirtualDevice(&data) {
return nil, fmt.Errorf("virtual device %q", data.ModelName)
}
return &data, nil
}
// newSmartData fills the fields shared by all smartctl protocols. deviceType is
// the exact type used to identify and query the device; when set, it takes
// precedence over the generic type reported by smartctl.
func newSmartData(data *smart.SmartctlOutput, deviceType, model, firmware string, temperature int64) *smart.SmartData {
smartData := &smart.SmartData{
ModelName: model,
SerialNumber: data.SerialNumber,
FirmwareVersion: firmware,
Capacity: data.UserCapacity.Bytes,
Temperature: uint8(min(max(temperature, 0), 255)),
SmartStatus: getSmartStatus(data.SmartStatus.Passed),
DiskName: data.Device.Name,
DiskType: data.Device.Type,
}
if deviceType != "" {
smartData.DiskType = deviceType
}
return smartData
}
// getSmartStatus maps smartctl's health check result. A missing result is
// UNKNOWN rather than FAILED, so an unreadable status never raises an alert.
func getSmartStatus(passed *bool) string {
switch {
case passed == nil:
return "UNKNOWN"
case *passed:
return "PASSED"
default:
return "FAILED"
}
}
func ataAttributes(table []smart.AtaSmartAttribute) []*smart.SmartAttribute {
attributes := make([]*smart.SmartAttribute, 0, len(table))
for _, attr := range table {
attributes = append(attributes, &smart.SmartAttribute{
ID: attr.ID,
Name: attr.Name,
Value: attr.Value,
Worst: attr.Worst,
Threshold: attr.Thresh,
RawValue: uint64(attr.Raw.Value), // already parsed from raw.string when possible
RawString: attr.Raw.String,
WhenFailed: attr.WhenFailed,
})
}
return attributes
}
// findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional
// metrics can be pulled from the same structure in the future.
func findAtaDeviceStatisticsValue(data *smart.SmartctlOutput, ataDeviceStats *smart.AtaDeviceStatistics, entryNumber uint8, entryName string, minValue, maxValue int64) *int64 {
if len(ataDeviceStats.Pages) == 0 {
if len(data.AtaDeviceStatistics) == 0 {
return nil
}
if err := json.Unmarshal(data.AtaDeviceStatistics, ataDeviceStats); err != nil {
return nil
}
}
for pageIdx := range ataDeviceStats.Pages {
page := &ataDeviceStats.Pages[pageIdx]
if page.Number != entryNumber {
continue
}
for entryIdx := range page.Table {
entry := &page.Table[entryIdx]
if !strings.EqualFold(entry.Name, entryName) {
continue
}
if entry.Value == nil || *entry.Value < minValue || *entry.Value > maxValue {
return nil
}
return entry.Value
}
}
return nil
}
func scsiAttributes(data *smart.SmartctlOutput) []*smart.SmartAttribute {
cycles := data.ScsiStartStopCycleCounter
attributes := []*smart.SmartAttribute{
{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours},
{Name: "PowerOnMinutes", RawValue: data.PowerOnTime.Minutes},
{Name: "GrownDefectList", RawValue: data.ScsiGrownDefectList},
{Name: "StartStopCycles", RawValue: cycles.AccumulatedStartStopCycles},
{Name: "LoadUnloadCycles", RawValue: cycles.AccumulatedLoadUnloadCycles},
{Name: "StartStopSpecified", RawValue: cycles.SpecifiedCycleCountOverDeviceLifetime},
{Name: "LoadUnloadSpecified", RawValue: cycles.SpecifiedLoadUnloadCountOverDeviceLifetime},
}
var errorLog smart.ScsiErrorCounterLog
if data.ScsiErrorCounterLog != nil {
errorLog = *data.ScsiErrorCounterLog
}
for _, counter := range []struct {
prefix string
stats smart.ScsiErrorCounter
}{{"Read", errorLog.Read}, {"Write", errorLog.Write}, {"Verify", errorLog.Verify}} {
attributes = append(attributes,
&smart.SmartAttribute{Name: counter.prefix + "TotalErrorsCorrected", RawValue: counter.stats.TotalErrorsCorrected},
&smart.SmartAttribute{Name: counter.prefix + "TotalUncorrectedErrors", RawValue: counter.stats.TotalUncorrectedErrors},
&smart.SmartAttribute{Name: counter.prefix + "CorrectionAlgorithmInvocations", RawValue: counter.stats.CorrectionAlgorithmInvocations},
)
if val := parseScsiGigabytesProcessed(counter.stats.GigabytesProcessed); val >= 0 {
attributes = append(attributes, &smart.SmartAttribute{Name: counter.prefix + "GigabytesProcessed", RawValue: uint64(val)})
}
}
return attributes
}
func parseScsiGigabytesProcessed(value string) int64 {
if value == "" {
return -1
}
normalized := strings.ReplaceAll(value, ",", "")
parsed, err := strconv.ParseInt(normalized, 10, 64)
if err != nil {
return -1
}
return parsed
}
// nvmeAttributes maps the NVMe health log, which does not follow the ATA
// attribute format, to SmartAttributes.
func nvmeAttributes(log *smart.NVMeSmartHealthInformationLog, temperature uint8) []*smart.SmartAttribute {
return []*smart.SmartAttribute{
{Name: "CriticalWarning", RawValue: log.CriticalWarning},
{Name: "Temperature", RawValue: uint64(temperature)},
{Name: "AvailableSpare", RawValue: log.AvailableSpare},
{Name: "AvailableSpareThreshold", RawValue: log.AvailableSpareThreshold},
{Name: "PercentageUsed", RawValue: log.PercentageUsed},
{Name: "DataUnitsRead", RawValue: log.DataUnitsRead},
{Name: "DataUnitsWritten", RawValue: log.DataUnitsWritten},
{Name: "HostReads", RawValue: log.HostReads},
{Name: "HostWrites", RawValue: log.HostWrites},
{Name: "ControllerBusyTime", RawValue: log.ControllerBusyTime},
{Name: "PowerCycles", RawValue: log.PowerCycles},
{Name: "PowerOnHours", RawValue: log.PowerOnHours},
{Name: "UnsafeShutdowns", RawValue: log.UnsafeShutdowns},
{Name: "MediaErrors", RawValue: log.MediaErrors},
{Name: "NumErrLogEntries", RawValue: log.NumErrLogEntries},
{Name: "WarningTempTime", RawValue: log.WarningTempTime},
{Name: "CriticalCompTime", RawValue: log.CriticalCompTime},
}
}
// lookupDarwinNvmeCapacity returns the capacity in bytes for a given NVMe serial number on Darwin.
// It uses system_profiler SPNVMeDataType to get capacity since Apple SSDs don't report user_capacity
// via smartctl. Results are cached after the first call via sync.Once.
func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
sm.darwinNvmeOnce.Do(func() {
sm.darwinNvmeCapacity = make(map[string]uint64)
provider := sm.darwinNvmeProvider
if provider == nil {
provider = func() ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return exec.CommandContext(ctx, "system_profiler", "SPNVMeDataType", "-json").Output()
}
}
out, err := provider()
if err != nil {
slog.Debug("system_profiler NVMe lookup failed", "err", err)
return
}
var result struct {
SPNVMeDataType []struct {
Items []struct {
DeviceSerial string `json:"device_serial"`
SizeInBytes uint64 `json:"size_in_bytes"`
} `json:"_items"`
} `json:"SPNVMeDataType"`
}
if err := json.Unmarshal(out, &result); err != nil {
slog.Debug("system_profiler NVMe parse failed", "err", err)
return
}
for _, controller := range result.SPNVMeDataType {
for _, item := range controller.Items {
if item.DeviceSerial != "" && item.SizeInBytes > 0 {
sm.darwinNvmeCapacity[item.DeviceSerial] = item.SizeInBytes
}
}
}
})
return sm.darwinNvmeCapacity[serial]
}
+839 -1110
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,228 @@
{
"json_format_version": [
1,
0
],
"smartctl": {
"version": [
7,
5
],
"pre_release": false,
"argv": [
"smartctl",
"-d",
"nvme",
"-a",
"--json=c",
"-n",
"standby",
"/dev/nvme0"
],
"messages": [
{
"string": "Read NVMe SMART/Health Information (NSID 0xffffffff) failed: NVME_IOCTL_ADMIN_CMD: Permission denied",
"severity": "error"
}
],
"exit_status": 4
},
"device": {
"name": "/dev/nvme0",
"info_name": "/dev/nvme0",
"type": "nvme",
"protocol": "NVMe"
},
"model_name": "HighRel 512GB SSD",
"serial_number": "MP51W00000001",
"firmware_version": "SN14665",
"nvme_pci_vendor": {
"id": 7755,
"subsystem_id": 7755
},
"nvme_ieee_oui_identifier": 3824167,
"nvme_total_capacity": 512110190592,
"nvme_unallocated_capacity": 0,
"nvme_controller_id": 0,
"nvme_version": {
"string": "1.4",
"value": 66560
},
"nvme_number_of_namespaces": 1,
"nvme_namespaces": [
{
"id": 1,
"size": {
"blocks": 1000215216,
"bytes": 512110190592
},
"capacity": {
"blocks": 1000215216,
"bytes": 512110190592
},
"utilization": {
"blocks": 1000215216,
"bytes": 512110190592
},
"formatted_lba_size": 512,
"eui64": {
"oui": 3824167,
"ext_id": 1234567890
},
"features": {
"value": 0,
"thin_provisioning": false,
"na_fields": false,
"dealloc_or_unwritten_block_error": false,
"uid_reuse": false,
"np_fields": false,
"other": 0
},
"lba_formats": [
{
"formatted": true,
"data_bytes": 512,
"metadata_bytes": 0,
"relative_performance": 0
}
]
}
],
"user_capacity": {
"blocks": 1000215216,
"bytes": 512110190592
},
"logical_block_size": 512,
"smart_support": {
"available": true,
"enabled": true
},
"nvme_firmware_update_capabilities": {
"value": 26,
"slots": 5,
"first_slot_is_read_only": false,
"activiation_without_reset": true,
"multiple_update_detection": false,
"other": 0
},
"nvme_optional_admin_commands": {
"value": 23,
"security_send_receive": true,
"format_nvm": true,
"firmware_download": true,
"namespace_management": false,
"self_test": true,
"directives": false,
"mi_send_receive": false,
"virtualization_management": false,
"doorbell_buffer_config": false,
"get_lba_status": false,
"command_and_feature_lockdown": false,
"other": 0
},
"nvme_optional_nvm_commands": {
"value": 31,
"compare": true,
"write_uncorrectable": true,
"dataset_management": true,
"write_zeroes": true,
"save_select_feature_nonzero": true,
"reservations": false,
"timestamp": false,
"verify": false,
"copy": false,
"other": 0
},
"nvme_log_page_attributes": {
"value": 6,
"smart_health_per_namespace": false,
"commands_effects_log": true,
"extended_get_log_page_cmd": true,
"telemetry_log": false,
"persistent_event_log": false,
"supported_log_pages_log": false,
"telemetry_data_area_4": false,
"other": 0
},
"nvme_maximum_data_transfer_pages": 128,
"nvme_composite_temperature_threshold": {
"warning": 90,
"critical": 95
},
"temperature": {
"op_limit_max": 90,
"critical_limit_max": 95
},
"nvme_power_states": [
{
"non_operational_state": false,
"relative_read_latency": 0,
"relative_read_throughput": 0,
"relative_write_latency": 0,
"relative_write_throughput": 0,
"entry_latency_us": 0,
"exit_latency_us": 0,
"max_power": {
"value": 650,
"scale": 2,
"units_per_watt": 100
}
},
{
"non_operational_state": false,
"relative_read_latency": 1,
"relative_read_throughput": 1,
"relative_write_latency": 1,
"relative_write_throughput": 1,
"entry_latency_us": 0,
"exit_latency_us": 0,
"max_power": {
"value": 580,
"scale": 2,
"units_per_watt": 100
}
},
{
"non_operational_state": false,
"relative_read_latency": 2,
"relative_read_throughput": 2,
"relative_write_latency": 2,
"relative_write_throughput": 2,
"entry_latency_us": 0,
"exit_latency_us": 0,
"max_power": {
"value": 360,
"scale": 2,
"units_per_watt": 100
}
},
{
"non_operational_state": true,
"relative_read_latency": 3,
"relative_read_throughput": 3,
"relative_write_latency": 3,
"relative_write_throughput": 3,
"entry_latency_us": 5000,
"exit_latency_us": 10000,
"max_power": {
"value": 500,
"scale": 1,
"units_per_watt": 10000
}
},
{
"non_operational_state": true,
"relative_read_latency": 4,
"relative_read_throughput": 4,
"relative_write_latency": 4,
"relative_write_throughput": 4,
"entry_latency_us": 8000,
"exit_latency_us": 45000,
"max_power": {
"value": 25,
"scale": 1,
"units_per_watt": 10000
}
}
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"smartctl": {
"exit_status": 0
},
"device": {
"name": "/dev/sda",
"info_name": "/dev/sda [SAT]",
"type": "sat",
"protocol": "ATA"
},
"model_name": "TOSHIBA MG03ACA200",
"serial_number": "Z4K2A01FS",
"firmware_version": "5701",
"user_capacity": { "blocks": 5860533168, "bytes": 3000592982016 },
"smart_status": { "passed": true },
"temperature": { "current": 38 },
"ata_smart_attributes": {
"revision": 16,
"table": [
{ "id": 1, "name": "Raw_Read_Error_Rate", "value": 100, "worst": 100, "thresh": 16, "when_failed": "", "raw": { "value": 0, "string": "0" } },
{ "id": 5, "name": "Reallocated_Sector_Ct", "value": 100, "worst": 100, "thresh": 5, "when_failed": "", "raw": { "value": 0, "string": "0" } },
{ "id": 9, "name": "Power_On_Hours", "value": 1, "worst": 1, "thresh": 0, "when_failed": "", "raw": { "value": 43953, "string": "43953" } },
{ "id": 194, "name": "Temperature_Celsius", "value": 62, "worst": 47, "thresh": 0, "when_failed": "", "raw": { "value": 38, "string": "38" } }
]
}
}
@@ -0,0 +1,40 @@
{
"json_format_version": [
1,
0
],
"smartctl": {
"version": [
7,
5
],
"pre_release": false,
"argv": [
"smartctl",
"-d",
"sat",
"-a",
"--json=c",
"-n",
"standby",
"/dev/sda"
],
"messages": [
{
"string": "Device is in SLEEP mode, exit(2)",
"severity": "information"
}
],
"exit_status": 2
},
"device": {
"name": "/dev/sda",
"info_name": "/dev/sda [SAT]",
"type": "sat",
"protocol": "ATA"
},
"power_mode": {
"ata_value": -1,
"name": "SLEEP"
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"smartctl": {
"exit_status": 0
},
"device": {
"name": "/dev/sda",
"info_name": "/dev/sda",
"type": "scsi",
"protocol": "SCSI"
},
"scsi_vendor": "TOSHIBA",
"scsi_product": "MG03ACA200",
"scsi_model_name": "TOSHIBA MG03ACA200",
"scsi_revision": "5701",
"serial_number": "Z4K2A01FS",
"user_capacity": { "blocks": 5860533168, "bytes": 3000592982016 },
"smart_status": { "passed": null },
"temperature": { "current": 0, "drive_trip": 0 },
"power_on_time": { "hours": 0, "minutes": 0 }
}
+1 -1
View File
@@ -21,7 +21,7 @@ type SmartctlOutput struct {
FirmwareVersion string `json:"firmware_version"`
UserCapacity UserCapacity `json:"user_capacity"`
SmartStatus struct {
Passed bool `json:"passed"`
Passed *bool `json:"passed"` // nil when smartctl could not read the health status
} `json:"smart_status"`
Temperature struct {
Current int64 `json:"current"`