fix for overview panel
This commit is contained in:
parent
21d8e91756
commit
d16f34070d
21 changed files with 920 additions and 40 deletions
79
internal/hoststats/cpu_unix.go
Normal file
79
internal/hoststats/cpu_unix.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
//go:build !windows
|
||||
|
||||
package hoststats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cpuSampler computes CPU busy% from successive cumulative /proc/stat
|
||||
// snapshots. The kernel's counters are monotonic totals since boot, so a
|
||||
// single read can't give a percentage -- only the delta between two reads
|
||||
// separated by real time can. sampleOnce (its only caller) already runs on
|
||||
// a fixed poll interval, so that interval doubles as the sampling window;
|
||||
// no internal sleep needed.
|
||||
type cpuSampler struct {
|
||||
prevTotal uint64
|
||||
prevIdle uint64
|
||||
have bool
|
||||
}
|
||||
|
||||
func (c *cpuSampler) sample() float64 {
|
||||
total, idle, err := readProcStatCPU()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if !c.have {
|
||||
c.prevTotal, c.prevIdle = total, idle
|
||||
c.have = true
|
||||
return 0
|
||||
}
|
||||
deltaTotal := total - c.prevTotal
|
||||
deltaIdle := idle - c.prevIdle
|
||||
c.prevTotal, c.prevIdle = total, idle
|
||||
if deltaTotal == 0 {
|
||||
return 0
|
||||
}
|
||||
pct := (1 - float64(deltaIdle)/float64(deltaTotal)) * 100
|
||||
switch {
|
||||
case pct < 0:
|
||||
pct = 0
|
||||
case pct > 100:
|
||||
pct = 100
|
||||
}
|
||||
return pct
|
||||
}
|
||||
|
||||
// readProcStatCPU parses the aggregate "cpu " line: user nice system idle
|
||||
// iowait irq softirq steal guest guest_nice (guest/guest_nice already
|
||||
// double-counted inside user/nice on Linux, per `man proc`, so they're not
|
||||
// added again here). idle = idle + iowait, matching the convention `top`
|
||||
// and most load calculators use.
|
||||
func readProcStatCPU() (total, idle uint64, err error) {
|
||||
data, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
line, _, _ := strings.Cut(string(data), "\n")
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 || fields[0] != "cpu" {
|
||||
return 0, 0, fmt.Errorf("hoststats: unexpected /proc/stat format")
|
||||
}
|
||||
values := make([]uint64, 0, len(fields)-1)
|
||||
for _, f := range fields[1:] {
|
||||
v, err := strconv.ParseUint(f, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
values = append(values, v)
|
||||
total += v
|
||||
}
|
||||
idle = values[3]
|
||||
if len(values) > 4 {
|
||||
idle += values[4] // iowait
|
||||
}
|
||||
return total, idle, nil
|
||||
}
|
||||
65
internal/hoststats/cpu_windows.go
Normal file
65
internal/hoststats/cpu_windows.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//go:build windows
|
||||
|
||||
package hoststats
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// GetSystemTimes isn't wrapped by x/sys/windows either -- same manual
|
||||
// kernel32.dll binding as GlobalMemoryStatusEx in mem_windows.go.
|
||||
var procGetSystemTimes = modkernel32.NewProc("GetSystemTimes")
|
||||
|
||||
// cpuSampler computes CPU busy% from successive cumulative GetSystemTimes
|
||||
// snapshots, mirroring cpu_unix.go's /proc/stat delta approach: the kernel
|
||||
// time value already includes idle time on Windows, so busy = (kernel -
|
||||
// idle) + user, and total = kernel + user.
|
||||
type cpuSampler struct {
|
||||
prevIdle, prevKernel, prevUser uint64
|
||||
have bool
|
||||
}
|
||||
|
||||
func (c *cpuSampler) sample() float64 {
|
||||
idle, kernel, user, ok := getSystemTimes()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if !c.have {
|
||||
c.prevIdle, c.prevKernel, c.prevUser = idle, kernel, user
|
||||
c.have = true
|
||||
return 0
|
||||
}
|
||||
deltaIdle := idle - c.prevIdle
|
||||
deltaTotal := (kernel - c.prevKernel) + (user - c.prevUser)
|
||||
c.prevIdle, c.prevKernel, c.prevUser = idle, kernel, user
|
||||
if deltaTotal == 0 {
|
||||
return 0
|
||||
}
|
||||
pct := (1 - float64(deltaIdle)/float64(deltaTotal)) * 100
|
||||
switch {
|
||||
case pct < 0:
|
||||
pct = 0
|
||||
case pct > 100:
|
||||
pct = 100
|
||||
}
|
||||
return pct
|
||||
}
|
||||
|
||||
func getSystemTimes() (idle, kernel, user uint64, ok bool) {
|
||||
var idleFT, kernelFT, userFT windows.Filetime
|
||||
r, _, _ := procGetSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idleFT)),
|
||||
uintptr(unsafe.Pointer(&kernelFT)),
|
||||
uintptr(unsafe.Pointer(&userFT)),
|
||||
)
|
||||
if r == 0 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return filetimeToUint64(idleFT), filetimeToUint64(kernelFT), filetimeToUint64(userFT), true
|
||||
}
|
||||
|
||||
func filetimeToUint64(ft windows.Filetime) uint64 {
|
||||
return uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime)
|
||||
}
|
||||
19
internal/hoststats/disk_unix.go
Normal file
19
internal/hoststats/disk_unix.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
//go:build !windows
|
||||
|
||||
package hoststats
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// diskFreeBytes returns free (available to an unprivileged caller, not
|
||||
// counting reserved blocks) and total bytes for the filesystem containing
|
||||
// path. Mirrors internal/app/files/diskspace_unix.go's localDiskFreeBytes --
|
||||
// duplicated locally rather than exported cross-package since this is a
|
||||
// three-line syscall wrapper, not shared logic worth coupling two packages
|
||||
// over.
|
||||
func diskFreeBytes(path string) (free, total int64, err error) {
|
||||
var st unix.Statfs_t
|
||||
if err := unix.Statfs(path, &st); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return int64(st.Bavail) * int64(st.Bsize), int64(st.Blocks) * int64(st.Bsize), nil
|
||||
}
|
||||
20
internal/hoststats/disk_windows.go
Normal file
20
internal/hoststats/disk_windows.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//go:build windows
|
||||
|
||||
package hoststats
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
// diskFreeBytes returns free (available to the calling user) and total
|
||||
// bytes for the volume containing path. Mirrors
|
||||
// internal/app/files/diskspace_windows.go's localDiskFreeBytes.
|
||||
func diskFreeBytes(path string) (free, total int64, err error) {
|
||||
ptr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var freeAvail, totalBytes, totalFree uint64
|
||||
if err := windows.GetDiskFreeSpaceEx(ptr, &freeAvail, &totalBytes, &totalFree); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return int64(freeAvail), int64(totalBytes), nil
|
||||
}
|
||||
88
internal/hoststats/hoststats.go
Normal file
88
internal/hoststats/hoststats.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// Package hoststats samples host-level CPU/RAM/disk usage for the admin
|
||||
// panel's dashboard. It intentionally reports the machine's own resources,
|
||||
// not the Go process's (runtime.MemStats already covers that elsewhere) --
|
||||
// on a single self-hosted box the two are the same box, but the metric an
|
||||
// operator wants here is "is this server about to fall over."
|
||||
package hoststats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Snapshot is the last successfully sampled host-resource reading. Ready is
|
||||
// false until the first sample completes, so callers can distinguish "0% CPU"
|
||||
// from "no data yet" instead of rendering a misleading zero on startup.
|
||||
type Snapshot struct {
|
||||
CPUPercent float64
|
||||
MemUsedBytes int64
|
||||
MemTotalBytes int64
|
||||
DiskFreeBytes int64
|
||||
DiskTotalBytes int64
|
||||
Ready bool
|
||||
}
|
||||
|
||||
// Poller periodically samples host stats and caches the last snapshot for
|
||||
// lock-cheap reads from HTTP handlers -- the same "background worker
|
||||
// refreshes, handler reads a cached value" split this codebase already uses
|
||||
// for the local blob-storage free-space guard.
|
||||
type Poller struct {
|
||||
diskPath string
|
||||
|
||||
mu sync.RWMutex
|
||||
snap Snapshot
|
||||
|
||||
cpu cpuSampler
|
||||
}
|
||||
|
||||
// NewPoller creates a poller that reports free/total disk space for the
|
||||
// filesystem containing diskPath (pass the server's data/blob directory, or
|
||||
// "." if it doesn't matter which volume).
|
||||
func NewPoller(diskPath string) *Poller {
|
||||
if diskPath == "" {
|
||||
diskPath = "."
|
||||
}
|
||||
return &Poller{diskPath: diskPath}
|
||||
}
|
||||
|
||||
// Snapshot returns the last sample. Safe to call concurrently with Run.
|
||||
func (p *Poller) Snapshot() Snapshot {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.snap
|
||||
}
|
||||
|
||||
// Run samples immediately, then on every tick of interval, until ctx is
|
||||
// canceled. CPU usage is a delta between successive samples, so the first
|
||||
// sample after startup reports 0% -- expected, not a bug, and Ready still
|
||||
// flips true for the memory/disk figures that don't need a delta.
|
||||
func (p *Poller) Run(ctx context.Context, interval time.Duration) {
|
||||
p.sampleOnce()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.sampleOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Poller) sampleOnce() {
|
||||
var snap Snapshot
|
||||
snap.CPUPercent = p.cpu.sample()
|
||||
if used, total, err := memStats(); err == nil {
|
||||
snap.MemUsedBytes, snap.MemTotalBytes = used, total
|
||||
}
|
||||
if free, total, err := diskFreeBytes(p.diskPath); err == nil {
|
||||
snap.DiskFreeBytes, snap.DiskTotalBytes = free, total
|
||||
}
|
||||
snap.Ready = true
|
||||
|
||||
p.mu.Lock()
|
||||
p.snap = snap
|
||||
p.mu.Unlock()
|
||||
}
|
||||
61
internal/hoststats/mem_unix.go
Normal file
61
internal/hoststats/mem_unix.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
//go:build !windows
|
||||
|
||||
package hoststats
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// memStats reads host RAM usage from /proc/meminfo. used is derived from
|
||||
// MemAvailable (kernel's own "usable without swapping" estimate, accounts
|
||||
// for reclaimable cache/buffers) rather than MemTotal-MemFree, which would
|
||||
// count page cache as "used" and make a healthy box look starved.
|
||||
func memStats() (used, total int64, err error) {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var totalKB, availKB int64
|
||||
haveTotal, haveAvail := false, false
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(line, "MemTotal:"):
|
||||
totalKB, err = parseMeminfoKB(line)
|
||||
haveTotal = err == nil
|
||||
case strings.HasPrefix(line, "MemAvailable:"):
|
||||
availKB, err = parseMeminfoKB(line)
|
||||
haveAvail = err == nil
|
||||
}
|
||||
if haveTotal && haveAvail {
|
||||
break
|
||||
}
|
||||
}
|
||||
if scanErr := scanner.Err(); scanErr != nil {
|
||||
return 0, 0, scanErr
|
||||
}
|
||||
if !haveTotal || !haveAvail {
|
||||
return 0, 0, fmt.Errorf("hoststats: MemTotal/MemAvailable not found in /proc/meminfo")
|
||||
}
|
||||
total = totalKB * 1024
|
||||
used = total - availKB*1024
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return used, total, nil
|
||||
}
|
||||
|
||||
func parseMeminfoKB(line string) (int64, error) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
return 0, fmt.Errorf("hoststats: malformed /proc/meminfo line %q", line)
|
||||
}
|
||||
return strconv.ParseInt(fields[1], 10, 64)
|
||||
}
|
||||
50
internal/hoststats/mem_windows.go
Normal file
50
internal/hoststats/mem_windows.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//go:build windows
|
||||
|
||||
package hoststats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// x/sys/windows doesn't wrap GlobalMemoryStatusEx (unlike GetDiskFreeSpaceEx,
|
||||
// which diskFreeBytes uses directly), so it's called through kernel32.dll by
|
||||
// hand -- the same LazyDLL/NewProc pattern the x/sys/windows package itself
|
||||
// uses internally for the calls it does wrap.
|
||||
var (
|
||||
modkernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGlobalMemoryStatusEx = modkernel32.NewProc("GlobalMemoryStatusEx")
|
||||
)
|
||||
|
||||
// memoryStatusEx mirrors the Win32 MEMORYSTATUSEX struct. Field order and
|
||||
// sizes must match exactly -- this is passed by pointer straight to the
|
||||
// syscall.
|
||||
type memoryStatusEx struct {
|
||||
cbSize uint32
|
||||
dwMemoryLoad uint32
|
||||
ullTotalPhys uint64
|
||||
ullAvailPhys uint64
|
||||
ullTotalPageFile uint64
|
||||
ullAvailPageFile uint64
|
||||
ullTotalVirtual uint64
|
||||
ullAvailVirtual uint64
|
||||
ullAvailExtendedVirtual uint64
|
||||
}
|
||||
|
||||
// memStats reads host RAM usage via GlobalMemoryStatusEx.
|
||||
func memStats() (used, total int64, err error) {
|
||||
var m memoryStatusEx
|
||||
m.cbSize = uint32(unsafe.Sizeof(m))
|
||||
r, _, callErr := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&m)))
|
||||
if r == 0 {
|
||||
return 0, 0, fmt.Errorf("hoststats: GlobalMemoryStatusEx: %w", callErr)
|
||||
}
|
||||
total = int64(m.ullTotalPhys)
|
||||
used = total - int64(m.ullAvailPhys)
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return used, total, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue