summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-18 09:19:03 +0200
committerPaul Buetow <paul@buetow.org>2026-02-18 09:19:03 +0200
commitd845cf3208c3bbdb7e3dd3041d1ae491b88d4d21 (patch)
tree5fa93b7787a1f1c9598aecfc772ede6dbac17f8d
parent88f4e239a7521112a4db8c7842e3a05db4446cd4 (diff)
feat: add load average bar visualization (4/l hotkey)0.12.0
Adds a new teal bar type showing /proc/loadavg data per host. The 1-min average fills from the top downward; yellow and white 1px reference lines mark the 5-min and 15-min averages. A global loadPeak tracker (floor 2.0, slow per-frame decay) keeps the scale meaningful across hosts and after spikes. Toggle with 4 or l; persisted to ~/.loadbarsrc as showload=1. Bump version to 0.12.0. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/config/config.go6
-rw-r--r--internal/constants/constants.go1
-rw-r--r--internal/display/display.go99
-rw-r--r--internal/display/display_test.go10
-rw-r--r--internal/display/hittest.go12
-rw-r--r--internal/display/tooltip.go24
-rw-r--r--internal/version/version.go2
7 files changed, 143 insertions, 11 deletions
diff --git a/internal/config/config.go b/internal/config/config.go
index 90b8192..99e9c6d 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -29,6 +29,7 @@ type Config struct {
CPUMode int // constants.CPUModeAverage / CPUModeCores / CPUModeOff
ShowMem bool
ShowNet bool
+ ShowLoad bool
ShowSeparators bool
MaxBarsPerRow int
SSHOpts string
@@ -109,7 +110,7 @@ func (c *Config) parseReader(f *os.File) error {
"title": true, "barwidth": true, "cpuaverage": true, "extended": true,
"hasagent": true, "height": true, "maxwidth": true, "netaverage": true,
"netlink": true, "cpumode": true, "showcores": true, "showmem": true,
- "showavgline": true, "showioavgline": true, "shownet": true, "showseparators": true,
+ "showavgline": true, "showioavgline": true, "shownet": true, "showload": true, "showseparators": true,
"maxbarsperrow": true, "sshopts": true, "cluster": true,
}
scanner := bufio.NewScanner(f)
@@ -185,6 +186,8 @@ func (c *Config) set(key, val string) {
c.ShowMem = parseBool(val)
case "shownet":
c.ShowNet = parseBool(val)
+ case "showload":
+ c.ShowLoad = parseBool(val)
case "showseparators":
c.ShowSeparators = parseBool(val)
case "maxbarsperrow":
@@ -222,6 +225,7 @@ func (c *Config) writeTo(f *os.File) error {
writeInt("cpumode", c.CPUMode)
writeBool("showmem", c.ShowMem)
writeBool("shownet", c.ShowNet)
+ writeBool("showload", c.ShowLoad)
writeBool("showseparators", c.ShowSeparators)
writeInt("maxbarsperrow", c.MaxBarsPerRow)
writeStr("sshopts", c.SSHOpts)
diff --git a/internal/constants/constants.go b/internal/constants/constants.go
index e2a4fcb..48d6e12 100644
--- a/internal/constants/constants.go
+++ b/internal/constants/constants.go
@@ -57,6 +57,7 @@ var (
DarkGrey = RGB{0x15, 0x15, 0x15}
Yellow0 = RGB{0xff, 0xa0, 0x00}
Yellow = RGB{0xff, 0xc0, 0x00}
+ Teal = RGB{0x00, 0xcc, 0xcc} // Load average bar fill
)
// BytesPerSec for link speed reference (bytes per second at given mbit)
diff --git a/internal/display/display.go b/internal/display/display.go
index 47f8f10..ffe5ca2 100644
--- a/internal/display/display.go
+++ b/internal/display/display.go
@@ -32,6 +32,8 @@ type runState struct {
cpuMode int // constants.CPUModeAverage / CPUModeCores / CPUModeOff
showMem bool
showNet bool
+ showLoad bool
+ loadPeak float64 // global max load1 across all hosts (for bar scaling)
showSeparators bool
extended bool
winW int32
@@ -103,6 +105,8 @@ func newRunState(cfg *config.Config, winW, winH int32) *runState {
cpuMode: cfg.CPUMode,
showMem: cfg.ShowMem,
showNet: cfg.ShowNet,
+ showLoad: cfg.ShowLoad,
+ loadPeak: 2.0, // minimum floor ensures meaningful scale on idle systems
showSeparators: cfg.ShowSeparators,
extended: cfg.Extended,
winW: winW,
@@ -175,6 +179,9 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r
case sdl.K_3, sdl.K_n:
state.showNet = !state.showNet
fmt.Println("==> Toggled show net:", state.showNet)
+ case sdl.K_4, sdl.K_l:
+ state.showLoad = !state.showLoad
+ fmt.Println("==> Toggled show load:", state.showLoad)
case sdl.K_e:
state.extended = !state.extended
fmt.Println("==> Toggled extended (peak line):", state.extended)
@@ -215,6 +222,7 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r
cfg.CPUMode = state.cpuMode
cfg.ShowMem = state.showMem
cfg.ShowNet = state.showNet
+ cfg.ShowLoad = state.showLoad
cfg.ShowSeparators = state.showSeparators
cfg.Extended = state.extended
if err := cfg.Write(); err != nil {
@@ -288,11 +296,15 @@ func barRect(winW, winH int32, numBars, maxPerRow, barIndex int) (x, y, w, h int
// When showAvgLine/showIOAvgLine are enabled, global average lines are drawn on top.
func drawFrame(renderer *sdl.Renderer, src stats.Source, cfg *config.Config, state *runState) {
snap := src.Snapshot()
- numBars := countBars(snap, state.cpuMode, state.showMem, state.showNet)
+ numBars := countBars(snap, state.cpuMode, state.showMem, state.showNet, state.showLoad)
// Always clear the entire window before drawing. SDL2 uses double-buffering,
// so skipping clear leaves stale content in the back buffer.
renderer.SetDrawColor(0, 0, 0, 255)
renderer.Clear()
+ if state.showLoad {
+ // Update the global load peak before drawing so bar scale is current.
+ updateLoadPeak(snap, state)
+ }
drawBars(renderer, snap, cfg, state, numBars)
if state.showAvgLine {
drawGlobalAvgLine(renderer, snap, state, numBars, cfg.MaxBarsPerRow)
@@ -304,7 +316,7 @@ func drawFrame(renderer *sdl.Renderer, src stats.Source, cfg *config.Config, sta
drawOverlay(renderer, snap, cfg, state)
}
-func countBars(snap map[string]*stats.HostStats, cpuMode int, showMem, showNet bool) int {
+func countBars(snap map[string]*stats.HostStats, cpuMode int, showMem, showNet, showLoad bool) int {
n := 0
for _, host := range sortedHosts(snap) {
if h := snap[host]; h != nil {
@@ -315,6 +327,9 @@ func countBars(snap map[string]*stats.HostStats, cpuMode int, showMem, showNet b
if showNet {
n++
}
+ if showLoad {
+ n++
+ }
}
}
if n == 0 {
@@ -491,6 +506,11 @@ func drawHostBars(renderer *sdl.Renderer, h *stats.HostStats, host string, cfg *
*barIndex++
state.prevNet[host] = drawNetBarSmoothed(renderer, h, cfg, state.smoothedNet[host], state.prevNet[host], smoothFactor, barW, x, y, barH)
}
+ if state.showLoad {
+ x, y, barW, barH := barRect(state.winW, state.winH, numBars, maxPerRow, *barIndex)
+ *barIndex++
+ drawLoadAvgBar(renderer, h, state.loadPeak, barW, x, y, barH)
+ }
}
func peakPctForBar(state *runState, key string, cpuAvg int, s *[10]float64) float64 {
@@ -710,7 +730,7 @@ func drawMemBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, smoothed *st
}
func printHotkeys() {
- fmt.Println("=> Hotkeys: 1=cores 2/m=mem 3/n=net e=extended g=avg line i=io avg s=separators h=help q=quit w=write config a/y=cpu avg d/c=net avg f/v=link scale arrows=resize")
+ fmt.Println("=> Hotkeys: 1=cores 2/m=mem 3/n=net 4/l=load e=extended g=avg line i=io avg s=separators h=help q=quit w=write config a/y=cpu avg d/c=net avg f/v=link scale arrows=resize")
}
// scaleLinkUp moves cfg.NetLink to the next higher link speed in linkScales.
@@ -866,3 +886,76 @@ func drawNetBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, cfg *config.
}
return prev
}
+
+// updateLoadPeak decays the global load peak and updates it with the current
+// maximum 1-min load across all hosts. The floor of 2.0 prevents a zero scale
+// on idle systems. The slow per-frame decay (× 0.9999) lets the scale recover
+// gradually after a spike rather than snapping back immediately.
+func updateLoadPeak(snap map[string]*stats.HostStats, state *runState) {
+ state.loadPeak *= 0.9999 // slow per-frame decay toward idle baseline
+ if state.loadPeak < 2.0 {
+ state.loadPeak = 2.0
+ }
+ for _, h := range snap {
+ if h == nil {
+ continue
+ }
+ if l1, err := strconv.ParseFloat(strings.TrimSpace(h.LoadAvg1), 64); err == nil {
+ if l1 > state.loadPeak {
+ state.loadPeak = l1
+ }
+ }
+ }
+}
+
+// drawLoadAvgBar renders a load-average bar for one host.
+// The teal fill extends from the top downward proportional to the smoothed 1-min
+// load average relative to the global loadPeak scale.
+// A yellow 1px line marks the 5-min average and a white 1px line marks the
+// 15-min average, giving a visual indication of load trend direction:
+// when load is rising the reference lines appear inside the fill;
+// when load is falling they hang below it.
+func drawLoadAvgBar(renderer *sdl.Renderer, h *stats.HostStats, loadPeak float64, barW int32, x, y, barH int32) {
+ // Clear this slot to black before drawing.
+ renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255)
+ renderer.FillRect(&sdl.Rect{X: x, Y: y, W: barW, H: barH})
+
+ // Load averages are already kernel-computed time averages; no further smoothing needed.
+ l1, err1 := strconv.ParseFloat(strings.TrimSpace(h.LoadAvg1), 64)
+ l5, err5 := strconv.ParseFloat(strings.TrimSpace(h.LoadAvg5), 64)
+ l15, err15 := strconv.ParseFloat(strings.TrimSpace(h.LoadAvg15), 64)
+ if err1 != nil || err5 != nil || err15 != nil {
+ return // no valid data yet
+ }
+
+ clamp := func(v, lo, hi float64) float64 {
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+ }
+
+ // Teal fill from top downward for 1-min load.
+ l1H := int32(clamp(l1/loadPeak, 0, 1) * float64(barH))
+ if l1H > 0 {
+ renderer.SetDrawColor(constants.Teal.R, constants.Teal.G, constants.Teal.B, 255)
+ renderer.FillRect(&sdl.Rect{X: x, Y: y, W: barW, H: l1H})
+ }
+
+ // Yellow 1px line for 5-min average.
+ l5Y := y + int32(clamp(l5/loadPeak, 0, 1)*float64(barH))
+ if l5Y < y+barH {
+ renderer.SetDrawColor(constants.Yellow.R, constants.Yellow.G, constants.Yellow.B, 255)
+ renderer.DrawLine(x, l5Y, x+barW-1, l5Y)
+ }
+
+ // White 1px line for 15-min average.
+ l15Y := y + int32(clamp(l15/loadPeak, 0, 1)*float64(barH))
+ if l15Y < y+barH {
+ renderer.SetDrawColor(constants.White.R, constants.White.G, constants.White.B, 255)
+ renderer.DrawLine(x, l15Y, x+barW-1, l15Y)
+ }
+}
diff --git a/internal/display/display_test.go b/internal/display/display_test.go
index b51e6d1..7d3e82e 100644
--- a/internal/display/display_test.go
+++ b/internal/display/display_test.go
@@ -397,7 +397,7 @@ func TestMultiHost_BarCount(t *testing.T) {
}
snap := src.Snapshot()
- numBars := countBars(snap, constants.CPUModeAverage, true, true)
+ numBars := countBars(snap, constants.CPUModeAverage, true, true, false)
if numBars != 6 {
t.Fatalf("expected 6 bars (2 hosts × 3), got %d", numBars)
}
@@ -430,19 +430,19 @@ func TestCores_Toggle(t *testing.T) {
snap := map[string]*stats.HostStats{"host1": hostStats}
// CPUModeAverage: aggregate bar only (1 bar)
- nAverage := countBars(snap, constants.CPUModeAverage, false, false)
+ nAverage := countBars(snap, constants.CPUModeAverage, false, false, false)
if nAverage != 1 {
t.Errorf("CPUModeAverage: expected 1 bar, got %d", nAverage)
}
// CPUModeCores: aggregate + individual cores = cpu + cpu0 + cpu1 (3 bars)
- nCores := countBars(snap, constants.CPUModeCores, false, false)
+ nCores := countBars(snap, constants.CPUModeCores, false, false, false)
if nCores != 3 {
t.Errorf("CPUModeCores: expected 3 bars, got %d", nCores)
}
// CPUModeOff: no CPU bars → countBars floors to 1 (window always shows something)
- nOff := countBars(snap, constants.CPUModeOff, false, false)
+ nOff := countBars(snap, constants.CPUModeOff, false, false, false)
if nOff != 1 {
t.Errorf("CPUModeOff: expected 1 (floor), got %d", nOff)
}
@@ -700,7 +700,7 @@ func TestHandleKey_ToggleCores(t *testing.T) {
}
// State 2 (CPUModeOff): no CPU bars; countBars returns 1 (floor) so window is still drawn
- nOff := countBars(src.Snapshot(), constants.CPUModeOff, false, false)
+ nOff := countBars(src.Snapshot(), constants.CPUModeOff, false, false, false)
if nOff != 1 {
t.Errorf("CPUModeOff: expected countBars=1 (floor), got %d", nOff)
}
diff --git a/internal/display/hittest.go b/internal/display/hittest.go
index fe88471..fd2a218 100644
--- a/internal/display/hittest.go
+++ b/internal/display/hittest.go
@@ -13,6 +13,7 @@ const (
barCPU barKind = iota
barMem
barNet
+ barLoad
)
// barDescriptor describes a single bar's position, host, and type.
@@ -26,7 +27,7 @@ type barDescriptor struct {
// buildBarMap replays the same host/bar iteration as drawBars to produce
// a slice of bar descriptors with their screen rectangles.
func buildBarMap(snap map[string]*stats.HostStats, cfg *config.Config, state *runState) []barDescriptor {
- numBars := countBars(snap, state.cpuMode, state.showMem, state.showNet)
+ numBars := countBars(snap, state.cpuMode, state.showMem, state.showNet, state.showLoad)
maxPerRow := cfg.MaxBarsPerRow
hosts := sortedHosts(snap)
@@ -66,6 +67,15 @@ func buildBarMap(snap map[string]*stats.HostStats, cfg *config.Config, state *ru
})
barIndex++
}
+ if state.showLoad {
+ x, y, w, bh := barRect(state.winW, state.winH, numBars, maxPerRow, barIndex)
+ bars = append(bars, barDescriptor{
+ host: host,
+ kind: barLoad,
+ rect: sdl.Rect{X: x, Y: y, W: w, H: bh},
+ })
+ barIndex++
+ }
}
return bars
}
diff --git a/internal/display/tooltip.go b/internal/display/tooltip.go
index 42075e0..e5cf951 100644
--- a/internal/display/tooltip.go
+++ b/internal/display/tooltip.go
@@ -2,6 +2,8 @@ package display
import (
"fmt"
+ "strconv"
+ "strings"
"time"
"codeberg.org/snonux/loadbars/internal/config"
@@ -34,6 +36,8 @@ func tooltipLines(bar *barDescriptor, snap map[string]*stats.HostStats, cfg *con
return memTooltipLines(bar, h, state)
case barNet:
return netTooltipLines(bar, cfg, state)
+ case barLoad:
+ return loadTooltipLines(bar, h, state)
}
return nil
}
@@ -96,6 +100,26 @@ func netTooltipLines(bar *barDescriptor, cfg *config.Config, state *runState) []
return lines
}
+// loadTooltipLines returns tooltip text for a load-average bar showing
+// the smoothed 1/5/15-min averages and the current global peak scale.
+func loadTooltipLines(bar *barDescriptor, h *stats.HostStats, state *runState) []string {
+ lines := []string{fmt.Sprintf("%s [load]", bar.host)}
+ l1, err1 := strconv.ParseFloat(strings.TrimSpace(h.LoadAvg1), 64)
+ l5, err5 := strconv.ParseFloat(strings.TrimSpace(h.LoadAvg5), 64)
+ l15, err15 := strconv.ParseFloat(strings.TrimSpace(h.LoadAvg15), 64)
+ if err1 != nil || err5 != nil || err15 != nil {
+ lines = append(lines, "No data yet")
+ return lines
+ }
+ lines = append(lines,
+ fmt.Sprintf("1min: %.2f", l1),
+ fmt.Sprintf("5min: %.2f", l5),
+ fmt.Sprintf("15min: %.2f", l15),
+ fmt.Sprintf("Peak: %.2f", state.loadPeak),
+ )
+ return lines
+}
+
// formatKB formats a value in KB as a human-readable string (KB, MB, or GB).
func formatKB(kb int64) string {
switch {
diff --git a/internal/version/version.go b/internal/version/version.go
index 2c64451..5261309 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -1,4 +1,4 @@
package version
// Version is the application version (set at build time or here for development).
-const Version = "0.11.1"
+const Version = "0.12.0"