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:
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user