1
0
mirror of https://github.com/tw93/Mole.git synced 2026-03-22 17:55:08 +00:00
Files
Mole/cmd/status/metrics.go
Noah Qin 2a4eaf007b feat(status): add --json flag for programmatic access (#529)
Add --json command-line flag to mo status that outputs system metrics
in JSON format without requiring a TTY environment.

This enables:
- Integration with GUI applications (e.g., native macOS apps)
- Use in automation scripts and monitoring systems
- Piping output to tools like jq for data extraction
- Recording metrics for analysis

Implementation:
- Add JSON struct tags to all metric types
- Add --json flag using Go's flag package
- Implement runJSONMode() for one-time metric collection
- Refactor main() to support both TUI and JSON modes
- Maintain 100% backward compatibility (default TUI unchanged)

Testing:
- All 454 existing tests pass
- JSON output validated with jq and python json.tool
- Pipeline and redirection work correctly
- No breaking changes to existing functionality
2026-03-03 16:05:55 +08:00

357 lines
10 KiB
Go

package main
import (
"context"
"fmt"
"os/exec"
"sync"
"time"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/net"
)
// RingBuffer is a fixed-size circular buffer for float64 values.
type RingBuffer struct {
data []float64
index int // Current insert position (oldest value)
size int // Number of valid elements
cap int // Total capacity
}
func NewRingBuffer(capacity int) *RingBuffer {
return &RingBuffer{
data: make([]float64, capacity),
cap: capacity,
}
}
func (rb *RingBuffer) Add(val float64) {
rb.data[rb.index] = val
rb.index = (rb.index + 1) % rb.cap
if rb.size < rb.cap {
rb.size++
}
}
// Slice returns the data in chronological order (oldest to newest).
func (rb *RingBuffer) Slice() []float64 {
if rb.size == 0 {
return nil
}
res := make([]float64, rb.size)
if rb.size < rb.cap {
// Not full yet: data is at [0 : size]
copy(res, rb.data[:rb.size])
} else {
// Full: oldest is at index, then wrapped
// data: [4, 5, 1, 2, 3] (cap=5, index=2, oldest=1)
// want: [1, 2, 3, 4, 5]
// part1: [index:] -> [1, 2, 3]
// part2: [:index] -> [4, 5]
copy(res, rb.data[rb.index:])
copy(res[rb.cap-rb.index:], rb.data[:rb.index])
}
return res
}
type MetricsSnapshot struct {
CollectedAt time.Time `json:"collected_at"`
Host string `json:"host"`
Platform string `json:"platform"`
Uptime string `json:"uptime"`
Procs uint64 `json:"procs"`
Hardware HardwareInfo `json:"hardware"`
HealthScore int `json:"health_score"` // 0-100 system health score
HealthScoreMsg string `json:"health_score_msg"` // Brief explanation
CPU CPUStatus `json:"cpu"`
GPU []GPUStatus `json:"gpu"`
Memory MemoryStatus `json:"memory"`
Disks []DiskStatus `json:"disks"`
DiskIO DiskIOStatus `json:"disk_io"`
Network []NetworkStatus `json:"network"`
NetworkHistory NetworkHistory `json:"network_history"`
Proxy ProxyStatus `json:"proxy"`
Batteries []BatteryStatus `json:"batteries"`
Thermal ThermalStatus `json:"thermal"`
Sensors []SensorReading `json:"sensors"`
Bluetooth []BluetoothDevice `json:"bluetooth"`
TopProcesses []ProcessInfo `json:"top_processes"`
}
type HardwareInfo struct {
Model string `json:"model"` // MacBook Pro 14-inch, 2021
CPUModel string `json:"cpu_model"` // Apple M1 Pro / Intel Core i7
TotalRAM string `json:"total_ram"` // 16GB
DiskSize string `json:"disk_size"` // 512GB
OSVersion string `json:"os_version"` // macOS Sonoma 14.5
RefreshRate string `json:"refresh_rate"` // 120Hz / 60Hz
}
type DiskIOStatus struct {
ReadRate float64 `json:"read_rate"` // MB/s
WriteRate float64 `json:"write_rate"` // MB/s
}
type ProcessInfo struct {
Name string `json:"name"`
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
}
type CPUStatus struct {
Usage float64 `json:"usage"`
PerCore []float64 `json:"per_core"`
PerCoreEstimated bool `json:"per_core_estimated"`
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
CoreCount int `json:"core_count"`
LogicalCPU int `json:"logical_cpu"`
PCoreCount int `json:"p_core_count"` // Performance cores (Apple Silicon)
ECoreCount int `json:"e_core_count"` // Efficiency cores (Apple Silicon)
}
type GPUStatus struct {
Name string `json:"name"`
Usage float64 `json:"usage"`
MemoryUsed float64 `json:"memory_used"`
MemoryTotal float64 `json:"memory_total"`
CoreCount int `json:"core_count"`
Note string `json:"note"`
}
type MemoryStatus struct {
Used uint64 `json:"used"`
Total uint64 `json:"total"`
UsedPercent float64 `json:"used_percent"`
SwapUsed uint64 `json:"swap_used"`
SwapTotal uint64 `json:"swap_total"`
Cached uint64 `json:"cached"` // File cache that can be freed if needed
Pressure string `json:"pressure"` // macOS memory pressure: normal/warn/critical
}
type DiskStatus struct {
Mount string `json:"mount"`
Device string `json:"device"`
Used uint64 `json:"used"`
Total uint64 `json:"total"`
UsedPercent float64 `json:"used_percent"`
Fstype string `json:"fstype"`
External bool `json:"external"`
}
type NetworkStatus struct {
Name string `json:"name"`
RxRateMBs float64 `json:"rx_rate_mbs"`
TxRateMBs float64 `json:"tx_rate_mbs"`
IP string `json:"ip"`
}
// NetworkHistory holds the global network usage history.
type NetworkHistory struct {
RxHistory []float64 `json:"rx_history"`
TxHistory []float64 `json:"tx_history"`
}
const NetworkHistorySize = 120 // Increased history size for wider graph
type ProxyStatus struct {
Enabled bool `json:"enabled"`
Type string `json:"type"` // HTTP, HTTPS, SOCKS, PAC, WPAD, TUN
Host string `json:"host"`
}
type BatteryStatus struct {
Percent float64 `json:"percent"`
Status string `json:"status"`
TimeLeft string `json:"time_left"`
Health string `json:"health"`
CycleCount int `json:"cycle_count"`
Capacity int `json:"capacity"` // Maximum capacity percentage (e.g., 85 means 85% of original)
}
type ThermalStatus struct {
CPUTemp float64 `json:"cpu_temp"`
GPUTemp float64 `json:"gpu_temp"`
FanSpeed int `json:"fan_speed"`
FanCount int `json:"fan_count"`
SystemPower float64 `json:"system_power"` // System power consumption in Watts
AdapterPower float64 `json:"adapter_power"` // AC adapter max power in Watts
BatteryPower float64 `json:"battery_power"` // Battery charge/discharge power in Watts (positive = discharging)
}
type SensorReading struct {
Label string `json:"label"`
Value float64 `json:"value"`
Unit string `json:"unit"`
Note string `json:"note"`
}
type BluetoothDevice struct {
Name string `json:"name"`
Connected bool `json:"connected"`
Battery string `json:"battery"`
}
type Collector struct {
// Static cache.
cachedHW HardwareInfo
lastHWAt time.Time
hasStatic bool
// Slow cache (30s-1m).
lastBTAt time.Time
lastBT []BluetoothDevice
// Fast metrics (1s).
prevNet map[string]net.IOCountersStat
lastNetAt time.Time
rxHistoryBuf *RingBuffer
txHistoryBuf *RingBuffer
lastGPUAt time.Time
cachedGPU []GPUStatus
prevDiskIO disk.IOCountersStat
lastDiskAt time.Time
}
func NewCollector() *Collector {
return &Collector{
prevNet: make(map[string]net.IOCountersStat),
rxHistoryBuf: NewRingBuffer(NetworkHistorySize),
txHistoryBuf: NewRingBuffer(NetworkHistorySize),
}
}
func (c *Collector) Collect() (MetricsSnapshot, error) {
now := time.Now()
// Host info is cached by gopsutil; fetch once.
hostInfo, _ := host.Info()
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
topProcs []ProcessInfo
)
// Helper to launch concurrent collection.
collect := func(fn func() error) {
wg.Add(1)
go func() {
defer wg.Done()
if err := fn(); err != nil {
errMu.Lock()
if mergeErr == nil {
mergeErr = err
} else {
mergeErr = fmt.Errorf("%v; %w", mergeErr, err)
}
errMu.Unlock()
}
}()
}
// 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 })
collect(func() (err error) { diskIO = c.collectDiskIO(now); return nil })
collect(func() (err error) { netStats, err = c.collectNetwork(now); return })
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) { topProcs = collectTopProcesses(); return nil })
// Wait for all to complete.
wg.Wait()
// 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
c.hasStatic = true
}
hwInfo := c.cachedHW
score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats)
return MetricsSnapshot{
CollectedAt: now,
Host: hostInfo.Hostname,
Platform: fmt.Sprintf("%s %s", hostInfo.Platform, hostInfo.PlatformVersion),
Uptime: formatUptime(hostInfo.Uptime),
Procs: hostInfo.Procs,
Hardware: hwInfo,
HealthScore: score,
HealthScoreMsg: scoreMsg,
CPU: cpuStats,
GPU: gpuStats,
Memory: memStats,
Disks: diskStats,
DiskIO: diskIO,
Network: netStats,
NetworkHistory: NetworkHistory{
RxHistory: c.rxHistoryBuf.Slice(),
TxHistory: c.txHistoryBuf.Slice(),
},
Proxy: proxyStats,
Batteries: batteryStats,
Thermal: thermalStats,
Sensors: sensorStats,
Bluetooth: btStats,
TopProcesses: topProcs,
}, mergeErr
}
func runCmd(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
output, err := cmd.Output()
if err != nil {
return "", err
}
return string(output), nil
}
func commandExists(name string) bool {
if name == "" {
return false
}
defer func() {
// Treat LookPath panics as "missing".
_ = recover()
}()
_, err := exec.LookPath(name)
return err == nil
}