TUISTREAM/internal/tui/model.go
28allday 77929de645 Spin down idle media drives by default via a watcher service
Drives on a headless Jellyfin box ran hot 24/7: NAS-class drives (WD
Red, IronWolf) ship with no idle timer at all. v0.1.2 stopped tuistream
from keeping them awake; this makes them actually go to sleep.

Spin-down is a DEFAULT, not a feature: when the inventory shows
spinning media drives, the TUI silently installs/syncs the mechanism
and flashes what it did. [s] on Setup is the opt-OUT, remembered via
/etc/tuistream/spindown-off so the default never fights the user.

The mechanism is a tiny systemd service (tuistream --spindown-watch)
that samples /proc/diskstats and issues `hdparm -y` to any target drive
idle past 3 minutes. We deliberately do NOT use the drive's own standby
timer (hdparm -S): the 10TB helium WD Reds advertise it and then ignore
it — verified on real hardware (fresh -S 36, zero I/O, four minutes,
still active/idle). Forcing standby from outside works on everything.
An rc shipped the udev+hdparm -S approach; the plans clean its rule up.

Safety: targets are spinning (sysfs rotational), top-level, real disks,
never a system disk — same classifier the rest of Setup trusts. The
watcher's own probes can't disturb drives: /proc/diskstats and sysfs are
kernel memory, and hdparm -C (CHECK POWER MODE) doesn't wake or reset
anything. The unit's ExecStart points at the running binary and the
auto-sync rewrites it when the content goes stale (e.g. binary moved
from a dev path to /usr/local/bin).

hdparm joins the dependency pre-flight. Verified end-to-end on moviebox
(2x WD Red 10TB SATA + IronWolf 10TB USB): auto-applied on launch, all
three drives reached standby after 3 idle minutes, Monitor shows them
as "asleep" without waking them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:28:39 +01:00

468 lines
12 KiB
Go

// Package tui implements the Bubble Tea front end. Two tabs: Setup and
// Manage. Tab/Shift-Tab to switch; q to quit; '?' for help.
package tui
import (
"fmt"
"os"
"os/user"
"strings"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tuistream/internal/drives"
"tuistream/internal/firewall"
"tuistream/internal/jellyfin"
"tuistream/internal/spindown"
"tuistream/internal/theme"
)
type tab int
const (
tabSetup tab = iota
tabManage
tabMonitor
tabCount = 3
)
func (t tab) String() string {
switch t {
case tabSetup:
return "Setup"
case tabManage:
return "Manage"
case tabMonitor:
return "Monitor"
}
return "?"
}
// Model is the root model. Each tab's state lives in its own field so the
// tab switch is just a number bump and we never lose work-in-progress.
type Model struct {
width, height int
currentTab tab
username string // owning Linux user (SUDO_USER if available)
inventory *drives.Inventory
inventoryErr error
status jellyfin.Status
statusErr error
firewall firewall.State
setup setupModel
manage manageModel
monitor monitorModel
// In-flight plan, shared between tabs. runOwnerTab is the tab the
// run started from — we return to it when the user dismisses the
// "done" splash.
run planRun
runStage runStage
runOwnerTab tab
// spindownSyncing guards the silent spin-down default apply so a
// refresh mid-sync can't start a second one.
spindownSyncing bool
// spinner ticked while a step is running in the background.
spinner spinner.Model
// flash message shown in the footer for a few seconds after an action
flash string
}
// NewModel constructs a model with the given theme. The inventory is loaded
// lazily on first View() so startup is instant even on slow lsblk hosts.
func NewModel(t theme.Theme) Model {
applyTheme(t)
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(accent).Bold(true)
return Model{
currentTab: tabSetup,
username: detectUser(),
setup: newSetupModel(),
manage: newManageModel(),
monitor: newMonitorModel(),
spinner: sp,
}
}
// Init is the Bubble Tea entry point. We kick off the first inventory load,
// Jellyfin status check, and firewall snapshot asynchronously so the UI
// paints immediately. The monitor tick fires on a 5s interval but only
// probes while the Monitor tab is visible (see monitorTickMsg).
func (m Model) Init() tea.Cmd {
return tea.Batch(
loadInventoryCmd(m.username),
loadStatusCmd(),
loadFirewallCmd(),
loadHealthCmd(m.inventory, false),
monitorTickCmd(),
cpuSampleCmd(nil), // seed the previous-sample slot
cpuTickCmd(),
m.spinner.Tick,
)
}
type spindownSyncedMsg struct{ err error }
// spindownAutoSyncCmd applies the spin-down default in the background when
// it's due (spinning media drives present, no opt-out, rule missing or
// stale). Returns nil — no work, no UI churn — in the common case where
// the rule already matches.
func (m *Model) spindownAutoSyncCmd() tea.Cmd {
if m.spindownSyncing || os.Geteuid() != 0 {
return nil
}
targets := spindownTargets(m.inventory)
if !spindown.AutoSyncDue(targets) {
return nil
}
m.spindownSyncing = true
return func() tea.Msg {
return spindownSyncedMsg{err: spindown.AutoSync(targets)}
}
}
// healthRefreshCmd builds the next health probe, including SMART only when
// its slower cadence is due. Marks lastSmart at issue time so an in-flight
// probe isn't doubled up by the next tick.
func (m *Model) healthRefreshCmd() tea.Cmd {
withSmart := time.Since(m.monitor.lastSmart) >= smartRefresh
if withSmart {
m.monitor.lastSmart = time.Now()
}
return loadHealthCmd(m.inventory, withSmart)
}
// enteredMonitorCmd fires an immediate health probe when a tab switch lands
// on Monitor, so the user isn't staring at a stale snapshot until the next
// 5s tick.
func (m *Model) enteredMonitorCmd() tea.Cmd {
if m.currentTab != tabMonitor {
return nil
}
return m.healthRefreshCmd()
}
type firewallLoadedMsg struct{ state firewall.State }
func loadFirewallCmd() tea.Cmd {
return func() tea.Msg {
return firewallLoadedMsg{state: firewall.LoadState()}
}
}
type inventoryLoadedMsg struct {
inv *drives.Inventory
err error
}
func loadInventoryCmd(username string) tea.Cmd {
return func() tea.Msg {
inv, err := drives.Load(username)
return inventoryLoadedMsg{inv: inv, err: err}
}
}
// Update routes messages. Global keys (quit, tab switch) are handled here;
// tab-specific keys (Setup actions etc.) are delegated to per-tab handlers.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case inventoryLoadedMsg:
m.inventory = msg.inv
m.inventoryErr = msg.err
// Spin-down is a default, not a feature: whenever the fresh
// inventory shows spinning media drives that the udev rule doesn't
// cover yet (first run, or a drive was added), silently sync it —
// unless the user opted out via [s]. Root only; --read-only can't
// write rules.
return m, m.spindownAutoSyncCmd()
case spindownSyncedMsg:
m.spindownSyncing = false
if msg.err != nil {
m.flash = "Couldn't apply drive spin-down: " + msg.err.Error()
} else {
m.flash = fmt.Sprintf(
"Drive spin-down active — media drives sleep after %d min idle ([s] turns it off)",
spindown.TimeoutMinutes)
}
return m, nil
case statusLoadedMsg:
m.status = msg.status
m.statusErr = msg.err
return m, nil
case firewallLoadedMsg:
m.firewall = msg.state
return m, nil
case healthLoadedMsg:
if msg.snap != nil {
if msg.withSmart {
// Fresh SMART rows — update the cache.
m.monitor.smartCache = msg.snap.Disks
} else {
// Cheap refresh — carry the cached SMART rows forward so
// the drive table doesn't flicker empty between probes.
msg.snap.Disks = m.monitor.smartCache
}
}
m.monitor.snap = msg.snap
m.monitor.err = msg.err
return m, nil
case monitorTickMsg:
// Always re-arm so the tick keeps firing, but only probe while the
// Monitor tab is visible — polling SMART from the other tabs kept
// drives awake (and hot) for nothing. SMART itself runs on its own
// slower cadence even when the tab is up.
if m.currentTab != tabMonitor {
return m, monitorTickCmd()
}
return m, tea.Batch(m.healthRefreshCmd(), monitorTickCmd())
case cpuTickMsg:
return m, tea.Batch(cpuSampleCmd(m.monitor.cpuPrev), cpuTickCmd())
case cpuSampleMsg:
m.monitor.cpuPrev = msg.sample
if len(msg.usage.Cores) == 0 {
return m, nil
}
m.monitor.cpuUsage = msg.usage
if m.monitor.cpuHist == nil || len(m.monitor.cpuHist) != len(msg.usage.Cores) {
m.monitor.cpuHist = make([][]float64, len(msg.usage.Cores))
}
for i, v := range msg.usage.Cores {
m.monitor.cpuHist[i] = append(m.monitor.cpuHist[i], v)
if len(m.monitor.cpuHist[i]) > cpuHistoryLen {
m.monitor.cpuHist[i] = m.monitor.cpuHist[i][len(m.monitor.cpuHist[i])-cpuHistoryLen:]
}
}
return m, nil
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case stepFinishedMsg:
var cmd tea.Cmd
m, cmd = advanceRun(m, msg)
return m, cmd
case tea.KeyMsg:
key := msg.String()
// While a plan is running, only allow ctrl+c — nothing else should
// fire and the user can't switch tabs out from under it.
if m.runStage == runRunning {
if key == "ctrl+c" {
return m, tea.Quit
}
return m, nil
}
// Any key dismisses the runDone splash and returns to the owning
// tab's idle state.
if m.runStage == runDone {
nm, cmd := dismissRun(m)
return nm, cmd
}
// Stage-active keys: when a modal is up on the Setup tab, route through
// its handlers FIRST so e.g. 'q' inside an input still works.
if m.currentTab == tabSetup && m.setup.stage != stageIdle {
nm, cmd, consumed := handleSetupKey(m, msg)
if consumed {
return nm, cmd
}
}
// Same for Manage tab when it has a modal open.
if m.currentTab == tabManage && m.manage.stage != manageIdle {
nm, cmd, consumed := handleManageKey(m, msg)
if consumed {
return nm, cmd
}
}
switch key {
case "ctrl+c", "q":
return m, tea.Quit
case "tab", "right":
m.currentTab = (m.currentTab + 1) % tabCount
return m, m.enteredMonitorCmd()
case "shift+tab", "left":
m.currentTab = (m.currentTab + tabCount - 1) % tabCount
return m, m.enteredMonitorCmd()
case "r":
m.flash = "Refreshing…"
return m, tea.Batch(loadInventoryCmd(m.username), loadStatusCmd(),
loadFirewallCmd(), m.enteredMonitorCmd())
}
// Idle Setup-tab keys ('i' install, 'u' uninstall, 'a' add drive, 'f' firewall)
if m.currentTab == tabSetup {
if nm, cmd, consumed := handleSetupKey(m, msg); consumed {
return nm, cmd
}
}
// Idle Manage-tab keys ('c' copy, 'd' delete, 'n' rename)
if m.currentTab == tabManage {
if nm, cmd, consumed := handleManageKey(m, msg); consumed {
return nm, cmd
}
}
}
return m, nil
}
// View composes the chrome (title bar, tab bar, footer) around the active
// tab's view.
func (m Model) View() string {
if m.width == 0 || m.height == 0 {
return ""
}
// Re-stretch cards / frames to the current terminal width so every
// element drawn this frame lines up regardless of which renderer
// produced it.
applyWidth(m.width)
// Title bar is naturally full-width — centre its text content.
title := titleBarStyle.Width(m.width).Align(lipgloss.Center).Render(
"TUISTREAM — headless Jellyfin for Omarchy / Arch",
)
tabs := []string{}
for i := 0; i < tabCount; i++ {
t := tab(i)
style := tabInactiveStyle
if t == m.currentTab {
style = tabActiveStyle
}
tabs = append(tabs, style.Render(t.String()))
}
tabBar := lipgloss.NewStyle().Width(m.width).Align(lipgloss.Center).Render(
lipgloss.JoinHorizontal(lipgloss.Top, tabs...),
)
body := ""
switch {
case m.runStage == runRunning:
body = renderRunning(m.spinner, m.run)
case m.runStage == runDone:
body = renderRunDone(m.run)
case m.currentTab == tabSetup:
body = m.setup.view(m)
case m.currentTab == tabManage:
body = m.manage.view(m)
case m.currentTab == tabMonitor:
body = m.monitor.view(m)
}
footer := m.renderFooter()
// Centre the body horizontally inside the terminal. Cards are sized
// via cardWidth(m.width); this just slides them to the middle.
chromeHeight := lipgloss.Height(title) + lipgloss.Height(tabBar) + lipgloss.Height(footer)
bodyHeight := m.height - chromeHeight
if bodyHeight < 1 {
bodyHeight = 1
}
bodyBlock := lipgloss.NewStyle().
Width(m.width).
Height(bodyHeight).
Align(lipgloss.Center).
Render(body)
return lipgloss.JoinVertical(lipgloss.Left, title, tabBar, bodyBlock, footer)
}
func (m Model) renderFooter() string {
hints := []string{
"tab/⇆ switch", "r refresh", "q quit",
}
if m.flash != "" {
return footerStyle.Width(m.width).Align(lipgloss.Center).Render(m.flash + " · " + strings.Join(hints, " · "))
}
return footerStyle.Width(m.width).Align(lipgloss.Center).Render(strings.Join(hints, " · "))
}
// detectUser returns the user we should operate on behalf of: SUDO_USER if
// present (the real user behind a sudo invocation), otherwise the current
// user. Empty string if neither resolves.
func detectUser() string {
if su := envSudoUser(); su != "" {
return su
}
if u, err := user.Current(); err == nil {
return u.Username
}
return ""
}
func envSudoUser() string {
if v := os.Getenv("SUDO_USER"); v != "" && v != "root" {
return v
}
return ""
}
// WithInventory injects an already-loaded inventory and returns the updated
// model. Useful for tests.
func (m Model) WithInventory(inv *drives.Inventory) Model {
m.inventory = inv
m.inventoryErr = nil
return m
}
// WithTab switches the active tab and returns the updated model. Tests only.
func (m Model) WithTab(i int) Model {
m.currentTab = tab(i % tabCount)
return m
}
// summaryLine renders the "n drives detected, m available" line used in both
// tabs to keep the user oriented after a refresh.
func summaryLine(inv *drives.Inventory) string {
if inv == nil {
return headerStyle.Render("Loading drives…")
}
var disks, parts, available, managed int
for _, d := range inv.All {
switch d.Type {
case "disk":
disks++
case "part", "crypt":
parts++
}
switch d.Role {
case drives.RoleAvailable:
available++
case drives.RoleManagedOurs:
managed++
}
}
msg := fmt.Sprintf(
"%d disks · %d partitions · %d available · %d already managed",
disks, parts, available, managed,
)
return headerStyle.Render(msg)
}