Stop SMART polling from keeping drives awake and hot

The Monitor tick ran smartctl against every physical disk every 5
seconds, regardless of which tab was visible — and smartctl's default
behaviour is to spin up a drive in standby to read its attributes. Net
effect: drives could never spin down and ran (hot) 24/7 while the TUI
was open.

Three fixes:
- smartctl now runs with -n standby: a sleeping drive is never woken
  for a health probe. It's reported as a new Standby state and shown
  as "asleep" in the SMART table — a good sign, not a missing reading.
- Health probes only run while the Monitor tab is visible. The tick
  always re-arms, but Setup/Manage now cost zero disk activity.
  Switching to Monitor (or pressing r on it) fires an immediate
  refresh so the snapshot is never stale.
- SMART moved to its own 60s cadence. The 5s tick keeps the cheap
  probes (statfs, /proc, systemd); disk rows are cached between SMART
  probes so the table doesn't flicker. lastSmart is stamped at issue
  time so a slow USB bridge can't double up probes.

Verified on moviebox (3x 10TB spinning drives) with a counting shim
around smartctl: 0 calls while on Setup, immediate probe on entering
Monitor, then exactly one batch per 60s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-06-04 15:16:56 +01:00
parent 3786d6bae1
commit 262ba8e3b4
3 changed files with 97 additions and 15 deletions

View file

@ -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
} }

View file

@ -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"
@ -93,14 +94,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 +109,27 @@ func (m Model) Init() tea.Cmd {
) )
} }
// 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 {
@ -151,13 +173,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 +267,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)

View file

@ -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: