From 262ba8e3b4a0951918ff9784d9f59668d1d71ceb Mon Sep 17 00:00:00 2001 From: 28allday Date: Thu, 4 Jun 2026 15:16:56 +0100 Subject: [PATCH] Stop SMART polling from keeping drives awake and hot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/health/health.go | 18 +++++++++++- internal/tui/model.go | 55 ++++++++++++++++++++++++++++++------ internal/tui/view_monitor.go | 39 +++++++++++++++++++++---- 3 files changed, 97 insertions(+), 15 deletions(-) diff --git a/internal/health/health.go b/internal/health/health.go index c682b79..492b3b2 100644 --- a/internal/health/health.go +++ b/internal/health/health.go @@ -67,6 +67,7 @@ type DiskHealth struct { Device string // /dev/sda Model string Transport string // sata / usb / nvme + Standby bool // drive was in standby/sleep; we deliberately didn't wake it SmartReady bool Passed bool TempC int @@ -222,7 +223,11 @@ func takeDisk(t DiskTarget) DiskHealth { d.Note = "smartmontools not installed" 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 // bails. Try sat for USB SATA bridges. if t.Transport == "usb" { @@ -242,6 +247,17 @@ func takeDisk(t DiskTarget) DiskHealth { d.Note = "couldn't parse smartctl JSON" 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 == "" { d.Model = parsed.ModelName } diff --git a/internal/tui/model.go b/internal/tui/model.go index 1230556..70a2a42 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -7,6 +7,7 @@ import ( "os" "os/user" "strings" + "time" "github.com/charmbracelet/bubbles/spinner" 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, // Jellyfin status check, and firewall snapshot asynchronously so the UI -// paints immediately. The monitor tick fires on a 5s interval to refresh -// the Monitor tab's health snapshot. +// paints immediately. The monitor tick fires on a 5s interval but only +// probes while the Monitor tab is visible (see monitorTickMsg). func (m Model) Init() tea.Cmd { return tea.Batch( loadInventoryCmd(m.username), loadStatusCmd(), loadFirewallCmd(), - loadHealthCmd(m.inventory), + loadHealthCmd(m.inventory, false), monitorTickCmd(), cpuSampleCmd(nil), // seed the previous-sample slot 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 } func loadFirewallCmd() tea.Cmd { @@ -151,13 +173,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case healthLoadedMsg: + if msg.snap != nil { + if msg.withSmart { + // Fresh SMART rows — update the cache. + m.monitor.smartCache = msg.snap.Disks + } else { + // Cheap refresh — carry the cached SMART rows forward so + // the drive table doesn't flicker empty between probes. + msg.snap.Disks = m.monitor.smartCache + } + } m.monitor.snap = msg.snap m.monitor.err = msg.err return m, nil case monitorTickMsg: - // Refresh on tick. Always re-arm so the next tick fires. - return m, tea.Batch(loadHealthCmd(m.inventory), monitorTickCmd()) + // Always re-arm so the tick keeps firing, but only probe while the + // Monitor tab is visible — polling SMART from the other tabs kept + // drives awake (and hot) for nothing. SMART itself runs on its own + // slower cadence even when the tab is up. + if m.currentTab != tabMonitor { + return m, monitorTickCmd() + } + return m, tea.Batch(m.healthRefreshCmd(), monitorTickCmd()) case cpuTickMsg: return m, tea.Batch(cpuSampleCmd(m.monitor.cpuPrev), cpuTickCmd()) @@ -229,13 +267,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case "tab", "right": m.currentTab = (m.currentTab + 1) % tabCount - return m, nil + return m, m.enteredMonitorCmd() case "shift+tab", "left": m.currentTab = (m.currentTab + tabCount - 1) % tabCount - return m, nil + return m, m.enteredMonitorCmd() case "r": 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) diff --git a/internal/tui/view_monitor.go b/internal/tui/view_monitor.go index d9420cc..e805a9e 100644 --- a/internal/tui/view_monitor.go +++ b/internal/tui/view_monitor.go @@ -1,8 +1,10 @@ // Monitor tab — a refreshing dashboard of the host's health. // -// Drives, btrfs pools, SMART, CPU + memory, Jellyfin service. Updates -// every 5 seconds via tea.Tick. Re-uses the inventory we already track -// for the other tabs to pick which mountpoints/disks to probe. +// Drives, btrfs pools, SMART, CPU + memory, Jellyfin service. The cheap +// probes refresh every 5 seconds via tea.Tick — but only while this tab is +// 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 import ( @@ -25,6 +27,13 @@ type monitorModel struct { snap *health.Snapshot 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 // tick can compute deltas. usage is the most recent % per core + // 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 { snap *health.Snapshot 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{} 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 { return tea.Tick(monitorRefresh, func(time.Time) tea.Msg { return monitorTickMsg{} @@ -102,12 +120,16 @@ func cpuSampleCmd(prev []health.ProcStatLine) tea.Cmd { // loadHealthCmd derives the health Inputs from the current inventory and // kicks off a snapshot in the background. If inventory hasn't loaded // yet we still take a snapshot — system/jellyfin/cpu sections work -// without it. -func loadHealthCmd(inv *drives.Inventory) tea.Cmd { +// without it. withSmart=false drops the physical disks from the probe so +// smartctl isn't run; the Update loop re-attaches the cached rows. +func loadHealthCmd(inv *drives.Inventory, withSmart bool) tea.Cmd { in := healthInputsFrom(inv) + if !withSmart { + in.PhysicalDisks = nil + } return func() tea.Msg { 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") subhead := headerStyle.Render( "Refreshing every " + monitorRefresh.String() + + " · SMART every " + smartRefresh.String() + " · last update " + snap.Time.Format("15:04:05")) w := cardWidth(m.width) @@ -507,6 +530,10 @@ func renderDiskCard(s *health.Snapshot) string { for _, d := range s.Disks { var smart string 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: smart = roleInUseStyle.Render("n/a") case d.Passed: