TUISTREAM/internal/tui/view_setup.go
28allday 959e507a83 Initial commit: TUISTREAM — headless Jellyfin TUI for Omarchy/Arch
Three-tab Bubble Tea TUI (Setup / Manage / Monitor) for setting up and
running a headless Jellyfin server over SSH:

- Setup: install/uninstall Jellyfin, add media drives (single or btrfs
  RAID pool), import an existing detached btrfs pool non-destructively,
  firewall, move Jellyfin storage onto a media drive
- Boot-drive-safe drive classifier (walks LUKS/LVM/RAID to the physical
  disk; never offers a disk hosting /, /boot or swap)
- Keep-existing-filesystem path previews on-disk content, skips starter
  folders when content exists, and grants Jellyfin recursive read access
- Manage: copy from external drives, mount/eject, rename/delete
- Monitor: per-core CPU, load/mem/uptime, btrfs error counters, SMART

Single static binary; installs to /usr/local/bin so sudo can find it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:47:18 +01:00

503 lines
15 KiB
Go

package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"tuistream/internal/drives"
"tuistream/internal/jellyfin"
)
// setupModel holds Setup-tab-specific state: the current sub-stage (idle,
// confirm modal, add-drive flow). The "running" and "done" splash live on
// 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
// Move-Jellyfin-storage flow.
moveChoices []drives.Drive // managed media drives to choose from
movePickIdx int // cursor in the picker
moveTarget drives.Drive // the chosen destination drive
}
func newSetupModel() setupModel { return setupModel{stage: stageIdle} }
func (s setupModel) view(m Model) string {
if m.inventoryErr != nil {
return centeredCard(
titleStyle.Render("Couldn't read drives") + "\n\n" +
m.inventoryErr.Error(),
)
}
if m.inventory == nil {
return centeredCard(headerStyle.Render("Loading drives…"))
}
switch s.stage {
case stageConfirmInstall:
return renderConfirmInstall(s.confirmIdx)
case stageConfirmUninstall:
return renderConfirmUninstall(m.status, s.confirmIdx)
case stageConfirmFirewall:
return renderConfirmFirewall(m.firewall, m.setup.firewallTarget, s.confirmIdx)
case stageAddDrive:
return renderAddDrive(m)
case stageImportPool:
return renderImportPool(m)
case stageMoveJellyfinPick:
return renderMoveJellyfinPick(m)
case stageConfirmMoveJellyfin:
return renderMoveJellyfinConfirm(m)
}
status := renderStatusLine(m.status)
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))
var heading, subhead, inv string
if s.showTech {
heading = titleStyle.Render("Drives detected on this system")
subhead = summaryLine(m.inventory)
inv = renderInventory(m.inventory)
} else {
body, ready, media := renderInventoryFriendly(m.inventory)
heading = titleStyle.Render("Your drives")
subhead = friendlyDriveSummary(ready, media)
inv = body
}
hint := headerStyle.Render(techToggleHint(s.showTech))
w := cardWidth(m.width)
return lipgloss.JoinVertical(lipgloss.Center,
"",
centered(heading, w),
centered(subhead, w),
"",
centered(status, w),
centered(fw, w),
"",
inv,
centered(hint, w),
"",
centered(actions, w),
)
}
// techToggleHint is the dim one-liner under the drive list pointing at the
// 'd' toggle, worded for whichever view is currently showing.
func techToggleHint(showTech bool) string {
if showTech {
return " [d] back to the simple view"
}
return " [d] show technical details"
}
// friendlyDriveSummary is the plain-English subheading under "Your drives",
// e.g. "1 drive ready to use · none set up for media yet".
func friendlyDriveSummary(ready, media int) string {
var parts []string
switch ready {
case 0:
parts = append(parts, "no spare drives ready")
case 1:
parts = append(parts, "1 drive ready to use")
default:
parts = append(parts, fmt.Sprintf("%d drives ready to use", ready))
}
switch media {
case 0:
parts = append(parts, "none set up for media yet")
case 1:
parts = append(parts, "1 set up for media")
default:
parts = append(parts, fmt.Sprintf("%d set up for media", media))
}
return headerStyle.Render(strings.Join(parts, " · "))
}
// renderInventoryFriendly draws the default, jargon-free drive list: one row
// per physical disk with a friendly name, human size, and a plain-English
// status. Virtual devices (zram / loop) are hidden entirely. Returns the
// rendered frame plus counts of "ready" and "media" disks for the subheading.
func renderInventoryFriendly(inv *drives.Inventory) (string, int, int) {
// Device paths that can be turned into a media drive (whole disks and/or
// individual partitions), so a disk reads "Ready to use" when it OR one of
// its partitions qualifies.
cand := map[string]bool{}
for _, c := range inv.Candidates() {
cand[c.Path] = true
}
var lines []string
ready, media := 0, 0
for _, d := range inv.All {
if d.Type != "disk" {
continue
}
// Swap-in-RAM, loopback and eMMC firmware areas aren't drives the user
// thinks about.
if drives.IsPseudoDisk(d.Name) {
continue
}
text, style, detail, kind := diskFriendlyStatus(inv, d, cand)
switch kind {
case "ready":
ready++
case "media":
media++
}
name := devNameStyle.Render(fmt.Sprintf("%-24s", truncate(friendlyDriveName(d), 24)))
size := valueStyle.Render(fmt.Sprintf("%-8s", prettySize(d.Size)))
lines = append(lines, " "+name+" "+size+" "+style.Render(text))
if detail != "" {
lines = append(lines, " "+headerStyle.Render(detail))
}
}
if len(lines) == 0 {
lines = append(lines, headerStyle.Render("No drives detected."))
}
return centeredFrame(strings.Join(lines, "\n")), ready, media
}
// diskFriendlyStatus summarises a physical disk into a single plain-English
// status: the colour style to render it in, a dim one-line detail (or ""),
// and a "kind" tag ("ready"/"media"/"system"/"inuse") used for counting.
// Priority: system disk → already-our-media → ready → in-use.
func diskFriendlyStatus(inv *drives.Inventory, d drives.Drive, cand map[string]bool) (text string, style lipgloss.Style, detail, kind string) {
if d.Role == drives.RoleSystem {
return "⛌ System drive (protected)", roleSystemStyle, "Holds the operating system — left alone", "system"
}
children := inv.ChildrenOf(d.Name)
// Already set up by us as media (whole-disk or one of its partitions).
if d.Role == drives.RoleManagedOurs {
return "★ TUISTREAM media", roleAvailableStyle, "Mounted at " + d.MountPoint, "media"
}
for _, c := range children {
if c.Role == drives.RoleManagedOurs {
return "★ TUISTREAM media", roleAvailableStyle, "Mounted at " + c.MountPoint, "media"
}
}
// Spare and pickable. Both a whole disk and its partition can qualify; pick
// the most informative candidate (a labelled / formatted partition beats the
// bare disk) so the detail line says something useful.
var best *drives.Drive
consider := func(c drives.Drive) {
if !cand[c.Path] {
return
}
if best == nil || readyScore(c) > readyScore(*best) {
cc := c
best = &cc
}
}
consider(d)
for _, c := range children {
consider(c)
}
if best != nil {
return "✓ Ready to use", roleAvailableStyle, readyDetail(*best, d), "ready"
}
// Everything else is occupied — keep it short and reassuring.
if d.Role == drives.RolePoolMember {
return "• In use (storage pool)", roleInUseStyle, "", "inuse"
}
for _, c := range children {
if c.Role == drives.RolePoolMember {
return "• In use (storage pool)", roleInUseStyle, "", "inuse"
}
}
if mp := firstMount(d, children); mp != "" {
return "• In use", roleInUseStyle, "Mounted at " + mp, "inuse"
}
return "• In use", roleInUseStyle, "", "inuse"
}
// readyScore ranks ready candidates so the friendly view describes the most
// meaningful one: a labelled partition outranks a formatted-but-unlabelled one,
// which outranks a bare/blank device.
func readyScore(d drives.Drive) int {
s := 0
if d.FSType != "" {
s++
}
if d.Label != "" {
s += 2
}
return s
}
// readyDetail builds the dim hint under a "Ready to use" row: how it's
// connected, plus its label (if any) or a note that a blank disk gets
// formatted on add. `disk` supplies the transport, which lsblk only reports
// on the top-level device, not on partitions.
func readyDetail(target, disk drives.Drive) string {
bits := []string{plainTransport(disk.Transport)}
switch {
case target.Label != "":
bits = append(bits, "labelled “"+target.Label+"”")
case target.FSType == "":
bits = append(bits, "blank — formatted when you add it")
}
return strings.Join(bits, " · ")
}
// firstMount returns the first mountpoint found on the disk or its children.
func firstMount(d drives.Drive, children []drives.Drive) string {
if d.MountPoint != "" {
return d.MountPoint
}
for _, c := range children {
if c.MountPoint != "" {
return c.MountPoint
}
}
return ""
}
// friendlyDriveName is the human label for a disk: its model if lsblk knows
// it, otherwise the bare kernel name (e.g. "sda").
func friendlyDriveName(d drives.Drive) string {
if d.Model != "" {
return d.Model
}
return d.Name
}
// prettySize expands lsblk's compact size ("3.6T", "4G") into "3.6 TB" /
// "4 GB". Anything unexpected passes through unchanged.
func prettySize(s string) string {
if s == "" {
return "—"
}
switch s[len(s)-1] {
case 'K', 'M', 'G', 'T', 'P', 'E':
return s[:len(s)-1] + " " + s[len(s)-1:] + "B"
}
return s
}
// plainFS normalises the filesystem names users are most likely to be
// confused by (vfat → FAT32, ntfs3 → NTFS) and leaves the rest as-is, since
// ext4 / btrfs / xfs are recognisable to anyone setting up a media server.
func plainFS(fs string) string {
switch strings.ToLower(fs) {
case "vfat", "fat", "fat32":
return "FAT32"
case "exfat":
return "exFAT"
case "ntfs", "ntfs3":
return "NTFS"
case "":
return "unformatted"
default:
return fs
}
}
// plainTransport renders a disk's bus in friendly words.
func plainTransport(tran string) string {
switch strings.ToLower(tran) {
case "usb":
return "USB"
case "nvme":
return "NVMe SSD"
case "sata", "ata":
return "SATA"
case "":
return "internal"
default:
return strings.ToUpper(tran)
}
}
// renderMoveJellyfinPick lets the user choose which managed media drive to
// relocate Jellyfin's storage onto, when there's more than one.
func renderMoveJellyfinPick(m Model) string {
st := &m.setup
var rows []string
rows = append(rows, titleStyle.Render("Move Jellyfin storage — pick a drive"))
rows = append(rows, "")
rows = append(rows, headerStyle.Render("Jellyfin's library DB, metadata and artwork will move onto this drive."))
rows = append(rows, "")
for i, d := range st.moveChoices {
name := d.Label
if name == "" {
name = d.MountPoint
}
body := fmt.Sprintf("%-20s %-8s ", truncate(name, 20), prettySize(d.Size)) +
headerStyle.Render("→ "+d.MountPoint)
rows = append(rows, markerLine(i == st.movePickIdx, body))
}
rows = append(rows, "")
rows = append(rows, footerStyle.Render("↑/↓ move · enter pick · esc cancel"))
return centeredCard(strings.Join(rows, "\n"))
}
// renderMoveJellyfinConfirm is the last-chance screen before migrating.
func renderMoveJellyfinConfirm(m Model) string {
st := &m.setup
target := st.moveTarget.MountPoint + "/JellyfinData"
var rows []string
rows = append(rows, titleStyle.Render("Move Jellyfin storage to the media drive?"))
rows = append(rows, "")
rows = append(rows, "Frees space on your OS drive by relocating Jellyfin's library")
rows = append(rows, "database, downloaded metadata and artwork onto the media drive.")
rows = append(rows, "")
rows = append(rows, "Data: "+devNameStyle.Render(target+"/data")+
headerStyle.Render(" (library DB, metadata, artwork)"))
rows = append(rows, "Cache: "+devNameStyle.Render(target+"/cache")+
headerStyle.Render(" (transcodes/thumbnails regenerate)"))
rows = append(rows, "")
rows = append(rows, headerStyle.Render("Jellyfin keeps using /var/lib/jellyfin, but that path is bind-mounted"))
rows = append(rows, headerStyle.Render("to the media drive via fstab. The service is stopped, data copied,"))
rows = append(rows, headerStyle.Render("the OS-drive copy reclaimed, then Jellyfin restarts. systemd mounts"))
rows = append(rows, headerStyle.Render("the drive before Jellyfin starts, so ordering is automatic."))
rows = append(rows, "")
rows = append(rows, roleSystemStyle.Render("⚠ The media drive must stay attached — if it's ever missing,"))
rows = append(rows, roleSystemStyle.Render(" Jellyfin won't start until it's back."))
rows = append(rows, "")
yes := " Yes, move it "
no := " Cancel "
if st.confirmIdx == 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"))
}
// renderInventory builds the table-style overview of every disk and partition
// with its role coloured according to whether it's available, off-limits, etc.
func renderInventory(inv *drives.Inventory) string {
header := headerStyle.Render(fmt.Sprintf(
" %-20s %-7s %-12s %-14s %s",
"Device", "Size", "Filesystem", "Label", "Used for",
))
var rows []string
rows = append(rows, header)
for i, d := range inv.All {
switch d.Type {
case "disk":
label := fmt.Sprintf("▸ %s %s %s",
devNameStyle.Render(d.Path),
d.Size,
diskDescriptor(d),
)
if d.Role == drives.RoleSystem {
label += " " + roleSystemStyle.Render("[SYSTEM DISK — off-limits]")
}
if i > 0 {
rows = append(rows, "")
}
rows = append(rows, label)
// When the disk has a filesystem directly on it (no partition
// table), show that on a sub-line — otherwise the user has no
// indication that mkfs / mount actually landed.
if d.FSType != "" && d.Role != drives.RoleSystem {
rows = append(rows, renderWholeDiskFSRow(d))
}
case "part", "crypt":
rows = append(rows, renderPartitionRow(d))
}
}
return centeredFrame(strings.Join(rows, "\n"))
}
func renderWholeDiskFSRow(d drives.Drive) string {
fs := dashIfEmpty(d.FSType)
label := dashIfEmpty(d.Label)
used := d.RoleDetail
switch d.Role {
case drives.RoleAvailable:
used = roleAvailableStyle.Render("✓ AVAILABLE (formatted but unmounted)")
case drives.RoleManagedOurs:
used = roleAvailableStyle.Render(used)
case drives.RoleMounted, drives.RolePoolMember:
used = roleInUseStyle.Render(used)
}
return fmt.Sprintf(
" └─ %-17s %-7s %-12s %-14s %s",
"(whole-disk fs)",
d.Size,
truncate(fs, 12),
truncate(label, 14),
used,
)
}
func renderPartitionRow(d drives.Drive) string {
fs := dashIfEmpty(d.FSType)
label := dashIfEmpty(d.Label)
used := d.RoleDetail
switch d.Role {
case drives.RoleAvailable:
used = roleAvailableStyle.Render("✓ AVAILABLE")
case drives.RoleSystem:
used = roleSystemStyle.Render(used)
case drives.RoleManagedOurs:
used = roleAvailableStyle.Render(used)
case drives.RoleMounted, drives.RoleLUKS, drives.RoleLVM, drives.RoleRAID, drives.RoleSwap, drives.RolePoolMember:
used = roleInUseStyle.Render(used)
}
return fmt.Sprintf(
" └─ %-17s %-7s %-12s %-14s %s",
d.Path,
d.Size,
truncate(fs, 12),
truncate(label, 14),
used,
)
}
func diskDescriptor(d drives.Drive) string {
model := d.Model
if model == "" {
model = "(unknown)"
}
tran := d.Transport
if tran == "" {
tran = "?"
}
return fmt.Sprintf("%s [%s]", model, tran)
}
func dashIfEmpty(s string) string {
if s == "" {
return "—"
}
return s
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
if n <= 1 {
return s[:n]
}
return s[:n-1] + "…"
}