fix for overview panel

This commit is contained in:
onysd 2026-08-07 04:26:56 +03:00
parent 21d8e91756
commit d16f34070d
21 changed files with 920 additions and 40 deletions

View 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
}

View 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)
}

View 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
}

View 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
}

View 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()
}

View 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)
}

View 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
}