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.