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 /internal | |
| 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>
Diffstat (limited to 'internal')
| -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 |
14 files changed, 1316 insertions, 0 deletions
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" |
