summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md5
-rw-r--r--internal/config/config.go10
-rw-r--r--internal/config/config_test.go37
-rw-r--r--internal/constants/constants.go1
-rw-r--r--internal/display/display.go61
-rw-r--r--internal/display/display_test.go196
6 files changed, 297 insertions, 13 deletions
diff --git a/README.md b/README.md
index 836b8df..4746d61 100644
--- a/README.md
+++ b/README.md
@@ -108,10 +108,11 @@ Press these keys while loadbars is running (see also `h` for a short list on std
| Key | Action |
|-----|--------|
| **1** | Toggle CPU cores (one bar per core vs one aggregate bar per host) |
-| **2** | Toggle memory bars (RAM left, Swap right per host) |
-| **3** | Toggle network bars (RX/TX summed across all non-lo interfaces per host) |
+| **2** / **m** | Toggle memory bars (RAM left, Swap right per host) |
+| **3** / **n** | Toggle network bars (RX/TX summed across all non-lo interfaces per host) |
| **e** | Toggle extended display (1px peak line on CPU bars: max system+user over last samples) |
| **g** | Toggle global average CPU line (1px red line showing mean CPU usage across all hosts) |
+| **i** | Toggle global I/O average line (1px pink line showing mean iowait+IRQ across all hosts) |
| **h** | Print hotkey list to stdout |
| **q** | Quit |
| **w** | Write current settings to ~/.loadbarsrc |
diff --git a/internal/config/config.go b/internal/config/config.go
index 036d081..d53fd7f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -24,8 +24,9 @@ type Config struct {
MaxWidth int
NetAverage int
NetLink string
- ShowAvgLine bool
- ShowCores bool
+ ShowAvgLine bool
+ ShowIOAvgLine bool
+ ShowCores bool
ShowMem bool
ShowNet bool
SSHOpts string
@@ -105,7 +106,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, "showcores": true, "showmem": true,
- "showavgline": true, "shownet": true, "sshopts": true, "cluster": true,
+ "showavgline": true, "showioavgline": true, "shownet": true, "sshopts": true, "cluster": true,
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
@@ -162,6 +163,8 @@ func (c *Config) set(key, val string) {
c.NetLink = val
case "showavgline":
c.ShowAvgLine = parseBool(val)
+ case "showioavgline":
+ c.ShowIOAvgLine = parseBool(val)
case "showcores":
c.ShowCores = parseBool(val)
case "showmem":
@@ -200,6 +203,7 @@ func (c *Config) writeTo(f *os.File) error {
writeInt("netaverage", c.NetAverage)
writeStr("netlink", c.NetLink)
writeBool("showavgline", c.ShowAvgLine)
+ writeBool("showioavgline", c.ShowIOAvgLine)
writeBool("showcores", c.ShowCores)
writeBool("showmem", c.ShowMem)
writeBool("shownet", c.ShowNet)
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index db0e068..a8535be 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -115,6 +115,43 @@ func TestConfig_showAvgLineRoundTrip(t *testing.T) {
}
}
+func TestConfig_showIOAvgLineRoundTrip(t *testing.T) {
+ // Write a config with showioavgline=1, read it back, verify round-trip
+ c := Default()
+ c.ShowIOAvgLine = true
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "rc")
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := c.writeTo(f); err != nil {
+ f.Close()
+ t.Fatal(err)
+ }
+ f.Close()
+
+ data, _ := os.ReadFile(path)
+ if !bytes.Contains(data, []byte("showioavgline=1")) {
+ t.Errorf("expected showioavgline=1 in output, got:\n%s", data)
+ }
+
+ // Read it back
+ c2 := Default()
+ f2, err := os.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f2.Close()
+ if err := c2.parseReader(f2); err != nil {
+ t.Fatal(err)
+ }
+ if !c2.ShowIOAvgLine {
+ t.Error("expected ShowIOAvgLine=true after round-trip")
+ }
+}
+
func TestGetClusterHostsFromFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "clusters")
diff --git a/internal/constants/constants.go b/internal/constants/constants.go
index 6787045..82600d2 100644
--- a/internal/constants/constants.go
+++ b/internal/constants/constants.go
@@ -40,6 +40,7 @@ var (
LightGreen = RGB{0x00, 0xf0, 0x00}
Orange = RGB{0xff, 0x70, 0x00}
Purple = RGB{0xa0, 0x20, 0xf0}
+ Pink = RGB{0xff, 0x40, 0xff}
Red = RGB{0xff, 0x00, 0x00}
White = RGB{0xff, 0xff, 0xff}
Grey0 = RGB{0x11, 0x11, 0x11}
diff --git a/internal/display/display.go b/internal/display/display.go
index 649f636..fe5340e 100644
--- a/internal/display/display.go
+++ b/internal/display/display.go
@@ -21,8 +21,9 @@ const smoothFactor = 0.12 // blend toward target each frame; lower = smoother
// runState holds mutable state across the display loop (hotkeys, window size, smoothed data).
type runState struct {
- showAvgLine bool
- showCores bool
+ showAvgLine bool
+ showIOAvgLine bool
+ showCores bool
showMem bool
showNet bool
extended bool
@@ -39,8 +40,9 @@ type runState struct {
// newRunState builds initial run state from config.
func newRunState(cfg *config.Config, winW, winH int32) *runState {
return &runState{
- showAvgLine: cfg.ShowAvgLine,
- showCores: cfg.ShowCores,
+ showAvgLine: cfg.ShowAvgLine,
+ showIOAvgLine: cfg.ShowIOAvgLine,
+ showCores: cfg.ShowCores,
showMem: cfg.ShowMem,
showNet: cfg.ShowNet,
extended: cfg.Extended,
@@ -143,10 +145,10 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r
case sdl.K_1:
state.showCores = !state.showCores
fmt.Println("==> Toggled show cores:", state.showCores)
- case sdl.K_2:
+ case sdl.K_2, sdl.K_m:
state.showMem = !state.showMem
fmt.Println("==> Toggled show mem:", state.showMem)
- case sdl.K_3:
+ case sdl.K_3, sdl.K_n:
state.showNet = !state.showNet
fmt.Println("==> Toggled show net:", state.showNet)
case sdl.K_e:
@@ -155,6 +157,9 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r
case sdl.K_g:
state.showAvgLine = !state.showAvgLine
fmt.Println("==> Toggled global avg line:", state.showAvgLine)
+ case sdl.K_i:
+ state.showIOAvgLine = !state.showIOAvgLine
+ fmt.Println("==> Toggled global I/O avg line:", state.showIOAvgLine)
case sdl.K_a:
cfg.CPUAverage++
fmt.Println("==> CPU average samples:", cfg.CPUAverage)
@@ -179,6 +184,7 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r
printHotkeys()
case sdl.K_w:
cfg.ShowAvgLine = state.showAvgLine
+ cfg.ShowIOAvgLine = state.showIOAvgLine
cfg.ShowCores = state.showCores
cfg.ShowMem = state.showMem
cfg.ShowNet = state.showNet
@@ -226,7 +232,7 @@ func barBounds(winW int32, numBars int, barIndex int) (x int32, width int32) {
}
// drawFrame updates state from snapshot, clears if layout changed, and draws all bars.
-// When showAvgLine is enabled, a global average CPU line is drawn on top.
+// 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.showCores, state.showMem, state.showNet)
@@ -238,6 +244,9 @@ func drawFrame(renderer *sdl.Renderer, src stats.Source, cfg *config.Config, sta
if state.showAvgLine {
drawGlobalAvgLine(renderer, snap, state)
}
+ if state.showIOAvgLine {
+ drawGlobalIOAvgLine(renderer, snap, state)
+ }
}
func countBars(snap map[string]*stats.HostStats, showCores, showMem, showNet bool) int {
@@ -312,6 +321,42 @@ func drawGlobalAvgLine(renderer *sdl.Renderer, snap map[string]*stats.HostStats,
renderer.FillRect(&sdl.Rect{X: 0, Y: lineY, W: state.winW, H: 1})
}
+// drawGlobalIOAvgLine draws a 1px pink horizontal line from the top of the window
+// at the Y position corresponding to the mean I/O overhead (iowait + IRQ + softIRQ,
+// indices 4, 5, 6 in the smoothed CPU array) across all hosts.
+func drawGlobalIOAvgLine(renderer *sdl.Renderer, snap map[string]*stats.HostStats, state *runState) {
+ var totalIO float64
+ var hostCount int
+ for _, host := range sortedHosts(snap) {
+ h := snap[host]
+ if h == nil {
+ continue
+ }
+ key := host + ";cpu"
+ s := state.smoothedCPU[key]
+ if s == nil {
+ continue
+ }
+ // Sum iowait (4) + IRQ (5) + softIRQ (6) for I/O overhead
+ totalIO += (*s)[4] + (*s)[5] + (*s)[6]
+ hostCount++
+ }
+ if hostCount == 0 {
+ return
+ }
+ avgPct := totalIO / float64(hostCount)
+ // Draw from top: lineY = percentage of window height from the top
+ lineY := int32(avgPct * float64(state.winH) / 100)
+ if lineY < 0 {
+ lineY = 0
+ }
+ if lineY >= state.winH {
+ lineY = state.winH - 1
+ }
+ renderer.SetDrawColor(constants.Pink.R, constants.Pink.G, constants.Pink.B, 255)
+ renderer.FillRect(&sdl.Rect{X: 0, Y: lineY, W: state.winW, H: 1})
+}
+
// drawHostBars draws CPU, mem, and net bars for one host and advances barIndex.
func drawHostBars(renderer *sdl.Renderer, h *stats.HostStats, host string, cfg *config.Config, state *runState, numBars int, barIndex *int) {
winH := state.winH
@@ -564,7 +609,7 @@ func drawMemBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, smoothed *st
}
func printHotkeys() {
- fmt.Println("=> Hotkeys: 1=cores 2=mem 3=net e=extended g=avg line 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 e=extended g=avg line i=io avg h=help q=quit w=write config a/y=cpu avg d/c=net avg f/v=link scale arrows=resize")
}
diff --git a/internal/display/display_test.go b/internal/display/display_test.go
index f0377b5..734e144 100644
--- a/internal/display/display_test.go
+++ b/internal/display/display_test.go
@@ -710,6 +710,23 @@ func TestHandleKey_ToggleMem(t *testing.T) {
assertPixelColor(t, surface, 110, 95, constants.DarkGrey, 5, "mem bar RAM after toggle")
}
+func TestHandleKey_ToggleMemAlias(t *testing.T) {
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+ if state.showMem {
+ t.Fatal("expected showMem=false initially")
+ }
+ // 'm' should toggle mem just like '2'
+ handleKey(sdl.K_m, nil, cfg, state)
+ if !state.showMem {
+ t.Fatal("expected showMem=true after pressing m")
+ }
+ handleKey(sdl.K_m, nil, cfg, state)
+ if state.showMem {
+ t.Fatal("expected showMem=false after pressing m again")
+ }
+}
+
func TestHandleKey_ToggleNet(t *testing.T) {
renderer, surface, cfg, state, src := newHotkeyTestEnv(t, false, false, false)
defer renderer.Destroy()
@@ -729,6 +746,23 @@ func TestHandleKey_ToggleNet(t *testing.T) {
assertPixelColor(t, surface, 110, 2, constants.LightGreen, 5, "net bar RX after toggle")
}
+func TestHandleKey_ToggleNetAlias(t *testing.T) {
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+ if state.showNet {
+ t.Fatal("expected showNet=false initially")
+ }
+ // 'n' should toggle net just like '3'
+ handleKey(sdl.K_n, nil, cfg, state)
+ if !state.showNet {
+ t.Fatal("expected showNet=true after pressing n")
+ }
+ handleKey(sdl.K_n, nil, cfg, state)
+ if state.showNet {
+ t.Fatal("expected showNet=false after pressing n again")
+ }
+}
+
func TestHandleKey_ToggleExtended(t *testing.T) {
renderer, surface, cfg, state, src := newHotkeyTestEnv(t, false, false, false)
defer renderer.Destroy()
@@ -1070,3 +1104,165 @@ func TestHandleKey_ArrowResize(t *testing.T) {
t.Errorf("expected winH=1 (clamped), got %d", state.winH)
}
}
+
+// makeCPUPairWithIO creates a (prev, cur) pair where the delta yields the desired
+// system, user, idle, iowait, irq, and softirq percentages.
+func makeCPUPairWithIO(systemPct, userPct, idlePct, iowaitPct, irqPct, softirqPct float64) (prev, cur collector.CPULine) {
+ const base = 1000
+ const delta = 1000
+ prev = collector.CPULine{Idle: base}
+ dSys := int64(systemPct * float64(delta) / 100)
+ dUser := int64(userPct * float64(delta) / 100)
+ dIdle := int64(idlePct * float64(delta) / 100)
+ dIowait := int64(iowaitPct * float64(delta) / 100)
+ dIRQ := int64(irqPct * float64(delta) / 100)
+ dSoftIRQ := int64(softirqPct * float64(delta) / 100)
+ dNice := delta - dSys - dUser - dIdle - dIowait - dIRQ - dSoftIRQ
+ if dNice < 0 {
+ dNice = 0
+ }
+ cur = collector.CPULine{
+ System: prev.System + dSys,
+ User: prev.User + dUser,
+ Idle: prev.Idle + dIdle,
+ Nice: prev.Nice + dNice,
+ Iowait: prev.Iowait + dIowait,
+ IRQ: prev.IRQ + dIRQ,
+ SoftIRQ: prev.SoftIRQ + dSoftIRQ,
+ }
+ return prev, cur
+}
+
+func TestHandleKey_ToggleIOAvgLine(t *testing.T) {
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+ if state.showIOAvgLine {
+ t.Fatal("expected showIOAvgLine=false initially")
+ }
+ handleKey(sdl.K_i, nil, cfg, state)
+ if !state.showIOAvgLine {
+ t.Fatal("expected showIOAvgLine=true after pressing i")
+ }
+ handleKey(sdl.K_i, nil, cfg, state)
+ if state.showIOAvgLine {
+ t.Fatal("expected showIOAvgLine=false after pressing i again")
+ }
+}
+
+func TestGlobalIOAvgLine_SingleHost(t *testing.T) {
+ // One host with 20% iowait + 5% irq + 5% softirq = 30% → pink line at y=30 from top
+ const w, h int32 = 100, 100
+
+ renderer, surface, err := createTestRenderer(w, h)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer renderer.Destroy()
+ defer surface.Free()
+
+ prev, cur := makeCPUPairWithIO(10, 10, 20, 20, 5, 5)
+ cfg := defaultTestConfig()
+
+ src := &mockSource{
+ data: map[string]*stats.HostStats{
+ "host1": {CPU: map[string]collector.CPULine{"cpu": cur}},
+ },
+ }
+
+ state := newRunState(cfg, w, h)
+ state.showIOAvgLine = true
+ state.prevCPU["host1;cpu"] = prev
+
+ drawFrame(renderer, src, cfg, state)
+
+ // Pink line at y=30 (30% from top in a 100px window)
+ assertPixelColor(t, surface, 50, 30, constants.Pink, 3, "IO avg line at y=30")
+ // Spans full width
+ assertPixelColor(t, surface, 0, 30, constants.Pink, 3, "IO avg line at x=0")
+ assertPixelColor(t, surface, 99, 30, constants.Pink, 3, "IO avg line at x=99")
+}
+
+func TestGlobalIOAvgLine_MultiHost(t *testing.T) {
+ // Two hosts: host1=30% IO, host2=0% IO → average 15% → pink line at y=15
+ const w, h int32 = 100, 100
+
+ renderer, surface, err := createTestRenderer(w, h)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer renderer.Destroy()
+ defer surface.Free()
+
+ prev1, cur1 := makeCPUPairWithIO(10, 10, 20, 20, 5, 5) // 30% IO
+ prev2, cur2 := makeCPUPair(40, 40, 20) // 0% IO
+
+ cfg := defaultTestConfig()
+
+ src := &mockSource{
+ data: map[string]*stats.HostStats{
+ "alpha": {CPU: map[string]collector.CPULine{"cpu": cur1}},
+ "beta": {CPU: map[string]collector.CPULine{"cpu": cur2}},
+ },
+ }
+
+ state := newRunState(cfg, w, h)
+ state.showIOAvgLine = true
+ state.prevCPU["alpha;cpu"] = prev1
+ state.prevCPU["beta;cpu"] = prev2
+
+ drawFrame(renderer, src, cfg, state)
+
+ // Average 15% → pink line at y=15
+ assertPixelColor(t, surface, 50, 15, constants.Pink, 3, "IO avg line at y=15")
+}
+
+func TestGlobalIOAvgLine_Disabled(t *testing.T) {
+ // With showIOAvgLine=false, no pink line should appear
+ const w, h int32 = 100, 100
+
+ renderer, surface, err := createTestRenderer(w, h)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer renderer.Destroy()
+ defer surface.Free()
+
+ prev, cur := makeCPUPairWithIO(10, 10, 20, 20, 5, 5) // 30% IO
+ cfg := defaultTestConfig()
+
+ src := &mockSource{
+ data: map[string]*stats.HostStats{
+ "host1": {CPU: map[string]collector.CPULine{"cpu": cur}},
+ },
+ }
+
+ state := newRunState(cfg, w, h)
+ state.showIOAvgLine = false
+ state.prevCPU["host1;cpu"] = prev
+
+ drawFrame(renderer, src, cfg, state)
+
+ // At y=30, there should be no pink line
+ r, g, b := getPixelColor(surface, 50, 30)
+ if r == constants.Pink.R && g == constants.Pink.G && b == constants.Pink.B {
+ t.Errorf("expected no pink IO avg line at y=30 when disabled, got RGB(%d,%d,%d)", r, g, b)
+ }
+}
+
+func TestHandleKey_WriteConfig_IOAvgLine(t *testing.T) {
+ // Verify that 'w' hotkey persists showIOAvgLine to config
+ tmpDir := t.TempDir()
+ origHome := os.Getenv("HOME")
+ os.Setenv("HOME", tmpDir)
+ defer os.Setenv("HOME", origHome)
+
+ cfg := defaultTestConfig()
+ state := newRunState(cfg, 200, 100)
+ state.showIOAvgLine = true
+
+ handleKey(sdl.K_w, nil, cfg, state)
+
+ if !cfg.ShowIOAvgLine {
+ t.Error("expected ShowIOAvgLine=true in config after 'w'")
+ }
+}