diff --git a/bin/status-go b/bin/status-go index 228f841..1145376 100755 Binary files a/bin/status-go and b/bin/status-go differ diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index f3542fd..d3acd86 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -63,6 +63,8 @@ type CPUStatus struct { Load15 float64 CoreCount int LogicalCPU int + PCoreCount int // Performance cores (Apple Silicon) + ECoreCount int // Efficiency cores (Apple Silicon) } type GPUStatus struct { @@ -70,6 +72,7 @@ type GPUStatus struct { Usage float64 MemoryUsed float64 MemoryTotal float64 + CoreCount int Note string } diff --git a/cmd/status/metrics_cpu.go b/cmd/status/metrics_cpu.go index f5c5614..1de8df6 100644 --- a/cmd/status/metrics_cpu.go +++ b/cmd/status/metrics_cpu.go @@ -63,6 +63,9 @@ func collectCPU() (CPUStatus, error) { } } + // Get P-core and E-core counts for Apple Silicon + pCores, eCores := getCoreTopology() + return CPUStatus{ Usage: totalPercent, PerCore: percents, @@ -72,6 +75,8 @@ func collectCPU() (CPUStatus, error) { Load15: loadAvg.Load15, CoreCount: counts, LogicalCPU: logical, + PCoreCount: pCores, + ECoreCount: eCores, }, nil } @@ -79,6 +84,55 @@ func isZeroLoad(avg load.AvgStat) bool { return avg.Load1 == 0 && avg.Load5 == 0 && avg.Load15 == 0 } +// getCoreTopology returns P-core and E-core counts on Apple Silicon. +// Returns (0, 0) on non-Apple Silicon or if detection fails. +func getCoreTopology() (pCores, eCores int) { + if runtime.GOOS != "darwin" { + return 0, 0 + } + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + // Get performance level info from sysctl + out, err := runCmd(ctx, "sysctl", "-n", + "hw.perflevel0.logicalcpu", + "hw.perflevel0.name", + "hw.perflevel1.logicalcpu", + "hw.perflevel1.name") + if err != nil { + return 0, 0 + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) < 4 { + return 0, 0 + } + + // Parse perflevel0 + level0Count, _ := strconv.Atoi(strings.TrimSpace(lines[0])) + level0Name := strings.ToLower(strings.TrimSpace(lines[1])) + + // Parse perflevel1 + level1Count, _ := strconv.Atoi(strings.TrimSpace(lines[2])) + level1Name := strings.ToLower(strings.TrimSpace(lines[3])) + + // Assign based on name (Performance vs Efficiency) + if strings.Contains(level0Name, "performance") { + pCores = level0Count + } else if strings.Contains(level0Name, "efficiency") { + eCores = level0Count + } + + if strings.Contains(level1Name, "performance") { + pCores = level1Count + } else if strings.Contains(level1Name, "efficiency") { + eCores = level1Count + } + + return pCores, eCores +} + func fallbackLoadAvgFromUptime() (load.AvgStat, error) { if !commandExists("uptime") { return load.AvgStat{}, errors.New("uptime command unavailable") diff --git a/cmd/status/metrics_gpu.go b/cmd/status/metrics_gpu.go index ebd4576..968e6eb 100644 --- a/cmd/status/metrics_gpu.go +++ b/cmd/status/metrics_gpu.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "regexp" "runtime" "strconv" "strings" @@ -13,17 +14,35 @@ import ( const ( systemProfilerTimeout = 4 * time.Second macGPUInfoTTL = 10 * time.Minute + powermetricsTimeout = 2 * time.Second +) + +// Pre-compiled regex patterns for GPU usage parsing +var ( + gpuActiveResidencyRe = regexp.MustCompile(`GPU HW active residency:\s+([\d.]+)%`) + gpuIdleResidencyRe = regexp.MustCompile(`GPU idle residency:\s+([\d.]+)%`) ) 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 + // Get static GPU info (cached for 10 min) + if len(c.cachedGPU) == 0 || c.lastGPUAt.IsZero() || now.Sub(c.lastGPUAt) >= macGPUInfoTTL { + if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 { + c.cachedGPU = gpus + c.lastGPUAt = now + } } - if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 { - c.cachedGPU = gpus - c.lastGPUAt = now - return gpus, nil + + // Get real-time GPU usage + if len(c.cachedGPU) > 0 { + usage := getMacGPUUsage() + result := make([]GPUStatus, len(c.cachedGPU)) + copy(result, c.cachedGPU) + // Apply usage to first GPU (Apple Silicon has one integrated GPU) + if len(result) > 0 { + result[0].Usage = usage + } + return result, nil } } @@ -91,6 +110,7 @@ func readMacGPUInfo() ([]GPUStatus, error) { VRAM string `json:"spdisplays_vram"` Vendor string `json:"spdisplays_vendor"` Metal string `json:"spdisplays_metal"` + Cores string `json:"sppci_cores"` } `json:"SPDisplaysDataType"` } if err := json.Unmarshal([]byte(out), &data); err != nil { @@ -113,10 +133,12 @@ func readMacGPUInfo() ([]GPUStatus, error) { noteParts = append(noteParts, d.Vendor) } note := strings.Join(noteParts, " ยท ") + coreCount, _ := strconv.Atoi(d.Cores) gpus = append(gpus, GPUStatus{ - Name: d.Name, - Usage: -1, - Note: note, + Name: d.Name, + Usage: -1, // Will be updated with real-time data + CoreCount: coreCount, + Note: note, }) } @@ -129,3 +151,36 @@ func readMacGPUInfo() ([]GPUStatus, error) { return gpus, nil } + +// getMacGPUUsage gets GPU active residency from powermetrics. +// Returns -1 if unavailable (e.g., not running as root). +func getMacGPUUsage() float64 { + ctx, cancel := context.WithTimeout(context.Background(), powermetricsTimeout) + defer cancel() + + // powermetrics requires root, but we try anyway - some systems may have it enabled + out, err := runCmd(ctx, "powermetrics", "--samplers", "gpu_power", "-i", "500", "-n", "1") + if err != nil { + return -1 + } + + // Parse "GPU HW active residency: X.XX%" + matches := gpuActiveResidencyRe.FindStringSubmatch(out) + if len(matches) >= 2 { + usage, err := strconv.ParseFloat(matches[1], 64) + if err == nil { + return usage + } + } + + // Fallback: parse "GPU idle residency: X.XX%" and calculate active + matchesIdle := gpuIdleResidencyRe.FindStringSubmatch(out) + if len(matchesIdle) >= 2 { + idle, err := strconv.ParseFloat(matchesIdle[1], 64) + if err == nil { + return 100.0 - idle + } + } + + return -1 +} diff --git a/cmd/status/view.go b/cmd/status/view.go index 21ce13d..9d95951 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -163,8 +163,8 @@ func buildCards(m MetricsSnapshot, _ int) []cardData { renderProcessCard(m.TopProcesses), renderNetworkCard(m.Network, m.Proxy), } - // Only show GPU card if there are GPUs with usage data - if len(m.GPU) > 0 && m.GPU[0].Usage >= 0 { + // Only show GPU card if there are GPUs with usage data or core count + if len(m.GPU) > 0 && (m.GPU[0].Usage >= 0 || m.GPU[0].CoreCount > 0) { cards = append(cards, renderGPUCard(m.GPU)) } // Only show sensors if we have valid temperature readings @@ -186,12 +186,18 @@ func hasSensorData(sensors []SensorReading) bool { func renderCPUCard(cpu CPUStatus) cardData { var lines []string lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(cpu.Usage), cpu.Usage)) - lines = append(lines, subtleStyle.Render(fmt.Sprintf("%.2f / %.2f / %.2f (%d cores)", cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU))) + + if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 { + lines = append(lines, subtleStyle.Render(fmt.Sprintf("Load %.2f / %.2f / %.2f (%dP+%dE)", + cpu.Load1, cpu.Load5, cpu.Load15, cpu.PCoreCount, cpu.ECoreCount))) + } else { + lines = append(lines, subtleStyle.Render(fmt.Sprintf("%.2f / %.2f / %.2f (%d cores)", + cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU))) + } if cpu.PerCoreEstimated { lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)")) } else if len(cpu.PerCore) > 0 { - // Show top 3 busiest cores type coreUsage struct { idx int val float64 @@ -221,11 +227,16 @@ func renderGPUCard(gpus []GPUStatus) cardData { lines = append(lines, subtleStyle.Render("No GPU detected")) } else { for _, g := range gpus { - name := shorten(g.Name, 12) if g.Usage >= 0 { - lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%", name, progressBar(g.Usage), g.Usage)) - } else { - lines = append(lines, name) + lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(g.Usage), g.Usage)) + } + coreInfo := "" + if g.CoreCount > 0 { + coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount) + } + lines = append(lines, subtleStyle.Render(g.Name+coreInfo)) + if g.Usage < 0 { + lines = append(lines, subtleStyle.Render("Run with sudo for usage metrics")) } } }