diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-02 12:41:08 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-02 12:41:08 +0200 |
| commit | d0b9bc84aed1898a06a9d6fc3b82beee407d3cde (patch) | |
| tree | 7bb343960ee912d77dbc9f7720cd8cdd1f0172ea | |
| parent | bbc91e8764bd83c4497f2ddac86bb8947a91765c (diff) | |
Refactor display iteration/state and harden collector runtime
| -rw-r--r-- | internal/app/app.go | 41 | ||||
| -rw-r--r-- | internal/collector/collector.go | 5 | ||||
| -rw-r--r-- | internal/collector/types.go | 52 | ||||
| -rw-r--r-- | internal/display/cpu.go | 151 | ||||
| -rw-r--r-- | internal/display/disk.go | 8 | ||||
| -rw-r--r-- | internal/display/display.go | 652 | ||||
| -rw-r--r-- | internal/display/display_test.go | 26 | ||||
| -rw-r--r-- | internal/display/hittest.go | 74 | ||||
| -rw-r--r-- | internal/display/mem.go | 58 | ||||
| -rw-r--r-- | internal/display/net.go | 122 | ||||
| -rw-r--r-- | internal/stats/stats.go | 6 | ||||
| -rw-r--r-- | internal/stats/types.go | 46 |
12 files changed, 638 insertions, 603 deletions
diff --git a/internal/app/app.go b/internal/app/app.go index c18dc30..85c348a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,7 +2,11 @@ package app import ( "context" + "fmt" + "os" + "strings" "sync" + "time" "codeberg.org/snonux/loadbars/internal/collector" "codeberg.org/snonux/loadbars/internal/config" @@ -23,7 +27,7 @@ func Run(cfg *config.Config) error { wg.Add(1) go func() { defer wg.Done() - _ = collector.Run(ctx, h, cfg, store) + runCollectorLoop(ctx, h, cfg, store) }() } @@ -32,3 +36,38 @@ func Run(cfg *config.Config) error { wg.Wait() return err } + +func runCollectorLoop(ctx context.Context, host string, cfg *config.Config, store *Store) { + backoff := time.Second + for { + err := collector.Run(ctx, host, cfg, store) + if err == nil || ctx.Err() != nil { + return + } + fmt.Fprintf(os.Stderr, "!!! collector %s failed: %v\n", host, err) + if !isRemoteHost(host) { + return + } + timer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + if backoff < 30*time.Second { + backoff *= 2 + if backoff > 30*time.Second { + backoff = 30 * time.Second + } + } + } +} + +func isRemoteHost(host string) bool { + host = strings.TrimSpace(host) + if i := strings.Index(host, ":"); i >= 0 { + host = strings.TrimSpace(host[:i]) + } + return host != "localhost" && host != "127.0.0.1" +} diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 5c99347..9ee7e02 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "fmt" + "os" "os/exec" "strings" "time" @@ -163,8 +164,8 @@ func isLocal(h string) bool { // getLocalScript returns the appropriate script for the local OS func getLocalScript() []byte { - // Check if /proc exists (Linux/Unix) - if _, err := exec.Command("test", "-d", "/proc").CombinedOutput(); err == nil { + // Check if /proc exists (Linux/Unix). + if st, err := os.Stat("/proc"); err == nil && st.IsDir() { return LinuxScript } // /proc not found - unsupported OS for local stats gathering diff --git a/internal/collector/types.go b/internal/collector/types.go index 6d1ecf0..3fac47b 100644 --- a/internal/collector/types.go +++ b/internal/collector/types.go @@ -1,46 +1,10 @@ package collector -// CPULine is one line of /proc/stat: cpu name + counters (user, nice, system, idle, ...). -type CPULine struct { - Name string - User, Nice, System, Idle, Iowait, IRQ, SoftIRQ, Steal, Guest, GuestNice int64 -} - -// Total returns sum of all CPU counters. -func (c CPULine) Total() int64 { - return c.User + c.Nice + c.System + c.Idle + c.Iowait + c.IRQ + c.SoftIRQ + c.Steal + c.Guest + c.GuestNice -} - -// MemLine is one key from /proc/meminfo (e.g. MemTotal, MemFree). -type MemLine struct { - Key string - Value int64 -} - -// NetLine is one interface line: iface and key=value pairs (b, tb, p, tp, e, te, d, td). -type NetLine struct { - Iface string - B int64 // rx bytes - Tb int64 // tx bytes - P int64 - Tp int64 - E int64 - Te int64 - D int64 - Td int64 -} - -// LoadAvg is 1/5/15 min load average. -type LoadAvg struct { - Load1, Load5, Load15 string -} - -// DiskLine is one device from /proc/diskstats with cumulative counters. -type DiskLine struct { - Device string - SectorsRead int64 // cumulative sectors read (each sector = 512 bytes) - SectorsWrite int64 // cumulative sectors written - ReadTicks int64 // cumulative ms spent reading - WriteTicks int64 // cumulative ms spent writing - IoTicks int64 // cumulative ms the device had I/O in progress -} +import "codeberg.org/snonux/loadbars/internal/stats" + +// Collector parsing uses the shared stats protocol types. +type CPULine = stats.CPULine +type MemLine = stats.MemLine +type NetLine = stats.NetLine +type LoadAvg = stats.LoadAvg +type DiskLine = stats.DiskLine diff --git a/internal/display/cpu.go b/internal/display/cpu.go new file mode 100644 index 0000000..1b9edaf --- /dev/null +++ b/internal/display/cpu.go @@ -0,0 +1,151 @@ +package display + +import ( + "sort" + + "codeberg.org/snonux/loadbars/internal/constants" + "codeberg.org/snonux/loadbars/internal/stats" + "github.com/veandco/go-sdl2/sdl" +) + +func peakPctForBar(state *runState, key string, cpuAvg int, s *[10]float64) float64 { + if !state.extended || s == nil { + return 0 + } + userSys := (*s)[0] + (*s)[1] + hist := state.peakHistory[key] + hist = append(hist, userSys) + n := cpuAvg + if n < 1 { + n = 1 + } + for len(hist) > n { + hist = hist[1:] + } + state.peakHistory[key] = hist + var max float64 + for _, v := range hist { + if v > max { + max = v + } + } + return max +} + +func sortedCPUNames(cpu map[string]stats.CPULine, cpuMode int) []string { + if cpuMode == constants.CPUModeOff { + return nil + } + var names []string + for name := range cpu { + if name == "cpu" { + names = append(names, "cpu") + continue + } + if cpuMode == constants.CPUModeCores { + names = append(names, name) + } + } + sort.Slice(names, func(i, j int) bool { + if names[i] == "cpu" { + return true + } + if names[j] == "cpu" { + return false + } + return names[i] < names[j] + }) + return names +} + +// cpuBarTargetPcts returns the 9 segment percentages (system, user, nice, idle, iowait, irq, softirq, guest, steal) from cur/prev delta. +func cpuBarTargetPcts(cur, prev stats.CPULine) (out [10]float64, ok bool) { + totalCur := cur.Total() + totalPrev := prev.Total() + if totalPrev == 0 || totalCur <= totalPrev { + return out, false + } + scale := float64(totalCur-totalPrev) / 100.0 + if scale <= 0 { + return out, false + } + out[0] = float64(cur.System-prev.System) / scale + out[1] = float64(cur.User-prev.User) / scale + out[2] = float64(cur.Nice-prev.Nice) / scale + out[3] = float64(cur.Idle-prev.Idle) / scale + out[4] = float64(cur.Iowait-prev.Iowait) / scale + out[5] = float64(cur.IRQ-prev.IRQ) / scale + out[6] = float64(cur.SoftIRQ-prev.SoftIRQ) / scale + out[7] = float64(cur.Guest-prev.Guest) / scale + out[8] = float64(cur.Steal-prev.Steal) / scale + out[9] = float64(cur.GuestNice-prev.GuestNice) / scale + for i := range out { + if out[i] < 0 { + out[i] = 0 + } + if out[i] > 100 { + out[i] = 100 + } + } + return out, true +} + +func normalizePcts(s *[10]float64) { + var sum float64 + for i := 0; i < 10; i++ { + sum += (*s)[i] + } + if sum <= 0 { + return + } + for i := 0; i < 10; i++ { + (*s)[i] = (*s)[i] * 100 / sum + } +} + +func drawCPUBarFromPcts(renderer *sdl.Renderer, s *[10]float64, barW int32, x, y, barH int32, extended bool, peakPct float64) { + renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) + renderer.FillRect(&sdl.Rect{X: x, Y: y, W: barW, H: barH}) + if s == nil { + return + } + pxPerPct := float64(barH) / 100.0 + curY := float64(y + barH) + fill := func(r, g, b uint8, pct float64) { + hh := int32(pct * pxPerPct) + if hh < 1 && pct > 0 { + hh = 1 + } + curY -= float64(hh) + renderer.SetDrawColor(r, g, b, 255) + renderer.FillRect(&sdl.Rect{X: x, Y: int32(curY), W: barW, H: hh}) + } + fill(constants.Blue.R, constants.Blue.G, constants.Blue.B, (*s)[0]) + fill(constants.Yellow.R, constants.Yellow.G, constants.Yellow.B, (*s)[1]) + fill(constants.Green.R, constants.Green.G, constants.Green.B, (*s)[2]) + fill(constants.LimeGreen.R, constants.LimeGreen.G, constants.LimeGreen.B, (*s)[9]) + fill(constants.Black.R, constants.Black.G, constants.Black.B, (*s)[3]) + fill(constants.Purple.R, constants.Purple.G, constants.Purple.B, (*s)[4]) + fill(constants.White.R, constants.White.G, constants.White.B, (*s)[5]) + fill(constants.White.R, constants.White.G, constants.White.B, (*s)[6]) + fill(constants.Red.R, constants.Red.G, constants.Red.B, (*s)[7]) + fill(constants.Red.R, constants.Red.G, constants.Red.B, (*s)[8]) + if !extended || peakPct <= 0 { + return + } + peakY := y + barH - int32(peakPct*pxPerPct) + if peakY < y { + peakY = y + } + if peakY >= y+barH { + peakY = y + barH - 1 + } + if peakPct > float64(constants.UserOrangeThreshold) { + renderer.SetDrawColor(constants.Orange.R, constants.Orange.G, constants.Orange.B, 255) + } else if peakPct > float64(constants.UserYellowThreshold) { + renderer.SetDrawColor(constants.Yellow0.R, constants.Yellow0.G, constants.Yellow0.B, 255) + } else { + renderer.SetDrawColor(constants.Yellow.R, constants.Yellow.G, constants.Yellow.B, 255) + } + renderer.FillRect(&sdl.Rect{X: x, Y: peakY, W: barW, H: 1}) +} diff --git a/internal/display/disk.go b/internal/display/disk.go index 9d03ba9..8615b21 100644 --- a/internal/display/disk.go +++ b/internal/display/disk.go @@ -115,7 +115,7 @@ func updateDiskPeak(snap map[string]*stats.HostStats, state *runState, diskMax f // drawDiskBarSmoothed draws a single disk bar with read (top, purple) and write (bottom, // darker purple). Returns the current DiskStamp to be stored as previous for the next frame. -func drawDiskBarSmoothed(renderer *sdl.Renderer, cur stats.DiskStamp, cfg *runState, smoothed *struct{ readPct, writePct float64 }, prev stats.DiskStamp, factor float64, barW, x, y, barH int32, extended bool) stats.DiskStamp { +func drawDiskBarSmoothed(renderer *sdl.Renderer, cur stats.DiskStamp, state *runState, smoothed *struct{ readPct, writePct float64 }, prev stats.DiskStamp, factor float64, barW, x, y, barH int32, extended bool) stats.DiskStamp { // Clear this slot to a dim purple so the bar is visible even when idle. // This distinguishes "disk bar present but idle" from background. renderer.SetDrawColor(0x18, 0x00, 0x28, 255) @@ -123,7 +123,7 @@ func drawDiskBarSmoothed(renderer *sdl.Renderer, cur stats.DiskStamp, cfg *runSt // Only recompute when the collector has provided new data (same guard as net bars). if cur.Stamp > prev.Stamp && prev.Stamp > 0 { - prev = smoothDiskUtilization(cur, prev, cfg, smoothed, factor) + prev = smoothDiskUtilization(cur, prev, state, smoothed, factor) } else if prev.Stamp == 0 && cur.Stamp > 0 { // First sample: record it but can't compute delta yet. prev = cur @@ -133,7 +133,7 @@ func drawDiskBarSmoothed(renderer *sdl.Renderer, cur stats.DiskStamp, cfg *runSt // In extended mode, overlay a utilization % line if extended && prev.Stamp > 0 { - drawDiskUtilLine(renderer, cur, prev, cfg, x, y, barW, barH) + drawDiskUtilLine(renderer, cur, prev, x, y, barW, barH) } return prev } @@ -192,7 +192,7 @@ func drawDiskHalves(renderer *sdl.Renderer, smoothed *struct{ readPct, writePct // drawDiskUtilLine draws a 3px-thick horizontal line showing disk utilization % // (fraction of time the device had I/O in progress) in extended mode. -func drawDiskUtilLine(renderer *sdl.Renderer, cur, prev stats.DiskStamp, state *runState, x, y, barW, barH int32) { +func drawDiskUtilLine(renderer *sdl.Renderer, cur, prev stats.DiskStamp, x, y, barW, barH int32) { dt := cur.Stamp - prev.Stamp if dt <= 0 { return diff --git a/internal/display/display.go b/internal/display/display.go index 514a62f..f5c545c 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "codeberg.org/snonux/loadbars/internal/collector" "codeberg.org/snonux/loadbars/internal/config" "codeberg.org/snonux/loadbars/internal/constants" "codeberg.org/snonux/loadbars/internal/stats" @@ -25,32 +24,48 @@ const smoothFactor = 0.12 // used by the f/v hotkeys to cycle through link scale values. var linkScales = []string{"mbit", "10mbit", "100mbit", "gbit", "10gbit"} -// runState holds mutable state across the display loop (hotkeys, window size, smoothed data). -type runState struct { +type displayFlags struct { showAvgLine bool showIOAvgLine bool 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 - winH int32 - prevCPU map[string]collector.CPULine - smoothedCPU map[string]*[10]float64 - smoothedMem map[string]*struct{ ramUsed, swapUsed float64 } - smoothedNet map[string]*struct{ rxPct, txPct float64 } - prevNet map[string]stats.NetStamp // aggregated (summed) previous net stamp per host - peakHistory map[string][]float64 - diskMode int // constants.DiskModeAggregate / DiskModeDevices / DiskModeOff - diskPeak float64 // auto-scale peak (bytes/sec) for disk bars - prevDisk map[string]stats.DiskStamp // previous disk stamp per host+device key - smoothedDisk map[string]*struct{ readPct, writePct float64 } - mouseX int32 // last known mouse X position (for tooltip hit testing) - mouseY int32 // last known mouse Y position (for tooltip hit testing) - mouseLastMove time.Time // timestamp of last mouse movement; tooltip hidden after 3s idle + diskMode int // constants.DiskModeAggregate / DiskModeDevices / DiskModeOff +} + +type smoothState struct { + prevCPU map[string]stats.CPULine + smoothedCPU map[string]*[10]float64 + smoothedMem map[string]*struct{ ramUsed, swapUsed float64 } + smoothedNet map[string]*struct{ rxPct, txPct float64 } + prevNet map[string]stats.NetStamp // aggregated (summed) previous net stamp per host + peakHistory map[string][]float64 + prevDisk map[string]stats.DiskStamp + smoothedDisk map[string]*struct{ readPct, writePct float64 } +} + +type peakState struct { + loadPeak float64 // global max load1 across all hosts (for bar scaling) + diskPeak float64 // auto-scale peak (bytes/sec) for disk bars +} + +type mouseState struct { + mouseX int32 // last known mouse X position (for tooltip hit testing) + mouseY int32 // last known mouse Y position (for tooltip hit testing) + mouseLastMove time.Time // timestamp of last mouse movement; tooltip hidden after 3s idle +} + +// runState holds mutable state across the display loop (hotkeys, window size, smoothed data). +type runState struct { + displayFlags + smoothState + peakState + mouseState + winW int32 + winH int32 } // newRunState builds initial run state from config. @@ -66,29 +81,37 @@ func newRunState(cfg *config.Config, winW, winH int32) *runState { initDiskPeak = cfg.DiskMax } return &runState{ - showAvgLine: cfg.ShowAvgLine, - showIOAvgLine: cfg.ShowIOAvgLine, - cpuMode: cfg.CPUMode, - showMem: cfg.ShowMem, - showNet: cfg.ShowNet, - showLoad: cfg.ShowLoad, - loadPeak: initLoadPeak, - showSeparators: cfg.ShowSeparators, - extended: cfg.Extended, - winW: winW, - winH: winH, - prevCPU: make(map[string]collector.CPULine), - smoothedCPU: make(map[string]*[10]float64), - smoothedMem: make(map[string]*struct{ ramUsed, swapUsed float64 }), - smoothedNet: make(map[string]*struct{ rxPct, txPct float64 }), - prevNet: make(map[string]stats.NetStamp), - peakHistory: make(map[string][]float64), - diskMode: cfg.DiskMode, - diskPeak: initDiskPeak, - prevDisk: make(map[string]stats.DiskStamp), - smoothedDisk: make(map[string]*struct{ readPct, writePct float64 }), - mouseX: -1, // off-screen until first mouse move - mouseY: -1, + displayFlags: displayFlags{ + showAvgLine: cfg.ShowAvgLine, + showIOAvgLine: cfg.ShowIOAvgLine, + cpuMode: cfg.CPUMode, + showMem: cfg.ShowMem, + showNet: cfg.ShowNet, + showLoad: cfg.ShowLoad, + showSeparators: cfg.ShowSeparators, + extended: cfg.Extended, + diskMode: cfg.DiskMode, + }, + smoothState: smoothState{ + prevCPU: make(map[string]stats.CPULine), + smoothedCPU: make(map[string]*[10]float64), + smoothedMem: make(map[string]*struct{ ramUsed, swapUsed float64 }), + smoothedNet: make(map[string]*struct{ rxPct, txPct float64 }), + prevNet: make(map[string]stats.NetStamp), + peakHistory: make(map[string][]float64), + prevDisk: make(map[string]stats.DiskStamp), + smoothedDisk: make(map[string]*struct{ readPct, writePct float64 }), + }, + peakState: peakState{ + loadPeak: initLoadPeak, + diskPeak: initDiskPeak, + }, + mouseState: mouseState{ + mouseX: -1, // off-screen until first mouse move + mouseY: -1, + }, + winW: winW, + winH: winH, } } @@ -192,53 +215,17 @@ func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *r func handleToggleKeys(sym sdl.Keycode, cfg *config.Config, state *runState) { switch sym { case sdl.K_1: - // Cycle through three CPU display modes: average → all cores → off → average - state.cpuMode = (state.cpuMode + 1) % constants.CPUModeCount - switch state.cpuMode { - case constants.CPUModeAverage: - fmt.Println("==> CPU: average bar only") - case constants.CPUModeCores: - fmt.Println("==> CPU: individual cores") - case constants.CPUModeOff: - fmt.Println("==> CPU: off") - } + cycleCPUMode(state) case sdl.K_2, sdl.K_m: - state.showMem = !state.showMem - fmt.Println("==> Toggled show mem:", state.showMem) + toggleMem(state) case sdl.K_3, sdl.K_n: - state.showNet = !state.showNet - fmt.Println("==> Toggled show net:", state.showNet) + toggleNet(state) case sdl.K_4, sdl.K_l: - state.showLoad = !state.showLoad - fmt.Println("==> Toggled show load:", state.showLoad) + toggleLoad(state) case sdl.K_5: - // Cycle through three disk display modes: aggregate → devices → off → aggregate - state.diskMode = (state.diskMode + 1) % constants.DiskModeCount - switch state.diskMode { - case constants.DiskModeAggregate: - fmt.Println("==> Disk: aggregate (all devices)") - case constants.DiskModeDevices: - fmt.Println("==> Disk: per-device") - case constants.DiskModeOff: - fmt.Println("==> Disk: off") - } + cycleDiskMode(state) case sdl.K_r: - // Reset load auto-scale peak to the floor so the bar rescales immediately. - // Has no effect when loadmax is fixed (cfg.LoadMax > 0). - if cfg.LoadMax == 0 { - state.loadPeak = 2.0 - fmt.Println("==> Load peak reset to auto-scale floor (2.0)") - } else { - fmt.Println("==> Load peak reset ignored (fixed loadmax =", cfg.LoadMax, ")") - } - // Reset disk auto-scale peak to the floor when disk bars are on and diskmax is not fixed. - if state.diskMode != constants.DiskModeOff && cfg.DiskMax == 0 { - const diskPeakFloorBps = 1048576.0 // 1 MB/s, same as in updateDiskPeak - state.diskPeak = diskPeakFloorBps - fmt.Println("==> Disk peak reset to auto-scale floor (1 MB/s)") - } else if state.diskMode != constants.DiskModeOff && cfg.DiskMax > 0 { - fmt.Println("==> Disk peak reset ignored (fixed diskmax =", cfg.DiskMax, ")") - } + resetAutoScalePeaks(cfg, state) case sdl.K_e: state.extended = !state.extended fmt.Println("==> Toggled extended (peak line):", state.extended) @@ -254,6 +241,64 @@ func handleToggleKeys(sym sdl.Keycode, cfg *config.Config, state *runState) { } } +func cycleCPUMode(state *runState) { + state.cpuMode = (state.cpuMode + 1) % constants.CPUModeCount + switch state.cpuMode { + case constants.CPUModeAverage: + fmt.Println("==> CPU: average bar only") + case constants.CPUModeCores: + fmt.Println("==> CPU: individual cores") + case constants.CPUModeOff: + fmt.Println("==> CPU: off") + } +} + +func cycleDiskMode(state *runState) { + state.diskMode = (state.diskMode + 1) % constants.DiskModeCount + switch state.diskMode { + case constants.DiskModeAggregate: + fmt.Println("==> Disk: aggregate (all devices)") + case constants.DiskModeDevices: + fmt.Println("==> Disk: per-device") + case constants.DiskModeOff: + fmt.Println("==> Disk: off") + } +} + +func toggleMem(state *runState) { + state.showMem = !state.showMem + fmt.Println("==> Toggled show mem:", state.showMem) +} + +func toggleNet(state *runState) { + state.showNet = !state.showNet + fmt.Println("==> Toggled show net:", state.showNet) +} + +func toggleLoad(state *runState) { + state.showLoad = !state.showLoad + fmt.Println("==> Toggled show load:", state.showLoad) +} + +func resetAutoScalePeaks(cfg *config.Config, state *runState) { + if cfg.LoadMax == 0 { + state.loadPeak = 2.0 + fmt.Println("==> Load peak reset to auto-scale floor (2.0)") + } else { + fmt.Println("==> Load peak reset ignored (fixed loadmax =", cfg.LoadMax, ")") + } + if state.diskMode == constants.DiskModeOff { + return + } + if cfg.DiskMax == 0 { + const diskPeakFloorBps = 1048576.0 // 1 MB/s, same as in updateDiskPeak + state.diskPeak = diskPeakFloorBps + fmt.Println("==> Disk peak reset to auto-scale floor (1 MB/s)") + return + } + fmt.Println("==> Disk peak reset ignored (fixed diskmax =", cfg.DiskMax, ")") +} + // handleAdjustAndSave processes sampling-adjust and config-write hotkeys (a, y, d, c, f, v, h, w). func handleAdjustAndSave(sym sdl.Keycode, cfg *config.Config, state *runState) { switch sym { @@ -433,23 +478,22 @@ func countBars(snap map[string]*stats.HostStats, cpuMode int, showMem, showNet, // drawBars draws CPU, memory, and network bars for all hosts in snap. // Bars wrap into multiple rows when cfg.MaxBarsPerRow is set. func drawBars(renderer *sdl.Renderer, snap map[string]*stats.HostStats, cfg *config.Config, state *runState, numBars int) { - barIndex := 0 - hosts := sortedHosts(snap) - maxPerRow := cfg.MaxBarsPerRow + bars := buildBarMap(snap, cfg, state) // Track separator rects (position + row height) for drawing after all bars type sepRect struct{ x, y, h int32 } var separators []sepRect - for i, host := range hosts { - h := snap[host] + prevHost := "" + for i := range bars { + bar := bars[i] + h := snap[bar.host] if h == nil { continue } - drawHostBars(renderer, h, host, cfg, state, numBars, maxPerRow, &barIndex) - // Record separator position between hosts (not after the last one) - if state.showSeparators && i < len(hosts)-1 { - sx, sy, _, sh := barRect(state.winW, state.winH, numBars, maxPerRow, barIndex) - separators = append(separators, sepRect{sx, sy, sh}) + if state.showSeparators && prevHost != "" && bar.host != prevHost { + separators = append(separators, sepRect{bar.rect.X, bar.rect.Y, bar.rect.H}) } + drawBar(renderer, h, bar, cfg, state) + prevHost = bar.host } // Draw 1px red vertical separators on top of all bars (same color as CPU steal) for _, sep := range separators { @@ -491,10 +535,7 @@ func drawGlobalAvgLine(renderer *sdl.Renderer, snap map[string]*stats.HostStats, avgPct := totalUsage / float64(hostCount) renderer.SetDrawColor(constants.Red.R, constants.Red.G, constants.Red.B, 255) // Draw one line per row, positioned proportionally within each row's height - numRows := 1 - if maxPerRow > 0 && maxPerRow < numBars { - numRows = (numBars + maxPerRow - 1) / maxPerRow - } + numRows := countRows(numBars, maxPerRow) for row := 0; row < numRows; row++ { rowY := (state.winH * int32(row)) / int32(numRows) rowH := (state.winH*int32(row+1))/int32(numRows) - rowY @@ -536,10 +577,7 @@ func drawGlobalIOAvgLine(renderer *sdl.Renderer, snap map[string]*stats.HostStat avgPct := totalIO / float64(hostCount) renderer.SetDrawColor(constants.Pink.R, constants.Pink.G, constants.Pink.B, 255) // Draw one line per row, positioned proportionally from the top of each row - numRows := 1 - if maxPerRow > 0 && maxPerRow < numBars { - numRows = (numBars + maxPerRow - 1) / maxPerRow - } + numRows := countRows(numBars, maxPerRow) for row := 0; row < numRows; row++ { rowY := (state.winH * int32(row)) / int32(numRows) rowH := (state.winH*int32(row+1))/int32(numRows) - rowY @@ -554,13 +592,19 @@ func drawGlobalIOAvgLine(renderer *sdl.Renderer, snap map[string]*stats.HostStat } } -// drawHostBars draws CPU, mem, and net bars for one host and advances barIndex. -// maxPerRow controls multi-row wrapping (0 = single row). -func drawHostBars(renderer *sdl.Renderer, h *stats.HostStats, host string, cfg *config.Config, state *runState, numBars, maxPerRow int, barIndex *int) { - cpuNames := sortedCPUNames(h.CPU, state.cpuMode) - for _, name := range cpuNames { - key := host + ";" + name - cur := h.CPU[name] +func countRows(numBars, maxPerRow int) int { + if maxPerRow > 0 && maxPerRow < numBars { + return (numBars + maxPerRow - 1) / maxPerRow + } + return 1 +} + +func drawBar(renderer *sdl.Renderer, h *stats.HostStats, bar barDescriptor, cfg *config.Config, state *runState) { + x, y, barW, barH := bar.rect.X, bar.rect.Y, bar.rect.W, bar.rect.H + switch bar.kind { + case barCPU: + key := bar.host + ";" + bar.cpuName + cur := h.CPU[bar.cpuName] prev := state.prevCPU[key] state.prevCPU[key] = cur target, ok := cpuBarTargetPcts(cur, prev) @@ -578,74 +622,32 @@ func drawHostBars(renderer *sdl.Renderer, h *stats.HostStats, host string, cfg * normalizePcts(s) } peakPct := peakPctForBar(state, key, cfg.CPUAverage, s) - x, y, barW, barH := barRect(state.winW, state.winH, numBars, maxPerRow, *barIndex) - *barIndex++ drawCPUBarFromPcts(renderer, s, barW, x, y, barH, state.extended, peakPct) - } - if state.showMem { - if state.smoothedMem[host] == nil { - state.smoothedMem[host] = &struct{ ramUsed, swapUsed float64 }{} + case barMem: + if state.smoothedMem[bar.host] == nil { + state.smoothedMem[bar.host] = &struct{ ramUsed, swapUsed float64 }{} } - x, y, barW, barH := barRect(state.winW, state.winH, numBars, maxPerRow, *barIndex) - *barIndex++ - drawMemBarSmoothed(renderer, h, state.smoothedMem[host], smoothFactor, barW, x, y, barH) - } - if state.showNet { - if state.smoothedNet[host] == nil { - state.smoothedNet[host] = &struct{ rxPct, txPct float64 }{} + drawMemBarSmoothed(renderer, h, state.smoothedMem[bar.host], smoothFactor, barW, x, y, barH) + case barNet: + if state.smoothedNet[bar.host] == nil { + state.smoothedNet[bar.host] = &struct{ rxPct, txPct float64 }{} } - x, y, barW, barH := barRect(state.winW, state.winH, numBars, maxPerRow, *barIndex) - *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++ + state.prevNet[bar.host] = drawNetBarSmoothed(renderer, h, cfg, state.smoothedNet[bar.host], state.prevNet[bar.host], smoothFactor, barW, x, y, barH) + case barLoad: drawLoadAvgBar(renderer, h, state.loadPeak, barW, x, y, barH) - } - // Disk I/O bars: aggregate (one bar) or per-device based on diskMode - diskNames := sortedDiskNames(h.Disk, state.diskMode) - for _, dname := range diskNames { - key := host + ";disk;" + dname - var cur stats.DiskStamp - if dname == "all" { + case barDisk: + key := bar.host + ";disk;" + bar.diskName + cur := h.Disk[bar.diskName] + if bar.diskName == "all" { cur = sumAllDisks(h.Disk) - } else { - cur = h.Disk[dname] } if state.smoothedDisk[key] == nil { state.smoothedDisk[key] = &struct{ readPct, writePct float64 }{} } - x, y, barW, barH := barRect(state.winW, state.winH, numBars, maxPerRow, *barIndex) - *barIndex++ state.prevDisk[key] = drawDiskBarSmoothed(renderer, cur, state, state.smoothedDisk[key], state.prevDisk[key], smoothFactor, barW, x, y, barH, state.extended) } } -func peakPctForBar(state *runState, key string, cpuAvg int, s *[10]float64) float64 { - if !state.extended || s == nil { - return 0 - } - userSys := (*s)[0] + (*s)[1] - hist := state.peakHistory[key] - hist = append(hist, userSys) - n := cpuAvg - if n < 1 { - n = 1 - } - for len(hist) > n { - hist = hist[1:] - } - state.peakHistory[key] = hist - var max float64 - for _, v := range hist { - if v > max { - max = v - } - } - return max -} - func sortedHosts(snap map[string]*stats.HostStats) []string { out := make([]string, 0, len(snap)) for h := range snap { @@ -655,189 +657,6 @@ func sortedHosts(snap map[string]*stats.HostStats) []string { return out } -func sortedCPUNames(cpu map[string]collector.CPULine, cpuMode int) []string { - // CPUModeOff: hide all CPU bars - if cpuMode == constants.CPUModeOff { - return nil - } - var names []string - for name := range cpu { - if name == "cpu" { - // Aggregate bar always shown unless CPUModeOff - names = append(names, "cpu") - continue - } - // Individual core bars only shown in CPUModeCores - if cpuMode == constants.CPUModeCores { - names = append(names, name) - } - } - sort.Slice(names, func(i, j int) bool { - if names[i] == "cpu" { - return true - } - if names[j] == "cpu" { - return false - } - return names[i] < names[j] - }) - return names -} - -// cpuBarTargetPcts returns the 9 segment percentages (system, user, nice, idle, iowait, irq, softirq, guest, steal) from cur/prev delta. ok is false if no valid sample. -func cpuBarTargetPcts(cur, prev collector.CPULine) (out [10]float64, ok bool) { - totalCur := cur.Total() - totalPrev := prev.Total() - if totalPrev == 0 || totalCur <= totalPrev { - return out, false - } - scale := float64(totalCur-totalPrev) / 100.0 - if scale <= 0 { - return out, false - } - out[0] = float64(cur.System-prev.System) / scale - out[1] = float64(cur.User-prev.User) / scale - out[2] = float64(cur.Nice-prev.Nice) / scale - out[3] = float64(cur.Idle-prev.Idle) / scale - out[4] = float64(cur.Iowait-prev.Iowait) / scale - out[5] = float64(cur.IRQ-prev.IRQ) / scale - out[6] = float64(cur.SoftIRQ-prev.SoftIRQ) / scale - out[7] = float64(cur.Guest-prev.Guest) / scale - out[8] = float64(cur.Steal-prev.Steal) / scale - out[9] = float64(cur.GuestNice-prev.GuestNice) / scale - for i := range out { - if out[i] < 0 { - out[i] = 0 - } - if out[i] > 100 { - out[i] = 100 - } - } - return out, true -} - -func normalizePcts(s *[10]float64) { - var sum float64 - for i := 0; i < 10; i++ { - sum += (*s)[i] - } - if sum <= 0 { - return - } - for i := 0; i < 10; i++ { - (*s)[i] = (*s)[i] * 100 / sum - } -} - -// drawCPUBarFromPcts draws one CPU bar from 10 smoothed segment percentages. -// The bar occupies the region (x, y) with dimensions (barW, barH). -// If s is nil, only clears the slot. When extended is true and peakPct > 0, -// draws a 1px peak line (max system+user over history). -func drawCPUBarFromPcts(renderer *sdl.Renderer, s *[10]float64, barW int32, x, y, barH int32, extended bool, peakPct float64) { - // Clear this slot so we never leave previous (e.g. mem/net) content visible - renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) - renderer.FillRect(&sdl.Rect{X: x, Y: y, W: barW, H: barH}) - if s == nil { - return - } - pxPerPct := float64(barH) / 100.0 - curY := float64(y + barH) - fill := func(r, g, b uint8, pct float64) { - hh := int32(pct * pxPerPct) - if hh < 1 && pct > 0 { - hh = 1 - } - curY -= float64(hh) - renderer.SetDrawColor(r, g, b, 255) - renderer.FillRect(&sdl.Rect{X: x, Y: int32(curY), W: barW, H: hh}) - } - fill(constants.Blue.R, constants.Blue.G, constants.Blue.B, (*s)[0]) // system - fill(constants.Yellow.R, constants.Yellow.G, constants.Yellow.B, (*s)[1]) // user - fill(constants.Green.R, constants.Green.G, constants.Green.B, (*s)[2]) // nice - fill(constants.LimeGreen.R, constants.LimeGreen.G, constants.LimeGreen.B, (*s)[9]) // guestnice - fill(constants.Black.R, constants.Black.G, constants.Black.B, (*s)[3]) // idle - fill(constants.Purple.R, constants.Purple.G, constants.Purple.B, (*s)[4]) // iowait - fill(constants.White.R, constants.White.G, constants.White.B, (*s)[5]) // irq - fill(constants.White.R, constants.White.G, constants.White.B, (*s)[6]) // softirq - fill(constants.Red.R, constants.Red.G, constants.Red.B, (*s)[7]) // guest - fill(constants.Red.R, constants.Red.G, constants.Red.B, (*s)[8]) // steal - // Extended: 1px peak line at max (system+user) over history - if extended && peakPct > 0 { - peakY := y + barH - int32(peakPct*pxPerPct) - if peakY < y { - peakY = y - } - if peakY >= y+barH { - peakY = y + barH - 1 - } - if peakPct > float64(constants.UserOrangeThreshold) { - renderer.SetDrawColor(constants.Orange.R, constants.Orange.G, constants.Orange.B, 255) - } else if peakPct > float64(constants.UserYellowThreshold) { - renderer.SetDrawColor(constants.Yellow0.R, constants.Yellow0.G, constants.Yellow0.B, 255) - } else { - renderer.SetDrawColor(constants.Yellow.R, constants.Yellow.G, constants.Yellow.B, 255) - } - renderer.FillRect(&sdl.Rect{X: x, Y: peakY, W: barW, H: 1}) - } -} - -// drawMemBarSmoothed blends mem stats toward target and draws one memory bar (RAM left, Swap right). -// The bar occupies the region (x, y) with dimensions (barW, barH). -func drawMemBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, smoothed *struct{ ramUsed, swapUsed float64 }, factor float64, barW int32, x, y, barH int32) { - // Clear this slot so we never leave previous (e.g. CPU/net) content visible - renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) - renderer.FillRect(&sdl.Rect{X: x, Y: y, W: barW, H: barH}) - if h.Mem == nil { - return - } - var targetRam, targetSwap float64 - if memTotal := h.Mem["MemTotal"]; memTotal > 0 { - targetRam = 100 - 100*float64(h.Mem["MemFree"])/float64(memTotal) - if targetRam < 0 { - targetRam = 0 - } - if targetRam > 100 { - targetRam = 100 - } - } - if swapTotal := h.Mem["SwapTotal"]; swapTotal > 0 { - targetSwap = 100 - 100*float64(h.Mem["SwapFree"])/float64(swapTotal) - if targetSwap < 0 { - targetSwap = 0 - } - if targetSwap > 100 { - targetSwap = 100 - } - } - smoothed.ramUsed += (targetRam - smoothed.ramUsed) * factor - smoothed.swapUsed += (targetSwap - smoothed.swapUsed) * factor - - halfW := barW / 2 - pxPerPct := float64(barH) / 100.0 - - // RAM: used (dark grey) from bottom, free (black) on top - ramUsedH := int32(smoothed.ramUsed * pxPerPct) - if ramUsedH > 0 { - renderer.SetDrawColor(constants.DarkGrey.R, constants.DarkGrey.G, constants.DarkGrey.B, 255) - renderer.FillRect(&sdl.Rect{X: x, Y: y + barH - ramUsedH, W: halfW, H: ramUsedH}) - } - if ramFreeH := barH - ramUsedH; ramFreeH > 0 { - renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) - renderer.FillRect(&sdl.Rect{X: x, Y: y, W: halfW, H: ramFreeH}) - } - - // Swap: used (grey) from bottom, free (black) on top - swapUsedH := int32(smoothed.swapUsed * pxPerPct) - if swapUsedH > 0 { - renderer.SetDrawColor(constants.Grey.R, constants.Grey.G, constants.Grey.B, 255) - renderer.FillRect(&sdl.Rect{X: x + halfW, Y: y + barH - swapUsedH, W: halfW, H: swapUsedH}) - } - if swapFreeH := barH - swapUsedH; swapFreeH > 0 { - renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) - renderer.FillRect(&sdl.Rect{X: x + halfW, Y: y, W: halfW, H: swapFreeH}) - } -} - func printHotkeys() { fmt.Println("=> Hotkeys: 1=cores 2/m=mem 3/n=net 4/l=load 5=disk r=reset load/disk peak 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 b/x=disk avg f/v=link scale arrows=resize") } @@ -874,145 +693,6 @@ func linkScaleIndex(netLink string) int { return 3 // default: gbit } -func netLinkBytesPerSec(cfg *config.Config) int64 { - s := strings.ToLower(strings.TrimSpace(cfg.NetLink)) - switch s { - case "gbit", "1gbit": - return int64(constants.BytesGbit) - case "10gbit": - return int64(constants.Bytes10Gbit) - case "mbit", "1mbit": - return int64(constants.BytesMbit) - case "10mbit": - return int64(constants.Bytes10Mbit) - case "100mbit": - return int64(constants.Bytes100Mbit) - case "": - return int64(constants.BytesGbit) - } - if n, err := strconv.ParseInt(s, 10, 64); err == nil { - return n * int64(constants.BytesMbit) - } - return int64(constants.BytesGbit) -} - -// sumNonLoNet aggregates RX (B) and TX (Tb) bytes across all non-lo interfaces, -// using the latest timestamp from any interface. -func sumNonLoNet(h *stats.HostStats) (sum stats.NetStamp, hasIface bool) { - if h.Net == nil { - return sum, false - } - for iface, ns := range h.Net { - if iface == "lo" { - continue - } - hasIface = true - sum.B += ns.B - sum.Tb += ns.Tb - if ns.Stamp > sum.Stamp { - sum.Stamp = ns.Stamp - } - } - return sum, hasIface -} - -// drawNetBarSmoothed sums RX/TX across all non-lo interfaces, computes utilization -// vs link speed, smooths toward target, and draws one net bar (RX left from top, TX right from bottom). -// The bar occupies the region (x, y) with dimensions (barW, barH). -// Smoothed values and prevNet are only updated when new collector data arrives -// (cur.Stamp > prev.Stamp), so the bar holds steady between collector cycles -// instead of decaying toward zero on frames with no new data. -// drawNetBarSmoothed sums RX/TX across all non-lo interfaces, computes utilization -// vs link speed, smooths toward target, and draws one net bar (RX left from top, TX right from bottom). -// The bar occupies the region (x, y) with dimensions (barW, barH). -// Smoothed values and prevNet are only updated when new collector data arrives -// (cur.Stamp > prev.Stamp), so the bar holds steady between collector cycles -// instead of decaying toward zero on frames with no new data. -func drawNetBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, cfg *config.Config, smoothed *struct{ rxPct, txPct float64 }, prev stats.NetStamp, factor float64, barW int32, x, y, barH int32) stats.NetStamp { - // Clear this slot so we never leave previous (e.g. CPU/mem) content visible - renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) - renderer.FillRect(&sdl.Rect{X: x, Y: y, W: barW, H: barH}) - cur, hasIface := sumNonLoNet(h) - if !hasIface { - // No non-lo interface: show red bar - renderer.SetDrawColor(constants.Red.R, constants.Red.G, constants.Red.B, 255) - renderer.FillRect(&sdl.Rect{X: x, Y: y, W: barW, H: barH}) - return prev - } - // Only recompute and smooth when the collector has provided new data. - // The collector updates net stamps every ~2.8s, but drawFrame runs every - // ~0.14s. Without this guard, the 19 intermediate frames would set - // target to 0 (no delta) and smooth the bar toward zero, making real - // traffic invisible. - if cur.Stamp > prev.Stamp && prev.Stamp > 0 { - prev = smoothNetUtilization(cur, prev, cfg, smoothed, factor) - } else if prev.Stamp == 0 { - // First sample: record it but don't draw yet (no delta available) - prev = cur - } - drawNetHalves(renderer, smoothed, x, y, barW, barH) - return prev -} - -// smoothNetUtilization computes RX/TX utilization deltas and blends them into smoothed. -// Returns the updated previous stamp (cur) so callers can advance the baseline. -func smoothNetUtilization(cur, prev stats.NetStamp, cfg *config.Config, smoothed *struct{ rxPct, txPct float64 }, factor float64) stats.NetStamp { - linkBps := netLinkBytesPerSec(cfg) - if linkBps <= 0 { - linkBps = int64(constants.BytesGbit) - } - dt := cur.Stamp - prev.Stamp - if dt > 0 { - deltaB := cur.B - prev.B - deltaTb := cur.Tb - prev.Tb - if deltaB < 0 { - deltaB = 0 - } - if deltaTb < 0 { - deltaTb = 0 - } - targetRx := 100 * float64(deltaB) / (float64(linkBps) * dt) - targetTx := 100 * float64(deltaTb) / (float64(linkBps) * dt) - smoothed.rxPct += (targetRx - smoothed.rxPct) * factor - smoothed.txPct += (targetTx - smoothed.txPct) * factor - } - return cur // advance the baseline to the consumed sample -} - -// drawNetHalves renders the RX (left half, from top) and TX (right half, from bottom) -// filled rectangles for one network bar using pre-smoothed utilization percentages. -func drawNetHalves(renderer *sdl.Renderer, smoothed *struct{ rxPct, txPct float64 }, x, y, barW, barH int32) { - halfW := barW / 2 - pxPerPct := float64(barH) / 100.0 - halfH := barH / 2 - // Left half: RX from top (light green = used) - rxH := int32(smoothed.rxPct * pxPerPct) - if rxH > halfH { - rxH = halfH - } - if rxH > 0 { - renderer.SetDrawColor(constants.LightGreen.R, constants.LightGreen.G, constants.LightGreen.B, 255) - renderer.FillRect(&sdl.Rect{X: x, Y: y, W: halfW, H: rxH}) - } - if halfW > 0 && halfH-rxH > 0 { - renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) - renderer.FillRect(&sdl.Rect{X: x, Y: y + rxH, W: halfW, H: halfH - rxH}) - } - // Right half: TX from bottom (light green = used) - txH := int32(smoothed.txPct * pxPerPct) - if txH > halfH { - txH = halfH - } - if txH > 0 { - renderer.SetDrawColor(constants.LightGreen.R, constants.LightGreen.G, constants.LightGreen.B, 255) - renderer.FillRect(&sdl.Rect{X: x + halfW, Y: y + barH - txH, W: halfW, H: txH}) - } - if halfW > 0 && (barH-txH) > 0 { - renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) - renderer.FillRect(&sdl.Rect{X: x + halfW, Y: y, W: halfW, H: barH - txH}) - } -} - // updateLoadPeak maintains the load scale used by the bar renderer. // When loadMax > 0, the scale is pinned to that fixed value every frame // (no decay, no tracking). When loadMax == 0, auto-scale is used: the diff --git a/internal/display/display_test.go b/internal/display/display_test.go index 6a2d8fe..7fd353f 100644 --- a/internal/display/display_test.go +++ b/internal/display/display_test.go @@ -1632,7 +1632,7 @@ func TestIsWholeDisk(t *testing.T) { {"ram0", false}, {"dm-0", false}, {"dm-1", false}, - {"sr0", true}, // CD-ROM, not a partition + {"sr0", true}, // CD-ROM, not a partition {"mmcblk0", true}, // SD card, whole disk } for _, tt := range tests { @@ -1674,10 +1674,10 @@ func TestSortedDiskNames(t *testing.T) { func TestSumAllDisks(t *testing.T) { disk := map[string]stats.DiskStamp{ - "sda": {SectorsRead: 100, SectorsWrite: 200, IoTicks: 10, Stamp: 2.0}, - "sda1": {SectorsRead: 50, SectorsWrite: 100, IoTicks: 5, Stamp: 2.0}, // partition, skipped - "nvme0n1": {SectorsRead: 300, SectorsWrite: 400, IoTicks: 20, Stamp: 3.0}, - "loop0": {SectorsRead: 10, SectorsWrite: 0, IoTicks: 1, Stamp: 1.0}, // loop, skipped + "sda": {SectorsRead: 100, SectorsWrite: 200, IoTicks: 10, Stamp: 2.0}, + "sda1": {SectorsRead: 50, SectorsWrite: 100, IoTicks: 5, Stamp: 2.0}, // partition, skipped + "nvme0n1": {SectorsRead: 300, SectorsWrite: 400, IoTicks: 20, Stamp: 3.0}, + "loop0": {SectorsRead: 10, SectorsWrite: 0, IoTicks: 1, Stamp: 1.0}, // loop, skipped } sum := sumAllDisks(disk) if sum.SectorsRead != 400 || sum.SectorsWrite != 600 || sum.IoTicks != 30 { @@ -1692,9 +1692,15 @@ func TestSumAllDisks(t *testing.T) { func TestUpdateDiskPeak(t *testing.T) { // Fixed override: diskPeak always equals DiskMax state := &runState{ - diskMode: constants.DiskModeAggregate, - diskPeak: 1048576, - prevDisk: make(map[string]stats.DiskStamp), + displayFlags: displayFlags{ + diskMode: constants.DiskModeAggregate, + }, + peakState: peakState{ + diskPeak: 1048576, + }, + smoothState: smoothState{ + prevDisk: make(map[string]stats.DiskStamp), + }, } snap := map[string]*stats.HostStats{} updateDiskPeak(snap, state, 5000000) // fixed 5 MB/s @@ -1842,7 +1848,7 @@ func TestDiskBar_Rendering(t *testing.T) { src1 := &mockSource{ data: map[string]*stats.HostStats{ "host1": { - CPU: map[string]collector.CPULine{"cpu": cur}, + CPU: map[string]collector.CPULine{"cpu": cur}, Disk: map[string]stats.DiskStamp{ "sda": {SectorsRead: 0, SectorsWrite: 0, IoTicks: 0, Stamp: 1.0}, }, @@ -1872,7 +1878,7 @@ func TestDiskBar_Rendering(t *testing.T) { src2 := &mockSource{ data: map[string]*stats.HostStats{ "host1": { - CPU: map[string]collector.CPULine{"cpu": cur}, + CPU: map[string]collector.CPULine{"cpu": cur}, Disk: map[string]stats.DiskStamp{ "sda": {SectorsRead: 100000, SectorsWrite: 50000, IoTicks: 500, Stamp: 4.0}, }, diff --git a/internal/display/hittest.go b/internal/display/hittest.go index e8c1909..15b1343 100644 --- a/internal/display/hittest.go +++ b/internal/display/hittest.go @@ -26,70 +26,42 @@ type barDescriptor struct { rect sdl.Rect } -// 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, state.showLoad, state.diskMode) - maxPerRow := cfg.MaxBarsPerRow - hosts := sortedHosts(snap) - - bars := make([]barDescriptor, 0, numBars) - barIndex := 0 - for _, host := range hosts { +// iterateBars emits bars in the canonical host/CPU/mem/net/load/disk order. +func iterateBars(snap map[string]*stats.HostStats, state *runState, visit func(h *stats.HostStats, bar barDescriptor)) { + for _, host := range sortedHosts(snap) { h := snap[host] if h == nil { continue } - cpuNames := sortedCPUNames(h.CPU, state.cpuMode) - for _, name := range cpuNames { - x, y, w, bh := barRect(state.winW, state.winH, numBars, maxPerRow, barIndex) - bars = append(bars, barDescriptor{ - host: host, - kind: barCPU, - cpuName: name, - rect: sdl.Rect{X: x, Y: y, W: w, H: bh}, - }) - barIndex++ + for _, name := range sortedCPUNames(h.CPU, state.cpuMode) { + visit(h, barDescriptor{host: host, kind: barCPU, cpuName: name}) } if state.showMem { - x, y, w, bh := barRect(state.winW, state.winH, numBars, maxPerRow, barIndex) - bars = append(bars, barDescriptor{ - host: host, - kind: barMem, - rect: sdl.Rect{X: x, Y: y, W: w, H: bh}, - }) - barIndex++ + visit(h, barDescriptor{host: host, kind: barMem}) } if state.showNet { - x, y, w, bh := barRect(state.winW, state.winH, numBars, maxPerRow, barIndex) - bars = append(bars, barDescriptor{ - host: host, - kind: barNet, - rect: sdl.Rect{X: x, Y: y, W: w, H: bh}, - }) - barIndex++ + visit(h, barDescriptor{host: host, kind: barNet}) } 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++ + visit(h, barDescriptor{host: host, kind: barLoad}) } - diskNames := sortedDiskNames(h.Disk, state.diskMode) - for _, dname := range diskNames { - x, y, w, bh := barRect(state.winW, state.winH, numBars, maxPerRow, barIndex) - bars = append(bars, barDescriptor{ - host: host, - kind: barDisk, - diskName: dname, - rect: sdl.Rect{X: x, Y: y, W: w, H: bh}, - }) - barIndex++ + for _, dname := range sortedDiskNames(h.Disk, state.diskMode) { + visit(h, barDescriptor{host: host, kind: barDisk, diskName: dname}) } } +} + +// buildBarMap produces bar descriptors with screen rectangles. +func buildBarMap(snap map[string]*stats.HostStats, cfg *config.Config, state *runState) []barDescriptor { + numBars := countBars(snap, state.cpuMode, state.showMem, state.showNet, state.showLoad, state.diskMode) + bars := make([]barDescriptor, 0, numBars) + barIndex := 0 + iterateBars(snap, state, func(_ *stats.HostStats, bar barDescriptor) { + x, y, w, bh := barRect(state.winW, state.winH, numBars, cfg.MaxBarsPerRow, barIndex) + bar.rect = sdl.Rect{X: x, Y: y, W: w, H: bh} + bars = append(bars, bar) + barIndex++ + }) return bars } diff --git a/internal/display/mem.go b/internal/display/mem.go new file mode 100644 index 0000000..7a814d8 --- /dev/null +++ b/internal/display/mem.go @@ -0,0 +1,58 @@ +package display + +import ( + "codeberg.org/snonux/loadbars/internal/constants" + "codeberg.org/snonux/loadbars/internal/stats" + "github.com/veandco/go-sdl2/sdl" +) + +func drawMemBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, smoothed *struct{ ramUsed, swapUsed float64 }, factor float64, barW int32, x, y, barH int32) { + renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) + renderer.FillRect(&sdl.Rect{X: x, Y: y, W: barW, H: barH}) + if h.Mem == nil { + return + } + var targetRam, targetSwap float64 + if memTotal := h.Mem["MemTotal"]; memTotal > 0 { + targetRam = 100 - 100*float64(h.Mem["MemFree"])/float64(memTotal) + if targetRam < 0 { + targetRam = 0 + } + if targetRam > 100 { + targetRam = 100 + } + } + if swapTotal := h.Mem["SwapTotal"]; swapTotal > 0 { + targetSwap = 100 - 100*float64(h.Mem["SwapFree"])/float64(swapTotal) + if targetSwap < 0 { + targetSwap = 0 + } + if targetSwap > 100 { + targetSwap = 100 + } + } + smoothed.ramUsed += (targetRam - smoothed.ramUsed) * factor + smoothed.swapUsed += (targetSwap - smoothed.swapUsed) * factor + + halfW := barW / 2 + pxPerPct := float64(barH) / 100.0 + ramUsedH := int32(smoothed.ramUsed * pxPerPct) + if ramUsedH > 0 { + renderer.SetDrawColor(constants.DarkGrey.R, constants.DarkGrey.G, constants.DarkGrey.B, 255) + renderer.FillRect(&sdl.Rect{X: x, Y: y + barH - ramUsedH, W: halfW, H: ramUsedH}) + } + if ramFreeH := barH - ramUsedH; ramFreeH > 0 { + renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) + renderer.FillRect(&sdl.Rect{X: x, Y: y, W: halfW, H: ramFreeH}) + } + + swapUsedH := int32(smoothed.swapUsed * pxPerPct) + if swapUsedH > 0 { + renderer.SetDrawColor(constants.Grey.R, constants.Grey.G, constants.Grey.B, 255) + renderer.FillRect(&sdl.Rect{X: x + halfW, Y: y + barH - swapUsedH, W: halfW, H: swapUsedH}) + } + if swapFreeH := barH - swapUsedH; swapFreeH > 0 { + renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) + renderer.FillRect(&sdl.Rect{X: x + halfW, Y: y, W: halfW, H: swapFreeH}) + } +} diff --git a/internal/display/net.go b/internal/display/net.go new file mode 100644 index 0000000..7fc3a9d --- /dev/null +++ b/internal/display/net.go @@ -0,0 +1,122 @@ +package display + +import ( + "strconv" + "strings" + + "codeberg.org/snonux/loadbars/internal/config" + "codeberg.org/snonux/loadbars/internal/constants" + "codeberg.org/snonux/loadbars/internal/stats" + "github.com/veandco/go-sdl2/sdl" +) + +func netLinkBytesPerSec(cfg *config.Config) int64 { + s := strings.ToLower(strings.TrimSpace(cfg.NetLink)) + switch s { + case "gbit", "1gbit": + return int64(constants.BytesGbit) + case "10gbit": + return int64(constants.Bytes10Gbit) + case "mbit", "1mbit": + return int64(constants.BytesMbit) + case "10mbit": + return int64(constants.Bytes10Mbit) + case "100mbit": + return int64(constants.Bytes100Mbit) + case "": + return int64(constants.BytesGbit) + } + if n, err := strconv.ParseInt(s, 10, 64); err == nil { + return n * int64(constants.BytesMbit) + } + return int64(constants.BytesGbit) +} + +func sumNonLoNet(h *stats.HostStats) (sum stats.NetStamp, hasIface bool) { + if h.Net == nil { + return sum, false + } + for iface, ns := range h.Net { + if iface == "lo" { + continue + } + hasIface = true + sum.B += ns.B + sum.Tb += ns.Tb + if ns.Stamp > sum.Stamp { + sum.Stamp = ns.Stamp + } + } + return sum, hasIface +} + +func drawNetBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, cfg *config.Config, smoothed *struct{ rxPct, txPct float64 }, prev stats.NetStamp, factor float64, barW int32, x, y, barH int32) stats.NetStamp { + renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) + renderer.FillRect(&sdl.Rect{X: x, Y: y, W: barW, H: barH}) + cur, hasIface := sumNonLoNet(h) + if !hasIface { + renderer.SetDrawColor(constants.Red.R, constants.Red.G, constants.Red.B, 255) + renderer.FillRect(&sdl.Rect{X: x, Y: y, W: barW, H: barH}) + return prev + } + if cur.Stamp > prev.Stamp && prev.Stamp > 0 { + prev = smoothNetUtilization(cur, prev, cfg, smoothed, factor) + } else if prev.Stamp == 0 { + prev = cur + } + drawNetHalves(renderer, smoothed, x, y, barW, barH) + return prev +} + +func smoothNetUtilization(cur, prev stats.NetStamp, cfg *config.Config, smoothed *struct{ rxPct, txPct float64 }, factor float64) stats.NetStamp { + linkBps := netLinkBytesPerSec(cfg) + if linkBps <= 0 { + linkBps = int64(constants.BytesGbit) + } + dt := cur.Stamp - prev.Stamp + if dt > 0 { + deltaB := cur.B - prev.B + deltaTb := cur.Tb - prev.Tb + if deltaB < 0 { + deltaB = 0 + } + if deltaTb < 0 { + deltaTb = 0 + } + targetRx := 100 * float64(deltaB) / (float64(linkBps) * dt) + targetTx := 100 * float64(deltaTb) / (float64(linkBps) * dt) + smoothed.rxPct += (targetRx - smoothed.rxPct) * factor + smoothed.txPct += (targetTx - smoothed.txPct) * factor + } + return cur +} + +func drawNetHalves(renderer *sdl.Renderer, smoothed *struct{ rxPct, txPct float64 }, x, y, barW, barH int32) { + halfW := barW / 2 + pxPerPct := float64(barH) / 100.0 + halfH := barH / 2 + rxH := int32(smoothed.rxPct * pxPerPct) + if rxH > halfH { + rxH = halfH + } + if rxH > 0 { + renderer.SetDrawColor(constants.LightGreen.R, constants.LightGreen.G, constants.LightGreen.B, 255) + renderer.FillRect(&sdl.Rect{X: x, Y: y, W: halfW, H: rxH}) + } + if halfW > 0 && halfH-rxH > 0 { + renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) + renderer.FillRect(&sdl.Rect{X: x, Y: y + rxH, W: halfW, H: halfH - rxH}) + } + txH := int32(smoothed.txPct * pxPerPct) + if txH > halfH { + txH = halfH + } + if txH > 0 { + renderer.SetDrawColor(constants.LightGreen.R, constants.LightGreen.G, constants.LightGreen.B, 255) + renderer.FillRect(&sdl.Rect{X: x + halfW, Y: y + barH - txH, W: halfW, H: txH}) + } + if halfW > 0 && (barH-txH) > 0 { + renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) + renderer.FillRect(&sdl.Rect{X: x + halfW, Y: y, W: halfW, H: barH - txH}) + } +} diff --git a/internal/stats/stats.go b/internal/stats/stats.go index 463268f..cc99483 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -1,9 +1,5 @@ package stats -import ( - "codeberg.org/snonux/loadbars/internal/collector" -) - // NetStamp holds network stats and timestamp for delta calculation. type NetStamp struct { B int64 @@ -24,7 +20,7 @@ type HostStats struct { LoadAvg1, LoadAvg5, LoadAvg15 string Mem map[string]int64 Net map[string]NetStamp - CPU map[string]collector.CPULine + CPU map[string]CPULine Disk map[string]DiskStamp } diff --git a/internal/stats/types.go b/internal/stats/types.go new file mode 100644 index 0000000..4094d2d --- /dev/null +++ b/internal/stats/types.go @@ -0,0 +1,46 @@ +package stats + +// CPULine is one line of /proc/stat: cpu name + counters (user, nice, system, idle, ...). +type CPULine struct { + Name string + User, Nice, System, Idle, Iowait, IRQ, SoftIRQ, Steal, Guest, GuestNice int64 +} + +// Total returns sum of all CPU counters. +func (c CPULine) Total() int64 { + return c.User + c.Nice + c.System + c.Idle + c.Iowait + c.IRQ + c.SoftIRQ + c.Steal + c.Guest + c.GuestNice +} + +// MemLine is one key from /proc/meminfo (e.g. MemTotal, MemFree). +type MemLine struct { + Key string + Value int64 +} + +// NetLine is one interface line: iface and key=value pairs (b, tb, p, tp, e, te, d, td). +type NetLine struct { + Iface string + B int64 // rx bytes + Tb int64 // tx bytes + P int64 + Tp int64 + E int64 + Te int64 + D int64 + Td int64 +} + +// LoadAvg is 1/5/15 min load average. +type LoadAvg struct { + Load1, Load5, Load15 string +} + +// DiskLine is one device from /proc/diskstats with cumulative counters. +type DiskLine struct { + Device string + SectorsRead int64 // cumulative sectors read (each sector = 512 bytes) + SectorsWrite int64 // cumulative sectors written + ReadTicks int64 // cumulative ms spent reading + WriteTicks int64 // cumulative ms spent writing + IoTicks int64 // cumulative ms the device had I/O in progress +} |
