Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96c908f324 | |||
| 77929de645 | |||
| 262ba8e3b4 | |||
| 3786d6bae1 |
16 changed files with 862 additions and 34 deletions
19
README.md
19
README.md
|
|
@ -14,6 +14,17 @@ monitor, all without dropping you to a shell or switching terminals mid-task.
|
||||||
- **Setup** — install / uninstall Jellyfin, open or close the firewall ports,
|
- **Setup** — install / uninstall Jellyfin, open or close the firewall ports,
|
||||||
copy the server's web address to your clipboard (works over SSH + tmux), and
|
copy the server's web address to your clipboard (works over SSH + tmux), and
|
||||||
move Jellyfin's library storage onto a media drive.
|
move Jellyfin's library storage onto a media drive.
|
||||||
|
- **Hardware transcoding ready out of the box** — the installer detects the
|
||||||
|
machine's GPU (Intel / AMD / NVIDIA) and installs the matching encoding
|
||||||
|
packages (Intel QSV/VA-API, AMD VA-API) so clients that need a transcode
|
||||||
|
don't hit "fatal playback error". NVIDIA NVENC needs only the driver you
|
||||||
|
already have; if no NVIDIA driver is loaded the installer says so rather
|
||||||
|
than guessing which kernel driver to install.
|
||||||
|
- **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
|
- **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
|
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
|
**btrfs RAID pool** (1 / 0 / 5 / 10). The boot drive is never offered — the
|
||||||
|
|
@ -90,6 +101,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
|
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).
|
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
|
## Build from source
|
||||||
|
|
||||||
Needs Go 1.26+.
|
Needs Go 1.26+.
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,43 @@ import (
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"tuistream/internal/spindown"
|
||||||
"tuistream/internal/system"
|
"tuistream/internal/system"
|
||||||
"tuistream/internal/theme"
|
"tuistream/internal/theme"
|
||||||
"tuistream/internal/tui"
|
"tuistream/internal/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// version is stamped at build time via:
|
||||||
|
//
|
||||||
|
// -ldflags "-X main.version=v0.1.1"
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
readOnly := flag.Bool("read-only", false,
|
readOnly := flag.Bool("read-only", false,
|
||||||
"open the TUI without checking for root; only the inventory views work")
|
"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()
|
flag.Parse()
|
||||||
|
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Println("tuistream", version)
|
||||||
|
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 {
|
if !*readOnly && os.Geteuid() != 0 {
|
||||||
fmt.Fprintln(os.Stderr,
|
fmt.Fprintln(os.Stderr,
|
||||||
"tuistream needs administrator rights to install Jellyfin, edit /etc/fstab, etc.")
|
"tuistream needs administrator rights to install Jellyfin, edit /etc/fstab, etc.")
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,13 @@ case "$FS" in
|
||||||
esac
|
esac
|
||||||
LINE="UUID=${UUID} ${MP} ${FS} ${OPTS} 0 2"
|
LINE="UUID=${UUID} ${MP} ${FS} ${OPTS} 0 2"
|
||||||
|
|
||||||
grep -qF -- "$LINE" /etc/fstab || printf '%%s\n' "$LINE" >> /etc/fstab
|
# Drop any stale entries for this mount point first (e.g. the UUID of a drive
|
||||||
|
# that has since been reformatted). Duplicate mount points make
|
||||||
|
# systemd-fstab-generator keep only the FIRST line — if that one is stale the
|
||||||
|
# drive never mounts at boot.
|
||||||
|
awk -v mp="$MP" '$0 ~ /^[[:space:]]*#/ || $2 != mp' /etc/fstab > /etc/fstab.tuistream \
|
||||||
|
&& cat /etc/fstab.tuistream > /etc/fstab && rm -f /etc/fstab.tuistream
|
||||||
|
printf '%%s\n' "$LINE" >> /etc/fstab
|
||||||
echo "fstab entry: $LINE"
|
echo "fstab entry: $LINE"
|
||||||
`,
|
`,
|
||||||
shellQuote(target),
|
shellQuote(target),
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,13 @@ fi
|
||||||
OPTS="defaults,nofail,x-gvfs-show,x-gvfs-name=$LABEL"
|
OPTS="defaults,nofail,x-gvfs-show,x-gvfs-name=$LABEL"
|
||||||
LINE="UUID=${UUID} ${MP} btrfs ${OPTS} 0 0"
|
LINE="UUID=${UUID} ${MP} btrfs ${OPTS} 0 0"
|
||||||
|
|
||||||
grep -qF -- "$LINE" /etc/fstab || printf '%%s\n' "$LINE" >> /etc/fstab
|
# Drop any stale entries for this mount point first (e.g. the UUID of a pool
|
||||||
|
# that has since been recreated). Duplicate mount points make
|
||||||
|
# systemd-fstab-generator keep only the FIRST line — if that one is stale the
|
||||||
|
# pool never mounts at boot and shows up as detached again every reboot.
|
||||||
|
awk -v mp="$MP" '$0 ~ /^[[:space:]]*#/ || $2 != mp' /etc/fstab > /etc/fstab.tuistream \
|
||||||
|
&& cat /etc/fstab.tuistream > /etc/fstab && rm -f /etc/fstab.tuistream
|
||||||
|
printf '%%s\n' "$LINE" >> /etc/fstab
|
||||||
echo "fstab: $LINE"
|
echo "fstab: $LINE"
|
||||||
`,
|
`,
|
||||||
shellQuote(devs[0]),
|
shellQuote(devs[0]),
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,13 @@ fi
|
||||||
OPTS="defaults,nofail,x-gvfs-show,x-gvfs-name=$LABEL"
|
OPTS="defaults,nofail,x-gvfs-show,x-gvfs-name=$LABEL"
|
||||||
LINE="UUID=${UUID} ${MP} btrfs ${OPTS} 0 0"
|
LINE="UUID=${UUID} ${MP} btrfs ${OPTS} 0 0"
|
||||||
|
|
||||||
grep -qF -- "$LINE" /etc/fstab || printf '%%s\n' "$LINE" >> /etc/fstab
|
# Drop any stale entries for this mount point first (e.g. the UUID of a pool
|
||||||
|
# that has since been recreated). Duplicate mount points make
|
||||||
|
# systemd-fstab-generator keep only the FIRST line — if that one is stale the
|
||||||
|
# pool never mounts at boot and shows up as detached again every reboot.
|
||||||
|
awk -v mp="$MP" '$0 ~ /^[[:space:]]*#/ || $2 != mp' /etc/fstab > /etc/fstab.tuistream \
|
||||||
|
&& cat /etc/fstab.tuistream > /etc/fstab && rm -f /etc/fstab.tuistream
|
||||||
|
printf '%%s\n' "$LINE" >> /etc/fstab
|
||||||
echo "fstab: $LINE"
|
echo "fstab: $LINE"
|
||||||
`,
|
`,
|
||||||
shellQuote(dev),
|
shellQuote(dev),
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ type DiskHealth struct {
|
||||||
Device string // /dev/sda
|
Device string // /dev/sda
|
||||||
Model string
|
Model string
|
||||||
Transport string // sata / usb / nvme
|
Transport string // sata / usb / nvme
|
||||||
|
Standby bool // drive was in standby/sleep; we deliberately didn't wake it
|
||||||
SmartReady bool
|
SmartReady bool
|
||||||
Passed bool
|
Passed bool
|
||||||
TempC int
|
TempC int
|
||||||
|
|
@ -222,7 +223,11 @@ func takeDisk(t DiskTarget) DiskHealth {
|
||||||
d.Note = "smartmontools not installed"
|
d.Note = "smartmontools not installed"
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
args := []string{"-j", "-H", "-A", "-i", t.Device}
|
// -n standby: if the drive has spun down, do NOT wake it just to read
|
||||||
|
// SMART. Without this, every poll spins the drive back up, so it never
|
||||||
|
// reaches standby and runs (hot) 24/7. A standby drive is reported as
|
||||||
|
// such instead — which is itself a healthy sign.
|
||||||
|
args := []string{"-j", "-n", "standby", "-H", "-A", "-i", t.Device}
|
||||||
// USB bridges often need a device-type hint; without it smartctl
|
// USB bridges often need a device-type hint; without it smartctl
|
||||||
// bails. Try sat for USB SATA bridges.
|
// bails. Try sat for USB SATA bridges.
|
||||||
if t.Transport == "usb" {
|
if t.Transport == "usb" {
|
||||||
|
|
@ -242,6 +247,17 @@ func takeDisk(t DiskTarget) DiskHealth {
|
||||||
d.Note = "couldn't parse smartctl JSON"
|
d.Note = "couldn't parse smartctl JSON"
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
// `-n standby` reports a sleeping drive via a message like
|
||||||
|
// "Device is in STANDBY mode, exit(2)". Surface that as its own state
|
||||||
|
// rather than "SMART not available".
|
||||||
|
for _, m := range parsed.Smartctl.Messages {
|
||||||
|
up := strings.ToUpper(m.String)
|
||||||
|
if strings.Contains(up, "STANDBY") || strings.Contains(up, "SLEEP") {
|
||||||
|
d.Standby = true
|
||||||
|
d.Note = "in standby — not woken for SMART"
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
if parsed.ModelName != "" && d.Model == "" {
|
if parsed.ModelName != "" && d.Model == "" {
|
||||||
d.Model = parsed.ModelName
|
d.Model = parsed.ModelName
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"tuistream/internal/step"
|
"tuistream/internal/step"
|
||||||
|
"tuistream/internal/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InstallPlan builds the ordered list of commands needed to install Jellyfin
|
// InstallPlan builds the ordered list of commands needed to install Jellyfin
|
||||||
|
|
@ -14,7 +15,7 @@ import (
|
||||||
// This is a *plan*, not an execution. The TUI runs it step by step via
|
// This is a *plan*, not an execution. The TUI runs it step by step via
|
||||||
// tea.ExecProcess so pacman can prompt interactively if needed.
|
// tea.ExecProcess so pacman can prompt interactively if needed.
|
||||||
func InstallPlan() []step.Step {
|
func InstallPlan() []step.Step {
|
||||||
return []step.Step{
|
steps := []step.Step{
|
||||||
{
|
{
|
||||||
Title: "Refresh package databases",
|
Title: "Refresh package databases",
|
||||||
Cmd: exec.Command("pacman", "-Sy", "--noconfirm"),
|
Cmd: exec.Command("pacman", "-Sy", "--noconfirm"),
|
||||||
|
|
@ -24,11 +25,53 @@ func InstallPlan() []step.Step {
|
||||||
Cmd: exec.Command("pacman", "-S", "--needed", "--noconfirm",
|
Cmd: exec.Command("pacman", "-S", "--needed", "--noconfirm",
|
||||||
"jellyfin-server", "jellyfin-web", "jellyfin-ffmpeg"),
|
"jellyfin-server", "jellyfin-web", "jellyfin-ffmpeg"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Title: "Enable and start jellyfin.service",
|
|
||||||
Cmd: exec.Command("systemctl", "enable", "--now", "jellyfin.service"),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
steps = append(steps, gpuEncodingSteps()...)
|
||||||
|
return append(steps, step.Step{
|
||||||
|
Title: "Enable and start jellyfin.service",
|
||||||
|
Cmd: exec.Command("systemctl", "enable", "--now", "jellyfin.service"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpuEncodingSteps maps the host's GPU vendor(s) to the userspace packages
|
||||||
|
// jellyfin-ffmpeg needs before hardware transcoding works on that vendor.
|
||||||
|
// Without them Jellyfin installs and direct-plays fine, but the moment a
|
||||||
|
// client needs a transcode every attempt dies at hw-init with the opaque
|
||||||
|
// "FFmpeg exited with code 251" / fatal-playback-error combo.
|
||||||
|
//
|
||||||
|
// NVIDIA is deliberately conservative: NVENC needs only the driver's own
|
||||||
|
// userspace (nvidia-utils), so with a loaded driver there is nothing to
|
||||||
|
// add, and without one we won't auto-install a kernel driver from here —
|
||||||
|
// picking nvidia vs nvidia-open vs -dkms per kernel flavour is a job for
|
||||||
|
// the distro/user, and getting it wrong can break the box's boot.
|
||||||
|
func gpuEncodingSteps() []step.Step {
|
||||||
|
var steps []step.Step
|
||||||
|
for _, v := range system.DetectGPUs() {
|
||||||
|
switch v {
|
||||||
|
case system.VendorIntel:
|
||||||
|
steps = append(steps, step.Step{
|
||||||
|
Title: "Install Intel QSV/VA-API encoding packages",
|
||||||
|
Cmd: exec.Command("pacman", "-S", "--needed", "--noconfirm",
|
||||||
|
"intel-media-driver", "vpl-gpu-rt", "intel-compute-runtime"),
|
||||||
|
})
|
||||||
|
case system.VendorAMD:
|
||||||
|
steps = append(steps, step.Step{
|
||||||
|
Title: "Install AMD VA-API encoding packages",
|
||||||
|
Cmd: exec.Command("pacman", "-S", "--needed", "--noconfirm",
|
||||||
|
"mesa", "libva-mesa-driver"),
|
||||||
|
})
|
||||||
|
case system.VendorNVIDIA:
|
||||||
|
if system.NvidiaDriverLoaded() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
steps = append(steps, step.Step{
|
||||||
|
Title: "NVIDIA GPU found but no driver loaded — skipping NVENC setup",
|
||||||
|
Cmd: exec.Command("bash", "-lc",
|
||||||
|
`echo "Install the NVIDIA driver for your kernel (nvidia / nvidia-open / nvidia-lts) and reboot to enable NVENC transcoding."`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return steps
|
||||||
}
|
}
|
||||||
|
|
||||||
// UninstallPlan reverses an install. Pass `purgeData` to also delete
|
// UninstallPlan reverses an install. Pass `purgeData` to also delete
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,13 @@ install -d %[2]s %[3]s`,
|
||||||
{
|
{
|
||||||
Title: "Add bind mounts to /etc/fstab",
|
Title: "Add bind mounts to /etc/fstab",
|
||||||
Cmd: bashAsRoot(fmt.Sprintf(`
|
Cmd: bashAsRoot(fmt.Sprintf(`
|
||||||
grep -qsF ' %[2]s ' /etc/fstab || printf '%%s %%s none bind,x-systemd.requires-mounts-for=%[5]s 0 0\n' %[1]s %[2]s >> /etc/fstab
|
# Drop any stale bind entries for these targets first (e.g. pointing at a
|
||||||
grep -qsF ' %[4]s ' /etc/fstab || printf '%%s %%s none bind,x-systemd.requires-mounts-for=%[5]s 0 0\n' %[3]s %[4]s >> /etc/fstab`,
|
# previous media drive) — duplicate mount points make systemd-fstab-generator
|
||||||
|
# keep only the FIRST line, so a stale one would win at boot.
|
||||||
|
awk -v a=%[2]s -v b=%[4]s '$0 ~ /^[[:space:]]*#/ || ($2 != a && $2 != b)' /etc/fstab > /etc/fstab.tuistream \
|
||||||
|
&& cat /etc/fstab.tuistream > /etc/fstab && rm -f /etc/fstab.tuistream
|
||||||
|
printf '%%s %%s none bind,x-systemd.requires-mounts-for=%[5]s 0 0\n' %[1]s %[2]s >> /etc/fstab
|
||||||
|
printf '%%s %%s none bind,x-systemd.requires-mounts-for=%[5]s 0 0\n' %[3]s %[4]s >> /etc/fstab`,
|
||||||
shellQuote(data), defaultDataDir,
|
shellQuote(data), defaultDataDir,
|
||||||
shellQuote(cache), defaultCacheDir,
|
shellQuote(cache), defaultCacheDir,
|
||||||
mp)),
|
mp)),
|
||||||
|
|
|
||||||
276
internal/spindown/spindown.go
Normal file
276
internal/spindown/spindown.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ var Required = []Dep{
|
||||||
{Pkg: "gptfdisk", Binary: "sgdisk"}, // Setup > wipe-whole-disk
|
{Pkg: "gptfdisk", Binary: "sgdisk"}, // Setup > wipe-whole-disk
|
||||||
{Pkg: "parted", Binary: "partprobe"}, // Setup > partprobe after wipe
|
{Pkg: "parted", Binary: "partprobe"}, // Setup > partprobe after wipe
|
||||||
{Pkg: "smartmontools", Binary: "smartctl"}, // Monitor > SMART health per disk
|
{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.
|
// Missing returns the subset of Required whose binary isn't on $PATH.
|
||||||
|
|
|
||||||
74
internal/system/gpu.go
Normal file
74
internal/system/gpu.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package system
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GPU vendor detection for hardware-accelerated transcoding. We walk
|
||||||
|
// /sys/bus/pci/devices directly instead of shelling out to lspci so the
|
||||||
|
// pre-flight needs no extra package and works before any GPU driver is
|
||||||
|
// loaded (an unbound card still has its PCI class/vendor files).
|
||||||
|
|
||||||
|
type GPUVendor string
|
||||||
|
|
||||||
|
const (
|
||||||
|
VendorIntel GPUVendor = "intel"
|
||||||
|
VendorAMD GPUVendor = "amd"
|
||||||
|
VendorNVIDIA GPUVendor = "nvidia"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DetectGPUs returns the distinct GPU vendors present on the host, in a
|
||||||
|
// stable intel/amd/nvidia order. Vendors jellyfin-ffmpeg has no encoder
|
||||||
|
// for are ignored.
|
||||||
|
func DetectGPUs() []GPUVendor {
|
||||||
|
return detectGPUs("/sys/bus/pci/devices")
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectGPUs(sysfsRoot string) []GPUVendor {
|
||||||
|
entries, err := os.ReadDir(sysfsRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[GPUVendor]bool{}
|
||||||
|
for _, e := range entries {
|
||||||
|
dir := filepath.Join(sysfsRoot, e.Name())
|
||||||
|
// PCI class 0x03xxxx = display controller (VGA, 3D, other).
|
||||||
|
class := readSysfsHex(filepath.Join(dir, "class"))
|
||||||
|
if !strings.HasPrefix(class, "0x03") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch readSysfsHex(filepath.Join(dir, "vendor")) {
|
||||||
|
case "0x8086":
|
||||||
|
seen[VendorIntel] = true
|
||||||
|
case "0x1002":
|
||||||
|
seen[VendorAMD] = true
|
||||||
|
case "0x10de":
|
||||||
|
seen[VendorNVIDIA] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []GPUVendor
|
||||||
|
for _, v := range []GPUVendor{VendorIntel, VendorAMD, VendorNVIDIA} {
|
||||||
|
if seen[v] {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// NvidiaDriverLoaded reports whether the proprietary/open NVIDIA kernel
|
||||||
|
// driver is active. When it is, its userspace (nvidia-utils) is already
|
||||||
|
// installed as a dependency, which is all NVENC needs — no extra packages.
|
||||||
|
func NvidiaDriverLoaded() bool {
|
||||||
|
_, err := os.Stat("/proc/driver/nvidia")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSysfsHex(path string) string {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
90
internal/system/gpu_test.go
Normal file
90
internal/system/gpu_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
package system
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakePCIDev writes a minimal sysfs PCI device dir with class + vendor.
|
||||||
|
func fakePCIDev(t *testing.T, root, addr, class, vendor string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(root, addr)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, val := range map[string]string{"class": class, "vendor": vendor} {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(val+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectGPUs(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
devs [][3]string // addr, class, vendor
|
||||||
|
want []GPUVendor
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "intel igpu only (zee)",
|
||||||
|
devs: [][3]string{
|
||||||
|
{"0000:00:02.0", "0x030000", "0x8086"},
|
||||||
|
{"0000:00:1f.3", "0x040300", "0x8086"}, // audio, same vendor — ignored
|
||||||
|
},
|
||||||
|
want: []GPUVendor{VendorIntel},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "amd discrete",
|
||||||
|
devs: [][3]string{{"0000:03:00.0", "0x030000", "0x1002"}},
|
||||||
|
want: []GPUVendor{VendorAMD},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nvidia 3d controller class",
|
||||||
|
devs: [][3]string{{"0000:01:00.0", "0x030200", "0x10de"}},
|
||||||
|
want: []GPUVendor{VendorNVIDIA},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hybrid intel+nvidia, stable order",
|
||||||
|
devs: [][3]string{
|
||||||
|
{"0000:01:00.0", "0x030000", "0x10de"},
|
||||||
|
{"0000:00:02.0", "0x030000", "0x8086"},
|
||||||
|
},
|
||||||
|
want: []GPUVendor{VendorIntel, VendorNVIDIA},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no display devices",
|
||||||
|
devs: [][3]string{{"0000:00:14.0", "0x0c0330", "0x8086"}},
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for _, d := range tc.devs {
|
||||||
|
fakePCIDev(t, root, d[0], d[1], d[2])
|
||||||
|
}
|
||||||
|
got := detectGPUs(root)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tc.want[i] {
|
||||||
|
t.Fatalf("got %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectGPUsMissingRoot(t *testing.T) {
|
||||||
|
if got := detectGPUs("/nonexistent/sysfs"); got != nil {
|
||||||
|
t.Fatalf("expected nil for missing root, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectGPUsRealHost never fails — it just logs what the real host
|
||||||
|
// reports so `go test -v` doubles as a quick manual check.
|
||||||
|
func TestDetectGPUsRealHost(t *testing.T) {
|
||||||
|
t.Logf("real host GPUs: %v (nvidia driver loaded: %v)", DetectGPUs(), NvidiaDriverLoaded())
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
@ -15,6 +16,7 @@ import (
|
||||||
"tuistream/internal/drives"
|
"tuistream/internal/drives"
|
||||||
"tuistream/internal/firewall"
|
"tuistream/internal/firewall"
|
||||||
"tuistream/internal/jellyfin"
|
"tuistream/internal/jellyfin"
|
||||||
|
"tuistream/internal/spindown"
|
||||||
"tuistream/internal/theme"
|
"tuistream/internal/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -67,6 +69,10 @@ type Model struct {
|
||||||
runStage runStage
|
runStage runStage
|
||||||
runOwnerTab tab
|
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 ticked while a step is running in the background.
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
|
|
||||||
|
|
@ -93,14 +99,14 @@ func NewModel(t theme.Theme) Model {
|
||||||
|
|
||||||
// Init is the Bubble Tea entry point. We kick off the first inventory load,
|
// Init is the Bubble Tea entry point. We kick off the first inventory load,
|
||||||
// Jellyfin status check, and firewall snapshot asynchronously so the UI
|
// Jellyfin status check, and firewall snapshot asynchronously so the UI
|
||||||
// paints immediately. The monitor tick fires on a 5s interval to refresh
|
// paints immediately. The monitor tick fires on a 5s interval but only
|
||||||
// the Monitor tab's health snapshot.
|
// probes while the Monitor tab is visible (see monitorTickMsg).
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
return tea.Batch(
|
return tea.Batch(
|
||||||
loadInventoryCmd(m.username),
|
loadInventoryCmd(m.username),
|
||||||
loadStatusCmd(),
|
loadStatusCmd(),
|
||||||
loadFirewallCmd(),
|
loadFirewallCmd(),
|
||||||
loadHealthCmd(m.inventory),
|
loadHealthCmd(m.inventory, false),
|
||||||
monitorTickCmd(),
|
monitorTickCmd(),
|
||||||
cpuSampleCmd(nil), // seed the previous-sample slot
|
cpuSampleCmd(nil), // seed the previous-sample slot
|
||||||
cpuTickCmd(),
|
cpuTickCmd(),
|
||||||
|
|
@ -108,6 +114,47 @@ 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.
|
||||||
|
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 }
|
type firewallLoadedMsg struct{ state firewall.State }
|
||||||
|
|
||||||
func loadFirewallCmd() tea.Cmd {
|
func loadFirewallCmd() tea.Cmd {
|
||||||
|
|
@ -139,6 +186,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
case inventoryLoadedMsg:
|
case inventoryLoadedMsg:
|
||||||
m.inventory = msg.inv
|
m.inventory = msg.inv
|
||||||
m.inventoryErr = msg.err
|
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
|
return m, nil
|
||||||
|
|
||||||
case statusLoadedMsg:
|
case statusLoadedMsg:
|
||||||
|
|
@ -151,13 +214,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case healthLoadedMsg:
|
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.snap = msg.snap
|
||||||
m.monitor.err = msg.err
|
m.monitor.err = msg.err
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case monitorTickMsg:
|
case monitorTickMsg:
|
||||||
// Refresh on tick. Always re-arm so the next tick fires.
|
// Always re-arm so the tick keeps firing, but only probe while the
|
||||||
return m, tea.Batch(loadHealthCmd(m.inventory), monitorTickCmd())
|
// 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:
|
case cpuTickMsg:
|
||||||
return m, tea.Batch(cpuSampleCmd(m.monitor.cpuPrev), cpuTickCmd())
|
return m, tea.Batch(cpuSampleCmd(m.monitor.cpuPrev), cpuTickCmd())
|
||||||
|
|
@ -229,13 +308,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case "tab", "right":
|
case "tab", "right":
|
||||||
m.currentTab = (m.currentTab + 1) % tabCount
|
m.currentTab = (m.currentTab + 1) % tabCount
|
||||||
return m, nil
|
return m, m.enteredMonitorCmd()
|
||||||
case "shift+tab", "left":
|
case "shift+tab", "left":
|
||||||
m.currentTab = (m.currentTab + tabCount - 1) % tabCount
|
m.currentTab = (m.currentTab + tabCount - 1) % tabCount
|
||||||
return m, nil
|
return m, m.enteredMonitorCmd()
|
||||||
case "r":
|
case "r":
|
||||||
m.flash = "Refreshing…"
|
m.flash = "Refreshing…"
|
||||||
return m, tea.Batch(loadInventoryCmd(m.username), loadStatusCmd(), loadFirewallCmd())
|
return m, tea.Batch(loadInventoryCmd(m.username), loadStatusCmd(),
|
||||||
|
loadFirewallCmd(), m.enteredMonitorCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle Setup-tab keys ('i' install, 'u' uninstall, 'a' add drive, 'f' firewall)
|
// Idle Setup-tab keys ('i' install, 'u' uninstall, 'a' add drive, 'f' firewall)
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@ import (
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"tuistream/internal/drives"
|
||||||
"tuistream/internal/firewall"
|
"tuistream/internal/firewall"
|
||||||
"tuistream/internal/jellyfin"
|
"tuistream/internal/jellyfin"
|
||||||
|
"tuistream/internal/spindown"
|
||||||
"tuistream/internal/step"
|
"tuistream/internal/step"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -27,6 +29,7 @@ const (
|
||||||
stageConfirmInstall
|
stageConfirmInstall
|
||||||
stageConfirmUninstall
|
stageConfirmUninstall
|
||||||
stageConfirmFirewall
|
stageConfirmFirewall
|
||||||
|
stageConfirmSpindown
|
||||||
stageAddDrive
|
stageAddDrive
|
||||||
stageImportPool // import an existing detached btrfs pool (see importPoolState)
|
stageImportPool // import an existing detached btrfs pool (see importPoolState)
|
||||||
stageMoveJellyfinPick // choose which media drive to relocate onto (2+ managed)
|
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)
|
return setupConfirmUninstallKey(m, key)
|
||||||
case stageConfirmFirewall:
|
case stageConfirmFirewall:
|
||||||
return setupConfirmFirewallKey(m, key)
|
return setupConfirmFirewallKey(m, key)
|
||||||
|
case stageConfirmSpindown:
|
||||||
|
return setupConfirmSpindownKey(m, key)
|
||||||
case stageAddDrive:
|
case stageAddDrive:
|
||||||
return setupAddDriveKey(m, msg)
|
return setupAddDriveKey(m, msg)
|
||||||
case stageImportPool:
|
case stageImportPool:
|
||||||
|
|
@ -209,6 +214,21 @@ func setupIdleKey(m Model, key string) (Model, tea.Cmd, bool) {
|
||||||
m.setup.stage = stageConfirmFirewall
|
m.setup.stage = stageConfirmFirewall
|
||||||
m.setup.confirmIdx = 0
|
m.setup.confirmIdx = 0
|
||||||
return m, nil, true
|
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":
|
case "j":
|
||||||
return setupStartMoveJellyfin(m)
|
return setupStartMoveJellyfin(m)
|
||||||
case "y", "Y":
|
case "y", "Y":
|
||||||
|
|
@ -275,6 +295,54 @@ func runFirewall(m Model) (Model, tea.Cmd, bool) {
|
||||||
return m, runStepCmd(plan[0]), true
|
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
|
// setupStartMoveJellyfin gates entry into the move-storage flow and routes to
|
||||||
// either the picker (2+ media drives) or straight to confirm (exactly one).
|
// either the picker (2+ media drives) or straight to confirm (exactly one).
|
||||||
func setupStartMoveJellyfin(m Model) (Model, tea.Cmd, bool) {
|
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
|
// renderSetupActionBar shows the keyboard shortcuts at the bottom of the
|
||||||
// idle setup view. The set of keys depends on current install + firewall state.
|
// 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
|
var keys []string
|
||||||
if installed {
|
if installed {
|
||||||
keys = append(keys, "[i] reinstall")
|
keys = append(keys, "[i] reinstall")
|
||||||
|
|
@ -507,6 +575,13 @@ func renderSetupActionBar(installed, fwOpen, serviceActive, jellyfinMoved, hasDe
|
||||||
} else {
|
} else {
|
||||||
keys = append(keys, "[f] open firewall")
|
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 installed {
|
||||||
if jellyfinMoved {
|
if jellyfinMoved {
|
||||||
keys = append(keys, headerStyle.Render("[j] storage on media ✓"))
|
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"))
|
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
|
// renderStatusLine shows "Jellyfin: not installed" or "Jellyfin: installed
|
||||||
// (jellyfin-bin) · service active" at the top of the Setup tab.
|
// (jellyfin-bin) · service active" at the top of the Setup tab.
|
||||||
func renderStatusLine(s jellyfin.Status) string {
|
func renderStatusLine(s jellyfin.Status) string {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
// Monitor tab — a refreshing dashboard of the host's health.
|
// Monitor tab — a refreshing dashboard of the host's health.
|
||||||
//
|
//
|
||||||
// Drives, btrfs pools, SMART, CPU + memory, Jellyfin service. Updates
|
// Drives, btrfs pools, SMART, CPU + memory, Jellyfin service. The cheap
|
||||||
// every 5 seconds via tea.Tick. Re-uses the inventory we already track
|
// probes refresh every 5 seconds via tea.Tick — but only while this tab is
|
||||||
// for the other tabs to pick which mountpoints/disks to probe.
|
// visible — and SMART runs on its own slower cadence so polling never keeps
|
||||||
|
// the drives awake. Re-uses the inventory we already track for the other
|
||||||
|
// tabs to pick which mountpoints/disks to probe.
|
||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -25,6 +27,13 @@ type monitorModel struct {
|
||||||
snap *health.Snapshot
|
snap *health.Snapshot
|
||||||
err error
|
err error
|
||||||
|
|
||||||
|
// SMART runs on its own slow cadence (smartRefresh) because polling
|
||||||
|
// smartctl keeps drives busy — lastSmart is when we last probed,
|
||||||
|
// smartCache carries the disk rows across the cheap refreshes in
|
||||||
|
// between.
|
||||||
|
lastSmart time.Time
|
||||||
|
smartCache []health.DiskHealth
|
||||||
|
|
||||||
// CPU sampling: prev holds the last /proc/stat snapshot so the next
|
// CPU sampling: prev holds the last /proc/stat snapshot so the next
|
||||||
// tick can compute deltas. usage is the most recent % per core +
|
// tick can compute deltas. usage is the most recent % per core +
|
||||||
// aggregate. history is a per-core ring buffer used to render the
|
// aggregate. history is a per-core ring buffer used to render the
|
||||||
|
|
@ -58,12 +67,21 @@ func (m Model) WithCPU(u health.CPUUsage, history [][]float64) Model {
|
||||||
type healthLoadedMsg struct {
|
type healthLoadedMsg struct {
|
||||||
snap *health.Snapshot
|
snap *health.Snapshot
|
||||||
err error
|
err error
|
||||||
|
// withSmart records whether this snapshot included a SMART probe, so
|
||||||
|
// the Update loop knows to refresh or reuse the cached disk rows.
|
||||||
|
withSmart bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type monitorTickMsg struct{}
|
type monitorTickMsg struct{}
|
||||||
|
|
||||||
const monitorRefresh = 5 * time.Second
|
const monitorRefresh = 5 * time.Second
|
||||||
|
|
||||||
|
// smartRefresh is the SMART-probe cadence. Deliberately much slower than
|
||||||
|
// monitorRefresh: SMART data barely changes second-to-second, and hammering
|
||||||
|
// smartctl keeps drives awake (and hot). Cheap probes (statfs, /proc,
|
||||||
|
// systemd) stay on the 5s tick.
|
||||||
|
const smartRefresh = 60 * time.Second
|
||||||
|
|
||||||
func monitorTickCmd() tea.Cmd {
|
func monitorTickCmd() tea.Cmd {
|
||||||
return tea.Tick(monitorRefresh, func(time.Time) tea.Msg {
|
return tea.Tick(monitorRefresh, func(time.Time) tea.Msg {
|
||||||
return monitorTickMsg{}
|
return monitorTickMsg{}
|
||||||
|
|
@ -102,12 +120,16 @@ func cpuSampleCmd(prev []health.ProcStatLine) tea.Cmd {
|
||||||
// loadHealthCmd derives the health Inputs from the current inventory and
|
// loadHealthCmd derives the health Inputs from the current inventory and
|
||||||
// kicks off a snapshot in the background. If inventory hasn't loaded
|
// kicks off a snapshot in the background. If inventory hasn't loaded
|
||||||
// yet we still take a snapshot — system/jellyfin/cpu sections work
|
// yet we still take a snapshot — system/jellyfin/cpu sections work
|
||||||
// without it.
|
// without it. withSmart=false drops the physical disks from the probe so
|
||||||
func loadHealthCmd(inv *drives.Inventory) tea.Cmd {
|
// smartctl isn't run; the Update loop re-attaches the cached rows.
|
||||||
|
func loadHealthCmd(inv *drives.Inventory, withSmart bool) tea.Cmd {
|
||||||
in := healthInputsFrom(inv)
|
in := healthInputsFrom(inv)
|
||||||
|
if !withSmart {
|
||||||
|
in.PhysicalDisks = nil
|
||||||
|
}
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
s := health.Take(in)
|
s := health.Take(in)
|
||||||
return healthLoadedMsg{snap: &s}
|
return healthLoadedMsg{snap: &s, withSmart: withSmart}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,6 +208,7 @@ func (mn monitorModel) view(m Model) string {
|
||||||
heading := titleStyle.Render("System health")
|
heading := titleStyle.Render("System health")
|
||||||
subhead := headerStyle.Render(
|
subhead := headerStyle.Render(
|
||||||
"Refreshing every " + monitorRefresh.String() +
|
"Refreshing every " + monitorRefresh.String() +
|
||||||
|
" · SMART every " + smartRefresh.String() +
|
||||||
" · last update " + snap.Time.Format("15:04:05"))
|
" · last update " + snap.Time.Format("15:04:05"))
|
||||||
|
|
||||||
w := cardWidth(m.width)
|
w := cardWidth(m.width)
|
||||||
|
|
@ -507,6 +530,10 @@ func renderDiskCard(s *health.Snapshot) string {
|
||||||
for _, d := range s.Disks {
|
for _, d := range s.Disks {
|
||||||
var smart string
|
var smart string
|
||||||
switch {
|
switch {
|
||||||
|
case d.Standby:
|
||||||
|
// Drive is asleep and we deliberately didn't wake it — a good
|
||||||
|
// sign, not a missing reading.
|
||||||
|
smart = headerStyle.Render("asleep")
|
||||||
case !d.SmartReady:
|
case !d.SmartReady:
|
||||||
smart = roleInUseStyle.Render("n/a")
|
smart = roleInUseStyle.Render("n/a")
|
||||||
case d.Passed:
|
case d.Passed:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
|
|
||||||
"tuistream/internal/drives"
|
"tuistream/internal/drives"
|
||||||
"tuistream/internal/jellyfin"
|
"tuistream/internal/jellyfin"
|
||||||
|
"tuistream/internal/spindown"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupModel holds Setup-tab-specific state: the current sub-stage (idle,
|
// 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.
|
// the root Model so they're shared with the Manage tab.
|
||||||
type setupModel struct {
|
type setupModel struct {
|
||||||
stage setupStage
|
stage setupStage
|
||||||
confirmIdx int // 0 = Yes, 1 = No (used by confirm modals)
|
confirmIdx int // 0 = Yes, 1 = No (used by confirm modals)
|
||||||
firewallTarget bool // true = "we're about to open", false = "we're about to close"
|
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
|
// Drive spin-down flow: true = about to enable, false = about to
|
||||||
showTech bool // 'd' toggle: false = friendly drive list, true = raw lsblk table
|
// 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.
|
// Move-Jellyfin-storage flow.
|
||||||
moveChoices []drives.Drive // managed media drives to choose from
|
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)
|
return renderConfirmUninstall(m.status, s.confirmIdx)
|
||||||
case stageConfirmFirewall:
|
case stageConfirmFirewall:
|
||||||
return renderConfirmFirewall(m.firewall, m.setup.firewallTarget, s.confirmIdx)
|
return renderConfirmFirewall(m.firewall, m.setup.firewallTarget, s.confirmIdx)
|
||||||
|
case stageConfirmSpindown:
|
||||||
|
return renderConfirmSpindown(m.setup.spindownDrives, m.setup.spindownTarget, s.confirmIdx)
|
||||||
case stageAddDrive:
|
case stageAddDrive:
|
||||||
return renderAddDrive(m)
|
return renderAddDrive(m)
|
||||||
case stageImportPool:
|
case stageImportPool:
|
||||||
|
|
@ -61,7 +71,8 @@ func (s setupModel) view(m Model) string {
|
||||||
fw := renderFirewallLine(m.firewall)
|
fw := renderFirewallLine(m.firewall)
|
||||||
jellyfinMoved := jellyfin.LoadStorageState().Moved
|
jellyfinMoved := jellyfin.LoadStorageState().Moved
|
||||||
hasDetachedPool := len(m.inventory.DetachedPools()) > 0
|
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
|
var heading, subhead, inv string
|
||||||
if s.showTech {
|
if s.showTech {
|
||||||
|
|
@ -77,13 +88,17 @@ func (s setupModel) view(m Model) string {
|
||||||
hint := headerStyle.Render(techToggleHint(s.showTech))
|
hint := headerStyle.Render(techToggleHint(s.showTech))
|
||||||
|
|
||||||
w := cardWidth(m.width)
|
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,
|
return lipgloss.JoinVertical(lipgloss.Center,
|
||||||
"",
|
"",
|
||||||
centered(heading, w),
|
centered(heading, w),
|
||||||
centered(subhead, w),
|
centered(subhead, w),
|
||||||
"",
|
"",
|
||||||
centered(status, w),
|
strings.Join(headerLines, "\n"),
|
||||||
centered(fw, w),
|
|
||||||
"",
|
"",
|
||||||
inv,
|
inv,
|
||||||
centered(hint, w),
|
centered(hint, w),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue