Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/status/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type MetricsSnapshot struct {
Host string `json:"host"`
Platform string `json:"platform"`
Uptime string `json:"uptime"`
UptimeSeconds uint64 `json:"uptime_seconds"`
Procs uint64 `json:"procs"`
Hardware HardwareInfo `json:"hardware"`
HealthScore int `json:"health_score"` // 0-100 system health score
Expand Down Expand Up @@ -328,7 +329,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
}
hwInfo := c.cachedHW

score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats)
score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats, batteryStats, hostInfo.Uptime)
topProcs := topProcesses(allProcs, 5)

var processAlerts []ProcessAlert
Expand All @@ -343,6 +344,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
Host: hostInfo.Hostname,
Platform: fmt.Sprintf("%s %s", hostInfo.Platform, hostInfo.PlatformVersion),
Uptime: formatUptime(hostInfo.Uptime),
UptimeSeconds: hostInfo.Uptime,
Procs: hostInfo.Procs,
Hardware: hwInfo,
HealthScore: score,
Expand Down
64 changes: 63 additions & 1 deletion cmd/status/metrics_health.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,21 @@ const (
// Disk IO (MB/s).
ioNormalThreshold = 50.0
ioHighThreshold = 150.0

// Battery.
batteryCycleWarn = 500
batteryCycleDanger = 900
batteryCapWarn = 90
batteryCapDanger = 80

// Uptime (seconds).
uptimeWarnDays = 7
uptimeDangerDays = 14
uptimeWarnSecs = uptimeWarnDays * 86400
uptimeDangerSecs = uptimeDangerDays * 86400
)

func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus) (int, string) {
func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus, batteries []BatteryStatus, uptimeSecs uint64) (int, string) {
score := 100.0
issues := []string{}

Expand Down Expand Up @@ -123,6 +135,33 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
}
score -= ioPenalty

// Battery health penalty (only when battery present).
if len(batteries) > 0 {
b := batteries[0]
if b.CycleCount > batteryCycleDanger {
score -= 5
issues = append(issues, "Battery Aging")
} else if b.CycleCount > batteryCycleWarn {
score -= 2
}
if b.Capacity > 0 && b.Capacity < batteryCapDanger {
score -= 5
if b.CycleCount <= batteryCycleDanger {
issues = append(issues, "Battery Degraded")
}
} else if b.Capacity > 0 && b.Capacity < batteryCapWarn {
score -= 2
}
}

// Uptime penalty (long uptime without restart).
if uptimeSecs > uptimeDangerSecs {
score -= 3
issues = append(issues, "Restart Recommended")
} else if uptimeSecs > uptimeWarnSecs {
score -= 1
}

// Clamp score.
if score < 0 {
score = 0
Expand Down Expand Up @@ -153,6 +192,29 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
return int(score), msg
}

// batteryHealthLabel returns a human-readable health label and severity based on cycle count and capacity.
// Severity is "ok", "warn", or "danger".
func batteryHealthLabel(cycles int, capacity int) (string, string) {
if cycles > batteryCycleDanger || (capacity > 0 && capacity < batteryCapDanger) {
return "Service Soon", "danger"
}
if cycles > batteryCycleWarn || (capacity > 0 && capacity < batteryCapWarn) {
return "Fair", "warn"
}
return "Healthy", "ok"
}

// uptimeSeverity returns "ok", "warn", or "danger" based on uptime seconds.
func uptimeSeverity(secs uint64) string {
if secs > uptimeDangerSecs {
return "danger"
}
if secs > uptimeWarnSecs {
return "warn"
}
return "ok"
}

func formatUptime(secs uint64) string {
days := secs / 86400
hours := (secs % 86400) / 3600
Expand Down
75 changes: 74 additions & 1 deletion cmd/status/metrics_health_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ func TestCalculateHealthScorePerfect(t *testing.T) {
[]DiskStatus{{UsedPercent: 30}},
DiskIOStatus{ReadRate: 5, WriteRate: 5},
ThermalStatus{CPUTemp: 40},
nil, 0,
)

if score != 100 {
Expand All @@ -29,6 +30,7 @@ func TestCalculateHealthScoreDetectsIssues(t *testing.T) {
[]DiskStatus{{UsedPercent: 95}},
DiskIOStatus{ReadRate: 120, WriteRate: 80},
ThermalStatus{CPUTemp: 90},
nil, 0,
)

if score >= 40 {
Expand Down Expand Up @@ -160,14 +162,85 @@ func TestCalculateHealthScoreEdgeCases(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal)
score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal, nil, 0)
if score < tt.wantMin || score > tt.wantMax {
t.Errorf("calculateHealthScore() = %d, want range [%d, %d]", score, tt.wantMin, tt.wantMax)
}
})
}
}

func TestBatteryHealthLabel(t *testing.T) {
tests := []struct {
name string
cycles int
capacity int
label string
severity string
}{
{"new battery", 100, 98, "Healthy", "ok"},
{"moderate cycles", 600, 92, "Fair", "warn"},
{"high cycles", 950, 85, "Service Soon", "danger"},
{"low capacity", 200, 75, "Service Soon", "danger"},
{"warn capacity", 200, 88, "Fair", "warn"},
{"zero values", 0, 0, "Healthy", "ok"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
label, severity := batteryHealthLabel(tt.cycles, tt.capacity)
if label != tt.label {
t.Errorf("batteryHealthLabel(%d, %d) label = %q, want %q", tt.cycles, tt.capacity, label, tt.label)
}
if severity != tt.severity {
t.Errorf("batteryHealthLabel(%d, %d) severity = %q, want %q", tt.cycles, tt.capacity, severity, tt.severity)
}
})
}
}

func TestUptimeSeverity(t *testing.T) {
tests := []struct {
name string
secs uint64
want string
}{
{"fresh restart", 3600, "ok"},
{"6 days", 6 * 86400, "ok"},
{"8 days", 8 * 86400, "warn"},
{"15 days", 15 * 86400, "danger"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := uptimeSeverity(tt.secs)
if got != tt.want {
t.Errorf("uptimeSeverity(%d) = %q, want %q", tt.secs, got, tt.want)
}
})
}
}

func TestHealthScoreBatteryPenalty(t *testing.T) {
base := func(batts []BatteryStatus, uptime uint64) int {
s, _ := calculateHealthScore(
CPUStatus{Usage: 10}, MemoryStatus{UsedPercent: 20},
[]DiskStatus{{UsedPercent: 30}}, DiskIOStatus{ReadRate: 5, WriteRate: 5},
ThermalStatus{CPUTemp: 40}, batts, uptime,
)
return s
}

perfect := base(nil, 0)
withOldBattery := base([]BatteryStatus{{CycleCount: 950, Capacity: 75}}, 0)
withLongUptime := base(nil, 15*86400)

if withOldBattery >= perfect {
t.Errorf("old battery should reduce score: got %d vs perfect %d", withOldBattery, perfect)
}
if withLongUptime >= perfect {
t.Errorf("long uptime should reduce score: got %d vs perfect %d", withLongUptime, perfect)
}
}

func TestFormatUptimeEdgeCases(t *testing.T) {
tests := []struct {
name string
Expand Down
36 changes: 32 additions & 4 deletions cmd/status/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,16 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
optionalInfoParts = append(optionalInfoParts, m.Hardware.OSVersion)
}
if !compactHeader && m.Uptime != "" {
optionalInfoParts = append(optionalInfoParts, subtleStyle.Render("up "+m.Uptime))
uptimeText := "up " + m.Uptime
switch uptimeSeverity(m.UptimeSeconds) {
case "danger":
uptimeText = dangerStyle.Render(uptimeText + " ↻")
case "warn":
uptimeText = warnStyle.Render(uptimeText)
default:
uptimeText = subtleStyle.Render(uptimeText)
}
optionalInfoParts = append(optionalInfoParts, uptimeText)
}

headLeft := title + " " + scoreText
Expand Down Expand Up @@ -641,15 +650,34 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
lines = append(lines, statusStyle.Render(statusText+statusIcon))

healthParts := []string{}
if b.Health != "" {

// Battery health assessment label.
if b.CycleCount > 0 || b.Capacity > 0 {
label, severity := batteryHealthLabel(b.CycleCount, b.Capacity)
switch severity {
case "danger":
healthParts = append(healthParts, dangerStyle.Render(label))
case "warn":
healthParts = append(healthParts, warnStyle.Render(label))
default:
healthParts = append(healthParts, okStyle.Render(label))
}
} else if b.Health != "" {
healthParts = append(healthParts, b.Health)
}

if b.CycleCount > 0 {
healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount))
cycleText := fmt.Sprintf("%d cycles", b.CycleCount)
if b.CycleCount > batteryCycleDanger {
cycleText = dangerStyle.Render(cycleText)
} else if b.CycleCount > batteryCycleWarn {
cycleText = warnStyle.Render(cycleText)
}
healthParts = append(healthParts, cycleText)
}

if thermal.CPUTemp > 0 {
tempText := colorizeTemp(thermal.CPUTemp) + "°C" // Reuse common color logic
tempText := colorizeTemp(thermal.CPUTemp) + "°C"
healthParts = append(healthParts, tempText)
}

Expand Down
Loading