1
0
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:
Tw93
2026-01-10 13:23:29 +08:00
parent e84a457c2f
commit edf5ed09a9
140 changed files with 1472 additions and 34059 deletions

View File

@@ -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
View 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")
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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 ""
}

View File

@@ -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}
}

View File

@@ -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
}

View File

@@ -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
}