TUISTREAM/internal/tui/view_monitor.go
28allday 959e507a83 Initial commit: TUISTREAM — headless Jellyfin TUI for Omarchy/Arch
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>
2026-05-31 16:47:18 +01:00

569 lines
16 KiB
Go

// Monitor tab — a refreshing dashboard of the host's health.
//
// Drives, btrfs pools, SMART, CPU + memory, Jellyfin service. Updates
// every 5 seconds via tea.Tick. Re-uses the inventory we already track
// for the other tabs to pick which mountpoints/disks to probe.
package tui
import (
"fmt"
"path/filepath"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tuistream/internal/drives"
"tuistream/internal/health"
"tuistream/internal/jellyfin"
)
// monitorModel holds the latest health snapshot plus the rolling CPU
// state (sampled on its own faster tick).
type monitorModel struct {
snap *health.Snapshot
err error
// CPU sampling: prev holds the last /proc/stat snapshot so the next
// tick can compute deltas. usage is the most recent % per core +
// aggregate. history is a per-core ring buffer used to render the
// sparkline alongside the bar.
cpuPrev []health.ProcStatLine
cpuUsage health.CPUUsage
cpuHist [][]float64
}
const cpuHistoryLen = 30
func newMonitorModel() monitorModel { return monitorModel{} }
// WithHealth injects a snapshot into the model directly. For test use; the
// real flow loads via the async loadHealthCmd.
func (m Model) WithHealth(snap *health.Snapshot) Model {
m.monitor.snap = snap
return m
}
// WithCPU injects a CPU usage sample + per-core history into the model.
// For test use.
func (m Model) WithCPU(u health.CPUUsage, history [][]float64) Model {
m.monitor.cpuUsage = u
m.monitor.cpuHist = history
return m
}
// --- async loading ---
type healthLoadedMsg struct {
snap *health.Snapshot
err error
}
type monitorTickMsg struct{}
const monitorRefresh = 5 * time.Second
func monitorTickCmd() tea.Cmd {
return tea.Tick(monitorRefresh, func(time.Time) tea.Msg {
return monitorTickMsg{}
})
}
// Fast tick for CPU only — cheap, no shell-outs.
type cpuTickMsg struct{}
const cpuRefresh = 1 * time.Second
func cpuTickCmd() tea.Cmd {
return tea.Tick(cpuRefresh, func(time.Time) tea.Msg {
return cpuTickMsg{}
})
}
type cpuSampleMsg struct {
sample []health.ProcStatLine
usage health.CPUUsage
}
// cpuSampleCmd reads /proc/stat in the background and returns a delta
// against prev. First sample (prev nil) returns empty usage — the next
// tick produces real numbers.
func cpuSampleCmd(prev []health.ProcStatLine) tea.Cmd {
return func() tea.Msg {
now, err := health.ReadProcStat()
if err != nil {
return cpuSampleMsg{}
}
return cpuSampleMsg{sample: now, usage: health.DeltaCPU(prev, now)}
}
}
// loadHealthCmd derives the health Inputs from the current inventory and
// kicks off a snapshot in the background. If inventory hasn't loaded
// yet we still take a snapshot — system/jellyfin/cpu sections work
// without it.
func loadHealthCmd(inv *drives.Inventory) tea.Cmd {
in := healthInputsFrom(inv)
return func() tea.Msg {
s := health.Take(in)
return healthLoadedMsg{snap: &s}
}
}
func healthInputsFrom(inv *drives.Inventory) health.Inputs {
if inv == nil {
return health.Inputs{}
}
var in health.Inputs
seenDisk := map[string]bool{}
for _, d := range inv.All {
// Capacities: every mounted filesystem we might care about.
if d.MountPoint != "" {
in.Capacities = append(in.Capacities, health.CapacityTarget{
Mountpoint: d.MountPoint,
Label: capacityLabel(d),
FSType: d.FSType,
})
}
// btrfs pools (managed-ours only; others belong to the user
// or are system).
if d.FSType == "btrfs" && d.Role == drives.RoleManagedOurs {
label := d.Label
if label == "" {
label = filepath.Base(d.MountPoint)
}
in.BtrfsMounts = append(in.BtrfsMounts, health.BtrfsTarget{
Mountpoint: d.MountPoint,
Label: label,
})
}
// Physical disks (real spinning rust / SSDs only — skip
// zram, loop, dm-mapper).
if d.Type == "disk" && !skipDiskForSmart(d.Name) && !seenDisk[d.Name] {
seenDisk[d.Name] = true
in.PhysicalDisks = append(in.PhysicalDisks, health.DiskTarget{
Device: d.Path,
Model: d.Model,
Transport: d.Transport,
})
}
}
return in
}
func skipDiskForSmart(name string) bool {
return drives.IsPseudoDisk(name)
}
func capacityLabel(d drives.Drive) string {
if d.Label != "" {
return d.Label
}
switch d.MountPoint {
case "/":
return "system"
case "/boot":
return "boot"
}
return filepath.Base(d.MountPoint)
}
// --- view ---
func (mn monitorModel) view(m Model) string {
if mn.err != nil {
return centeredCard(
titleStyle.Render("Couldn't read system health") + "\n\n" +
mn.err.Error(),
)
}
if mn.snap == nil {
return centeredCard(headerStyle.Render("Loading health snapshot…"))
}
snap := mn.snap
heading := titleStyle.Render("System health")
subhead := headerStyle.Render(
"Refreshing every " + monitorRefresh.String() +
" · last update " + snap.Time.Format("15:04:05"))
w := cardWidth(m.width)
return lipgloss.JoinVertical(lipgloss.Center,
"",
centered(heading, w),
centered(subhead, w),
"",
renderCPUCard(mn.cpuUsage, mn.cpuHist, snap.System.CPUCount),
"",
renderSystemCard(snap),
"",
renderCapacityCard(snap),
"",
renderBtrfsCard(snap),
"",
renderDiskCard(snap),
)
}
// renderCPUCard is the live activity visualizer: aggregate bar at top,
// per-core row of "Cn XX% ▇▇▇▇░░░░ ▁▂▃▄▅▆▇█▇▆▅▄▃▂▁" — % readout,
// 12-cell bar, 30-sample sparkline. Refreshed every 1 second.
func renderCPUCard(u health.CPUUsage, history [][]float64, cores int) string {
rows := []string{titleStyle.Render("CPU activity") + headerStyle.Render(
fmt.Sprintf(" — %d cores · sampled every %s", cores, cpuRefresh))}
rows = append(rows, "")
if u.Aggregate == 0 && len(u.Cores) == 0 {
rows = append(rows, headerStyle.Render(
"Waiting for second sample… (first reading uses no prior delta)"))
return centeredFrame(strings.Join(rows, "\n"))
}
// Aggregate at the top, wider bar.
rows = append(rows, " "+padRight("ALL", 3)+" "+pctColumn(u.Aggregate)+
" "+coloredBar(u.Aggregate, 28))
if len(u.Cores) > 0 {
rows = append(rows, "")
}
for i, pct := range u.Cores {
label := fmt.Sprintf("C%d", i)
var hist []float64
if i < len(history) {
hist = history[i]
}
rows = append(rows, " "+padRight(label, 3)+" "+pctColumn(pct)+
" "+coloredBar(pct, 12)+" "+sparkline(hist, cpuHistoryLen))
}
return centeredFrame(strings.Join(rows, "\n"))
}
// humanAge turns a SMART power-on-hours count into a readable drive age:
// years (1 decimal) for ≥1 year, days for ≥2 days, else raw hours. A drive
// reporting 70466 hours reads as "8.0y" instead of an opaque "70466h".
func humanAge(hours int) string {
switch {
case hours <= 0:
return "—"
case hours >= 8766: // 365.25 * 24
return fmt.Sprintf("%.1fy", float64(hours)/8766)
case hours >= 48:
return fmt.Sprintf("%dd", hours/24)
default:
return fmt.Sprintf("%dh", hours)
}
}
// pctColumn renders a 5-char " XX%" column, colored against load.
func pctColumn(pct float64) string {
style := roleAvailableStyle
switch {
case pct >= 90:
style = roleSystemStyle
case pct >= 70:
style = roleInUseStyle
}
return style.Render(fmt.Sprintf("%4.0f%%", pct))
}
// coloredBar is the inline % bar used by the CPU card. Same colour
// bucket logic as the capacity bar.
func coloredBar(pct float64, width int) string {
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
filled := int(pct / 100 * float64(width))
bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
style := roleAvailableStyle
switch {
case pct >= 90:
style = roleSystemStyle
case pct >= 70:
style = roleInUseStyle
}
return style.Render(bar)
}
// sparkline renders up to `max` samples as a single line of unicode
// block elements. Missing samples render as a space so the line stays
// the same width as it fills up. Anything non-zero shows at least the
// smallest block so a mostly-idle box still has a visible trace.
func sparkline(history []float64, max int) string {
const blocks = " ▁▂▃▄▅▆▇█"
chars := []rune(blocks)
out := make([]rune, max)
for i := range out {
out[i] = ' '
}
start := 0
if len(history) > max {
start = len(history) - max
}
off := max - (len(history) - start)
for i, v := range history[start:] {
var idx int
switch {
case v <= 0:
idx = 0
case v >= 100:
idx = len(chars) - 1
default:
// Bucket 0 is "no activity"; anything > 0 must show at
// least block 1 so the trace is visible at low load.
idx = int(v/12.5) + 1
if idx >= len(chars) {
idx = len(chars) - 1
}
}
out[off+i] = chars[idx]
}
return string(out)
}
func renderSystemCard(s *health.Snapshot) string {
jVerb := roleSystemStyle.Render("not installed")
switch {
case s.Jellyfin.Active:
jVerb = roleAvailableStyle.Render("active")
case s.Jellyfin.Installed:
jVerb = roleInUseStyle.Render("installed, stopped")
}
loadCol := func(v float64) string {
// Colour a load average against CPU count: green if <= cores,
// red if > 2x cores.
cpu := float64(s.System.CPUCount)
switch {
case v >= 2*cpu:
return roleSystemStyle.Render(fmt.Sprintf("%.2f", v))
case v >= cpu:
return roleInUseStyle.Render(fmt.Sprintf("%.2f", v))
}
return roleAvailableStyle.Render(fmt.Sprintf("%.2f", v))
}
memColour := roleAvailableStyle
switch {
case s.System.MemPct >= 90:
memColour = roleSystemStyle
case s.System.MemPct >= 75:
memColour = roleInUseStyle
}
rows := []string{titleStyle.Render("System")}
rows = append(rows,
fmt.Sprintf("Load avg: %s · %s · %s over %d cores",
loadCol(s.System.LoadAvg1),
loadCol(s.System.LoadAvg5),
loadCol(s.System.LoadAvg15),
s.System.CPUCount))
rows = append(rows,
fmt.Sprintf("Memory: %s used / %s total (%s)",
health.HumanBytes(s.System.MemUsedKB*1024),
health.HumanBytes(s.System.MemTotalKB*1024),
memColour.Render(fmt.Sprintf("%.0f%%", s.System.MemPct))))
rows = append(rows,
fmt.Sprintf("Uptime: %s", health.HumanUptime(s.System.UptimeS)))
rows = append(rows, "Jellyfin: "+jVerb)
if s.Jellyfin.Active {
for i, u := range jellyfin.WebURLs() {
label := "Web URL:"
if i > 0 {
label = ""
}
rows = append(rows, fmt.Sprintf("%-11s %s", label, devNameStyle.Render(u)))
}
}
return centeredFrame(strings.Join(rows, "\n"))
}
func renderCapacityCard(s *health.Snapshot) string {
rows := []string{titleStyle.Render("Disk space")}
// Hide rows we can't measure (swap, failed statfs) — they're just noise
// to someone checking how full their media drives are.
var shown []health.Capacity
for _, c := range s.Capacities {
if c.Total == 0 || strings.EqualFold(c.FSType, "swap") {
continue
}
shown = append(shown, c)
}
if len(shown) == 0 {
rows = append(rows, headerStyle.Render("Nothing mounted yet."))
return centeredFrame(strings.Join(rows, "\n"))
}
for _, c := range shown {
name := capacityName(c)
bar := capacityBar(c.PctUsed, 20)
rows = append(rows, " "+devNameStyle.Render(padRight(truncate(name, 20), 21))+bar)
detail := fmt.Sprintf("%s free of %s · %s used",
health.HumanBytes(c.Avail),
health.HumanBytes(c.Total),
health.HumanBytes(c.Used))
rows = append(rows, " "+headerStyle.Render(detail))
}
return centeredFrame(strings.Join(rows, "\n"))
}
// capacityName picks the friendliest label for a mounted filesystem: its
// health Label if set, otherwise a plain word for the well-known system
// mounts, otherwise the last path segment.
func capacityName(c health.Capacity) string {
if c.Label != "" {
return c.Label
}
switch c.Mountpoint {
case "/":
return "System drive"
case "/boot", "/boot/efi", "/efi":
return "Boot partition"
}
return filepath.Base(c.Mountpoint)
}
// capacityBar renders a 20-cell percentage bar with colour buckets:
// green <70, amber 70-90, red >=90.
func capacityBar(pct float64, width int) string {
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
filled := int(pct / 100 * float64(width))
bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
style := roleAvailableStyle
switch {
case pct >= 90:
style = roleSystemStyle
case pct >= 70:
style = roleInUseStyle
}
return style.Render(bar) + fmt.Sprintf(" %5.1f%%", pct)
}
func renderBtrfsCard(s *health.Snapshot) string {
rows := []string{titleStyle.Render("Storage pool health (Btrfs)")}
if len(s.Btrfs) == 0 {
rows = append(rows, headerStyle.Render("No storage pools — single-drive setups don't need one."))
return centeredFrame(strings.Join(rows, "\n"))
}
for i, p := range s.Btrfs {
if i > 0 {
rows = append(rows, "")
}
state := roleAvailableStyle.Render("✓ healthy")
if !p.Healthy {
state = roleSystemStyle.Render("✗ ATTENTION")
}
rows = append(rows, devNameStyle.Render(p.Label)+
" "+headerStyle.Render(p.Mountpoint)+" "+state)
if p.Note != "" {
rows = append(rows, " "+roleSystemStyle.Render(p.Note))
}
if len(p.Devices) == 0 {
rows = append(rows, " "+headerStyle.Render("(no devices reported)"))
continue
}
rows = append(rows, " "+headerStyle.Render(fmt.Sprintf(
"%-18s %6s %6s %6s %6s %6s %s",
"Device", "write", "read", "flush", "corrupt", "gen", "state",
)))
for _, d := range p.Devices {
devState := roleAvailableStyle.Render("ok")
if d.Missing {
devState = roleSystemStyle.Render("MISSING")
} else if d.WriteErrs+d.ReadErrs+d.FlushErrs+d.CorruptErrs+d.GenErrs > 0 {
devState = roleSystemStyle.Render("errors")
}
rows = append(rows, fmt.Sprintf(
" %-18s %6d %6d %6d %6d %6d %s",
d.Path, d.WriteErrs, d.ReadErrs, d.FlushErrs,
d.CorruptErrs, d.GenErrs, devState,
))
}
}
return centeredFrame(strings.Join(rows, "\n"))
}
func renderDiskCard(s *health.Snapshot) string {
rows := []string{titleStyle.Render("Drive health (SMART)")}
if len(s.Disks) == 0 {
rows = append(rows, headerStyle.Render("No physical disks detected."))
return centeredFrame(strings.Join(rows, "\n"))
}
header := " " +
padRight("Drive", 18) + " " +
padRight("Connection", 11) + " " +
padRight("Health", 8) + " " +
padLeft("Temp", 6) + " " +
padLeft("Age", 8) + " " +
padLeft("Realloc", 8) + " " +
padLeft("Pending", 8)
rows = append(rows, headerStyle.Render(header))
for _, d := range s.Disks {
var smart string
switch {
case !d.SmartReady:
smart = roleInUseStyle.Render("n/a")
case d.Passed:
smart = roleAvailableStyle.Render("✓ good")
default:
smart = roleSystemStyle.Render("✗ FAILING")
}
temp := "—"
if d.TempC > 0 {
tcol := roleAvailableStyle
if d.TempC >= 55 {
tcol = roleSystemStyle
} else if d.TempC >= 45 {
tcol = roleInUseStyle
}
temp = tcol.Render(fmt.Sprintf("%d°C", d.TempC))
}
hrs := humanAge(d.PowerOnHr)
realloc := fmt.Sprintf("%d", d.Reallocated)
pending := fmt.Sprintf("%d", d.Pending)
if d.Reallocated > 0 {
realloc = roleSystemStyle.Render(realloc)
}
if d.Pending > 0 {
pending = roleSystemStyle.Render(pending)
}
name := d.Model
if name == "" {
name = filepath.Base(d.Device)
}
row := " " +
padRight(truncate(name, 18), 18) + " " +
padRight(plainTransport(d.Transport), 11) + " " +
padRight(smart, 8) + " " +
padLeft(temp, 6) + " " +
padLeft(hrs, 8) + " " +
padLeft(realloc, 8) + " " +
padLeft(pending, 8)
rows = append(rows, row)
}
return centeredFrame(strings.Join(rows, "\n"))
}
// padRight/padLeft pad a possibly-styled string to a visible-character
// width. Plain fmt %-*s counts ANSI escape bytes, breaking alignment.
func padRight(s string, width int) string {
w := lipgloss.Width(s)
if w >= width {
return s
}
return s + strings.Repeat(" ", width-w)
}
func padLeft(s string, width int) string {
w := lipgloss.Width(s)
if w >= width {
return s
}
return strings.Repeat(" ", width-w) + s
}