mirror of
https://github.com/tw93/Mole.git
synced 2026-02-07 20:19:21 +00:00
chore: restructure windows branch (move windows/ content to root, remove macos files)
This commit is contained in:
@@ -1,200 +1,674 @@
|
||||
// Package main provides the mo status command for real-time system monitoring.
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
"github.com/shirou/gopsutil/v3/net"
|
||||
"github.com/shirou/gopsutil/v3/process"
|
||||
)
|
||||
|
||||
const refreshInterval = time.Second
|
||||
|
||||
// Styles
|
||||
var (
|
||||
Version = "dev"
|
||||
BuildTime = ""
|
||||
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true)
|
||||
headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEEB")).Bold(true)
|
||||
labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
|
||||
valueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
|
||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7"))
|
||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F"))
|
||||
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true)
|
||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
||||
cardStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#444444")).Padding(0, 1)
|
||||
)
|
||||
|
||||
type tickMsg struct{}
|
||||
type animTickMsg struct{}
|
||||
// Metrics snapshot
|
||||
type MetricsSnapshot struct {
|
||||
CollectedAt time.Time
|
||||
HealthScore int
|
||||
HealthMessage string
|
||||
|
||||
type metricsMsg struct {
|
||||
data MetricsSnapshot
|
||||
err error
|
||||
// Hardware
|
||||
Hostname string
|
||||
OS string
|
||||
Platform string
|
||||
Uptime time.Duration
|
||||
|
||||
// CPU
|
||||
CPUModel string
|
||||
CPUCores int
|
||||
CPUPercent float64
|
||||
CPUPerCore []float64
|
||||
|
||||
// Memory
|
||||
MemTotal uint64
|
||||
MemUsed uint64
|
||||
MemPercent float64
|
||||
SwapTotal uint64
|
||||
SwapUsed uint64
|
||||
SwapPercent float64
|
||||
|
||||
// Disk
|
||||
Disks []DiskInfo
|
||||
|
||||
// Network
|
||||
Networks []NetworkInfo
|
||||
|
||||
// Processes
|
||||
TopProcesses []ProcessInfo
|
||||
}
|
||||
|
||||
type DiskInfo struct {
|
||||
Device string
|
||||
Mountpoint string
|
||||
Total uint64
|
||||
Used uint64
|
||||
Free uint64
|
||||
UsedPercent float64
|
||||
Fstype string
|
||||
}
|
||||
|
||||
type NetworkInfo struct {
|
||||
Name string
|
||||
BytesSent uint64
|
||||
BytesRecv uint64
|
||||
PacketsSent uint64
|
||||
PacketsRecv uint64
|
||||
}
|
||||
|
||||
type ProcessInfo struct {
|
||||
PID int32
|
||||
Name string
|
||||
CPU float64
|
||||
Memory float32
|
||||
}
|
||||
|
||||
// Collector
|
||||
type Collector struct {
|
||||
prevNet map[string]net.IOCountersStat
|
||||
prevNetTime time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewCollector() *Collector {
|
||||
return &Collector{
|
||||
prevNet: make(map[string]net.IOCountersStat),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) Collect() MetricsSnapshot {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
snapshot MetricsSnapshot
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
snapshot.CollectedAt = time.Now()
|
||||
|
||||
// Host info
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if info, err := host.InfoWithContext(ctx); err == nil {
|
||||
mu.Lock()
|
||||
snapshot.Hostname = info.Hostname
|
||||
snapshot.OS = info.OS
|
||||
snapshot.Platform = fmt.Sprintf("%s %s", info.Platform, info.PlatformVersion)
|
||||
snapshot.Uptime = time.Duration(info.Uptime) * time.Second
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// CPU info
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if cpuInfo, err := cpu.InfoWithContext(ctx); err == nil && len(cpuInfo) > 0 {
|
||||
mu.Lock()
|
||||
snapshot.CPUModel = cpuInfo[0].ModelName
|
||||
snapshot.CPUCores = runtime.NumCPU()
|
||||
mu.Unlock()
|
||||
}
|
||||
if percent, err := cpu.PercentWithContext(ctx, 500*time.Millisecond, false); err == nil && len(percent) > 0 {
|
||||
mu.Lock()
|
||||
snapshot.CPUPercent = percent[0]
|
||||
mu.Unlock()
|
||||
}
|
||||
if perCore, err := cpu.PercentWithContext(ctx, 500*time.Millisecond, true); err == nil {
|
||||
mu.Lock()
|
||||
snapshot.CPUPerCore = perCore
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Memory
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if memInfo, err := mem.VirtualMemoryWithContext(ctx); err == nil {
|
||||
mu.Lock()
|
||||
snapshot.MemTotal = memInfo.Total
|
||||
snapshot.MemUsed = memInfo.Used
|
||||
snapshot.MemPercent = memInfo.UsedPercent
|
||||
mu.Unlock()
|
||||
}
|
||||
if swapInfo, err := mem.SwapMemoryWithContext(ctx); err == nil {
|
||||
mu.Lock()
|
||||
snapshot.SwapTotal = swapInfo.Total
|
||||
snapshot.SwapUsed = swapInfo.Used
|
||||
snapshot.SwapPercent = swapInfo.UsedPercent
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Disk
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if partitions, err := disk.PartitionsWithContext(ctx, false); err == nil {
|
||||
var disks []DiskInfo
|
||||
for _, p := range partitions {
|
||||
// Skip non-physical drives
|
||||
if !strings.HasPrefix(p.Device, "C:") &&
|
||||
!strings.HasPrefix(p.Device, "D:") &&
|
||||
!strings.HasPrefix(p.Device, "E:") &&
|
||||
!strings.HasPrefix(p.Device, "F:") {
|
||||
continue
|
||||
}
|
||||
if usage, err := disk.UsageWithContext(ctx, p.Mountpoint); err == nil {
|
||||
disks = append(disks, DiskInfo{
|
||||
Device: p.Device,
|
||||
Mountpoint: p.Mountpoint,
|
||||
Total: usage.Total,
|
||||
Used: usage.Used,
|
||||
Free: usage.Free,
|
||||
UsedPercent: usage.UsedPercent,
|
||||
Fstype: p.Fstype,
|
||||
})
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
snapshot.Disks = disks
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Network
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if netIO, err := net.IOCountersWithContext(ctx, true); err == nil {
|
||||
var networks []NetworkInfo
|
||||
for _, io := range netIO {
|
||||
// Skip loopback and inactive interfaces
|
||||
if io.Name == "Loopback Pseudo-Interface 1" || (io.BytesSent == 0 && io.BytesRecv == 0) {
|
||||
continue
|
||||
}
|
||||
networks = append(networks, NetworkInfo{
|
||||
Name: io.Name,
|
||||
BytesSent: io.BytesSent,
|
||||
BytesRecv: io.BytesRecv,
|
||||
PacketsSent: io.PacketsSent,
|
||||
PacketsRecv: io.PacketsRecv,
|
||||
})
|
||||
}
|
||||
mu.Lock()
|
||||
snapshot.Networks = networks
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Top Processes
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
procs, err := process.ProcessesWithContext(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var procInfos []ProcessInfo
|
||||
for _, p := range procs {
|
||||
name, err := p.NameWithContext(ctx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cpuPercent, _ := p.CPUPercentWithContext(ctx)
|
||||
memPercent, _ := p.MemoryPercentWithContext(ctx)
|
||||
|
||||
if cpuPercent > 0.1 || memPercent > 0.1 {
|
||||
procInfos = append(procInfos, ProcessInfo{
|
||||
PID: p.Pid,
|
||||
Name: name,
|
||||
CPU: cpuPercent,
|
||||
Memory: memPercent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by CPU usage
|
||||
for i := 0; i < len(procInfos)-1; i++ {
|
||||
for j := i + 1; j < len(procInfos); j++ {
|
||||
if procInfos[j].CPU > procInfos[i].CPU {
|
||||
procInfos[i], procInfos[j] = procInfos[j], procInfos[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take top 5
|
||||
if len(procInfos) > 5 {
|
||||
procInfos = procInfos[:5]
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
snapshot.TopProcesses = procInfos
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Calculate health score
|
||||
snapshot.HealthScore, snapshot.HealthMessage = calculateHealthScore(snapshot)
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func calculateHealthScore(s MetricsSnapshot) (int, string) {
|
||||
score := 100
|
||||
var issues []string
|
||||
|
||||
// CPU penalty (30% weight)
|
||||
if s.CPUPercent > 90 {
|
||||
score -= 30
|
||||
issues = append(issues, "High CPU")
|
||||
} else if s.CPUPercent > 70 {
|
||||
score -= 15
|
||||
issues = append(issues, "Elevated CPU")
|
||||
}
|
||||
|
||||
// Memory penalty (25% weight)
|
||||
if s.MemPercent > 90 {
|
||||
score -= 25
|
||||
issues = append(issues, "High Memory")
|
||||
} else if s.MemPercent > 80 {
|
||||
score -= 12
|
||||
issues = append(issues, "Elevated Memory")
|
||||
}
|
||||
|
||||
// Disk penalty (20% weight)
|
||||
for _, d := range s.Disks {
|
||||
if d.UsedPercent > 95 {
|
||||
score -= 20
|
||||
issues = append(issues, fmt.Sprintf("Disk %s Critical", d.Device))
|
||||
break
|
||||
} else if d.UsedPercent > 85 {
|
||||
score -= 10
|
||||
issues = append(issues, fmt.Sprintf("Disk %s Low", d.Device))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Swap penalty (10% weight)
|
||||
if s.SwapPercent > 80 {
|
||||
score -= 10
|
||||
issues = append(issues, "High Swap")
|
||||
}
|
||||
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
|
||||
msg := "Excellent"
|
||||
if len(issues) > 0 {
|
||||
msg = strings.Join(issues, ", ")
|
||||
} else if score >= 90 {
|
||||
msg = "Excellent"
|
||||
} else if score >= 70 {
|
||||
msg = "Good"
|
||||
} else if score >= 50 {
|
||||
msg = "Fair"
|
||||
} else {
|
||||
msg = "Poor"
|
||||
}
|
||||
|
||||
return score, msg
|
||||
}
|
||||
|
||||
// Model for Bubble Tea
|
||||
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
|
||||
metrics MetricsSnapshot
|
||||
animFrame int
|
||||
catHidden bool
|
||||
ready bool
|
||||
collecting bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// getConfigPath returns the path to the status preferences file.
|
||||
func getConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".config", "mole", "status_prefs")
|
||||
}
|
||||
|
||||
// loadCatHidden loads the cat hidden preference from config file.
|
||||
func loadCatHidden() bool {
|
||||
path := getConfigPath()
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(data)) == "cat_hidden=true"
|
||||
}
|
||||
|
||||
// saveCatHidden saves the cat hidden preference to config file.
|
||||
func saveCatHidden(hidden bool) {
|
||||
path := getConfigPath()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return
|
||||
}
|
||||
value := "cat_hidden=false"
|
||||
if hidden {
|
||||
value = "cat_hidden=true"
|
||||
}
|
||||
_ = os.WriteFile(path, []byte(value+"\n"), 0644)
|
||||
}
|
||||
// Messages
|
||||
type tickMsg time.Time
|
||||
type metricsMsg MetricsSnapshot
|
||||
|
||||
func newModel() model {
|
||||
return model{
|
||||
collector: NewCollector(),
|
||||
catHidden: loadCatHidden(),
|
||||
animFrame: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(tickAfter(0), animTick())
|
||||
return tea.Batch(
|
||||
m.collectMetrics(),
|
||||
tickCmd(),
|
||||
)
|
||||
}
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
func (m model) collectMetrics() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return metricsMsg(m.collector.Collect())
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc", "ctrl+c":
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "k":
|
||||
// Toggle cat visibility and persist preference
|
||||
case "c":
|
||||
m.catHidden = !m.catHidden
|
||||
saveCatHidden(m.catHidden)
|
||||
return m, nil
|
||||
case "r":
|
||||
m.collecting = true
|
||||
return m, m.collectMetrics()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case tickMsg:
|
||||
if m.collecting {
|
||||
return m, nil
|
||||
}
|
||||
m.collecting = true
|
||||
return m, m.collectCmd()
|
||||
case metricsMsg:
|
||||
if msg.err != nil {
|
||||
m.errMessage = msg.err.Error()
|
||||
} else {
|
||||
m.errMessage = ""
|
||||
}
|
||||
m.metrics = msg.data
|
||||
m.lastUpdated = msg.data.CollectedAt
|
||||
m.collecting = false
|
||||
// Mark ready after first successful data collection.
|
||||
if !m.ready {
|
||||
m.ready = true
|
||||
}
|
||||
return m, tickAfter(refreshInterval)
|
||||
case animTickMsg:
|
||||
m.animFrame++
|
||||
return m, animTickWithSpeed(m.metrics.CPU.Usage)
|
||||
if m.animFrame%2 == 0 && !m.collecting {
|
||||
return m, tea.Batch(
|
||||
m.collectMetrics(),
|
||||
tickCmd(),
|
||||
)
|
||||
}
|
||||
return m, tickCmd()
|
||||
case metricsMsg:
|
||||
m.metrics = MetricsSnapshot(msg)
|
||||
m.ready = true
|
||||
m.collecting = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if !m.ready {
|
||||
return "Loading..."
|
||||
return "\n Loading system metrics..."
|
||||
}
|
||||
|
||||
header := renderHeader(m.metrics, m.errMessage, m.animFrame, m.width, m.catHidden)
|
||||
cardWidth := 0
|
||||
if m.width > 80 {
|
||||
cardWidth = maxInt(24, m.width/2-4)
|
||||
}
|
||||
cards := buildCards(m.metrics, cardWidth)
|
||||
var b strings.Builder
|
||||
|
||||
if m.width <= 80 {
|
||||
var rendered []string
|
||||
for i, c := range cards {
|
||||
if i > 0 {
|
||||
rendered = append(rendered, "")
|
||||
// Header with mole animation
|
||||
moleFrame := getMoleFrame(m.animFrame, m.catHidden)
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(titleStyle.Render(" 🐹 Mole System Status"))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(moleFrame)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Health score
|
||||
healthColor := okStyle
|
||||
if m.metrics.HealthScore < 50 {
|
||||
healthColor = dangerStyle
|
||||
} else if m.metrics.HealthScore < 70 {
|
||||
healthColor = warnStyle
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" Health: %s %s\n\n",
|
||||
healthColor.Render(fmt.Sprintf("%d%%", m.metrics.HealthScore)),
|
||||
dimStyle.Render(m.metrics.HealthMessage),
|
||||
))
|
||||
|
||||
// System info
|
||||
b.WriteString(headerStyle.Render(" 📍 System"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Host:"), valueStyle.Render(m.metrics.Hostname)))
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("OS:"), valueStyle.Render(m.metrics.Platform)))
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Uptime:"), valueStyle.Render(formatDuration(m.metrics.Uptime))))
|
||||
b.WriteString("\n")
|
||||
|
||||
// CPU
|
||||
b.WriteString(headerStyle.Render(" ⚡ CPU"))
|
||||
b.WriteString("\n")
|
||||
cpuColor := getPercentColor(m.metrics.CPUPercent)
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Model:"), valueStyle.Render(truncateString(m.metrics.CPUModel, 50))))
|
||||
b.WriteString(fmt.Sprintf(" %s %s (%d cores)\n",
|
||||
labelStyle.Render("Usage:"),
|
||||
cpuColor.Render(fmt.Sprintf("%.1f%%", m.metrics.CPUPercent)),
|
||||
m.metrics.CPUCores,
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(m.metrics.CPUPercent, 30)))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Memory
|
||||
b.WriteString(headerStyle.Render(" 🧠 Memory"))
|
||||
b.WriteString("\n")
|
||||
memColor := getPercentColor(m.metrics.MemPercent)
|
||||
b.WriteString(fmt.Sprintf(" %s %s / %s %s\n",
|
||||
labelStyle.Render("RAM:"),
|
||||
memColor.Render(formatBytes(m.metrics.MemUsed)),
|
||||
valueStyle.Render(formatBytes(m.metrics.MemTotal)),
|
||||
memColor.Render(fmt.Sprintf("(%.1f%%)", m.metrics.MemPercent)),
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(m.metrics.MemPercent, 30)))
|
||||
if m.metrics.SwapTotal > 0 {
|
||||
b.WriteString(fmt.Sprintf(" %s %s / %s\n",
|
||||
labelStyle.Render("Swap:"),
|
||||
valueStyle.Render(formatBytes(m.metrics.SwapUsed)),
|
||||
valueStyle.Render(formatBytes(m.metrics.SwapTotal)),
|
||||
))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Disk
|
||||
b.WriteString(headerStyle.Render(" 💾 Disks"))
|
||||
b.WriteString("\n")
|
||||
for _, d := range m.metrics.Disks {
|
||||
diskColor := getPercentColor(d.UsedPercent)
|
||||
b.WriteString(fmt.Sprintf(" %s %s / %s %s\n",
|
||||
labelStyle.Render(d.Device),
|
||||
diskColor.Render(formatBytes(d.Used)),
|
||||
valueStyle.Render(formatBytes(d.Total)),
|
||||
diskColor.Render(fmt.Sprintf("(%.1f%%)", d.UsedPercent)),
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(d.UsedPercent, 30)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Top Processes
|
||||
if len(m.metrics.TopProcesses) > 0 {
|
||||
b.WriteString(headerStyle.Render(" 📊 Top Processes"))
|
||||
b.WriteString("\n")
|
||||
for _, p := range m.metrics.TopProcesses {
|
||||
b.WriteString(fmt.Sprintf(" %s %s (CPU: %.1f%%, Mem: %.1f%%)\n",
|
||||
dimStyle.Render(fmt.Sprintf("[%d]", p.PID)),
|
||||
valueStyle.Render(truncateString(p.Name, 20)),
|
||||
p.CPU,
|
||||
p.Memory,
|
||||
))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Network
|
||||
if len(m.metrics.Networks) > 0 {
|
||||
b.WriteString(headerStyle.Render(" 🌐 Network"))
|
||||
b.WriteString("\n")
|
||||
for i, n := range m.metrics.Networks {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
rendered = append(rendered, renderCard(c, cardWidth, 0))
|
||||
b.WriteString(fmt.Sprintf(" %s ↑%s ↓%s\n",
|
||||
labelStyle.Render(truncateString(n.Name, 20)+":"),
|
||||
valueStyle.Render(formatBytes(n.BytesSent)),
|
||||
valueStyle.Render(formatBytes(n.BytesRecv)),
|
||||
))
|
||||
}
|
||||
result := header + "\n" + lipgloss.JoinVertical(lipgloss.Left, rendered...)
|
||||
// Add extra newline if cat is hidden for better spacing
|
||||
if m.catHidden {
|
||||
result = header + "\n\n" + lipgloss.JoinVertical(lipgloss.Left, rendered...)
|
||||
}
|
||||
return result
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
twoCol := renderTwoColumns(cards, m.width)
|
||||
// Add extra newline if cat is hidden for better spacing
|
||||
if m.catHidden {
|
||||
return header + "\n\n" + twoCol
|
||||
// Footer
|
||||
b.WriteString(dimStyle.Render(" [q] quit [r] refresh [c] toggle mole"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func getMoleFrame(frame int, hidden bool) string {
|
||||
if hidden {
|
||||
return ""
|
||||
}
|
||||
return header + "\n" + twoCol
|
||||
}
|
||||
|
||||
func (m model) collectCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
data, err := m.collector.Collect()
|
||||
return metricsMsg{data: data, err: err}
|
||||
frames := []string{
|
||||
"🐹",
|
||||
"🐹.",
|
||||
"🐹..",
|
||||
"🐹...",
|
||||
}
|
||||
return frames[frame%len(frames)]
|
||||
}
|
||||
|
||||
func tickAfter(delay time.Duration) tea.Cmd {
|
||||
return tea.Tick(delay, func(time.Time) tea.Msg { return tickMsg{} })
|
||||
func renderProgressBar(percent float64, width int) string {
|
||||
filled := int(percent / 100 * float64(width))
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
|
||||
color := okStyle
|
||||
if percent > 85 {
|
||||
color = dangerStyle
|
||||
} else if percent > 70 {
|
||||
color = warnStyle
|
||||
}
|
||||
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
|
||||
return color.Render(bar)
|
||||
}
|
||||
|
||||
func animTick() tea.Cmd {
|
||||
return tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} })
|
||||
func getPercentColor(percent float64) lipgloss.Style {
|
||||
if percent > 85 {
|
||||
return dangerStyle
|
||||
} else if percent > 70 {
|
||||
return warnStyle
|
||||
}
|
||||
return okStyle
|
||||
}
|
||||
|
||||
func animTickWithSpeed(cpuUsage float64) tea.Cmd {
|
||||
// Higher CPU = faster animation.
|
||||
interval := max(300-int(cpuUsage*2.5), 50)
|
||||
return tea.Tick(time.Duration(interval)*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} })
|
||||
func formatBytes(bytes uint64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
days := int(d.Hours() / 24)
|
||||
hours := int(d.Hours()) % 24
|
||||
minutes := int(d.Minutes()) % 60
|
||||
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
}
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// getWindowsVersion gets detailed Windows version using PowerShell
|
||||
func getWindowsVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "powershell", "-Command",
|
||||
"(Get-CimInstance Win32_OperatingSystem).Caption")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "Windows"
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// getBatteryInfo gets battery info on Windows (for laptops)
|
||||
func getBatteryInfo() (int, bool, bool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "powershell", "-Command",
|
||||
"(Get-CimInstance Win32_Battery).EstimatedChargeRemaining")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
percent, err := strconv.Atoi(strings.TrimSpace(string(output)))
|
||||
if err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
// Check if charging
|
||||
cmdStatus := exec.CommandContext(ctx, "powershell", "-Command",
|
||||
"(Get-CimInstance Win32_Battery).BatteryStatus")
|
||||
statusOutput, _ := cmdStatus.Output()
|
||||
status, _ := strconv.Atoi(strings.TrimSpace(string(statusOutput)))
|
||||
isCharging := status == 2 // 2 = AC Power
|
||||
|
||||
return percent, isCharging, true
|
||||
}
|
||||
|
||||
func main() {
|
||||
p := tea.NewProgram(newModel(), tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "system status error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
219
cmd/status/main_test.go
Normal file
219
cmd/status/main_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatBytesUint64(t *testing.T) {
|
||||
tests := []struct {
|
||||
input uint64
|
||||
expected string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{512, "512 B"},
|
||||
{1024, "1.0 KB"},
|
||||
{1536, "1.5 KB"},
|
||||
{1048576, "1.0 MB"},
|
||||
{1073741824, "1.0 GB"},
|
||||
{1099511627776, "1.0 TB"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := formatBytes(test.input)
|
||||
if result != test.expected {
|
||||
t.Errorf("formatBytes(%d) = %s, expected %s", test.input, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
input time.Duration
|
||||
expected string
|
||||
}{
|
||||
{5 * time.Minute, "5m"},
|
||||
{2 * time.Hour, "2h 0m"},
|
||||
{25 * time.Hour, "1d 1h 0m"},
|
||||
{49*time.Hour + 30*time.Minute, "2d 1h 30m"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := formatDuration(test.input)
|
||||
if result != test.expected {
|
||||
t.Errorf("formatDuration(%v) = %s, expected %s", test.input, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateString(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
maxLen int
|
||||
expected string
|
||||
}{
|
||||
{"short", 10, "short"},
|
||||
{"this is a long string", 10, "this is..."},
|
||||
{"exact", 5, "exact"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := truncateString(test.input, test.maxLen)
|
||||
if result != test.expected {
|
||||
t.Errorf("truncateString(%s, %d) = %s, expected %s", test.input, test.maxLen, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateHealthScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
snapshot MetricsSnapshot
|
||||
minScore int
|
||||
maxScore int
|
||||
}{
|
||||
{
|
||||
name: "Healthy system",
|
||||
snapshot: MetricsSnapshot{
|
||||
CPUPercent: 20,
|
||||
MemPercent: 40,
|
||||
SwapPercent: 10,
|
||||
Disks: []DiskInfo{
|
||||
{UsedPercent: 50},
|
||||
},
|
||||
},
|
||||
minScore: 90,
|
||||
maxScore: 100,
|
||||
},
|
||||
{
|
||||
name: "High CPU",
|
||||
snapshot: MetricsSnapshot{
|
||||
CPUPercent: 95,
|
||||
MemPercent: 40,
|
||||
SwapPercent: 10,
|
||||
Disks: []DiskInfo{
|
||||
{UsedPercent: 50},
|
||||
},
|
||||
},
|
||||
minScore: 50,
|
||||
maxScore: 75,
|
||||
},
|
||||
{
|
||||
name: "High Memory",
|
||||
snapshot: MetricsSnapshot{
|
||||
CPUPercent: 20,
|
||||
MemPercent: 95,
|
||||
SwapPercent: 10,
|
||||
Disks: []DiskInfo{
|
||||
{UsedPercent: 50},
|
||||
},
|
||||
},
|
||||
minScore: 60,
|
||||
maxScore: 80,
|
||||
},
|
||||
{
|
||||
name: "Critical Disk",
|
||||
snapshot: MetricsSnapshot{
|
||||
CPUPercent: 20,
|
||||
MemPercent: 40,
|
||||
SwapPercent: 10,
|
||||
Disks: []DiskInfo{
|
||||
{Device: "C:", UsedPercent: 98},
|
||||
},
|
||||
},
|
||||
minScore: 60,
|
||||
maxScore: 85,
|
||||
},
|
||||
{
|
||||
name: "Multiple issues",
|
||||
snapshot: MetricsSnapshot{
|
||||
CPUPercent: 95,
|
||||
MemPercent: 95,
|
||||
SwapPercent: 85,
|
||||
Disks: []DiskInfo{
|
||||
{Device: "C:", UsedPercent: 98},
|
||||
},
|
||||
},
|
||||
minScore: 0,
|
||||
maxScore: 30,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
score, msg := calculateHealthScore(test.snapshot)
|
||||
if score < test.minScore || score > test.maxScore {
|
||||
t.Errorf("calculateHealthScore() = %d (%s), expected between %d and %d",
|
||||
score, msg, test.minScore, test.maxScore)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCollector(t *testing.T) {
|
||||
collector := NewCollector()
|
||||
|
||||
if collector == nil {
|
||||
t.Fatal("NewCollector returned nil")
|
||||
}
|
||||
|
||||
if collector.prevNet == nil {
|
||||
t.Error("prevNet map should be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMoleFrame(t *testing.T) {
|
||||
// Test visible frames
|
||||
for i := 0; i < 8; i++ {
|
||||
frame := getMoleFrame(i, false)
|
||||
if frame == "" {
|
||||
t.Errorf("getMoleFrame(%d, false) returned empty string", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Test hidden
|
||||
frame := getMoleFrame(0, true)
|
||||
if frame != "" {
|
||||
t.Errorf("getMoleFrame(0, true) = %s, expected empty string", frame)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderProgressBar(t *testing.T) {
|
||||
tests := []struct {
|
||||
percent float64
|
||||
width int
|
||||
}{
|
||||
{0, 20},
|
||||
{50, 20},
|
||||
{100, 20},
|
||||
{75, 30},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := renderProgressBar(test.percent, test.width)
|
||||
if result == "" {
|
||||
t.Errorf("renderProgressBar(%.0f, %d) returned empty string", test.percent, test.width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPercentColor(t *testing.T) {
|
||||
// Just verify it doesn't panic
|
||||
_ = getPercentColor(50)
|
||||
_ = getPercentColor(75)
|
||||
_ = getPercentColor(90)
|
||||
}
|
||||
|
||||
func TestNewModel(t *testing.T) {
|
||||
model := newModel()
|
||||
|
||||
if model.collector == nil {
|
||||
t.Error("collector should be initialized")
|
||||
}
|
||||
|
||||
if model.ready {
|
||||
t.Error("ready should be false initially")
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/net"
|
||||
)
|
||||
|
||||
type MetricsSnapshot struct {
|
||||
CollectedAt time.Time
|
||||
Host string
|
||||
Platform string
|
||||
Uptime string
|
||||
Procs uint64
|
||||
Hardware HardwareInfo
|
||||
HealthScore int // 0-100 system health score
|
||||
HealthScoreMsg string // Brief explanation
|
||||
|
||||
CPU CPUStatus
|
||||
GPU []GPUStatus
|
||||
Memory MemoryStatus
|
||||
Disks []DiskStatus
|
||||
DiskIO DiskIOStatus
|
||||
Network []NetworkStatus
|
||||
Proxy ProxyStatus
|
||||
Batteries []BatteryStatus
|
||||
Thermal ThermalStatus
|
||||
Sensors []SensorReading
|
||||
Bluetooth []BluetoothDevice
|
||||
TopProcesses []ProcessInfo
|
||||
}
|
||||
|
||||
type HardwareInfo struct {
|
||||
Model string // MacBook Pro 14-inch, 2021
|
||||
CPUModel string // Apple M1 Pro / Intel Core i7
|
||||
TotalRAM string // 16GB
|
||||
DiskSize string // 512GB
|
||||
OSVersion string // macOS Sonoma 14.5
|
||||
RefreshRate string // 120Hz / 60Hz
|
||||
}
|
||||
|
||||
type DiskIOStatus struct {
|
||||
ReadRate float64 // MB/s
|
||||
WriteRate float64 // MB/s
|
||||
}
|
||||
|
||||
type ProcessInfo struct {
|
||||
Name string
|
||||
CPU float64
|
||||
Memory float64
|
||||
}
|
||||
|
||||
type CPUStatus struct {
|
||||
Usage float64
|
||||
PerCore []float64
|
||||
PerCoreEstimated bool
|
||||
Load1 float64
|
||||
Load5 float64
|
||||
Load15 float64
|
||||
CoreCount int
|
||||
LogicalCPU int
|
||||
PCoreCount int // Performance cores (Apple Silicon)
|
||||
ECoreCount int // Efficiency cores (Apple Silicon)
|
||||
}
|
||||
|
||||
type GPUStatus struct {
|
||||
Name string
|
||||
Usage float64
|
||||
MemoryUsed float64
|
||||
MemoryTotal float64
|
||||
CoreCount int
|
||||
Note string
|
||||
}
|
||||
|
||||
type MemoryStatus struct {
|
||||
Used uint64
|
||||
Total uint64
|
||||
UsedPercent float64
|
||||
SwapUsed uint64
|
||||
SwapTotal uint64
|
||||
Cached uint64 // File cache that can be freed if needed
|
||||
Pressure string // macOS memory pressure: normal/warn/critical
|
||||
}
|
||||
|
||||
type DiskStatus struct {
|
||||
Mount string
|
||||
Device string
|
||||
Used uint64
|
||||
Total uint64
|
||||
UsedPercent float64
|
||||
Fstype string
|
||||
External bool
|
||||
}
|
||||
|
||||
type NetworkStatus struct {
|
||||
Name string
|
||||
RxRateMBs float64
|
||||
TxRateMBs float64
|
||||
IP string
|
||||
}
|
||||
|
||||
type ProxyStatus struct {
|
||||
Enabled bool
|
||||
Type string // HTTP, SOCKS, System
|
||||
Host string
|
||||
}
|
||||
|
||||
type BatteryStatus struct {
|
||||
Percent float64
|
||||
Status string
|
||||
TimeLeft string
|
||||
Health string
|
||||
CycleCount int
|
||||
Capacity int // Maximum capacity percentage (e.g., 85 means 85% of original)
|
||||
}
|
||||
|
||||
type ThermalStatus struct {
|
||||
CPUTemp float64
|
||||
GPUTemp float64
|
||||
FanSpeed int
|
||||
FanCount int
|
||||
SystemPower float64 // System power consumption in Watts
|
||||
AdapterPower float64 // AC adapter max power in Watts
|
||||
BatteryPower float64 // Battery charge/discharge power in Watts (positive = discharging)
|
||||
}
|
||||
|
||||
type SensorReading struct {
|
||||
Label string
|
||||
Value float64
|
||||
Unit string
|
||||
Note string
|
||||
}
|
||||
|
||||
type BluetoothDevice struct {
|
||||
Name string
|
||||
Connected bool
|
||||
Battery string
|
||||
}
|
||||
|
||||
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
|
||||
lastGPUAt time.Time
|
||||
cachedGPU []GPUStatus
|
||||
prevDiskIO disk.IOCountersStat
|
||||
lastDiskAt time.Time
|
||||
}
|
||||
|
||||
func NewCollector() *Collector {
|
||||
return &Collector{
|
||||
prevNet: make(map[string]net.IOCountersStat),
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
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,
|
||||
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
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
)
|
||||
|
||||
var (
|
||||
// Cache for heavy system_profiler output.
|
||||
lastPowerAt time.Time
|
||||
cachedPower string
|
||||
powerCacheTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
func collectBatteries() (batts []BatteryStatus, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Swallow panics to keep UI alive.
|
||||
err = fmt.Errorf("battery collection failed: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// macOS: pmset for real-time percentage/status.
|
||||
if runtime.GOOS == "darwin" && commandExists("pmset") {
|
||||
if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil {
|
||||
// Health/cycles/capacity from cached system_profiler.
|
||||
health, cycles, capacity := getCachedPowerData()
|
||||
if batts := parsePMSet(out, health, cycles, capacity); 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, health string, cycles int, capacity int) []BatteryStatus {
|
||||
var out []BatteryStatus
|
||||
var timeLeft string
|
||||
|
||||
for line := range strings.Lines(raw) {
|
||||
// Time remaining.
|
||||
if strings.Contains(line, "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
|
||||
}
|
||||
|
||||
out = append(out, BatteryStatus{
|
||||
Percent: percent,
|
||||
Status: status,
|
||||
TimeLeft: timeLeft,
|
||||
Health: health,
|
||||
CycleCount: cycles,
|
||||
Capacity: capacity,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getCachedPowerData returns condition, cycles, and capacity from cached system_profiler.
|
||||
func getCachedPowerData() (health string, cycles int, capacity int) {
|
||||
out := getSystemPowerOutput()
|
||||
if out == "" {
|
||||
return "", 0, 0
|
||||
}
|
||||
|
||||
for line := range strings.Lines(out) {
|
||||
lower := strings.ToLower(line)
|
||||
if strings.Contains(lower, "cycle count") {
|
||||
if _, after, found := strings.Cut(line, ":"); found {
|
||||
cycles, _ = strconv.Atoi(strings.TrimSpace(after))
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "condition") {
|
||||
if _, after, found := strings.Cut(line, ":"); found {
|
||||
health = strings.TrimSpace(after)
|
||||
}
|
||||
}
|
||||
if strings.Contains(lower, "maximum capacity") {
|
||||
if _, after, found := strings.Cut(line, ":"); found {
|
||||
capacityStr := strings.TrimSpace(after)
|
||||
capacityStr = strings.TrimSuffix(capacityStr, "%")
|
||||
capacity, _ = strconv.Atoi(strings.TrimSpace(capacityStr))
|
||||
}
|
||||
}
|
||||
}
|
||||
return health, cycles, capacity
|
||||
}
|
||||
|
||||
func getSystemPowerOutput() string {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return ""
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if cachedPower != "" && now.Sub(lastPowerAt) < powerCacheTTL {
|
||||
return cachedPower
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := runCmd(ctx, "system_profiler", "SPPowerDataType")
|
||||
if err == nil {
|
||||
cachedPower = out
|
||||
lastPowerAt = now
|
||||
}
|
||||
return cachedPower
|
||||
}
|
||||
|
||||
func collectThermal() ThermalStatus {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return ThermalStatus{}
|
||||
}
|
||||
|
||||
var thermal ThermalStatus
|
||||
|
||||
// Fan info from cached system_profiler.
|
||||
out := getSystemPowerOutput()
|
||||
if out != "" {
|
||||
for line := range strings.Lines(out) {
|
||||
lower := strings.ToLower(line)
|
||||
if strings.Contains(lower, "fan") && strings.Contains(lower, "speed") {
|
||||
if _, after, found := strings.Cut(line, ":"); found {
|
||||
numStr := strings.TrimSpace(after)
|
||||
numStr, _, _ = strings.Cut(numStr, " ")
|
||||
thermal.FanSpeed, _ = strconv.Atoi(numStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Power metrics from ioreg (fast, real-time).
|
||||
ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancelPower()
|
||||
if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil {
|
||||
for line := range strings.Lines(out) {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Battery temperature ("Temperature" = 3055).
|
||||
if _, after, found := strings.Cut(line, "\"Temperature\" = "); found {
|
||||
valStr := strings.TrimSpace(after)
|
||||
if tempRaw, err := strconv.Atoi(valStr); err == nil && tempRaw > 0 {
|
||||
thermal.CPUTemp = float64(tempRaw) / 100.0
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter power (Watts) from current adapter.
|
||||
if strings.Contains(line, "\"AdapterDetails\" = {") && !strings.Contains(line, "AppleRaw") {
|
||||
if _, after, found := strings.Cut(line, "\"Watts\"="); found {
|
||||
valStr := strings.TrimSpace(after)
|
||||
valStr, _, _ = strings.Cut(valStr, ",")
|
||||
valStr, _, _ = strings.Cut(valStr, "}")
|
||||
valStr = strings.TrimSpace(valStr)
|
||||
if watts, err := strconv.ParseFloat(valStr, 64); err == nil && watts > 0 {
|
||||
thermal.AdapterPower = watts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// System power consumption (mW -> W).
|
||||
if _, after, found := strings.Cut(line, "\"SystemPowerIn\"="); found {
|
||||
valStr := strings.TrimSpace(after)
|
||||
valStr, _, _ = strings.Cut(valStr, ",")
|
||||
valStr, _, _ = strings.Cut(valStr, "}")
|
||||
valStr = strings.TrimSpace(valStr)
|
||||
if powerMW, err := strconv.ParseFloat(valStr, 64); err == nil && powerMW > 0 {
|
||||
thermal.SystemPower = powerMW / 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
// Battery power (mW -> W, positive = discharging).
|
||||
if _, after, found := strings.Cut(line, "\"BatteryPower\"="); found {
|
||||
valStr := strings.TrimSpace(after)
|
||||
valStr, _, _ = strings.Cut(valStr, ",")
|
||||
valStr, _, _ = strings.Cut(valStr, "}")
|
||||
valStr = strings.TrimSpace(valStr)
|
||||
// Parse as int64 first to handle negative values (charging)
|
||||
if powerMW, err := strconv.ParseInt(valStr, 10, 64); err == nil {
|
||||
thermal.BatteryPower = float64(powerMW) / 1000.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: thermal level proxy.
|
||||
if thermal.CPUTemp == 0 {
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel2()
|
||||
out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level")
|
||||
if err == nil {
|
||||
level, _ := strconv.Atoi(strings.TrimSpace(out2))
|
||||
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
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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 {
|
||||
var devices []BluetoothDevice
|
||||
var currentName string
|
||||
var connected bool
|
||||
var battery string
|
||||
|
||||
for line := range strings.Lines(raw) {
|
||||
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 {
|
||||
var devices []BluetoothDevice
|
||||
current := BluetoothDevice{}
|
||||
for line := range strings.Lines(raw) {
|
||||
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 after, ok := strings.CutPrefix(trim, "Name:"); ok {
|
||||
current.Name = strings.TrimSpace(after)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
// Two-call pattern for more reliable CPU usage.
|
||||
warmUpCPU()
|
||||
time.Sleep(cpuSampleInterval)
|
||||
percents, err := cpu.Percent(0, 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
|
||||
}
|
||||
}
|
||||
|
||||
// P/E core counts for Apple Silicon.
|
||||
pCores, eCores := getCoreTopology()
|
||||
|
||||
return CPUStatus{
|
||||
Usage: totalPercent,
|
||||
PerCore: percents,
|
||||
PerCoreEstimated: perCoreEstimated,
|
||||
Load1: loadAvg.Load1,
|
||||
Load5: loadAvg.Load5,
|
||||
Load15: loadAvg.Load15,
|
||||
CoreCount: counts,
|
||||
LogicalCPU: logical,
|
||||
PCoreCount: pCores,
|
||||
ECoreCount: eCores,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isZeroLoad(avg load.AvgStat) bool {
|
||||
return avg.Load1 == 0 && avg.Load5 == 0 && avg.Load15 == 0
|
||||
}
|
||||
|
||||
var (
|
||||
// Cache for core topology.
|
||||
lastTopologyAt time.Time
|
||||
cachedP, cachedE int
|
||||
topologyTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
// getCoreTopology returns P/E core counts on Apple Silicon.
|
||||
func getCoreTopology() (pCores, eCores int) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if cachedP > 0 || cachedE > 0 {
|
||||
if now.Sub(lastTopologyAt) < topologyTTL {
|
||||
return cachedP, cachedE
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
out, err := runCmd(ctx, "sysctl", "-n",
|
||||
"hw.perflevel0.logicalcpu",
|
||||
"hw.perflevel0.name",
|
||||
"hw.perflevel1.logicalcpu",
|
||||
"hw.perflevel1.name")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for line := range strings.Lines(strings.TrimSpace(out)) {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if len(lines) < 4 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
level0Count, _ := strconv.Atoi(strings.TrimSpace(lines[0]))
|
||||
level0Name := strings.ToLower(strings.TrimSpace(lines[1]))
|
||||
|
||||
level1Count, _ := strconv.Atoi(strings.TrimSpace(lines[2]))
|
||||
level1Name := strings.ToLower(strings.TrimSpace(lines[3]))
|
||||
|
||||
if strings.Contains(level0Name, "performance") {
|
||||
pCores = level0Count
|
||||
} else if strings.Contains(level0Name, "efficiency") {
|
||||
eCores = level0Count
|
||||
}
|
||||
|
||||
if strings.Contains(level1Name, "performance") {
|
||||
pCores = level1Count
|
||||
} else if strings.Contains(level1Name, "efficiency") {
|
||||
eCores = level1Count
|
||||
}
|
||||
|
||||
cachedP, cachedE = pCores, eCores
|
||||
lastTopologyAt = now
|
||||
return pCores, eCores
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
avg := total / float64(logical)
|
||||
perCore := make([]float64, logical)
|
||||
for i := range perCore {
|
||||
perCore[i] = avg
|
||||
}
|
||||
return avg, perCore, nil
|
||||
}
|
||||
|
||||
func warmUpCPU() {
|
||||
cpu.Percent(0, true) //nolint:errcheck
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
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 mounts.
|
||||
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 <1GB volumes.
|
||||
if usage.Total < 1<<30 {
|
||||
continue
|
||||
}
|
||||
// Use size-based dedupe key for shared pools.
|
||||
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
|
||||
}
|
||||
|
||||
var (
|
||||
// External disk cache.
|
||||
lastDiskCacheAt time.Time
|
||||
diskTypeCache = make(map[string]bool)
|
||||
diskCacheTTL = 2 * time.Minute
|
||||
)
|
||||
|
||||
func annotateDiskTypes(disks []DiskStatus) {
|
||||
if len(disks) == 0 || runtime.GOOS != "darwin" || !commandExists("diskutil") {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// Clear stale cache.
|
||||
if now.Sub(lastDiskCacheAt) > diskCacheTTL {
|
||||
diskTypeCache = make(map[string]bool)
|
||||
lastDiskCacheAt = now
|
||||
}
|
||||
|
||||
for i := range disks {
|
||||
base := baseDeviceName(disks[i].Device)
|
||||
if base == "" {
|
||||
base = disks[i].Device
|
||||
}
|
||||
|
||||
if val, ok := diskTypeCache[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
|
||||
diskTypeCache[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.Lines(out) {
|
||||
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}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
systemProfilerTimeout = 4 * time.Second
|
||||
macGPUInfoTTL = 10 * time.Minute
|
||||
powermetricsTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// Regex for GPU usage parsing.
|
||||
var (
|
||||
gpuActiveResidencyRe = regexp.MustCompile(`GPU HW active residency:\s+([\d.]+)%`)
|
||||
gpuIdleResidencyRe = regexp.MustCompile(`GPU idle residency:\s+([\d.]+)%`)
|
||||
)
|
||||
|
||||
func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) {
|
||||
if runtime.GOOS == "darwin" {
|
||||
// Static GPU info (cached 10 min).
|
||||
if len(c.cachedGPU) == 0 || c.lastGPUAt.IsZero() || now.Sub(c.lastGPUAt) >= macGPUInfoTTL {
|
||||
if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 {
|
||||
c.cachedGPU = gpus
|
||||
c.lastGPUAt = now
|
||||
}
|
||||
}
|
||||
|
||||
// Real-time GPU usage.
|
||||
if len(c.cachedGPU) > 0 {
|
||||
usage := getMacGPUUsage()
|
||||
result := make([]GPUStatus, len(c.cachedGPU))
|
||||
copy(result, c.cachedGPU)
|
||||
// Apply usage to first GPU (Apple Silicon).
|
||||
if len(result) > 0 {
|
||||
result[0].Usage = usage
|
||||
}
|
||||
return result, 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
|
||||
}
|
||||
|
||||
var gpus []GPUStatus
|
||||
for line := range strings.Lines(strings.TrimSpace(out)) {
|
||||
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"`
|
||||
Cores string `json:"sppci_cores"`
|
||||
} `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, " · ")
|
||||
coreCount, _ := strconv.Atoi(d.Cores)
|
||||
gpus = append(gpus, GPUStatus{
|
||||
Name: d.Name,
|
||||
Usage: -1, // Will be updated with real-time data
|
||||
CoreCount: coreCount,
|
||||
Note: note,
|
||||
})
|
||||
}
|
||||
|
||||
if len(gpus) == 0 {
|
||||
return []GPUStatus{{
|
||||
Name: "GPU info unavailable",
|
||||
Note: "Unable to parse system_profiler output",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
return gpus, nil
|
||||
}
|
||||
|
||||
// getMacGPUUsage reads GPU active residency from powermetrics.
|
||||
func getMacGPUUsage() float64 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), powermetricsTimeout)
|
||||
defer cancel()
|
||||
|
||||
// powermetrics may require root.
|
||||
out, err := runCmd(ctx, "powermetrics", "--samplers", "gpu_power", "-i", "500", "-n", "1")
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Parse "GPU HW active residency: X.XX%".
|
||||
matches := gpuActiveResidencyRe.FindStringSubmatch(out)
|
||||
if len(matches) >= 2 {
|
||||
usage, err := strconv.ParseFloat(matches[1], 64)
|
||||
if err == nil {
|
||||
return usage
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: parse idle residency and derive active.
|
||||
matchesIdle := gpuIdleResidencyRe.FindStringSubmatch(out)
|
||||
if len(matchesIdle) >= 2 {
|
||||
idle, err := strconv.ParseFloat(matchesIdle[1], 64)
|
||||
if err == nil {
|
||||
return 100.0 - idle
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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,
|
||||
RefreshRate: "",
|
||||
}
|
||||
}
|
||||
|
||||
// Model and CPU from system_profiler.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var model, cpuModel, osVersion, refreshRate string
|
||||
|
||||
out, err := runCmd(ctx, "system_profiler", "SPHardwareDataType")
|
||||
if err == nil {
|
||||
for line := range strings.Lines(out) {
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 refresh rate from display info (use mini detail to keep it fast).
|
||||
ctx3, cancel3 := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel3()
|
||||
out3, err := runCmd(ctx3, "system_profiler", "-detailLevel", "mini", "SPDisplaysDataType")
|
||||
if err == nil {
|
||||
refreshRate = parseRefreshRate(out3)
|
||||
}
|
||||
|
||||
diskSize := "Unknown"
|
||||
if len(disks) > 0 {
|
||||
diskSize = humanBytes(disks[0].Total)
|
||||
}
|
||||
|
||||
return HardwareInfo{
|
||||
Model: model,
|
||||
CPUModel: cpuModel,
|
||||
TotalRAM: humanBytes(totalRAM),
|
||||
DiskSize: diskSize,
|
||||
OSVersion: osVersion,
|
||||
RefreshRate: refreshRate,
|
||||
}
|
||||
}
|
||||
|
||||
// parseRefreshRate extracts the highest refresh rate from system_profiler display output.
|
||||
func parseRefreshRate(output string) string {
|
||||
maxHz := 0
|
||||
|
||||
for line := range strings.Lines(output) {
|
||||
lower := strings.ToLower(line)
|
||||
// Look for patterns like "@ 60Hz", "@ 60.00Hz", or "Refresh Rate: 120 Hz".
|
||||
if strings.Contains(lower, "hz") {
|
||||
fields := strings.Fields(lower)
|
||||
for i, field := range fields {
|
||||
if field == "hz" && i > 0 {
|
||||
if hz := parseInt(fields[i-1]); hz > maxHz && hz < 500 {
|
||||
maxHz = hz
|
||||
}
|
||||
continue
|
||||
}
|
||||
if numStr, ok := strings.CutSuffix(field, "hz"); ok {
|
||||
if numStr == "" && i > 0 {
|
||||
numStr = fields[i-1]
|
||||
}
|
||||
if hz := parseInt(numStr); hz > maxHz && hz < 500 {
|
||||
maxHz = hz
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if maxHz > 0 {
|
||||
return fmt.Sprintf("%dHz", maxHz)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseInt safely parses an integer from a string.
|
||||
func parseInt(s string) int {
|
||||
// Trim away non-numeric padding, keep digits and '.' for decimals.
|
||||
cleaned := strings.TrimSpace(s)
|
||||
cleaned = strings.TrimLeftFunc(cleaned, func(r rune) bool {
|
||||
return (r < '0' || r > '9') && r != '.'
|
||||
})
|
||||
cleaned = strings.TrimRightFunc(cleaned, func(r rune) bool {
|
||||
return (r < '0' || r > '9') && r != '.'
|
||||
})
|
||||
if cleaned == "" {
|
||||
return 0
|
||||
}
|
||||
var num int
|
||||
if _, err := fmt.Sscanf(cleaned, "%d", &num); err != nil {
|
||||
return 0
|
||||
}
|
||||
return num
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Health score weights and thresholds.
|
||||
const (
|
||||
// Weights.
|
||||
healthCPUWeight = 30.0
|
||||
healthMemWeight = 25.0
|
||||
healthDiskWeight = 20.0
|
||||
healthThermalWeight = 15.0
|
||||
healthIOWeight = 10.0
|
||||
|
||||
// CPU.
|
||||
cpuNormalThreshold = 30.0
|
||||
cpuHighThreshold = 70.0
|
||||
|
||||
// Memory.
|
||||
memNormalThreshold = 50.0
|
||||
memHighThreshold = 80.0
|
||||
memPressureWarnPenalty = 5.0
|
||||
memPressureCritPenalty = 15.0
|
||||
|
||||
// Disk.
|
||||
diskWarnThreshold = 70.0
|
||||
diskCritThreshold = 90.0
|
||||
|
||||
// Thermal.
|
||||
thermalNormalThreshold = 60.0
|
||||
thermalHighThreshold = 85.0
|
||||
|
||||
// Disk IO (MB/s).
|
||||
ioNormalThreshold = 50.0
|
||||
ioHighThreshold = 150.0
|
||||
)
|
||||
|
||||
func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus) (int, string) {
|
||||
score := 100.0
|
||||
issues := []string{}
|
||||
|
||||
// CPU 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 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 penalty.
|
||||
// Memory pressure penalty.
|
||||
switch mem.Pressure {
|
||||
case "warn":
|
||||
score -= memPressureWarnPenalty
|
||||
issues = append(issues, "Memory Pressure")
|
||||
case "critical":
|
||||
score -= memPressureCritPenalty
|
||||
issues = append(issues, "Critical Memory")
|
||||
}
|
||||
|
||||
// Disk penalty.
|
||||
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 penalty.
|
||||
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 penalty.
|
||||
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
|
||||
|
||||
// Clamp score.
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
|
||||
// Build message.
|
||||
var msg string
|
||||
switch {
|
||||
case score >= 90:
|
||||
msg = "Excellent"
|
||||
case score >= 75:
|
||||
msg = "Good"
|
||||
case score >= 60:
|
||||
msg = "Fair"
|
||||
case score >= 40:
|
||||
msg = "Poor"
|
||||
default:
|
||||
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)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCalculateHealthScorePerfect(t *testing.T) {
|
||||
score, msg := calculateHealthScore(
|
||||
CPUStatus{Usage: 10},
|
||||
MemoryStatus{UsedPercent: 20, Pressure: "normal"},
|
||||
[]DiskStatus{{UsedPercent: 30}},
|
||||
DiskIOStatus{ReadRate: 5, WriteRate: 5},
|
||||
ThermalStatus{CPUTemp: 40},
|
||||
)
|
||||
|
||||
if score != 100 {
|
||||
t.Fatalf("expected perfect score 100, got %d", score)
|
||||
}
|
||||
if msg != "Excellent" {
|
||||
t.Fatalf("unexpected message %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateHealthScoreDetectsIssues(t *testing.T) {
|
||||
score, msg := calculateHealthScore(
|
||||
CPUStatus{Usage: 95},
|
||||
MemoryStatus{UsedPercent: 90, Pressure: "critical"},
|
||||
[]DiskStatus{{UsedPercent: 95}},
|
||||
DiskIOStatus{ReadRate: 120, WriteRate: 80},
|
||||
ThermalStatus{CPUTemp: 90},
|
||||
)
|
||||
|
||||
if score >= 40 {
|
||||
t.Fatalf("expected heavy penalties bringing score down, got %d", score)
|
||||
}
|
||||
if msg == "Excellent" {
|
||||
t.Fatalf("expected message to include issues, got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "High CPU") {
|
||||
t.Fatalf("message should mention CPU issue: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "Disk Almost Full") {
|
||||
t.Fatalf("message should mention disk issue: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatUptime(t *testing.T) {
|
||||
if got := formatUptime(65); got != "1m" {
|
||||
t.Fatalf("expected 1m, got %s", got)
|
||||
}
|
||||
if got := formatUptime(3600 + 120); got != "1h 2m" {
|
||||
t.Fatalf("expected \"1h 2m\", got %s", got)
|
||||
}
|
||||
if got := formatUptime(86400*2 + 3600*3 + 60*5); got != "2d 3h 5m" {
|
||||
t.Fatalf("expected \"2d 3h 5m\", got %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"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()
|
||||
|
||||
// On macOS, vm.Cached is 0, so we calculate from file-backed pages.
|
||||
cached := vm.Cached
|
||||
if runtime.GOOS == "darwin" && cached == 0 {
|
||||
cached = getFileBackedMemory()
|
||||
}
|
||||
|
||||
return MemoryStatus{
|
||||
Used: vm.Used,
|
||||
Total: vm.Total,
|
||||
UsedPercent: vm.UsedPercent,
|
||||
SwapUsed: swap.Used,
|
||||
SwapTotal: swap.Total,
|
||||
Cached: cached,
|
||||
Pressure: pressure,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getFileBackedMemory() uint64 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
out, err := runCmd(ctx, "vm_stat")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Parse page size from first line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
|
||||
var pageSize uint64 = 4096 // Default
|
||||
firstLine := true
|
||||
for line := range strings.Lines(out) {
|
||||
if firstLine {
|
||||
firstLine = false
|
||||
if strings.Contains(line, "page size of") {
|
||||
if _, after, found := strings.Cut(line, "page size of "); found {
|
||||
if before, _, found := strings.Cut(after, " bytes"); found {
|
||||
if size, err := strconv.ParseUint(strings.TrimSpace(before), 10, 64); err == nil {
|
||||
pageSize = size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse "File-backed pages: 388975."
|
||||
if strings.Contains(line, "File-backed pages:") {
|
||||
if _, after, found := strings.Cut(line, ":"); found {
|
||||
numStr := strings.TrimSpace(after)
|
||||
numStr = strings.TrimSuffix(numStr, ".")
|
||||
if pages, err := strconv.ParseUint(numStr, 10, 64); err == nil {
|
||||
return pages * pageSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
// Map interface IPs.
|
||||
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 {
|
||||
// IPv4 only.
|
||||
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}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
var procs []ProcessInfo
|
||||
i := 0
|
||||
for line := range strings.Lines(strings.TrimSpace(out)) {
|
||||
if i == 0 {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if i > 5 {
|
||||
break
|
||||
}
|
||||
i++
|
||||
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]
|
||||
// Strip path from command name.
|
||||
if idx := strings.LastIndex(name, "/"); idx >= 0 {
|
||||
name = name[idx+1:]
|
||||
}
|
||||
procs = append(procs, ProcessInfo{
|
||||
Name: name,
|
||||
CPU: cpuVal,
|
||||
Memory: memVal,
|
||||
})
|
||||
}
|
||||
return procs
|
||||
}
|
||||
@@ -1,758 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true)
|
||||
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#737373"))
|
||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F"))
|
||||
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true)
|
||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7"))
|
||||
lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040"))
|
||||
|
||||
primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9"))
|
||||
)
|
||||
|
||||
const (
|
||||
colWidth = 38
|
||||
iconCPU = "◉"
|
||||
iconMemory = "◫"
|
||||
iconGPU = "◧"
|
||||
iconDisk = "▥"
|
||||
iconNetwork = "⇅"
|
||||
iconBattery = "◪"
|
||||
iconSensors = "◈"
|
||||
iconProcs = "❊"
|
||||
)
|
||||
|
||||
// Mole body frames (facing right).
|
||||
var moleBody = [][]string{
|
||||
{
|
||||
` /\_/\`,
|
||||
` ___/ o o \`,
|
||||
`/___ =-= /`,
|
||||
`\____)-m-m)`,
|
||||
},
|
||||
{
|
||||
` /\_/\`,
|
||||
` ___/ o o \`,
|
||||
`/___ =-= /`,
|
||||
`\____)mm__)`,
|
||||
},
|
||||
{
|
||||
` /\_/\`,
|
||||
` ___/ · · \`,
|
||||
`/___ =-= /`,
|
||||
`\___)-m__m)`,
|
||||
},
|
||||
{
|
||||
` /\_/\`,
|
||||
` ___/ o o \`,
|
||||
`/___ =-= /`,
|
||||
`\____)-mm-)`,
|
||||
},
|
||||
}
|
||||
|
||||
// Mirror mole body frames (facing left).
|
||||
var moleBodyMirror = [][]string{
|
||||
{
|
||||
` /\_/\`,
|
||||
` / o o \___`,
|
||||
` \ =-= ___\`,
|
||||
` (m-m-(____/`,
|
||||
},
|
||||
{
|
||||
` /\_/\`,
|
||||
` / o o \___`,
|
||||
` \ =-= ___\`,
|
||||
` (__mm(____/`,
|
||||
},
|
||||
{
|
||||
` /\_/\`,
|
||||
` / · · \___`,
|
||||
` \ =-= ___\`,
|
||||
` (m__m-(___/`,
|
||||
},
|
||||
{
|
||||
` /\_/\`,
|
||||
` / o o \___`,
|
||||
` \ =-= ___\`,
|
||||
` (-mm-(____/`,
|
||||
},
|
||||
}
|
||||
|
||||
// getMoleFrame renders the animated mole.
|
||||
func getMoleFrame(animFrame int, termWidth int) string {
|
||||
moleWidth := 15
|
||||
maxPos := max(termWidth-moleWidth, 0)
|
||||
|
||||
cycleLength := maxPos * 2
|
||||
if cycleLength == 0 {
|
||||
cycleLength = 1
|
||||
}
|
||||
pos := animFrame % cycleLength
|
||||
movingLeft := pos > maxPos
|
||||
if movingLeft {
|
||||
pos = cycleLength - pos
|
||||
}
|
||||
|
||||
// Use mirror frames when moving left
|
||||
var frames [][]string
|
||||
if movingLeft {
|
||||
frames = moleBodyMirror
|
||||
} else {
|
||||
frames = moleBody
|
||||
}
|
||||
|
||||
bodyIdx := animFrame % len(frames)
|
||||
body := frames[bodyIdx]
|
||||
|
||||
padding := strings.Repeat(" ", pos)
|
||||
var lines []string
|
||||
|
||||
for _, line := range body {
|
||||
lines = append(lines, padding+line)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
type cardData struct {
|
||||
icon string
|
||||
title string
|
||||
lines []string
|
||||
}
|
||||
|
||||
func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int, catHidden bool) string {
|
||||
title := titleStyle.Render("Mole Status")
|
||||
|
||||
scoreStyle := getScoreStyle(m.HealthScore)
|
||||
scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore))
|
||||
|
||||
// Hardware info for a single line.
|
||||
infoParts := []string{}
|
||||
if m.Hardware.Model != "" {
|
||||
infoParts = append(infoParts, primaryStyle.Render(m.Hardware.Model))
|
||||
}
|
||||
if m.Hardware.CPUModel != "" {
|
||||
cpuInfo := m.Hardware.CPUModel
|
||||
// Append GPU core count when available.
|
||||
if len(m.GPU) > 0 && m.GPU[0].CoreCount > 0 {
|
||||
cpuInfo += fmt.Sprintf(" (%dGPU)", m.GPU[0].CoreCount)
|
||||
}
|
||||
infoParts = append(infoParts, cpuInfo)
|
||||
}
|
||||
var specs []string
|
||||
if m.Hardware.TotalRAM != "" {
|
||||
specs = append(specs, m.Hardware.TotalRAM)
|
||||
}
|
||||
if m.Hardware.DiskSize != "" {
|
||||
specs = append(specs, m.Hardware.DiskSize)
|
||||
}
|
||||
if len(specs) > 0 {
|
||||
infoParts = append(infoParts, strings.Join(specs, "/"))
|
||||
}
|
||||
if m.Hardware.RefreshRate != "" {
|
||||
infoParts = append(infoParts, m.Hardware.RefreshRate)
|
||||
}
|
||||
if m.Hardware.OSVersion != "" {
|
||||
infoParts = append(infoParts, m.Hardware.OSVersion)
|
||||
}
|
||||
|
||||
headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ")
|
||||
|
||||
// Show cat unless hidden
|
||||
var mole string
|
||||
if !catHidden {
|
||||
mole = getMoleFrame(animFrame, termWidth)
|
||||
}
|
||||
|
||||
if errMsg != "" {
|
||||
if mole == "" {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", dangerStyle.Render("ERROR: "+errMsg), "")
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render("ERROR: "+errMsg), "")
|
||||
}
|
||||
if mole == "" {
|
||||
return headerLine
|
||||
}
|
||||
return headerLine + "\n" + mole
|
||||
}
|
||||
|
||||
func getScoreStyle(score int) lipgloss.Style {
|
||||
switch {
|
||||
case score >= 90:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true)
|
||||
case score >= 75:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true)
|
||||
case score >= 60:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true)
|
||||
case score >= 40:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true)
|
||||
default:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true)
|
||||
}
|
||||
}
|
||||
|
||||
func buildCards(m MetricsSnapshot, _ int) []cardData {
|
||||
cards := []cardData{
|
||||
renderCPUCard(m.CPU),
|
||||
renderMemoryCard(m.Memory),
|
||||
renderDiskCard(m.Disks, m.DiskIO),
|
||||
renderBatteryCard(m.Batteries, m.Thermal),
|
||||
renderProcessCard(m.TopProcesses),
|
||||
renderNetworkCard(m.Network, m.Proxy),
|
||||
}
|
||||
if hasSensorData(m.Sensors) {
|
||||
cards = append(cards, renderSensorsCard(m.Sensors))
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
func hasSensorData(sensors []SensorReading) bool {
|
||||
for _, s := range sensors {
|
||||
if s.Note == "" && s.Value > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderCPUCard(cpu CPUStatus) cardData {
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(cpu.Usage), cpu.Usage))
|
||||
|
||||
if cpu.PerCoreEstimated {
|
||||
lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)"))
|
||||
} else if len(cpu.PerCore) > 0 {
|
||||
type coreUsage struct {
|
||||
idx int
|
||||
val float64
|
||||
}
|
||||
var cores []coreUsage
|
||||
for i, v := range cpu.PerCore {
|
||||
cores = append(cores, coreUsage{i, v})
|
||||
}
|
||||
sort.Slice(cores, func(i, j int) bool { return cores[i].val > cores[j].val })
|
||||
|
||||
maxCores := min(len(cores), 3)
|
||||
for i := 0; i < maxCores; i++ {
|
||||
c := cores[i]
|
||||
lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", c.idx+1, progressBar(c.val), c.val))
|
||||
}
|
||||
}
|
||||
|
||||
// Load line at the end
|
||||
if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 {
|
||||
lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%dP+%dE)",
|
||||
cpu.Load1, cpu.Load5, cpu.Load15, cpu.PCoreCount, cpu.ECoreCount))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%d cores)",
|
||||
cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU))
|
||||
}
|
||||
|
||||
return cardData{icon: iconCPU, title: "CPU", lines: lines}
|
||||
}
|
||||
|
||||
func renderMemoryCard(mem MemoryStatus) cardData {
|
||||
// Check if swap is being used (or at least allocated).
|
||||
hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0
|
||||
|
||||
var lines []string
|
||||
// Line 1: Used
|
||||
lines = append(lines, fmt.Sprintf("Used %s %5.1f%%", progressBar(mem.UsedPercent), mem.UsedPercent))
|
||||
|
||||
// Line 2: Free
|
||||
freePercent := 100 - mem.UsedPercent
|
||||
lines = append(lines, fmt.Sprintf("Free %s %5.1f%%", progressBar(freePercent), freePercent))
|
||||
|
||||
if hasSwap {
|
||||
// Layout with Swap:
|
||||
// 3. Swap (progress bar + text)
|
||||
// 4. Total
|
||||
// 5. Avail
|
||||
var swapPercent float64
|
||||
if mem.SwapTotal > 0 {
|
||||
swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.0
|
||||
}
|
||||
swapText := fmt.Sprintf("(%s/%s)", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal))
|
||||
lines = append(lines, fmt.Sprintf("Swap %s %5.1f%% %s", progressBar(swapPercent), swapPercent, swapText))
|
||||
|
||||
lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total)))
|
||||
lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(mem.Total-mem.Used))) // Simplified avail logic for consistency
|
||||
} else {
|
||||
// Layout without Swap:
|
||||
// 3. Total
|
||||
// 4. Cached (if > 0)
|
||||
// 5. Avail
|
||||
lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total)))
|
||||
|
||||
if mem.Cached > 0 {
|
||||
lines = append(lines, fmt.Sprintf("Cached %s", humanBytes(mem.Cached)))
|
||||
}
|
||||
// Calculate available if not provided directly, or use Total-Used as proxy if needed,
|
||||
// but typically available is more nuanced. Using what we have.
|
||||
// Re-calculating available based on logic if needed, but mem.Total - mem.Used is often "Avail"
|
||||
// in simple terms for this view or we could use the passed definition.
|
||||
// Original code calculated: available := mem.Total - mem.Used
|
||||
available := mem.Total - mem.Used
|
||||
lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(available)))
|
||||
}
|
||||
// Memory pressure status.
|
||||
if mem.Pressure != "" {
|
||||
pressureStyle := okStyle
|
||||
pressureText := "Status " + mem.Pressure
|
||||
switch mem.Pressure {
|
||||
case "warn":
|
||||
pressureStyle = warnStyle
|
||||
case "critical":
|
||||
pressureStyle = dangerStyle
|
||||
}
|
||||
lines = append(lines, pressureStyle.Render(pressureText))
|
||||
}
|
||||
return cardData{icon: iconMemory, title: "Memory", lines: lines}
|
||||
}
|
||||
|
||||
func renderDiskCard(disks []DiskStatus, io DiskIOStatus) cardData {
|
||||
var lines []string
|
||||
if len(disks) == 0 {
|
||||
lines = append(lines, subtleStyle.Render("Collecting..."))
|
||||
} else {
|
||||
internal, external := splitDisks(disks)
|
||||
addGroup := func(prefix string, list []DiskStatus) {
|
||||
if len(list) == 0 {
|
||||
return
|
||||
}
|
||||
for i, d := range list {
|
||||
label := diskLabel(prefix, i, len(list))
|
||||
lines = append(lines, formatDiskLine(label, d))
|
||||
}
|
||||
}
|
||||
addGroup("INTR", internal)
|
||||
addGroup("EXTR", external)
|
||||
if len(lines) == 0 {
|
||||
lines = append(lines, subtleStyle.Render("No disks detected"))
|
||||
}
|
||||
}
|
||||
readBar := ioBar(io.ReadRate)
|
||||
writeBar := ioBar(io.WriteRate)
|
||||
lines = append(lines, fmt.Sprintf("Read %s %.1f MB/s", readBar, io.ReadRate))
|
||||
lines = append(lines, fmt.Sprintf("Write %s %.1f MB/s", writeBar, io.WriteRate))
|
||||
return cardData{icon: iconDisk, title: "Disk", lines: lines}
|
||||
}
|
||||
|
||||
func splitDisks(disks []DiskStatus) (internal, external []DiskStatus) {
|
||||
for _, d := range disks {
|
||||
if d.External {
|
||||
external = append(external, d)
|
||||
} else {
|
||||
internal = append(internal, d)
|
||||
}
|
||||
}
|
||||
return internal, external
|
||||
}
|
||||
|
||||
func diskLabel(prefix string, index int, total int) string {
|
||||
if total <= 1 {
|
||||
return prefix
|
||||
}
|
||||
return fmt.Sprintf("%s%d", prefix, index+1)
|
||||
}
|
||||
|
||||
func formatDiskLine(label string, d DiskStatus) string {
|
||||
if label == "" {
|
||||
label = "DISK"
|
||||
}
|
||||
bar := progressBar(d.UsedPercent)
|
||||
used := humanBytesShort(d.Used)
|
||||
total := humanBytesShort(d.Total)
|
||||
return fmt.Sprintf("%-6s %s %5.1f%% (%s/%s)", label, bar, d.UsedPercent, used, total)
|
||||
}
|
||||
|
||||
func ioBar(rate float64) string {
|
||||
filled := min(int(rate/10.0), 5)
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
bar := strings.Repeat("▮", filled) + strings.Repeat("▯", 5-filled)
|
||||
if rate > 80 {
|
||||
return dangerStyle.Render(bar)
|
||||
}
|
||||
if rate > 30 {
|
||||
return warnStyle.Render(bar)
|
||||
}
|
||||
return okStyle.Render(bar)
|
||||
}
|
||||
|
||||
func renderProcessCard(procs []ProcessInfo) cardData {
|
||||
var lines []string
|
||||
maxProcs := 3
|
||||
for i, p := range procs {
|
||||
if i >= maxProcs {
|
||||
break
|
||||
}
|
||||
name := shorten(p.Name, 12)
|
||||
cpuBar := miniBar(p.CPU)
|
||||
lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%", name, cpuBar, p.CPU))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
lines = append(lines, subtleStyle.Render("No data"))
|
||||
}
|
||||
return cardData{icon: iconProcs, title: "Processes", lines: lines}
|
||||
}
|
||||
|
||||
func miniBar(percent float64) string {
|
||||
filled := min(int(percent/20), 5)
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
return colorizePercent(percent, strings.Repeat("▮", filled)+strings.Repeat("▯", 5-filled))
|
||||
}
|
||||
|
||||
func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
|
||||
var lines []string
|
||||
var totalRx, totalTx float64
|
||||
var primaryIP string
|
||||
|
||||
for _, n := range netStats {
|
||||
totalRx += n.RxRateMBs
|
||||
totalTx += n.TxRateMBs
|
||||
if primaryIP == "" && n.IP != "" && n.Name == "en0" {
|
||||
primaryIP = n.IP
|
||||
}
|
||||
}
|
||||
|
||||
if len(netStats) == 0 {
|
||||
lines = []string{subtleStyle.Render("Collecting...")}
|
||||
} else {
|
||||
rxBar := netBar(totalRx)
|
||||
txBar := netBar(totalTx)
|
||||
lines = append(lines, fmt.Sprintf("Down %s %s", rxBar, formatRate(totalRx)))
|
||||
lines = append(lines, fmt.Sprintf("Up %s %s", txBar, formatRate(totalTx)))
|
||||
// Show proxy and IP on one line.
|
||||
var infoParts []string
|
||||
if proxy.Enabled {
|
||||
infoParts = append(infoParts, "Proxy "+proxy.Type)
|
||||
}
|
||||
if primaryIP != "" {
|
||||
infoParts = append(infoParts, primaryIP)
|
||||
}
|
||||
if len(infoParts) > 0 {
|
||||
lines = append(lines, strings.Join(infoParts, " · "))
|
||||
}
|
||||
}
|
||||
return cardData{icon: iconNetwork, title: "Network", lines: lines}
|
||||
}
|
||||
|
||||
func netBar(rate float64) string {
|
||||
filled := min(int(rate/2.0), 5)
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
bar := strings.Repeat("▮", filled) + strings.Repeat("▯", 5-filled)
|
||||
if rate > 8 {
|
||||
return dangerStyle.Render(bar)
|
||||
}
|
||||
if rate > 3 {
|
||||
return warnStyle.Render(bar)
|
||||
}
|
||||
return okStyle.Render(bar)
|
||||
}
|
||||
|
||||
func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
|
||||
var lines []string
|
||||
if len(batts) == 0 {
|
||||
lines = append(lines, subtleStyle.Render("No battery"))
|
||||
} else {
|
||||
b := batts[0]
|
||||
statusLower := strings.ToLower(b.Status)
|
||||
percentText := fmt.Sprintf("%5.1f%%", b.Percent)
|
||||
if b.Percent < 20 && statusLower != "charging" && statusLower != "charged" {
|
||||
percentText = dangerStyle.Render(percentText)
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("Level %s %s", batteryProgressBar(b.Percent), percentText))
|
||||
|
||||
// Add capacity line if available.
|
||||
if b.Capacity > 0 {
|
||||
capacityText := fmt.Sprintf("%5d%%", b.Capacity)
|
||||
if b.Capacity < 70 {
|
||||
capacityText = dangerStyle.Render(capacityText)
|
||||
} else if b.Capacity < 85 {
|
||||
capacityText = warnStyle.Render(capacityText)
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("Health %s %s", batteryProgressBar(float64(b.Capacity)), capacityText))
|
||||
}
|
||||
|
||||
statusIcon := ""
|
||||
statusStyle := subtleStyle
|
||||
if statusLower == "charging" || statusLower == "charged" {
|
||||
statusIcon = " ⚡"
|
||||
statusStyle = okStyle
|
||||
} else if b.Percent < 20 {
|
||||
statusStyle = dangerStyle
|
||||
}
|
||||
statusText := b.Status
|
||||
if len(statusText) > 0 {
|
||||
statusText = strings.ToUpper(statusText[:1]) + strings.ToLower(statusText[1:])
|
||||
}
|
||||
if b.TimeLeft != "" {
|
||||
statusText += " · " + b.TimeLeft
|
||||
}
|
||||
// Add power info.
|
||||
if statusLower == "charging" || statusLower == "charged" {
|
||||
if thermal.SystemPower > 0 {
|
||||
statusText += fmt.Sprintf(" · %.0fW", thermal.SystemPower)
|
||||
} else if thermal.AdapterPower > 0 {
|
||||
statusText += fmt.Sprintf(" · %.0fW Adapter", thermal.AdapterPower)
|
||||
}
|
||||
} else if thermal.BatteryPower > 0 {
|
||||
// Only show battery power when discharging (positive value)
|
||||
statusText += fmt.Sprintf(" · %.0fW", thermal.BatteryPower)
|
||||
}
|
||||
lines = append(lines, statusStyle.Render(statusText+statusIcon))
|
||||
|
||||
healthParts := []string{}
|
||||
if b.Health != "" {
|
||||
healthParts = append(healthParts, b.Health)
|
||||
}
|
||||
if b.CycleCount > 0 {
|
||||
healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount))
|
||||
}
|
||||
|
||||
if thermal.CPUTemp > 0 {
|
||||
tempText := fmt.Sprintf("%.0f°C", thermal.CPUTemp)
|
||||
if thermal.CPUTemp > 80 {
|
||||
tempText = dangerStyle.Render(tempText)
|
||||
} else if thermal.CPUTemp > 60 {
|
||||
tempText = warnStyle.Render(tempText)
|
||||
}
|
||||
healthParts = append(healthParts, tempText)
|
||||
}
|
||||
|
||||
if thermal.FanSpeed > 0 {
|
||||
healthParts = append(healthParts, fmt.Sprintf("%d RPM", thermal.FanSpeed))
|
||||
}
|
||||
|
||||
if len(healthParts) > 0 {
|
||||
lines = append(lines, strings.Join(healthParts, " · "))
|
||||
}
|
||||
}
|
||||
|
||||
return cardData{icon: iconBattery, title: "Power", lines: lines}
|
||||
}
|
||||
|
||||
func renderSensorsCard(sensors []SensorReading) cardData {
|
||||
var lines []string
|
||||
for _, s := range sensors {
|
||||
if s.Note != "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%-12s %s", shorten(s.Label, 12), colorizeTemp(s.Value)+s.Unit))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
lines = append(lines, subtleStyle.Render("No sensors"))
|
||||
}
|
||||
return cardData{icon: iconSensors, title: "Sensors", lines: lines}
|
||||
}
|
||||
|
||||
func renderCard(data cardData, width int, height int) string {
|
||||
titleText := data.icon + " " + data.title
|
||||
lineLen := max(width-lipgloss.Width(titleText)-2, 4)
|
||||
header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("╌", lineLen))
|
||||
content := header + "\n" + strings.Join(data.lines, "\n")
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
for len(lines) < height {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func progressBar(percent float64) string {
|
||||
total := 16
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
filled := int(percent / 100 * float64(total))
|
||||
|
||||
var builder strings.Builder
|
||||
for i := range total {
|
||||
if i < filled {
|
||||
builder.WriteString("█")
|
||||
} else {
|
||||
builder.WriteString("░")
|
||||
}
|
||||
}
|
||||
return colorizePercent(percent, builder.String())
|
||||
}
|
||||
|
||||
func batteryProgressBar(percent float64) string {
|
||||
total := 16
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
filled := int(percent / 100 * float64(total))
|
||||
|
||||
var builder strings.Builder
|
||||
for i := range total {
|
||||
if i < filled {
|
||||
builder.WriteString("█")
|
||||
} else {
|
||||
builder.WriteString("░")
|
||||
}
|
||||
}
|
||||
return colorizeBattery(percent, builder.String())
|
||||
}
|
||||
|
||||
func colorizePercent(percent float64, s string) string {
|
||||
switch {
|
||||
case percent >= 85:
|
||||
return dangerStyle.Render(s)
|
||||
case percent >= 60:
|
||||
return warnStyle.Render(s)
|
||||
default:
|
||||
return okStyle.Render(s)
|
||||
}
|
||||
}
|
||||
|
||||
func colorizeBattery(percent float64, s string) string {
|
||||
switch {
|
||||
case percent < 20:
|
||||
return dangerStyle.Render(s)
|
||||
case percent < 50:
|
||||
return warnStyle.Render(s)
|
||||
default:
|
||||
return okStyle.Render(s)
|
||||
}
|
||||
}
|
||||
|
||||
func colorizeTemp(t float64) string {
|
||||
switch {
|
||||
case t >= 85:
|
||||
return dangerStyle.Render(fmt.Sprintf("%.1f", t))
|
||||
case t >= 70:
|
||||
return warnStyle.Render(fmt.Sprintf("%.1f", t))
|
||||
default:
|
||||
return subtleStyle.Render(fmt.Sprintf("%.1f", t))
|
||||
}
|
||||
}
|
||||
|
||||
func formatRate(mb float64) string {
|
||||
if mb < 0.01 {
|
||||
return "0 MB/s"
|
||||
}
|
||||
if mb < 1 {
|
||||
return fmt.Sprintf("%.2f MB/s", mb)
|
||||
}
|
||||
if mb < 10 {
|
||||
return fmt.Sprintf("%.1f MB/s", mb)
|
||||
}
|
||||
return fmt.Sprintf("%.0f MB/s", mb)
|
||||
}
|
||||
|
||||
func humanBytes(v uint64) string {
|
||||
switch {
|
||||
case v > 1<<40:
|
||||
return fmt.Sprintf("%.1f TB", float64(v)/(1<<40))
|
||||
case v > 1<<30:
|
||||
return fmt.Sprintf("%.1f GB", float64(v)/(1<<30))
|
||||
case v > 1<<20:
|
||||
return fmt.Sprintf("%.1f MB", float64(v)/(1<<20))
|
||||
case v > 1<<10:
|
||||
return fmt.Sprintf("%.1f KB", float64(v)/(1<<10))
|
||||
default:
|
||||
return strconv.FormatUint(v, 10) + " B"
|
||||
}
|
||||
}
|
||||
|
||||
func humanBytesShort(v uint64) string {
|
||||
switch {
|
||||
case v >= 1<<40:
|
||||
return fmt.Sprintf("%.0fT", float64(v)/(1<<40))
|
||||
case v >= 1<<30:
|
||||
return fmt.Sprintf("%.0fG", float64(v)/(1<<30))
|
||||
case v >= 1<<20:
|
||||
return fmt.Sprintf("%.0fM", float64(v)/(1<<20))
|
||||
case v >= 1<<10:
|
||||
return fmt.Sprintf("%.0fK", float64(v)/(1<<10))
|
||||
default:
|
||||
return strconv.FormatUint(v, 10)
|
||||
}
|
||||
}
|
||||
|
||||
func humanBytesCompact(v uint64) string {
|
||||
switch {
|
||||
case v >= 1<<40:
|
||||
return fmt.Sprintf("%.1fT", float64(v)/(1<<40))
|
||||
case v >= 1<<30:
|
||||
return fmt.Sprintf("%.1fG", float64(v)/(1<<30))
|
||||
case v >= 1<<20:
|
||||
return fmt.Sprintf("%.1fM", float64(v)/(1<<20))
|
||||
case v >= 1<<10:
|
||||
return fmt.Sprintf("%.1fK", float64(v)/(1<<10))
|
||||
default:
|
||||
return strconv.FormatUint(v, 10)
|
||||
}
|
||||
}
|
||||
|
||||
func shorten(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-1] + "…"
|
||||
}
|
||||
|
||||
func renderTwoColumns(cards []cardData, width int) string {
|
||||
if len(cards) == 0 {
|
||||
return ""
|
||||
}
|
||||
cw := colWidth
|
||||
if width > 0 && width/2-2 > cw {
|
||||
cw = width/2 - 2
|
||||
}
|
||||
var rows []string
|
||||
for i := 0; i < len(cards); i += 2 {
|
||||
left := renderCard(cards[i], cw, 0)
|
||||
right := ""
|
||||
if i+1 < len(cards) {
|
||||
right = renderCard(cards[i+1], cw, 0)
|
||||
}
|
||||
targetHeight := maxInt(lipgloss.Height(left), lipgloss.Height(right))
|
||||
left = renderCard(cards[i], cw, targetHeight)
|
||||
if right != "" {
|
||||
right = renderCard(cards[i+1], cw, targetHeight)
|
||||
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right))
|
||||
} else {
|
||||
rows = append(rows, left)
|
||||
}
|
||||
}
|
||||
|
||||
var spacedRows []string
|
||||
for i, r := range rows {
|
||||
if i > 0 {
|
||||
spacedRows = append(spacedRows, "")
|
||||
}
|
||||
spacedRows = append(spacedRows, r)
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, spacedRows...)
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user