diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-13 22:52:46 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-13 22:52:46 +0200 |
| commit | cd5a3614baab756a41d764b79308afeea93f12dd (patch) | |
| tree | efc8c31e8b162ca2121ba92c841322119e6d3b04 | |
| parent | bf7c6ade292a6444877797c8d699d147aceb57cc (diff) | |
Remove Perl version and build files; add .gitignore for .serena/
Amp-Thread-ID: https://ampcode.com/threads/T-019c58b3-06fb-733d-8fc1-f268fe7f70d5
Co-authored-by: Amp <amp@ampcode.com>
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Justfile | 57 | ||||
| -rw-r--r-- | README.md | 12 | ||||
| -rw-r--r-- | cmd/loadbars/main.go | 129 | ||||
| -rw-r--r-- | go.mod | 5 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | internal/app/app.go | 35 | ||||
| -rw-r--r-- | internal/app/script.go | 29 | ||||
| -rw-r--r-- | internal/app/store.go | 103 | ||||
| -rw-r--r-- | internal/collector/collector.go | 129 | ||||
| -rw-r--r-- | internal/collector/parse.go | 102 | ||||
| -rw-r--r-- | internal/collector/parse_test.go | 112 | ||||
| -rw-r--r-- | internal/collector/protocol.go | 9 | ||||
| -rw-r--r-- | internal/collector/types.go | 36 | ||||
| -rw-r--r-- | internal/config/config.go | 263 | ||||
| -rw-r--r-- | internal/config/config_test.go | 123 | ||||
| -rw-r--r-- | internal/constants/constants.go | 59 | ||||
| -rw-r--r-- | internal/display/display.go | 287 | ||||
| -rw-r--r-- | internal/stats/stats.go | 25 | ||||
| -rw-r--r-- | internal/version/version.go | 4 | ||||
| -rwxr-xr-x | loadbars | 76 | ||||
| -rwxr-xr-x | loadbars-go | bin | 0 -> 3847840 bytes | |||
| -rw-r--r-- | scripts/loadbars-remote.sh | 35 |
23 files changed, 1500 insertions, 133 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e5aea8 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.serena/ diff --git a/Justfile b/Justfile deleted file mode 100644 index 5f5385c..0000000 --- a/Justfile +++ /dev/null @@ -1,57 +0,0 @@ -NAME := "loadbars" -VERSION := "0.7.5" - -default: version perltidy - -version: - echo {{VERSION}} > .version - -profile: - perl -d:NYTProf loadbars --hosts localhost - nytprofhtml nytprof.out - -perltidy: - find . -name \*.pm | xargs perltidy -b - perltidy -b {{NAME}} - find . -name \*.bak -delete - -install DESTDIR="": - #!/usr/bin/env bash - if [ ! -d "{{DESTDIR}}/usr/bin" ]; then - mkdir -p {{DESTDIR}}/usr/bin - fi - if [ ! -d "{{DESTDIR}}/usr/share/{{NAME}}" ]; then - mkdir -p {{DESTDIR}}/usr/share/{{NAME}} - fi - cp {{NAME}} {{DESTDIR}}/usr/bin - cp -r ./lib {{DESTDIR}}/usr/share/{{NAME}}/lib - cp -r ./fonts {{DESTDIR}}/usr/share/{{NAME}}/fonts - cp ./.version {{DESTDIR}}/usr/share/{{NAME}}/version - -deinstall DESTDIR="": - #!/usr/bin/env bash - if [ -n "{{DESTDIR}}" ] && [ -f "{{DESTDIR}}/usr/bin/{{NAME}}" ]; then - rm {{DESTDIR}}/usr/bin/{{NAME}} - fi - if [ -n "{{DESTDIR}}" ] && [ -d "{{DESTDIR}}/usr/share/{{NAME}}" ]; then - rm -r {{DESTDIR}}/usr/share/{{NAME}} - fi - -clean: - #!/usr/bin/env bash - if [ -f nytprof.out ]; then - rm nytprof.out - fi - if [ -f tmon.out ]; then - rm tmon.out - fi - if [ -d nytprof ]; then - rm -Rf nytprof - fi - -release: version perltidy - git add -A - git commit -m 'New release {{VERSION}}' - git tag {{VERSION}} - git push --tags - git push origin master @@ -38,6 +38,18 @@ loadbars servername{01..50}.example.com --showcores 1 Loadbars is a small script that can be used to observe CPU loads of several remote servers at once in real time. It connects with SSH (using SSH public/private key auth) to several servers at once and vizualizes all server CPUs and memory statistics right next each other (either summarized or each core separately). Loadbars is not a tool for collecting CPU loads and drawing graphs for later analysis. However, since such tools require a significant amount of time before producing results, Loadbars lets you observe the current state immediately. Loadbars does not remember or record any load information. It just shows the current CPU usages like top or vmstat does. +## Go version (loadbars-go) + +A Go rewrite is available in this repo. Build and run: + +```bash +go build -o loadbars-go ./cmd/loadbars +./loadbars-go --hosts localhost +# or: just go-build && ./loadbars-go --hosts localhost +``` + +Remote hosts need no Perl: the Go binary pipes `scripts/loadbars-remote.sh` over SSH. Install: `just go-install DESTDIR=/tmp/loadbars`. + ## Installation ### Dependencies (Fedora/RHEL/CentOS) diff --git a/cmd/loadbars/main.go b/cmd/loadbars/main.go new file mode 100644 index 0000000..f4e8985 --- /dev/null +++ b/cmd/loadbars/main.go @@ -0,0 +1,129 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/loadbars/loadbars/internal/app" + "github.com/loadbars/loadbars/internal/config" + "github.com/loadbars/loadbars/internal/constants" + "github.com/loadbars/loadbars/internal/version" +) + +func main() { + cfg := config.Default() + + var ( + hosts = flag.String("hosts", "", "Comma-separated list of hosts; optional user@ in front") + cluster = flag.String("cluster", "", "Cluster name from /etc/clusters") + showHelp = flag.Bool("help", false, "Show usage") + showVer = flag.Bool("version", false, "Show version") + ) + flag.IntVar(&cfg.BarWidth, "barwidth", cfg.BarWidth, "Set bar width") + flag.IntVar(&cfg.Height, "height", cfg.Height, "Set window height") + flag.IntVar(&cfg.MaxWidth, "maxwidth", cfg.MaxWidth, "Set max width") + flag.IntVar(&cfg.CPUAverage, "cpuaverage", cfg.CPUAverage, "Num of CPU samples for avg") + flag.IntVar(&cfg.NetAverage, "netaverage", cfg.NetAverage, "Num of net samples for avg") + flag.StringVar(&cfg.NetInt, "netint", cfg.NetInt, "Interface to show netstats for") + flag.StringVar(&cfg.NetLink, "netlink", cfg.NetLink, "Link speed (mbit, 10mbit, 100mbit, gbit, 10gbit or number)") + flag.BoolVar(&cfg.ShowCores, "showcores", cfg.ShowCores, "Toggle core display") + flag.BoolVar(&cfg.ShowMem, "showmem", cfg.ShowMem, "Toggle mem display") + flag.BoolVar(&cfg.ShowNet, "shownet", cfg.ShowNet, "Toggle net display") + flag.BoolVar(&cfg.Extended, "extended", cfg.Extended, "Toggle extended display") + flag.StringVar(&cfg.Title, "title", cfg.Title, "Set title bar text") + flag.StringVar(&cfg.SSHOpts, "sshopts", cfg.SSHOpts, "Set SSH options") + flag.BoolVar(&cfg.HasAgent, "hasagent", cfg.HasAgent, "SSH key already known by agent") + + flag.Parse() + + if *showVer { + fmt.Printf("Loadbars %s %s\n", version.Version, constants.Copyright) + os.Exit(constants.Success) + } + + if err := cfg.Load(); err != nil { + fmt.Fprintf(os.Stderr, "loadbars: config: %v\n", err) + os.Exit(constants.EUnknown) + } + + // Flags override config file; re-parse into cfg for hosts/cluster + if *cluster != "" { + cfg.Cluster = *cluster + } + if *hosts != "" { + // Hosts from flag: optional user@host → we keep "user:host" or "host" + for _, h := range strings.Split(*hosts, ",") { + h = strings.TrimSpace(h) + if h == "" { + continue + } + cfg.Hosts = append(cfg.Hosts, normalizeHost(h)) + } + } + // Hosts from positional args (before first flag-like arg) + for i := 0; i < flag.NArg(); i++ { + arg := flag.Arg(i) + if strings.HasPrefix(arg, "-") { + break + } + cfg.Hosts = append(cfg.Hosts, normalizeHost(arg)) + } + if cfg.Cluster != "" { + clusterHosts, err := config.GetClusterHosts(cfg.Cluster) + if err != nil { + fmt.Fprintf(os.Stderr, "loadbars: cluster: %v\n", err) + os.Exit(constants.EUnknown) + } + cfg.Hosts = append(cfg.Hosts, clusterHosts...) + } + + if *showHelp { + printUsage() + os.Exit(constants.Success) + } + + if len(cfg.Hosts) == 0 { + fmt.Fprintf(os.Stderr, "loadbars: no hosts specified (use --hosts or --cluster)\n") + printUsage() + os.Exit(constants.ENoHost) + } + + if err := app.Run(&cfg); err != nil { + fmt.Fprintf(os.Stderr, "loadbars: %v\n", err) + os.Exit(constants.EUnknown) + } + os.Exit(constants.Success) +} + +// normalizeHost converts "user@host" to "host:user", or returns "host" if no user. +func normalizeHost(h string) string { + h = strings.TrimSpace(h) + if idx := strings.Index(h, "@"); idx >= 0 { + user, host := strings.TrimSpace(h[:idx]), strings.TrimSpace(h[idx+1:]) + return host + ":" + user + } + return h +} + +func printUsage() { + fmt.Fprintf(os.Stderr, `Loadbars %s - real-time server load monitoring + +Usage: loadbars [HOSTS...] [OPTIONS] + +Options: + --hosts <list> Comma-separated hosts (optional user@host) + --cluster <name> Cluster from %s + --barwidth <n> Bar width (default 20) + --height <n> Window height (default 150) + --showcores Show per-CPU bars + --showmem Show memory bars + --shownet Show network bars + --extended Extended display (peak line) + --help This help + --version Print version + +Press 'h' during execution for hotkeys. See README for more. +`, version.Version, constants.CSSHConfFile) +} @@ -0,0 +1,5 @@ +module github.com/loadbars/loadbars + +go 1.21 + +require github.com/veandco/go-sdl2 v0.4.40 // indirect @@ -0,0 +1,2 @@ +github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/U= +github.com/veandco/go-sdl2 v0.4.40/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..946e985 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,35 @@ +package app + +import ( + "context" + "sync" + + "github.com/loadbars/loadbars/internal/collector" + "github.com/loadbars/loadbars/internal/config" + "github.com/loadbars/loadbars/internal/display" +) + +// Run starts the loadbars application: collectors and display. +// It blocks until the user quits (e.g. 'q' key). +func Run(cfg *config.Config) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + scriptPath := ScriptPath() + store := NewStore() + + var wg sync.WaitGroup + for _, host := range cfg.Hosts { + h := host + wg.Add(1) + go func() { + defer wg.Done() + _ = collector.Run(ctx, h, cfg, store, scriptPath) + }() + } + + err := display.Run(ctx, cfg, store) + cancel() + wg.Wait() + return err +} diff --git a/internal/app/script.go b/internal/app/script.go new file mode 100644 index 0000000..2701cb2 --- /dev/null +++ b/internal/app/script.go @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..f9c90ef --- /dev/null +++ b/internal/app/store.go @@ -0,0 +1,103 @@ +package app + +import ( + "sync" + + "github.com/loadbars/loadbars/internal/collector" + "github.com/loadbars/loadbars/internal/stats" +) + +// Store holds current stats from all hosts and implements collector.StatsStore. +type Store struct { + mu sync.RWMutex + // host -> *hostData + hosts map[string]*hostData +} + +type hostData struct { + load1, load5, load15 string + mem map[string]int64 + net map[string]stats.NetStamp + cpu map[string]collector.CPULine +} + +// NewStore creates an empty store. +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. +func (s *Store) SetLoadAvg(host, load1, load5, load15 string) { + s.mu.Lock() + defer s.mu.Unlock() + d := s.getOrCreate(host) + d.load1, d.load5, d.load15 = load1, load5, load15 +} + +// SetCPU implements collector.StatsStore. +func (s *Store) SetCPU(host, name string, line collector.CPULine) { + s.mu.Lock() + defer s.mu.Unlock() + d := s.getOrCreate(host) + d.cpu[name] = line +} + +// SetMem implements collector.StatsStore. +func (s *Store) SetMem(host, key string, value int64) { + s.mu.Lock() + defer s.mu.Unlock() + d := s.getOrCreate(host) + d.mem[key] = value +} + +// SetNet implements collector.StatsStore. +func (s *Store) SetNet(host, iface string, net collector.NetLine, stamp float64) { + s.mu.Lock() + defer s.mu.Unlock() + d := s.getOrCreate(host) + 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). +func (s *Store) Snapshot() map[string]*stats.HostStats { + s.mu.RLock() + defer s.mu.RUnlock() + out := make(map[string]*stats.HostStats, len(s.hosts)) + for h, d := range s.hosts { + mem := make(map[string]int64, len(d.mem)) + for k, v := range d.mem { + mem[k] = v + } + net := make(map[string]stats.NetStamp, len(d.net)) + for k, v := range d.net { + net[k] = v + } + cpu := make(map[string]collector.CPULine, len(d.cpu)) + for k, v := range d.cpu { + cpu[k] = v + } + out[h] = &stats.HostStats{ + LoadAvg1: d.load1, + LoadAvg5: d.load5, + LoadAvg15: d.load15, + Mem: mem, + Net: net, + CPU: cpu, + } + } + return out +} + +var _ stats.Source = (*Store)(nil) +var _ collector.StatsStore = (*Store)(nil) diff --git a/internal/collector/collector.go b/internal/collector/collector.go new file mode 100644 index 0000000..f2f89de --- /dev/null +++ b/internal/collector/collector.go @@ -0,0 +1,129 @@ +package collector + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/loadbars/loadbars/internal/config" +) + +// StatsStore is the interface for receiving parsed stats (implemented by app). +type StatsStore interface { + SetLoadAvg(host, load1, load5, load15 string) + SetCPU(host, name string, line CPULine) + SetMem(host, key string, value int64) + 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. +// 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 { + hostKey, user := splitHostUser(host) + var scanner *bufio.Scanner + if isLocal(hostKey) { + cmd := exec.CommandContext(ctx, "bash", scriptPath) + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("%s: %w", hostKey, err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("%s: %w", hostKey, err) + } + defer cmd.Wait() + scanner = bufio.NewScanner(stdout) + } else { + args := []string{"-o", "StrictHostKeyChecking=no"} + if cfg.SSHOpts != "" { + args = append(args, strings.Fields(cfg.SSHOpts)...) + } + if user != "" { + args = append(args, "-l", user) + } + 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 + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("%s: %w", hostKey, err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("%s: %w", hostKey, err) + } + defer cmd.Wait() + scanner = bufio.NewScanner(stdout) + } + + mode := "" + cpustring := "cpu" + if !cfg.ShowCores { + cpustring = "cpu " + } + for scanner.Scan() { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "M ") { + mode = line + continue + } + switch mode { + case ModeLoadAvg: + l := ParseLoadAvg(line) + store.SetLoadAvg(hostKey, l.Load1, l.Load5, l.Load15) + case ModeMemStats: + if mem, ok := ParseMemLine(line); ok { + store.SetMem(hostKey, mem.Key, mem.Value) + } + case ModeNetStats: + if idx := strings.Index(line, ":"); idx >= 0 { + iface := strings.TrimSpace(line[:idx]) + rest := line[idx+1:] + net, err := ParseNetLine(iface + ":" + rest) + if err != nil { + continue + } + store.SetNet(hostKey, net.Iface, net, float64(time.Now().UnixNano())/1e9) + } + case ModeCPUStats: + if strings.HasPrefix(line, cpustring) { + cu, err := ParseCPULine(line) + if err != nil { + continue + } + store.SetCPU(hostKey, cu.Name, cu) + } + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("%s: read: %w", hostKey, err) + } + return nil +} + +// splitHostUser splits "host:user" into (host, user). If no colon, returns (host, ""). +func splitHostUser(host string) (h, u string) { + idx := strings.Index(host, ":") + if idx < 0 { + return strings.TrimSpace(host), "" + } + return strings.TrimSpace(host[:idx]), strings.TrimSpace(host[idx+1:]) +} + +func isLocal(h string) bool { + return h == "localhost" || h == "127.0.0.1" +} + + diff --git a/internal/collector/parse.go b/internal/collector/parse.go new file mode 100644 index 0000000..4f49456 --- /dev/null +++ b/internal/collector/parse.go @@ -0,0 +1,102 @@ +package collector + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// Mem key regex: "MemTotal: 12345 kB" -> MemTotal, 12345 +var memRegex = regexp.MustCompile(`^([A-Za-z0-9_]+):\s*(\d+)`) + +// ParseCPULine parses a /proc/stat line: "cpu 100 0 50 200 0 0 0 0 0 0" (name + 10 numbers). +// Older kernels may have fewer fields; missing ones are treated as 0. +func ParseCPULine(line string) (CPULine, error) { + fields := strings.Fields(line) + if len(fields) < 2 { + return CPULine{}, fmt.Errorf("cpu line too short: %q", line) + } + nums := make([]int64, 10) + for i := 1; i < len(fields) && i-1 < 10; i++ { + n, _ := strconv.ParseInt(fields[i], 10, 64) + nums[i-1] = n + } + return CPULine{ + Name: fields[0], + User: nums[0], + Nice: nums[1], + System: nums[2], + Idle: nums[3], + Iowait: nums[4], + IRQ: nums[5], + SoftIRQ: nums[6], + Steal: nums[7], + Guest: nums[8], + GuestNice: nums[9], + }, nil +} + +// ParseMemLine parses a /proc/meminfo line: "MemTotal: 123456 kB". +func ParseMemLine(line string) (MemLine, bool) { + m := memRegex.FindStringSubmatch(line) + if m == nil { + return MemLine{}, false + } + v, _ := strconv.ParseInt(m[2], 10, 64) + return MemLine{Key: m[1], Value: v}, true +} + +// ParseNetLine parses a protocol net line: "eth0:b=0;tb=0;p=0;tp=0 e=0;te=0;d=0;td=0". +// There may be a space between first block (b,tb,p,tp) and second (e,te,d,td). +func ParseNetLine(line string) (NetLine, error) { + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + return NetLine{}, fmt.Errorf("net line missing colon: %q", line) + } + net := NetLine{Iface: strings.TrimSpace(parts[0])} + rest := strings.ReplaceAll(parts[1], " ", ";") + for _, pair := range strings.Split(rest, ";") { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + continue + } + k := strings.TrimSpace(kv[0]) + v, _ := strconv.ParseInt(strings.TrimSpace(kv[1]), 10, 64) + switch k { + case "b": + net.B = v + case "tb": + net.Tb = v + case "p": + net.P = v + case "tp": + net.Tp = v + case "e": + net.E = v + case "te": + net.Te = v + case "d": + net.D = v + case "td": + net.Td = v + } + } + return net, nil +} + +// ParseLoadAvg parses "1.0;0.5;0.2" into Load1, Load5, Load15. +func ParseLoadAvg(line string) LoadAvg { + parts := strings.SplitN(line, ";", 3) + l := LoadAvg{} + if len(parts) > 0 { + l.Load1 = strings.TrimSpace(parts[0]) + } + if len(parts) > 1 { + l.Load5 = strings.TrimSpace(parts[1]) + } + if len(parts) > 2 { + l.Load15 = strings.TrimSpace(parts[2]) + } + return l +} diff --git a/internal/collector/parse_test.go b/internal/collector/parse_test.go new file mode 100644 index 0000000..0faa4fb --- /dev/null +++ b/internal/collector/parse_test.go @@ -0,0 +1,112 @@ +package collector + +import ( + "testing" +) + +func TestParseCPULine(t *testing.T) { + tests := []struct { + name string + line string + wantName string + wantUser int64 + wantTotal int64 + 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}, + {"short", "cpu 1 2 3", "cpu", 1, 6, false}, + {"empty", "", "", 0, 0, true}, + {"one_field", "cpu", "", 0, 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseCPULine(tt.line) + if (err != nil) != tt.wantErr { + t.Errorf("ParseCPULine() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if got.Name != tt.wantName { + t.Errorf("Name = %q, want %q", got.Name, tt.wantName) + } + if got.User != tt.wantUser { + t.Errorf("User = %d, want %d", got.User, tt.wantUser) + } + if total := got.Total(); total != tt.wantTotal { + t.Errorf("Total() = %d, want %d", total, tt.wantTotal) + } + }) + } +} + +func TestParseMemLine(t *testing.T) { + tests := []struct { + line string + wantKey string + wantValue int64 + wantOK bool + }{ + {"MemTotal: 123456 kB", "MemTotal", 123456, true}, + {"MemFree: 99999 kB", "MemFree", 99999, true}, + {"Buffers: 0 kB", "Buffers", 0, true}, + {"not a mem line", "", 0, false}, + {"", "", 0, false}, + } + for _, tt := range tests { + got, ok := ParseMemLine(tt.line) + if ok != tt.wantOK { + t.Errorf("ParseMemLine(%q) ok = %v, want %v", tt.line, ok, tt.wantOK) + continue + } + if !tt.wantOK { + continue + } + if got.Key != tt.wantKey || got.Value != tt.wantValue { + t.Errorf("ParseMemLine(%q) = %+v, want key=%q value=%d", tt.line, got, tt.wantKey, tt.wantValue) + } + } +} + +func TestParseNetLine(t *testing.T) { + tests := []struct { + name string + line string + wantIface string + wantB int64 + wantTb int64 + wantErr bool + }{ + {"simple", "eth0:b=1000;tb=2000;p=10;tp=20;e=0;te=0;d=0;td=0", "eth0", 1000, 2000, false}, + {"with_space", "eth0:b=100;tb=200 p=0;tp=0;e=0;te=0;d=0;td=0", "eth0", 100, 200, false}, + {"no_colon", "eth0 b=1", "", 0, 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseNetLine(tt.line) + if (err != nil) != tt.wantErr { + t.Errorf("ParseNetLine() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if got.Iface != tt.wantIface || got.B != tt.wantB || got.Tb != tt.wantTb { + t.Errorf("got %+v, want Iface=%s B=%d Tb=%d", got, tt.wantIface, tt.wantB, tt.wantTb) + } + }) + } +} + +func TestParseLoadAvg(t *testing.T) { + got := ParseLoadAvg("1.25;0.50;0.20") + if got.Load1 != "1.25" || got.Load5 != "0.50" || got.Load15 != "0.20" { + t.Errorf("ParseLoadAvg = %+v", got) + } + got2 := ParseLoadAvg("1.0") + if got2.Load1 != "1.0" || got2.Load5 != "" || got2.Load15 != "" { + t.Errorf("ParseLoadAvg(1.0) = %+v", got2) + } +} diff --git a/internal/collector/protocol.go b/internal/collector/protocol.go new file mode 100644 index 0000000..26e8a8d --- /dev/null +++ b/internal/collector/protocol.go @@ -0,0 +1,9 @@ +package collector + +// Protocol mode markers (line-based, sent by remote script) +const ( + ModeLoadAvg = "M LOADAVG" + ModeMemStats = "M MEMSTATS" + ModeNetStats = "M NETSTATS" + ModeCPUStats = "M CPUSTATS" +) diff --git a/internal/collector/types.go b/internal/collector/types.go new file mode 100644 index 0000000..1b10979 --- /dev/null +++ b/internal/collector/types.go @@ -0,0 +1,36 @@ +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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7551a10 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,263 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/loadbars/loadbars/internal/constants" +) + +// Config holds all loadbars configuration (file + CLI). +// Defaults match the Perl Shared.pm %C. +type Config struct { + Hosts []string // Each entry is "host" or "host:user" + Title string + BarWidth int + CPUAverage int + Extended bool + HasAgent bool + Height int + MaxWidth int + NetAverage int + NetInt string + NetLink string + ShowCores bool + ShowMem bool + ShowNet bool + SSHOpts string + Cluster string +} + +// Default returns a Config with default values. +func Default() Config { + return Config{ + BarWidth: 20, + CPUAverage: 10, + Extended: false, + HasAgent: false, + Height: 150, + MaxWidth: 1900, + NetAverage: 15, + NetLink: "gbit", + ShowCores: false, + ShowMem: false, + ShowNet: false, + } +} + +// ConfFilePath returns the full path to the config file (~/.loadbarsrc). +func ConfFilePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("home dir: %w", err) + } + return filepath.Join(home, constants.ConfFile), nil +} + +// Load reads config from the config file and merges into c. Unknown keys are ignored. +func (c *Config) Load() error { + path, err := ConfFilePath() + if err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("open config: %w", err) + } + defer f.Close() + return c.parseReader(f) +} + +func (c *Config) parseReader(f *os.File) error { + validKeys := map[string]bool{ + "title": true, "barwidth": true, "cpuaverage": true, "extended": true, + "hasagent": true, "height": true, "maxwidth": true, "netaverage": true, + "netint": true, "netlink": true, "showcores": true, "showmem": true, + "shownet": true, "sshopts": true, "cluster": true, + } + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if idx := strings.Index(line, "#"); idx >= 0 { + line = strings.TrimSpace(line[:idx]) + } + if line == "" { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + if !validKeys[key] { + continue + } + c.set(key, val) + } + return scanner.Err() +} + +func (c *Config) set(key, val string) { + switch key { + case "title": + c.Title = val + case "barwidth": + if n, err := strconv.Atoi(val); err == nil { + c.BarWidth = n + } + case "cpuaverage": + if n, err := strconv.Atoi(val); err == nil { + c.CPUAverage = n + } + case "extended": + c.Extended = parseBool(val) + case "hasagent": + c.HasAgent = parseBool(val) + case "height": + if n, err := strconv.Atoi(val); err == nil { + c.Height = n + } + case "maxwidth": + if n, err := strconv.Atoi(val); err == nil { + c.MaxWidth = n + } + case "netaverage": + if n, err := strconv.Atoi(val); err == nil { + c.NetAverage = n + } + case "netint": + c.NetInt = val + case "netlink": + c.NetLink = val + case "showcores": + c.ShowCores = parseBool(val) + case "showmem": + c.ShowMem = parseBool(val) + case "shownet": + c.ShowNet = parseBool(val) + case "sshopts": + c.SSHOpts = val + case "cluster": + c.Cluster = val + } +} + +func parseBool(s string) bool { + s = strings.TrimSpace(strings.ToLower(s)) + 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) } + writeStr := func(key, v string) { fmt.Fprintf(w, "%s=%s\n", key, v) } + writeBool := func(key string, v bool) { + val := "0" + if v { + val = "1" + } + fmt.Fprintf(w, "%s=%s\n", key, val) + } + writeInt("barwidth", c.BarWidth) + writeInt("cpuaverage", c.CPUAverage) + writeBool("extended", c.Extended) + writeBool("hasagent", c.HasAgent) + writeInt("height", c.Height) + writeInt("maxwidth", c.MaxWidth) + writeInt("netaverage", c.NetAverage) + writeStr("netint", c.NetInt) + writeStr("netlink", c.NetLink) + writeBool("showcores", c.ShowCores) + writeBool("showmem", c.ShowMem) + writeBool("shownet", c.ShowNet) + writeStr("sshopts", c.SSHOpts) + writeStr("cluster", c.Cluster) + 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) + } + if seen == nil { + seen = make(map[string]bool) + } + if seen[cluster] { + return nil, fmt.Errorf("cluster cycle detected: %s", cluster) + } + + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + + var line string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + ln := strings.TrimSpace(scanner.Text()) + if ln == "" || strings.HasPrefix(ln, "#") { + continue + } + fields := strings.Fields(ln) + if len(fields) >= 1 && fields[0] == cluster { + if len(fields) > 1 { + line = strings.Join(fields[1:], " ") + } + break + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + + if line == "" { + return []string{cluster}, nil + } + + seen[cluster] = true + defer delete(seen, cluster) + + var out []string + for _, part := range strings.Fields(line) { + hosts, err := getClusterHostsRec(part, path, depth+1, seen) + if err != nil { + return nil, err + } + out = append(out, hosts...) + } + return out, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..d51feb7 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,123 @@ +package config + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestConfig_parseReader(t *testing.T) { + tests := []struct { + name string + input string + wantBar int + wantExt bool + }{ + {"empty", "", 20, false}, + {"barwidth", "barwidth=42\n", 42, false}, + {"extended_1", "extended=1\n", 20, true}, + {"extended_true", "extended=true\n", 20, true}, + {"comments", "# foo\nbarwidth=10\n# bar\n", 10, false}, + {"unknown_key", "barwidth=5\nunknown=ignored\n", 5, false}, + {"multiple", "barwidth=30\nextended=1\nshowcores=1\n", 30, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Default() + f, _ := os.Open(os.DevNull) + defer f.Close() + // Use a temp file with the content since parseReader takes *os.File + dir := t.TempDir() + path := filepath.Join(dir, "rc") + if err := os.WriteFile(path, []byte(tt.input), 0600); err != nil { + t.Fatal(err) + } + f2, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f2.Close() + if err := c.parseReader(f2); err != nil { + t.Fatal(err) + } + if c.BarWidth != tt.wantBar { + t.Errorf("BarWidth = %d, want %d", c.BarWidth, tt.wantBar) + } + if c.Extended != tt.wantExt { + t.Errorf("Extended = %v, want %v", c.Extended, tt.wantExt) + } + }) + } +} + +func TestConfig_writeTo(t *testing.T) { + c := Default() + c.BarWidth = 25 + c.ShowCores = true + dir := t.TempDir() + path := filepath.Join(dir, "out") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + err = c.writeTo(f) + f.Close() + if err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + if len(data) == 0 { + t.Error("writeTo wrote nothing") + } + if !bytes.Contains(data, []byte("barwidth=25")) { + t.Errorf("expected barwidth=25 in %s", data) + } + if !bytes.Contains(data, []byte("showcores=1")) { + t.Errorf("expected showcores=1 in %s", data) + } +} + +func TestGetClusterHostsFromFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "clusters") + + tests := []struct { + name string + content string + cluster string + wantHosts []string + wantErr bool + }{ + {"single_host", "foo host1\n", "foo", []string{"host1"}, false}, + {"two_hosts", "bar host1 host2\n", "bar", []string{"host1", "host2"}, false}, + {"missing_returns_cluster", "x y\n", "missing", []string{"missing"}, false}, + {"recursive", "a b\nb c\nc d\n", "a", []string{"d"}, false}, + {"cycle", "a b\nb a\n", "a", nil, true}, + {"comment_ignored", "# comment\na h1\n", "a", []string{"h1"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := os.WriteFile(path, []byte(tt.content), 0600); err != nil { + t.Fatal(err) + } + got, err := GetClusterHostsFromFile(tt.cluster, path) + if (err != nil) != tt.wantErr { + t.Errorf("GetClusterHostsFromFile() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if len(got) != len(tt.wantHosts) { + t.Errorf("got %v, want %v", got, tt.wantHosts) + return + } + for i := range got { + if got[i] != tt.wantHosts[i] { + t.Errorf("got[%d] = %s, want %s", i, got[i], tt.wantHosts[i]) + } + } + }) + } +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go new file mode 100644 index 0000000..44a0478 --- /dev/null +++ b/internal/constants/constants.go @@ -0,0 +1,59 @@ +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 +) + +// Exit codes +const ( + Success = 0 + EUnknown = 1 + ENoHost = 2 +) + +// Copyright string +const Copyright = "2010-2026 (c) Paul Buetow <loadbars@dev.buetow.org>" + +// RGB returns 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} + LightBlue0 = RGB{0x00, 0x00, 0xcc} + 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} +) + +// BytesPerSec for link speed reference (bytes per second at given mbit) +var ( + BytesMbit = 125000 + Bytes10Mbit = 1250000 + Bytes100Mbit = 12500000 + BytesGbit = 125000000 + Bytes10Gbit = 1250000000 +) diff --git a/internal/display/display.go b/internal/display/display.go new file mode 100644 index 0000000..5c06d1d --- /dev/null +++ b/internal/display/display.go @@ -0,0 +1,287 @@ +package display + +import ( + "context" + "fmt" + "os" + "sort" + "time" + + "github.com/loadbars/loadbars/internal/collector" + "github.com/loadbars/loadbars/internal/config" + "github.com/loadbars/loadbars/internal/constants" + "github.com/loadbars/loadbars/internal/stats" + "github.com/veandco/go-sdl2/sdl" +) + +// 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 { + return fmt.Errorf("sdl init: %w", err) + } + defer sdl.Quit() + + width := cfg.BarWidth + if width < 1 { + width = 1 + } + if width > cfg.MaxWidth { + width = cfg.MaxWidth + } + height := cfg.Height + if height < 1 { + height = 1 + } + + title := cfg.Title + if title == "" { + title = "Loadbars (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) + redrawBg := true + + // Previous CPU state for delta (key = host;cpuName) + prevCPU := make(map[string]collector.CPULine) + // We need collector.CPULine - use stats.HostStats.CPU which is map[string]collector.CPULine. So we need to import collector for CPULine. + // Actually we have stats.HostStats which has CPU map[string]collector.CPULine. So we need to import collector in display for the type. Let me add the import and a type alias or use the type from collector. So display will import collector for CPULine. + _ = prevCPU + + ticker := time.NewTicker(time.Duration(constants.IntervalSDL * float64(time.Second))) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // 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 + redrawBg = true + case sdl.K_2: + showMem = !showMem + case sdl.K_3: + showNet = !showNet + case sdl.K_e: + extended = !extended + redrawBg = true + case sdl.K_h: + printHotkeys() + 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) + redrawBg = true + case sdl.K_RIGHT: + winW += 100 + if winW > int32(cfg.MaxWidth) { + winW = int32(cfg.MaxWidth) + } + window.SetSize(winW, winH) + redrawBg = true + case sdl.K_UP: + winH -= 100 + if winH < 1 { + winH = 1 + } + window.SetSize(winW, winH) + redrawBg = true + case sdl.K_DOWN: + winH += 100 + window.SetSize(winW, winH) + redrawBg = true + } + case *sdl.WindowEvent: + if ev.Event == sdl.WINDOWEVENT_RESIZED { + winW, winH = ev.Data1, ev.Data2 + redrawBg = true + } + } + } + + snap := src.Snapshot() + numStats := len(snap) + if cfg.ShowMem { + numStats += len(snap) + } + if cfg.ShowNet { + numStats += len(snap) + } + if numStats == 0 { + numStats = 1 + } + + barWidth := (winW / int32(numStats)) - 1 + if barWidth < 1 { + barWidth = 1 + } + + if redrawBg { + renderer.SetDrawColor(0, 0, 0, 255) + renderer.Clear() + redrawBg = false + } + + 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) + cpuNames := sortedCPUNames(h.CPU, showCores) + for _, name := range cpuNames { + drawCPUBar(renderer, h.CPU[name], prevCPU[host+";"+name], barWidth, &x, winH) + prevCPU[host+";"+name] = h.CPU[name] + } + } + + renderer.Present() + sdl.Delay(10) + + <-ticker.C + } +} + +func sortedHosts(snap map[string]*stats.HostStats) []string { + out := make([]string, 0, len(snap)) + for h := range snap { + out = append(out, h) + } + sort.Strings(out) + return out +} + +func sortedCPUNames(cpu map[string]collector.CPULine, showCores bool) []string { + var names []string + for name := range cpu { + if name == "cpu" { + names = append(names, "cpu") + continue + } + if showCores { + 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 +} + +func drawCPUBar(renderer *sdl.Renderer, cur, prev collector.CPULine, barW int32, x *int32, winH int32) { + defer func() { *x += barW + 1 }() + // Compute delta and normalize to % + totalCur := cur.Total() + totalPrev := prev.Total() + if totalPrev == 0 || totalCur <= totalPrev { + return + } + scale := float64(totalCur-totalPrev) / 100.0 + if scale <= 0 { + return + } + userPct := int((cur.User - prev.User) / int64(scale)) + nicePct := int((cur.Nice - prev.Nice) / int64(scale)) + sysPct := int((cur.System - prev.System) / int64(scale)) + idlePct := int((cur.Idle - prev.Idle) / int64(scale)) + iowaitPct := int((cur.Iowait - prev.Iowait) / int64(scale)) + irqPct := int((cur.IRQ - prev.IRQ) / int64(scale)) + softirqPct := int((cur.SoftIRQ - prev.SoftIRQ) / int64(scale)) + guestPct := int((cur.Guest - prev.Guest) / int64(scale)) + stealPct := int((cur.Steal - prev.Steal) / int64(scale)) + + norm := func(v int) int { + if v < 0 { + return 0 + } + if v > 100 { + return 100 + } + return v + } + userPct = norm(userPct) + nicePct = norm(nicePct) + sysPct = norm(sysPct) + idlePct = norm(idlePct) + iowaitPct = norm(iowaitPct) + irqPct = norm(irqPct) + softirqPct = norm(softirqPct) + guestPct = norm(guestPct) + stealPct = norm(stealPct) + + barH := float64(winH) / 100.0 + y := float64(winH) + fill := func(r, g, b uint8, h int) { + hh := int32(float64(h) * barH) + if hh < 1 && h > 0 { + hh = 1 + } + y -= float64(hh) + renderer.SetDrawColor(r, g, b, 255) + rect := sdl.Rect{X: *x, Y: int32(y), W: barW, H: hh} + renderer.FillRect(&rect) + } + // Order bottom to top: system, user, nice, idle, iowait, irq, softirq, guest, steal (match Perl) + fill(constants.Blue.R, constants.Blue.G, constants.Blue.B, sysPct) + fill(constants.Yellow.R, constants.Yellow.G, constants.Yellow.B, userPct) + fill(constants.Green.R, constants.Green.G, constants.Green.B, nicePct) + fill(constants.Black.R, constants.Black.G, constants.Black.B, idlePct) + fill(constants.Purple.R, constants.Purple.G, constants.Purple.B, iowaitPct) + fill(constants.White.R, constants.White.G, constants.White.B, irqPct) + fill(constants.White.R, constants.White.G, constants.White.B, softirqPct) + fill(constants.Red.R, constants.Red.G, constants.Red.B, guestPct) + fill(constants.Red.R, constants.Red.G, constants.Red.B, stealPct) +} + +func printHotkeys() { + fmt.Println("=> Hotkeys: 1=cores 2=mem 3=net e=extended h=help n=next net q=quit w=write config a/y=cpu avg d/c=net avg f/v=link scale arrows=resize") +} diff --git a/internal/stats/stats.go b/internal/stats/stats.go new file mode 100644 index 0000000..b78f2f7 --- /dev/null +++ b/internal/stats/stats.go @@ -0,0 +1,25 @@ +package stats + +import ( + "github.com/loadbars/loadbars/internal/collector" +) + +// NetStamp holds network stats and timestamp for delta calculation. +type NetStamp struct { + B int64 + Tb int64 + Stamp float64 +} + +// 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 +} + +// Source is the interface the display uses to read current stats. +type Source interface { + Snapshot() map[string]*HostStats +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..77c0ee6 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,4 @@ +package version + +// Version is the application version, set at build time or here for development. +const Version = "0.8.0" diff --git a/loadbars b/loadbars deleted file mode 100755 index 33b4ab4..0000000 --- a/loadbars +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/perl - -# loadbars (c) 2010 - 2026, Paul Buetow -# E-Mail: loadbars@dev.buetow.org WWW: http://buetow.org -# For legal informations see COPYING and COPYING.FONT - -use strict; -use warnings; -use v5.14; -use autodie; - -use Getopt::Long; - -my $lib; - -BEGIN { - if ( -d './lib/Loadbars' ) { - $lib = 'lib'; - - } - else { - $lib = '/usr/share/loadbars/lib'; - } -} - -use lib $lib; - -use Loadbars::Main; -use Loadbars::Constants; -use Loadbars::HelpDispatch; -use Loadbars::Shared; -use Loadbars::Utils; - -my ( $hosts, $dispatch ) = Loadbars::HelpDispatch::create; -my $usage; - -say( 'Loadbars ' . get_version . ' ' . Loadbars::Constants->COPYRIGHT ); - -Loadbars::Config::read; - -my @hosts_extra; -push @hosts_extra, shift @ARGV while @ARGV and $ARGV[0] !~ /^-/; - -GetOptions( 'help|?' => \$usage, $dispatch->('options') ); - -if ( defined $usage ) { - say $dispatch->('usage'); - exit Loadbars::Constants->SUCCESS; -} - -Loadbars::Main::cpu_set_showcores_re; - -my @hosts = map { - my ( $a, $b ) = split /\@/, $_; - defined $b ? "$b:$a" : $a; -} split ',', $$hosts; - -push @hosts, @hosts_extra; - -if ( @hosts || defined $Loadbars::Main::C{cluster} ) { - push @hosts, Loadbars::Config::get_cluster_hosts $C{cluster} - if defined $C{cluster}; - - system 'ssh-add' - if $C{hasagent} == 0; -} -else { - Loadbars::Main::say $dispatch->('usage'); - exit Loadbars::Constants->E_NOHOST; -} - -my @threads = Loadbars::Main::threads_create @hosts; -Loadbars::Main::loop $dispatch, @threads; - -exit Loadbars::Constants->SUCCESS; - diff --git a/loadbars-go b/loadbars-go Binary files differnew file mode 100755 index 0000000..ae47fa8 --- /dev/null +++ b/loadbars-go diff --git a/scripts/loadbars-remote.sh b/scripts/loadbars-remote.sh new file mode 100644 index 0000000..9037ad8 --- /dev/null +++ b/scripts/loadbars-remote.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# loadbars-remote.sh - Emits loadbars protocol (M LOADAVG, M MEMSTATS, M NETSTATS, M CPUSTATS) +# for local or remote execution. No Perl required. +# Usage: bash loadbars-remote.sh +# Interval for CPU sampling (seconds) +INTERVAL=0.14 + +while true; do + # Load average: first 3 fields of /proc/loadavg joined by ; + echo "M LOADAVG" + read -r l1 l5 l15 _ < /proc/loadavg 2>/dev/null || true + echo "${l1:-0};${l5:-0};${l15:-0}" + + # Memory: full /proc/meminfo + echo "M MEMSTATS" + cat /proc/meminfo 2>/dev/null || true + + # Network: /proc/net/dev, skip 2 header lines, then "iface: rx... tx..." + echo "M NETSTATS" + while IFS= read -r line; do + line="${line/:/ }" + set -- $line + # $1=iface, $2=rx_bytes $3=rx_packets $4=rx_errs $5=rx_drop ... $10=tx_bytes $11=tx_packets $12=tx_errs $13=tx_drop + if [ -n "$2" ] || [ -n "${10:-}" ]; then + echo "$1:b=${2:-0};tb=${10:-0};p=${3:-0};tp=${11:-0} e=${4:-0};te=${12:-0};d=${5:-0};td=${13:-0}" + fi + done < <(tail -n +3 /proc/net/dev 2>/dev/null) + + # CPU: /proc/stat, 20 times with INTERVAL sleep + echo "M CPUSTATS" + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do + cat /proc/stat 2>/dev/null || true + sleep "$INTERVAL" 2>/dev/null || true + done +done |
