diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-18 09:19:03 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-18 09:19:03 +0200 |
| commit | d845cf3208c3bbdb7e3dd3041d1ae491b88d4d21 (patch) | |
| tree | 5fa93b7787a1f1c9598aecfc772ede6dbac17f8d | |
| parent | 88f4e239a7521112a4db8c7842e3a05db4446cd4 (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.go | 6 | ||||
| -rw-r--r-- | internal/constants/constants.go | 1 | ||||
| -rw-r--r-- | internal/display/display.go | 99 | ||||
| -rw-r--r-- | internal/display/display_test.go | 10 | ||||
| -rw-r--r-- | internal/display/hittest.go | 12 | ||||
| -rw-r--r-- | internal/display/tooltip.go | 24 | ||||
| -rw-r--r-- | internal/version/version.go | 2 |
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" |
