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/collector | |
| 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/collector')
| -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 |
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 +} |
