diff --git a/app/app.go b/app/app.go index b2a6d24..d07a08f 100644 --- a/app/app.go +++ b/app/app.go @@ -2,6 +2,7 @@ package app import ( "finally-a-monolithic-linux-hw-monitor/app/common" + "finally-a-monolithic-linux-hw-monitor/app/disks" "finally-a-monolithic-linux-hw-monitor/app/mem" "finally-a-monolithic-linux-hw-monitor/app/proc" "fmt" @@ -28,6 +29,7 @@ func Run() { term := common.NewTerminal() proc, _ := proc.GetProc() mem, _ := mem.GetMem() + disks, _ := disks.GetDisks() ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() @@ -36,6 +38,7 @@ func Run() { proc.Render(term) mem.Render(term) + disks.Render(term) term.End() diff --git a/app/disks/disks.go b/app/disks/disks.go new file mode 100644 index 0000000..7e9ee7d --- /dev/null +++ b/app/disks/disks.go @@ -0,0 +1,29 @@ +package disks + +import ( + "finally-a-monolithic-linux-hw-monitor/app/common" + "finally-a-monolithic-linux-hw-monitor/app/disks/drivers" +) + +type Disks struct { + drivers []common.Driver +} + +func GetDisks() (Disks, error) { + disks := Disks{} + disks.drivers = []common.Driver{} + + smart, err := drivers.NewSMARTDriver() + if err == nil { + disks.drivers = append(disks.drivers, smart) + } + + return disks, nil +} + +func (mem *Disks) Render(term common.Terminal) { + term.Header("Disks") + for _, driver := range mem.drivers { + driver.Render(term) + } +} diff --git a/app/disks/drivers/drivers.go b/app/disks/drivers/drivers.go new file mode 100644 index 0000000..3de4c65 --- /dev/null +++ b/app/disks/drivers/drivers.go @@ -0,0 +1,27 @@ +package drivers + +type DiskInfo struct { + DevicePath string + Model string + Serial string + Protocol string + MountPoint string + IsMounted bool +} + +type DiskUsage struct { + TotalBytes uint64 + FreeBytes uint64 + UsedBytes uint64 + UsagePercent float64 +} + +type DiskSMART struct { + Passed bool + TemperatureC int + PowerOnHours int + PowerCycleCount int + WearoutPercent int + HealthRemaining int + HasWearData bool +} diff --git a/app/disks/drivers/smart.go b/app/disks/drivers/smart.go new file mode 100644 index 0000000..8ccbf39 --- /dev/null +++ b/app/disks/drivers/smart.go @@ -0,0 +1,329 @@ +package drivers + +import ( + "bytes" + "encoding/json" + "finally-a-monolithic-linux-hw-monitor/app/common" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" +) + +type SMARTDiskReport struct { + Info DiskInfo + Usage DiskUsage + SMART DiskSMART +} + +// Internal JSON mapping matching smartctl 7.x+ output schemas +type smartctlJSON struct { + Device struct { + Protocol string `json:"protocol"` + ModelName string `json:"model_name"` + } `json:"device"` + Smartctl struct { + ExitStatus int `json:"exit_status"` + Messages []string `json:"messages"` + } `json:"smartctl"` + SmartStatus struct { + Passed bool `json:"passed"` + } `json:"smart_status"` + + // ATA / SATA Root Metrics + PowerOnTime struct { + Hours int `json:"hours"` + } `json:"power_on_time"` + PowerCycleCount int `json:"power_cycle_count"` + Temperature struct { + Current int `json:"current"` + } `json:"temperature"` + ModelName string `json:"model_name"` + SerialNumber string `json:"serial_number"` + Protocol string `json:"protocol"` + + // NVMe-Specific Health Log Metrics + NVMeHealthLog struct { + PercentageUsed int `json:"percentage_used"` + Temperature int `json:"temperature"` + PowerOnHours int `json:"power_on_hours"` + PowerCycles int `json:"power_cycles"` + } `json:"nvme_smart_health_information_log"` + + ATAMetrics struct { + Table []struct { + ID int `json:"id"` + Value int `json:"value"` + } `json:"table"` + } `json:"ata_smart_attributes"` +} + +type SMARTDriver struct{} + +func NewSMARTDriver() (*SMARTDriver, error) { + r := SMARTDriver{} + _, err := r.Read() + return &r, err +} + +// Helper function to convert raw byte counts to decimal Gigabytes (GB) +func formatBytesToGB(bytes uint64) string { + gb := float64(bytes) / (1000 * 1000 * 1000) + return fmt.Sprintf("%.2f GB", gb) +} + +func (r *SMARTDriver) Render(term common.Terminal) error { + d, err := r.Read() + if err != nil { + return err + } + + disks := d.([]SMARTDiskReport) + term.Subheader("Disks") + + for _, disk := range disks { + term.Print(fmt.Sprintf(" Disk %s ", disk.Info.DevicePath)) + term.Print(fmt.Sprintf("%-16s | %-16s", "Model Name", disk.Info.Model)) + term.Print(fmt.Sprintf("%-16s | %-16s", "Model Serial", disk.Info.Serial)) + term.Print(fmt.Sprintf("%-16s | %-16s", "Protocol", disk.Info.Protocol)) + var mp string + if disk.Info.IsMounted { + mp = disk.Info.MountPoint + } else { + mp = "Unmounted" + } + 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)) + + term.Print(fmt.Sprintf("%-16s | %t", "SMART Passed", disk.SMART.Passed)) + term.Print(fmt.Sprintf("%-16s | %d%%", "Wearout", disk.SMART.WearoutPercent)) + term.Print(fmt.Sprintf("%-16s | %d%%", "Health Remaining", disk.SMART.HealthRemaining)) + term.Print(fmt.Sprintf("%-16s | %d C", "Temperature", disk.SMART.TemperatureC)) + term.Print(fmt.Sprintf("%-16s | %d hrs", "Power-On Hours", disk.SMART.PowerOnHours)) + } + + return nil +} + +func (r *SMARTDriver) Read() (any, error) { + devicePaths, err := GetBlockDevices() + if err != nil { + return nil, err + } + + disks := []SMARTDiskReport{} + + for _, devicePath := range devicePaths { + disk, err := GetDiskReport(devicePath) + if err != nil { + continue + } + disks = append(disks, disk) + } + + return disks, nil +} + +func GetDiskReport(devicePath string) (SMARTDiskReport, error) { + info, err := GetDiskInfo(devicePath) + if err != nil { + return SMARTDiskReport{}, fmt.Errorf("failed to get disk info for %s: %w", devicePath, err) + } + + smart, err := GetDiskSMART(devicePath) + if err != nil { + return SMARTDiskReport{}, fmt.Errorf("failed to get SMART data for %s: %w", devicePath, err) + } + + var usage DiskUsage + if info.IsMounted { + u, err := GetDiskUsage(info.MountPoint) + if err == nil { + usage = *u + } + } + + return SMARTDiskReport{ + Info: *info, + Usage: usage, + SMART: *smart, + }, nil +} + +func extractProtocol(smartData *smartctlJSON) string { + if smartData.Protocol != "" { + return smartData.Protocol + } + return smartData.Device.Protocol +} + +func extractModel(smartData *smartctlJSON) string { + if smartData.ModelName != "" { + return smartData.ModelName + } + return smartData.Device.ModelName +} + +func GetDiskInfo(devicePath string) (*DiskInfo, error) { + smartData, err := execSmartctl(devicePath) + if err != nil { + return nil, fmt.Errorf("failed to query device info: %w", err) + } + + info := &DiskInfo{ + DevicePath: devicePath, + Model: extractModel(smartData), + Serial: smartData.SerialNumber, + Protocol: strings.ToUpper(extractProtocol(smartData)), + } + + mountPoint, isMounted, err := findMountPoint(devicePath) + if err != nil { + return nil, fmt.Errorf("failed to check mount status: %w", err) + } + + info.MountPoint = mountPoint + info.IsMounted = isMounted + + return info, nil +} + +func GetDiskUsage(path string) (*DiskUsage, error) { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return nil, fmt.Errorf("statfs failed for path %s: %w", path, err) + } + + total := stat.Blocks * uint64(stat.Bsize) + free := stat.Bfree * uint64(stat.Bsize) + used := total - free + + var percent float64 + if total > 0 { + percent = (float64(used) / float64(total)) * 100.0 + } + + return &DiskUsage{ + TotalBytes: total, + FreeBytes: free, + UsedBytes: used, + UsagePercent: percent, + }, nil +} + +func GetDiskSMART(devicePath string) (*DiskSMART, error) { + smartData, err := execSmartctl(devicePath) + if err != nil { + return nil, fmt.Errorf("failed to fetch SMART data: %w", err) + } + + smart := &DiskSMART{ + Passed: smartData.SmartStatus.Passed, + } + + protocol := strings.ToUpper(extractProtocol(smartData)) + + if protocol == "NVME" || strings.Contains(strings.ToLower(devicePath), "nvme") { + log := smartData.NVMeHealthLog + + smart.TemperatureC = log.Temperature + smart.PowerOnHours = log.PowerOnHours + smart.PowerCycleCount = log.PowerCycles + + smart.WearoutPercent = log.PercentageUsed + smart.HealthRemaining = 100 - log.PercentageUsed + if smart.HealthRemaining < 0 { + smart.HealthRemaining = 0 + } + smart.HasWearData = true + } else { + smart.TemperatureC = smartData.Temperature.Current + smart.PowerOnHours = smartData.PowerOnTime.Hours + smart.PowerCycleCount = smartData.PowerCycleCount + + 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) + smart.HasWearData = true + } + + if smart.HasWearData { + break // Exits the for-loop once a matching attribute is found + } + } + } + + return smart, nil +} + +func GetBlockDevices() ([]string, error) { + entries, err := os.ReadDir("/sys/block") + if err != nil { + return nil, err + } + + var devices []string + for _, entry := range entries { + name := entry.Name() + + if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "sr") { + continue + } + + devPath := filepath.Join("/dev", name) + if _, err := os.Stat(devPath); err == nil { + devices = append(devices, devPath) + } + } + + return devices, nil +} + +func findMountPoint(devicePath string) (string, bool, error) { + data, err := os.ReadFile("/proc/mounts") + if err != nil { + return "", false, err + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) >= 2 { + if fields[0] == devicePath || strings.HasPrefix(fields[0], devicePath) { + return fields[1], true, nil + } + } + } + + return "", false, nil +} + +func execSmartctl(devicePath string) (*smartctlJSON, error) { + cmd := exec.Command("smartctl", "-a", "-j", devicePath) + var out bytes.Buffer + cmd.Stdout = &out + + _ = cmd.Run() + + 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) + } + + return &result, nil +} diff --git a/app/mem/mem.go b/app/mem/mem.go index 7d0132d..4f5c39c 100644 --- a/app/mem/mem.go +++ b/app/mem/mem.go @@ -5,13 +5,6 @@ import ( "finally-a-monolithic-linux-hw-monitor/app/mem/drivers" ) -func (mem *Mem) Render(term common.Terminal) { - term.Header("Memory") - for _, driver := range mem.drivers { - driver.Render(term) - } -} - type Mem struct { drivers []common.Driver } @@ -32,3 +25,10 @@ func GetMem() (Mem, error) { return mem, nil } + +func (mem *Mem) Render(term common.Terminal) { + term.Header("Memory") + for _, driver := range mem.drivers { + driver.Render(term) + } +} diff --git a/app/proc/proc.go b/app/proc/proc.go index 1fb2df8..5f498d1 100644 --- a/app/proc/proc.go +++ b/app/proc/proc.go @@ -8,24 +8,17 @@ import ( "strings" ) -func (proc *Proc) Render(term common.Terminal) { - term.Header(proc.Model()) - for _, driver := range proc.drivers { - driver.Render(term) - } -} - -type Proc struct { - drivers []common.Driver - Info map[string]string -} - const ( VendorIntel string = "GenuineIntel" VendorAMD string = "AuthenticAMD" VendorUnknown string = "Unknown" ) +type Proc struct { + drivers []common.Driver + Info map[string]string +} + func GetProc() (Proc, error) { // open and parse cpuinfo proc := Proc{} @@ -56,7 +49,7 @@ func GetProc() (Proc, error) { } } - vendor := proc.Vendor() + vendor := proc.vendor() switch vendor { case VendorIntel: @@ -72,10 +65,17 @@ func GetProc() (Proc, error) { } } -func (proc *Proc) Vendor() string { +func (proc *Proc) Render(term common.Terminal) { + term.Header(proc.model()) + for _, driver := range proc.drivers { + driver.Render(term) + } +} + +func (proc *Proc) vendor() string { return proc.Info["vendor_id"] } -func (proc *Proc) Model() string { +func (proc *Proc) model() string { return proc.Info["model name"] }