diff --git a/bin/status-go b/bin/status-go index 228f841..8a79231 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..3befcf6 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,29 @@ import ( const ( systemProfilerTimeout = 4 * time.Second macGPUInfoTTL = 10 * time.Minute + powermetricsTimeout = 2 * time.Second ) 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 +104,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 +127,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 +145,38 @@ 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%" + re := regexp.MustCompile(`GPU HW active residency:\s+([\d.]+)%`) + matches := re.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 + reIdle := regexp.MustCompile(`GPU idle residency:\s+([\d.]+)%`) + matchesIdle := reIdle.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..b6363ef 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "sort" "strconv" "strings" @@ -163,8 +162,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,29 +185,36 @@ 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))) + + // Show core topology info if available (Apple Silicon) + if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 { + lines = append(lines, subtleStyle.Render(fmt.Sprintf("%.2f / %.2f / %.2f (%dP + %dE cores)", + 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 - } - var cores []coreUsage - for i, v := range cpu.PerCore { - cores = append(cores, coreUsage{i, v}) - } - sort.Slice(cores, func(i, j int) bool { return cores[i].val > cores[j].val }) - - maxCores := 3 - if len(cores) < maxCores { - maxCores = len(cores) - } - for i := 0; i < maxCores; i++ { - c := cores[i] - lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", c.idx+1, progressBar(c.val), c.val)) + // Apple Silicon: Group cores into P-CPU and E-CPU + if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 { + // P-cores (Performance) come first + lines = append(lines, titleStyle.Render("P-CPU")) + for i := 0; i < cpu.PCoreCount && i < len(cpu.PerCore); i++ { + lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", i+1, progressBar(cpu.PerCore[i]), cpu.PerCore[i])) + } + // E-cores (Efficiency) come after P-cores + lines = append(lines, titleStyle.Render("E-CPU")) + for i := cpu.PCoreCount; i < cpu.PCoreCount+cpu.ECoreCount && i < len(cpu.PerCore); i++ { + lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", i+1, progressBar(cpu.PerCore[i]), cpu.PerCore[i])) + } + } else { + // Non-Apple Silicon: Show all cores without grouping + for i, v := range cpu.PerCore { + lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", i+1, progressBar(v), v)) + } } } @@ -221,11 +227,19 @@ func renderGPUCard(gpus []GPUStatus) cardData { lines = append(lines, subtleStyle.Render("No GPU detected")) } else { for _, g := range gpus { - name := shorten(g.Name, 12) + // Line 1: Usage bar (if available) 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)) + } + // Line 2: GPU name and core count + coreInfo := "" + if g.CoreCount > 0 { + coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount) + } + lines = append(lines, subtleStyle.Render(g.Name+coreInfo)) + // Line 3: Hint for sudo if usage unavailable + if g.Usage < 0 { + lines = append(lines, subtleStyle.Render("Run with sudo for usage metrics")) } } }