From 9162699dc1e21b5dc079da09dbf0838ff132dd9f Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Sun, 31 May 2026 02:05:44 +0500 Subject: [PATCH 1/2] feat(status): paint dashboard from fast metrics first --- cmd/status/main.go | 82 ++++++-- cmd/status/main_test.go | 201 ++++++++++++++++++++ cmd/status/metrics.go | 320 ++++++++++++++++++++++++-------- cmd/status/metrics_cpu.go | 39 +++- cmd/status/metrics_disk.go | 26 ++- cmd/status/metrics_disk_test.go | 55 ++++++ cmd/status/metrics_fast_test.go | 63 +++++++ cmd/status/metrics_memory.go | 15 +- cmd/status/view.go | 6 +- cmd/status/view_test.go | 17 ++ 10 files changed, 712 insertions(+), 112 deletions(-) create mode 100644 cmd/status/metrics_fast_test.go diff --git a/cmd/status/main.go b/cmd/status/main.go index 4dbc224c..ce329aa8 100644 --- a/cmd/status/main.go +++ b/cmd/status/main.go @@ -14,7 +14,11 @@ import ( "github.com/charmbracelet/lipgloss" ) -const refreshInterval = time.Second +const ( + refreshInterval = time.Second + processWatchInterval = refreshInterval + slowRefreshInterval = 30 * time.Second +) var ( // Command-line flags @@ -41,22 +45,33 @@ func shouldUseJSONOutput(forceJSON bool, stdout *os.File) bool { type tickMsg struct{} type animTickMsg struct{} +type collectionMode int + +const ( + collectionFast collectionMode = iota + collectionProcess + collectionFull +) + type metricsMsg struct { data MetricsSnapshot err error + mode collectionMode } type model struct { - collector *Collector - width int - height int - metrics MetricsSnapshot - errMessage string - ready bool - lastUpdated time.Time - collecting bool - animFrame int - catHidden bool // true = hidden, false = visible + collector *Collector + width int + height int + metrics MetricsSnapshot + errMessage string + ready bool + lastUpdated time.Time + lastFullAt time.Time + lastProcessAt time.Time + collecting bool + animFrame int + catHidden bool // true = hidden, false = visible } // padViewToHeight ensures the rendered frame always overwrites the full @@ -164,8 +179,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.collecting = true - return m, m.collectCmd() + return m, m.collectCmd(m.nextCollectionMode(time.Now())) case metricsMsg: + wasReady := m.ready if msg.err != nil { m.errMessage = msg.err.Error() } else { @@ -173,12 +189,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.metrics = msg.data m.lastUpdated = msg.data.CollectedAt + if msg.mode == collectionFull && msg.err == nil { + m.lastFullAt = msg.data.CollectedAt + } + if (msg.mode == collectionProcess || msg.mode == collectionFull) && msg.err == nil { + m.lastProcessAt = msg.data.CollectedAt + } m.collecting = false // Mark ready after first successful data collection. if !m.ready { m.ready = true } - return m, tickAfter(refreshInterval) + delay := refreshInterval + if !wasReady { + delay = 0 + } + return m, tickAfter(delay) case animTickMsg: m.animFrame++ return m, animTickWithSpeed(m.metrics.CPU.Usage) @@ -234,10 +260,34 @@ func (m model) View() string { return padViewToHeight(output, m.height) } -func (m model) collectCmd() tea.Cmd { +func (m model) nextCollectionMode(now time.Time) collectionMode { + if !m.ready { + return collectionFast + } + if m.lastFullAt.IsZero() || now.Sub(m.lastFullAt) >= slowRefreshInterval { + return collectionFull + } + if m.lastProcessAt.IsZero() || now.Sub(m.lastProcessAt) >= processWatchInterval { + return collectionProcess + } + return collectionFast +} + +func (m model) collectCmd(mode collectionMode) tea.Cmd { return func() tea.Msg { - data, err := m.collector.Collect() - return metricsMsg{data: data, err: err} + var ( + data MetricsSnapshot + err error + ) + switch mode { + case collectionFull: + data, err = m.collector.Collect() + case collectionProcess: + data, err = m.collector.CollectProcesses() + default: + data, err = m.collector.CollectFast() + } + return metricsMsg{data: data, err: err, mode: mode} } } diff --git a/cmd/status/main_test.go b/cmd/status/main_test.go index 028c103a..8832cccc 100644 --- a/cmd/status/main_test.go +++ b/cmd/status/main_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "os" "testing" "time" @@ -90,3 +91,203 @@ func TestValidateFlags(t *testing.T) { t.Fatal("expected zero window to fail validation") } } + +func TestNextCollectionModeUsesFastFirstThenPeriodicFull(t *testing.T) { + now := time.Now() + + m := model{} + if got := m.nextCollectionMode(now); got != collectionFast { + t.Fatalf("new model nextCollectionMode() = %v, want fast", got) + } + + m.ready = true + if got := m.nextCollectionMode(now); got != collectionFull { + t.Fatalf("ready model without full collection = %v, want full", got) + } + + m.lastFullAt = now.Add(-slowRefreshInterval + time.Second) + if got := m.nextCollectionMode(now); got != collectionProcess { + t.Fatalf("fresh full without process collection mode = %v, want process", got) + } + + m.lastProcessAt = now.Add(-processWatchInterval + time.Millisecond) + if got := m.nextCollectionMode(now); got != collectionFast { + t.Fatalf("fresh process collection mode = %v, want fast", got) + } + + m.lastProcessAt = now.Add(-processWatchInterval) + if got := m.nextCollectionMode(now); got != collectionProcess { + t.Fatalf("stale process collection mode = %v, want process", got) + } + + m.lastFullAt = now.Add(-slowRefreshInterval) + if got := m.nextCollectionMode(now); got != collectionFull { + t.Fatalf("expired full collection mode = %v, want full", got) + } +} + +func TestFullCollectionErrorDoesNotMarkFullFresh(t *testing.T) { + now := time.Now() + lastFull := now.Add(-slowRefreshInterval) + m := model{ + ready: true, + lastFullAt: lastFull, + } + + updated, _ := m.Update(metricsMsg{ + data: MetricsSnapshot{ + CollectedAt: now, + }, + err: errors.New("full collector failed"), + mode: collectionFull, + }) + got := updated.(model) + + if !got.lastFullAt.Equal(lastFull) { + t.Fatalf("full error updated lastFullAt = %v, want %v", got.lastFullAt, lastFull) + } + if got.nextCollectionMode(now) != collectionFull { + t.Fatalf("full error should leave the next tick eligible for a full retry") + } +} + +func TestProcessCollectionUpdatesProcessFreshness(t *testing.T) { + now := time.Now() + m := model{ready: true} + + updated, _ := m.Update(metricsMsg{ + data: MetricsSnapshot{CollectedAt: now}, + mode: collectionProcess, + }) + got := updated.(model) + + if !got.lastProcessAt.Equal(now) { + t.Fatalf("process collection updated lastProcessAt = %v, want %v", got.lastProcessAt, now) + } + if !got.lastFullAt.IsZero() { + t.Fatalf("process collection should not update lastFullAt, got %v", got.lastFullAt) + } +} + +func TestCollectorAppliesCachedEnrichmentToFastSnapshot(t *testing.T) { + previous := MetricsSnapshot{ + CPU: CPUStatus{PCoreCount: 8, ECoreCount: 4}, + Memory: MemoryStatus{Cached: 512, Pressure: "warn"}, + Hardware: HardwareInfo{Model: "MacBook Pro", CPUModel: "M3", OSVersion: "macOS 15", RefreshRate: "120Hz"}, + GPU: []GPUStatus{{Name: "Apple GPU", Usage: 12}}, + TrashSize: 42, + TrashApprox: true, + Proxy: ProxyStatus{Enabled: true, Type: "HTTP", Host: "127.0.0.1:8080"}, + Batteries: []BatteryStatus{{Percent: 80, Capacity: 92}}, + Thermal: ThermalStatus{CPUTemp: 45}, + Sensors: []SensorReading{{Label: "Fan", Value: 1200, Unit: "rpm"}}, + Bluetooth: []BluetoothDevice{{Name: "Keyboard", Connected: true}}, + TopProcesses: []ProcessInfo{ + {PID: 42, Name: "Xcode", CPU: 82}, + }, + ProcessAlerts: []ProcessAlert{ + {PID: 42, Name: "Xcode", CPU: 140, Status: "active"}, + }, + } + + collector := NewCollector(ProcessWatchOptions{}) + collector.cacheEnrichment(previous) + previous.GPU[0].Name = "mutated" + + next := MetricsSnapshot{ + UptimeSeconds: 60, + Hardware: HardwareInfo{TotalRAM: "16G", DiskSize: "1T"}, + CPU: CPUStatus{Usage: 10}, + Memory: MemoryStatus{UsedPercent: 30, Pressure: "normal"}, + Disks: []DiskStatus{{Mount: "/", Total: 100, Used: 20, UsedPercent: 20}}, + DiskIO: DiskIOStatus{ReadRate: 1, WriteRate: 1}, + } + + collector.applyEnrichment(&next, false) + + if next.Hardware.Model != "MacBook Pro" { + t.Fatalf("expected hardware details to be preserved, got %#v", next.Hardware) + } + if next.CPU.PCoreCount != 8 || next.CPU.ECoreCount != 4 { + t.Fatalf("expected CPU topology to be preserved, got %#v", next.CPU) + } + if next.Memory.Cached != 512 || next.Memory.Pressure != "warn" { + t.Fatalf("expected slow memory annotations to be preserved, got %#v", next.Memory) + } + if next.TrashSize != 42 || !next.TrashApprox { + t.Fatalf("expected trash metadata to be preserved, got size=%d approx=%v", next.TrashSize, next.TrashApprox) + } + if !next.Proxy.Enabled || next.Proxy.Host != "127.0.0.1:8080" { + t.Fatalf("expected proxy metadata to be preserved, got %#v", next.Proxy) + } + if len(next.GPU) != 1 || next.GPU[0].Name != "Apple GPU" { + t.Fatalf("expected GPU metadata to be preserved from cache, got %#v", next.GPU) + } + if len(next.Batteries) != 1 || next.Batteries[0].Capacity != 92 { + t.Fatalf("expected battery metadata to be preserved, got %#v", next.Batteries) + } + if next.Thermal.CPUTemp != 45 { + t.Fatalf("expected thermal metadata to be preserved, got %#v", next.Thermal) + } + if len(next.Bluetooth) != 1 || next.Bluetooth[0].Name != "Keyboard" { + t.Fatalf("expected Bluetooth metadata to be preserved, got %#v", next.Bluetooth) + } + if len(next.TopProcesses) != 1 || next.TopProcesses[0].Name != "Xcode" { + t.Fatalf("expected top processes to be preserved, got %#v", next.TopProcesses) + } + if len(next.ProcessAlerts) != 1 || next.ProcessAlerts[0].Status != "active" { + t.Fatalf("expected process alerts to be preserved, got %#v", next.ProcessAlerts) + } + if next.HealthScore == 0 || next.HealthScoreMsg == "" { + t.Fatalf("expected health score to be recalculated, got %d %q", next.HealthScore, next.HealthScoreMsg) + } +} + +func TestCollectorAppliesZeroValueEnrichmentExactly(t *testing.T) { + collector := NewCollector(ProcessWatchOptions{}) + collector.cacheEnrichment(MetricsSnapshot{ + Memory: MemoryStatus{ + Cached: 0, + Pressure: "", + }, + }) + + next := MetricsSnapshot{ + Memory: MemoryStatus{ + Cached: 512, + Pressure: "critical", + }, + } + + collector.applyEnrichment(&next, false) + + if next.Memory.Cached != 0 || next.Memory.Pressure != "" { + t.Fatalf("expected exact memory enrichment, got %#v", next.Memory) + } +} + +func TestCollectorKeepsLiveProcessDataWhenApplyingEnrichment(t *testing.T) { + collector := NewCollector(ProcessWatchOptions{}) + collector.cacheEnrichment(MetricsSnapshot{ + TopProcesses: []ProcessInfo{{PID: 1, Name: "old", CPU: 10}}, + ProcessAlerts: []ProcessAlert{ + {PID: 1, Name: "old", Status: "active"}, + }, + }) + + next := MetricsSnapshot{ + TopProcesses: []ProcessInfo{{PID: 2, Name: "new", CPU: 90}}, + ProcessAlerts: []ProcessAlert{ + {PID: 2, Name: "new", Status: "active"}, + }, + } + + collector.applyEnrichment(&next, true) + + if len(next.TopProcesses) != 1 || next.TopProcesses[0].Name != "new" { + t.Fatalf("expected live top process data, got %#v", next.TopProcesses) + } + if len(next.ProcessAlerts) != 1 || next.ProcessAlerts[0].Name != "new" { + t.Fatalf("expected live process alerts, got %#v", next.ProcessAlerts) + } +} diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index 3a78af6a..cc621e3f 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os/exec" + "slices" "sync" "time" @@ -233,6 +234,44 @@ type Collector struct { watchMu sync.Mutex processWatch ProcessWatchConfig processWatcher *ProcessWatcher + enrichment snapshotEnrichment + hasEnrichment bool +} + +type collectedMetrics struct { + cpuStats CPUStatus + memStats MemoryStatus + diskStats []DiskStatus + trashSize uint64 + trashApprox bool + diskIO DiskIOStatus + netStats []NetworkStatus + proxyStats ProxyStatus + batteryStats []BatteryStatus + thermalStats ThermalStatus + sensorStats []SensorReading + gpuStats []GPUStatus + btStats []BluetoothDevice + allProcs []ProcessInfo + hasProcesses bool +} + +type snapshotEnrichment struct { + hardware HardwareInfo + cpuPCores int + cpuECores int + memoryCached uint64 + memoryPressure string + gpu []GPUStatus + trashSize uint64 + trashApprox bool + proxy ProxyStatus + batteries []BatteryStatus + thermal ThermalStatus + sensors []SensorReading + bluetooth []BluetoothDevice + topProcesses []ProcessInfo + processAlerts []ProcessAlert } func NewCollector(options ProcessWatchOptions) *Collector { @@ -248,108 +287,169 @@ func NewCollector(options ProcessWatchOptions) *Collector { return c } -func (c *Collector) Collect() (MetricsSnapshot, error) { - now := time.Now() - - // Host info is cached by gopsutil; fetch once. +func collectHostInfo() *host.InfoStat { hostInfo, _ := host.Info() if hostInfo == nil { hostInfo = &host.InfoStat{} } + return hostInfo +} +func collectConcurrently(tasks ...func() error) error { var ( - wg sync.WaitGroup - errMu sync.Mutex - mergeErr error - - cpuStats CPUStatus - memStats MemoryStatus - diskStats []DiskStatus - diskIO DiskIOStatus - netStats []NetworkStatus - proxyStats ProxyStatus - batteryStats []BatteryStatus - thermalStats ThermalStatus - sensorStats []SensorReading - gpuStats []GPUStatus - btStats []BluetoothDevice - allProcs []ProcessInfo + wg sync.WaitGroup + errMu sync.Mutex + merged error ) - // Helper to launch concurrent collection. - collect := func(fn func() error) { + for _, task := range tasks { wg.Go(func() { defer func() { if r := recover(); r != nil { errMu.Lock() panicErr := fmt.Errorf("collector panic: %v", r) - if mergeErr == nil { - mergeErr = panicErr + if merged == nil { + merged = panicErr } else { - mergeErr = fmt.Errorf("%v; %w", mergeErr, panicErr) + merged = fmt.Errorf("%v; %w", merged, panicErr) } errMu.Unlock() } }() - if err := fn(); err != nil { + if err := task(); err != nil { errMu.Lock() - if mergeErr == nil { - mergeErr = err + if merged == nil { + merged = err } else { - mergeErr = fmt.Errorf("%v; %w", mergeErr, err) + merged = fmt.Errorf("%v; %w", merged, err) } errMu.Unlock() } }) } + wg.Wait() + return merged +} + +func (c *Collector) CollectFast() (MetricsSnapshot, error) { + return c.collectFast(false) +} + +func (c *Collector) CollectProcesses() (MetricsSnapshot, error) { + return c.collectFast(true) +} + +func (c *Collector) collectFast(includeProcesses bool) (MetricsSnapshot, error) { + now := time.Now() + hostInfo := collectHostInfo() + var collected collectedMetrics + + tasks := []func() error{ + func() (err error) { collected.cpuStats, err = collectCPUFast(); return }, + func() (err error) { collected.memStats, err = collectMemoryFast(); return }, + func() (err error) { collected.diskStats, err = collectDisksFast(); return }, + func() (err error) { collected.diskIO = c.collectDiskIO(now); return nil }, + func() (err error) { collected.netStats = c.collectNetwork(now); return nil }, + } + if includeProcesses { + tasks = append(tasks, func() error { return collectProcessesInto(&collected) }) + } + + mergeErr := collectConcurrently(tasks...) + + snapshot := c.snapshotFromMetrics(now, hostInfo, collected, false) + c.applyEnrichment(&snapshot, collected.hasProcesses) + return snapshot, mergeErr +} + +func (c *Collector) Collect() (MetricsSnapshot, error) { + return c.collectFull() +} + +func (c *Collector) collectFull() (MetricsSnapshot, error) { + now := time.Now() + hostInfo := collectHostInfo() + var collected collectedMetrics + // 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 }) - var trashSize uint64 - var trashApprox bool - collect(func() (err error) { trashSize, trashApprox = collectTrashSize(); return nil }) - collect(func() (err error) { diskIO = c.collectDiskIO(now); return nil }) - collect(func() (err error) { netStats = c.collectNetwork(now); return nil }) - collect(func() (err error) { proxyStats = collectProxy(); return nil }) - collect(func() (err error) { batteryStats, _ = collectBatteries(); return nil }) - collect(func() (err error) { thermalStats = collectThermal(); return nil }) - // Sensors disabled - CPU temp already shown in CPU card - // 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. - 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) { allProcs, err = collectProcesses(); return }) + tasks := []func() error{ + func() (err error) { collected.cpuStats, err = collectCPU(); return }, + func() (err error) { collected.memStats, err = collectMemory(); return }, + func() (err error) { collected.diskStats, err = collectDisks(); return }, + func() (err error) { collected.trashSize, collected.trashApprox = collectTrashSize(); return nil }, + func() (err error) { collected.diskIO = c.collectDiskIO(now); return nil }, + func() (err error) { collected.netStats = c.collectNetwork(now); return nil }, + func() (err error) { collected.proxyStats = collectProxy(); return nil }, + func() (err error) { collected.batteryStats, _ = collectBatteries(); return nil }, + func() (err error) { collected.thermalStats = collectThermal(); return nil }, + // Sensors disabled - CPU temp already shown in CPU card + // collect(func() (err error) { sensorStats, _ = collectSensors(); return nil }) + func() (err error) { collected.gpuStats, err = c.collectGPU(now); return }, + func() (err error) { + // Bluetooth is slow; cache for 30s. + if now.Sub(c.lastBTAt) > 30*time.Second || len(c.lastBT) == 0 { + collected.btStats = c.collectBluetooth(now) + c.lastBT = collected.btStats + c.lastBTAt = now + } else { + collected.btStats = c.lastBT + } + return nil + }, + func() error { return collectProcessesInto(&collected) }, + } + mergeErr := collectConcurrently(tasks...) - // Wait for all to complete. - wg.Wait() + snapshot := c.snapshotFromMetrics(now, hostInfo, collected, true) + if mergeErr == nil { + c.cacheEnrichment(snapshot) + } + return snapshot, mergeErr +} +func collectProcessesInto(collected *collectedMetrics) error { + procs, err := collectProcesses() + if err != nil { + return err + } + collected.allProcs = procs + collected.hasProcesses = true + return nil +} + +func (c *Collector) snapshotFromMetrics(now time.Time, hostInfo *host.InfoStat, collected collectedMetrics, refreshHardware bool) MetricsSnapshot { // 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) + if refreshHardware && (!c.hasStatic || now.Sub(c.lastHWAt) > 10*time.Minute) { + c.cachedHW = collectHardware(collected.memStats.Total, collected.diskStats) c.lastHWAt = now c.hasStatic = true } - hwInfo := c.cachedHW - - score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats, batteryStats, hostInfo.Uptime) - topProcs := topProcesses(allProcs, 5) + hwInfo := c.hardwareForSnapshot() + + score, scoreMsg := calculateHealthScore( + collected.cpuStats, + collected.memStats, + collected.diskStats, + collected.diskIO, + collected.thermalStats, + collected.batteryStats, + hostInfo.Uptime, + ) + var topProcs []ProcessInfo + if collected.hasProcesses { + topProcs = topProcesses(collected.allProcs, 5) + } var processAlerts []ProcessAlert c.watchMu.Lock() if c.processWatcher != nil { - processAlerts = c.processWatcher.Update(now, allProcs) + if collected.hasProcesses { + processAlerts = c.processWatcher.Update(now, collected.allProcs) + } else { + processAlerts = c.processWatcher.Snapshot() + } } c.watchMu.Unlock() @@ -363,27 +463,91 @@ func (c *Collector) Collect() (MetricsSnapshot, error) { Hardware: hwInfo, HealthScore: score, HealthScoreMsg: scoreMsg, - CPU: cpuStats, - GPU: gpuStats, - Memory: memStats, - Disks: diskStats, - TrashSize: trashSize, - TrashApprox: trashApprox, - DiskIO: diskIO, - Network: netStats, + CPU: collected.cpuStats, + GPU: collected.gpuStats, + Memory: collected.memStats, + Disks: collected.diskStats, + TrashSize: collected.trashSize, + TrashApprox: collected.trashApprox, + DiskIO: collected.diskIO, + Network: collected.netStats, NetworkHistory: NetworkHistory{ RxHistory: c.rxHistoryBuf.Slice(), TxHistory: c.txHistoryBuf.Slice(), }, - Proxy: proxyStats, - Batteries: batteryStats, - Thermal: thermalStats, - Sensors: sensorStats, - Bluetooth: btStats, + Proxy: collected.proxyStats, + Batteries: collected.batteryStats, + Thermal: collected.thermalStats, + Sensors: collected.sensorStats, + Bluetooth: collected.btStats, TopProcesses: topProcs, ProcessWatch: c.processWatch, ProcessAlerts: processAlerts, - }, mergeErr + } +} + +func (c *Collector) hardwareForSnapshot() HardwareInfo { + if c.hasStatic { + return c.cachedHW + } + return HardwareInfo{} +} + +func (c *Collector) cacheEnrichment(snapshot MetricsSnapshot) { + c.enrichment = snapshotEnrichment{ + hardware: snapshot.Hardware, + cpuPCores: snapshot.CPU.PCoreCount, + cpuECores: snapshot.CPU.ECoreCount, + memoryCached: snapshot.Memory.Cached, + memoryPressure: snapshot.Memory.Pressure, + gpu: slices.Clone(snapshot.GPU), + trashSize: snapshot.TrashSize, + trashApprox: snapshot.TrashApprox, + proxy: snapshot.Proxy, + batteries: slices.Clone(snapshot.Batteries), + thermal: snapshot.Thermal, + sensors: slices.Clone(snapshot.Sensors), + bluetooth: slices.Clone(snapshot.Bluetooth), + topProcesses: slices.Clone(snapshot.TopProcesses), + processAlerts: slices.Clone(snapshot.ProcessAlerts), + } + c.hasEnrichment = true +} + +func (c *Collector) applyEnrichment(snapshot *MetricsSnapshot, preserveLiveProcesses bool) { + if snapshot == nil || !c.hasEnrichment { + return + } + c.enrichment.apply(snapshot, preserveLiveProcesses) + snapshot.HealthScore, snapshot.HealthScoreMsg = calculateHealthScore( + snapshot.CPU, + snapshot.Memory, + snapshot.Disks, + snapshot.DiskIO, + snapshot.Thermal, + snapshot.Batteries, + snapshot.UptimeSeconds, + ) +} + +func (e snapshotEnrichment) apply(snapshot *MetricsSnapshot, preserveLiveProcesses bool) { + snapshot.Hardware = e.hardware + snapshot.CPU.PCoreCount = e.cpuPCores + snapshot.CPU.ECoreCount = e.cpuECores + snapshot.Memory.Cached = e.memoryCached + snapshot.Memory.Pressure = e.memoryPressure + snapshot.GPU = slices.Clone(e.gpu) + snapshot.TrashSize = e.trashSize + snapshot.TrashApprox = e.trashApprox + snapshot.Proxy = e.proxy + snapshot.Batteries = slices.Clone(e.batteries) + snapshot.Thermal = e.thermal + snapshot.Sensors = slices.Clone(e.sensors) + snapshot.Bluetooth = slices.Clone(e.bluetooth) + if !preserveLiveProcesses { + snapshot.TopProcesses = slices.Clone(e.topProcesses) + snapshot.ProcessAlerts = slices.Clone(e.processAlerts) + } } var runCmd = func(ctx context.Context, name string, args ...string) (string, error) { diff --git a/cmd/status/metrics_cpu.go b/cmd/status/metrics_cpu.go index d5b88e60..4c2e9866 100644 --- a/cmd/status/metrics_cpu.go +++ b/cmd/status/metrics_cpu.go @@ -18,6 +18,14 @@ const ( ) func collectCPU() (CPUStatus, error) { + return collectCPUWithOptions(true) +} + +func collectCPUFast() (CPUStatus, error) { + return collectCPUWithOptions(false) +} + +func collectCPUWithOptions(includeSlowFallbacks bool) (CPUStatus, error) { counts, countsErr := cpu.Counts(false) if countsErr != nil || counts == 0 { counts = runtime.NumCPU() @@ -38,16 +46,24 @@ func collectCPU() (CPUStatus, error) { var totalPercent float64 perCoreEstimated := false if err != nil || len(percents) == 0 { - fallbackUsage, fallbackPerCore, fallbackErr := fallbackCPUUtilization(logical) - if fallbackErr != nil { - if err != nil { - return CPUStatus{}, err + if !includeSlowFallbacks { + percents = make([]float64, logical) + if len(percents) > 0 { + perCoreEstimated = true } - return CPUStatus{}, fallbackErr } - totalPercent = fallbackUsage - percents = fallbackPerCore - perCoreEstimated = true + if includeSlowFallbacks { + fallbackUsage, fallbackPerCore, fallbackErr := fallbackCPUUtilization(logical) + if fallbackErr != nil { + if err != nil { + return CPUStatus{}, err + } + return CPUStatus{}, fallbackErr + } + totalPercent = fallbackUsage + percents = fallbackPerCore + perCoreEstimated = true + } } else { for _, v := range percents { totalPercent += v @@ -60,14 +76,17 @@ func collectCPU() (CPUStatus, error) { if loadStats != nil { loadAvg = *loadStats } - if loadErr != nil || isZeroLoad(loadAvg) { + if includeSlowFallbacks && (loadErr != nil || isZeroLoad(loadAvg)) { if fallback, err := fallbackLoadAvgFromUptime(); err == nil { loadAvg = fallback } } // P/E core counts for Apple Silicon. - pCores, eCores := getCoreTopology() + var pCores, eCores int + if includeSlowFallbacks { + pCores, eCores = getCoreTopology() + } return CPUStatus{ Usage: totalPercent, diff --git a/cmd/status/metrics_disk.go b/cmd/status/metrics_disk.go index 981d02f8..669155eb 100644 --- a/cmd/status/metrics_disk.go +++ b/cmd/status/metrics_disk.go @@ -44,8 +44,21 @@ var skipDiskFSTypes = map[string]bool{ "webdav": true, } +var ( + diskPartitionsFunc = disk.Partitions + diskUsageFunc = disk.Usage +) + func collectDisks() ([]DiskStatus, error) { - partitions, err := disk.Partitions(false) + return collectDisksWithCorrections(true) +} + +func collectDisksFast() ([]DiskStatus, error) { + return collectDisksWithCorrections(false) +} + +func collectDisksWithCorrections(useCorrections bool) ([]DiskStatus, error) { + partitions, err := diskPartitionsFunc(false) if err != nil { return nil, err } @@ -66,12 +79,12 @@ func collectDisks() ([]DiskStatus, error) { if seenDevice[baseDevice] { continue } - usage, err := disk.Usage(part.Mountpoint) + usage, err := diskUsageFunc(part.Mountpoint) if err != nil || usage.Total == 0 { continue } total := usage.Total - if runtime.GOOS == "darwin" { + if useCorrections && runtime.GOOS == "darwin" { total = correctDiskTotalBytes(part.Mountpoint, total) } // Skip <1GB volumes. @@ -85,7 +98,7 @@ func collectDisks() ([]DiskStatus, error) { } used := usage.Used usedPercent := usage.UsedPercent - if runtime.GOOS == "darwin" && strings.ToLower(part.Fstype) == "apfs" { + if useCorrections && runtime.GOOS == "darwin" && strings.ToLower(part.Fstype) == "apfs" { used, usedPercent = correctAPFSDiskUsage(part.Mountpoint, total, usage.Used) } @@ -96,12 +109,15 @@ func collectDisks() ([]DiskStatus, error) { Total: total, UsedPercent: usedPercent, Fstype: part.Fstype, + External: !useCorrections && strings.HasPrefix(part.Mountpoint, "/Volumes/"), }) seenDevice[baseDevice] = true seenVolume[volKey] = true } - annotateDiskTypes(disks) + if useCorrections { + annotateDiskTypes(disks) + } sort.Slice(disks, func(i, j int) bool { // First, prefer internal disks over external diff --git a/cmd/status/metrics_disk_test.go b/cmd/status/metrics_disk_test.go index ab897ad5..739cb352 100644 --- a/cmd/status/metrics_disk_test.go +++ b/cmd/status/metrics_disk_test.go @@ -98,6 +98,61 @@ func TestExtractPlistUint(t *testing.T) { }) } +func TestCollectDisksFastSkipsSlowCorrections(t *testing.T) { + origPartitions := diskPartitionsFunc + origUsage := diskUsageFunc + origRunCmd := runCmd + origCommandExists := commandExists + t.Cleanup(func() { + diskPartitionsFunc = origPartitions + diskUsageFunc = origUsage + runCmd = origRunCmd + commandExists = origCommandExists + }) + + const rawTotal = uint64(2 * 1024 * 1024 * 1024) + const rawUsed = uint64(1024 * 1024 * 1024) + diskPartitionsFunc = func(all bool) ([]disk.PartitionStat, error) { + if all { + t.Fatalf("collectDisksFast() should request physical partitions only") + } + return []disk.PartitionStat{ + {Device: "/dev/disk3s1s1", Mountpoint: "/", Fstype: "apfs"}, + }, nil + } + diskUsageFunc = func(path string) (*disk.UsageStat, error) { + if path != "/" { + t.Fatalf("unexpected disk usage path %q", path) + } + return &disk.UsageStat{ + Path: path, + Fstype: "apfs", + Total: rawTotal, + Used: rawUsed, + UsedPercent: 50, + }, nil + } + commandExists = func(name string) bool { + t.Fatalf("collectDisksFast() should not check external command %q", name) + return false + } + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + t.Fatalf("collectDisksFast() should not run external command %q", name) + return "", errors.New("unexpected command") + } + + got, err := collectDisksFast() + if err != nil { + t.Fatalf("collectDisksFast() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("collectDisksFast() returned %d disks, want 1: %#v", len(got), got) + } + if got[0].Total != rawTotal || got[0].Used != rawUsed || got[0].UsedPercent != 50 { + t.Fatalf("collectDisksFast() should keep raw usage, got %#v", got[0]) + } +} + func TestCorrectDiskTotalBytes(t *testing.T) { origRunCmd := runCmd origCommandExists := commandExists diff --git a/cmd/status/metrics_fast_test.go b/cmd/status/metrics_fast_test.go new file mode 100644 index 00000000..5271c32b --- /dev/null +++ b/cmd/status/metrics_fast_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/shirou/gopsutil/v4/disk" + gopsutilnet "github.com/shirou/gopsutil/v4/net" +) + +func TestCollectFastAvoidsExternalCommands(t *testing.T) { + origRunCmd := runCmd + origCommandExists := commandExists + origPartitions := diskPartitionsFunc + origUsage := diskUsageFunc + origIOCounters := ioCountersFunc + t.Cleanup(func() { + runCmd = origRunCmd + commandExists = origCommandExists + diskPartitionsFunc = origPartitions + diskUsageFunc = origUsage + ioCountersFunc = origIOCounters + }) + + var externalCalls atomic.Int32 + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + externalCalls.Add(1) + return "", errors.New("unexpected command") + } + commandExists = func(name string) bool { + externalCalls.Add(1) + return false + } + diskPartitionsFunc = func(all bool) ([]disk.PartitionStat, error) { + return []disk.PartitionStat{ + {Device: "/dev/disk3s1s1", Mountpoint: "/", Fstype: "apfs"}, + }, nil + } + diskUsageFunc = func(path string) (*disk.UsageStat, error) { + return &disk.UsageStat{ + Path: path, + Fstype: "apfs", + Total: 2 * 1024 * 1024 * 1024, + Used: 1024 * 1024 * 1024, + UsedPercent: 50, + }, nil + } + ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) { + return []gopsutilnet.IOCountersStat{ + {Name: "en0", BytesRecv: 1024, BytesSent: 2048}, + }, nil + } + + collector := NewCollector(ProcessWatchOptions{}) + if _, err := collector.CollectFast(); err != nil { + t.Fatalf("CollectFast() error = %v", err) + } + if externalCalls.Load() != 0 { + t.Fatalf("CollectFast() made %d external command calls", externalCalls.Load()) + } +} diff --git a/cmd/status/metrics_memory.go b/cmd/status/metrics_memory.go index 943c32e3..ea1687ba 100644 --- a/cmd/status/metrics_memory.go +++ b/cmd/status/metrics_memory.go @@ -11,6 +11,14 @@ import ( ) func collectMemory() (MemoryStatus, error) { + return collectMemoryWithOptions(true) +} + +func collectMemoryFast() (MemoryStatus, error) { + return collectMemoryWithOptions(false) +} + +func collectMemoryWithOptions(includeSlowAnnotations bool) (MemoryStatus, error) { vm, err := mem.VirtualMemory() if err != nil { return MemoryStatus{}, err @@ -20,11 +28,14 @@ func collectMemory() (MemoryStatus, error) { if swap == nil { swap = &mem.SwapMemoryStat{} } - pressure := getMemoryPressure() + var pressure string + if includeSlowAnnotations { + pressure = getMemoryPressure() + } // On macOS, vm.Cached is 0, so we calculate from file-backed pages. cached := vm.Cached - if runtime.GOOS == "darwin" && cached == 0 { + if includeSlowAnnotations && runtime.GOOS == "darwin" && cached == 0 { cached = getFileBackedMemory() } diff --git a/cmd/status/view.go b/cmd/status/view.go index dfecaaeb..0b64f6d3 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -167,9 +167,13 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int var specs []string if m.Hardware.TotalRAM != "" { specs = append(specs, m.Hardware.TotalRAM) + } else if m.Memory.Total > 0 { + specs = append(specs, humanBytes(m.Memory.Total)) } if m.Hardware.DiskSize != "" { specs = append(specs, m.Hardware.DiskSize) + } else if disk, ok := rootDisk(m.Disks); ok && disk.Total > 0 { + specs = append(specs, humanBytes(disk.Total)) } if len(specs) > 0 { infoParts = append(infoParts, strings.Join(specs, "/")) @@ -541,7 +545,7 @@ func renderNetworkCard(netStats []NetworkStatus, history NetworkHistory, proxy P } if len(netStats) == 0 { - lines = []string{subtleStyle.Render("Collecting...")} + lines = append(lines, subtleStyle.Render("Collecting...")) } else { // Calculate dynamic width // Layout: "Down " (7) + graph + " " (2) + rate (approx 10-12) diff --git a/cmd/status/view_test.go b/cmd/status/view_test.go index ca0f9355..d646f9fd 100644 --- a/cmd/status/view_test.go +++ b/cmd/status/view_test.go @@ -1031,6 +1031,23 @@ func TestRenderProcessCardAddsInlineHintWithoutExtraRows(t *testing.T) { } } +func TestRenderHeaderUsesFastMetricSpecFallbacks(t *testing.T) { + const ram = uint64(16 * 1024 * 1024 * 1024) + const diskSize = uint64(512 * 1024 * 1024 * 1024) + m := MetricsSnapshot{ + HealthScore: 90, + Memory: MemoryStatus{Total: ram}, + Disks: []DiskStatus{{Mount: "/", Total: diskSize}}, + } + + header, _ := renderHeader(m, "", 0, 120, true) + plain := stripANSI(header) + want := humanBytes(ram) + "/" + humanBytes(diskSize) + if !strings.Contains(plain, want) { + t.Fatalf("renderHeader() should use fast metric specs %q, got %q", want, plain) + } +} + func TestRenderHeaderWrapsOnNarrowWidth(t *testing.T) { m := MetricsSnapshot{ HealthScore: 91, From 6e0f2b8f8f36271b0558898e6c97dfdd5164c636 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 1 Jun 2026 13:54:21 +0800 Subject: [PATCH 2/2] test(status): cover live process refresh with cached enrichment --- cmd/status/metrics.go | 2 +- cmd/status/metrics_fast_test.go | 63 +++++++++++++++++++++++++++++++++ cmd/status/metrics_process.go | 2 ++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index cc621e3f..b655f7dd 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -409,7 +409,7 @@ func (c *Collector) collectFull() (MetricsSnapshot, error) { } func collectProcessesInto(collected *collectedMetrics) error { - procs, err := collectProcesses() + procs, err := collectProcessesFunc() if err != nil { return err } diff --git a/cmd/status/metrics_fast_test.go b/cmd/status/metrics_fast_test.go index 5271c32b..f4810ece 100644 --- a/cmd/status/metrics_fast_test.go +++ b/cmd/status/metrics_fast_test.go @@ -61,3 +61,66 @@ func TestCollectFastAvoidsExternalCommands(t *testing.T) { t.Fatalf("CollectFast() made %d external command calls", externalCalls.Load()) } } + +func TestCollectProcessesKeepsLiveProcessesWithCachedEnrichment(t *testing.T) { + origPartitions := diskPartitionsFunc + origUsage := diskUsageFunc + origIOCounters := ioCountersFunc + origCollectProcesses := collectProcessesFunc + t.Cleanup(func() { + diskPartitionsFunc = origPartitions + diskUsageFunc = origUsage + ioCountersFunc = origIOCounters + collectProcessesFunc = origCollectProcesses + }) + + diskPartitionsFunc = func(all bool) ([]disk.PartitionStat, error) { + return []disk.PartitionStat{ + {Device: "/dev/disk3s1s1", Mountpoint: "/", Fstype: "apfs"}, + }, nil + } + diskUsageFunc = func(path string) (*disk.UsageStat, error) { + return &disk.UsageStat{ + Path: path, + Fstype: "apfs", + Total: 2 * 1024 * 1024 * 1024, + Used: 1024 * 1024 * 1024, + UsedPercent: 50, + }, nil + } + ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) { + return []gopsutilnet.IOCountersStat{{Name: "en0", BytesRecv: 1024, BytesSent: 2048}}, nil + } + collectProcessesFunc = func() ([]ProcessInfo, error) { + return []ProcessInfo{ + {PID: 200, PPID: 1, Name: "new-hot-process", Command: "/usr/bin/new-hot-process", CPU: 240, Memory: 1.5}, + }, nil + } + + collector := NewCollector(ProcessWatchOptions{Enabled: true, CPUThreshold: 50}) + collector.cacheEnrichment(MetricsSnapshot{ + Hardware: HardwareInfo{Model: "MacBook Pro"}, + TrashSize: 99, + TopProcesses: []ProcessInfo{ + {PID: 100, Name: "old-process", CPU: 10}, + }, + ProcessAlerts: []ProcessAlert{ + {PID: 100, Name: "old-process", Status: "active"}, + }, + }) + + snapshot, err := collector.CollectProcesses() + if err != nil { + t.Fatalf("CollectProcesses() error = %v", err) + } + + if snapshot.Hardware.Model != "MacBook Pro" || snapshot.TrashSize != 99 { + t.Fatalf("expected cached enrichment to be preserved, got hardware=%#v trash=%d", snapshot.Hardware, snapshot.TrashSize) + } + if len(snapshot.TopProcesses) != 1 || snapshot.TopProcesses[0].Name != "new-hot-process" { + t.Fatalf("expected live top process data, got %#v", snapshot.TopProcesses) + } + if len(snapshot.ProcessAlerts) != 1 || snapshot.ProcessAlerts[0].Name != "new-hot-process" { + t.Fatalf("expected live process alert data, got %#v", snapshot.ProcessAlerts) + } +} diff --git a/cmd/status/metrics_process.go b/cmd/status/metrics_process.go index a9a46116..1a40d6e5 100644 --- a/cmd/status/metrics_process.go +++ b/cmd/status/metrics_process.go @@ -11,6 +11,8 @@ import ( "time" ) +var collectProcessesFunc = collectProcesses + func collectProcesses() ([]ProcessInfo, error) { if runtime.GOOS != "darwin" { return nil, nil