mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 15:04:42 +00:00
Reconstruct the structure of go
This commit is contained in:
@@ -50,11 +50,27 @@ Config: `.editorconfig` and `.shellcheckrc`
|
|||||||
|
|
||||||
## Go Components
|
## Go Components
|
||||||
|
|
||||||
`mo status` and `mo analyze` use Go for the interactive dashboards.
|
`mo status` and `mo analyze` use Go with Bubble Tea for interactive dashboards.
|
||||||
|
|
||||||
|
**Code organization:**
|
||||||
|
|
||||||
|
- Each module split into focused files by responsibility
|
||||||
|
- `cmd/analyze/` - Disk analyzer with 7 files under 500 lines each
|
||||||
|
- `cmd/status/` - System monitor with metrics split into 11 domain files
|
||||||
|
|
||||||
|
**Development workflow:**
|
||||||
|
|
||||||
- Format code with `gofmt -w ./cmd/...`
|
- Format code with `gofmt -w ./cmd/...`
|
||||||
- Run `go test ./cmd/...` before submitting Go changes (ensures packages compile)
|
- Run `go vet ./cmd/...` to check for issues
|
||||||
- Build universal binaries locally via `./scripts/build-status.sh` and `./scripts/build-analyze.sh`
|
- Build with `go build ./...` to verify all packages compile
|
||||||
|
- Build universal binaries via `./scripts/build-status.sh` and `./scripts/build-analyze.sh`
|
||||||
|
|
||||||
|
**Guidelines:**
|
||||||
|
|
||||||
|
- Keep files focused on single responsibility
|
||||||
|
- Extract constants instead of magic numbers
|
||||||
|
- Use context for timeout control on external commands
|
||||||
|
- Add comments explaining why, not what
|
||||||
|
|
||||||
## Pull Requests
|
## Pull Requests
|
||||||
|
|
||||||
|
|||||||
BIN
bin/analyze-go
BIN
bin/analyze-go
Binary file not shown.
BIN
bin/status-go
BIN
bin/status-go
Binary file not shown.
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -291,7 +292,7 @@ func removeOverviewSnapshot(path string) {
|
|||||||
|
|
||||||
// prefetchOverviewCache scans overview directories in background
|
// prefetchOverviewCache scans overview directories in background
|
||||||
// to populate cache for faster overview mode access
|
// to populate cache for faster overview mode access
|
||||||
func prefetchOverviewCache() {
|
func prefetchOverviewCache(ctx context.Context) {
|
||||||
entries := createOverviewEntries()
|
entries := createOverviewEntries()
|
||||||
|
|
||||||
// Check which entries need refresh
|
// Check which entries need refresh
|
||||||
@@ -309,8 +310,15 @@ func prefetchOverviewCache() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan and cache in background
|
// Scan and cache in background with context cancellation support
|
||||||
for _, path := range needScan {
|
for _, path := range needScan {
|
||||||
|
// Check if context is cancelled
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
size, err := measureOverviewSize(path)
|
size, err := measureOverviewSize(path)
|
||||||
if err == nil && size > 0 {
|
if err == nil && size > 0 {
|
||||||
_ = storeOverviewSize(path, size)
|
_ = storeOverviewSize(path, size)
|
||||||
|
|||||||
@@ -59,17 +59,17 @@ var projectDependencyDirs = map[string]bool{
|
|||||||
".pnpm-store": true, // pnpm store
|
".pnpm-store": true, // pnpm store
|
||||||
|
|
||||||
// Python dependencies and outputs
|
// Python dependencies and outputs
|
||||||
"venv": true,
|
"venv": true,
|
||||||
".venv": true,
|
".venv": true,
|
||||||
"virtualenv": true,
|
"virtualenv": true,
|
||||||
"__pycache__": true,
|
"__pycache__": true,
|
||||||
".pytest_cache": true,
|
".pytest_cache": true,
|
||||||
".mypy_cache": true,
|
".mypy_cache": true,
|
||||||
".ruff_cache": true,
|
".ruff_cache": true,
|
||||||
".tox": true,
|
".tox": true,
|
||||||
".eggs": true,
|
".eggs": true,
|
||||||
"htmlcov": true, // Coverage reports
|
"htmlcov": true, // Coverage reports
|
||||||
".ipynb_checkpoints": true, // Jupyter checkpoints
|
".ipynb_checkpoints": true, // Jupyter checkpoints
|
||||||
|
|
||||||
// Ruby dependencies
|
// Ruby dependencies
|
||||||
"vendor": true,
|
"vendor": true,
|
||||||
@@ -95,10 +95,10 @@ var projectDependencyDirs = map[string]bool{
|
|||||||
".nyc_output": true, // NYC coverage
|
".nyc_output": true, // NYC coverage
|
||||||
|
|
||||||
// Frontend framework outputs
|
// Frontend framework outputs
|
||||||
".angular": true, // Angular CLI cache
|
".angular": true, // Angular CLI cache
|
||||||
".svelte-kit": true, // SvelteKit build
|
".svelte-kit": true, // SvelteKit build
|
||||||
".astro": true, // Astro cache
|
".astro": true, // Astro cache
|
||||||
".docusaurus": true, // Docusaurus build
|
".docusaurus": true, // Docusaurus build
|
||||||
|
|
||||||
// iOS/macOS development
|
// iOS/macOS development
|
||||||
"DerivedData": true,
|
"DerivedData": true,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ const (
|
|||||||
maxEntries = 30
|
maxEntries = 30
|
||||||
maxLargeFiles = 30
|
maxLargeFiles = 30
|
||||||
barWidth = 24
|
barWidth = 24
|
||||||
minLargeFileSize = 100 << 20 // 100 MB
|
minLargeFileSize = 100 << 20 // 100 MB
|
||||||
defaultViewport = 12 // Default viewport when terminal height is unknown
|
defaultViewport = 12 // Default viewport when terminal height is unknown
|
||||||
overviewCacheTTL = 7 * 24 * time.Hour // 7 days
|
overviewCacheTTL = 7 * 24 * time.Hour // 7 days
|
||||||
overviewCacheFile = "overview_sizes.json"
|
overviewCacheFile = "overview_sizes.json"
|
||||||
duTimeout = 60 * time.Second // Increased for large directories
|
duTimeout = 60 * time.Second // Increased for large directories
|
||||||
|
|||||||
@@ -56,14 +56,19 @@ func deletePathWithProgress(root string, counter *int64) (int64, error) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
// Track walk error separately
|
||||||
return count, err
|
if err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.RemoveAll(root); err != nil {
|
// Try to remove remaining directory structure
|
||||||
return count, err
|
// Even if this fails, we still report files deleted
|
||||||
|
if removeErr := os.RemoveAll(root); removeErr != nil {
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = removeErr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the first error encountered during deletion if any
|
// Always return count (even if there were errors), along with first error
|
||||||
return count, firstErr
|
return count, firstErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prefetch overview cache in background (non-blocking)
|
// Prefetch overview cache in background (non-blocking)
|
||||||
go prefetchOverviewCache()
|
// Use context with timeout to prevent hanging
|
||||||
|
prefetchCtx, prefetchCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer prefetchCancel()
|
||||||
|
go prefetchOverviewCache(prefetchCtx)
|
||||||
|
|
||||||
p := tea.NewProgram(newModel(abs, isOverview), tea.WithAltScreen())
|
p := tea.NewProgram(newModel(abs, isOverview), tea.WithAltScreen())
|
||||||
if err := p.Start(); err != nil {
|
if err := p.Start(); err != nil {
|
||||||
@@ -509,7 +512,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
// Handle delete confirmation
|
// Handle delete confirmation
|
||||||
if m.deleteConfirm {
|
if m.deleteConfirm {
|
||||||
if msg.String() == "delete" || msg.String() == "backspace" {
|
switch msg.String() {
|
||||||
|
case "delete", "backspace":
|
||||||
// Confirm delete - start async deletion
|
// Confirm delete - start async deletion
|
||||||
if m.deleteTarget != nil {
|
if m.deleteTarget != nil {
|
||||||
m.deleteConfirm = false
|
m.deleteConfirm = false
|
||||||
@@ -525,17 +529,14 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.deleteConfirm = false
|
m.deleteConfirm = false
|
||||||
m.deleteTarget = nil
|
m.deleteTarget = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
} else if msg.String() == "esc" || msg.String() == "q" {
|
case "esc", "q":
|
||||||
// Cancel delete with ESC or Q
|
// Cancel delete with ESC or Q
|
||||||
m.status = "Cancelled"
|
m.status = "Cancelled"
|
||||||
m.deleteConfirm = false
|
m.deleteConfirm = false
|
||||||
m.deleteTarget = nil
|
m.deleteTarget = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
} else {
|
default:
|
||||||
// Any other key also cancels
|
// Ignore other keys - keep showing confirmation
|
||||||
m.status = "Cancelled"
|
|
||||||
m.deleteConfirm = false
|
|
||||||
m.deleteTarget = nil
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,10 +87,10 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
|||||||
atomic.AddInt64(&total, size)
|
atomic.AddInt64(&total, size)
|
||||||
|
|
||||||
entryChan <- dirEntry{
|
entryChan <- dirEntry{
|
||||||
Name: child.Name() + " →", // Add arrow to indicate symlink
|
Name: child.Name() + " →", // Add arrow to indicate symlink
|
||||||
Path: fullPath,
|
Path: fullPath,
|
||||||
Size: size,
|
Size: size,
|
||||||
IsDir: false, // Don't allow navigation into symlinks
|
IsDir: false, // Don't allow navigation into symlinks
|
||||||
LastAccess: getLastAccessTimeFromInfo(info),
|
LastAccess: getLastAccessTimeFromInfo(info),
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -189,10 +189,14 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
|||||||
entries = entries[:maxEntries]
|
entries = entries[:maxEntries]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to use Spotlight for faster large file discovery
|
// Try to use Spotlight (mdfind) for faster large file discovery
|
||||||
|
// This is a performance optimization that gracefully falls back to scan results
|
||||||
|
// if Spotlight is unavailable or fails. The fallback is intentionally silent
|
||||||
|
// because users only care about correct results, not the method used.
|
||||||
if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 {
|
if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 {
|
||||||
largeFiles = spotlightFiles
|
largeFiles = spotlightFiles
|
||||||
} else {
|
} else {
|
||||||
|
// Use files collected during scanning (fallback path)
|
||||||
// Sort and trim large files collected from scanning
|
// Sort and trim large files collected from scanning
|
||||||
sort.Slice(largeFiles, func(i, j int) bool {
|
sort.Slice(largeFiles, func(i, j int) bool {
|
||||||
return largeFiles[i].Size > largeFiles[j].Size
|
return largeFiles[i].Size > largeFiles[j].Size
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
222
cmd/status/metrics_battery.go
Normal file
222
cmd/status/metrics_battery.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v3/host"
|
||||||
|
)
|
||||||
|
|
||||||
|
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.
|
||||||
|
err = fmt.Errorf("battery collection failed: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// macOS: pmset
|
||||||
|
if runtime.GOOS == "darwin" && commandExists("pmset") {
|
||||||
|
if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil {
|
||||||
|
if batts := parsePMSet(out); len(batts) > 0 {
|
||||||
|
return batts, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
capData, err := os.ReadFile(capFile)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
statusData, _ := os.ReadFile(statusFile)
|
||||||
|
percentStr := strings.TrimSpace(string(capData))
|
||||||
|
percent, _ := strconv.ParseFloat(percentStr, 64)
|
||||||
|
status := strings.TrimSpace(string(statusData))
|
||||||
|
if status == "" {
|
||||||
|
status = "Unknown"
|
||||||
|
}
|
||||||
|
batts = append(batts, BatteryStatus{
|
||||||
|
Percent: percent,
|
||||||
|
Status: status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(batts) > 0 {
|
||||||
|
return batts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("no battery data found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePMSet(raw string) []BatteryStatus {
|
||||||
|
lines := strings.Split(raw, "\n")
|
||||||
|
var out []BatteryStatus
|
||||||
|
var timeLeft string
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
// Check for 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 {
|
||||||
|
timeLeft = parts[i-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(line, "%") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
var (
|
||||||
|
percent float64
|
||||||
|
found bool
|
||||||
|
status = "Unknown"
|
||||||
|
)
|
||||||
|
for i, f := range fields {
|
||||||
|
if strings.Contains(f, "%") {
|
||||||
|
value := strings.TrimSuffix(strings.TrimSuffix(f, ";"), "%")
|
||||||
|
if p, err := strconv.ParseFloat(value, 64); err == nil {
|
||||||
|
percent = p
|
||||||
|
found = true
|
||||||
|
if i+1 < len(fields) {
|
||||||
|
status = strings.TrimSuffix(fields[i+1], ";")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get battery health and cycle count
|
||||||
|
health, cycles := getBatteryHealth()
|
||||||
|
|
||||||
|
out = append(out, BatteryStatus{
|
||||||
|
Percent: percent,
|
||||||
|
Status: status,
|
||||||
|
TimeLeft: timeLeft,
|
||||||
|
Health: health,
|
||||||
|
CycleCount: cycles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBatteryHealth() (string, int) {
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := runCmd(ctx, "system_profiler", "SPPowerDataType")
|
||||||
|
if err != nil {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var health string
|
||||||
|
var cycles int
|
||||||
|
|
||||||
|
lines := strings.Split(out, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
lower := strings.ToLower(line)
|
||||||
|
if strings.Contains(lower, "cycle count") {
|
||||||
|
parts := strings.Split(line, ":")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
cycles, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(lower, "condition") {
|
||||||
|
parts := strings.Split(line, ":")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
health = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return health, cycles
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectThermal() ThermalStatus {
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
return ThermalStatus{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var thermal ThermalStatus
|
||||||
|
|
||||||
|
// Get fan info from system_profiler
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := runCmd(ctx, "system_profiler", "SPPowerDataType")
|
||||||
|
if err == nil {
|
||||||
|
lines := strings.Split(out, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
lower := strings.ToLower(line)
|
||||||
|
if strings.Contains(lower, "fan") && strings.Contains(lower, "speed") {
|
||||||
|
parts := strings.Split(line, ":")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
// Extract number from string like "1200 RPM"
|
||||||
|
numStr := strings.TrimSpace(parts[1])
|
||||||
|
numStr = strings.Split(numStr, " ")[0]
|
||||||
|
thermal.FanSpeed, _ = strconv.Atoi(numStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get CPU temperature using sudo powermetrics (may not work without sudo)
|
||||||
|
// Fallback: use SMC reader or estimate from thermal pressure
|
||||||
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||||
|
defer cancel2()
|
||||||
|
|
||||||
|
// Try thermal level as a proxy
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return thermal
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectSensors() ([]SensorReading, error) {
|
||||||
|
temps, err := host.SensorsTemperatures()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []SensorReading
|
||||||
|
for _, t := range temps {
|
||||||
|
if t.Temperature <= 0 || t.Temperature > 150 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, SensorReading{
|
||||||
|
Label: prettifyLabel(t.SensorKey),
|
||||||
|
Value: t.Temperature,
|
||||||
|
Unit: "°C",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prettifyLabel(key string) string {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
key = strings.TrimPrefix(key, "TC")
|
||||||
|
key = strings.ReplaceAll(key, "_", " ")
|
||||||
|
return key
|
||||||
|
}
|
||||||
140
cmd/status/metrics_bluetooth.go
Normal file
140
cmd/status/metrics_bluetooth.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
bluetoothCacheTTL = 30 * time.Second
|
||||||
|
bluetoothctlTimeout = 1500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Collector) collectBluetooth(now time.Time) []BluetoothDevice {
|
||||||
|
if len(c.lastBT) > 0 && !c.lastBTAt.IsZero() && now.Sub(c.lastBTAt) < bluetoothCacheTTL {
|
||||||
|
return c.lastBT
|
||||||
|
}
|
||||||
|
|
||||||
|
if devs, err := readSystemProfilerBluetooth(); err == nil && len(devs) > 0 {
|
||||||
|
c.lastBTAt = now
|
||||||
|
c.lastBT = devs
|
||||||
|
return devs
|
||||||
|
}
|
||||||
|
|
||||||
|
if devs, err := readBluetoothCTLDevices(); err == nil && len(devs) > 0 {
|
||||||
|
c.lastBTAt = now
|
||||||
|
c.lastBT = devs
|
||||||
|
return devs
|
||||||
|
}
|
||||||
|
|
||||||
|
c.lastBTAt = now
|
||||||
|
if len(c.lastBT) == 0 {
|
||||||
|
c.lastBT = []BluetoothDevice{{Name: "No Bluetooth info", Connected: false}}
|
||||||
|
}
|
||||||
|
return c.lastBT
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSystemProfilerBluetooth() ([]BluetoothDevice, error) {
|
||||||
|
if runtime.GOOS != "darwin" || !commandExists("system_profiler") {
|
||||||
|
return nil, errors.New("system_profiler unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), systemProfilerTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := runCmd(ctx, "system_profiler", "SPBluetoothDataType")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseSPBluetooth(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBluetoothCTLDevices() ([]BluetoothDevice, error) {
|
||||||
|
if !commandExists("bluetoothctl") {
|
||||||
|
return nil, errors.New("bluetoothctl unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), bluetoothctlTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := runCmd(ctx, "bluetoothctl", "info")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseBluetoothctl(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSPBluetooth(raw string) []BluetoothDevice {
|
||||||
|
lines := strings.Split(raw, "\n")
|
||||||
|
var devices []BluetoothDevice
|
||||||
|
var currentName string
|
||||||
|
var connected bool
|
||||||
|
var battery string
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
trim := strings.TrimSpace(line)
|
||||||
|
if len(trim) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") {
|
||||||
|
// Reset at top-level sections
|
||||||
|
currentName = ""
|
||||||
|
connected = false
|
||||||
|
battery = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") {
|
||||||
|
if currentName != "" {
|
||||||
|
devices = append(devices, BluetoothDevice{Name: currentName, Connected: connected, Battery: battery})
|
||||||
|
}
|
||||||
|
currentName = strings.TrimSuffix(trim, ":")
|
||||||
|
connected = false
|
||||||
|
battery = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(trim, "Connected:") {
|
||||||
|
connected = strings.Contains(trim, "Yes")
|
||||||
|
}
|
||||||
|
if strings.Contains(trim, "Battery Level:") {
|
||||||
|
battery = strings.TrimSpace(strings.TrimPrefix(trim, "Battery Level:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if currentName != "" {
|
||||||
|
devices = append(devices, BluetoothDevice{Name: currentName, Connected: connected, Battery: battery})
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return []BluetoothDevice{{Name: "No devices", Connected: false}}
|
||||||
|
}
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBluetoothctl(raw string) []BluetoothDevice {
|
||||||
|
lines := strings.Split(raw, "\n")
|
||||||
|
var devices []BluetoothDevice
|
||||||
|
current := BluetoothDevice{}
|
||||||
|
for _, line := range lines {
|
||||||
|
trim := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trim, "Device ") {
|
||||||
|
if current.Name != "" {
|
||||||
|
devices = append(devices, current)
|
||||||
|
}
|
||||||
|
current = BluetoothDevice{Name: strings.TrimPrefix(trim, "Device "), Connected: false}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trim, "Name:") {
|
||||||
|
current.Name = strings.TrimSpace(strings.TrimPrefix(trim, "Name:"))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trim, "Connected:") {
|
||||||
|
current.Connected = strings.Contains(trim, "yes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current.Name != "" {
|
||||||
|
devices = append(devices, current)
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return []BluetoothDevice{{Name: "No devices", Connected: false}}
|
||||||
|
}
|
||||||
|
return devices
|
||||||
|
}
|
||||||
186
cmd/status/metrics_cpu.go
Normal file
186
cmd/status/metrics_cpu.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v3/cpu"
|
||||||
|
"github.com/shirou/gopsutil/v3/load"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cpuSampleInterval = 200 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
func collectCPU() (CPUStatus, error) {
|
||||||
|
counts, countsErr := cpu.Counts(false)
|
||||||
|
if countsErr != nil || counts == 0 {
|
||||||
|
counts = runtime.NumCPU()
|
||||||
|
}
|
||||||
|
|
||||||
|
logical, logicalErr := cpu.Counts(true)
|
||||||
|
if logicalErr != nil || logical == 0 {
|
||||||
|
logical = runtime.NumCPU()
|
||||||
|
}
|
||||||
|
if logical <= 0 {
|
||||||
|
logical = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
percents, err := cpu.Percent(cpuSampleInterval, true)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return CPUStatus{}, fallbackErr
|
||||||
|
}
|
||||||
|
totalPercent = fallbackUsage
|
||||||
|
percents = fallbackPerCore
|
||||||
|
perCoreEstimated = true
|
||||||
|
} else {
|
||||||
|
for _, v := range percents {
|
||||||
|
totalPercent += v
|
||||||
|
}
|
||||||
|
totalPercent /= float64(len(percents))
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStats, loadErr := load.Avg()
|
||||||
|
var loadAvg load.AvgStat
|
||||||
|
if loadStats != nil {
|
||||||
|
loadAvg = *loadStats
|
||||||
|
}
|
||||||
|
if loadErr != nil || isZeroLoad(loadAvg) {
|
||||||
|
if fallback, err := fallbackLoadAvgFromUptime(); err == nil {
|
||||||
|
loadAvg = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CPUStatus{
|
||||||
|
Usage: totalPercent,
|
||||||
|
PerCore: percents,
|
||||||
|
PerCoreEstimated: perCoreEstimated,
|
||||||
|
Load1: loadAvg.Load1,
|
||||||
|
Load5: loadAvg.Load5,
|
||||||
|
Load15: loadAvg.Load15,
|
||||||
|
CoreCount: counts,
|
||||||
|
LogicalCPU: logical,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isZeroLoad(avg load.AvgStat) bool {
|
||||||
|
return avg.Load1 == 0 && avg.Load5 == 0 && avg.Load15 == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackLoadAvgFromUptime() (load.AvgStat, error) {
|
||||||
|
if !commandExists("uptime") {
|
||||||
|
return load.AvgStat{}, errors.New("uptime command unavailable")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := runCmd(ctx, "uptime")
|
||||||
|
if err != nil {
|
||||||
|
return load.AvgStat{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
markers := []string{"load averages:", "load average:"}
|
||||||
|
idx := -1
|
||||||
|
for _, marker := range markers {
|
||||||
|
if pos := strings.LastIndex(out, marker); pos != -1 {
|
||||||
|
idx = pos + len(marker)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx == -1 {
|
||||||
|
return load.AvgStat{}, errors.New("load averages not found in uptime output")
|
||||||
|
}
|
||||||
|
|
||||||
|
segment := strings.TrimSpace(out[idx:])
|
||||||
|
fields := strings.Fields(segment)
|
||||||
|
var values []float64
|
||||||
|
for _, field := range fields {
|
||||||
|
field = strings.Trim(field, ",;")
|
||||||
|
if field == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val, err := strconv.ParseFloat(field, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values = append(values, val)
|
||||||
|
if len(values) == 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) < 3 {
|
||||||
|
return load.AvgStat{}, errors.New("could not parse load averages from uptime output")
|
||||||
|
}
|
||||||
|
|
||||||
|
return load.AvgStat{
|
||||||
|
Load1: values[0],
|
||||||
|
Load5: values[1],
|
||||||
|
Load15: values[2],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackCPUUtilization(logical int) (float64, []float64, error) {
|
||||||
|
if logical <= 0 {
|
||||||
|
logical = runtime.NumCPU()
|
||||||
|
}
|
||||||
|
if logical <= 0 {
|
||||||
|
logical = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := runCmd(ctx, "ps", "-Aceo", "pcpu")
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||||
|
total := 0.0
|
||||||
|
lineIndex := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lineIndex++
|
||||||
|
if lineIndex == 1 && (strings.Contains(strings.ToLower(line), "cpu") || strings.Contains(line, "%")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val, parseErr := strconv.ParseFloat(line, 64)
|
||||||
|
if parseErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += val
|
||||||
|
}
|
||||||
|
if scanErr := scanner.Err(); scanErr != nil {
|
||||||
|
return 0, nil, scanErr
|
||||||
|
}
|
||||||
|
|
||||||
|
maxTotal := float64(logical * 100)
|
||||||
|
if total < 0 {
|
||||||
|
total = 0
|
||||||
|
} else if total > maxTotal {
|
||||||
|
total = maxTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
perCore := make([]float64, logical)
|
||||||
|
avg := total / float64(logical)
|
||||||
|
for i := range perCore {
|
||||||
|
perCore[i] = avg
|
||||||
|
}
|
||||||
|
return total, perCore, nil
|
||||||
|
}
|
||||||
199
cmd/status/metrics_disk.go
Normal file
199
cmd/status/metrics_disk.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v3/disk"
|
||||||
|
)
|
||||||
|
|
||||||
|
var skipDiskMounts = map[string]bool{
|
||||||
|
"/System/Volumes/VM": true,
|
||||||
|
"/System/Volumes/Preboot": true,
|
||||||
|
"/System/Volumes/Update": true,
|
||||||
|
"/System/Volumes/xarts": true,
|
||||||
|
"/System/Volumes/Hardware": true,
|
||||||
|
"/System/Volumes/Data": true,
|
||||||
|
"/dev": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectDisks() ([]DiskStatus, error) {
|
||||||
|
partitions, err := disk.Partitions(false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
disks []DiskStatus
|
||||||
|
seenDevice = make(map[string]bool)
|
||||||
|
seenVolume = make(map[string]bool)
|
||||||
|
)
|
||||||
|
for _, part := range partitions {
|
||||||
|
if strings.HasPrefix(part.Device, "/dev/loop") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if skipDiskMounts[part.Mountpoint] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip private volumes
|
||||||
|
if strings.HasPrefix(part.Mountpoint, "/private/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
baseDevice := baseDeviceName(part.Device)
|
||||||
|
if baseDevice == "" {
|
||||||
|
baseDevice = part.Device
|
||||||
|
}
|
||||||
|
if seenDevice[baseDevice] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
usage, err := disk.Usage(part.Mountpoint)
|
||||||
|
if err != nil || usage.Total == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip small volumes (< 1GB)
|
||||||
|
if usage.Total < 1<<30 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// For APFS volumes, use a more precise dedup key (bytes level)
|
||||||
|
// to handle shared storage pools properly
|
||||||
|
volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total)
|
||||||
|
if seenVolume[volKey] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
disks = append(disks, DiskStatus{
|
||||||
|
Mount: part.Mountpoint,
|
||||||
|
Device: part.Device,
|
||||||
|
Used: usage.Used,
|
||||||
|
Total: usage.Total,
|
||||||
|
UsedPercent: usage.UsedPercent,
|
||||||
|
Fstype: part.Fstype,
|
||||||
|
})
|
||||||
|
seenDevice[baseDevice] = true
|
||||||
|
seenVolume[volKey] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
annotateDiskTypes(disks)
|
||||||
|
|
||||||
|
sort.Slice(disks, func(i, j int) bool {
|
||||||
|
return disks[i].Total > disks[j].Total
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(disks) > 3 {
|
||||||
|
disks = disks[:3]
|
||||||
|
}
|
||||||
|
|
||||||
|
return disks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func annotateDiskTypes(disks []DiskStatus) {
|
||||||
|
if len(disks) == 0 || runtime.GOOS != "darwin" || !commandExists("diskutil") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cache := make(map[string]bool)
|
||||||
|
for i := range disks {
|
||||||
|
base := baseDeviceName(disks[i].Device)
|
||||||
|
if base == "" {
|
||||||
|
base = disks[i].Device
|
||||||
|
}
|
||||||
|
if val, ok := cache[base]; ok {
|
||||||
|
disks[i].External = val
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
external, err := isExternalDisk(base)
|
||||||
|
if err != nil {
|
||||||
|
external = strings.HasPrefix(disks[i].Mount, "/Volumes/")
|
||||||
|
}
|
||||||
|
disks[i].External = external
|
||||||
|
cache[base] = external
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseDeviceName(device string) string {
|
||||||
|
device = strings.TrimPrefix(device, "/dev/")
|
||||||
|
if !strings.HasPrefix(device, "disk") {
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
for i := 4; i < len(device); i++ {
|
||||||
|
if device[i] == 's' {
|
||||||
|
return device[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExternalDisk(device string) (bool, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := runCmd(ctx, "diskutil", "info", device)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
found bool
|
||||||
|
external bool
|
||||||
|
)
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
trim := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trim, "Internal:") {
|
||||||
|
found = true
|
||||||
|
external = strings.Contains(trim, "No")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trim, "Device Location:") {
|
||||||
|
found = true
|
||||||
|
external = strings.Contains(trim, "External")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false, errors.New("diskutil info missing Internal field")
|
||||||
|
}
|
||||||
|
return external, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus {
|
||||||
|
counters, err := disk.IOCounters()
|
||||||
|
if err != nil || len(counters) == 0 {
|
||||||
|
return DiskIOStatus{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var total disk.IOCountersStat
|
||||||
|
for _, v := range counters {
|
||||||
|
total.ReadBytes += v.ReadBytes
|
||||||
|
total.WriteBytes += v.WriteBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.lastDiskAt.IsZero() {
|
||||||
|
c.prevDiskIO = total
|
||||||
|
c.lastDiskAt = now
|
||||||
|
return DiskIOStatus{}
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := now.Sub(c.lastDiskAt).Seconds()
|
||||||
|
if elapsed <= 0 {
|
||||||
|
elapsed = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
readRate := float64(total.ReadBytes-c.prevDiskIO.ReadBytes) / 1024 / 1024 / elapsed
|
||||||
|
writeRate := float64(total.WriteBytes-c.prevDiskIO.WriteBytes) / 1024 / 1024 / elapsed
|
||||||
|
|
||||||
|
c.prevDiskIO = total
|
||||||
|
c.lastDiskAt = now
|
||||||
|
|
||||||
|
if readRate < 0 {
|
||||||
|
readRate = 0
|
||||||
|
}
|
||||||
|
if writeRate < 0 {
|
||||||
|
writeRate = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return DiskIOStatus{ReadRate: readRate, WriteRate: writeRate}
|
||||||
|
}
|
||||||
131
cmd/status/metrics_gpu.go
Normal file
131
cmd/status/metrics_gpu.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
systemProfilerTimeout = 4 * time.Second
|
||||||
|
macGPUInfoTTL = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) {
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
if len(c.cachedGPU) > 0 && !c.lastGPUAt.IsZero() && now.Sub(c.lastGPUAt) < macGPUInfoTTL {
|
||||||
|
return c.cachedGPU, nil
|
||||||
|
}
|
||||||
|
if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 {
|
||||||
|
c.cachedGPU = gpus
|
||||||
|
c.lastGPUAt = now
|
||||||
|
return gpus, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if !commandExists("nvidia-smi") {
|
||||||
|
return []GPUStatus{{
|
||||||
|
Name: "No GPU metrics available",
|
||||||
|
Note: "Install nvidia-smi or use platform-specific metrics",
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := runCmd(ctx, "nvidia-smi", "--query-gpu=utilization.gpu,memory.used,memory.total,name", "--format=csv,noheader,nounits")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||||
|
var gpus []GPUStatus
|
||||||
|
for _, line := range lines {
|
||||||
|
fields := strings.Split(line, ",")
|
||||||
|
if len(fields) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
util, _ := strconv.ParseFloat(strings.TrimSpace(fields[0]), 64)
|
||||||
|
memUsed, _ := strconv.ParseFloat(strings.TrimSpace(fields[1]), 64)
|
||||||
|
memTotal, _ := strconv.ParseFloat(strings.TrimSpace(fields[2]), 64)
|
||||||
|
name := strings.TrimSpace(fields[3])
|
||||||
|
|
||||||
|
gpus = append(gpus, GPUStatus{
|
||||||
|
Name: name,
|
||||||
|
Usage: util,
|
||||||
|
MemoryUsed: memUsed,
|
||||||
|
MemoryTotal: memTotal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gpus) == 0 {
|
||||||
|
return []GPUStatus{{
|
||||||
|
Name: "GPU read failed",
|
||||||
|
Note: "Verify nvidia-smi availability",
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return gpus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMacGPUInfo() ([]GPUStatus, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), systemProfilerTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if !commandExists("system_profiler") {
|
||||||
|
return nil, errors.New("system_profiler unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := runCmd(ctx, "system_profiler", "-json", "SPDisplaysDataType")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
Displays []struct {
|
||||||
|
Name string `json:"_name"`
|
||||||
|
VRAM string `json:"spdisplays_vram"`
|
||||||
|
Vendor string `json:"spdisplays_vendor"`
|
||||||
|
Metal string `json:"spdisplays_metal"`
|
||||||
|
} `json:"SPDisplaysDataType"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(out), &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var gpus []GPUStatus
|
||||||
|
for _, d := range data.Displays {
|
||||||
|
if d.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
noteParts := []string{}
|
||||||
|
if d.VRAM != "" {
|
||||||
|
noteParts = append(noteParts, "VRAM "+d.VRAM)
|
||||||
|
}
|
||||||
|
if d.Metal != "" {
|
||||||
|
noteParts = append(noteParts, d.Metal)
|
||||||
|
}
|
||||||
|
if d.Vendor != "" {
|
||||||
|
noteParts = append(noteParts, d.Vendor)
|
||||||
|
}
|
||||||
|
note := strings.Join(noteParts, " · ")
|
||||||
|
gpus = append(gpus, GPUStatus{
|
||||||
|
Name: d.Name,
|
||||||
|
Usage: -1,
|
||||||
|
Note: note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gpus) == 0 {
|
||||||
|
return []GPUStatus{{
|
||||||
|
Name: "GPU info unavailable",
|
||||||
|
Note: "Unable to parse system_profiler output",
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return gpus, nil
|
||||||
|
}
|
||||||
76
cmd/status/metrics_hardware.go
Normal file
76
cmd/status/metrics_hardware.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
return HardwareInfo{
|
||||||
|
Model: "Unknown",
|
||||||
|
CPUModel: runtime.GOARCH,
|
||||||
|
TotalRAM: humanBytes(totalRAM),
|
||||||
|
DiskSize: "Unknown",
|
||||||
|
OSVersion: runtime.GOOS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 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"
|
||||||
|
if strings.Contains(lower, "model name:") {
|
||||||
|
parts := strings.Split(line, ":")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
model = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(lower, "chip:") {
|
||||||
|
parts := strings.Split(line, ":")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
cpuModel = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(lower, "processor name:") && cpuModel == "" {
|
||||||
|
parts := strings.Split(line, ":")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
cpuModel = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get macOS version
|
||||||
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
|
defer cancel2()
|
||||||
|
out2, err := runCmd(ctx2, "sw_vers", "-productVersion")
|
||||||
|
if err == nil {
|
||||||
|
osVersion = "macOS " + strings.TrimSpace(out2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get disk size
|
||||||
|
diskSize := "Unknown"
|
||||||
|
if len(disks) > 0 {
|
||||||
|
diskSize = humanBytes(disks[0].Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
return HardwareInfo{
|
||||||
|
Model: model,
|
||||||
|
CPUModel: cpuModel,
|
||||||
|
TotalRAM: humanBytes(totalRAM),
|
||||||
|
DiskSize: diskSize,
|
||||||
|
OSVersion: osVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
168
cmd/status/metrics_health.go
Normal file
168
cmd/status/metrics_health.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Health score calculation weights and thresholds
|
||||||
|
const (
|
||||||
|
// Weights (must sum to ~100 for total score)
|
||||||
|
healthCPUWeight = 30.0
|
||||||
|
healthMemWeight = 25.0
|
||||||
|
healthDiskWeight = 20.0
|
||||||
|
healthThermalWeight = 15.0
|
||||||
|
healthIOWeight = 10.0
|
||||||
|
|
||||||
|
// CPU thresholds
|
||||||
|
cpuNormalThreshold = 30.0
|
||||||
|
cpuHighThreshold = 70.0
|
||||||
|
|
||||||
|
// Memory thresholds
|
||||||
|
memNormalThreshold = 50.0
|
||||||
|
memHighThreshold = 80.0
|
||||||
|
memPressureWarnPenalty = 5.0
|
||||||
|
memPressureCritPenalty = 15.0
|
||||||
|
|
||||||
|
// Disk thresholds
|
||||||
|
diskWarnThreshold = 70.0
|
||||||
|
diskCritThreshold = 90.0
|
||||||
|
|
||||||
|
// Thermal thresholds
|
||||||
|
thermalNormalThreshold = 60.0
|
||||||
|
thermalHighThreshold = 85.0
|
||||||
|
|
||||||
|
// Disk IO thresholds (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
|
||||||
|
cpuPenalty := 0.0
|
||||||
|
if cpu.Usage > cpuNormalThreshold {
|
||||||
|
if cpu.Usage > cpuHighThreshold {
|
||||||
|
cpuPenalty = healthCPUWeight * (cpu.Usage - cpuNormalThreshold) / cpuHighThreshold
|
||||||
|
} else {
|
||||||
|
cpuPenalty = (healthCPUWeight / 2) * (cpu.Usage - cpuNormalThreshold) / (cpuHighThreshold - cpuNormalThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score -= cpuPenalty
|
||||||
|
if cpu.Usage > cpuHighThreshold {
|
||||||
|
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
|
||||||
|
memPenalty := 0.0
|
||||||
|
if mem.UsedPercent > memNormalThreshold {
|
||||||
|
if mem.UsedPercent > memHighThreshold {
|
||||||
|
memPenalty = healthMemWeight * (mem.UsedPercent - memNormalThreshold) / memNormalThreshold
|
||||||
|
} else {
|
||||||
|
memPenalty = (healthMemWeight / 2) * (mem.UsedPercent - memNormalThreshold) / (memHighThreshold - memNormalThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score -= memPenalty
|
||||||
|
if mem.UsedPercent > memHighThreshold {
|
||||||
|
issues = append(issues, "High Memory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory Pressure (extra penalty)
|
||||||
|
if mem.Pressure == "warn" {
|
||||||
|
score -= memPressureWarnPenalty
|
||||||
|
issues = append(issues, "Memory Pressure")
|
||||||
|
} else if mem.Pressure == "critical" {
|
||||||
|
score -= memPressureCritPenalty
|
||||||
|
issues = append(issues, "Critical Memory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk Usage (20% weight) - deduct up to 20 points
|
||||||
|
diskPenalty := 0.0
|
||||||
|
if len(disks) > 0 {
|
||||||
|
diskUsage := disks[0].UsedPercent
|
||||||
|
if diskUsage > diskWarnThreshold {
|
||||||
|
if diskUsage > diskCritThreshold {
|
||||||
|
diskPenalty = healthDiskWeight * (diskUsage - diskWarnThreshold) / (100 - diskWarnThreshold)
|
||||||
|
} else {
|
||||||
|
diskPenalty = (healthDiskWeight / 2) * (diskUsage - diskWarnThreshold) / (diskCritThreshold - diskWarnThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score -= diskPenalty
|
||||||
|
if diskUsage > diskCritThreshold {
|
||||||
|
issues = append(issues, "Disk Almost Full")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thermal (15% weight) - deduct up to 15 points
|
||||||
|
thermalPenalty := 0.0
|
||||||
|
if thermal.CPUTemp > 0 {
|
||||||
|
if thermal.CPUTemp > thermalNormalThreshold {
|
||||||
|
if thermal.CPUTemp > thermalHighThreshold {
|
||||||
|
thermalPenalty = healthThermalWeight
|
||||||
|
issues = append(issues, "Overheating")
|
||||||
|
} else {
|
||||||
|
thermalPenalty = healthThermalWeight * (thermal.CPUTemp - thermalNormalThreshold) / (thermalHighThreshold - thermalNormalThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score -= thermalPenalty
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk IO (10% weight) - deduct up to 10 points
|
||||||
|
ioPenalty := 0.0
|
||||||
|
totalIO := diskIO.ReadRate + diskIO.WriteRate
|
||||||
|
if totalIO > ioNormalThreshold {
|
||||||
|
if totalIO > ioHighThreshold {
|
||||||
|
ioPenalty = healthIOWeight
|
||||||
|
issues = append(issues, "Heavy Disk IO")
|
||||||
|
} else {
|
||||||
|
ioPenalty = healthIOWeight * (totalIO - ioNormalThreshold) / (ioHighThreshold - ioNormalThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score -= ioPenalty
|
||||||
|
|
||||||
|
// Ensure score is in valid range
|
||||||
|
if score < 0 {
|
||||||
|
score = 0
|
||||||
|
}
|
||||||
|
if score > 100 {
|
||||||
|
score = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate message
|
||||||
|
msg := "Excellent"
|
||||||
|
if score >= 90 {
|
||||||
|
msg = "Excellent"
|
||||||
|
} else if score >= 75 {
|
||||||
|
msg = "Good"
|
||||||
|
} else if score >= 60 {
|
||||||
|
msg = "Fair"
|
||||||
|
} else if score >= 40 {
|
||||||
|
msg = "Poor"
|
||||||
|
} else {
|
||||||
|
msg = "Critical"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) > 0 {
|
||||||
|
msg = msg + ": " + strings.Join(issues, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(score), msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatUptime(secs uint64) string {
|
||||||
|
days := secs / 86400
|
||||||
|
hours := (secs % 86400) / 3600
|
||||||
|
mins := (secs % 3600) / 60
|
||||||
|
if days > 0 {
|
||||||
|
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
|
||||||
|
}
|
||||||
|
if hours > 0 {
|
||||||
|
return fmt.Sprintf("%dh %dm", hours, mins)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dm", mins)
|
||||||
|
}
|
||||||
52
cmd/status/metrics_memory.go
Normal file
52
cmd/status/metrics_memory.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v3/mem"
|
||||||
|
)
|
||||||
|
|
||||||
|
func collectMemory() (MemoryStatus, error) {
|
||||||
|
vm, err := mem.VirtualMemory()
|
||||||
|
if err != nil {
|
||||||
|
return MemoryStatus{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
swap, _ := mem.SwapMemory()
|
||||||
|
pressure := getMemoryPressure()
|
||||||
|
|
||||||
|
return MemoryStatus{
|
||||||
|
Used: vm.Used,
|
||||||
|
Total: vm.Total,
|
||||||
|
UsedPercent: vm.UsedPercent,
|
||||||
|
SwapUsed: swap.Used,
|
||||||
|
SwapTotal: swap.Total,
|
||||||
|
Pressure: pressure,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMemoryPressure() string {
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
out, err := runCmd(ctx, "memory_pressure")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(out)
|
||||||
|
if strings.Contains(lower, "critical") {
|
||||||
|
return "critical"
|
||||||
|
}
|
||||||
|
if strings.Contains(lower, "warn") {
|
||||||
|
return "warn"
|
||||||
|
}
|
||||||
|
if strings.Contains(lower, "normal") {
|
||||||
|
return "normal"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
142
cmd/status/metrics_network.go
Normal file
142
cmd/status/metrics_network.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v3/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) {
|
||||||
|
stats, err := net.IOCounters(true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get IP addresses for interfaces
|
||||||
|
ifAddrs := getInterfaceIPs()
|
||||||
|
|
||||||
|
if c.lastNetAt.IsZero() {
|
||||||
|
c.lastNetAt = now
|
||||||
|
for _, s := range stats {
|
||||||
|
c.prevNet[s.Name] = s
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := now.Sub(c.lastNetAt).Seconds()
|
||||||
|
if elapsed <= 0 {
|
||||||
|
elapsed = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []NetworkStatus
|
||||||
|
for _, cur := range stats {
|
||||||
|
if isNoiseInterface(cur.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prev, ok := c.prevNet[cur.Name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rx := float64(cur.BytesRecv-prev.BytesRecv) / 1024.0 / 1024.0 / elapsed
|
||||||
|
tx := float64(cur.BytesSent-prev.BytesSent) / 1024.0 / 1024.0 / elapsed
|
||||||
|
if rx < 0 {
|
||||||
|
rx = 0
|
||||||
|
}
|
||||||
|
if tx < 0 {
|
||||||
|
tx = 0
|
||||||
|
}
|
||||||
|
result = append(result, NetworkStatus{
|
||||||
|
Name: cur.Name,
|
||||||
|
RxRateMBs: rx,
|
||||||
|
TxRateMBs: tx,
|
||||||
|
IP: ifAddrs[cur.Name],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.lastNetAt = now
|
||||||
|
for _, s := range stats {
|
||||||
|
c.prevNet[s.Name] = s
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
return result[i].RxRateMBs+result[i].TxRateMBs > result[j].RxRateMBs+result[j].TxRateMBs
|
||||||
|
})
|
||||||
|
if len(result) > 3 {
|
||||||
|
result = result[:3]
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInterfaceIPs() map[string]string {
|
||||||
|
result := make(map[string]string)
|
||||||
|
ifaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
for _, addr := range iface.Addrs {
|
||||||
|
// Only IPv4
|
||||||
|
if strings.Contains(addr.Addr, ".") && !strings.HasPrefix(addr.Addr, "127.") {
|
||||||
|
ip := strings.Split(addr.Addr, "/")[0]
|
||||||
|
result[iface.Name] = ip
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNoiseInterface(name string) bool {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
noiseList := []string{"lo", "awdl", "utun", "llw", "bridge", "gif", "stf", "xhc", "anpi", "ap"}
|
||||||
|
for _, prefix := range noiseList {
|
||||||
|
if strings.HasPrefix(lower, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectProxy() ProxyStatus {
|
||||||
|
// 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
|
||||||
|
host := val
|
||||||
|
if strings.Contains(host, "://") {
|
||||||
|
host = strings.SplitN(host, "://", 2)[1]
|
||||||
|
}
|
||||||
|
if idx := strings.Index(host, "@"); idx >= 0 {
|
||||||
|
host = host[idx+1:]
|
||||||
|
}
|
||||||
|
return ProxyStatus{Enabled: true, Type: proxyType, Host: host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS: check system proxy via scutil
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
out, err := runCmd(ctx, "scutil", "--proxy")
|
||||||
|
if err == nil {
|
||||||
|
if strings.Contains(out, "HTTPEnable : 1") || strings.Contains(out, "HTTPSEnable : 1") {
|
||||||
|
return ProxyStatus{Enabled: true, Type: "System", Host: "System Proxy"}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "SOCKSEnable : 1") {
|
||||||
|
return ProxyStatus{Enabled: true, Type: "SOCKS", Host: "System Proxy"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProxyStatus{Enabled: false}
|
||||||
|
}
|
||||||
51
cmd/status/metrics_process.go
Normal file
51
cmd/status/metrics_process.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func collectTopProcesses() []ProcessInfo {
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Use ps to get top processes by CPU
|
||||||
|
out, err := runCmd(ctx, "ps", "-Aceo", "pcpu,pmem,comm", "-r")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||||
|
var procs []ProcessInfo
|
||||||
|
for i, line := range lines {
|
||||||
|
if i == 0 { // skip header
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i > 5 { // top 5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cpuVal, _ := strconv.ParseFloat(fields[0], 64)
|
||||||
|
memVal, _ := strconv.ParseFloat(fields[1], 64)
|
||||||
|
name := fields[len(fields)-1]
|
||||||
|
// Get just the process name without path
|
||||||
|
if idx := strings.LastIndex(name, "/"); idx >= 0 {
|
||||||
|
name = name[idx+1:]
|
||||||
|
}
|
||||||
|
procs = append(procs, ProcessInfo{
|
||||||
|
Name: name,
|
||||||
|
CPU: cpuVal,
|
||||||
|
Memory: memVal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return procs
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user