diff --git a/README.md b/README.md index 9f1cd93..0c47cc8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ monitor, all without dropping you to a shell or switching terminals mid-task. - **Setup** — install / uninstall Jellyfin, open or close the firewall ports, copy the server's web address to your clipboard (works over SSH + tmux), and move Jellyfin's library storage onto a media drive. +- **Drive spin-down by default** — spinning media drives are automatically put + to sleep after 3 idle minutes (cooler, quieter) by a tiny background watcher + that survives reboots — and works even on NAS drives that ignore their own + firmware idle timer (looking at you, WD Red). System drives are never + touched; `s` opts out if you want 24/7 spinning. - **Add media drive** — attach a spare disk or partition: keep its existing filesystem or format it (btrfs / ext4 / xfs), or combine 2+ disks into a **btrfs RAID pool** (1 / 0 / 5 / 10). The boot drive is never offered — the @@ -90,6 +95,14 @@ A first-run, from an empty box to a working server — all from the **Setup** ta Optional: press `j` to move Jellyfin's own library database and metadata off the OS drive onto a media drive (handy on a small boot SSD). +Note on **drive spin-down**: when TUISTREAM sees spinning media drives it +automatically installs a small watcher service that spins them down after 3 +idle minutes, so they don't run hot 24/7. (It watches actual disk I/O rather +than trusting the drive's own idle timer, which many NAS drives silently +ignore.) The first play after a sleep takes a few seconds while the drives +wake. Press `s` to opt out — TUISTREAM remembers and won't re-apply the +default. + ## Build from source Needs Go 1.26+. diff --git a/cmd/tuistream/main.go b/cmd/tuistream/main.go index abe9f11..53744a4 100644 --- a/cmd/tuistream/main.go +++ b/cmd/tuistream/main.go @@ -18,6 +18,7 @@ import ( tea "github.com/charmbracelet/bubbletea" + "tuistream/internal/spindown" "tuistream/internal/system" "tuistream/internal/theme" "tuistream/internal/tui" @@ -32,6 +33,8 @@ func main() { readOnly := flag.Bool("read-only", false, "open the TUI without checking for root; only the inventory views work") showVersion := flag.Bool("version", false, "print the version and exit") + spindownWatch := flag.Bool("spindown-watch", false, + "internal: run the drive idle watcher (started by tuistream-spindown.service)") flag.Parse() if *showVersion { @@ -39,6 +42,19 @@ func main() { return } + // Daemon mode: no TUI, just the idle watcher (root needed for hdparm). + if *spindownWatch { + if os.Geteuid() != 0 { + fmt.Fprintln(os.Stderr, "tuistream --spindown-watch must run as root") + os.Exit(1) + } + if err := spindown.Watch(); err != nil { + fmt.Fprintln(os.Stderr, "tuistream:", err) + os.Exit(1) + } + return + } + if !*readOnly && os.Geteuid() != 0 { fmt.Fprintln(os.Stderr, "tuistream needs administrator rights to install Jellyfin, edit /etc/fstab, etc.") diff --git a/internal/spindown/spindown.go b/internal/spindown/spindown.go new file mode 100644 index 0000000..dbd929e --- /dev/null +++ b/internal/spindown/spindown.go @@ -0,0 +1,276 @@ +// Package spindown puts spinning media drives to sleep after a few idle +// minutes. NAS-class drives (WD Red, IronWolf) ship with NO idle timer at +// all — they spin 24/7 and run warm even when nothing touches them. +// +// Spin-down is a DEFAULT, not a feature the user enables: whenever the +// inventory shows spinning media drives, the TUI silently syncs the +// watcher onto the box (see AutoSyncDue). The Setup tab's [s] key is the +// opt-OUT — disabling writes a marker file so the default never fights +// the user. +// +// Mechanism: a tiny systemd service runs `tuistream --spindown-watch`, +// which samples /proc/diskstats and issues `hdparm -y` to any target +// drive that's been idle past the timeout. We deliberately do NOT use the +// drive's own standby timer (`hdparm -S`): common NAS drives — the 10TB +// helium WD Reds included — advertise the timer and then ignore it. +// Forcing standby from the outside works on everything `hdparm -y` +// works on, which we can verify per-drive. +// +// The watcher's own probes never touch the platters: /proc/diskstats and +// sysfs are kernel memory, and `hdparm -C` is an ATA CHECK POWER MODE, +// which neither wakes a sleeping drive nor resets its idle state. +// +// System disks are never targeted: Targets() refuses anything the OS +// lives on, using the same classifier the rest of Setup trusts. +package spindown + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "tuistream/internal/drives" + "tuistream/internal/step" +) + +// UnitPath is the systemd service that runs the watcher. +const UnitPath = "/etc/systemd/system/tuistream-spindown.service" + +const unitName = "tuistream-spindown.service" + +// legacyRulesPath is the v0.2.0-rc udev/`hdparm -S` approach, removed on +// sync: the firmware timer it relied on is ignored by common NAS drives. +const legacyRulesPath = "/etc/udev/rules.d/69-tuistream-spindown.rules" + +// OptOutPath marks "the user turned spin-down off on purpose" — its +// presence stops the auto-sync from re-enabling the default behind their +// back. Written by DisablePlan, removed by EnablePlan. +const OptOutPath = "/etc/tuistream/spindown-off" + +// TimeoutMinutes is the idle time before a drive is spun down. +const TimeoutMinutes = 3 + +// WatchInterval is how often the watcher samples /proc/diskstats. +const WatchInterval = 30 * time.Second + +// Target is one spinning, non-system physical disk the watcher may touch. +type Target struct { + Device string // /dev/sda + Name string // sda + Model string +} + +// Targets picks the disks the watcher may touch: spinning (per sysfs), +// top-level, real hardware, and never a disk the OS lives on. +func Targets(inv *drives.Inventory) []Target { + if inv == nil { + return nil + } + var ts []Target + for _, d := range inv.All { + if d.Type != "disk" || drives.IsPseudoDisk(d.Name) { + continue + } + if inv.SystemDisks[d.Name] { + continue + } + if !rotational(d.Name) { + continue + } + ts = append(ts, Target{Device: d.Path, Name: d.Name, Model: d.Model}) + } + return ts +} + +// rotational reports whether a disk spins, per sysfs. Anything we can't +// read is treated as non-rotational so SSDs/NVMe are never targeted by a +// misread. +func rotational(name string) bool { + b, err := os.ReadFile("/sys/block/" + name + "/queue/rotational") + if err != nil { + return false + } + return strings.TrimSpace(string(b)) == "1" +} + +// Enabled reports whether the watcher service is installed. +func Enabled() bool { + _, err := os.Stat(UnitPath) + return err == nil +} + +// OptedOut reports whether the user explicitly disabled the default. +func OptedOut() bool { + _, err := os.Stat(OptOutPath) + return err == nil +} + +// AutoSyncDue reports whether the silent default-apply should run: there +// are spinning media drives, the user hasn't opted out, and the unit on +// disk doesn't match what we'd write (missing, stale, or pointing at a +// binary that has since moved — e.g. after a proper install). +func AutoSyncDue(ts []Target) bool { + if len(ts) == 0 || OptedOut() { + return false + } + b, err := os.ReadFile(UnitPath) + if err != nil { + return true + } + return string(b) != unitContent() +} + +// unitContent renders the service. ExecStart points at the running +// binary so a dev copy works too; when the binary later moves (proper +// install), the content no longer matches and AutoSyncDue triggers a +// rewrite. +func unitContent() string { + exe, err := os.Executable() + if err != nil { + exe = "/usr/local/bin/tuistream" + } + return `[Unit] +Description=TUISTREAM drive spin-down watcher +Documentation=https://github.com/28allday/TUISTREAM + +[Service] +ExecStart=` + exe + ` --spindown-watch +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +` +} + +// EnablePlan installs and starts the watcher, clears any opt-out marker, +// and removes the legacy udev rule from the rc builds. +func EnablePlan(ts []Target) []step.Step { + return []step.Step{ + { + Title: "Install spin-down watcher service", + Cmd: exec.Command("bash", "-c", + "cat > "+UnitPath+" <<'TUISTREAM_EOF'\n"+unitContent()+"TUISTREAM_EOF"), + }, + {Title: "Clear spin-down opt-out", Cmd: exec.Command("rm", "-f", OptOutPath)}, + {Title: "Remove legacy udev rule", Cmd: exec.Command("rm", "-f", legacyRulesPath)}, + {Title: "Reload systemd units", Cmd: exec.Command("systemctl", "daemon-reload")}, + {Title: "Start spin-down watcher", Cmd: exec.Command("systemctl", "enable", "--now", unitName)}, + } +} + +// DisablePlan stops and removes the watcher and records the opt-out so +// the default never re-applies itself. Drives return to their factory +// behaviour (NAS drives spin 24/7). +func DisablePlan(ts []Target) []step.Step { + return []step.Step{ + { + Title: "Stop spin-down watcher", + Cmd: exec.Command("bash", "-c", "systemctl disable --now "+unitName+" 2>/dev/null; true"), + }, + {Title: "Remove watcher service", Cmd: exec.Command("rm", "-f", UnitPath)}, + {Title: "Remove legacy udev rule", Cmd: exec.Command("rm", "-f", legacyRulesPath)}, + {Title: "Reload systemd units", Cmd: exec.Command("systemctl", "daemon-reload")}, + { + Title: "Record spin-down opt-out", + Cmd: exec.Command("bash", "-c", + "mkdir -p "+filepath.Dir(OptOutPath)+" && touch "+OptOutPath), + }, + } +} + +// AutoSync is the silent path for the default: same work as EnablePlan +// but run directly (no step-runner UI), used when the TUI notices the +// watcher is missing or stale. Returns the first error; the TUI surfaces +// it as a flash rather than a failure screen. +func AutoSync(ts []Target) error { + for _, s := range EnablePlan(ts) { + if err := s.Cmd.Run(); err != nil { + return fmt.Errorf("%s: %w", s.Title, err) + } + } + return nil +} + +// ---------- the watcher itself (`tuistream --spindown-watch`) ---------- + +// Watch is the daemon loop. Every WatchInterval it re-derives the target +// set (so hotplugged drives are covered without a restart), reads each +// target's I/O counters, and spins down any drive that has been idle past +// the timeout and is still spinning. Logs go to stdout → journald. +func Watch() error { + timeout := time.Duration(TimeoutMinutes) * time.Minute + log.Printf("watching for drives idle ≥ %s (sampling every %s)", timeout, WatchInterval) + + type diskState struct { + sig string // last-seen I/O counter signature + last time.Time // when the signature last changed + } + states := map[string]*diskState{} + + for { + inv, err := drives.Load("") + if err != nil { + log.Printf("inventory failed (will retry): %v", err) + time.Sleep(WatchInterval) + continue + } + for _, t := range Targets(inv) { + sig := ioSignature(t.Name) + if sig == "" { + continue + } + st := states[t.Name] + if st == nil || st.sig != sig { + states[t.Name] = &diskState{sig: sig, last: time.Now()} + continue + } + if time.Since(st.last) < timeout || !isSpinning(t.Device) { + continue + } + if err := exec.Command("hdparm", "-y", t.Device).Run(); err != nil { + log.Printf("couldn't spin down %s: %v", t.Device, err) + // Push last forward so a refusing drive is retried after a + // full timeout instead of every sample. + st.last = time.Now() + continue + } + log.Printf("spun down %s (%s) after %s idle", t.Device, t.Model, timeout) + } + time.Sleep(WatchInterval) + } +} + +// ioSignature condenses a disk's /proc/diskstats counters that only move +// on real I/O: reads/writes completed and sectors read/written. Fields +// like io_ticks and in_flight churn on their own and are excluded. +func ioSignature(name string) string { + b, err := os.ReadFile("/proc/diskstats") + if err != nil { + return "" + } + for _, line := range strings.Split(string(b), "\n") { + f := strings.Fields(line) + if len(f) < 10 || f[2] != name { + continue + } + return f[3] + " " + f[5] + " " + f[7] + " " + f[9] + } + return "" +} + +// isSpinning reports whether the drive is in active/idle (as opposed to +// standby/sleeping). `hdparm -C` issues CHECK POWER MODE, which doesn't +// wake a sleeping drive. On error we report false — never send a sleep +// command to a drive we can't read. +func isSpinning(device string) bool { + out, err := exec.Command("hdparm", "-C", device).Output() + if err != nil { + return false + } + return strings.Contains(string(out), "active") +} diff --git a/internal/system/deps.go b/internal/system/deps.go index 669cb8d..d03ec9e 100644 --- a/internal/system/deps.go +++ b/internal/system/deps.go @@ -34,6 +34,7 @@ var Required = []Dep{ {Pkg: "gptfdisk", Binary: "sgdisk"}, // Setup > wipe-whole-disk {Pkg: "parted", Binary: "partprobe"}, // Setup > partprobe after wipe {Pkg: "smartmontools", Binary: "smartctl"}, // Monitor > SMART health per disk + {Pkg: "hdparm", Binary: "hdparm"}, // Setup > drive spin-down timer } // Missing returns the subset of Required whose binary isn't on $PATH. diff --git a/internal/tui/model.go b/internal/tui/model.go index 70a2a42..78d84d0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -16,6 +16,7 @@ import ( "tuistream/internal/drives" "tuistream/internal/firewall" "tuistream/internal/jellyfin" + "tuistream/internal/spindown" "tuistream/internal/theme" ) @@ -68,6 +69,10 @@ type Model struct { 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 @@ -109,6 +114,26 @@ func (m Model) Init() tea.Cmd { ) } +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. @@ -161,6 +186,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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: diff --git a/internal/tui/setup_actions.go b/internal/tui/setup_actions.go index 3a0f97f..4634578 100644 --- a/internal/tui/setup_actions.go +++ b/internal/tui/setup_actions.go @@ -9,8 +9,10 @@ import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "tuistream/internal/drives" "tuistream/internal/firewall" "tuistream/internal/jellyfin" + "tuistream/internal/spindown" "tuistream/internal/step" ) @@ -27,6 +29,7 @@ const ( stageConfirmInstall stageConfirmUninstall stageConfirmFirewall + stageConfirmSpindown stageAddDrive stageImportPool // import an existing detached btrfs pool (see importPoolState) stageMoveJellyfinPick // choose which media drive to relocate onto (2+ managed) @@ -158,6 +161,8 @@ func handleSetupKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) { return setupConfirmUninstallKey(m, key) case stageConfirmFirewall: return setupConfirmFirewallKey(m, key) + case stageConfirmSpindown: + return setupConfirmSpindownKey(m, key) case stageAddDrive: return setupAddDriveKey(m, msg) case stageImportPool: @@ -209,6 +214,21 @@ func setupIdleKey(m Model, key string) (Model, tea.Cmd, bool) { m.setup.stage = stageConfirmFirewall m.setup.confirmIdx = 0 return m, nil, true + case "s": + if m.inventory == nil { + m.flash = "Inventory still loading — press 'r' once it's done." + return m, nil, true + } + targets := spindownTargets(m.inventory) + if len(targets) == 0 { + m.flash = "No spinning media drives detected — nothing to spin down." + return m, nil, true + } + m.setup.spindownDrives = targets + m.setup.spindownTarget = !spindown.Enabled() + m.setup.stage = stageConfirmSpindown + m.setup.confirmIdx = 0 + return m, nil, true case "j": return setupStartMoveJellyfin(m) case "y", "Y": @@ -275,6 +295,54 @@ func runFirewall(m Model) (Model, tea.Cmd, bool) { return m, runStepCmd(plan[0]), true } +func setupConfirmSpindownKey(m Model, key string) (Model, tea.Cmd, bool) { + switch key { + case "left", "h": + m.setup.confirmIdx = 0 + return m, nil, true + case "right", "l": + m.setup.confirmIdx = 1 + return m, nil, true + case "y", "Y": + m.setup.confirmIdx = 0 + return runSpindown(m) + case "n", "N", "esc": + m.setup.stage = stageIdle + return m, nil, true + case "enter": + if m.setup.confirmIdx == 0 { + return runSpindown(m) + } + m.setup.stage = stageIdle + return m, nil, true + } + return m, nil, false +} + +func runSpindown(m Model) (Model, tea.Cmd, bool) { + var plan []step.Step + title := "" + if m.setup.spindownTarget { + plan = spindown.EnablePlan(m.setup.spindownDrives) + title = fmt.Sprintf("Enabling drive spin-down (%d min idle)", spindown.TimeoutMinutes) + } else { + plan = spindown.DisablePlan(m.setup.spindownDrives) + title = "Disabling drive spin-down" + } + m.setup.stage = stageIdle + m.run = planRun{title: title, steps: plan, index: 0} + m.runStage = runRunning + m.runOwnerTab = tabSetup + return m, runStepCmd(plan[0]), true +} + +// spindownTargets picks the disks the spin-down watcher may touch. The +// selection logic lives in the spindown package (the watcher daemon uses +// the same rules); this is just the nil-tolerant TUI entry point. +func spindownTargets(inv *drives.Inventory) []spindown.Target { + return spindown.Targets(inv) +} + // setupStartMoveJellyfin gates entry into the move-storage flow and routes to // either the picker (2+ media drives) or straight to confirm (exactly one). func setupStartMoveJellyfin(m Model) (Model, tea.Cmd, bool) { @@ -490,7 +558,7 @@ func dismissRun(m Model) (Model, tea.Cmd) { // renderSetupActionBar shows the keyboard shortcuts at the bottom of the // idle setup view. The set of keys depends on current install + firewall state. -func renderSetupActionBar(installed, fwOpen, serviceActive, jellyfinMoved, hasDetachedPool bool, width int) string { +func renderSetupActionBar(installed, fwOpen, serviceActive, jellyfinMoved, hasDetachedPool, spindownEligible, spindownOn bool, width int) string { var keys []string if installed { keys = append(keys, "[i] reinstall") @@ -507,6 +575,13 @@ func renderSetupActionBar(installed, fwOpen, serviceActive, jellyfinMoved, hasDe } else { keys = append(keys, "[f] open firewall") } + if spindownEligible { + if spindownOn { + keys = append(keys, "[s] turn off spin-down") + } else { + keys = append(keys, "[s] re-enable spin-down") + } + } if installed { if jellyfinMoved { keys = append(keys, headerStyle.Render("[j] storage on media ✓")) @@ -585,6 +660,68 @@ func renderConfirmFirewall(s firewall.State, opening bool, idx int) string { return centeredCard(strings.Join(rows, "\n")) } +// renderSpindownLine shows the idle-timer status in the Setup tab header. +// Only rendered when at least one spinning media drive exists. +func renderSpindownLine() string { + if spindown.Enabled() { + return labelStyle.Render("Spin-down:") + " " + + roleAvailableStyle.Render(fmt.Sprintf("drives sleep after %d min idle", spindown.TimeoutMinutes)) + } + if spindown.OptedOut() { + return labelStyle.Render("Spin-down:") + " " + + headerStyle.Render("off by your choice — drives run 24/7 ([s] re-enables)") + } + return labelStyle.Render("Spin-down:") + " " + + headerStyle.Render("off — applying default shortly…") +} + +// renderConfirmSpindown shows the enable/disable confirmation modal with +// the exact drives the timer will touch. +func renderConfirmSpindown(targets []spindown.Target, enabling bool, idx int) string { + var rows []string + verb := "Re-enable" + desc := fmt.Sprintf( + "Spin these drives down after %d minutes of inactivity (the TUISTREAM default). They run cooler and quieter; the first play after a sleep takes a few seconds while they wake.", + spindown.TimeoutMinutes) + if !enabling { + verb = "Turn off" + desc = "Remove the idle timer — drives return to their factory behaviour (NAS drives spin 24/7). TUISTREAM remembers this and won't re-apply the default." + } + rows = append(rows, titleStyle.Render(verb+" drive spin-down?")) + rows = append(rows, "") + rows = append(rows, desc) + rows = append(rows, "") + rows = append(rows, "Drives:") + for _, t := range targets { + name := t.Model + if name == "" { + name = t.Name + } + rows = append(rows, " · "+devNameStyle.Render(name)+" ("+t.Device+")") + } + if enabling { + rows = append(rows, "") + rows = append(rows, headerStyle.Render( + "A small background service watches for idle drives — works across reboots,")) + rows = append(rows, headerStyle.Render( + "even on drives that ignore their own firmware timer. System drives are never touched.")) + } + rows = append(rows, "") + yes := " Yes " + no := " Cancel " + if idx == 0 { + yes = roleAvailableStyle.Render("▸" + yes) + no = " " + no + } else { + yes = " " + yes + no = roleSystemStyle.Render("▸" + no) + } + rows = append(rows, yes+" "+no) + rows = append(rows, "") + rows = append(rows, footerStyle.Render("←/→ move · enter confirm · y / n shortcut · esc cancel")) + return centeredCard(strings.Join(rows, "\n")) +} + // renderStatusLine shows "Jellyfin: not installed" or "Jellyfin: installed // (jellyfin-bin) · service active" at the top of the Setup tab. func renderStatusLine(s jellyfin.Status) string { diff --git a/internal/tui/view_setup.go b/internal/tui/view_setup.go index 23a2448..773d5b8 100644 --- a/internal/tui/view_setup.go +++ b/internal/tui/view_setup.go @@ -8,6 +8,7 @@ import ( "tuistream/internal/drives" "tuistream/internal/jellyfin" + "tuistream/internal/spindown" ) // setupModel holds Setup-tab-specific state: the current sub-stage (idle, @@ -15,11 +16,18 @@ import ( // the root Model so they're shared with the Manage tab. type setupModel struct { stage setupStage - confirmIdx int // 0 = Yes, 1 = No (used by confirm modals) - firewallTarget bool // true = "we're about to open", false = "we're about to close" - addDrive addDriveState // state for the multi-stage Add-drive flow - importPool importPoolState // state for the import-existing-pool flow - showTech bool // 'd' toggle: false = friendly drive list, true = raw lsblk table + confirmIdx int // 0 = Yes, 1 = No (used by confirm modals) + firewallTarget bool // true = "we're about to open", false = "we're about to close" + + // Drive spin-down flow: true = about to enable, false = about to + // disable; spindownDrives is the resolved target list shown on the + // confirm screen and fed to the plan. + spindownTarget bool + spindownDrives []spindown.Target + + addDrive addDriveState // state for the multi-stage Add-drive flow + importPool importPoolState // state for the import-existing-pool flow + showTech bool // 'd' toggle: false = friendly drive list, true = raw lsblk table // Move-Jellyfin-storage flow. moveChoices []drives.Drive // managed media drives to choose from @@ -47,6 +55,8 @@ func (s setupModel) view(m Model) string { return renderConfirmUninstall(m.status, s.confirmIdx) case stageConfirmFirewall: return renderConfirmFirewall(m.firewall, m.setup.firewallTarget, s.confirmIdx) + case stageConfirmSpindown: + return renderConfirmSpindown(m.setup.spindownDrives, m.setup.spindownTarget, s.confirmIdx) case stageAddDrive: return renderAddDrive(m) case stageImportPool: @@ -61,7 +71,8 @@ func (s setupModel) view(m Model) string { fw := renderFirewallLine(m.firewall) jellyfinMoved := jellyfin.LoadStorageState().Moved hasDetachedPool := len(m.inventory.DetachedPools()) > 0 - actions := renderSetupActionBar(m.status.IsInstalled(), m.firewall.AllOpen(), m.status.ServiceActive, jellyfinMoved, hasDetachedPool, cardWidth(m.width)) + spindownEligible := len(spindownTargets(m.inventory)) > 0 + actions := renderSetupActionBar(m.status.IsInstalled(), m.firewall.AllOpen(), m.status.ServiceActive, jellyfinMoved, hasDetachedPool, spindownEligible, spindown.Enabled(), cardWidth(m.width)) var heading, subhead, inv string if s.showTech { @@ -77,13 +88,17 @@ func (s setupModel) view(m Model) string { hint := headerStyle.Render(techToggleHint(s.showTech)) w := cardWidth(m.width) + headerLines := []string{centered(status, w), centered(fw, w)} + if spindownEligible { + headerLines = append(headerLines, centered(renderSpindownLine(), w)) + } + return lipgloss.JoinVertical(lipgloss.Center, "", centered(heading, w), centered(subhead, w), "", - centered(status, w), - centered(fw, w), + strings.Join(headerLines, "\n"), "", inv, centered(hint, w),