diff --git a/app/disks/drivers/smart.go b/app/disks/drivers/smart.go index 8ccbf39..743b3c0 100644 --- a/app/disks/drivers/smart.go +++ b/app/disks/drivers/smart.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "syscall" + "unsafe" ) type SMARTDiskReport struct { @@ -47,7 +48,7 @@ type smartctlJSON struct { // NVMe-Specific Health Log Metrics NVMeHealthLog struct { PercentageUsed int `json:"percentage_used"` - Temperature int `json:"temperature"` + Temperature int `json:"temperature"` // Reported in Kelvin by smartctl NVMe JSON PowerOnHours int `json:"power_on_hours"` PowerCycles int `json:"power_cycles"` } `json:"nvme_smart_health_information_log"` @@ -68,9 +69,10 @@ func NewSMARTDriver() (*SMARTDriver, error) { return &r, err } -// Helper function to convert raw byte counts to decimal Gigabytes (GB) +const BytesPerGByte = 1000 * 1000 * 1000 + func formatBytesToGB(bytes uint64) string { - gb := float64(bytes) / (1000 * 1000 * 1000) + gb := float64(bytes) / BytesPerGByte return fmt.Sprintf("%.2f GB", gb) } @@ -92,11 +94,10 @@ func (r *SMARTDriver) Render(term common.Terminal) error { if disk.Info.IsMounted { mp = disk.Info.MountPoint } else { - mp = "Unmounted" + mp = "Unmounted (Raw/OSD/ZFS)" } term.Print(fmt.Sprintf("%-16s | %-16s", "Mount Point", mp)) - // Formatted bytes in GB term.Print(fmt.Sprintf("%-16s | %-16s", "Total Capacity", formatBytesToGB(disk.Usage.TotalBytes))) term.Print(fmt.Sprintf("%-16s | %-16s", "Free Space", formatBytesToGB(disk.Usage.FreeBytes))) term.Print(fmt.Sprintf("%-16s | %-16s (%.2f%%)", "Used Space", formatBytesToGB(disk.Usage.UsedBytes), disk.Usage.UsagePercent)) @@ -147,6 +148,11 @@ func GetDiskReport(devicePath string) (SMARTDiskReport, error) { if err == nil { usage = *u } + } else { + u, err := GetRawBlockDeviceUsage(devicePath) + if err == nil { + usage = *u + } } return SMARTDiskReport{ @@ -156,6 +162,34 @@ func GetDiskReport(devicePath string) (SMARTDiskReport, error) { }, nil } +func GetRawBlockDeviceUsage(devicePath string) (*DiskUsage, error) { + file, err := os.Open(devicePath) + if err != nil { + return nil, fmt.Errorf("failed to open device %s: %w", devicePath, err) + } + defer file.Close() + + var size uint64 + const BLKGETSIZE64 = 0x80081272 + _, _, sysErr := syscall.Syscall( + syscall.SYS_IOCTL, + file.Fd(), + uintptr(BLKGETSIZE64), + uintptr(unsafe.Pointer(&size)), + ) + + if sysErr != 0 { + return nil, fmt.Errorf("ioctl BLKGETSIZE64 failed: %v", sysErr) + } + + return &DiskUsage{ + TotalBytes: size, + FreeBytes: 0, + UsedBytes: 0, + UsagePercent: 0.0, + }, nil +} + func extractProtocol(smartData *smartctlJSON) string { if smartData.Protocol != "" { return smartData.Protocol @@ -232,7 +266,15 @@ func GetDiskSMART(devicePath string) (*DiskSMART, error) { if protocol == "NVME" || strings.Contains(strings.ToLower(devicePath), "nvme") { log := smartData.NVMeHealthLog - smart.TemperatureC = log.Temperature + // Convert Kelvin to Celsius if smartctl raw NVMe log provides Kelvin (> 200) + tempC := log.Temperature + if smartData.Temperature.Current > 0 { + tempC = smartData.Temperature.Current + } else if tempC > 200 { + tempC -= 273 + } + + smart.TemperatureC = tempC smart.PowerOnHours = log.PowerOnHours smart.PowerCycleCount = log.PowerCycles @@ -249,13 +291,11 @@ func GetDiskSMART(devicePath string) (*DiskSMART, error) { for _, attr := range smartData.ATAMetrics.Table { switch attr.ID { - // Attributes that represent Health Remaining (100 -> 0) case 231, 177, 233, 169, 230, 173: smart.HealthRemaining = attr.Value smart.WearoutPercent = max(100-attr.Value, 0) smart.HasWearData = true - // Attributes that represent Percent Used / Wearout (0 -> 100+) case 202: smart.WearoutPercent = attr.Value smart.HealthRemaining = max(100-attr.Value, 0) @@ -263,7 +303,7 @@ func GetDiskSMART(devicePath string) (*DiskSMART, error) { } if smart.HasWearData { - break // Exits the for-loop once a matching attribute is found + break } } } @@ -281,7 +321,8 @@ func GetBlockDevices() ([]string, error) { for _, entry := range entries { name := entry.Name() - if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "sr") { + // Ignore virtual and non-disk devices + if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "sr") || strings.HasPrefix(name, "dm-") || strings.HasPrefix(name, "nbd") { continue } @@ -295,6 +336,12 @@ func GetBlockDevices() ([]string, error) { } func findMountPoint(devicePath string) (string, bool, error) { + // Resolve symlinks (e.g., /dev/disk/by-id/...) to raw dev node + resolvedPath, err := filepath.EvalSymlinks(devicePath) + if err != nil { + resolvedPath = devicePath + } + data, err := os.ReadFile("/proc/mounts") if err != nil { return "", false, err @@ -304,7 +351,9 @@ func findMountPoint(devicePath string) (string, bool, error) { for _, line := range lines { fields := strings.Fields(line) if len(fields) >= 2 { - if fields[0] == devicePath || strings.HasPrefix(fields[0], devicePath) { + mountedDev := fields[0] + // Strict match to avoid matching /dev/sda when /dev/sda1 is mounted + if mountedDev == resolvedPath { return fields[1], true, nil } } @@ -318,8 +367,14 @@ func execSmartctl(devicePath string) (*smartctlJSON, error) { var out bytes.Buffer cmd.Stdout = &out + // smartctl uses bitmask exit codes (e.g., bit 1 set for SMART warnings). + // Run command and proceed if JSON output was produced regardless of exit status. _ = cmd.Run() + if out.Len() == 0 { + return nil, fmt.Errorf("smartctl returned empty output for %s", devicePath) + } + var result smartctlJSON if err := json.Unmarshal(out.Bytes(), &result); err != nil { return nil, fmt.Errorf("failed to parse smartctl json output for %s: %w", devicePath, err)