summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/app/app.go41
-rw-r--r--internal/collector/collector.go5
-rw-r--r--internal/collector/types.go52
-rw-r--r--internal/display/cpu.go151
-rw-r--r--internal/display/disk.go8
-rw-r--r--internal/display/display.go652
-rw-r--r--internal/display/display_test.go26
-rw-r--r--internal/display/hittest.go74
-rw-r--r--internal/display/mem.go58
-rw-r--r--internal/display/net.go122
-rw-r--r--internal/stats/stats.go6
-rw-r--r--internal/stats/types.go46
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
+}