diff options
| -rw-r--r-- | .version | 1 | ||||
| -rw-r--r-- | AGENTS.md | 8 | ||||
| -rw-r--r-- | Magefile.go | 78 | ||||
| -rw-r--r-- | README.md | 12 | ||||
| -rw-r--r-- | cmd/loadbars/main.go | 1 | ||||
| -rw-r--r-- | internal/app/app.go | 3 | ||||
| -rw-r--r-- | internal/app/script.go | 29 | ||||
| -rw-r--r-- | internal/app/store.go | 32 | ||||
| -rw-r--r-- | internal/collector/collector.go | 20 | ||||
| -rw-r--r-- | internal/collector/parse_test.go | 10 | ||||
| -rw-r--r-- | internal/collector/types.go | 2 | ||||
| -rw-r--r-- | internal/config/config.go | 62 | ||||
| -rw-r--r-- | internal/constants/constants.go | 54 | ||||
| -rw-r--r-- | internal/display/display.go | 500 | ||||
| -rw-r--r-- | internal/stats/stats.go | 6 |
15 files changed, 389 insertions, 429 deletions
diff --git a/.version b/.version deleted file mode 100644 index 8bd6ba8..0000000 --- a/.version +++ /dev/null @@ -1 +0,0 @@ -0.7.5 @@ -15,7 +15,7 @@ This file helps AI assistants (Cursor, Claude, etc.) work effectively in this re - **github.com/veandco/go-sdl2** – SDL2 bindings for window and 2D rendering - **Build/tasks:** Mage (`mage build`, `mage test`, `mage install`) -Remote hosts do not need Go: the client runs `scripts/loadbars-remote.sh` locally or over SSH (stdin). No agent or extra install on remotes beyond bash and `/proc` (Linux). +Remote hosts do not need Go: the client embeds the remote script in the binary and runs it via `bash -s` (stdin) locally or over SSH. No separate script file; install only the binary. Remotes need bash and `/proc` (Linux). ## Layout @@ -23,14 +23,14 @@ Remote hosts do not need Go: the client runs `scripts/loadbars-remote.sh` locall cmd/loadbars/ # Entry point: flags, config load, app.Run() internal/ app/ # App lifecycle, store, wires collector + display - collector/ # Runs script (local or ssh), parses M LOADAVG / M MEMSTATS / M NETSTATS / M CPUSTATS + collector/ # Runs embedded script (local or ssh via bash -s), parses M LOADAVG / M MEMSTATS / M NETSTATS / M CPUSTATS config/ # Config struct, ~/.loadbarsrc load/save, cluster from /etc/clusters constants/ # Intervals, colors (RGB), link-speed constants display/ # SDL window, event loop, drawing (CPU/mem/net bars, hotkeys) stats/ # HostStats, Snapshot, NetStamp; read by display version/ # Version string (e.g. "0.8.0") – used in title bar and --version scripts/ - loadbars-remote.sh # Emits protocol lines for collector (no Perl on remote) + loadbars-remote.sh # Source copy; embedded into binary at build (internal/collector/scriptdata/) ``` - **Version:** Set in `internal/version/version.go`. Shown in window title and `--version`. @@ -42,7 +42,7 @@ scripts/ |-------------------|----------------------------| | `mage build` | Build `loadbars` binary | | `mage test` | Run Go tests | -| `mage install` | Install binary + script | +| `mage install` | Copy binary to GOPATH/bin (~/go/bin) | | `go build -o loadbars ./cmd/loadbars` | Build without Mage | | `./loadbars --hosts localhost` | Run locally (no SSH) | diff --git a/Magefile.go b/Magefile.go index 119f0b8..46dd2ef 100644 --- a/Magefile.go +++ b/Magefile.go @@ -1,5 +1,6 @@ //go:build mage +// Loadbars mage targets: build, test, install (same style as hexai). package main import ( @@ -11,12 +12,9 @@ import ( "github.com/magefile/mage/sh" ) -const ( - binaryName = "loadbars" - shareName = "loadbars" -) +const binaryName = "loadbars" -// Default sets the default target (build). +// Default target: build. func Default() { mg.Deps(Build) } // Build compiles the loadbars binary. @@ -29,68 +27,20 @@ func Test() error { return sh.RunV("go", "test", "./...") } -// Install builds and installs the binary, script, and NOTICE under DESTDIR. -// Usage: DESTDIR=/tmp/install mage install (or mage install for DESTDIR empty) +// Install copies the built binary to GOPATH/bin (defaults to ~/go/bin when GOPATH is unset). func Install() error { mg.Deps(Build) - dest := getDestDir() - binDir := filepath.Join(dest, "usr", "bin") - shareDir := filepath.Join(dest, "usr", "share", shareName) - scriptsDir := filepath.Join(shareDir, "scripts") - if err := os.MkdirAll(binDir, 0755); err != nil { - return fmt.Errorf("mkdir %s: %w", binDir, err) - } - if err := os.MkdirAll(scriptsDir, 0755); err != nil { - return fmt.Errorf("mkdir %s: %w", scriptsDir, err) - } - if err := sh.Copy(filepath.Join(binDir, binaryName), binaryName); err != nil { - return fmt.Errorf("copy binary: %w", err) - } - if err := sh.Copy(filepath.Join(scriptsDir, "loadbars-remote.sh"), "scripts/loadbars-remote.sh"); err != nil { - return fmt.Errorf("copy script: %w", err) - } - if err := sh.Copy(filepath.Join(shareDir, "NOTICE"), "NOTICE"); err != nil { - return fmt.Errorf("copy NOTICE: %w", err) - } - fmt.Printf("Installed to %s (binary: %s/usr/bin/%s)\n", dest, dest, binaryName) - return nil -} - -// Uninstall removes files installed by Install from DESTDIR. -func Uninstall() error { - return deinstall() -} - -// Deinstall is an alias for Uninstall. -func Deinstall() error { - return deinstall() -} - -func deinstall() error { - dest := getDestDir() - if dest == "" { - return fmt.Errorf("DESTDIR must be set to uninstall (e.g. DESTDIR=/tmp/install mage uninstall)") - } - paths := []string{ - filepath.Join(dest, "usr", "bin", binaryName), - filepath.Join(dest, "usr", "share", shareName, "scripts", "loadbars-remote.sh"), - filepath.Join(dest, "usr", "share", shareName, "NOTICE"), - } - for _, p := range paths { - if err := os.Remove(p); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove %s: %w", p, err) + gopath := os.Getenv("GOPATH") + if gopath == "" { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("resolve home: %w", err) } + gopath = filepath.Join(home, "go") } - // Remove empty dirs - _ = os.Remove(filepath.Join(dest, "usr", "share", shareName, "scripts")) - _ = os.Remove(filepath.Join(dest, "usr", "share", shareName)) - fmt.Printf("Uninstalled from %s\n", dest) - return nil -} - -func getDestDir() string { - if d := os.Getenv("DESTDIR"); d != "" { - return filepath.Clean(d) + bin := filepath.Join(gopath, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + return err } - return "" + return sh.RunV("cp", "-v", binaryName, bin+"/") } @@ -49,16 +49,10 @@ mage build ./loadbars --hosts localhost ``` -Install system-wide: +Install to GOPATH/bin (e.g. ~/go/bin): ```bash -sudo mage install -``` - -Uninstall: - -```bash -sudo mage uninstall +mage install ``` Run tests: @@ -88,7 +82,7 @@ Or install the latest version from the repository: go install codeberg.org/snonux/loadbars/cmd/loadbars@latest ``` -Remote hosts need no Go: the binary pipes `scripts/loadbars-remote.sh` over SSH. +Remote hosts need no Go: the binary embeds the remote script and pipes it to `bash -s` locally or over SSH. Only the single binary needs to be installed. ## Installation diff --git a/cmd/loadbars/main.go b/cmd/loadbars/main.go index 62b90db..cd834ab 100644 --- a/cmd/loadbars/main.go +++ b/cmd/loadbars/main.go @@ -106,6 +106,7 @@ func normalizeHost(h string) string { return h } +// printUsage prints usage and options to stderr. func printUsage() { fmt.Fprintf(os.Stderr, `Loadbars %s - real-time server load monitoring diff --git a/internal/app/app.go b/internal/app/app.go index 4544f0f..c18dc30 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -15,7 +15,6 @@ func Run(cfg *config.Config) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - scriptPath := ScriptPath() store := NewStore() var wg sync.WaitGroup @@ -24,7 +23,7 @@ func Run(cfg *config.Config) error { wg.Add(1) go func() { defer wg.Done() - _ = collector.Run(ctx, h, cfg, store, scriptPath) + _ = collector.Run(ctx, h, cfg, store) }() } diff --git a/internal/app/script.go b/internal/app/script.go deleted file mode 100644 index 2701cb2..0000000 --- a/internal/app/script.go +++ /dev/null @@ -1,29 +0,0 @@ -package app - -import ( - "os" - "path/filepath" -) - -// ScriptPath returns the path to the loadbars-remote.sh script. -// It looks for scripts/loadbars-remote.sh relative to the executable, then current dir. -func ScriptPath() string { - exe, err := os.Executable() - if err == nil { - dir := filepath.Dir(exe) - // When installed: exe is /usr/bin/loadbars, script might be /usr/share/loadbars/scripts/ - for _, sub := range []string{"scripts/loadbars-remote.sh", "../scripts/loadbars-remote.sh", "../../scripts/loadbars-remote.sh"} { - p := filepath.Join(dir, sub) - if _, err := os.Stat(p); err == nil { - return p - } - } - } - // Development: run from repo root - if p, err := filepath.Abs("scripts/loadbars-remote.sh"); err == nil { - if _, err := os.Stat(p); err == nil { - return p - } - } - return "scripts/loadbars-remote.sh" -} diff --git a/internal/app/store.go b/internal/app/store.go index 424bb17..9e046a4 100644 --- a/internal/app/store.go +++ b/internal/app/store.go @@ -26,18 +26,7 @@ func NewStore() *Store { return &Store{hosts: make(map[string]*hostData)} } -func (s *Store) getOrCreate(host string) *hostData { - if s.hosts[host] == nil { - s.hosts[host] = &hostData{ - mem: make(map[string]int64), - net: make(map[string]stats.NetStamp), - cpu: make(map[string]collector.CPULine), - } - } - return s.hosts[host] -} - -// SetLoadAvg implements collector.StatsStore. +// SetLoadAvg sets the load average for the given host. func (s *Store) SetLoadAvg(host, load1, load5, load15 string) { s.mu.Lock() defer s.mu.Unlock() @@ -45,7 +34,7 @@ func (s *Store) SetLoadAvg(host, load1, load5, load15 string) { d.load1, d.load5, d.load15 = load1, load5, load15 } -// SetCPU implements collector.StatsStore. +// SetCPU sets the CPU line for the given host and CPU name. func (s *Store) SetCPU(host, name string, line collector.CPULine) { s.mu.Lock() defer s.mu.Unlock() @@ -53,7 +42,7 @@ func (s *Store) SetCPU(host, name string, line collector.CPULine) { d.cpu[name] = line } -// SetMem implements collector.StatsStore. +// SetMem sets a meminfo key/value for the given host. func (s *Store) SetMem(host, key string, value int64) { s.mu.Lock() defer s.mu.Unlock() @@ -61,7 +50,7 @@ func (s *Store) SetMem(host, key string, value int64) { d.mem[key] = value } -// SetNet implements collector.StatsStore. +// SetNet sets the network stamp for the given host and interface. func (s *Store) SetNet(host, iface string, net collector.NetLine, stamp float64) { s.mu.Lock() defer s.mu.Unlock() @@ -69,7 +58,7 @@ func (s *Store) SetNet(host, iface string, net collector.NetLine, stamp float64) d.net[iface] = stats.NetStamp{B: net.B, Tb: net.Tb, Stamp: stamp} } -// Snapshot returns a copy of current stats for all hosts (for display). +// Snapshot returns a copy of current stats for all hosts for the display. func (s *Store) Snapshot() map[string]*stats.HostStats { s.mu.RLock() defer s.mu.RUnlock() @@ -99,5 +88,16 @@ func (s *Store) Snapshot() map[string]*stats.HostStats { return out } +func (s *Store) getOrCreate(host string) *hostData { + if s.hosts[host] == nil { + s.hosts[host] = &hostData{ + mem: make(map[string]int64), + net: make(map[string]stats.NetStamp), + cpu: make(map[string]collector.CPULine), + } + } + return s.hosts[host] +} + var _ stats.Source = (*Store)(nil) var _ collector.StatsStore = (*Store)(nil) diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 42ab689..dea88c7 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -2,9 +2,9 @@ package collector import ( "bufio" + "bytes" "context" "fmt" - "os" "os/exec" "strings" "time" @@ -20,13 +20,16 @@ type StatsStore interface { SetNet(host, iface string, net NetLine, stamp float64) } -// Run starts a collector for one host: runs the remote (or local) script and parses the stream into store. +// Run starts a collector for one host: runs the embedded remote script (local or over SSH) and parses the stream into store. // Host may be "host" or "host:user". It runs until ctx is cancelled or the command exits. -func Run(ctx context.Context, host string, cfg *config.Config, store StatsStore, scriptPath string) error { +// The script is embedded in the binary; no external script file is required. +func Run(ctx context.Context, host string, cfg *config.Config, store StatsStore) error { hostKey, user := splitHostUser(host) + script := bytes.NewReader(RemoteScript) var scanner *bufio.Scanner if isLocal(hostKey) { - cmd := exec.CommandContext(ctx, "bash", scriptPath) + cmd := exec.CommandContext(ctx, "bash", "-s") + cmd.Stdin = script stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("%s: %w", hostKey, err) @@ -46,12 +49,7 @@ func Run(ctx context.Context, host string, cfg *config.Config, store StatsStore, } args = append(args, hostKey, "bash -s") cmd := exec.CommandContext(ctx, "ssh", args...) - scriptFile, err := os.Open(scriptPath) - if err != nil { - return fmt.Errorf("%s: open script: %w", hostKey, err) - } - defer scriptFile.Close() - cmd.Stdin = scriptFile + cmd.Stdin = script stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("%s: %w", hostKey, err) @@ -123,5 +121,3 @@ func splitHostUser(host string) (h, u string) { func isLocal(h string) bool { return h == "localhost" || h == "127.0.0.1" } - - diff --git a/internal/collector/parse_test.go b/internal/collector/parse_test.go index 6edac33..ec77067 100644 --- a/internal/collector/parse_test.go +++ b/internal/collector/parse_test.go @@ -6,12 +6,12 @@ import ( func TestParseCPULine(t *testing.T) { tests := []struct { - name string - line string - wantName string - wantUser int64 + name string + line string + wantName string + wantUser int64 wantTotal int64 - wantErr bool + wantErr bool }{ {"normal", "cpu 100 0 50 200 0 0 0 0 0 0", "cpu", 100, 350, false}, {"cpu0", "cpu0 10 0 5 80 0 0 0 0 0 0", "cpu0", 10, 95, false}, diff --git a/internal/collector/types.go b/internal/collector/types.go index 1b10979..b0db600 100644 --- a/internal/collector/types.go +++ b/internal/collector/types.go @@ -2,7 +2,7 @@ package collector // CPULine is one line of /proc/stat: cpu name + counters (user, nice, system, idle, ...). type CPULine struct { - Name string + Name string User, Nice, System, Idle, Iowait, IRQ, SoftIRQ, Steal, Guest, GuestNice int64 } diff --git a/internal/config/config.go b/internal/config/config.go index 42db523..e523372 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,13 +39,13 @@ func Default() Config { CPUAverage: 10, Extended: false, HasAgent: false, - Height: 150, - MaxWidth: 1900, + Height: 150, + MaxWidth: 1900, NetAverage: 15, - NetLink: "gbit", - ShowCores: false, - ShowMem: false, - ShowNet: false, + NetLink: "gbit", + ShowCores: false, + ShowMem: false, + ShowNet: false, } } @@ -75,6 +75,31 @@ func (c *Config) Load() error { return c.parseReader(f) } +// Write saves the current config to the config file (excluding title). +func (c *Config) Write() error { + path, err := ConfFilePath() + if err != nil { + return err + } + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create config: %w", err) + } + defer f.Close() + return c.writeTo(f) +} + +// GetClusterHosts resolves a cluster name from /etc/clusters into a list of hosts. +func GetClusterHosts(cluster string) ([]string, error) { + return GetClusterHostsFromFile(cluster, constants.CSSHConfFile) +} + +// GetClusterHostsFromFile resolves a cluster from a clusters file (for testing or custom path). +// Supports recursive cluster references with cycle detection. +func GetClusterHostsFromFile(cluster, path string) ([]string, error) { + return getClusterHostsRec(cluster, path, 1, nil) +} + func (c *Config) parseReader(f *os.File) error { validKeys := map[string]bool{ "title": true, "barwidth": true, "cpuaverage": true, "extended": true, @@ -155,20 +180,6 @@ func parseBool(s string) bool { return s == "1" || s == "true" || s == "yes" } -// Write saves the current config to the config file (excluding title). -func (c *Config) Write() error { - path, err := ConfFilePath() - if err != nil { - return err - } - f, err := os.Create(path) - if err != nil { - return fmt.Errorf("create config: %w", err) - } - defer f.Close() - return c.writeTo(f) -} - func (c *Config) writeTo(f *os.File) error { w := bufio.NewWriter(f) writeInt := func(key string, v int) { fmt.Fprintf(w, "%s=%d\n", key, v) } @@ -197,17 +208,6 @@ func (c *Config) writeTo(f *os.File) error { return w.Flush() } -// GetClusterHosts resolves a cluster name from /etc/clusters into a list of hosts. -func GetClusterHosts(cluster string) ([]string, error) { - return GetClusterHostsFromFile(cluster, constants.CSSHConfFile) -} - -// GetClusterHostsFromFile resolves a cluster from a clusters file (for testing or custom path). -// Supports recursive cluster references with cycle detection. -func GetClusterHostsFromFile(cluster, path string) ([]string, error) { - return getClusterHostsRec(cluster, path, 1, nil) -} - func getClusterHostsRec(cluster, path string, depth int, seen map[string]bool) ([]string, error) { if depth > constants.CSSHMaxRecursion { return nil, fmt.Errorf("cluster recursion limit reached in %s (possible cycle)", path) diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 44a0478..6787045 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -2,18 +2,18 @@ package constants // Paths and limits const ( - ConfFile = ".loadbarsrc" - CSSHConfFile = "/etc/clusters" - CSSHMaxRecursion = 10 - ColorDepth = 32 - Interval = 0.14 // CPU sampling interval (seconds) - IntervalNet = 3.0 // Network stats interval - IntervalMem = 1.0 // Memory stats interval - IntervalSDL = 0.14 // Display refresh interval - IntervalSDLWarn = 1.0 // Warn if loop is slower than this - SystemBlueThreshold = 30 // CPU system % for lighter blue - UserOrangeThreshold = 70 // CPU user % for orange - UserYellowThreshold = 50 // CPU user % for dark yellow + ConfFile = ".loadbarsrc" + CSSHConfFile = "/etc/clusters" + CSSHMaxRecursion = 10 + ColorDepth = 32 + Interval = 0.14 // CPU sampling interval (seconds) + IntervalNet = 3.0 // Network stats interval + IntervalMem = 1.0 // Memory stats interval + IntervalSDL = 0.14 // Display refresh interval + IntervalSDLWarn = 1.0 // Warn if loop is slower than this + SystemBlueThreshold = 30 // CPU system % for lighter blue + UserOrangeThreshold = 70 // CPU user % for orange + UserYellowThreshold = 50 // CPU user % for dark yellow ) // Exit codes @@ -26,27 +26,27 @@ const ( // Copyright string const Copyright = "2010-2026 (c) Paul Buetow <loadbars@dev.buetow.org>" -// RGB returns R, G, B in 0-255 for SDL. +// RGB holds R, G, B in 0-255 for SDL. type RGB struct{ R, G, B uint8 } // Colors used by the display (matching Perl Constants.pm) var ( - Black = RGB{0x00, 0x00, 0x00} - Blue0 = RGB{0x00, 0x00, 0xff} - LightBlue = RGB{0x00, 0x00, 0xdd} + Black = RGB{0x00, 0x00, 0x00} + Blue0 = RGB{0x00, 0x00, 0xff} + LightBlue = RGB{0x00, 0x00, 0xdd} LightBlue0 = RGB{0x00, 0x00, 0xcc} - Blue = RGB{0x00, 0x00, 0x88} - Green = RGB{0x00, 0x90, 0x00} + Blue = RGB{0x00, 0x00, 0x88} + Green = RGB{0x00, 0x90, 0x00} LightGreen = RGB{0x00, 0xf0, 0x00} - Orange = RGB{0xff, 0x70, 0x00} - Purple = RGB{0xa0, 0x20, 0xf0} - Red = RGB{0xff, 0x00, 0x00} - White = RGB{0xff, 0xff, 0xff} - Grey0 = RGB{0x11, 0x11, 0x11} - Grey = RGB{0xaa, 0xaa, 0xaa} - DarkGrey = RGB{0x15, 0x15, 0x15} - Yellow0 = RGB{0xff, 0xa0, 0x00} - Yellow = RGB{0xff, 0xc0, 0x00} + Orange = RGB{0xff, 0x70, 0x00} + Purple = RGB{0xa0, 0x20, 0xf0} + Red = RGB{0xff, 0x00, 0x00} + White = RGB{0xff, 0xff, 0xff} + Grey0 = RGB{0x11, 0x11, 0x11} + Grey = RGB{0xaa, 0xaa, 0xaa} + DarkGrey = RGB{0x15, 0x15, 0x15} + Yellow0 = RGB{0xff, 0xa0, 0x00} + Yellow = RGB{0xff, 0xc0, 0x00} ) // BytesPerSec for link speed reference (bytes per second at given mbit) diff --git a/internal/display/display.go b/internal/display/display.go index 45b7728..231b8c6 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -17,6 +17,51 @@ import ( "github.com/veandco/go-sdl2/sdl" ) +const smoothFactor = 0.12 // blend toward target each frame; lower = smoother + +// runState holds mutable state across the display loop (hotkeys, window size, smoothed data). +type runState struct { + showCores bool + showMem bool + showNet bool + extended bool + winW int32 + winH int32 + lastNumBars int + lastWinW int32 + lastWinH int32 + prevCPU map[string]collector.CPULine + smoothedCPU map[string]*[9]float64 + smoothedMem map[string]*struct{ ramUsed, swapUsed float64 } + smoothedNet map[string]*struct{ rxPct, txPct float64 } + prevNet map[string]stats.NetStamp + netIntIndex map[string]int + cycleNetNext bool + printNetInfoOnce bool + peakHistory map[string][]float64 +} + +// newRunState builds initial run state from config. +func newRunState(cfg *config.Config, winW, winH int32) *runState { + return &runState{ + showCores: cfg.ShowCores, + showMem: cfg.ShowMem, + showNet: cfg.ShowNet, + extended: cfg.Extended, + winW: winW, + winH: winH, + lastNumBars: -1, + prevCPU: make(map[string]collector.CPULine), + smoothedCPU: make(map[string]*[9]float64), + smoothedMem: make(map[string]*struct{ ramUsed, swapUsed float64 }), + smoothedNet: make(map[string]*struct{ rxPct, txPct float64 }), + prevNet: make(map[string]stats.NetStamp), + netIntIndex: make(map[string]int), + printNetInfoOnce: cfg.ShowNet, + peakHistory: make(map[string][]float64), + } +} + // Run runs the SDL display loop until ctx is cancelled or user presses 'q'. func Run(ctx context.Context, cfg *config.Config, src stats.Source) error { if err := sdl.Init(sdl.INIT_VIDEO); err != nil { @@ -24,55 +69,25 @@ func Run(ctx context.Context, cfg *config.Config, src stats.Source) error { } defer sdl.Quit() - width := cfg.BarWidth - if width < 1 { - width = 1 - } - if width > cfg.MaxWidth { - width = cfg.MaxWidth - } + const minWindowWidth = 800 + width := clampInt(cfg.BarWidth, minWindowWidth, cfg.MaxWidth) height := cfg.Height if height < 1 { height = 1 } - title := cfg.Title if title == "" { title = "Loadbars " + version.Version + " (press h for help on stdout)" } - window, renderer, err := sdl.CreateWindowAndRenderer(int32(width), int32(height), sdl.WINDOW_RESIZABLE) if err != nil { return fmt.Errorf("create window: %w", err) } defer window.Destroy() defer renderer.Destroy() - window.SetTitle(title) - // Mutable copy of config for hotkey toggles (only what display needs) - showCores := cfg.ShowCores - showMem := cfg.ShowMem - showNet := cfg.ShowNet - extended := cfg.Extended - winW, winH := int32(width), int32(height) - - // Previous CPU state for delta (key = host;cpuName) - prevCPU := make(map[string]collector.CPULine) - // Smoothed values for transitions (blend toward target each frame) - const smoothFactor = 0.12 // lower = smoother, less flicker from noisy samples - smoothedCPU := make(map[string]*[9]float64) - smoothedMem := make(map[string]*struct{ ramUsed, swapUsed float64 }) - smoothedNet := make(map[string]*struct{ rxPct, txPct float64 }) - prevNet := make(map[string]stats.NetStamp) - netIntIndex := make(map[string]int) // for cycling interface per host - var cycleNetNext bool - var printNetInfoOnce bool = showNet // print chosen interface when net view is on (once at start or after toggling on) - // Peak history for extended mode: per CPU bar key, ring of (system+user) % - peakHistory := make(map[string][]float64) - - lastNumBars := -1 - lastWinW, lastWinH := int32(0), int32(0) + state := newRunState(cfg, int32(width), int32(height)) ticker := time.NewTicker(time.Duration(constants.IntervalSDL * float64(time.Second))) defer ticker.Stop() @@ -82,211 +97,246 @@ func Run(ctx context.Context, cfg *config.Config, src stats.Source) error { return ctx.Err() default: } + if handleEvents(window, cfg, state) { + return nil + } + drawFrame(renderer, src, cfg, state) + renderer.Present() + sdl.Delay(10) + <-ticker.C + } +} - // Poll all pending events - for e := sdl.PollEvent(); e != nil; e = sdl.PollEvent() { - switch ev := e.(type) { - case *sdl.QuitEvent: - return nil - case *sdl.KeyboardEvent: - if ev.Type != sdl.KEYDOWN || ev.Repeat != 0 { - continue - } - sym := ev.Keysym.Sym - switch sym { - case sdl.K_q: - return nil - case sdl.K_1: - showCores = !showCores - fmt.Println("==> Toggled show cores:", showCores) - case sdl.K_2: - showMem = !showMem - fmt.Println("==> Toggled show mem:", showMem) - case sdl.K_3: - showNet = !showNet - fmt.Println("==> Toggled show net:", showNet) - if showNet { - printNetInfoOnce = true - } - case sdl.K_e: - extended = !extended - fmt.Println("==> Toggled extended (peak line):", extended) - case sdl.K_a: - cfg.CPUAverage++ - fmt.Println("==> CPU average samples:", cfg.CPUAverage) - case sdl.K_y: - if cfg.CPUAverage > 1 { - cfg.CPUAverage-- - } - fmt.Println("==> CPU average samples:", cfg.CPUAverage) - case sdl.K_d: - cfg.NetAverage++ - fmt.Println("==> Net average samples:", cfg.NetAverage) - case sdl.K_c: - if cfg.NetAverage > 1 { - cfg.NetAverage-- - } - fmt.Println("==> Net average samples:", cfg.NetAverage) - case sdl.K_h: - printHotkeys() - case sdl.K_n: - cycleNetNext = true - if showNet { - fmt.Println("==> Cycling to next network interface (per host)") - } - case sdl.K_w: - cfg.ShowCores = showCores - cfg.ShowMem = showMem - cfg.ShowNet = showNet - cfg.Extended = extended - if err := cfg.Write(); err != nil { - fmt.Fprintf(os.Stderr, "!!! Write config: %v\n", err) - } else { - fmt.Println("==> Config written to ~/.loadbarsrc") - } - case sdl.K_LEFT: - winW -= 100 - if winW < 1 { - winW = 1 - } - window.SetSize(winW, winH) - case sdl.K_RIGHT: - winW += 100 - if winW > int32(cfg.MaxWidth) { - winW = int32(cfg.MaxWidth) - } - window.SetSize(winW, winH) - case sdl.K_UP: - winH -= 100 - if winH < 1 { - winH = 1 - } - window.SetSize(winW, winH) - case sdl.K_DOWN: - winH += 100 - window.SetSize(winW, winH) - } - case *sdl.WindowEvent: - if ev.Event == sdl.WINDOWEVENT_RESIZED { - winW, winH = ev.Data1, ev.Data2 - } +func clampInt(v, min, max int) int { + if v < min { + return min + } + if v > max { + return max + } + return v +} + +// handleEvents processes all pending SDL events and updates state. Returns true if the user quit. +func handleEvents(window *sdl.Window, cfg *config.Config, state *runState) bool { + for e := sdl.PollEvent(); e != nil; e = sdl.PollEvent() { + switch ev := e.(type) { + case *sdl.QuitEvent: + return true + case *sdl.KeyboardEvent: + if ev.Type != sdl.KEYDOWN || ev.Repeat != 0 { + continue + } + if handleKey(ev.Keysym.Sym, window, cfg, state) { + return true + } + case *sdl.WindowEvent: + if ev.Event == sdl.WINDOWEVENT_RESIZED { + state.winW, state.winH = ev.Data1, ev.Data2 } } + } + return false +} - snap := src.Snapshot() - if cycleNetNext { - for _, host := range sortedHosts(snap) { - netIntIndex[host]++ - } - cycleNetNext = false +// handleKey handles one key press; returns true to quit. +func handleKey(sym sdl.Keycode, window *sdl.Window, cfg *config.Config, state *runState) bool { + switch sym { + case sdl.K_q: + return true + case sdl.K_1: + state.showCores = !state.showCores + fmt.Println("==> Toggled show cores:", state.showCores) + case sdl.K_2: + state.showMem = !state.showMem + fmt.Println("==> Toggled show mem:", state.showMem) + case sdl.K_3: + state.showNet = !state.showNet + fmt.Println("==> Toggled show net:", state.showNet) + if state.showNet { + state.printNetInfoOnce = true } - // One-time: print which interface is used for net stats and how to configure - if printNetInfoOnce && showNet { - printNetInfoOnce = false - printNetInterfaceHelp(snap, cfg, netIntIndex) + case sdl.K_e: + state.extended = !state.extended + fmt.Println("==> Toggled extended (peak line):", state.extended) + case sdl.K_a: + cfg.CPUAverage++ + fmt.Println("==> CPU average samples:", cfg.CPUAverage) + case sdl.K_y: + if cfg.CPUAverage > 1 { + cfg.CPUAverage-- } - // Count total bars we will draw (only non-nil hosts) so layout matches draw order - numBars := 0 - for _, host := range sortedHosts(snap) { - if h := snap[host]; h != nil { - numBars += len(sortedCPUNames(h.CPU, showCores)) - if showMem { - numBars++ - } - if showNet { - numBars++ - } - } + fmt.Println("==> CPU average samples:", cfg.CPUAverage) + case sdl.K_d: + cfg.NetAverage++ + fmt.Println("==> Net average samples:", cfg.NetAverage) + case sdl.K_c: + if cfg.NetAverage > 1 { + cfg.NetAverage-- } - if numBars == 0 { - numBars = 1 + fmt.Println("==> Net average samples:", cfg.NetAverage) + case sdl.K_h: + printHotkeys() + case sdl.K_n: + state.cycleNetNext = true + if state.showNet { + fmt.Println("==> Cycling to next network interface (per host)") } - - barWidth := winW / int32(numBars) - if barWidth < 1 { - barWidth = 1 + case sdl.K_w: + cfg.ShowCores = state.showCores + cfg.ShowMem = state.showMem + cfg.ShowNet = state.showNet + cfg.Extended = state.extended + if err := cfg.Write(); err != nil { + fmt.Fprintf(os.Stderr, "!!! Write config: %v\n", err) + } else { + fmt.Println("==> Config written to ~/.loadbarsrc") } + case sdl.K_LEFT: + state.winW -= 100 + if state.winW < 1 { + state.winW = 1 + } + window.SetSize(state.winW, state.winH) + case sdl.K_RIGHT: + state.winW += 100 + if state.winW > int32(cfg.MaxWidth) { + state.winW = int32(cfg.MaxWidth) + } + window.SetSize(state.winW, state.winH) + case sdl.K_UP: + state.winH -= 100 + if state.winH < 1 { + state.winH = 1 + } + window.SetSize(state.winW, state.winH) + case sdl.K_DOWN: + state.winH += 100 + window.SetSize(state.winW, state.winH) + } + return false +} - // Clear only when layout changes (bar count or window size) to avoid full-screen flicker - if numBars != lastNumBars || winW != lastWinW || winH != lastWinH { - renderer.SetDrawColor(0, 0, 0, 255) - renderer.Clear() - lastNumBars = numBars - lastWinW, lastWinH = winW, winH +// drawFrame updates state from snapshot, clears if layout changed, and draws all bars. +func drawFrame(renderer *sdl.Renderer, src stats.Source, cfg *config.Config, state *runState) { + snap := src.Snapshot() + if state.cycleNetNext { + for _, host := range sortedHosts(snap) { + state.netIntIndex[host]++ } + state.cycleNetNext = false + } + if state.printNetInfoOnce && state.showNet { + state.printNetInfoOnce = false + printNetInterfaceHelp(snap, cfg, state.netIntIndex) + } + numBars := countBars(snap, state.showCores, state.showMem, state.showNet) + barWidth := state.winW / int32(numBars) + if barWidth < 1 { + barWidth = 1 + } + if numBars != state.lastNumBars || state.winW != state.lastWinW || state.winH != state.lastWinH { + renderer.SetDrawColor(0, 0, 0, 255) + renderer.Clear() + state.lastNumBars = numBars + state.lastWinW, state.lastWinH = state.winW, state.winH + } + drawBars(renderer, snap, cfg, state, barWidth) +} - x := int32(0) - hosts := sortedHosts(snap) - for _, host := range hosts { - h := snap[host] - if h == nil { - continue - } - // Draw CPU bars for this host (aggregate or per-core), with smoothing - cpuNames := sortedCPUNames(h.CPU, showCores) - for _, name := range cpuNames { - key := host + ";" + name - cur := h.CPU[name] - prev := prevCPU[key] - prevCPU[key] = cur - target, ok := cpuBarTargetPcts(cur, prev) - s := smoothedCPU[key] - if s == nil { - s = &[9]float64{} - smoothedCPU[key] = s - if ok { - *s = target - } - } else if ok { - for i := 0; i < 9; i++ { - (*s)[i] += (target[i] - (*s)[i]) * smoothFactor - } - normalizePcts9(s) - } - // Peak line (extended): max of (system+user) over last CPUAverage samples - var peakPct float64 - if extended && s != nil { - userSys := (*s)[0] + (*s)[1] - hist := peakHistory[key] - hist = append(hist, userSys) - n := cfg.CPUAverage - if n < 1 { - n = 1 - } - for len(hist) > n { - hist = hist[1:] - } - peakHistory[key] = hist - for _, v := range hist { - if v > peakPct { - peakPct = v - } - } - } - // Always draw (smoothed or last state) so we never leave a blank bar and cause flicker - drawCPUBarFromPcts(renderer, s, barWidth, &x, winH, extended, peakPct) - } - // Draw memory bar(s) for this host when showMem, with smoothing +func countBars(snap map[string]*stats.HostStats, showCores, showMem, showNet bool) int { + n := 0 + for _, host := range sortedHosts(snap) { + if h := snap[host]; h != nil { + n += len(sortedCPUNames(h.CPU, showCores)) if showMem { - if smoothedMem[host] == nil { - smoothedMem[host] = &struct{ ramUsed, swapUsed float64 }{} - } - drawMemBarSmoothed(renderer, h, smoothedMem[host], smoothFactor, barWidth, &x, winH) + n++ } - // Draw network bar(s) for this host when showNet if showNet { - if smoothedNet[host] == nil { - smoothedNet[host] = &struct{ rxPct, txPct float64 }{} - } - prevNet[host] = drawNetBarSmoothed(renderer, h, cfg, smoothedNet[host], prevNet[host], netIntIndex, host, smoothFactor, barWidth, &x, winH) + n++ } } + } + if n == 0 { + n = 1 + } + return n +} - renderer.Present() - sdl.Delay(10) +// drawBars draws CPU, memory, and network bars for all hosts in snap. +func drawBars(renderer *sdl.Renderer, snap map[string]*stats.HostStats, cfg *config.Config, state *runState, barWidth int32) { + x := int32(0) + for _, host := range sortedHosts(snap) { + h := snap[host] + if h == nil { + continue + } + drawHostBars(renderer, h, host, cfg, state, barWidth, &x) + } +} - <-ticker.C +// drawHostBars draws CPU, mem, and net bars for one host and advances x. +func drawHostBars(renderer *sdl.Renderer, h *stats.HostStats, host string, cfg *config.Config, state *runState, barWidth int32, x *int32) { + winH := state.winH + cpuNames := sortedCPUNames(h.CPU, state.showCores) + for _, name := range cpuNames { + key := host + ";" + name + cur := h.CPU[name] + prev := state.prevCPU[key] + state.prevCPU[key] = cur + target, ok := cpuBarTargetPcts(cur, prev) + s := state.smoothedCPU[key] + if s == nil { + s = &[9]float64{} + state.smoothedCPU[key] = s + if ok { + *s = target + } + } else if ok { + for i := 0; i < 9; i++ { + (*s)[i] += (target[i] - (*s)[i]) * smoothFactor + } + normalizePcts9(s) + } + peakPct := peakPctForBar(state, key, cfg.CPUAverage, s) + drawCPUBarFromPcts(renderer, s, barWidth, x, winH, state.extended, peakPct) + } + if state.showMem { + if state.smoothedMem[host] == nil { + state.smoothedMem[host] = &struct{ ramUsed, swapUsed float64 }{} + } + drawMemBarSmoothed(renderer, h, state.smoothedMem[host], smoothFactor, barWidth, x, winH) + } + if state.showNet { + if state.smoothedNet[host] == nil { + state.smoothedNet[host] = &struct{ rxPct, txPct float64 }{} + } + state.prevNet[host] = drawNetBarSmoothed(renderer, h, cfg, state.smoothedNet[host], state.prevNet[host], state.netIntIndex, host, smoothFactor, barWidth, x, winH) + } +} + +func peakPctForBar(state *runState, key string, cpuAvg int, s *[9]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 { @@ -386,15 +436,15 @@ func drawCPUBarFromPcts(renderer *sdl.Renderer, s *[9]float64, barW int32, x *in renderer.SetDrawColor(r, g, b, 255) renderer.FillRect(&sdl.Rect{X: *x, Y: int32(y), W: barW, H: hh}) } - fill(constants.Blue.R, constants.Blue.G, constants.Blue.B, (*s)[0]) // system + 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.Green.R, constants.Green.G, constants.Green.B, (*s)[2]) // nice 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 + 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 := winH - int32(peakPct*barH) @@ -618,7 +668,7 @@ func drawNetBarSmoothed(renderer *sdl.Renderer, h *stats.HostStats, cfg *config. renderer.SetDrawColor(constants.LightGreen.R, constants.LightGreen.G, constants.LightGreen.B, 255) renderer.FillRect(&sdl.Rect{X: *x + halfW, Y: winH - txH, W: halfW, H: txH}) } - if halfW > 0 && (winH - txH) > 0 { + if halfW > 0 && (winH-txH) > 0 { renderer.SetDrawColor(constants.Black.R, constants.Black.G, constants.Black.B, 255) renderer.FillRect(&sdl.Rect{X: *x + halfW, Y: 0, W: halfW, H: winH - txH}) } diff --git a/internal/stats/stats.go b/internal/stats/stats.go index f2ef85b..ebb81b3 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -14,9 +14,9 @@ type NetStamp struct { // HostStats holds the latest stats for one host (read-only snapshot). type HostStats struct { LoadAvg1, LoadAvg5, LoadAvg15 string - Mem map[string]int64 - Net map[string]NetStamp - CPU map[string]collector.CPULine + Mem map[string]int64 + Net map[string]NetStamp + CPU map[string]collector.CPULine } // Source is the interface the display uses to read current stats. |
