diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a46d5d9..ad761fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,11 +50,27 @@ Config: `.editorconfig` and `.shellcheckrc` ## Go Components -`mo status` and `mo analyze` use Go for the interactive dashboards. +`mo status` and `mo analyze` use Go with Bubble Tea for interactive dashboards. + +**Code organization:** + +- Each module split into focused files by responsibility +- `cmd/analyze/` - Disk analyzer with 7 files under 500 lines each +- `cmd/status/` - System monitor with metrics split into 11 domain files + +**Development workflow:** - Format code with `gofmt -w ./cmd/...` -- Run `go test ./cmd/...` before submitting Go changes (ensures packages compile) -- Build universal binaries locally via `./scripts/build-status.sh` and `./scripts/build-analyze.sh` +- Run `go vet ./cmd/...` to check for issues +- Build with `go build ./...` to verify all packages compile +- Build universal binaries via `./scripts/build-status.sh` and `./scripts/build-analyze.sh` + +**Guidelines:** + +- Keep files focused on single responsibility +- Extract constants instead of magic numbers +- Use context for timeout control on external commands +- Add comments explaining why, not what ## Pull Requests diff --git a/bin/analyze-go b/bin/analyze-go index 8bf4b54..ade052c 100755 Binary files a/bin/analyze-go and b/bin/analyze-go differ diff --git a/bin/status-go b/bin/status-go index 62d74f5..81487ab 100755 Binary files a/bin/status-go and b/bin/status-go differ diff --git a/cmd/analyze/cache.go b/cmd/analyze/cache.go index 7eaaeff..fb4377c 100644 --- a/cmd/analyze/cache.go +++ b/cmd/analyze/cache.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/gob" "encoding/json" "fmt" @@ -291,7 +292,7 @@ func removeOverviewSnapshot(path string) { // prefetchOverviewCache scans overview directories in background // to populate cache for faster overview mode access -func prefetchOverviewCache() { +func prefetchOverviewCache(ctx context.Context) { entries := createOverviewEntries() // Check which entries need refresh @@ -309,8 +310,15 @@ func prefetchOverviewCache() { return } - // Scan and cache in background + // Scan and cache in background with context cancellation support for _, path := range needScan { + // Check if context is cancelled + select { + case <-ctx.Done(): + return + default: + } + size, err := measureOverviewSize(path) if err == nil && size > 0 { _ = storeOverviewSize(path, size) diff --git a/cmd/analyze/cleanable.go b/cmd/analyze/cleanable.go index f98b3f9..c74f7c7 100644 --- a/cmd/analyze/cleanable.go +++ b/cmd/analyze/cleanable.go @@ -59,17 +59,17 @@ var projectDependencyDirs = map[string]bool{ ".pnpm-store": true, // pnpm store // Python dependencies and outputs - "venv": true, - ".venv": true, - "virtualenv": true, - "__pycache__": true, - ".pytest_cache": true, - ".mypy_cache": true, - ".ruff_cache": true, - ".tox": true, - ".eggs": true, - "htmlcov": true, // Coverage reports - ".ipynb_checkpoints": true, // Jupyter checkpoints + "venv": true, + ".venv": true, + "virtualenv": true, + "__pycache__": true, + ".pytest_cache": true, + ".mypy_cache": true, + ".ruff_cache": true, + ".tox": true, + ".eggs": true, + "htmlcov": true, // Coverage reports + ".ipynb_checkpoints": true, // Jupyter checkpoints // Ruby dependencies "vendor": true, @@ -95,10 +95,10 @@ var projectDependencyDirs = map[string]bool{ ".nyc_output": true, // NYC coverage // Frontend framework outputs - ".angular": true, // Angular CLI cache - ".svelte-kit": true, // SvelteKit build - ".astro": true, // Astro cache - ".docusaurus": true, // Docusaurus build + ".angular": true, // Angular CLI cache + ".svelte-kit": true, // SvelteKit build + ".astro": true, // Astro cache + ".docusaurus": true, // Docusaurus build // iOS/macOS development "DerivedData": true, diff --git a/cmd/analyze/constants.go b/cmd/analyze/constants.go index 9a5225b..dbc6f61 100644 --- a/cmd/analyze/constants.go +++ b/cmd/analyze/constants.go @@ -6,8 +6,8 @@ const ( maxEntries = 30 maxLargeFiles = 30 barWidth = 24 - minLargeFileSize = 100 << 20 // 100 MB - defaultViewport = 12 // Default viewport when terminal height is unknown + minLargeFileSize = 100 << 20 // 100 MB + defaultViewport = 12 // Default viewport when terminal height is unknown overviewCacheTTL = 7 * 24 * time.Hour // 7 days overviewCacheFile = "overview_sizes.json" duTimeout = 60 * time.Second // Increased for large directories diff --git a/cmd/analyze/delete.go b/cmd/analyze/delete.go index 87ac071..bad3c2d 100644 --- a/cmd/analyze/delete.go +++ b/cmd/analyze/delete.go @@ -56,14 +56,19 @@ func deletePathWithProgress(root string, counter *int64) (int64, error) { return nil }) - if err != nil { - return count, err + // Track walk error separately + if err != nil && firstErr == nil { + firstErr = err } - if err := os.RemoveAll(root); err != nil { - return count, err + // Try to remove remaining directory structure + // Even if this fails, we still report files deleted + if removeErr := os.RemoveAll(root); removeErr != nil { + if firstErr == nil { + firstErr = removeErr + } } - // Return the first error encountered during deletion if any + // Always return count (even if there were errors), along with first error return count, firstErr } diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index fb4482b..66f74f5 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -141,7 +141,10 @@ func main() { } // Prefetch overview cache in background (non-blocking) - go prefetchOverviewCache() + // Use context with timeout to prevent hanging + prefetchCtx, prefetchCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer prefetchCancel() + go prefetchOverviewCache(prefetchCtx) p := tea.NewProgram(newModel(abs, isOverview), tea.WithAltScreen()) if err := p.Start(); err != nil { @@ -509,7 +512,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Handle delete confirmation if m.deleteConfirm { - if msg.String() == "delete" || msg.String() == "backspace" { + switch msg.String() { + case "delete", "backspace": // Confirm delete - start async deletion if m.deleteTarget != nil { m.deleteConfirm = false @@ -525,17 +529,14 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.deleteConfirm = false m.deleteTarget = nil return m, nil - } else if msg.String() == "esc" || msg.String() == "q" { + case "esc", "q": // Cancel delete with ESC or Q m.status = "Cancelled" m.deleteConfirm = false m.deleteTarget = nil return m, nil - } else { - // Any other key also cancels - m.status = "Cancelled" - m.deleteConfirm = false - m.deleteTarget = nil + default: + // Ignore other keys - keep showing confirmation return m, nil } } diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index ebd868f..b49f1f8 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -87,10 +87,10 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) entryChan <- dirEntry{ - Name: child.Name() + " →", // Add arrow to indicate symlink + Name: child.Name() + " →", // Add arrow to indicate symlink Path: fullPath, Size: size, - IsDir: false, // Don't allow navigation into symlinks + IsDir: false, // Don't allow navigation into symlinks LastAccess: getLastAccessTimeFromInfo(info), } continue @@ -189,10 +189,14 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in entries = entries[:maxEntries] } - // Try to use Spotlight for faster large file discovery + // Try to use Spotlight (mdfind) for faster large file discovery + // This is a performance optimization that gracefully falls back to scan results + // if Spotlight is unavailable or fails. The fallback is intentionally silent + // because users only care about correct results, not the method used. if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 { largeFiles = spotlightFiles } else { + // Use files collected during scanning (fallback path) // Sort and trim large files collected from scanning sort.Slice(largeFiles, func(i, j int) bool { return largeFiles[i].Size > largeFiles[j].Size diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index 5142f95..f3542fd 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -1,25 +1,13 @@ package main import ( - "bufio" "context" - "encoding/json" - "errors" "fmt" - "os" "os/exec" - "path/filepath" - "runtime" - "sort" - "strconv" - "strings" "time" - "github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/disk" "github.com/shirou/gopsutil/v3/host" - "github.com/shirou/gopsutil/v3/load" - "github.com/shirou/gopsutil/v3/mem" "github.com/shirou/gopsutil/v3/net" ) @@ -156,24 +144,6 @@ type Collector struct { lastDiskAt time.Time } -const ( - bluetoothCacheTTL = 30 * time.Second - systemProfilerTimeout = 4 * time.Second - bluetoothctlTimeout = 1500 * time.Millisecond - macGPUInfoTTL = 10 * time.Minute - cpuSampleInterval = 200 * time.Millisecond -) - -var skipDiskMounts = map[string]bool{ - "/System/Volumes/VM": true, - "/System/Volumes/Preboot": true, - "/System/Volumes/Update": true, - "/System/Volumes/xarts": true, - "/System/Volumes/Hardware": true, - "/System/Volumes/Data": true, - "/dev": true, -} - func NewCollector() *Collector { return &Collector{ prevNet: make(map[string]net.IOCountersStat), @@ -236,890 +206,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) { }, mergeErr } -func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus) (int, string) { - // Start with perfect score - score := 100.0 - issues := []string{} - - // CPU Usage (30% weight) - deduct up to 30 points - // 0-30% CPU = 0 deduction, 30-70% = linear, 70-100% = heavy penalty - cpuPenalty := 0.0 - if cpu.Usage > 30 { - if cpu.Usage > 70 { - cpuPenalty = 30.0 * (cpu.Usage - 30) / 70.0 - } else { - cpuPenalty = 15.0 * (cpu.Usage - 30) / 40.0 - } - } - score -= cpuPenalty - if cpu.Usage > 70 { - issues = append(issues, "High CPU") - } - - // Memory Usage (25% weight) - deduct up to 25 points - // 0-50% = 0 deduction, 50-80% = linear, 80-100% = heavy penalty - memPenalty := 0.0 - if mem.UsedPercent > 50 { - if mem.UsedPercent > 80 { - memPenalty = 25.0 * (mem.UsedPercent - 50) / 50.0 - } else { - memPenalty = 12.5 * (mem.UsedPercent - 50) / 30.0 - } - } - score -= memPenalty - if mem.UsedPercent > 80 { - issues = append(issues, "High Memory") - } - - // Memory Pressure (extra penalty) - if mem.Pressure == "warn" { - score -= 5 - issues = append(issues, "Memory Pressure") - } else if mem.Pressure == "critical" { - score -= 15 - issues = append(issues, "Critical Memory") - } - - // Disk Usage (20% weight) - deduct up to 20 points - diskPenalty := 0.0 - if len(disks) > 0 { - diskUsage := disks[0].UsedPercent - if diskUsage > 70 { - if diskUsage > 90 { - diskPenalty = 20.0 * (diskUsage - 70) / 30.0 - } else { - diskPenalty = 10.0 * (diskUsage - 70) / 20.0 - } - } - score -= diskPenalty - if diskUsage > 90 { - issues = append(issues, "Disk Almost Full") - } - } - - // Thermal (15% weight) - deduct up to 15 points - thermalPenalty := 0.0 - if thermal.CPUTemp > 0 { - if thermal.CPUTemp > 60 { - if thermal.CPUTemp > 85 { - thermalPenalty = 15.0 - issues = append(issues, "Overheating") - } else { - thermalPenalty = 15.0 * (thermal.CPUTemp - 60) / 25.0 - } - } - score -= thermalPenalty - } - - // Disk IO (10% weight) - deduct up to 10 points - ioPenalty := 0.0 - totalIO := diskIO.ReadRate + diskIO.WriteRate - if totalIO > 50 { - if totalIO > 150 { - ioPenalty = 10.0 - issues = append(issues, "Heavy Disk IO") - } else { - ioPenalty = 10.0 * (totalIO - 50) / 100.0 - } - } - score -= ioPenalty - - // Ensure score is in valid range - if score < 0 { - score = 0 - } - if score > 100 { - score = 100 - } - - // Generate message - msg := "Excellent" - if score >= 90 { - msg = "Excellent" - } else if score >= 75 { - msg = "Good" - } else if score >= 60 { - msg = "Fair" - } else if score >= 40 { - msg = "Poor" - } else { - msg = "Critical" - } - - if len(issues) > 0 { - msg = msg + ": " + strings.Join(issues, ", ") - } - - return int(score), msg -} - -func formatUptime(secs uint64) string { - days := secs / 86400 - hours := (secs % 86400) / 3600 - mins := (secs % 3600) / 60 - if days > 0 { - return fmt.Sprintf("%dd %dh %dm", days, hours, mins) - } - if hours > 0 { - return fmt.Sprintf("%dh %dm", hours, mins) - } - return fmt.Sprintf("%dm", mins) -} - -func collectCPU() (CPUStatus, error) { - counts, countsErr := cpu.Counts(false) - if countsErr != nil || counts == 0 { - counts = runtime.NumCPU() - } - - logical, logicalErr := cpu.Counts(true) - if logicalErr != nil || logical == 0 { - logical = runtime.NumCPU() - } - if logical <= 0 { - logical = 1 - } - - percents, err := cpu.Percent(cpuSampleInterval, true) - var totalPercent float64 - perCoreEstimated := false - if err != nil || len(percents) == 0 { - fallbackUsage, fallbackPerCore, fallbackErr := fallbackCPUUtilization(logical) - if fallbackErr != nil { - if err != nil { - return CPUStatus{}, err - } - return CPUStatus{}, fallbackErr - } - totalPercent = fallbackUsage - percents = fallbackPerCore - perCoreEstimated = true - } else { - for _, v := range percents { - totalPercent += v - } - totalPercent /= float64(len(percents)) - } - - loadStats, loadErr := load.Avg() - var loadAvg load.AvgStat - if loadStats != nil { - loadAvg = *loadStats - } - if loadErr != nil || isZeroLoad(loadAvg) { - if fallback, err := fallbackLoadAvgFromUptime(); err == nil { - loadAvg = fallback - } - } - - return CPUStatus{ - Usage: totalPercent, - PerCore: percents, - PerCoreEstimated: perCoreEstimated, - Load1: loadAvg.Load1, - Load5: loadAvg.Load5, - Load15: loadAvg.Load15, - CoreCount: counts, - LogicalCPU: logical, - }, nil -} - -func isZeroLoad(avg load.AvgStat) bool { - return avg.Load1 == 0 && avg.Load5 == 0 && avg.Load15 == 0 -} - -func fallbackLoadAvgFromUptime() (load.AvgStat, error) { - if !commandExists("uptime") { - return load.AvgStat{}, errors.New("uptime command unavailable") - } - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - - out, err := runCmd(ctx, "uptime") - if err != nil { - return load.AvgStat{}, err - } - - markers := []string{"load averages:", "load average:"} - idx := -1 - for _, marker := range markers { - if pos := strings.LastIndex(out, marker); pos != -1 { - idx = pos + len(marker) - break - } - } - if idx == -1 { - return load.AvgStat{}, errors.New("load averages not found in uptime output") - } - - segment := strings.TrimSpace(out[idx:]) - fields := strings.Fields(segment) - var values []float64 - for _, field := range fields { - field = strings.Trim(field, ",;") - if field == "" { - continue - } - val, err := strconv.ParseFloat(field, 64) - if err != nil { - continue - } - values = append(values, val) - if len(values) == 3 { - break - } - } - if len(values) < 3 { - return load.AvgStat{}, errors.New("could not parse load averages from uptime output") - } - - return load.AvgStat{ - Load1: values[0], - Load5: values[1], - Load15: values[2], - }, nil -} - -func fallbackCPUUtilization(logical int) (float64, []float64, error) { - if logical <= 0 { - logical = runtime.NumCPU() - } - if logical <= 0 { - logical = 1 - } - - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - - out, err := runCmd(ctx, "ps", "-Aceo", "pcpu") - if err != nil { - return 0, nil, err - } - - scanner := bufio.NewScanner(strings.NewReader(out)) - total := 0.0 - lineIndex := 0 - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - lineIndex++ - if lineIndex == 1 && (strings.Contains(strings.ToLower(line), "cpu") || strings.Contains(line, "%")) { - continue - } - - val, parseErr := strconv.ParseFloat(line, 64) - if parseErr != nil { - continue - } - total += val - } - if scanErr := scanner.Err(); scanErr != nil { - return 0, nil, scanErr - } - - maxTotal := float64(logical * 100) - if total < 0 { - total = 0 - } else if total > maxTotal { - total = maxTotal - } - - perCore := make([]float64, logical) - avg := total / float64(logical) - for i := range perCore { - perCore[i] = avg - } - return total, perCore, nil -} - -func collectMemory() (MemoryStatus, error) { - vm, err := mem.VirtualMemory() - if err != nil { - return MemoryStatus{}, err - } - - swap, _ := mem.SwapMemory() - pressure := getMemoryPressure() - - return MemoryStatus{ - Used: vm.Used, - Total: vm.Total, - UsedPercent: vm.UsedPercent, - SwapUsed: swap.Used, - SwapTotal: swap.Total, - Pressure: pressure, - }, nil -} - -func getMemoryPressure() string { - if runtime.GOOS != "darwin" { - return "" - } - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - out, err := runCmd(ctx, "memory_pressure") - if err != nil { - return "" - } - lower := strings.ToLower(out) - if strings.Contains(lower, "critical") { - return "critical" - } - if strings.Contains(lower, "warn") { - return "warn" - } - if strings.Contains(lower, "normal") { - return "normal" - } - return "" -} - -func collectDisks() ([]DiskStatus, error) { - partitions, err := disk.Partitions(false) - if err != nil { - return nil, err - } - - var ( - disks []DiskStatus - seenDevice = make(map[string]bool) - seenVolume = make(map[string]bool) - ) - for _, part := range partitions { - if strings.HasPrefix(part.Device, "/dev/loop") { - continue - } - if skipDiskMounts[part.Mountpoint] { - continue - } - if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") { - continue - } - // Skip private volumes - if strings.HasPrefix(part.Mountpoint, "/private/") { - continue - } - baseDevice := baseDeviceName(part.Device) - if baseDevice == "" { - baseDevice = part.Device - } - if seenDevice[baseDevice] { - continue - } - usage, err := disk.Usage(part.Mountpoint) - if err != nil || usage.Total == 0 { - continue - } - // Skip small volumes (< 1GB) - if usage.Total < 1<<30 { - continue - } - // For APFS volumes, use a more precise dedup key (bytes level) - // to handle shared storage pools properly - volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total) - if seenVolume[volKey] { - continue - } - disks = append(disks, DiskStatus{ - Mount: part.Mountpoint, - Device: part.Device, - Used: usage.Used, - Total: usage.Total, - UsedPercent: usage.UsedPercent, - Fstype: part.Fstype, - }) - seenDevice[baseDevice] = true - seenVolume[volKey] = true - } - - annotateDiskTypes(disks) - - sort.Slice(disks, func(i, j int) bool { - return disks[i].Total > disks[j].Total - }) - - if len(disks) > 3 { - disks = disks[:3] - } - - return disks, nil -} - -func annotateDiskTypes(disks []DiskStatus) { - if len(disks) == 0 || runtime.GOOS != "darwin" || !commandExists("diskutil") { - return - } - cache := make(map[string]bool) - for i := range disks { - base := baseDeviceName(disks[i].Device) - if base == "" { - base = disks[i].Device - } - if val, ok := cache[base]; ok { - disks[i].External = val - continue - } - external, err := isExternalDisk(base) - if err != nil { - external = strings.HasPrefix(disks[i].Mount, "/Volumes/") - } - disks[i].External = external - cache[base] = external - } -} - -func baseDeviceName(device string) string { - device = strings.TrimPrefix(device, "/dev/") - if !strings.HasPrefix(device, "disk") { - return device - } - for i := 4; i < len(device); i++ { - if device[i] == 's' { - return device[:i] - } - } - return device -} - -func isExternalDisk(device string) (bool, error) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - out, err := runCmd(ctx, "diskutil", "info", device) - if err != nil { - return false, err - } - var ( - found bool - external bool - ) - for _, line := range strings.Split(out, "\n") { - trim := strings.TrimSpace(line) - if strings.HasPrefix(trim, "Internal:") { - found = true - external = strings.Contains(trim, "No") - break - } - if strings.HasPrefix(trim, "Device Location:") { - found = true - external = strings.Contains(trim, "External") - } - } - if !found { - return false, errors.New("diskutil info missing Internal field") - } - return external, nil -} - -func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus { - counters, err := disk.IOCounters() - if err != nil || len(counters) == 0 { - return DiskIOStatus{} - } - - var total disk.IOCountersStat - for _, v := range counters { - total.ReadBytes += v.ReadBytes - total.WriteBytes += v.WriteBytes - } - - if c.lastDiskAt.IsZero() { - c.prevDiskIO = total - c.lastDiskAt = now - return DiskIOStatus{} - } - - elapsed := now.Sub(c.lastDiskAt).Seconds() - if elapsed <= 0 { - elapsed = 1 - } - - readRate := float64(total.ReadBytes-c.prevDiskIO.ReadBytes) / 1024 / 1024 / elapsed - writeRate := float64(total.WriteBytes-c.prevDiskIO.WriteBytes) / 1024 / 1024 / elapsed - - c.prevDiskIO = total - c.lastDiskAt = now - - if readRate < 0 { - readRate = 0 - } - if writeRate < 0 { - writeRate = 0 - } - - return DiskIOStatus{ReadRate: readRate, WriteRate: writeRate} -} - -func collectTopProcesses() []ProcessInfo { - if runtime.GOOS != "darwin" { - return nil - } - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - - // Use ps to get top processes by CPU - out, err := runCmd(ctx, "ps", "-Aceo", "pcpu,pmem,comm", "-r") - if err != nil { - return nil - } - - lines := strings.Split(strings.TrimSpace(out), "\n") - var procs []ProcessInfo - for i, line := range lines { - if i == 0 { // skip header - continue - } - if i > 5 { // top 5 - break - } - fields := strings.Fields(line) - if len(fields) < 3 { - continue - } - cpuVal, _ := strconv.ParseFloat(fields[0], 64) - memVal, _ := strconv.ParseFloat(fields[1], 64) - name := fields[len(fields)-1] - // Get just the process name without path - if idx := strings.LastIndex(name, "/"); idx >= 0 { - name = name[idx+1:] - } - procs = append(procs, ProcessInfo{ - Name: name, - CPU: cpuVal, - Memory: memVal, - }) - } - return procs -} - -func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) { - stats, err := net.IOCounters(true) - if err != nil { - return nil, err - } - - // Get IP addresses for interfaces - ifAddrs := getInterfaceIPs() - - if c.lastNetAt.IsZero() { - c.lastNetAt = now - for _, s := range stats { - c.prevNet[s.Name] = s - } - return nil, nil - } - - elapsed := now.Sub(c.lastNetAt).Seconds() - if elapsed <= 0 { - elapsed = 1 - } - - var result []NetworkStatus - for _, cur := range stats { - if isNoiseInterface(cur.Name) { - continue - } - prev, ok := c.prevNet[cur.Name] - if !ok { - continue - } - rx := float64(cur.BytesRecv-prev.BytesRecv) / 1024.0 / 1024.0 / elapsed - tx := float64(cur.BytesSent-prev.BytesSent) / 1024.0 / 1024.0 / elapsed - if rx < 0 { - rx = 0 - } - if tx < 0 { - tx = 0 - } - result = append(result, NetworkStatus{ - Name: cur.Name, - RxRateMBs: rx, - TxRateMBs: tx, - IP: ifAddrs[cur.Name], - }) - } - - c.lastNetAt = now - for _, s := range stats { - c.prevNet[s.Name] = s - } - - sort.Slice(result, func(i, j int) bool { - return result[i].RxRateMBs+result[i].TxRateMBs > result[j].RxRateMBs+result[j].TxRateMBs - }) - if len(result) > 3 { - result = result[:3] - } - - return result, nil -} - -func getInterfaceIPs() map[string]string { - result := make(map[string]string) - ifaces, err := net.Interfaces() - if err != nil { - return result - } - for _, iface := range ifaces { - for _, addr := range iface.Addrs { - // Only IPv4 - if strings.Contains(addr.Addr, ".") && !strings.HasPrefix(addr.Addr, "127.") { - ip := strings.Split(addr.Addr, "/")[0] - result[iface.Name] = ip - break - } - } - } - return result -} - -func isNoiseInterface(name string) bool { - lower := strings.ToLower(name) - noiseList := []string{"lo", "awdl", "utun", "llw", "bridge", "gif", "stf", "xhc", "anpi", "ap"} - for _, prefix := range noiseList { - if strings.HasPrefix(lower, prefix) { - return true - } - } - return false -} - -func collectBatteries() (batts []BatteryStatus, err error) { - defer func() { - if r := recover(); r != nil { - // Swallow panics from platform-specific battery probes to keep the UI alive. - err = fmt.Errorf("battery collection failed: %v", r) - } - }() - - // macOS: pmset - if runtime.GOOS == "darwin" && commandExists("pmset") { - if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil { - if batts := parsePMSet(out); len(batts) > 0 { - return batts, nil - } - } - } - - // Linux: /sys/class/power_supply - matches, _ := filepath.Glob("/sys/class/power_supply/BAT*/capacity") - for _, capFile := range matches { - statusFile := filepath.Join(filepath.Dir(capFile), "status") - capData, err := os.ReadFile(capFile) - if err != nil { - continue - } - statusData, _ := os.ReadFile(statusFile) - percentStr := strings.TrimSpace(string(capData)) - percent, _ := strconv.ParseFloat(percentStr, 64) - status := strings.TrimSpace(string(statusData)) - if status == "" { - status = "Unknown" - } - batts = append(batts, BatteryStatus{ - Percent: percent, - Status: status, - }) - } - if len(batts) > 0 { - return batts, nil - } - - return nil, errors.New("no battery data found") -} - -func collectSensors() ([]SensorReading, error) { - temps, err := host.SensorsTemperatures() - if err != nil { - return nil, err - } - sort.Slice(temps, func(i, j int) bool { - return temps[i].Temperature > temps[j].Temperature - }) - var out []SensorReading - for _, t := range temps { - if t.Temperature <= 0 || t.Temperature > 150 { - continue - } - out = append(out, SensorReading{ - Label: prettifyLabel(t.SensorKey), - Value: t.Temperature, - Unit: "°C", - }) - } - return out, nil -} - -func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) { - if runtime.GOOS == "darwin" { - if len(c.cachedGPU) > 0 && !c.lastGPUAt.IsZero() && now.Sub(c.lastGPUAt) < macGPUInfoTTL { - return c.cachedGPU, nil - } - if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 { - c.cachedGPU = gpus - c.lastGPUAt = now - return gpus, nil - } - } - - ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) - defer cancel() - - if !commandExists("nvidia-smi") { - return []GPUStatus{{ - Name: "No GPU metrics available", - Note: "Install nvidia-smi or use platform-specific metrics", - }}, nil - } - - out, err := runCmd(ctx, "nvidia-smi", "--query-gpu=utilization.gpu,memory.used,memory.total,name", "--format=csv,noheader,nounits") - if err != nil { - return nil, err - } - - lines := strings.Split(strings.TrimSpace(out), "\n") - var gpus []GPUStatus - for _, line := range lines { - fields := strings.Split(line, ",") - if len(fields) < 4 { - continue - } - util, _ := strconv.ParseFloat(strings.TrimSpace(fields[0]), 64) - memUsed, _ := strconv.ParseFloat(strings.TrimSpace(fields[1]), 64) - memTotal, _ := strconv.ParseFloat(strings.TrimSpace(fields[2]), 64) - name := strings.TrimSpace(fields[3]) - - gpus = append(gpus, GPUStatus{ - Name: name, - Usage: util, - MemoryUsed: memUsed, - MemoryTotal: memTotal, - }) - } - - if len(gpus) == 0 { - return []GPUStatus{{ - Name: "GPU read failed", - Note: "Verify nvidia-smi availability", - }}, nil - } - - return gpus, nil -} - -func (c *Collector) collectBluetooth(now time.Time) []BluetoothDevice { - if len(c.lastBT) > 0 && !c.lastBTAt.IsZero() && now.Sub(c.lastBTAt) < bluetoothCacheTTL { - return c.lastBT - } - - if devs, err := readSystemProfilerBluetooth(); err == nil && len(devs) > 0 { - c.lastBTAt = now - c.lastBT = devs - return devs - } - - if devs, err := readBluetoothCTLDevices(); err == nil && len(devs) > 0 { - c.lastBTAt = now - c.lastBT = devs - return devs - } - - c.lastBTAt = now - if len(c.lastBT) == 0 { - c.lastBT = []BluetoothDevice{{Name: "No Bluetooth info", Connected: false}} - } - return c.lastBT -} - -func readSystemProfilerBluetooth() ([]BluetoothDevice, error) { - if runtime.GOOS != "darwin" || !commandExists("system_profiler") { - return nil, errors.New("system_profiler unavailable") - } - - ctx, cancel := context.WithTimeout(context.Background(), systemProfilerTimeout) - defer cancel() - - out, err := runCmd(ctx, "system_profiler", "SPBluetoothDataType") - if err != nil { - return nil, err - } - return parseSPBluetooth(out), nil -} - -func readBluetoothCTLDevices() ([]BluetoothDevice, error) { - if !commandExists("bluetoothctl") { - return nil, errors.New("bluetoothctl unavailable") - } - - ctx, cancel := context.WithTimeout(context.Background(), bluetoothctlTimeout) - defer cancel() - - out, err := runCmd(ctx, "bluetoothctl", "info") - if err != nil { - return nil, err - } - return parseBluetoothctl(out), nil -} - -func readMacGPUInfo() ([]GPUStatus, error) { - ctx, cancel := context.WithTimeout(context.Background(), systemProfilerTimeout) - defer cancel() - - if !commandExists("system_profiler") { - return nil, errors.New("system_profiler unavailable") - } - - out, err := runCmd(ctx, "system_profiler", "-json", "SPDisplaysDataType") - if err != nil { - return nil, err - } - - var data struct { - Displays []struct { - Name string `json:"_name"` - VRAM string `json:"spdisplays_vram"` - Vendor string `json:"spdisplays_vendor"` - Metal string `json:"spdisplays_metal"` - } `json:"SPDisplaysDataType"` - } - if err := json.Unmarshal([]byte(out), &data); err != nil { - return nil, err - } - - var gpus []GPUStatus - for _, d := range data.Displays { - if d.Name == "" { - continue - } - noteParts := []string{} - if d.VRAM != "" { - noteParts = append(noteParts, "VRAM "+d.VRAM) - } - if d.Metal != "" { - noteParts = append(noteParts, d.Metal) - } - if d.Vendor != "" { - noteParts = append(noteParts, d.Vendor) - } - note := strings.Join(noteParts, " · ") - gpus = append(gpus, GPUStatus{ - Name: d.Name, - Usage: -1, - Note: note, - }) - } - - if len(gpus) == 0 { - return []GPUStatus{{ - Name: "GPU info unavailable", - Note: "Unable to parse system_profiler output", - }}, nil - } - - return gpus, nil -} +// Utility functions func runCmd(ctx context.Context, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) @@ -1143,324 +230,4 @@ func commandExists(name string) bool { return err == nil } -func parseSPBluetooth(raw string) []BluetoothDevice { - lines := strings.Split(raw, "\n") - var devices []BluetoothDevice - var currentName string - var connected bool - var battery string - - for _, line := range lines { - trim := strings.TrimSpace(line) - if len(trim) == 0 { - continue - } - if !strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") { - // Reset at top-level sections - currentName = "" - connected = false - battery = "" - continue - } - if strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") { - if currentName != "" { - devices = append(devices, BluetoothDevice{Name: currentName, Connected: connected, Battery: battery}) - } - currentName = strings.TrimSuffix(trim, ":") - connected = false - battery = "" - continue - } - if strings.Contains(trim, "Connected:") { - connected = strings.Contains(trim, "Yes") - } - if strings.Contains(trim, "Battery Level:") { - battery = strings.TrimSpace(strings.TrimPrefix(trim, "Battery Level:")) - } - } - if currentName != "" { - devices = append(devices, BluetoothDevice{Name: currentName, Connected: connected, Battery: battery}) - } - if len(devices) == 0 { - return []BluetoothDevice{{Name: "No devices", Connected: false}} - } - return devices -} - -func parseBluetoothctl(raw string) []BluetoothDevice { - lines := strings.Split(raw, "\n") - var devices []BluetoothDevice - current := BluetoothDevice{} - for _, line := range lines { - trim := strings.TrimSpace(line) - if strings.HasPrefix(trim, "Device ") { - if current.Name != "" { - devices = append(devices, current) - } - current = BluetoothDevice{Name: strings.TrimPrefix(trim, "Device "), Connected: false} - } - if strings.HasPrefix(trim, "Name:") { - current.Name = strings.TrimSpace(strings.TrimPrefix(trim, "Name:")) - } - if strings.HasPrefix(trim, "Connected:") { - current.Connected = strings.Contains(trim, "yes") - } - } - if current.Name != "" { - devices = append(devices, current) - } - if len(devices) == 0 { - return []BluetoothDevice{{Name: "No devices", Connected: false}} - } - return devices -} - -func parsePMSet(raw string) []BatteryStatus { - lines := strings.Split(raw, "\n") - var out []BatteryStatus - var timeLeft string - - for _, line := range lines { - // Check for time remaining - if strings.Contains(line, "remaining") { - // Extract time like "1:30 remaining" - parts := strings.Fields(line) - for i, p := range parts { - if p == "remaining" && i > 0 { - timeLeft = parts[i-1] - } - } - } - - if !strings.Contains(line, "%") { - continue - } - fields := strings.Fields(line) - var ( - percent float64 - found bool - status = "Unknown" - ) - for i, f := range fields { - if strings.Contains(f, "%") { - value := strings.TrimSuffix(strings.TrimSuffix(f, ";"), "%") - if p, err := strconv.ParseFloat(value, 64); err == nil { - percent = p - found = true - if i+1 < len(fields) { - status = strings.TrimSuffix(fields[i+1], ";") - } - } - break - } - } - if !found { - continue - } - - // Get battery health and cycle count - health, cycles := getBatteryHealth() - - out = append(out, BatteryStatus{ - Percent: percent, - Status: status, - TimeLeft: timeLeft, - Health: health, - CycleCount: cycles, - }) - } - return out -} - -func getBatteryHealth() (string, int) { - if runtime.GOOS != "darwin" { - return "", 0 - } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - out, err := runCmd(ctx, "system_profiler", "SPPowerDataType") - if err != nil { - return "", 0 - } - - var health string - var cycles int - - lines := strings.Split(out, "\n") - for _, line := range lines { - lower := strings.ToLower(line) - if strings.Contains(lower, "cycle count") { - parts := strings.Split(line, ":") - if len(parts) == 2 { - cycles, _ = strconv.Atoi(strings.TrimSpace(parts[1])) - } - } - if strings.Contains(lower, "condition") { - parts := strings.Split(line, ":") - if len(parts) == 2 { - health = strings.TrimSpace(parts[1]) - } - } - } - return health, cycles -} - -func collectThermal() ThermalStatus { - if runtime.GOOS != "darwin" { - return ThermalStatus{} - } - - var thermal ThermalStatus - - // Get fan info from system_profiler - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - out, err := runCmd(ctx, "system_profiler", "SPPowerDataType") - if err == nil { - lines := strings.Split(out, "\n") - for _, line := range lines { - lower := strings.ToLower(line) - if strings.Contains(lower, "fan") && strings.Contains(lower, "speed") { - parts := strings.Split(line, ":") - if len(parts) == 2 { - // Extract number from string like "1200 RPM" - numStr := strings.TrimSpace(parts[1]) - numStr = strings.Split(numStr, " ")[0] - thermal.FanSpeed, _ = strconv.Atoi(numStr) - } - } - } - } - - // Try to get CPU temperature using sudo powermetrics (may not work without sudo) - // Fallback: use SMC reader or estimate from thermal pressure - ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel2() - - // Try thermal level as a proxy - out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level") - if err == nil { - level, _ := strconv.Atoi(strings.TrimSpace(out2)) - // Estimate temp: level 0-100 roughly maps to 40-100°C - if level >= 0 { - thermal.CPUTemp = 45 + float64(level)*0.5 - } - } - - return thermal -} - -func prettifyLabel(key string) string { - key = strings.TrimSpace(key) - key = strings.TrimPrefix(key, "TC") - key = strings.ReplaceAll(key, "_", " ") - return key -} - -func collectProxy() ProxyStatus { - // Check environment variables first - for _, env := range []string{"https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"} { - if val := os.Getenv(env); val != "" { - proxyType := "HTTP" - if strings.HasPrefix(val, "socks") { - proxyType = "SOCKS" - } - // Extract host - host := val - if strings.Contains(host, "://") { - host = strings.SplitN(host, "://", 2)[1] - } - if idx := strings.Index(host, "@"); idx >= 0 { - host = host[idx+1:] - } - return ProxyStatus{Enabled: true, Type: proxyType, Host: host} - } - } - - // macOS: check system proxy via scutil - if runtime.GOOS == "darwin" { - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - out, err := runCmd(ctx, "scutil", "--proxy") - if err == nil { - if strings.Contains(out, "HTTPEnable : 1") || strings.Contains(out, "HTTPSEnable : 1") { - return ProxyStatus{Enabled: true, Type: "System", Host: "System Proxy"} - } - if strings.Contains(out, "SOCKSEnable : 1") { - return ProxyStatus{Enabled: true, Type: "SOCKS", Host: "System Proxy"} - } - } - } - - return ProxyStatus{Enabled: false} -} - -func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo { - if runtime.GOOS != "darwin" { - return HardwareInfo{ - Model: "Unknown", - CPUModel: runtime.GOARCH, - TotalRAM: humanBytes(totalRAM), - DiskSize: "Unknown", - OSVersion: runtime.GOOS, - } - } - - // Get model and CPU from system_profiler - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - var model, cpuModel, osVersion string - - // Get hardware overview - out, err := runCmd(ctx, "system_profiler", "SPHardwareDataType") - if err == nil { - lines := strings.Split(out, "\n") - for _, line := range lines { - lower := strings.ToLower(strings.TrimSpace(line)) - // Prefer "Model Name" over "Model Identifier" - if strings.Contains(lower, "model name:") { - parts := strings.Split(line, ":") - if len(parts) == 2 { - model = strings.TrimSpace(parts[1]) - } - } - if strings.Contains(lower, "chip:") { - parts := strings.Split(line, ":") - if len(parts) == 2 { - cpuModel = strings.TrimSpace(parts[1]) - } - } - if strings.Contains(lower, "processor name:") && cpuModel == "" { - parts := strings.Split(line, ":") - if len(parts) == 2 { - cpuModel = strings.TrimSpace(parts[1]) - } - } - } - } - - // Get macOS version - ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel2() - out2, err := runCmd(ctx2, "sw_vers", "-productVersion") - if err == nil { - osVersion = "macOS " + strings.TrimSpace(out2) - } - - // Get disk size - diskSize := "Unknown" - if len(disks) > 0 { - diskSize = humanBytes(disks[0].Total) - } - - return HardwareInfo{ - Model: model, - CPUModel: cpuModel, - TotalRAM: humanBytes(totalRAM), - DiskSize: diskSize, - OSVersion: osVersion, - } -} +// humanBytes is defined in view.go to avoid duplication diff --git a/cmd/status/metrics_battery.go b/cmd/status/metrics_battery.go new file mode 100644 index 0000000..8733a31 --- /dev/null +++ b/cmd/status/metrics_battery.go @@ -0,0 +1,222 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/shirou/gopsutil/v3/host" +) + +func collectBatteries() (batts []BatteryStatus, err error) { + defer func() { + if r := recover(); r != nil { + // Swallow panics from platform-specific battery probes to keep the UI alive. + err = fmt.Errorf("battery collection failed: %v", r) + } + }() + + // macOS: pmset + if runtime.GOOS == "darwin" && commandExists("pmset") { + if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil { + if batts := parsePMSet(out); len(batts) > 0 { + return batts, nil + } + } + } + + // Linux: /sys/class/power_supply + matches, _ := filepath.Glob("/sys/class/power_supply/BAT*/capacity") + for _, capFile := range matches { + statusFile := filepath.Join(filepath.Dir(capFile), "status") + capData, err := os.ReadFile(capFile) + if err != nil { + continue + } + statusData, _ := os.ReadFile(statusFile) + percentStr := strings.TrimSpace(string(capData)) + percent, _ := strconv.ParseFloat(percentStr, 64) + status := strings.TrimSpace(string(statusData)) + if status == "" { + status = "Unknown" + } + batts = append(batts, BatteryStatus{ + Percent: percent, + Status: status, + }) + } + if len(batts) > 0 { + return batts, nil + } + + return nil, errors.New("no battery data found") +} + +func parsePMSet(raw string) []BatteryStatus { + lines := strings.Split(raw, "\n") + var out []BatteryStatus + var timeLeft string + + for _, line := range lines { + // Check for time remaining + if strings.Contains(line, "remaining") { + // Extract time like "1:30 remaining" + parts := strings.Fields(line) + for i, p := range parts { + if p == "remaining" && i > 0 { + timeLeft = parts[i-1] + } + } + } + + if !strings.Contains(line, "%") { + continue + } + fields := strings.Fields(line) + var ( + percent float64 + found bool + status = "Unknown" + ) + for i, f := range fields { + if strings.Contains(f, "%") { + value := strings.TrimSuffix(strings.TrimSuffix(f, ";"), "%") + if p, err := strconv.ParseFloat(value, 64); err == nil { + percent = p + found = true + if i+1 < len(fields) { + status = strings.TrimSuffix(fields[i+1], ";") + } + } + break + } + } + if !found { + continue + } + + // Get battery health and cycle count + health, cycles := getBatteryHealth() + + out = append(out, BatteryStatus{ + Percent: percent, + Status: status, + TimeLeft: timeLeft, + Health: health, + CycleCount: cycles, + }) + } + return out +} + +func getBatteryHealth() (string, int) { + if runtime.GOOS != "darwin" { + return "", 0 + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + out, err := runCmd(ctx, "system_profiler", "SPPowerDataType") + if err != nil { + return "", 0 + } + + var health string + var cycles int + + lines := strings.Split(out, "\n") + for _, line := range lines { + lower := strings.ToLower(line) + if strings.Contains(lower, "cycle count") { + parts := strings.Split(line, ":") + if len(parts) == 2 { + cycles, _ = strconv.Atoi(strings.TrimSpace(parts[1])) + } + } + if strings.Contains(lower, "condition") { + parts := strings.Split(line, ":") + if len(parts) == 2 { + health = strings.TrimSpace(parts[1]) + } + } + } + return health, cycles +} + +func collectThermal() ThermalStatus { + if runtime.GOOS != "darwin" { + return ThermalStatus{} + } + + var thermal ThermalStatus + + // Get fan info from system_profiler + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + out, err := runCmd(ctx, "system_profiler", "SPPowerDataType") + if err == nil { + lines := strings.Split(out, "\n") + for _, line := range lines { + lower := strings.ToLower(line) + if strings.Contains(lower, "fan") && strings.Contains(lower, "speed") { + parts := strings.Split(line, ":") + if len(parts) == 2 { + // Extract number from string like "1200 RPM" + numStr := strings.TrimSpace(parts[1]) + numStr = strings.Split(numStr, " ")[0] + thermal.FanSpeed, _ = strconv.Atoi(numStr) + } + } + } + } + + // Try to get CPU temperature using sudo powermetrics (may not work without sudo) + // Fallback: use SMC reader or estimate from thermal pressure + ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel2() + + // Try thermal level as a proxy + out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level") + if err == nil { + level, _ := strconv.Atoi(strings.TrimSpace(out2)) + // Estimate temp: level 0-100 roughly maps to 40-100°C + if level >= 0 { + thermal.CPUTemp = 45 + float64(level)*0.5 + } + } + + return thermal +} + +func collectSensors() ([]SensorReading, error) { + temps, err := host.SensorsTemperatures() + if err != nil { + return nil, err + } + var out []SensorReading + for _, t := range temps { + if t.Temperature <= 0 || t.Temperature > 150 { + continue + } + out = append(out, SensorReading{ + Label: prettifyLabel(t.SensorKey), + Value: t.Temperature, + Unit: "°C", + }) + } + return out, nil +} + +func prettifyLabel(key string) string { + key = strings.TrimSpace(key) + key = strings.TrimPrefix(key, "TC") + key = strings.ReplaceAll(key, "_", " ") + return key +} diff --git a/cmd/status/metrics_bluetooth.go b/cmd/status/metrics_bluetooth.go new file mode 100644 index 0000000..7402fee --- /dev/null +++ b/cmd/status/metrics_bluetooth.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "errors" + "runtime" + "strings" + "time" +) + +const ( + bluetoothCacheTTL = 30 * time.Second + bluetoothctlTimeout = 1500 * time.Millisecond +) + +func (c *Collector) collectBluetooth(now time.Time) []BluetoothDevice { + if len(c.lastBT) > 0 && !c.lastBTAt.IsZero() && now.Sub(c.lastBTAt) < bluetoothCacheTTL { + return c.lastBT + } + + if devs, err := readSystemProfilerBluetooth(); err == nil && len(devs) > 0 { + c.lastBTAt = now + c.lastBT = devs + return devs + } + + if devs, err := readBluetoothCTLDevices(); err == nil && len(devs) > 0 { + c.lastBTAt = now + c.lastBT = devs + return devs + } + + c.lastBTAt = now + if len(c.lastBT) == 0 { + c.lastBT = []BluetoothDevice{{Name: "No Bluetooth info", Connected: false}} + } + return c.lastBT +} + +func readSystemProfilerBluetooth() ([]BluetoothDevice, error) { + if runtime.GOOS != "darwin" || !commandExists("system_profiler") { + return nil, errors.New("system_profiler unavailable") + } + + ctx, cancel := context.WithTimeout(context.Background(), systemProfilerTimeout) + defer cancel() + + out, err := runCmd(ctx, "system_profiler", "SPBluetoothDataType") + if err != nil { + return nil, err + } + return parseSPBluetooth(out), nil +} + +func readBluetoothCTLDevices() ([]BluetoothDevice, error) { + if !commandExists("bluetoothctl") { + return nil, errors.New("bluetoothctl unavailable") + } + + ctx, cancel := context.WithTimeout(context.Background(), bluetoothctlTimeout) + defer cancel() + + out, err := runCmd(ctx, "bluetoothctl", "info") + if err != nil { + return nil, err + } + return parseBluetoothctl(out), nil +} + +func parseSPBluetooth(raw string) []BluetoothDevice { + lines := strings.Split(raw, "\n") + var devices []BluetoothDevice + var currentName string + var connected bool + var battery string + + for _, line := range lines { + trim := strings.TrimSpace(line) + if len(trim) == 0 { + continue + } + if !strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") { + // Reset at top-level sections + currentName = "" + connected = false + battery = "" + continue + } + if strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") { + if currentName != "" { + devices = append(devices, BluetoothDevice{Name: currentName, Connected: connected, Battery: battery}) + } + currentName = strings.TrimSuffix(trim, ":") + connected = false + battery = "" + continue + } + if strings.Contains(trim, "Connected:") { + connected = strings.Contains(trim, "Yes") + } + if strings.Contains(trim, "Battery Level:") { + battery = strings.TrimSpace(strings.TrimPrefix(trim, "Battery Level:")) + } + } + if currentName != "" { + devices = append(devices, BluetoothDevice{Name: currentName, Connected: connected, Battery: battery}) + } + if len(devices) == 0 { + return []BluetoothDevice{{Name: "No devices", Connected: false}} + } + return devices +} + +func parseBluetoothctl(raw string) []BluetoothDevice { + lines := strings.Split(raw, "\n") + var devices []BluetoothDevice + current := BluetoothDevice{} + for _, line := range lines { + trim := strings.TrimSpace(line) + if strings.HasPrefix(trim, "Device ") { + if current.Name != "" { + devices = append(devices, current) + } + current = BluetoothDevice{Name: strings.TrimPrefix(trim, "Device "), Connected: false} + } + if strings.HasPrefix(trim, "Name:") { + current.Name = strings.TrimSpace(strings.TrimPrefix(trim, "Name:")) + } + if strings.HasPrefix(trim, "Connected:") { + current.Connected = strings.Contains(trim, "yes") + } + } + if current.Name != "" { + devices = append(devices, current) + } + if len(devices) == 0 { + return []BluetoothDevice{{Name: "No devices", Connected: false}} + } + return devices +} diff --git a/cmd/status/metrics_cpu.go b/cmd/status/metrics_cpu.go new file mode 100644 index 0000000..f5c5614 --- /dev/null +++ b/cmd/status/metrics_cpu.go @@ -0,0 +1,186 @@ +package main + +import ( + "bufio" + "context" + "errors" + "runtime" + "strconv" + "strings" + "time" + + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/load" +) + +const ( + cpuSampleInterval = 200 * time.Millisecond +) + +func collectCPU() (CPUStatus, error) { + counts, countsErr := cpu.Counts(false) + if countsErr != nil || counts == 0 { + counts = runtime.NumCPU() + } + + logical, logicalErr := cpu.Counts(true) + if logicalErr != nil || logical == 0 { + logical = runtime.NumCPU() + } + if logical <= 0 { + logical = 1 + } + + percents, err := cpu.Percent(cpuSampleInterval, true) + var totalPercent float64 + perCoreEstimated := false + if err != nil || len(percents) == 0 { + fallbackUsage, fallbackPerCore, fallbackErr := fallbackCPUUtilization(logical) + if fallbackErr != nil { + if err != nil { + return CPUStatus{}, err + } + return CPUStatus{}, fallbackErr + } + totalPercent = fallbackUsage + percents = fallbackPerCore + perCoreEstimated = true + } else { + for _, v := range percents { + totalPercent += v + } + totalPercent /= float64(len(percents)) + } + + loadStats, loadErr := load.Avg() + var loadAvg load.AvgStat + if loadStats != nil { + loadAvg = *loadStats + } + if loadErr != nil || isZeroLoad(loadAvg) { + if fallback, err := fallbackLoadAvgFromUptime(); err == nil { + loadAvg = fallback + } + } + + return CPUStatus{ + Usage: totalPercent, + PerCore: percents, + PerCoreEstimated: perCoreEstimated, + Load1: loadAvg.Load1, + Load5: loadAvg.Load5, + Load15: loadAvg.Load15, + CoreCount: counts, + LogicalCPU: logical, + }, nil +} + +func isZeroLoad(avg load.AvgStat) bool { + return avg.Load1 == 0 && avg.Load5 == 0 && avg.Load15 == 0 +} + +func fallbackLoadAvgFromUptime() (load.AvgStat, error) { + if !commandExists("uptime") { + return load.AvgStat{}, errors.New("uptime command unavailable") + } + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + out, err := runCmd(ctx, "uptime") + if err != nil { + return load.AvgStat{}, err + } + + markers := []string{"load averages:", "load average:"} + idx := -1 + for _, marker := range markers { + if pos := strings.LastIndex(out, marker); pos != -1 { + idx = pos + len(marker) + break + } + } + if idx == -1 { + return load.AvgStat{}, errors.New("load averages not found in uptime output") + } + + segment := strings.TrimSpace(out[idx:]) + fields := strings.Fields(segment) + var values []float64 + for _, field := range fields { + field = strings.Trim(field, ",;") + if field == "" { + continue + } + val, err := strconv.ParseFloat(field, 64) + if err != nil { + continue + } + values = append(values, val) + if len(values) == 3 { + break + } + } + if len(values) < 3 { + return load.AvgStat{}, errors.New("could not parse load averages from uptime output") + } + + return load.AvgStat{ + Load1: values[0], + Load5: values[1], + Load15: values[2], + }, nil +} + +func fallbackCPUUtilization(logical int) (float64, []float64, error) { + if logical <= 0 { + logical = runtime.NumCPU() + } + if logical <= 0 { + logical = 1 + } + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + out, err := runCmd(ctx, "ps", "-Aceo", "pcpu") + if err != nil { + return 0, nil, err + } + + scanner := bufio.NewScanner(strings.NewReader(out)) + total := 0.0 + lineIndex := 0 + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + lineIndex++ + if lineIndex == 1 && (strings.Contains(strings.ToLower(line), "cpu") || strings.Contains(line, "%")) { + continue + } + + val, parseErr := strconv.ParseFloat(line, 64) + if parseErr != nil { + continue + } + total += val + } + if scanErr := scanner.Err(); scanErr != nil { + return 0, nil, scanErr + } + + maxTotal := float64(logical * 100) + if total < 0 { + total = 0 + } else if total > maxTotal { + total = maxTotal + } + + perCore := make([]float64, logical) + avg := total / float64(logical) + for i := range perCore { + perCore[i] = avg + } + return total, perCore, nil +} diff --git a/cmd/status/metrics_disk.go b/cmd/status/metrics_disk.go new file mode 100644 index 0000000..0bb9c5d --- /dev/null +++ b/cmd/status/metrics_disk.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "errors" + "fmt" + "runtime" + "sort" + "strings" + "time" + + "github.com/shirou/gopsutil/v3/disk" +) + +var skipDiskMounts = map[string]bool{ + "/System/Volumes/VM": true, + "/System/Volumes/Preboot": true, + "/System/Volumes/Update": true, + "/System/Volumes/xarts": true, + "/System/Volumes/Hardware": true, + "/System/Volumes/Data": true, + "/dev": true, +} + +func collectDisks() ([]DiskStatus, error) { + partitions, err := disk.Partitions(false) + if err != nil { + return nil, err + } + + var ( + disks []DiskStatus + seenDevice = make(map[string]bool) + seenVolume = make(map[string]bool) + ) + for _, part := range partitions { + if strings.HasPrefix(part.Device, "/dev/loop") { + continue + } + if skipDiskMounts[part.Mountpoint] { + continue + } + if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") { + continue + } + // Skip private volumes + if strings.HasPrefix(part.Mountpoint, "/private/") { + continue + } + baseDevice := baseDeviceName(part.Device) + if baseDevice == "" { + baseDevice = part.Device + } + if seenDevice[baseDevice] { + continue + } + usage, err := disk.Usage(part.Mountpoint) + if err != nil || usage.Total == 0 { + continue + } + // Skip small volumes (< 1GB) + if usage.Total < 1<<30 { + continue + } + // For APFS volumes, use a more precise dedup key (bytes level) + // to handle shared storage pools properly + volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total) + if seenVolume[volKey] { + continue + } + disks = append(disks, DiskStatus{ + Mount: part.Mountpoint, + Device: part.Device, + Used: usage.Used, + Total: usage.Total, + UsedPercent: usage.UsedPercent, + Fstype: part.Fstype, + }) + seenDevice[baseDevice] = true + seenVolume[volKey] = true + } + + annotateDiskTypes(disks) + + sort.Slice(disks, func(i, j int) bool { + return disks[i].Total > disks[j].Total + }) + + if len(disks) > 3 { + disks = disks[:3] + } + + return disks, nil +} + +func annotateDiskTypes(disks []DiskStatus) { + if len(disks) == 0 || runtime.GOOS != "darwin" || !commandExists("diskutil") { + return + } + cache := make(map[string]bool) + for i := range disks { + base := baseDeviceName(disks[i].Device) + if base == "" { + base = disks[i].Device + } + if val, ok := cache[base]; ok { + disks[i].External = val + continue + } + external, err := isExternalDisk(base) + if err != nil { + external = strings.HasPrefix(disks[i].Mount, "/Volumes/") + } + disks[i].External = external + cache[base] = external + } +} + +func baseDeviceName(device string) string { + device = strings.TrimPrefix(device, "/dev/") + if !strings.HasPrefix(device, "disk") { + return device + } + for i := 4; i < len(device); i++ { + if device[i] == 's' { + return device[:i] + } + } + return device +} + +func isExternalDisk(device string) (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + out, err := runCmd(ctx, "diskutil", "info", device) + if err != nil { + return false, err + } + var ( + found bool + external bool + ) + for _, line := range strings.Split(out, "\n") { + trim := strings.TrimSpace(line) + if strings.HasPrefix(trim, "Internal:") { + found = true + external = strings.Contains(trim, "No") + break + } + if strings.HasPrefix(trim, "Device Location:") { + found = true + external = strings.Contains(trim, "External") + } + } + if !found { + return false, errors.New("diskutil info missing Internal field") + } + return external, nil +} + +func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus { + counters, err := disk.IOCounters() + if err != nil || len(counters) == 0 { + return DiskIOStatus{} + } + + var total disk.IOCountersStat + for _, v := range counters { + total.ReadBytes += v.ReadBytes + total.WriteBytes += v.WriteBytes + } + + if c.lastDiskAt.IsZero() { + c.prevDiskIO = total + c.lastDiskAt = now + return DiskIOStatus{} + } + + elapsed := now.Sub(c.lastDiskAt).Seconds() + if elapsed <= 0 { + elapsed = 1 + } + + readRate := float64(total.ReadBytes-c.prevDiskIO.ReadBytes) / 1024 / 1024 / elapsed + writeRate := float64(total.WriteBytes-c.prevDiskIO.WriteBytes) / 1024 / 1024 / elapsed + + c.prevDiskIO = total + c.lastDiskAt = now + + if readRate < 0 { + readRate = 0 + } + if writeRate < 0 { + writeRate = 0 + } + + return DiskIOStatus{ReadRate: readRate, WriteRate: writeRate} +} diff --git a/cmd/status/metrics_gpu.go b/cmd/status/metrics_gpu.go new file mode 100644 index 0000000..ebd4576 --- /dev/null +++ b/cmd/status/metrics_gpu.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "runtime" + "strconv" + "strings" + "time" +) + +const ( + systemProfilerTimeout = 4 * time.Second + macGPUInfoTTL = 10 * time.Minute +) + +func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) { + if runtime.GOOS == "darwin" { + if len(c.cachedGPU) > 0 && !c.lastGPUAt.IsZero() && now.Sub(c.lastGPUAt) < macGPUInfoTTL { + return c.cachedGPU, nil + } + if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 { + c.cachedGPU = gpus + c.lastGPUAt = now + return gpus, nil + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) + defer cancel() + + if !commandExists("nvidia-smi") { + return []GPUStatus{{ + Name: "No GPU metrics available", + Note: "Install nvidia-smi or use platform-specific metrics", + }}, nil + } + + out, err := runCmd(ctx, "nvidia-smi", "--query-gpu=utilization.gpu,memory.used,memory.total,name", "--format=csv,noheader,nounits") + if err != nil { + return nil, err + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + var gpus []GPUStatus + for _, line := range lines { + fields := strings.Split(line, ",") + if len(fields) < 4 { + continue + } + util, _ := strconv.ParseFloat(strings.TrimSpace(fields[0]), 64) + memUsed, _ := strconv.ParseFloat(strings.TrimSpace(fields[1]), 64) + memTotal, _ := strconv.ParseFloat(strings.TrimSpace(fields[2]), 64) + name := strings.TrimSpace(fields[3]) + + gpus = append(gpus, GPUStatus{ + Name: name, + Usage: util, + MemoryUsed: memUsed, + MemoryTotal: memTotal, + }) + } + + if len(gpus) == 0 { + return []GPUStatus{{ + Name: "GPU read failed", + Note: "Verify nvidia-smi availability", + }}, nil + } + + return gpus, nil +} + +func readMacGPUInfo() ([]GPUStatus, error) { + ctx, cancel := context.WithTimeout(context.Background(), systemProfilerTimeout) + defer cancel() + + if !commandExists("system_profiler") { + return nil, errors.New("system_profiler unavailable") + } + + out, err := runCmd(ctx, "system_profiler", "-json", "SPDisplaysDataType") + if err != nil { + return nil, err + } + + var data struct { + Displays []struct { + Name string `json:"_name"` + VRAM string `json:"spdisplays_vram"` + Vendor string `json:"spdisplays_vendor"` + Metal string `json:"spdisplays_metal"` + } `json:"SPDisplaysDataType"` + } + if err := json.Unmarshal([]byte(out), &data); err != nil { + return nil, err + } + + var gpus []GPUStatus + for _, d := range data.Displays { + if d.Name == "" { + continue + } + noteParts := []string{} + if d.VRAM != "" { + noteParts = append(noteParts, "VRAM "+d.VRAM) + } + if d.Metal != "" { + noteParts = append(noteParts, d.Metal) + } + if d.Vendor != "" { + noteParts = append(noteParts, d.Vendor) + } + note := strings.Join(noteParts, " · ") + gpus = append(gpus, GPUStatus{ + Name: d.Name, + Usage: -1, + Note: note, + }) + } + + if len(gpus) == 0 { + return []GPUStatus{{ + Name: "GPU info unavailable", + Note: "Unable to parse system_profiler output", + }}, nil + } + + return gpus, nil +} diff --git a/cmd/status/metrics_hardware.go b/cmd/status/metrics_hardware.go new file mode 100644 index 0000000..328930f --- /dev/null +++ b/cmd/status/metrics_hardware.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "runtime" + "strings" + "time" +) + +func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo { + if runtime.GOOS != "darwin" { + return HardwareInfo{ + Model: "Unknown", + CPUModel: runtime.GOARCH, + TotalRAM: humanBytes(totalRAM), + DiskSize: "Unknown", + OSVersion: runtime.GOOS, + } + } + + // Get model and CPU from system_profiler + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + var model, cpuModel, osVersion string + + // Get hardware overview + out, err := runCmd(ctx, "system_profiler", "SPHardwareDataType") + if err == nil { + lines := strings.Split(out, "\n") + for _, line := range lines { + lower := strings.ToLower(strings.TrimSpace(line)) + // Prefer "Model Name" over "Model Identifier" + if strings.Contains(lower, "model name:") { + parts := strings.Split(line, ":") + if len(parts) == 2 { + model = strings.TrimSpace(parts[1]) + } + } + if strings.Contains(lower, "chip:") { + parts := strings.Split(line, ":") + if len(parts) == 2 { + cpuModel = strings.TrimSpace(parts[1]) + } + } + if strings.Contains(lower, "processor name:") && cpuModel == "" { + parts := strings.Split(line, ":") + if len(parts) == 2 { + cpuModel = strings.TrimSpace(parts[1]) + } + } + } + } + + // Get macOS version + ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel2() + out2, err := runCmd(ctx2, "sw_vers", "-productVersion") + if err == nil { + osVersion = "macOS " + strings.TrimSpace(out2) + } + + // Get disk size + diskSize := "Unknown" + if len(disks) > 0 { + diskSize = humanBytes(disks[0].Total) + } + + return HardwareInfo{ + Model: model, + CPUModel: cpuModel, + TotalRAM: humanBytes(totalRAM), + DiskSize: diskSize, + OSVersion: osVersion, + } +} diff --git a/cmd/status/metrics_health.go b/cmd/status/metrics_health.go new file mode 100644 index 0000000..06b9b57 --- /dev/null +++ b/cmd/status/metrics_health.go @@ -0,0 +1,168 @@ +package main + +import ( + "fmt" + "strings" +) + +// Health score calculation weights and thresholds +const ( + // Weights (must sum to ~100 for total score) + healthCPUWeight = 30.0 + healthMemWeight = 25.0 + healthDiskWeight = 20.0 + healthThermalWeight = 15.0 + healthIOWeight = 10.0 + + // CPU thresholds + cpuNormalThreshold = 30.0 + cpuHighThreshold = 70.0 + + // Memory thresholds + memNormalThreshold = 50.0 + memHighThreshold = 80.0 + memPressureWarnPenalty = 5.0 + memPressureCritPenalty = 15.0 + + // Disk thresholds + diskWarnThreshold = 70.0 + diskCritThreshold = 90.0 + + // Thermal thresholds + thermalNormalThreshold = 60.0 + thermalHighThreshold = 85.0 + + // Disk IO thresholds (MB/s) + ioNormalThreshold = 50.0 + ioHighThreshold = 150.0 +) + +func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus) (int, string) { + // Start with perfect score + score := 100.0 + issues := []string{} + + // CPU Usage (30% weight) - deduct up to 30 points + // 0-30% CPU = 0 deduction, 30-70% = linear, 70-100% = heavy penalty + cpuPenalty := 0.0 + if cpu.Usage > cpuNormalThreshold { + if cpu.Usage > cpuHighThreshold { + cpuPenalty = healthCPUWeight * (cpu.Usage - cpuNormalThreshold) / cpuHighThreshold + } else { + cpuPenalty = (healthCPUWeight / 2) * (cpu.Usage - cpuNormalThreshold) / (cpuHighThreshold - cpuNormalThreshold) + } + } + score -= cpuPenalty + if cpu.Usage > cpuHighThreshold { + issues = append(issues, "High CPU") + } + + // Memory Usage (25% weight) - deduct up to 25 points + // 0-50% = 0 deduction, 50-80% = linear, 80-100% = heavy penalty + memPenalty := 0.0 + if mem.UsedPercent > memNormalThreshold { + if mem.UsedPercent > memHighThreshold { + memPenalty = healthMemWeight * (mem.UsedPercent - memNormalThreshold) / memNormalThreshold + } else { + memPenalty = (healthMemWeight / 2) * (mem.UsedPercent - memNormalThreshold) / (memHighThreshold - memNormalThreshold) + } + } + score -= memPenalty + if mem.UsedPercent > memHighThreshold { + issues = append(issues, "High Memory") + } + + // Memory Pressure (extra penalty) + if mem.Pressure == "warn" { + score -= memPressureWarnPenalty + issues = append(issues, "Memory Pressure") + } else if mem.Pressure == "critical" { + score -= memPressureCritPenalty + issues = append(issues, "Critical Memory") + } + + // Disk Usage (20% weight) - deduct up to 20 points + diskPenalty := 0.0 + if len(disks) > 0 { + diskUsage := disks[0].UsedPercent + if diskUsage > diskWarnThreshold { + if diskUsage > diskCritThreshold { + diskPenalty = healthDiskWeight * (diskUsage - diskWarnThreshold) / (100 - diskWarnThreshold) + } else { + diskPenalty = (healthDiskWeight / 2) * (diskUsage - diskWarnThreshold) / (diskCritThreshold - diskWarnThreshold) + } + } + score -= diskPenalty + if diskUsage > diskCritThreshold { + issues = append(issues, "Disk Almost Full") + } + } + + // Thermal (15% weight) - deduct up to 15 points + thermalPenalty := 0.0 + if thermal.CPUTemp > 0 { + if thermal.CPUTemp > thermalNormalThreshold { + if thermal.CPUTemp > thermalHighThreshold { + thermalPenalty = healthThermalWeight + issues = append(issues, "Overheating") + } else { + thermalPenalty = healthThermalWeight * (thermal.CPUTemp - thermalNormalThreshold) / (thermalHighThreshold - thermalNormalThreshold) + } + } + score -= thermalPenalty + } + + // Disk IO (10% weight) - deduct up to 10 points + ioPenalty := 0.0 + totalIO := diskIO.ReadRate + diskIO.WriteRate + if totalIO > ioNormalThreshold { + if totalIO > ioHighThreshold { + ioPenalty = healthIOWeight + issues = append(issues, "Heavy Disk IO") + } else { + ioPenalty = healthIOWeight * (totalIO - ioNormalThreshold) / (ioHighThreshold - ioNormalThreshold) + } + } + score -= ioPenalty + + // Ensure score is in valid range + if score < 0 { + score = 0 + } + if score > 100 { + score = 100 + } + + // Generate message + msg := "Excellent" + if score >= 90 { + msg = "Excellent" + } else if score >= 75 { + msg = "Good" + } else if score >= 60 { + msg = "Fair" + } else if score >= 40 { + msg = "Poor" + } else { + msg = "Critical" + } + + if len(issues) > 0 { + msg = msg + ": " + strings.Join(issues, ", ") + } + + return int(score), msg +} + +func formatUptime(secs uint64) string { + days := secs / 86400 + hours := (secs % 86400) / 3600 + mins := (secs % 3600) / 60 + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, mins) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, mins) + } + return fmt.Sprintf("%dm", mins) +} diff --git a/cmd/status/metrics_memory.go b/cmd/status/metrics_memory.go new file mode 100644 index 0000000..e23c258 --- /dev/null +++ b/cmd/status/metrics_memory.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "runtime" + "strings" + "time" + + "github.com/shirou/gopsutil/v3/mem" +) + +func collectMemory() (MemoryStatus, error) { + vm, err := mem.VirtualMemory() + if err != nil { + return MemoryStatus{}, err + } + + swap, _ := mem.SwapMemory() + pressure := getMemoryPressure() + + return MemoryStatus{ + Used: vm.Used, + Total: vm.Total, + UsedPercent: vm.UsedPercent, + SwapUsed: swap.Used, + SwapTotal: swap.Total, + Pressure: pressure, + }, nil +} + +func getMemoryPressure() string { + if runtime.GOOS != "darwin" { + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + out, err := runCmd(ctx, "memory_pressure") + if err != nil { + return "" + } + lower := strings.ToLower(out) + if strings.Contains(lower, "critical") { + return "critical" + } + if strings.Contains(lower, "warn") { + return "warn" + } + if strings.Contains(lower, "normal") { + return "normal" + } + return "" +} diff --git a/cmd/status/metrics_network.go b/cmd/status/metrics_network.go new file mode 100644 index 0000000..0f5246b --- /dev/null +++ b/cmd/status/metrics_network.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "os" + "runtime" + "sort" + "strings" + "time" + + "github.com/shirou/gopsutil/v3/net" +) + +func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) { + stats, err := net.IOCounters(true) + if err != nil { + return nil, err + } + + // Get IP addresses for interfaces + ifAddrs := getInterfaceIPs() + + if c.lastNetAt.IsZero() { + c.lastNetAt = now + for _, s := range stats { + c.prevNet[s.Name] = s + } + return nil, nil + } + + elapsed := now.Sub(c.lastNetAt).Seconds() + if elapsed <= 0 { + elapsed = 1 + } + + var result []NetworkStatus + for _, cur := range stats { + if isNoiseInterface(cur.Name) { + continue + } + prev, ok := c.prevNet[cur.Name] + if !ok { + continue + } + rx := float64(cur.BytesRecv-prev.BytesRecv) / 1024.0 / 1024.0 / elapsed + tx := float64(cur.BytesSent-prev.BytesSent) / 1024.0 / 1024.0 / elapsed + if rx < 0 { + rx = 0 + } + if tx < 0 { + tx = 0 + } + result = append(result, NetworkStatus{ + Name: cur.Name, + RxRateMBs: rx, + TxRateMBs: tx, + IP: ifAddrs[cur.Name], + }) + } + + c.lastNetAt = now + for _, s := range stats { + c.prevNet[s.Name] = s + } + + sort.Slice(result, func(i, j int) bool { + return result[i].RxRateMBs+result[i].TxRateMBs > result[j].RxRateMBs+result[j].TxRateMBs + }) + if len(result) > 3 { + result = result[:3] + } + + return result, nil +} + +func getInterfaceIPs() map[string]string { + result := make(map[string]string) + ifaces, err := net.Interfaces() + if err != nil { + return result + } + for _, iface := range ifaces { + for _, addr := range iface.Addrs { + // Only IPv4 + if strings.Contains(addr.Addr, ".") && !strings.HasPrefix(addr.Addr, "127.") { + ip := strings.Split(addr.Addr, "/")[0] + result[iface.Name] = ip + break + } + } + } + return result +} + +func isNoiseInterface(name string) bool { + lower := strings.ToLower(name) + noiseList := []string{"lo", "awdl", "utun", "llw", "bridge", "gif", "stf", "xhc", "anpi", "ap"} + for _, prefix := range noiseList { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} + +func collectProxy() ProxyStatus { + // Check environment variables first + for _, env := range []string{"https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"} { + if val := os.Getenv(env); val != "" { + proxyType := "HTTP" + if strings.HasPrefix(val, "socks") { + proxyType = "SOCKS" + } + // Extract host + host := val + if strings.Contains(host, "://") { + host = strings.SplitN(host, "://", 2)[1] + } + if idx := strings.Index(host, "@"); idx >= 0 { + host = host[idx+1:] + } + return ProxyStatus{Enabled: true, Type: proxyType, Host: host} + } + } + + // macOS: check system proxy via scutil + if runtime.GOOS == "darwin" { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + out, err := runCmd(ctx, "scutil", "--proxy") + if err == nil { + if strings.Contains(out, "HTTPEnable : 1") || strings.Contains(out, "HTTPSEnable : 1") { + return ProxyStatus{Enabled: true, Type: "System", Host: "System Proxy"} + } + if strings.Contains(out, "SOCKSEnable : 1") { + return ProxyStatus{Enabled: true, Type: "SOCKS", Host: "System Proxy"} + } + } + } + + return ProxyStatus{Enabled: false} +} diff --git a/cmd/status/metrics_process.go b/cmd/status/metrics_process.go new file mode 100644 index 0000000..ee66049 --- /dev/null +++ b/cmd/status/metrics_process.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "runtime" + "strconv" + "strings" + "time" +) + +func collectTopProcesses() []ProcessInfo { + if runtime.GOOS != "darwin" { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + // Use ps to get top processes by CPU + out, err := runCmd(ctx, "ps", "-Aceo", "pcpu,pmem,comm", "-r") + if err != nil { + return nil + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + var procs []ProcessInfo + for i, line := range lines { + if i == 0 { // skip header + continue + } + if i > 5 { // top 5 + break + } + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + cpuVal, _ := strconv.ParseFloat(fields[0], 64) + memVal, _ := strconv.ParseFloat(fields[1], 64) + name := fields[len(fields)-1] + // Get just the process name without path + if idx := strings.LastIndex(name, "/"); idx >= 0 { + name = name[idx+1:] + } + procs = append(procs, ProcessInfo{ + Name: name, + CPU: cpuVal, + Memory: memVal, + }) + } + return procs +}