summaryrefslogtreecommitdiff
path: root/internal/collector
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/collector
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/collector')
-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
5 files changed, 388 insertions, 0 deletions
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
+}