1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 15:39:42 +00:00

feat: enhance status UI with new styles and icons, refactor battery metrics with caching, and centralize Apple Silicon clean logic.

This commit is contained in:
Tw93
2025-12-19 20:34:23 +08:00
parent 4b740ee543
commit be43f68cc1
5 changed files with 138 additions and 62 deletions

View File

@@ -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{

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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)