Portability: sysfs-only sensors with thermal-zone fallback, /sys/block disk discovery, fs blacklist, pwrstat/NUT/apcupsd UPS, login shell from passwd, templated unit + install for any user, cross-compile targets
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
3c6f4d4639
commit
7053cce7fe
@@ -12,6 +12,10 @@ web:
|
||||
build: web
|
||||
$(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlab ./cmd/hamlab
|
||||
|
||||
# cross-compile for another Linux box (static binary, no cgo): make build-arm64 / build-amd64
|
||||
build-%: web
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=$* $(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlab-linux-$* ./cmd/hamlab
|
||||
|
||||
# backend only (assumes web/dist already built)
|
||||
go:
|
||||
$(GO) build -ldflags "-X main.version=$(VERSION)" -o bin/hamlab ./cmd/hamlab
|
||||
|
||||
@@ -3,8 +3,26 @@
|
||||
Web-based Linux management console for bare-metal Debian, in the style of the Proxmox
|
||||
web UI. Named after Ham the cat (see also HamTV).
|
||||
|
||||
Status: **planning**. See [`PLAN.md`](PLAN.md) for the MVP scope and roadmap. No code yet.
|
||||
Status: **v0.1** — persistent web terminals and a live Dashboard tab. Scope and roadmap in
|
||||
[`PLAN.md`](PLAN.md).
|
||||
|
||||
- Runs as a systemd service on the host it manages, as user `core`.
|
||||
- Public at `https://hamlab.bigham.dev` (behind Nginx Proxy Manager on `core`).
|
||||
## Runs on any Linux box
|
||||
|
||||
One static Go binary with the UI embedded; no cgo, no Node at runtime, no agents. The
|
||||
Dashboard reads `/proc` and `/sys` directly (CPU, memory, hwmon or thermal-zone temps,
|
||||
fans, NICs, block devices, mounts) so it works the same on x86 and ARM, bare metal or VM,
|
||||
and just leaves a section empty when the hardware has nothing to report. UPS status is
|
||||
optional and picked up from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is
|
||||
installed.
|
||||
|
||||
```sh
|
||||
make build # web + Go, or: make build-arm64 for a cross-compile
|
||||
./deploy/install.sh # installs the binary + a systemd unit for the invoking user
|
||||
hamlab passwd # set the login password (config in ~/apps/hamlab/)
|
||||
sudo systemctl start hamlab # listens on 127.0.0.1:8100 by default; set "listen" in config.json
|
||||
```
|
||||
|
||||
Terminals run as the user the service runs as, with that user's login shell.
|
||||
|
||||
- On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager.
|
||||
- Installable as a PWA.
|
||||
|
||||
+23
-1
@@ -12,6 +12,7 @@ import (
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -116,7 +117,7 @@ func serve(cfg *config.Config) error {
|
||||
}
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/bash"
|
||||
shell = loginShell(u.Username)
|
||||
}
|
||||
mgr := hterm.NewManager(shell, u.HomeDir)
|
||||
mgr.OnChange = func(list []hterm.Info) {
|
||||
@@ -162,3 +163,24 @@ func serve(cfg *config.Config) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginShell returns the user's shell from /etc/passwd, falling back to bash if
|
||||
// present and /bin/sh otherwise. Systemd units don't export $SHELL.
|
||||
func loginShell(username string) string {
|
||||
if b, err := os.ReadFile("/etc/passwd"); err == nil {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
f := strings.Split(line, ":")
|
||||
if len(f) >= 7 && f[0] == username && f[6] != "" {
|
||||
if _, err := os.Stat(f[6]); err == nil {
|
||||
return f[6]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, sh := range []string{"/bin/bash", "/usr/bin/bash", "/bin/zsh", "/bin/sh"} {
|
||||
if _, err := os.Stat(sh); err == nil {
|
||||
return sh
|
||||
}
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=core
|
||||
Group=core
|
||||
WorkingDirectory=/home/core
|
||||
Environment=HAMLAB_CONFIG=/home/core/apps/hamlab/config.json
|
||||
ExecStart=/usr/local/bin/hamlab serve
|
||||
User=@USER@
|
||||
Group=@GROUP@
|
||||
WorkingDirectory=@HOME@
|
||||
Environment=HAMLAB_CONFIG=@HOME@/apps/hamlab/config.json
|
||||
ExecStart=@PREFIX@/hamlab serve
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillMode=control-group
|
||||
|
||||
+26
-5
@@ -1,11 +1,32 @@
|
||||
#!/bin/sh
|
||||
# Install the built binary and unit on this host, then (re)start the service.
|
||||
# Install the built binary and a systemd unit on this host, then (re)start the service.
|
||||
# Runs as the user who should own the terminals (their login shell, their sudo rights);
|
||||
# needs sudo only for the copy into /usr/local/bin and the unit install.
|
||||
#
|
||||
# HAMLAB_USER user the daemon runs as (default: the invoking user)
|
||||
# PREFIX binary directory (default: /usr/local/bin)
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
sudo install -m 0755 bin/hamlab /usr/local/bin/hamlab
|
||||
sudo install -m 0644 deploy/hamlab.service /etc/systemd/system/hamlab.service
|
||||
|
||||
USER_NAME="${HAMLAB_USER:-${SUDO_USER:-$(id -un)}}"
|
||||
GROUP_NAME="$(id -gn "$USER_NAME")"
|
||||
HOME_DIR="$(getent passwd "$USER_NAME" | cut -d: -f6)"
|
||||
PREFIX="${PREFIX:-/usr/local/bin}"
|
||||
[ -n "$HOME_DIR" ] || { echo "no home dir for $USER_NAME" >&2; exit 1; }
|
||||
|
||||
unit=$(mktemp)
|
||||
sed -e "s|@USER@|$USER_NAME|g" -e "s|@GROUP@|$GROUP_NAME|g" -e "s|@HOME@|$HOME_DIR|g" -e "s|@PREFIX@|$PREFIX|g" \
|
||||
deploy/hamlab.service > "$unit"
|
||||
|
||||
sudo install -m 0755 bin/hamlab "$PREFIX/hamlab"
|
||||
sudo install -m 0644 "$unit" /etc/systemd/system/hamlab.service
|
||||
rm -f "$unit"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now hamlab.service >/dev/null
|
||||
sudo systemctl enable hamlab.service >/dev/null
|
||||
if [ ! -f "$HOME_DIR/apps/hamlab/config.json" ]; then
|
||||
echo "No config yet. Set a password, then start: $PREFIX/hamlab passwd && sudo systemctl start hamlab" >&2
|
||||
exit 0
|
||||
fi
|
||||
sudo systemctl restart hamlab.service
|
||||
sleep 1
|
||||
systemctl is-active hamlab.service && /usr/local/bin/hamlab version
|
||||
systemctl is-active hamlab.service && "$PREFIX/hamlab" version
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -41,8 +42,11 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
host, _ := os.Hostname()
|
||||
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, ui: ui, version: version,
|
||||
started: time.Now(), host: host, user: os.Getenv("USER")}
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
s.user = u.Username
|
||||
}
|
||||
if s.user == "" {
|
||||
s.user = "core"
|
||||
s.user = "?"
|
||||
}
|
||||
m := http.NewServeMux()
|
||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
||||
|
||||
+326
-78
@@ -1,7 +1,12 @@
|
||||
// Package metrics samples host telemetry (CPU, memory, temps, fans, network, disks,
|
||||
// filesystems, UPS) on a timer into an in-memory ring, for the Dashboard tab.
|
||||
// Everything is read from /proc and /sys directly; the only external command is
|
||||
// pwrstat (CyberPower PowerPanel), which is optional.
|
||||
//
|
||||
// Portability rules: everything comes from /proc and /sys, which every Linux kernel
|
||||
// exposes the same way regardless of distro or architecture; nothing here needs
|
||||
// lm-sensors, smartmontools or root. Sections whose source is absent (no fans, no
|
||||
// UPS daemon, no hwmon at all on a VM) simply come back empty rather than failing.
|
||||
// The only external commands are optional UPS daemons: pwrstat (CyberPower), upsc
|
||||
// (NUT) and apcaccess (apcupsd), tried in that order.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
@@ -24,7 +29,7 @@ const (
|
||||
History = 300 // samples kept (10 min at 2 s)
|
||||
fsEvery = 15 // sample fs usage every N ticks
|
||||
upsEvery = 5 // sample UPS every N ticks
|
||||
sectorSize = 512
|
||||
sectorSize = 512 // /proc/diskstats always counts 512-byte sectors
|
||||
)
|
||||
|
||||
type Mem struct {
|
||||
@@ -59,6 +64,7 @@ type Net struct {
|
||||
type Disk struct {
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Size uint64 `json:"size"`
|
||||
ReadBps float64 `json:"read_bps"`
|
||||
WriteBps float64 `json:"write_bps"`
|
||||
UtilPct float64 `json:"util_pct"`
|
||||
@@ -73,9 +79,11 @@ type FS struct {
|
||||
}
|
||||
|
||||
type UPS struct {
|
||||
Driver string `json:"driver"` // pwrstat | nut | apcupsd
|
||||
Model string `json:"model"`
|
||||
State string `json:"state"`
|
||||
Source string `json:"source"`
|
||||
OnBattery bool `json:"on_battery"`
|
||||
InV float64 `json:"in_v"`
|
||||
OutV float64 `json:"out_v"`
|
||||
BatteryPct float64 `json:"battery_pct"`
|
||||
@@ -122,12 +130,19 @@ type Collector struct {
|
||||
prevT time.Time
|
||||
lastFS []FS
|
||||
lastUPS *UPS
|
||||
upsOK bool
|
||||
upsProbe func() *UPS // nil when no UPS daemon is installed
|
||||
}
|
||||
|
||||
func NewCollector() *Collector {
|
||||
c := &Collector{prevNet: map[string][2]uint64{}, prevDisk: map[string][3]uint64{}}
|
||||
c.upsOK = lookPath("pwrstat") && lookPath("sudo")
|
||||
switch {
|
||||
case lookPath("pwrstat"):
|
||||
c.upsProbe = upsPwrstat
|
||||
case lookPath("upsc"):
|
||||
c.upsProbe = upsNUT
|
||||
case lookPath("apcaccess"):
|
||||
c.upsProbe = upsAPC
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -207,15 +222,18 @@ func (c *Collector) sample() {
|
||||
s.Mem, s.Swap = meminfo()
|
||||
s.Uptime = uptime()
|
||||
s.Temps, s.Fans = hwmon()
|
||||
if len(s.Temps) == 0 {
|
||||
s.Temps = thermalZones() // ARM SoCs and older kernels often expose only these
|
||||
}
|
||||
s.Net = c.net(dt)
|
||||
s.Disks = c.disks(dt, s.Temps)
|
||||
s.Disks = c.disks(dt)
|
||||
|
||||
if c.tick%fsEvery == 0 || c.lastFS == nil {
|
||||
c.lastFS = filesystems()
|
||||
}
|
||||
s.FS = c.lastFS
|
||||
if c.upsOK && (c.tick%upsEvery == 0 || c.lastUPS == nil) {
|
||||
c.lastUPS = ups()
|
||||
if c.upsProbe != nil && (c.tick%upsEvery == 0 || c.lastUPS == nil) {
|
||||
c.lastUPS = c.upsProbe()
|
||||
}
|
||||
s.UPS = c.lastUPS
|
||||
c.tick++
|
||||
@@ -307,6 +325,9 @@ func meminfo() (Mem, Mem) {
|
||||
m[k] = n * 1024
|
||||
}
|
||||
mem := Mem{Total: m["MemTotal"], Avail: m["MemAvailable"]}
|
||||
if mem.Avail == 0 { // pre-3.14 kernels: approximate
|
||||
mem.Avail = m["MemFree"] + m["Buffers"] + m["Cached"]
|
||||
}
|
||||
mem.Used = mem.Total - mem.Avail
|
||||
swap := Mem{Total: m["SwapTotal"], Avail: m["SwapFree"]}
|
||||
swap.Used = swap.Total - swap.Avail
|
||||
@@ -325,45 +346,57 @@ func uptime() float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- temperatures and fans ----
|
||||
|
||||
var sensorRe = regexp.MustCompile(`^(temp|fan)(\d+)_input$`)
|
||||
|
||||
// tempLimit reads a *_max / *_crit file; values outside 0-200 °C are unset sentinels
|
||||
// (NVMe reports ~65000 for absent thresholds).
|
||||
func tempLimit(p string) float64 {
|
||||
if v, ok := readInt(p); ok && v > 0 && v < 200000 {
|
||||
return float64(v) / 1000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// hwmonDir reads every temp*/fan* sensor under one hwmon directory.
|
||||
func hwmonDir(d string) (chip string, temps []Temp, fans []Fan) {
|
||||
chip = strings.TrimSpace(readStr(filepath.Join(d, "name")))
|
||||
ents, err := os.ReadDir(d)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range ents {
|
||||
m := sensorRe.FindStringSubmatch(e.Name())
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
base := m[1] + m[2]
|
||||
raw, ok := readInt(filepath.Join(d, e.Name()))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
label := strings.TrimSpace(readStr(filepath.Join(d, base+"_label")))
|
||||
if label == "" {
|
||||
label = base
|
||||
}
|
||||
if m[1] == "temp" {
|
||||
temps = append(temps, Temp{Chip: chip, Label: label, C: float64(raw) / 1000,
|
||||
Max: tempLimit(filepath.Join(d, base+"_max")), Crit: tempLimit(filepath.Join(d, base+"_crit"))})
|
||||
} else if raw > 0 || fileExists(filepath.Join(d, base+"_enable")) {
|
||||
fans = append(fans, Fan{Chip: chip, Label: label, RPM: int(raw)})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func hwmon() ([]Temp, []Fan) {
|
||||
temps, fans := []Temp{}, []Fan{}
|
||||
dirs, _ := filepath.Glob("/sys/class/hwmon/hwmon*")
|
||||
for _, d := range dirs {
|
||||
chip := strings.TrimSpace(readStr(filepath.Join(d, "name")))
|
||||
ents, err := os.ReadDir(d)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range ents {
|
||||
m := sensorRe.FindStringSubmatch(e.Name())
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
base := m[1] + m[2]
|
||||
raw, ok := readInt(filepath.Join(d, e.Name()))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
label := strings.TrimSpace(readStr(filepath.Join(d, base+"_label")))
|
||||
if label == "" {
|
||||
label = base
|
||||
}
|
||||
if m[1] == "temp" {
|
||||
t := Temp{Chip: chip, Label: label, C: float64(raw) / 1000}
|
||||
// Limits outside 0-200 °C are unset sentinels (NVMe reports ~65000 for absent ones).
|
||||
if v, ok := readInt(filepath.Join(d, base+"_max")); ok && v > 0 && v < 200000 {
|
||||
t.Max = float64(v) / 1000
|
||||
}
|
||||
if v, ok := readInt(filepath.Join(d, base+"_crit")); ok && v > 0 && v < 200000 {
|
||||
t.Crit = float64(v) / 1000
|
||||
}
|
||||
temps = append(temps, t)
|
||||
} else {
|
||||
fans = append(fans, Fan{Chip: chip, Label: label, RPM: int(raw)})
|
||||
}
|
||||
}
|
||||
_, t, f := hwmonDir(d)
|
||||
temps = append(temps, t...)
|
||||
fans = append(fans, f...)
|
||||
}
|
||||
sort.SliceStable(temps, func(i, j int) bool {
|
||||
if temps[i].Chip != temps[j].Chip {
|
||||
@@ -374,9 +407,67 @@ func hwmon() ([]Temp, []Fan) {
|
||||
return temps, fans
|
||||
}
|
||||
|
||||
func thermalZones() []Temp {
|
||||
out := []Temp{}
|
||||
dirs, _ := filepath.Glob("/sys/class/thermal/thermal_zone*")
|
||||
for _, d := range dirs {
|
||||
raw, ok := readInt(filepath.Join(d, "temp"))
|
||||
if !ok || raw <= 0 {
|
||||
continue
|
||||
}
|
||||
typ := strings.TrimSpace(readStr(filepath.Join(d, "type")))
|
||||
if typ == "" {
|
||||
typ = filepath.Base(d)
|
||||
}
|
||||
t := Temp{Chip: "thermal", Label: typ, C: float64(raw) / 1000}
|
||||
// trip points: trip_point_N_type in {critical, hot, passive, active}
|
||||
trips, _ := filepath.Glob(filepath.Join(d, "trip_point_*_type"))
|
||||
for _, tp := range trips {
|
||||
kind := strings.TrimSpace(readStr(tp))
|
||||
v := tempLimit(strings.TrimSuffix(tp, "_type") + "_temp")
|
||||
switch kind {
|
||||
case "critical":
|
||||
t.Crit = v
|
||||
case "hot", "passive":
|
||||
if t.Max == 0 || v < t.Max {
|
||||
t.Max = v
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// blockTemp finds the drive's own temperature via the hwmon node the kernel attaches
|
||||
// to the device: the nvme driver and drivetemp (SATA/SAS) both do this.
|
||||
func blockTemp(name string) float64 {
|
||||
for _, pat := range []string{"/sys/block/" + name + "/device/hwmon*", "/sys/block/" + name + "/device/hwmon/hwmon*"} {
|
||||
dirs, _ := filepath.Glob(pat)
|
||||
for _, d := range dirs {
|
||||
if v, ok := readInt(filepath.Join(d, "temp1_input")); ok {
|
||||
return float64(v) / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- network ----
|
||||
|
||||
// Virtual interfaces that only ever carry traffic already counted on a real one.
|
||||
var skipIfacePrefix = []string{"veth", "br-", "docker", "virbr", "vnet", "tap", "lxc", "lxd", "cni", "flannel", "cali", "kube", "podman", "dummy"}
|
||||
|
||||
func skipIface(n string) bool {
|
||||
return n == "lo" || strings.HasPrefix(n, "veth") || strings.HasPrefix(n, "br-") ||
|
||||
strings.HasPrefix(n, "docker") || strings.HasPrefix(n, "virbr")
|
||||
if n == "lo" {
|
||||
return true
|
||||
}
|
||||
for _, p := range skipIfacePrefix {
|
||||
if strings.HasPrefix(n, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Collector) net(dt float64) []Net {
|
||||
@@ -416,10 +507,44 @@ func (c *Collector) net(dt float64) []Net {
|
||||
return out
|
||||
}
|
||||
|
||||
var wholeDisk = regexp.MustCompile(`^(sd[a-z]+|vd[a-z]+|nvme\d+n\d+|mmcblk\d+)$`)
|
||||
// ---- disks ----
|
||||
|
||||
func (c *Collector) disks(dt float64, temps []Temp) []Disk {
|
||||
// Pseudo block devices with nothing physical behind them.
|
||||
var skipDiskPrefix = []string{"loop", "ram", "zram", "nbd", "sr", "fd", "dm-", "md", "drbd", "zd", "pmem"}
|
||||
|
||||
func skipDisk(n string) bool {
|
||||
for _, p := range skipDiskPrefix {
|
||||
if strings.HasPrefix(n, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func diskModel(name string) string {
|
||||
dev := "/sys/block/" + name + "/device/"
|
||||
for _, f := range []string{"model", "name", "vendor"} { // SCSI/NVMe: model; MMC/SD: name
|
||||
if s := strings.TrimSpace(readStr(dev + f)); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Collector) disks(dt float64) []Disk {
|
||||
out := []Disk{}
|
||||
// /sys/block lists whole devices only (partitions live underneath), so this is
|
||||
// the authoritative "is a disk" test across sd/nvme/mmcblk/vd/xvd/hd/…
|
||||
ents, err := os.ReadDir("/sys/block")
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
whole := map[string]bool{}
|
||||
for _, e := range ents {
|
||||
if !skipDisk(e.Name()) {
|
||||
whole[e.Name()] = true
|
||||
}
|
||||
}
|
||||
f, err := os.Open("/proc/diskstats")
|
||||
if err != nil {
|
||||
return out
|
||||
@@ -428,14 +553,20 @@ func (c *Collector) disks(dt float64, temps []Temp) []Disk {
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fs := strings.Fields(sc.Text())
|
||||
if len(fs) < 14 || !wholeDisk.MatchString(fs[2]) {
|
||||
if len(fs) < 14 || !whole[fs[2]] {
|
||||
continue
|
||||
}
|
||||
name := fs[2]
|
||||
rd, _ := strconv.ParseUint(fs[5], 10, 64)
|
||||
wr, _ := strconv.ParseUint(fs[9], 10, 64)
|
||||
ticks, _ := strconv.ParseUint(fs[12], 10, 64)
|
||||
d := Disk{Name: name, Model: strings.TrimSpace(readStr("/sys/block/" + name + "/device/model"))}
|
||||
d := Disk{Name: name, Model: diskModel(name), Temp: blockTemp(name)}
|
||||
if sz, ok := readInt("/sys/block/" + name + "/size"); ok {
|
||||
d.Size = uint64(sz) * sectorSize
|
||||
}
|
||||
if d.Size == 0 {
|
||||
continue // empty card reader / detached media
|
||||
}
|
||||
if p, ok := c.prevDisk[name]; ok && dt > 0 {
|
||||
d.ReadBps = round1(float64(rd-p[0]) * sectorSize / dt)
|
||||
d.WriteBps = round1(float64(wr-p[1]) * sectorSize / dt)
|
||||
@@ -445,22 +576,35 @@ func (c *Collector) disks(dt float64, temps []Temp) []Disk {
|
||||
}
|
||||
}
|
||||
c.prevDisk[name] = [3]uint64{rd, wr, ticks}
|
||||
// NVMe exposes a "Composite" temp on an hwmon chip named "nvme"; attach it when there is exactly one such disk.
|
||||
if strings.HasPrefix(name, "nvme") {
|
||||
for _, t := range temps {
|
||||
if t.Chip == "nvme" && t.Label == "Composite" {
|
||||
d.Temp = t.C
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
var realFS = map[string]bool{"ext4": true, "ext3": true, "ext2": true, "xfs": true, "btrfs": true, "zfs": true,
|
||||
"f2fs": true, "vfat": true, "exfat": true, "ntfs": true, "ntfs3": true, "cifs": true, "nfs": true, "nfs4": true, "fuse.sshfs": true}
|
||||
// ---- filesystems ----
|
||||
|
||||
// Kernel/virtual filesystems that aren't storage. Anything else that's mounted is
|
||||
// shown, so new real filesystems (bcachefs, exotic FUSE) work without a code change.
|
||||
var pseudoFS = map[string]bool{
|
||||
"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true, "ramfs": true,
|
||||
"cgroup": true, "cgroup2": true, "pstore": true, "bpf": true, "securityfs": true, "debugfs": true,
|
||||
"tracefs": true, "configfs": true, "fusectl": true, "hugetlbfs": true, "mqueue": true, "efivarfs": true,
|
||||
"binfmt_misc": true, "autofs": true, "rpc_pipefs": true, "nsfs": true, "overlay": true, "squashfs": true,
|
||||
"fuse.portal": true, "fuse.gvfsd-fuse": true, "fuse.lxcfs": true, "selinuxfs": true, "none": true, "zfs_snap": true,
|
||||
}
|
||||
|
||||
// Mount prefixes that are container/snap/runtime internals, not the host's storage.
|
||||
var skipMountPrefix = []string{"/var/lib/docker/", "/var/lib/containers/", "/var/lib/kubelet/", "/run/", "/snap/", "/proc/", "/sys/", "/dev/"}
|
||||
|
||||
func skipMount(m string) bool {
|
||||
for _, p := range skipMountPrefix {
|
||||
if strings.HasPrefix(m, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filesystems() []FS {
|
||||
out := []FS{}
|
||||
@@ -471,11 +615,14 @@ func filesystems() []FS {
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
fs := strings.Fields(line)
|
||||
if len(fs) < 3 || !realFS[fs[2]] || seen[fs[1]] {
|
||||
if len(fs) < 3 || pseudoFS[fs[2]] {
|
||||
continue
|
||||
}
|
||||
seen[fs[1]] = true
|
||||
mount := strings.ReplaceAll(fs[1], `\040`, " ")
|
||||
if seen[mount] || skipMount(mount) {
|
||||
continue
|
||||
}
|
||||
seen[mount] = true
|
||||
// statfs on a dead network mount can block; bound it.
|
||||
done := make(chan FS, 1)
|
||||
go func() {
|
||||
@@ -499,25 +646,41 @@ func filesystems() []FS {
|
||||
return out
|
||||
}
|
||||
|
||||
func ups() *UPS {
|
||||
// ---- UPS ----
|
||||
|
||||
func runOut(args ...string) (string, bool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
b, err := exec.CommandContext(ctx, "sudo", "-n", "pwrstat", "-status").Output()
|
||||
b, err := exec.CommandContext(ctx, args[0], args[1:]...).Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
return "", false
|
||||
}
|
||||
u := &UPS{}
|
||||
got := false
|
||||
num := func(s string) float64 {
|
||||
s = strings.TrimSpace(s)
|
||||
end := 0
|
||||
for end < len(s) && (s[end] >= '0' && s[end] <= '9' || s[end] == '.') {
|
||||
end++
|
||||
return string(b), true
|
||||
}
|
||||
|
||||
// leadingNum parses the number at the start of "119 V", "47 min.", "170 Watt(17 %)".
|
||||
func leadingNum(s string) float64 {
|
||||
s = strings.TrimSpace(s)
|
||||
end := 0
|
||||
for end < len(s) && (s[end] >= '0' && s[end] <= '9' || s[end] == '.') {
|
||||
end++
|
||||
}
|
||||
v, _ := strconv.ParseFloat(s[:end], 64)
|
||||
return v
|
||||
}
|
||||
|
||||
// upsPwrstat: CyberPower PowerPanel. pwrstat only talks to its daemon as root, so fall
|
||||
// back to passwordless sudo when plain execution is refused.
|
||||
func upsPwrstat() *UPS {
|
||||
out, ok := runOut("pwrstat", "-status")
|
||||
if !ok {
|
||||
if out, ok = runOut("sudo", "-n", "pwrstat", "-status"); !ok {
|
||||
return nil
|
||||
}
|
||||
v, _ := strconv.ParseFloat(s[:end], 64)
|
||||
return v
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
u := &UPS{Driver: "pwrstat"}
|
||||
got := false
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
k, v, ok := strings.Cut(line, "....")
|
||||
if !ok {
|
||||
continue
|
||||
@@ -532,18 +695,19 @@ func ups() *UPS {
|
||||
u.State = v
|
||||
case "Power Supply by":
|
||||
u.Source = v
|
||||
u.OnBattery = !strings.Contains(v, "Utility")
|
||||
case "Utility Voltage":
|
||||
u.InV = num(v)
|
||||
u.InV = leadingNum(v)
|
||||
case "Output Voltage":
|
||||
u.OutV = num(v)
|
||||
u.OutV = leadingNum(v)
|
||||
case "Battery Capacity":
|
||||
u.BatteryPct = num(v)
|
||||
u.BatteryPct = leadingNum(v)
|
||||
case "Remaining Runtime":
|
||||
u.RuntimeMin = num(v)
|
||||
u.RuntimeMin = leadingNum(v)
|
||||
case "Load":
|
||||
u.LoadW = num(v)
|
||||
u.LoadW = leadingNum(v)
|
||||
if i := strings.Index(v, "("); i >= 0 {
|
||||
u.LoadPct = num(v[i+1:])
|
||||
u.LoadPct = leadingNum(v[i+1:])
|
||||
}
|
||||
case "Last Power Event":
|
||||
u.LastEvent = v
|
||||
@@ -555,6 +719,88 @@ func ups() *UPS {
|
||||
return u
|
||||
}
|
||||
|
||||
// upsNUT: Network UPS Tools. `upsc -l` names the UPSes; `upsc <name>` dumps key: value.
|
||||
func upsNUT() *UPS {
|
||||
list, ok := runOut("upsc", "-l")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
name := ""
|
||||
for _, l := range strings.Split(list, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
name = l
|
||||
break
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
out, ok := runOut("upsc", name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
kv := map[string]string{}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if k, v, ok := strings.Cut(l, ":"); ok {
|
||||
kv[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if len(kv) == 0 {
|
||||
return nil
|
||||
}
|
||||
u := &UPS{Driver: "nut", Model: strings.TrimSpace(kv["ups.mfr"] + " " + kv["ups.model"])}
|
||||
status := kv["ups.status"]
|
||||
u.OnBattery = strings.Contains(status, "OB")
|
||||
u.State = status
|
||||
u.Source = "Utility Power"
|
||||
if u.OnBattery {
|
||||
u.Source = "Battery"
|
||||
}
|
||||
u.InV = leadingNum(kv["input.voltage"])
|
||||
u.OutV = leadingNum(kv["output.voltage"])
|
||||
u.BatteryPct = leadingNum(kv["battery.charge"])
|
||||
u.RuntimeMin = leadingNum(kv["battery.runtime"]) / 60
|
||||
u.LoadPct = leadingNum(kv["ups.load"])
|
||||
u.LoadW = leadingNum(kv["ups.realpower"])
|
||||
if u.LoadW == 0 {
|
||||
u.LoadW = round1(leadingNum(kv["ups.realpower.nominal"]) * u.LoadPct / 100)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// upsAPC: apcupsd. `apcaccess` prints "KEY : value" lines.
|
||||
func upsAPC() *UPS {
|
||||
out, ok := runOut("apcaccess", "status")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
kv := map[string]string{}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if k, v, ok := strings.Cut(l, ":"); ok {
|
||||
kv[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if kv["STATUS"] == "" {
|
||||
return nil
|
||||
}
|
||||
u := &UPS{Driver: "apcupsd", Model: kv["MODEL"], State: kv["STATUS"]}
|
||||
u.OnBattery = strings.Contains(kv["STATUS"], "ONBATT")
|
||||
u.Source = "Utility Power"
|
||||
if u.OnBattery {
|
||||
u.Source = "Battery"
|
||||
}
|
||||
u.InV = leadingNum(kv["LINEV"])
|
||||
u.OutV = leadingNum(kv["OUTPUTV"])
|
||||
u.BatteryPct = leadingNum(kv["BCHARGE"])
|
||||
u.RuntimeMin = leadingNum(kv["TIMELEFT"])
|
||||
u.LoadPct = leadingNum(kv["LOADPCT"])
|
||||
u.LoadW = round1(leadingNum(kv["NOMPOWER"]) * u.LoadPct / 100)
|
||||
if kv["LASTXFER"] != "" {
|
||||
u.LastEvent = kv["LASTXFER"]
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// ---- small helpers ----
|
||||
|
||||
func readStr(p string) string {
|
||||
@@ -574,6 +820,8 @@ func readInt(p string) (int64, bool) {
|
||||
return v, err == nil
|
||||
}
|
||||
|
||||
func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
|
||||
|
||||
func round1(v float64) float64 { return float64(int64(v*10+0.5)) / 10 }
|
||||
|
||||
func lookPath(bin string) bool { _, err := exec.LookPath(bin); return err == nil }
|
||||
|
||||
+20
-7
@@ -1,5 +1,5 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { api, Sample, Series } from './api'
|
||||
import { api, Sample, Series, Temp } from './api'
|
||||
import Sparkline from './Sparkline'
|
||||
|
||||
const MAX = 300
|
||||
@@ -53,6 +53,19 @@ function Legend({ items }: { items: { name: string; color: string }[] }) {
|
||||
return <div className="legend">{items.map((i) => <span key={i.name}><span className="sw" style={{ background: i.color }} />{i.name}</span>)}</div>
|
||||
}
|
||||
|
||||
// Chips with many sensors (per-core temps on a big CPU) collapse to one row showing the hottest.
|
||||
function collapseTemps(temps: Temp[]): Temp[] {
|
||||
const byChip = new Map<string, Temp[]>()
|
||||
for (const t of temps) (byChip.get(t.chip) ?? byChip.set(t.chip, []).get(t.chip)!).push(t)
|
||||
const out: Temp[] = []
|
||||
for (const [chip, list] of byChip) {
|
||||
if (list.length <= 8) { out.push(...list); continue }
|
||||
const hot = list.reduce((a, b) => (b.c > a.c ? b : a))
|
||||
out.push({ ...hot, label: `${list.length} sensors · hottest ${hot.label}`, chip })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [latest, setLatest] = useState<Sample | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
@@ -107,7 +120,7 @@ export default function Dashboard() {
|
||||
<div className="dash">
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="kpis">
|
||||
<Tile label="CPU" value={pct(s.cpu)} sub={s.cores.length ? `cores ${s.cores.map((c) => Math.round(c)).join(' · ')}` : undefined}>
|
||||
<Tile label="CPU" value={pct(s.cpu)} sub={s.cores.length === 0 ? undefined : s.cores.length <= 16 ? `cores ${s.cores.map((c) => Math.round(c)).join(' · ')}` : `${s.cores.length} cores · busiest ${pct(Math.max(...s.cores))}`}>
|
||||
{h && <Sparkline series={[{ name: 'cpu', values: h.cpu, color: S1 }]} max={100} format={pct} height={36} />}
|
||||
</Tile>
|
||||
<Tile label="Memory" value={fmtBytes(s.mem.used)} sub={`of ${fmtBytes(s.mem.total)} · ${pct(memFrac * 100)}${s.swap.total ? ` · swap ${fmtBytes(s.swap.used)}` : ''}`}>
|
||||
@@ -120,7 +133,7 @@ export default function Dashboard() {
|
||||
<div className="cards">
|
||||
<Card title="Temperatures">
|
||||
{s.temps.length === 0 && <div className="empty">No temperature sensors reported by this host.</div>}
|
||||
{s.temps.map((t) => {
|
||||
{collapseTemps(s.temps).map((t) => {
|
||||
const limit = t.crit || t.max || 100
|
||||
const cls = t.c >= limit - 5 ? 'bad' : t.max && t.c >= t.max - 15 ? 'warn' : ''
|
||||
return (
|
||||
@@ -171,7 +184,7 @@ export default function Dashboard() {
|
||||
<span>{d.name}</span>
|
||||
<span className="model">{d.model}</span>
|
||||
<span className="spacer" />
|
||||
<span className="num">{d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy</span>
|
||||
<span className="num">{fmtBytes(d.size)} · {d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy</span>
|
||||
</div>
|
||||
<div className="sub-head num">read {fmtRate(d.read_bps)}<span className="spacer" />write {fmtRate(d.write_bps)}</div>
|
||||
{h?.disk[d.name] && (
|
||||
@@ -193,14 +206,14 @@ export default function Dashboard() {
|
||||
</Card>
|
||||
|
||||
<Card title="UPS">
|
||||
{!s.ups && <div className="empty">No UPS data (pwrstat not available on this host).</div>}
|
||||
{!s.ups && <div className="empty">No UPS daemon found (looks for pwrstat, NUT upsc, or apcaccess).</div>}
|
||||
{s.ups && (
|
||||
<>
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (s.ups.source === 'Utility Power' ? 'ok' : 'bad')} />
|
||||
<span className={'dot ' + (s.ups.on_battery ? 'bad' : 'ok')} />
|
||||
<span>{s.ups.state} · {s.ups.source}</span>
|
||||
<span className="spacer" />
|
||||
<span className="model">{s.ups.model}</span>
|
||||
<span className="model">{s.ups.model} · {s.ups.driver}</span>
|
||||
</div>
|
||||
<div className="row"><span className="k">Battery</span><Meter frac={s.ups.battery_pct / 100} cls={s.ups.battery_pct < 20 ? 'bad' : s.ups.battery_pct < 50 ? 'warn' : ''} /><span className="num">{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min</span></div>
|
||||
<div className="row"><span className="k">Load</span><Meter frac={s.ups.load_pct / 100} /><span className="num">{s.ups.load_w} W · {pct(s.ups.load_pct)}</span></div>
|
||||
|
||||
+2
-2
@@ -23,9 +23,9 @@ export interface Term { id: string; name: string; running: boolean; exit_code: n
|
||||
export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number }
|
||||
export interface Fan { chip: string; label: string; rpm: number }
|
||||
export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number }
|
||||
export interface Disk { name: string; model?: string; read_bps: number; write_bps: number; util_pct: number; temp?: number }
|
||||
export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
|
||||
export interface FS { mount: string; type: string; size: number; used: number }
|
||||
export interface UPS { model: string; state: string; source: string; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
|
||||
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
|
||||
export interface Sample {
|
||||
t: number; cpu: number; cores: number[]; load: [number, number, number]
|
||||
mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number }
|
||||
|
||||
Reference in New Issue
Block a user