Skip to content

Commit 31766ec

Browse files
feat(status): add battery health scoring and uptime warning
- Battery card now shows health label (Healthy/Fair/Service Soon) based on cycle count and capacity thresholds, with color-coded cycles - Uptime in header is color-coded: green ≤7d, yellow 7-14d, red >14d with restart indicator - Both metrics now factor into the overall health score - Added tests for batteryHealthLabel, uptimeSeverity, and health score battery/uptime penalties
1 parent bf0f776 commit 31766ec

File tree

4 files changed

+172
-7
lines changed

4 files changed

+172
-7
lines changed

cmd/status/metrics.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ type MetricsSnapshot struct {
6161
Host string `json:"host"`
6262
Platform string `json:"platform"`
6363
Uptime string `json:"uptime"`
64+
UptimeSeconds uint64 `json:"uptime_seconds"`
6465
Procs uint64 `json:"procs"`
6566
Hardware HardwareInfo `json:"hardware"`
6667
HealthScore int `json:"health_score"` // 0-100 system health score
@@ -328,7 +329,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
328329
}
329330
hwInfo := c.cachedHW
330331

331-
score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats)
332+
score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats, batteryStats, hostInfo.Uptime)
332333
topProcs := topProcesses(allProcs, 5)
333334

334335
var processAlerts []ProcessAlert
@@ -343,6 +344,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
343344
Host: hostInfo.Hostname,
344345
Platform: fmt.Sprintf("%s %s", hostInfo.Platform, hostInfo.PlatformVersion),
345346
Uptime: formatUptime(hostInfo.Uptime),
347+
UptimeSeconds: hostInfo.Uptime,
346348
Procs: hostInfo.Procs,
347349
Hardware: hwInfo,
348350
HealthScore: score,

cmd/status/metrics_health.go

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,21 @@ const (
3535
// Disk IO (MB/s).
3636
ioNormalThreshold = 50.0
3737
ioHighThreshold = 150.0
38+
39+
// Battery.
40+
batteryCycleWarn = 500
41+
batteryCycleDanger = 900
42+
batteryCapWarn = 90
43+
batteryCapDanger = 80
44+
45+
// Uptime (seconds).
46+
uptimeWarnDays = 7
47+
uptimeDangerDays = 14
48+
uptimeWarnSecs = uptimeWarnDays * 86400
49+
uptimeDangerSecs = uptimeDangerDays * 86400
3850
)
3951

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

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

138+
// Battery health penalty (only when battery present).
139+
if len(batteries) > 0 {
140+
b := batteries[0]
141+
if b.CycleCount > batteryCycleDanger {
142+
score -= 5
143+
issues = append(issues, "Battery Aging")
144+
} else if b.CycleCount > batteryCycleWarn {
145+
score -= 2
146+
}
147+
if b.Capacity > 0 && b.Capacity < batteryCapDanger {
148+
score -= 5
149+
if b.CycleCount <= batteryCycleDanger {
150+
issues = append(issues, "Battery Degraded")
151+
}
152+
} else if b.Capacity > 0 && b.Capacity < batteryCapWarn {
153+
score -= 2
154+
}
155+
}
156+
157+
// Uptime penalty (long uptime without restart).
158+
if uptimeSecs > uptimeDangerSecs {
159+
score -= 3
160+
issues = append(issues, "Restart Recommended")
161+
} else if uptimeSecs > uptimeWarnSecs {
162+
score -= 1
163+
}
164+
126165
// Clamp score.
127166
if score < 0 {
128167
score = 0
@@ -153,6 +192,29 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
153192
return int(score), msg
154193
}
155194

195+
// batteryHealthLabel returns a human-readable health label and severity based on cycle count and capacity.
196+
// Severity is "ok", "warn", or "danger".
197+
func batteryHealthLabel(cycles int, capacity int) (string, string) {
198+
if cycles > batteryCycleDanger || (capacity > 0 && capacity < batteryCapDanger) {
199+
return "Service Soon", "danger"
200+
}
201+
if cycles > batteryCycleWarn || (capacity > 0 && capacity < batteryCapWarn) {
202+
return "Fair", "warn"
203+
}
204+
return "Healthy", "ok"
205+
}
206+
207+
// uptimeSeverity returns "ok", "warn", or "danger" based on uptime seconds.
208+
func uptimeSeverity(secs uint64) string {
209+
if secs > uptimeDangerSecs {
210+
return "danger"
211+
}
212+
if secs > uptimeWarnSecs {
213+
return "warn"
214+
}
215+
return "ok"
216+
}
217+
156218
func formatUptime(secs uint64) string {
157219
days := secs / 86400
158220
hours := (secs % 86400) / 3600

cmd/status/metrics_health_test.go

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ func TestCalculateHealthScorePerfect(t *testing.T) {
1212
[]DiskStatus{{UsedPercent: 30}},
1313
DiskIOStatus{ReadRate: 5, WriteRate: 5},
1414
ThermalStatus{CPUTemp: 40},
15+
nil, 0,
1516
)
1617

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

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

161163
for _, tt := range tests {
162164
t.Run(tt.name, func(t *testing.T) {
163-
score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal)
165+
score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal, nil, 0)
164166
if score < tt.wantMin || score > tt.wantMax {
165167
t.Errorf("calculateHealthScore() = %d, want range [%d, %d]", score, tt.wantMin, tt.wantMax)
166168
}
167169
})
168170
}
169171
}
170172

173+
func TestBatteryHealthLabel(t *testing.T) {
174+
tests := []struct {
175+
name string
176+
cycles int
177+
capacity int
178+
label string
179+
severity string
180+
}{
181+
{"new battery", 100, 98, "Healthy", "ok"},
182+
{"moderate cycles", 600, 92, "Fair", "warn"},
183+
{"high cycles", 950, 85, "Service Soon", "danger"},
184+
{"low capacity", 200, 75, "Service Soon", "danger"},
185+
{"warn capacity", 200, 88, "Fair", "warn"},
186+
{"zero values", 0, 0, "Healthy", "ok"},
187+
}
188+
for _, tt := range tests {
189+
t.Run(tt.name, func(t *testing.T) {
190+
label, severity := batteryHealthLabel(tt.cycles, tt.capacity)
191+
if label != tt.label {
192+
t.Errorf("batteryHealthLabel(%d, %d) label = %q, want %q", tt.cycles, tt.capacity, label, tt.label)
193+
}
194+
if severity != tt.severity {
195+
t.Errorf("batteryHealthLabel(%d, %d) severity = %q, want %q", tt.cycles, tt.capacity, severity, tt.severity)
196+
}
197+
})
198+
}
199+
}
200+
201+
func TestUptimeSeverity(t *testing.T) {
202+
tests := []struct {
203+
name string
204+
secs uint64
205+
want string
206+
}{
207+
{"fresh restart", 3600, "ok"},
208+
{"6 days", 6 * 86400, "ok"},
209+
{"8 days", 8 * 86400, "warn"},
210+
{"15 days", 15 * 86400, "danger"},
211+
}
212+
for _, tt := range tests {
213+
t.Run(tt.name, func(t *testing.T) {
214+
got := uptimeSeverity(tt.secs)
215+
if got != tt.want {
216+
t.Errorf("uptimeSeverity(%d) = %q, want %q", tt.secs, got, tt.want)
217+
}
218+
})
219+
}
220+
}
221+
222+
func TestHealthScoreBatteryPenalty(t *testing.T) {
223+
base := func(batts []BatteryStatus, uptime uint64) int {
224+
s, _ := calculateHealthScore(
225+
CPUStatus{Usage: 10}, MemoryStatus{UsedPercent: 20},
226+
[]DiskStatus{{UsedPercent: 30}}, DiskIOStatus{ReadRate: 5, WriteRate: 5},
227+
ThermalStatus{CPUTemp: 40}, batts, uptime,
228+
)
229+
return s
230+
}
231+
232+
perfect := base(nil, 0)
233+
withOldBattery := base([]BatteryStatus{{CycleCount: 950, Capacity: 75}}, 0)
234+
withLongUptime := base(nil, 15*86400)
235+
236+
if withOldBattery >= perfect {
237+
t.Errorf("old battery should reduce score: got %d vs perfect %d", withOldBattery, perfect)
238+
}
239+
if withLongUptime >= perfect {
240+
t.Errorf("long uptime should reduce score: got %d vs perfect %d", withLongUptime, perfect)
241+
}
242+
}
243+
171244
func TestFormatUptimeEdgeCases(t *testing.T) {
172245
tests := []struct {
173246
name string

cmd/status/view.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,16 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
177177
optionalInfoParts = append(optionalInfoParts, m.Hardware.OSVersion)
178178
}
179179
if !compactHeader && m.Uptime != "" {
180-
optionalInfoParts = append(optionalInfoParts, subtleStyle.Render("up "+m.Uptime))
180+
uptimeText := "up " + m.Uptime
181+
switch uptimeSeverity(m.UptimeSeconds) {
182+
case "danger":
183+
uptimeText = dangerStyle.Render(uptimeText + " ↻")
184+
case "warn":
185+
uptimeText = warnStyle.Render(uptimeText)
186+
default:
187+
uptimeText = subtleStyle.Render(uptimeText)
188+
}
189+
optionalInfoParts = append(optionalInfoParts, uptimeText)
181190
}
182191

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

643652
healthParts := []string{}
644-
if b.Health != "" {
653+
654+
// Battery health assessment label.
655+
if b.CycleCount > 0 || b.Capacity > 0 {
656+
label, severity := batteryHealthLabel(b.CycleCount, b.Capacity)
657+
switch severity {
658+
case "danger":
659+
healthParts = append(healthParts, dangerStyle.Render(label))
660+
case "warn":
661+
healthParts = append(healthParts, warnStyle.Render(label))
662+
default:
663+
healthParts = append(healthParts, okStyle.Render(label))
664+
}
665+
} else if b.Health != "" {
645666
healthParts = append(healthParts, b.Health)
646667
}
668+
647669
if b.CycleCount > 0 {
648-
healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount))
670+
cycleText := fmt.Sprintf("%d cycles", b.CycleCount)
671+
if b.CycleCount > batteryCycleDanger {
672+
cycleText = dangerStyle.Render(cycleText)
673+
} else if b.CycleCount > batteryCycleWarn {
674+
cycleText = warnStyle.Render(cycleText)
675+
}
676+
healthParts = append(healthParts, cycleText)
649677
}
650678

651679
if thermal.CPUTemp > 0 {
652-
tempText := colorizeTemp(thermal.CPUTemp) + "°C" // Reuse common color logic
680+
tempText := colorizeTemp(thermal.CPUTemp) + "°C"
653681
healthParts = append(healthParts, tempText)
654682
}
655683

0 commit comments

Comments
 (0)