summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-13 22:52:46 +0200
committerPaul Buetow <paul@buetow.org>2026-02-13 22:52:46 +0200
commitcd5a3614baab756a41d764b79308afeea93f12dd (patch)
treeefc8c31e8b162ca2121ba92c841322119e6d3b04 /internal
parentbf7c6ade292a6444877797c8d699d147aceb57cc (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.go35
-rw-r--r--internal/app/script.go29
-rw-r--r--internal/app/store.go103
-rw-r--r--internal/collector/collector.go129
-rw-r--r--internal/collector/parse.go102
-rw-r--r--internal/collector/parse_test.go112
-rw-r--r--internal/collector/protocol.go9
-rw-r--r--internal/collector/types.go36
-rw-r--r--internal/config/config.go263
-rw-r--r--internal/config/config_test.go123
-rw-r--r--internal/constants/constants.go59
-rw-r--r--internal/display/display.go287
-rw-r--r--internal/stats/stats.go25
-rw-r--r--internal/version/version.go4
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"