Files
worker/internal/webapp/metrics.go
Gleb Tv 2c7a0236da feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
2026-07-13 17:55:14 +03:00

447 строки
11 KiB
Go

package webapp
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// Metrics owns the host-level status sample used by the overview and
// server-status pages. Phase 1 reads only /proc and statfs over
// mount points. CLI tools (lsblk, smartctl, sensors) are Phase 5.
type Metrics struct {
mu sync.RWMutex
last Snapshot
lastAt time.Time
stopCh chan struct{}
stopWG sync.WaitGroup
started bool
}
// Snapshot is the JSON-friendly view of host metrics the templates
// render. The fields are picked so the status table on /status and
// the cards on /overview share a single type.
type Snapshot struct {
CPU CPUSample `json:"cpu"`
Memory MemorySample `json:"memory"`
Load LoadSample `json:"load"`
Uptime time.Duration `json:"uptime"`
BootAt time.Time `json:"boot_at"`
Networks []NetDev `json:"networks"`
Disks []DiskSample `json:"disks"`
}
// CPUSample reports aggregate CPU usage since the last sample. The
// fields are percentages normalised to 0..100.
type CPUSample struct {
UserPct float64 `json:"user_pct"`
NicePct float64 `json:"nice_pct"`
SystemPct float64 `json:"system_pct"`
IdlePct float64 `json:"idle_pct"`
IOWaitPct float64 `json:"iowait_pct"`
StealPct float64 `json:"steal_pct"`
TotalPct float64 `json:"total_pct"`
}
// MemorySample reports bytes of physical RAM, swap, and various
// accounting fields from /proc/meminfo.
type MemorySample struct {
Total uint64 `json:"total_bytes"`
Available uint64 `json:"available_bytes"`
Free uint64 `json:"free_bytes"`
Buffers uint64 `json:"buffers_bytes"`
Cached uint64 `json:"cached_bytes"`
SwapTotal uint64 `json:"swap_total_bytes"`
SwapFree uint64 `json:"swap_free_bytes"`
UsedPct float64 `json:"used_pct"`
AvailablePct float64 `json:"available_pct"`
}
// LoadSample is the 1/5/15 minute load averages from /proc/loadavg.
type LoadSample struct {
One float64 `json:"load1"`
Five float64 `json:"load5"`
Fifteen float64 `json:"load15"`
}
// NetDev is one row from /proc/net/dev.
type NetDev struct {
Name string `json:"name"`
RxBytes uint64 `json:"rx_bytes"`
TxBytes uint64 `json:"tx_bytes"`
RxPkt uint64 `json:"rx_packets"`
TxPkt uint64 `json:"tx_packets"`
RxErr uint64 `json:"rx_errors"`
TxErr uint64 `json:"tx_errors"`
RxDrop uint64 `json:"rx_dropped"`
TxDrop uint64 `json:"tx_dropped"`
}
// DiskSample is a single mount point from /proc/mounts with disk
// usage from statfs(2).
type DiskSample struct {
Mount string `json:"mount"`
Device string `json:"device"`
FSType string `json:"fstype"`
Total uint64 `json:"total_bytes"`
Free uint64 `json:"free_bytes"`
Used uint64 `json:"used_bytes"`
UsedPct float64 `json:"used_pct"`
}
// NewMetrics constructs an empty Metrics sampler. The loop is not
// started until Start is called.
func NewMetrics() *Metrics {
return &Metrics{
stopCh: make(chan struct{}),
}
}
// Start launches a sample loop. The first sample is taken
// immediately so /status never renders "no data yet".
func (m *Metrics) Start(ctx context.Context) {
m.mu.Lock()
if m.started {
m.mu.Unlock()
return
}
m.started = true
m.mu.Unlock()
m.sample(ctx)
m.stopWG.Add(1)
go m.loop(ctx)
}
// Stop cancels the sample loop and waits for it to exit.
func (m *Metrics) Stop() {
m.mu.Lock()
if !m.started {
m.mu.Unlock()
return
}
select {
case <-m.stopCh:
default:
close(m.stopCh)
}
m.mu.Unlock()
m.stopWG.Wait()
}
// Last returns the most recent snapshot and the time it was taken.
// Always safe to call; returns a zero-value snapshot if the first
// sample has not yet completed.
func (m *Metrics) Last() (Snapshot, time.Time) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.last, m.lastAt
}
func (m *Metrics) loop(ctx context.Context) {
defer m.stopWG.Done()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-m.stopCh:
return
case <-ticker.C:
m.sample(ctx)
}
}
}
func (m *Metrics) sample(_ context.Context) {
snap, err := CollectSnapshot(ProcRoot())
if err != nil {
return // best-effort
}
m.mu.Lock()
m.last = snap
m.lastAt = time.Now().UTC()
m.mu.Unlock()
}
// CollectSnapshot reads the /proc mount once and returns a Snapshot.
// Exposed at package scope so tests can drive it directly with a
// fixture /proc tree.
func CollectSnapshot(root string) (Snapshot, error) {
now := time.Now().UTC()
cpu, err := readProcStat(filepath.Join(root, "stat"))
if err != nil {
return Snapshot{}, fmt.Errorf("read stat: %w", err)
}
mem, err := readMemInfo(filepath.Join(root, "meminfo"))
if err != nil {
return Snapshot{}, fmt.Errorf("read meminfo: %w", err)
}
load, err := readLoadAvg(filepath.Join(root, "loadavg"))
if err != nil {
return Snapshot{}, fmt.Errorf("read loadavg: %w", err)
}
uptime, err := readUptime(filepath.Join(root, "uptime"))
if err != nil {
return Snapshot{}, fmt.Errorf("read uptime: %w", err)
}
net, err := readNetDev(filepath.Join(root, "net", "dev"))
if err != nil {
return Snapshot{}, fmt.Errorf("read net/dev: %w", err)
}
disks, err := readMounts(filepath.Join(root, "mounts"))
if err != nil {
return Snapshot{}, fmt.Errorf("read mounts: %w", err)
}
for i := range disks {
if err := statDisk(disks[i].Mount, &disks[i]); err != nil {
// statfs may fail for some pseudo mounts (proc, sys);
// we leave Total/Free/Used at zero in that case so the
// page renders an empty row rather than a hard error.
continue
}
}
bootAt := now.Add(-uptime)
return Snapshot{
CPU: cpu,
Memory: mem,
Load: load,
Uptime: uptime,
BootAt: bootAt,
Networks: net,
Disks: disks,
}, nil
}
// readProcStat reads the aggregate "cpu " row of /proc/stat and
// returns percentages. /proc/stat is cumulative since boot, so a
// single read yields busy/total ratios only if we remember the
// previous delta. Phase 1 does not keep history; the per-CPU
// "busy since boot" snapshot is rendered on the page as a static
// "load since boot" indicator instead of a live % value.
//
// The function takes a "previous" sample for delta math; if prev
// is the zero value, the function returns zero percentages.
func readProcStat(path string) (CPUSample, error) {
f, err := os.Open(path)
if err != nil {
return CPUSample{}, err
}
defer f.Close() //nolint:errcheck
var (
user, nice, system, idle, iowait, steal uint64
agg bool
)
scanner := bufioNewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "cpu ") {
continue
}
fields := strings.Fields(line)
if len(fields) < 8 {
return CPUSample{}, fmt.Errorf("short cpu line: %q", line)
}
agg = true
user, _ = strconv.ParseUint(fields[1], 10, 64)
nice, _ = strconv.ParseUint(fields[2], 10, 64)
system, _ = strconv.ParseUint(fields[3], 10, 64)
idle, _ = strconv.ParseUint(fields[4], 10, 64)
iowait, _ = strconv.ParseUint(fields[5], 10, 64)
steal, _ = strconv.ParseUint(fields[7], 10, 64)
break
}
if err := scanner.Err(); err != nil {
return CPUSample{}, err
}
if !agg {
return CPUSample{}, fmt.Errorf("no aggregate cpu line in %s", path)
}
total := user + nice + system + idle + iowait + steal
if total == 0 {
return CPUSample{}, nil
}
return CPUSample{
UserPct: pct(user, total),
NicePct: pct(nice, total),
SystemPct: pct(system, total),
IdlePct: pct(idle, total),
IOWaitPct: pct(iowait, total),
StealPct: pct(steal, total),
TotalPct: pct(total-(idle+iowait), total),
}, nil
}
func pct(part, total uint64) float64 {
if total == 0 {
return 0
}
return float64(part) * 100 / float64(total)
}
// readMemInfo parses /proc/meminfo. Units are kB; we convert to
// bytes on the way out so the page never has to multiply.
func readMemInfo(path string) (MemorySample, error) {
f, err := os.Open(path)
if err != nil {
return MemorySample{}, err
}
defer f.Close() //nolint:errcheck
values := map[string]uint64{}
scanner := bufioNewScanner(f)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
key := strings.TrimSuffix(fields[0], ":")
v, err := strconv.ParseUint(fields[1], 10, 64)
if err != nil {
continue
}
values[key] = v * 1024
}
if err := scanner.Err(); err != nil {
return MemorySample{}, err
}
mem := MemorySample{
Total: values["MemTotal"],
Available: values["MemAvailable"],
Free: values["MemFree"],
Buffers: values["Buffers"],
Cached: values["Cached"],
SwapTotal: values["SwapTotal"],
SwapFree: values["SwapFree"],
}
if mem.Total > 0 {
used := mem.Total - mem.Available
mem.UsedPct = pct(used, mem.Total)
mem.AvailablePct = pct(mem.Available, mem.Total)
}
return mem, nil
}
func readLoadAvg(path string) (LoadSample, error) {
data, err := os.ReadFile(path)
if err != nil {
return LoadSample{}, err
}
fields := strings.Fields(string(data))
if len(fields) < 3 {
return LoadSample{}, fmt.Errorf("short loadavg line: %q", data)
}
one, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return LoadSample{}, err
}
five, err := strconv.ParseFloat(fields[1], 64)
if err != nil {
return LoadSample{}, err
}
fifteen, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
return LoadSample{}, err
}
return LoadSample{One: one, Five: five, Fifteen: fifteen}, nil
}
func readUptime(path string) (time.Duration, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
fields := strings.Fields(string(data))
if len(fields) < 1 {
return 0, fmt.Errorf("short uptime line: %q", data)
}
secs, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0, err
}
return time.Duration(secs * float64(time.Second)), nil
}
func readNetDev(path string) ([]NetDev, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close() //nolint:errcheck
var out []NetDev
scanner := bufioNewScanner(f)
first := true
for scanner.Scan() {
line := scanner.Text()
if first {
first = false
if strings.Contains(line, "Inter-|") {
continue
}
}
fields := strings.Fields(line)
if len(fields) < 17 {
continue
}
name := strings.TrimSuffix(fields[0], ":")
out = append(out, NetDev{
Name: name,
RxBytes: parseU64(fields[1]),
TxBytes: parseU64(fields[9]),
RxPkt: parseU64(fields[2]),
TxPkt: parseU64(fields[10]),
RxErr: parseU64(fields[3]),
TxErr: parseU64(fields[11]),
RxDrop: parseU64(fields[4]),
TxDrop: parseU64(fields[12]),
})
}
return out, scanner.Err()
}
func parseU64(s string) uint64 {
v, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0
}
return v
}
func readMounts(path string) ([]DiskSample, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var out []DiskSample
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
// device mountpoint fstype options dump pass
out = append(out, DiskSample{
Device: fields[0],
Mount: fields[1],
FSType: fields[2],
})
}
return out, nil
}
// statDisk is implemented in metrics_linux.go (real statfs) and
// metrics_other.go (no-op stub for non-Linux dev builds). The
// function is called from CollectSnapshot below to fill disk usage
// data; tests that exercise the metric paths with fake /proc trees
// accept the zero-value stats as expected.