diff options
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | internal/config/config.go | 10 | ||||
| -rw-r--r-- | internal/config/config_test.go | 37 | ||||
| -rw-r--r-- | internal/constants/constants.go | 1 | ||||
| -rw-r--r-- | internal/display/display.go | 61 | ||||
| -rw-r--r-- | internal/display/display_test.go | 196 |
6 files changed, 297 insertions, 13 deletions
@@ -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'") + } +} |
