diff --git a/Makefile b/Makefile index 6dd8843..47be63d 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 403518b..ea743ec 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index 56837a2..ea20a4e 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -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" +} diff --git a/deploy/hamlab.service b/deploy/hamlab.service index 55fd8fa..103d89f 100644 --- a/deploy/hamlab.service +++ b/deploy/hamlab.service @@ -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 diff --git a/deploy/install.sh b/deploy/install.sh index 12e2578..639d150 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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 diff --git a/internal/api/server.go b/internal/api/server.go index 7e1c084..18b1944 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) }) diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 7847481..a317c98 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -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 ` 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 } diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx index 51e3285..fce43ff 100644 --- a/web/src/Dashboard.tsx +++ b/web/src/Dashboard.tsx @@ -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
{items.map((i) => {i.name})}
} +// 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() + 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(null) const [err, setErr] = useState('') @@ -107,7 +120,7 @@ export default function Dashboard() {
{err &&
{err}
}
- Math.round(c)).join(' · ')}` : undefined}> + Math.round(c)).join(' · ')}` : `${s.cores.length} cores · busiest ${pct(Math.max(...s.cores))}`}> {h && } @@ -120,7 +133,7 @@ export default function Dashboard() {
{s.temps.length === 0 &&
No temperature sensors reported by this host.
} - {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() { {d.name} {d.model} - {d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy + {fmtBytes(d.size)} · {d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy
read {fmtRate(d.read_bps)}write {fmtRate(d.write_bps)}
{h?.disk[d.name] && ( @@ -193,14 +206,14 @@ export default function Dashboard() { - {!s.ups &&
No UPS data (pwrstat not available on this host).
} + {!s.ups &&
No UPS daemon found (looks for pwrstat, NUT upsc, or apcaccess).
} {s.ups && ( <>
- + {s.ups.state} · {s.ups.source} - {s.ups.model} + {s.ups.model} · {s.ups.driver}
Battery{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min
Load{s.ups.load_w} W · {pct(s.ups.load_pct)}
diff --git a/web/src/api.ts b/web/src/api.ts index 87c3226..16ea605 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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 }