feat: machine status page, speed test, live presence, forwards without ufw (0.0.5)

Status page per machine: CPU load, RAM and disk of the client, sampled
by the agent only while the page is open and visible (one reading
serves every open tab), plus a button that has the agent measure its
link to the host: ping, down and up.

Online/offline now comes from the control polls the hub holds open, so
a dropped agent shows offline at once instead of 45 s later, and the
dashboard keeps the status column live.

A forward rule can be created "without ufw": the panel opens nothing in
ufw for it and so revokes nothing when it is deleted. The checkbox is
shown only while the ufw integration is on.

Dashboard row actions are icons; a status action is added.
This commit is contained in:
redtea committed 2026-10-13 18:56:15 +03:00
1 parent 7f4d645f19
commit 86cb0226ab
22 files changed
+920 -34

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
package agent
import (
"bytes"
"errors"
"os"
"strconv"
"time"
)
// Previous /proc/stat reading; the load is the busy share of the ticks since.
var (
cpuPrevIdle, cpuPrevTotal uint64
cpuPrevAt time.Time
)
// cpuStaleAfter is how old the previous reading may be: past it the delta
// would average over a stretch nobody was watching, so take a fresh pair.
const cpuStaleAfter = 15 * time.Second
func cpuPercent() (float64, error) {
if time.Since(cpuPrevAt) > cpuStaleAfter {
idle, total, err := readCPUTicks()
if err != nil {
return 0, err
}
cpuPrevIdle, cpuPrevTotal = idle, total
time.Sleep(200 * time.Millisecond)
}
idle, total, err := readCPUTicks()
if err != nil {
return 0, err
}
dIdle, dTotal := idle-cpuPrevIdle, total-cpuPrevTotal
cpuPrevIdle, cpuPrevTotal, cpuPrevAt = idle, total, time.Now()
if dTotal == 0 || dIdle > dTotal {
return 0, nil
}
return clampPercent(100 * float64(dTotal-dIdle) / float64(dTotal)), nil
}
// readCPUTicks parses the aggregate "cpu" line of /proc/stat:
// user nice system idle iowait irq softirq steal ...
func readCPUTicks() (idle, total uint64, err error) {
var buf [512]byte
f, err := os.Open("/proc/stat")
if err != nil {
return 0, 0, err
}
n, _ := f.Read(buf[:])
f.Close()
line := buf[:n]
if i := bytes.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
fields := bytes.Fields(line)
if len(fields) < 5 || string(fields[0]) != "cpu" {
return 0, 0, errors.New("unexpected /proc/stat")
}
for i, f := range fields[1:] {
if i >= 8 { // guest time is already counted in user/nice
break
}
v, _ := strconv.ParseUint(string(f), 10, 64)
total += v
if i == 3 || i == 4 { // idle, iowait
idle += v
}
}
return idle, total, nil
}
// memUsage reads /proc/meminfo; used is everything not available to new
// processes (so page cache does not count as used).
func memUsage() (total, used uint64, err error) {
data, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0, 0, err
}
var avail, free uint64
var haveAvail bool
for len(data) > 0 {
line := data
if i := bytes.IndexByte(data, '\n'); i >= 0 {
line, data = data[:i], data[i+1:]
} else {
data = nil
}
switch {
case bytes.HasPrefix(line, []byte("MemTotal:")):
total = meminfoKB(line)
case bytes.HasPrefix(line, []byte("MemFree:")):
free = meminfoKB(line)
case bytes.HasPrefix(line, []byte("MemAvailable:")):
avail, haveAvail = meminfoKB(line), true
}
if haveAvail && total > 0 {
break
}
}
if total == 0 {
return 0, 0, errors.New("unexpected /proc/meminfo")
}
if !haveAvail {
avail = free
}
if avail > total {
avail = total
}
return total, total - avail, nil
}
// meminfoKB returns the value of a "Key: 123 kB" line in bytes.
func meminfoKB(line []byte) uint64 {
f := bytes.Fields(line)
if len(f) < 2 {
return 0
}
v, _ := strconv.ParseUint(string(f[1]), 10, 64)
return v * 1024
}