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:
parent
3786d6bae1
commit
262ba8e3b4
3 changed files with 97 additions and 15 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue