diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index c61ed0b..5e3090c 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -138,10 +138,18 @@ type BluetoothDevice struct { } type Collector struct { - prevNet map[string]net.IOCountersStat - lastNetAt time.Time + // Static Cache (Collected once at startup) + cachedHW HardwareInfo + lastHWAt time.Time + hasStatic bool + + // Slow Cache (Collected every 30s-1m) lastBTAt time.Time lastBT []BluetoothDevice + + // Fast Metrics (Collected every 1 second) + prevNet map[string]net.IOCountersStat + lastNetAt time.Time lastGPUAt time.Time cachedGPU []GPUStatus prevDiskIO disk.IOCountersStat @@ -209,14 +217,32 @@ func (c *Collector) Collect() (MetricsSnapshot, error) { collect(func() (err error) { thermalStats = collectThermal(); return nil }) collect(func() (err error) { sensorStats, _ = collectSensors(); return nil }) collect(func() (err error) { gpuStats, err = c.collectGPU(now); return }) - collect(func() (err error) { btStats = c.collectBluetooth(now); return nil }) + collect(func() (err error) { + // Bluetooth is slow, cache for 30s + if now.Sub(c.lastBTAt) > 30*time.Second || len(c.lastBT) == 0 { + btStats = c.collectBluetooth(now) + c.lastBT = btStats + c.lastBTAt = now + } else { + btStats = c.lastBT + } + return nil + }) collect(func() (err error) { topProcs = collectTopProcesses(); return nil }) // Wait for all to complete wg.Wait() // Dependent tasks (must run after others) - hwInfo := collectHardware(memStats.Total, diskStats) + // Dependent tasks (must run after others) + // Cache hardware info as it's expensive and rarely changes + if !c.hasStatic || now.Sub(c.lastHWAt) > 10*time.Minute { + c.cachedHW = collectHardware(memStats.Total, diskStats) + c.lastHWAt = now + c.hasStatic = true + } + hwInfo := c.cachedHW + score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats) return MetricsSnapshot{ diff --git a/cmd/status/metrics_battery.go b/cmd/status/metrics_battery.go index 84161db..2fc4ec2 100644 --- a/cmd/status/metrics_battery.go +++ b/cmd/status/metrics_battery.go @@ -14,6 +14,13 @@ import ( "github.com/shirou/gopsutil/v3/host" ) +var ( + // Package-level cache for heavy system_profiler data + lastPowerAt time.Time + cachedPower string + powerCacheTTL = 30 * time.Second +) + func collectBatteries() (batts []BatteryStatus, err error) { defer func() { if r := recover(); r != nil { @@ -22,10 +29,12 @@ func collectBatteries() (batts []BatteryStatus, err error) { } }() - // macOS: pmset + // macOS: pmset (fast, for real-time percentage/status) if runtime.GOOS == "darwin" && commandExists("pmset") { if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil { - if batts := parsePMSet(out); len(batts) > 0 { + // Get heavy info (health, cycles) from cached system_profiler + health, cycles := getCachedPowerData() + if batts := parsePMSet(out, health, cycles); len(batts) > 0 { return batts, nil } } @@ -58,7 +67,7 @@ func collectBatteries() (batts []BatteryStatus, err error) { return nil, errors.New("no battery data found") } -func parsePMSet(raw string) []BatteryStatus { +func parsePMSet(raw string, health string, cycles int) []BatteryStatus { lines := strings.Split(raw, "\n") var out []BatteryStatus var timeLeft string @@ -101,9 +110,6 @@ func parsePMSet(raw string) []BatteryStatus { continue } - // Get battery health and cycle count - health, cycles := getBatteryHealth() - out = append(out, BatteryStatus{ Percent: percent, Status: status, @@ -115,20 +121,12 @@ func parsePMSet(raw string) []BatteryStatus { return out } -func getBatteryHealth() (string, int) { - if runtime.GOOS != "darwin" { +// getCachedPowerData returns condition, cycles, and fan speed from cached system_profiler output. +func getCachedPowerData() (health string, cycles int) { + out := getSystemPowerOutput() + if out == "" { 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 { @@ -149,6 +147,27 @@ func getBatteryHealth() (string, int) { return health, cycles } +func getSystemPowerOutput() string { + if runtime.GOOS != "darwin" { + return "" + } + + now := time.Now() + if cachedPower != "" && now.Sub(lastPowerAt) < powerCacheTTL { + return cachedPower + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + out, err := runCmd(ctx, "system_profiler", "SPPowerDataType") + if err == nil { + cachedPower = out + lastPowerAt = now + } + return cachedPower +} + func collectThermal() ThermalStatus { if runtime.GOOS != "darwin" { return ThermalStatus{} @@ -156,12 +175,9 @@ func collectThermal() 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 { + // Get fan info from cached system_profiler + out := getSystemPowerOutput() + if out != "" { lines := strings.Split(out, "\n") for _, line := range lines { lower := strings.ToLower(line) diff --git a/cmd/status/metrics_cpu.go b/cmd/status/metrics_cpu.go index 1de8df6..c95d7f9 100644 --- a/cmd/status/metrics_cpu.go +++ b/cmd/status/metrics_cpu.go @@ -84,6 +84,13 @@ func isZeroLoad(avg load.AvgStat) bool { return avg.Load1 == 0 && avg.Load5 == 0 && avg.Load15 == 0 } +var ( + // Package-level cache for core topology + lastTopologyAt time.Time + cachedP, cachedE int + topologyTTL = 10 * time.Minute +) + // 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) { @@ -91,6 +98,13 @@ func getCoreTopology() (pCores, eCores int) { return 0, 0 } + now := time.Now() + if cachedP > 0 || cachedE > 0 { + if now.Sub(lastTopologyAt) < topologyTTL { + return cachedP, cachedE + } + } + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() @@ -130,6 +144,8 @@ func getCoreTopology() (pCores, eCores int) { eCores = level1Count } + cachedP, cachedE = pCores, eCores + lastTopologyAt = now return pCores, eCores } diff --git a/cmd/status/metrics_disk.go b/cmd/status/metrics_disk.go index 0bb9c5d..a8fd46d 100644 --- a/cmd/status/metrics_disk.go +++ b/cmd/status/metrics_disk.go @@ -93,26 +93,42 @@ func collectDisks() ([]DiskStatus, error) { return disks, nil } +var ( + // Package-level cache for external disk status + lastDiskCacheAt time.Time + diskTypeCache = make(map[string]bool) + diskCacheTTL = 2 * time.Minute +) + func annotateDiskTypes(disks []DiskStatus) { if len(disks) == 0 || runtime.GOOS != "darwin" || !commandExists("diskutil") { return } - cache := make(map[string]bool) + + now := time.Now() + // Clear cache if stale + if now.Sub(lastDiskCacheAt) > diskCacheTTL { + diskTypeCache = make(map[string]bool) + lastDiskCacheAt = now + } + for i := range disks { base := baseDeviceName(disks[i].Device) if base == "" { base = disks[i].Device } - if val, ok := cache[base]; ok { + + if val, ok := diskTypeCache[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 + diskTypeCache[base] = external } } diff --git a/cmd/status/view.go b/cmd/status/view.go index 7442032..6faab10 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -12,24 +12,25 @@ import ( var ( titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true) - subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9E9E9E")) + subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#737373")) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")) - dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) - okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")) - lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5A5A5A")) - hatStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")) + dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true) + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7")) + lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040")) + hatStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF4D4D")) + primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9")) ) const ( colWidth = 38 - iconCPU = "⚙" - iconMemory = "▦" - iconGPU = "▣" - iconDisk = "▤" + iconCPU = "◉" + iconMemory = "◫" + iconGPU = "◧" + iconDisk = "▥" iconNetwork = "⇅" - iconBattery = "▮" - iconSensors = "♨" - iconProcs = "▶" + iconBattery = "◪" + iconSensors = "◈" + iconProcs = "❊" ) // Check if it's Christmas season (Dec 10-31) @@ -167,39 +168,46 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int // Title title := titleStyle.Render("Mole Status") - // Health Score with color and label + // Health Score scoreStyle := getScoreStyle(m.HealthScore) scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore)) - // Hardware info + // Hardware info - compact for single line infoParts := []string{} if m.Hardware.Model != "" { - infoParts = append(infoParts, m.Hardware.Model) + infoParts = append(infoParts, primaryStyle.Render(m.Hardware.Model)) } if m.Hardware.CPUModel != "" { cpuInfo := m.Hardware.CPUModel + // Add GPU core count if available (compact format) if len(m.GPU) > 0 && m.GPU[0].CoreCount > 0 { - cpuInfo += fmt.Sprintf(" (%d GPU cores)", m.GPU[0].CoreCount) + cpuInfo += fmt.Sprintf(" (%dGPU)", m.GPU[0].CoreCount) } infoParts = append(infoParts, cpuInfo) } + // Combine RAM and Disk to save space + var specs []string if m.Hardware.TotalRAM != "" { - infoParts = append(infoParts, m.Hardware.TotalRAM) + specs = append(specs, m.Hardware.TotalRAM) } if m.Hardware.DiskSize != "" { - infoParts = append(infoParts, m.Hardware.DiskSize) + specs = append(specs, m.Hardware.DiskSize) + } + if len(specs) > 0 { + infoParts = append(infoParts, strings.Join(specs, "/")) } if m.Hardware.OSVersion != "" { infoParts = append(infoParts, m.Hardware.OSVersion) } + // Single line compact header headerLine := title + " " + scoreText + " " + subtleStyle.Render(strings.Join(infoParts, " · ")) // Running mole animation mole := getMoleFrame(animFrame, termWidth) if errMsg != "" { - return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render(errMsg), "") + return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render("ERROR: "+errMsg), "") } return headerLine + "\n" + mole } @@ -580,11 +588,11 @@ func renderSensorsCard(sensors []SensorReading) cardData { func renderCard(data cardData, width int, height int) string { titleText := data.icon + " " + data.title - lineLen := width - lipgloss.Width(titleText) - 1 + lineLen := width - lipgloss.Width(titleText) - 2 if lineLen < 4 { lineLen = 4 } - header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("─", lineLen)) + header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("╌", lineLen)) content := header + "\n" + strings.Join(data.lines, "\n") // Pad to target height @@ -596,7 +604,7 @@ func renderCard(data cardData, width int, height int) string { } func progressBar(percent float64) string { - total := 18 + total := 16 if percent < 0 { percent = 0 } @@ -604,9 +612,6 @@ func progressBar(percent float64) string { percent = 100 } filled := int(percent / 100 * float64(total)) - if filled > total { - filled = total - } var builder strings.Builder for i := 0; i < total; i++ { @@ -620,7 +625,7 @@ func progressBar(percent float64) string { } func batteryProgressBar(percent float64) string { - total := 18 + total := 16 if percent < 0 { percent = 0 } @@ -628,9 +633,6 @@ func batteryProgressBar(percent float64) string { percent = 100 } filled := int(percent / 100 * float64(total)) - if filled > total { - filled = total - } var builder strings.Builder for i := 0; i < total; i++ { @@ -645,9 +647,9 @@ func batteryProgressBar(percent float64) string { func colorizePercent(percent float64, s string) string { switch { - case percent >= 90: + case percent >= 85: return dangerStyle.Render(s) - case percent >= 70: + case percent >= 60: return warnStyle.Render(s) default: return okStyle.Render(s)