mirror of
https://github.com/tw93/Mole.git
synced 2026-03-22 23:40:09 +00:00
372 lines
11 KiB
Go
372 lines
11 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()
|
|
if hostInfo == nil {
|
|
hostInfo = &host.InfoStat{}
|
|
}
|
|
|
|
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()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
errMu.Lock()
|
|
panicErr := fmt.Errorf("collector panic: %v", r)
|
|
if mergeErr == nil {
|
|
mergeErr = panicErr
|
|
} else {
|
|
mergeErr = fmt.Errorf("%v; %w", mergeErr, panicErr)
|
|
}
|
|
errMu.Unlock()
|
|
}
|
|
}()
|
|
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
|
|
}
|