Three-tab Bubble Tea TUI (Setup / Manage / Monitor) for setting up and running a headless Jellyfin server over SSH: - Setup: install/uninstall Jellyfin, add media drives (single or btrfs RAID pool), import an existing detached btrfs pool non-destructively, firewall, move Jellyfin storage onto a media drive - Boot-drive-safe drive classifier (walks LUKS/LVM/RAID to the physical disk; never offers a disk hosting /, /boot or swap) - Keep-existing-filesystem path previews on-disk content, skips starter folders when content exists, and grants Jellyfin recursive read access - Manage: copy from external drives, mount/eject, rename/delete - Monitor: per-core CPU, load/mem/uptime, btrfs error counters, SMART Single static binary; installs to /usr/local/bin so sudo can find it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
139 lines
3.1 KiB
Go
139 lines
3.1 KiB
Go
// CPU sampling for the Monitor tab. /proc/stat reports cumulative ticks
|
|
// per CPU since boot; usage % is the delta of (total - idle) over the
|
|
// delta of total between two snapshots.
|
|
//
|
|
// The Monitor tab samples once per second, holds the previous reading on
|
|
// the model, and renders the delta — first sample after startup shows 0
|
|
// because there's nothing to subtract against yet.
|
|
package health
|
|
|
|
import (
|
|
"bufio"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// ProcStatLine is one row of /proc/stat (either the "cpu" aggregate or a
|
|
// single "cpuN" core). All values are cumulative kernel ticks since boot.
|
|
type ProcStatLine struct {
|
|
Index int // -1 for the aggregate line
|
|
User uint64
|
|
Nice uint64
|
|
System uint64
|
|
Idle uint64
|
|
IOWait uint64
|
|
IRQ uint64
|
|
SoftIRQ uint64
|
|
Steal uint64
|
|
Total uint64 // sum of all of the above
|
|
IdleAll uint64 // Idle + IOWait
|
|
}
|
|
|
|
// CPUUsage is the per-tick result: an aggregate % and per-core %s in
|
|
// CPU-index order. Empty when we don't yet have a previous sample.
|
|
type CPUUsage struct {
|
|
Aggregate float64
|
|
Cores []float64
|
|
}
|
|
|
|
// ReadProcStat parses /proc/stat. Stops at the first non-cpu line for
|
|
// speed (we don't need intr/ctxt/btime/etc).
|
|
func ReadProcStat() ([]ProcStatLine, error) {
|
|
f, err := os.Open("/proc/stat")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
var out []ProcStatLine
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
if !strings.HasPrefix(line, "cpu") {
|
|
break
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
idx := -1
|
|
if fields[0] != "cpu" {
|
|
if n, err := strconv.Atoi(strings.TrimPrefix(fields[0], "cpu")); err == nil {
|
|
idx = n
|
|
}
|
|
}
|
|
var ps ProcStatLine
|
|
ps.Index = idx
|
|
vals := fields[1:]
|
|
readU64 := func(i int) uint64 {
|
|
if i >= len(vals) {
|
|
return 0
|
|
}
|
|
v, _ := strconv.ParseUint(vals[i], 10, 64)
|
|
return v
|
|
}
|
|
ps.User = readU64(0)
|
|
ps.Nice = readU64(1)
|
|
ps.System = readU64(2)
|
|
ps.Idle = readU64(3)
|
|
ps.IOWait = readU64(4)
|
|
ps.IRQ = readU64(5)
|
|
ps.SoftIRQ = readU64(6)
|
|
ps.Steal = readU64(7)
|
|
for _, v := range vals {
|
|
n, _ := strconv.ParseUint(v, 10, 64)
|
|
ps.Total += n
|
|
}
|
|
ps.IdleAll = ps.Idle + ps.IOWait
|
|
out = append(out, ps)
|
|
}
|
|
return out, sc.Err()
|
|
}
|
|
|
|
// DeltaCPU computes usage between two /proc/stat snapshots. Returns an
|
|
// empty CPUUsage if `prev` is empty (first sample after startup).
|
|
func DeltaCPU(prev, now []ProcStatLine) CPUUsage {
|
|
if len(prev) == 0 {
|
|
return CPUUsage{}
|
|
}
|
|
prevByIdx := map[int]ProcStatLine{}
|
|
for _, p := range prev {
|
|
prevByIdx[p.Index] = p
|
|
}
|
|
var u CPUUsage
|
|
for _, n := range now {
|
|
p, ok := prevByIdx[n.Index]
|
|
if !ok {
|
|
continue
|
|
}
|
|
pct := cpuPct(p, n)
|
|
if n.Index == -1 {
|
|
u.Aggregate = pct
|
|
} else {
|
|
u.Cores = append(u.Cores, pct)
|
|
}
|
|
}
|
|
return u
|
|
}
|
|
|
|
func cpuPct(prev, now ProcStatLine) float64 {
|
|
if now.Total <= prev.Total {
|
|
return 0
|
|
}
|
|
dTotal := now.Total - prev.Total
|
|
var dIdle uint64
|
|
if now.IdleAll > prev.IdleAll {
|
|
dIdle = now.IdleAll - prev.IdleAll
|
|
}
|
|
if dTotal == 0 {
|
|
return 0
|
|
}
|
|
pct := float64(dTotal-dIdle) / float64(dTotal) * 100
|
|
if pct < 0 {
|
|
return 0
|
|
}
|
|
if pct > 100 {
|
|
return 100
|
|
}
|
|
return pct
|
|
}
|