diff options
| -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) |
