mirror of
https://github.com/tw93/Mole.git
synced 2026-02-10 13:09:16 +00:00
feat: Enhance clean, optimize, analyze, and status commands, and update security audit documentation.
This commit is contained in:
@@ -72,7 +72,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.metrics = msg.data
|
||||
m.lastUpdated = msg.data.CollectedAt
|
||||
m.collecting = false
|
||||
// Mark ready after first successful data collection
|
||||
// Mark ready after first successful data collection.
|
||||
if !m.ready {
|
||||
m.ready = true
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func animTick() tea.Cmd {
|
||||
}
|
||||
|
||||
func animTickWithSpeed(cpuUsage float64) tea.Cmd {
|
||||
// Higher CPU = faster animation (50ms to 300ms)
|
||||
// Higher CPU = faster animation.
|
||||
interval := 300 - int(cpuUsage*2.5)
|
||||
if interval < 50 {
|
||||
interval = 50
|
||||
|
||||
@@ -141,16 +141,16 @@ type BluetoothDevice struct {
|
||||
}
|
||||
|
||||
type Collector struct {
|
||||
// Static Cache (Collected once at startup)
|
||||
// Static cache.
|
||||
cachedHW HardwareInfo
|
||||
lastHWAt time.Time
|
||||
hasStatic bool
|
||||
|
||||
// Slow Cache (Collected every 30s-1m)
|
||||
// Slow cache (30s-1m).
|
||||
lastBTAt time.Time
|
||||
lastBT []BluetoothDevice
|
||||
|
||||
// Fast Metrics (Collected every 1 second)
|
||||
// Fast metrics (1s).
|
||||
prevNet map[string]net.IOCountersStat
|
||||
lastNetAt time.Time
|
||||
lastGPUAt time.Time
|
||||
@@ -168,9 +168,7 @@ func NewCollector() *Collector {
|
||||
func (c *Collector) Collect() (MetricsSnapshot, error) {
|
||||
now := time.Now()
|
||||
|
||||
// Start host info collection early (it's fast but good to parallelize if possible,
|
||||
// but it returns a struct needed for result, so we can just run it here or in parallel)
|
||||
// host.Info is usually cached by gopsutil but let's just call it.
|
||||
// Host info is cached by gopsutil; fetch once.
|
||||
hostInfo, _ := host.Info()
|
||||
|
||||
var (
|
||||
@@ -192,7 +190,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
|
||||
topProcs []ProcessInfo
|
||||
)
|
||||
|
||||
// Helper to launch concurrent collection
|
||||
// Helper to launch concurrent collection.
|
||||
collect := func(fn func() error) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
@@ -209,7 +207,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
|
||||
}()
|
||||
}
|
||||
|
||||
// Launch all independent collection tasks
|
||||
// Launch independent collection tasks.
|
||||
collect(func() (err error) { cpuStats, err = collectCPU(); return })
|
||||
collect(func() (err error) { memStats, err = collectMemory(); return })
|
||||
collect(func() (err error) { diskStats, err = collectDisks(); return })
|
||||
@@ -221,7 +219,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
|
||||
collect(func() (err error) { sensorStats, _ = collectSensors(); return nil })
|
||||
collect(func() (err error) { gpuStats, err = c.collectGPU(now); return })
|
||||
collect(func() (err error) {
|
||||
// Bluetooth is slow, cache for 30s
|
||||
// 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
|
||||
@@ -233,12 +231,11 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
|
||||
})
|
||||
collect(func() (err error) { topProcs = collectTopProcesses(); return nil })
|
||||
|
||||
// Wait for all to complete
|
||||
// Wait for all to complete.
|
||||
wg.Wait()
|
||||
|
||||
// Dependent tasks (must run after others)
|
||||
// Dependent tasks (must run after others)
|
||||
// Cache hardware info as it's expensive and rarely changes
|
||||
// Dependent tasks (post-collect).
|
||||
// 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
|
||||
@@ -272,8 +269,6 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
|
||||
}, mergeErr
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
func runCmd(ctx context.Context, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
output, err := cmd.Output()
|
||||
@@ -289,11 +284,9 @@ func commandExists(name string) bool {
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// If LookPath panics due to permissions or platform quirks, act as if the command is missing.
|
||||
// Treat LookPath panics as "missing".
|
||||
}
|
||||
}()
|
||||
_, err := exec.LookPath(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// humanBytes is defined in view.go to avoid duplication
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// Package-level cache for heavy system_profiler data
|
||||
// Cache for heavy system_profiler output.
|
||||
lastPowerAt time.Time
|
||||
cachedPower string
|
||||
powerCacheTTL = 30 * time.Second
|
||||
@@ -24,15 +24,15 @@ var (
|
||||
func collectBatteries() (batts []BatteryStatus, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Swallow panics from platform-specific battery probes to keep the UI alive.
|
||||
// Swallow panics to keep UI alive.
|
||||
err = fmt.Errorf("battery collection failed: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// macOS: pmset (fast, for real-time percentage/status)
|
||||
// macOS: pmset for real-time percentage/status.
|
||||
if runtime.GOOS == "darwin" && commandExists("pmset") {
|
||||
if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil {
|
||||
// Get heavy info (health, cycles) from cached system_profiler
|
||||
// Health/cycles from cached system_profiler.
|
||||
health, cycles := getCachedPowerData()
|
||||
if batts := parsePMSet(out, health, cycles); len(batts) > 0 {
|
||||
return batts, nil
|
||||
@@ -40,7 +40,7 @@ func collectBatteries() (batts []BatteryStatus, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Linux: /sys/class/power_supply
|
||||
// Linux: /sys/class/power_supply.
|
||||
matches, _ := filepath.Glob("/sys/class/power_supply/BAT*/capacity")
|
||||
for _, capFile := range matches {
|
||||
statusFile := filepath.Join(filepath.Dir(capFile), "status")
|
||||
@@ -73,9 +73,8 @@ func parsePMSet(raw string, health string, cycles int) []BatteryStatus {
|
||||
var timeLeft string
|
||||
|
||||
for _, line := range lines {
|
||||
// Check for time remaining
|
||||
// Time remaining.
|
||||
if strings.Contains(line, "remaining") {
|
||||
// Extract time like "1:30 remaining"
|
||||
parts := strings.Fields(line)
|
||||
for i, p := range parts {
|
||||
if p == "remaining" && i > 0 {
|
||||
@@ -121,7 +120,7 @@ func parsePMSet(raw string, health string, cycles int) []BatteryStatus {
|
||||
return out
|
||||
}
|
||||
|
||||
// getCachedPowerData returns condition, cycles, and fan speed from cached system_profiler output.
|
||||
// getCachedPowerData returns condition and cycles from cached system_profiler.
|
||||
func getCachedPowerData() (health string, cycles int) {
|
||||
out := getSystemPowerOutput()
|
||||
if out == "" {
|
||||
@@ -173,7 +172,7 @@ func collectThermal() ThermalStatus {
|
||||
|
||||
var thermal ThermalStatus
|
||||
|
||||
// Get fan info and adapter power from cached system_profiler
|
||||
// Fan info from cached system_profiler.
|
||||
out := getSystemPowerOutput()
|
||||
if out != "" {
|
||||
lines := strings.Split(out, "\n")
|
||||
@@ -181,7 +180,6 @@ func collectThermal() ThermalStatus {
|
||||
lower := strings.ToLower(line)
|
||||
if strings.Contains(lower, "fan") && strings.Contains(lower, "speed") {
|
||||
if _, after, found := strings.Cut(line, ":"); found {
|
||||
// Extract number from string like "1200 RPM"
|
||||
numStr := strings.TrimSpace(after)
|
||||
numStr, _, _ = strings.Cut(numStr, " ")
|
||||
thermal.FanSpeed, _ = strconv.Atoi(numStr)
|
||||
@@ -190,7 +188,7 @@ func collectThermal() ThermalStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// Get power metrics from ioreg (fast, real-time data)
|
||||
// Power metrics from ioreg (fast, real-time).
|
||||
ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancelPower()
|
||||
if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil {
|
||||
@@ -198,8 +196,7 @@ func collectThermal() ThermalStatus {
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Get battery temperature
|
||||
// Matches: "Temperature" = 3055 (note: space before =)
|
||||
// Battery temperature ("Temperature" = 3055).
|
||||
if _, after, found := strings.Cut(line, "\"Temperature\" = "); found {
|
||||
valStr := strings.TrimSpace(after)
|
||||
if tempRaw, err := strconv.Atoi(valStr); err == nil && tempRaw > 0 {
|
||||
@@ -207,13 +204,10 @@ func collectThermal() ThermalStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// Get adapter power (Watts)
|
||||
// Read from current adapter: "AdapterDetails" = {"Watts"=140...}
|
||||
// Skip historical data: "AppleRawAdapterDetails" = ({Watts=90}, {Watts=140})
|
||||
// Adapter power (Watts) from current adapter.
|
||||
if strings.Contains(line, "\"AdapterDetails\" = {") && !strings.Contains(line, "AppleRaw") {
|
||||
if _, after, found := strings.Cut(line, "\"Watts\"="); found {
|
||||
valStr := strings.TrimSpace(after)
|
||||
// Remove trailing characters like , or }
|
||||
valStr, _, _ = strings.Cut(valStr, ",")
|
||||
valStr, _, _ = strings.Cut(valStr, "}")
|
||||
valStr = strings.TrimSpace(valStr)
|
||||
@@ -223,8 +217,7 @@ func collectThermal() ThermalStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// Get system power consumption (mW -> W)
|
||||
// Matches: "SystemPowerIn"=12345
|
||||
// System power consumption (mW -> W).
|
||||
if _, after, found := strings.Cut(line, "\"SystemPowerIn\"="); found {
|
||||
valStr := strings.TrimSpace(after)
|
||||
valStr, _, _ = strings.Cut(valStr, ",")
|
||||
@@ -235,8 +228,7 @@ func collectThermal() ThermalStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// Get battery power (mW -> W, positive = discharging)
|
||||
// Matches: "BatteryPower"=12345
|
||||
// Battery power (mW -> W, positive = discharging).
|
||||
if _, after, found := strings.Cut(line, "\"BatteryPower\"="); found {
|
||||
valStr := strings.TrimSpace(after)
|
||||
valStr, _, _ = strings.Cut(valStr, ",")
|
||||
@@ -249,14 +241,13 @@ func collectThermal() ThermalStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try thermal level as a proxy if temperature not found
|
||||
// Fallback: thermal level proxy.
|
||||
if thermal.CPUTemp == 0 {
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel2()
|
||||
out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level")
|
||||
if err == nil {
|
||||
level, _ := strconv.Atoi(strings.TrimSpace(out2))
|
||||
// Estimate temp: level 0-100 roughly maps to 40-100°C
|
||||
if level >= 0 {
|
||||
thermal.CPUTemp = 45 + float64(level)*0.5
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func parseSPBluetooth(raw string) []BluetoothDevice {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") {
|
||||
// Reset at top-level sections
|
||||
// Reset at top-level sections.
|
||||
currentName = ""
|
||||
connected = false
|
||||
battery = ""
|
||||
|
||||
@@ -31,12 +31,9 @@ func collectCPU() (CPUStatus, error) {
|
||||
logical = 1
|
||||
}
|
||||
|
||||
// Use two-call pattern for more reliable CPU measurements
|
||||
// First call: initialize/store current CPU times
|
||||
// Two-call pattern for more reliable CPU usage.
|
||||
cpu.Percent(0, true)
|
||||
// Wait for sampling interval
|
||||
time.Sleep(cpuSampleInterval)
|
||||
// Second call: get actual percentages based on difference
|
||||
percents, err := cpu.Percent(0, true)
|
||||
var totalPercent float64
|
||||
perCoreEstimated := false
|
||||
@@ -69,7 +66,7 @@ func collectCPU() (CPUStatus, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get P-core and E-core counts for Apple Silicon
|
||||
// P/E core counts for Apple Silicon.
|
||||
pCores, eCores := getCoreTopology()
|
||||
|
||||
return CPUStatus{
|
||||
@@ -91,14 +88,13 @@ func isZeroLoad(avg load.AvgStat) bool {
|
||||
}
|
||||
|
||||
var (
|
||||
// Package-level cache for core topology
|
||||
// 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.
|
||||
// getCoreTopology returns P/E core counts on Apple Silicon.
|
||||
func getCoreTopology() (pCores, eCores int) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return 0, 0
|
||||
@@ -114,7 +110,6 @@ func getCoreTopology() (pCores, eCores int) {
|
||||
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",
|
||||
@@ -129,15 +124,12 @@ func getCoreTopology() (pCores, eCores int) {
|
||||
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") {
|
||||
|
||||
@@ -43,7 +43,7 @@ func collectDisks() ([]DiskStatus, error) {
|
||||
if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") {
|
||||
continue
|
||||
}
|
||||
// Skip private volumes
|
||||
// Skip /private mounts.
|
||||
if strings.HasPrefix(part.Mountpoint, "/private/") {
|
||||
continue
|
||||
}
|
||||
@@ -58,12 +58,11 @@ func collectDisks() ([]DiskStatus, error) {
|
||||
if err != nil || usage.Total == 0 {
|
||||
continue
|
||||
}
|
||||
// Skip small volumes (< 1GB)
|
||||
// Skip <1GB volumes.
|
||||
if usage.Total < 1<<30 {
|
||||
continue
|
||||
}
|
||||
// For APFS volumes, use a more precise dedup key (bytes level)
|
||||
// to handle shared storage pools properly
|
||||
// Use size-based dedupe key for shared pools.
|
||||
volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total)
|
||||
if seenVolume[volKey] {
|
||||
continue
|
||||
@@ -94,7 +93,7 @@ func collectDisks() ([]DiskStatus, error) {
|
||||
}
|
||||
|
||||
var (
|
||||
// Package-level cache for external disk status
|
||||
// External disk cache.
|
||||
lastDiskCacheAt time.Time
|
||||
diskTypeCache = make(map[string]bool)
|
||||
diskCacheTTL = 2 * time.Minute
|
||||
@@ -106,7 +105,7 @@ func annotateDiskTypes(disks []DiskStatus) {
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// Clear cache if stale
|
||||
// Clear stale cache.
|
||||
if now.Sub(lastDiskCacheAt) > diskCacheTTL {
|
||||
diskTypeCache = make(map[string]bool)
|
||||
lastDiskCacheAt = now
|
||||
|
||||
@@ -17,7 +17,7 @@ const (
|
||||
powermetricsTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// Pre-compiled regex patterns for GPU usage parsing
|
||||
// Regex for GPU usage parsing.
|
||||
var (
|
||||
gpuActiveResidencyRe = regexp.MustCompile(`GPU HW active residency:\s+([\d.]+)%`)
|
||||
gpuIdleResidencyRe = regexp.MustCompile(`GPU idle residency:\s+([\d.]+)%`)
|
||||
@@ -25,7 +25,7 @@ var (
|
||||
|
||||
func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) {
|
||||
if runtime.GOOS == "darwin" {
|
||||
// Get static GPU info (cached for 10 min)
|
||||
// Static GPU info (cached 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
|
||||
@@ -33,12 +33,12 @@ func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get real-time GPU usage
|
||||
// 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)
|
||||
// Apply usage to first GPU (Apple Silicon).
|
||||
if len(result) > 0 {
|
||||
result[0].Usage = usage
|
||||
}
|
||||
@@ -152,19 +152,18 @@ func readMacGPUInfo() ([]GPUStatus, error) {
|
||||
return gpus, nil
|
||||
}
|
||||
|
||||
// getMacGPUUsage gets GPU active residency from powermetrics.
|
||||
// Returns -1 if unavailable (e.g., not running as root).
|
||||
// getMacGPUUsage reads GPU active residency from powermetrics.
|
||||
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
|
||||
// powermetrics may require root.
|
||||
out, err := runCmd(ctx, "powermetrics", "--samplers", "gpu_power", "-i", "500", "-n", "1")
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Parse "GPU HW active residency: X.XX%"
|
||||
// Parse "GPU HW active residency: X.XX%".
|
||||
matches := gpuActiveResidencyRe.FindStringSubmatch(out)
|
||||
if len(matches) >= 2 {
|
||||
usage, err := strconv.ParseFloat(matches[1], 64)
|
||||
@@ -173,7 +172,7 @@ func getMacGPUUsage() float64 {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: parse "GPU idle residency: X.XX%" and calculate active
|
||||
// Fallback: parse idle residency and derive active.
|
||||
matchesIdle := gpuIdleResidencyRe.FindStringSubmatch(out)
|
||||
if len(matchesIdle) >= 2 {
|
||||
idle, err := strconv.ParseFloat(matchesIdle[1], 64)
|
||||
|
||||
@@ -18,19 +18,18 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Get model and CPU from system_profiler
|
||||
// Model and CPU from system_profiler.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var model, cpuModel, osVersion string
|
||||
|
||||
// Get hardware overview
|
||||
out, err := runCmd(ctx, "system_profiler", "SPHardwareDataType")
|
||||
if err == nil {
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
lower := strings.ToLower(strings.TrimSpace(line))
|
||||
// Prefer "Model Name" over "Model Identifier"
|
||||
// Prefer "Model Name" over "Model Identifier".
|
||||
if strings.Contains(lower, "model name:") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) == 2 {
|
||||
@@ -52,7 +51,6 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Get macOS version
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel2()
|
||||
out2, err := runCmd(ctx2, "sw_vers", "-productVersion")
|
||||
@@ -60,7 +58,6 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
|
||||
osVersion = "macOS " + strings.TrimSpace(out2)
|
||||
}
|
||||
|
||||
// Get disk size
|
||||
diskSize := "Unknown"
|
||||
if len(disks) > 0 {
|
||||
diskSize = humanBytes(disks[0].Total)
|
||||
|
||||
@@ -5,45 +5,43 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Health score calculation weights and thresholds
|
||||
// Health score weights and thresholds.
|
||||
const (
|
||||
// Weights (must sum to ~100 for total score)
|
||||
// Weights.
|
||||
healthCPUWeight = 30.0
|
||||
healthMemWeight = 25.0
|
||||
healthDiskWeight = 20.0
|
||||
healthThermalWeight = 15.0
|
||||
healthIOWeight = 10.0
|
||||
|
||||
// CPU thresholds
|
||||
// CPU.
|
||||
cpuNormalThreshold = 30.0
|
||||
cpuHighThreshold = 70.0
|
||||
|
||||
// Memory thresholds
|
||||
// Memory.
|
||||
memNormalThreshold = 50.0
|
||||
memHighThreshold = 80.0
|
||||
memPressureWarnPenalty = 5.0
|
||||
memPressureCritPenalty = 15.0
|
||||
|
||||
// Disk thresholds
|
||||
// Disk.
|
||||
diskWarnThreshold = 70.0
|
||||
diskCritThreshold = 90.0
|
||||
|
||||
// Thermal thresholds
|
||||
// Thermal.
|
||||
thermalNormalThreshold = 60.0
|
||||
thermalHighThreshold = 85.0
|
||||
|
||||
// Disk IO thresholds (MB/s)
|
||||
// Disk IO (MB/s).
|
||||
ioNormalThreshold = 50.0
|
||||
ioHighThreshold = 150.0
|
||||
)
|
||||
|
||||
func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus) (int, string) {
|
||||
// Start with perfect score
|
||||
score := 100.0
|
||||
issues := []string{}
|
||||
|
||||
// CPU Usage (30% weight) - deduct up to 30 points
|
||||
// 0-30% CPU = 0 deduction, 30-70% = linear, 70-100% = heavy penalty
|
||||
// CPU penalty.
|
||||
cpuPenalty := 0.0
|
||||
if cpu.Usage > cpuNormalThreshold {
|
||||
if cpu.Usage > cpuHighThreshold {
|
||||
@@ -57,8 +55,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
|
||||
issues = append(issues, "High CPU")
|
||||
}
|
||||
|
||||
// Memory Usage (25% weight) - deduct up to 25 points
|
||||
// 0-50% = 0 deduction, 50-80% = linear, 80-100% = heavy penalty
|
||||
// Memory penalty.
|
||||
memPenalty := 0.0
|
||||
if mem.UsedPercent > memNormalThreshold {
|
||||
if mem.UsedPercent > memHighThreshold {
|
||||
@@ -72,7 +69,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
|
||||
issues = append(issues, "High Memory")
|
||||
}
|
||||
|
||||
// Memory Pressure (extra penalty)
|
||||
// Memory pressure penalty.
|
||||
if mem.Pressure == "warn" {
|
||||
score -= memPressureWarnPenalty
|
||||
issues = append(issues, "Memory Pressure")
|
||||
@@ -81,7 +78,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
|
||||
issues = append(issues, "Critical Memory")
|
||||
}
|
||||
|
||||
// Disk Usage (20% weight) - deduct up to 20 points
|
||||
// Disk penalty.
|
||||
diskPenalty := 0.0
|
||||
if len(disks) > 0 {
|
||||
diskUsage := disks[0].UsedPercent
|
||||
@@ -98,7 +95,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
|
||||
}
|
||||
}
|
||||
|
||||
// Thermal (15% weight) - deduct up to 15 points
|
||||
// Thermal penalty.
|
||||
thermalPenalty := 0.0
|
||||
if thermal.CPUTemp > 0 {
|
||||
if thermal.CPUTemp > thermalNormalThreshold {
|
||||
@@ -112,7 +109,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
|
||||
score -= thermalPenalty
|
||||
}
|
||||
|
||||
// Disk IO (10% weight) - deduct up to 10 points
|
||||
// Disk IO penalty.
|
||||
ioPenalty := 0.0
|
||||
totalIO := diskIO.ReadRate + diskIO.WriteRate
|
||||
if totalIO > ioNormalThreshold {
|
||||
@@ -125,7 +122,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
|
||||
}
|
||||
score -= ioPenalty
|
||||
|
||||
// Ensure score is in valid range
|
||||
// Clamp score.
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
@@ -133,7 +130,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
|
||||
score = 100
|
||||
}
|
||||
|
||||
// Generate message
|
||||
// Build message.
|
||||
msg := "Excellent"
|
||||
if score >= 90 {
|
||||
msg = "Excellent"
|
||||
|
||||
@@ -17,7 +17,7 @@ func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get IP addresses for interfaces
|
||||
// Map interface IPs.
|
||||
ifAddrs := getInterfaceIPs()
|
||||
|
||||
if c.lastNetAt.IsZero() {
|
||||
@@ -81,7 +81,7 @@ func getInterfaceIPs() map[string]string {
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
for _, addr := range iface.Addrs {
|
||||
// Only IPv4
|
||||
// IPv4 only.
|
||||
if strings.Contains(addr.Addr, ".") && !strings.HasPrefix(addr.Addr, "127.") {
|
||||
ip := strings.Split(addr.Addr, "/")[0]
|
||||
result[iface.Name] = ip
|
||||
@@ -104,14 +104,14 @@ func isNoiseInterface(name string) bool {
|
||||
}
|
||||
|
||||
func collectProxy() ProxyStatus {
|
||||
// Check environment variables first
|
||||
// Check environment variables first.
|
||||
for _, env := range []string{"https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"} {
|
||||
if val := os.Getenv(env); val != "" {
|
||||
proxyType := "HTTP"
|
||||
if strings.HasPrefix(val, "socks") {
|
||||
proxyType = "SOCKS"
|
||||
}
|
||||
// Extract host
|
||||
// Extract host.
|
||||
host := val
|
||||
if strings.Contains(host, "://") {
|
||||
host = strings.SplitN(host, "://", 2)[1]
|
||||
@@ -123,7 +123,7 @@ func collectProxy() ProxyStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// macOS: check system proxy via scutil
|
||||
// macOS: check system proxy via scutil.
|
||||
if runtime.GOOS == "darwin" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
@@ -15,7 +15,7 @@ func collectTopProcesses() []ProcessInfo {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use ps to get top processes by CPU
|
||||
// Use ps to get top processes by CPU.
|
||||
out, err := runCmd(ctx, "ps", "-Aceo", "pcpu,pmem,comm", "-r")
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -24,10 +24,10 @@ func collectTopProcesses() []ProcessInfo {
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
var procs []ProcessInfo
|
||||
for i, line := range lines {
|
||||
if i == 0 { // skip header
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
if i > 5 { // top 5
|
||||
if i > 5 {
|
||||
break
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
@@ -37,7 +37,7 @@ func collectTopProcesses() []ProcessInfo {
|
||||
cpuVal, _ := strconv.ParseFloat(fields[0], 64)
|
||||
memVal, _ := strconv.ParseFloat(fields[1], 64)
|
||||
name := fields[len(fields)-1]
|
||||
// Get just the process name without path
|
||||
// Strip path from command name.
|
||||
if idx := strings.LastIndex(name, "/"); idx >= 0 {
|
||||
name = name[idx+1:]
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ const (
|
||||
iconProcs = "❊"
|
||||
)
|
||||
|
||||
// Check if it's Christmas season (Dec 10-31)
|
||||
// isChristmasSeason reports Dec 10-31.
|
||||
func isChristmasSeason() bool {
|
||||
now := time.Now()
|
||||
month := now.Month()
|
||||
@@ -41,7 +41,7 @@ func isChristmasSeason() bool {
|
||||
return month == time.December && day >= 10 && day <= 31
|
||||
}
|
||||
|
||||
// Mole body frames (legs animate)
|
||||
// Mole body frames.
|
||||
var moleBody = [][]string{
|
||||
{
|
||||
` /\_/\`,
|
||||
@@ -69,7 +69,7 @@ var moleBody = [][]string{
|
||||
},
|
||||
}
|
||||
|
||||
// Mole body frames with Christmas hat
|
||||
// Mole body frames with Christmas hat.
|
||||
var moleBodyWithHat = [][]string{
|
||||
{
|
||||
` *`,
|
||||
@@ -105,7 +105,7 @@ var moleBodyWithHat = [][]string{
|
||||
},
|
||||
}
|
||||
|
||||
// Generate frames with horizontal movement
|
||||
// getMoleFrame renders the animated mole.
|
||||
func getMoleFrame(animFrame int, termWidth int) string {
|
||||
var body []string
|
||||
var bodyIdx int
|
||||
@@ -119,15 +119,12 @@ func getMoleFrame(animFrame int, termWidth int) string {
|
||||
body = moleBody[bodyIdx]
|
||||
}
|
||||
|
||||
// Calculate mole width (approximate)
|
||||
moleWidth := 15
|
||||
// Move across terminal width
|
||||
maxPos := termWidth - moleWidth
|
||||
if maxPos < 0 {
|
||||
maxPos = 0
|
||||
}
|
||||
|
||||
// Move position: 0 -> maxPos -> 0
|
||||
cycleLength := maxPos * 2
|
||||
if cycleLength == 0 {
|
||||
cycleLength = 1
|
||||
@@ -141,7 +138,6 @@ func getMoleFrame(animFrame int, termWidth int) string {
|
||||
var lines []string
|
||||
|
||||
if isChristmas {
|
||||
// Render with red hat on first 3 lines
|
||||
for i, line := range body {
|
||||
if i < 3 {
|
||||
lines = append(lines, padding+hatStyle.Render(line))
|
||||
@@ -165,27 +161,24 @@ type cardData struct {
|
||||
}
|
||||
|
||||
func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int) string {
|
||||
// Title
|
||||
title := titleStyle.Render("Mole Status")
|
||||
|
||||
// Health Score
|
||||
scoreStyle := getScoreStyle(m.HealthScore)
|
||||
scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore))
|
||||
|
||||
// Hardware info - compact for single line
|
||||
// Hardware info for a single line.
|
||||
infoParts := []string{}
|
||||
if 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)
|
||||
// Append GPU core count when available.
|
||||
if len(m.GPU) > 0 && m.GPU[0].CoreCount > 0 {
|
||||
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 != "" {
|
||||
specs = append(specs, m.Hardware.TotalRAM)
|
||||
@@ -200,10 +193,8 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
|
||||
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 != "" {
|
||||
@@ -214,19 +205,14 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
|
||||
|
||||
func getScoreStyle(score int) lipgloss.Style {
|
||||
if score >= 90 {
|
||||
// Excellent - Bright Green
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true)
|
||||
} else if score >= 75 {
|
||||
// Good - Green
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true)
|
||||
} else if score >= 60 {
|
||||
// Fair - Yellow
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true)
|
||||
} else if score >= 40 {
|
||||
// Poor - Orange
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true)
|
||||
} else {
|
||||
// Critical - Red
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true)
|
||||
}
|
||||
}
|
||||
@@ -240,7 +226,6 @@ func buildCards(m MetricsSnapshot, _ int) []cardData {
|
||||
renderProcessCard(m.TopProcesses),
|
||||
renderNetworkCard(m.Network, m.Proxy),
|
||||
}
|
||||
// Only show sensors if we have valid temperature readings
|
||||
if hasSensorData(m.Sensors) {
|
||||
cards = append(cards, renderSensorsCard(m.Sensors))
|
||||
}
|
||||
@@ -334,7 +319,7 @@ func renderMemoryCard(mem MemoryStatus) cardData {
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("Swap %s", subtleStyle.Render("not in use")))
|
||||
}
|
||||
// Memory pressure
|
||||
// Memory pressure status.
|
||||
if mem.Pressure != "" {
|
||||
pressureStyle := okStyle
|
||||
pressureText := "Status " + mem.Pressure
|
||||
@@ -405,7 +390,6 @@ func formatDiskLine(label string, d DiskStatus) string {
|
||||
}
|
||||
|
||||
func ioBar(rate float64) string {
|
||||
// Scale: 0-50 MB/s maps to 0-5 blocks
|
||||
filled := int(rate / 10.0)
|
||||
if filled > 5 {
|
||||
filled = 5
|
||||
@@ -441,7 +425,7 @@ func renderProcessCard(procs []ProcessInfo) cardData {
|
||||
}
|
||||
|
||||
func miniBar(percent float64) string {
|
||||
filled := int(percent / 20) // 5 chars max for 100%
|
||||
filled := int(percent / 20)
|
||||
if filled > 5 {
|
||||
filled = 5
|
||||
}
|
||||
@@ -471,7 +455,7 @@ func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
|
||||
txBar := netBar(totalTx)
|
||||
lines = append(lines, fmt.Sprintf("Down %s %s", rxBar, formatRate(totalRx)))
|
||||
lines = append(lines, fmt.Sprintf("Up %s %s", txBar, formatRate(totalTx)))
|
||||
// Show proxy and IP in one line
|
||||
// Show proxy and IP on one line.
|
||||
var infoParts []string
|
||||
if proxy.Enabled {
|
||||
infoParts = append(infoParts, "Proxy "+proxy.Type)
|
||||
@@ -487,7 +471,6 @@ func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
|
||||
}
|
||||
|
||||
func netBar(rate float64) string {
|
||||
// Scale: 0-10 MB/s maps to 0-5 blocks
|
||||
filled := int(rate / 2.0)
|
||||
if filled > 5 {
|
||||
filled = 5
|
||||
@@ -511,8 +494,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
|
||||
lines = append(lines, subtleStyle.Render("No battery"))
|
||||
} else {
|
||||
b := batts[0]
|
||||
// Line 1: label + bar + percentage (consistent with other cards)
|
||||
// Only show red when battery is critically low
|
||||
statusLower := strings.ToLower(b.Status)
|
||||
percentText := fmt.Sprintf("%5.1f%%", b.Percent)
|
||||
if b.Percent < 20 && statusLower != "charging" && statusLower != "charged" {
|
||||
@@ -520,7 +501,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("Level %s %s", batteryProgressBar(b.Percent), percentText))
|
||||
|
||||
// Line 2: status with power info
|
||||
statusIcon := ""
|
||||
statusStyle := subtleStyle
|
||||
if statusLower == "charging" || statusLower == "charged" {
|
||||
@@ -529,7 +509,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
|
||||
} else if b.Percent < 20 {
|
||||
statusStyle = dangerStyle
|
||||
}
|
||||
// Capitalize first letter
|
||||
statusText := b.Status
|
||||
if len(statusText) > 0 {
|
||||
statusText = strings.ToUpper(statusText[:1]) + strings.ToLower(statusText[1:])
|
||||
@@ -537,21 +516,18 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
|
||||
if b.TimeLeft != "" {
|
||||
statusText += " · " + b.TimeLeft
|
||||
}
|
||||
// Add power information
|
||||
// Add power info.
|
||||
if statusLower == "charging" || statusLower == "charged" {
|
||||
// AC powered - show system power consumption
|
||||
if thermal.SystemPower > 0 {
|
||||
statusText += fmt.Sprintf(" · %.0fW", thermal.SystemPower)
|
||||
} else if thermal.AdapterPower > 0 {
|
||||
statusText += fmt.Sprintf(" · %.0fW Adapter", thermal.AdapterPower)
|
||||
}
|
||||
} else if thermal.BatteryPower > 0 {
|
||||
// Battery powered - show discharge rate
|
||||
statusText += fmt.Sprintf(" · %.0fW", thermal.BatteryPower)
|
||||
}
|
||||
lines = append(lines, statusStyle.Render(statusText+statusIcon))
|
||||
|
||||
// Line 3: Health + cycles + temp
|
||||
healthParts := []string{}
|
||||
if b.Health != "" {
|
||||
healthParts = append(healthParts, b.Health)
|
||||
@@ -560,7 +536,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
|
||||
healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount))
|
||||
}
|
||||
|
||||
// Add temperature if available
|
||||
if thermal.CPUTemp > 0 {
|
||||
tempStyle := subtleStyle
|
||||
if thermal.CPUTemp > 80 {
|
||||
@@ -571,7 +546,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
|
||||
healthParts = append(healthParts, tempStyle.Render(fmt.Sprintf("%.0f°C", thermal.CPUTemp)))
|
||||
}
|
||||
|
||||
// Add fan speed if available
|
||||
if thermal.FanSpeed > 0 {
|
||||
healthParts = append(healthParts, fmt.Sprintf("%d RPM", thermal.FanSpeed))
|
||||
}
|
||||
@@ -607,7 +581,6 @@ func renderCard(data cardData, width int, height int) string {
|
||||
header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("╌", lineLen))
|
||||
content := header + "\n" + strings.Join(data.lines, "\n")
|
||||
|
||||
// Pad to target height
|
||||
lines := strings.Split(content, "\n")
|
||||
for len(lines) < height {
|
||||
lines = append(lines, "")
|
||||
@@ -780,7 +753,6 @@ func renderTwoColumns(cards []cardData, width int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Add empty lines between rows for separation
|
||||
var spacedRows []string
|
||||
for i, r := range rows {
|
||||
if i > 0 {
|
||||
|
||||
Reference in New Issue
Block a user