Warn in the navbar when a hard drive runs hot (#470)

* Warn in the navbar when a hard drive runs hot.

The navbar already warns about high CPU temperature; add the equivalent
for drive temperature, since an overheating disk risks data loss and a
user running an off-grid library won't be watching for it.

- controller/lib/smart.py: build_smart_stats() returns the per-drive list
  plus warning thresholds (55C warn / 65C critical) so the frontend stays
  dumb. Reading SMART wakes a spun-down drive, so drives in standby are
  detected with `smartctl -n standby` (which works through the USB-SATA
  bridges where `hdparm -C` only reports "unknown") and NOT read; their
  last-known reading is reused instead.
- controller/lib/status_worker.py: collect SMART on a slow 60s cadence
  (cached on app.state) rather than the 5s status loop, so the poll does
  not keep USB drives awake. The result flows into the same cached payload
  via a new collect_all_status(smart_stats=...) kwarg.
- Frontend: useDriveTemperature() reports the hottest drive; Nav.js shows
  a yellow/red `hdd` icon with a popup, mirroring the CPU warning.

Verified on hardware: smart_stats reports real temps and SMART runs once
a minute, not every 5s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address Greptile review on HDD temperature warning.

- _drive_is_asleep also treats SLEEP mode as asleep, not just STANDBY.
  smartctl -n standby exits "Device is in SLEEP mode, exit(2)" for the
  deeper power mode; matching only "STANDBY" meant a SLEEP drive was read
  and woken — the exact failure the check prevents.  (TDD: added a failing
  SLEEP test, then fixed.)
- useDriveTemperature uses ?? instead of || for the temperature fallback
  to express "fall back only on null/undefined".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lrnselfreliance 2026-06-03 18:46:29 -06:00 • committed by Roland
parent b112f07b4a
commit 51f89593a4
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
8 changed files with 450 additions and 8 deletions

View file

@ -7,9 +7,13 @@ from unittest import mock
from controller.lib.smart import (
is_smart_available,
get_all_smart_status,
build_smart_stats,
_get_device_smart,
_get_temperature,
_get_attribute,
_drive_is_asleep,
HDD_HIGH_TEMPERATURE,
HDD_CRITICAL_TEMPERATURE,
)
@ -203,3 +207,95 @@ class TestGetAttribute:
result = _get_attribute(mock_device, "Power_On_Hours")
assert result is None
class TestDriveIsAsleep:
"""Tests for _drive_is_asleep function (smartctl -n standby)."""
def test_returns_true_for_standby(self):
completed = mock.Mock(stdout="Device is in STANDBY mode, exit(2)\n")
with mock.patch("controller.lib.smart.subprocess.run", return_value=completed):
assert _drive_is_asleep("/dev/sda") is True
def test_returns_true_for_sleep(self):
# smartctl -n standby also bails on the deeper SLEEP power mode;
# that drive must NOT be woken for a read either.
completed = mock.Mock(stdout="Device is in SLEEP mode, exit(2)\n")
with mock.patch("controller.lib.smart.subprocess.run", return_value=completed):
assert _drive_is_asleep("/dev/sda") is True
def test_returns_false_for_active(self):
# A normal `-i` read on an awake drive never mentions STANDBY.
completed = mock.Mock(stdout="Model Family: WDC\nDevice Model: WD100EMAZ\n")
with mock.patch("controller.lib.smart.subprocess.run", return_value=completed):
assert _drive_is_asleep("/dev/sda") is False
def test_returns_false_on_error(self):
"""smartctl missing / errors out → treat as awake (safe fallback)."""
with mock.patch("controller.lib.smart.subprocess.run",
side_effect=OSError("boom")):
assert _drive_is_asleep("/dev/sda") is False
class TestBuildSmartStats:
"""Tests for build_smart_stats — the navbar/Disk Management payload."""
def test_returns_thresholds_when_unavailable(self):
"""Docker / no pySMART → empty drive list but thresholds present."""
with mock.patch("controller.lib.smart.is_smart_available", return_value=False):
stats = build_smart_stats()
assert stats["drives"] == []
assert stats["high_temperature"] == HDD_HIGH_TEMPERATURE
assert stats["critical_temperature"] == HDD_CRITICAL_TEMPERATURE
def test_reads_awake_drives(self):
fake_device = mock.Mock()
fake_device.name = "sda"
fake_device.model = "WDC WD100EMAZ"
fake_device.serial = "S1"
fake_device.capacity = "10.0 TB"
fake_device.assessment = "PASS"
fake_device.smart_enabled = True
fake_device.temperature = 52
fake_device.attributes = []
with mock.patch("controller.lib.smart.is_smart_available", return_value=True), \
mock.patch("controller.lib.smart._scan_devices",
return_value=[("/dev/sda", "sat")]), \
mock.patch("controller.lib.smart._drive_is_asleep", return_value=False), \
mock.patch("controller.lib.smart.Device", return_value=fake_device) as Dev:
stats = build_smart_stats()
Dev.assert_called_once_with("/dev/sda", interface="sat")
assert len(stats["drives"]) == 1
assert stats["drives"][0]["device"] == "sda"
assert stats["drives"][0]["temperature"] == 52
def test_sleeping_drive_is_not_read_and_reuses_previous(self):
"""A standby drive must NOT be read (would wake it); reuse old data."""
previous = {
"drives": [{"device": "sda", "path": "/dev/sda", "temperature": 49}],
"high_temperature": HDD_HIGH_TEMPERATURE,
"critical_temperature": HDD_CRITICAL_TEMPERATURE,
}
with mock.patch("controller.lib.smart.is_smart_available", return_value=True), \
mock.patch("controller.lib.smart._scan_devices",
return_value=[("/dev/sda", "sat")]), \
mock.patch("controller.lib.smart._drive_is_asleep", return_value=True), \
mock.patch("controller.lib.smart.Device") as Dev:
stats = build_smart_stats(previous=previous)
Dev.assert_not_called()
assert stats["drives"] == [{"device": "sda", "path": "/dev/sda", "temperature": 49}]
def test_sleeping_drive_without_previous_is_skipped(self):
"""Standby drive with no prior reading is simply omitted (not hot)."""
with mock.patch("controller.lib.smart.is_smart_available", return_value=True), \
mock.patch("controller.lib.smart._scan_devices",
return_value=[("/dev/sda", "sat")]), \
mock.patch("controller.lib.smart._drive_is_asleep", return_value=True), \
mock.patch("controller.lib.smart.Device") as Dev:
stats = build_smart_stats(previous=None)
Dev.assert_not_called()
assert stats["drives"] == []

View file

@ -20,11 +20,29 @@ class TestCollectAllStatus:
for key in (
"cpu_stats", "memory_stats", "load_stats", "drives_stats",
"nic_bandwidth_stats", "power_stats", "last_status",
"nic_bandwidth_stats", "power_stats", "smart_stats", "last_status",
):
assert key in status
datetime.fromisoformat(status["last_status"])
@pytest.mark.asyncio
async def test_smart_stats_passed_through(self):
"""The smart_stats kwarg should land in the cached payload verbatim."""
from controller.lib.status_worker import collect_all_status
smart = {"drives": [{"device": "sda", "temperature": 52}],
"high_temperature": 55, "critical_temperature": 65}
status = await collect_all_status(smart_stats=smart)
assert status["smart_stats"] is smart
@pytest.mark.asyncio
async def test_smart_stats_defaults_to_none(self):
"""Without the kwarg, smart_stats is None (worker fills it in)."""
from controller.lib.status_worker import collect_all_status
status = await collect_all_status()
assert status["smart_stats"] is None
@pytest.mark.asyncio
@pytest.mark.parametrize("collector,result_key,failure_value", [
("get_cpu_status", "cpu_stats", None),
@ -48,6 +66,65 @@ class TestCollectAllStatus:
assert any(status[k] is not None for k in other_keys)
class TestRefreshSmartStats:
"""SMART must be collected on a slow cadence, not every status cycle."""
def _fake_app(self):
app = mock.Mock()
app.state = mock.Mock(spec=[]) # no smart_cache / smart_last_collected yet
return app
@pytest.mark.asyncio
async def test_collects_on_first_call(self):
from controller.lib.status_worker import refresh_smart_stats
sentinel = {"drives": [], "high_temperature": 55, "critical_temperature": 65}
with mock.patch("controller.lib.status_worker.build_smart_stats",
return_value=sentinel) as build:
app = self._fake_app()
now = datetime(2026, 6, 3, 12, 0, 0)
result = await refresh_smart_stats(app, now)
build.assert_called_once()
assert result is sentinel
assert app.state.smart_cache is sentinel
assert app.state.smart_last_collected == now
@pytest.mark.asyncio
async def test_reuses_cache_before_interval(self):
"""A second call a few seconds later must NOT re-read SMART."""
from controller.lib.status_worker import refresh_smart_stats
with mock.patch("controller.lib.status_worker.build_smart_stats",
side_effect=[{"n": 1}, {"n": 2}]) as build:
app = self._fake_app()
t0 = datetime(2026, 6, 3, 12, 0, 0)
first = await refresh_smart_stats(app, t0)
# 5s later — well within the 60s window.
second = await refresh_smart_stats(app, datetime(2026, 6, 3, 12, 0, 5))
assert build.call_count == 1
assert first == {"n": 1}
assert second == {"n": 1}
@pytest.mark.asyncio
async def test_refreshes_after_interval(self):
"""After >= 60s, SMART is re-read and the prior result is passed in."""
from controller.lib.status_worker import refresh_smart_stats
with mock.patch("controller.lib.status_worker.build_smart_stats",
side_effect=[{"n": 1}, {"n": 2}]) as build:
app = self._fake_app()
t0 = datetime(2026, 6, 3, 12, 0, 0)
await refresh_smart_stats(app, t0)
result = await refresh_smart_stats(app, datetime(2026, 6, 3, 12, 1, 0))
assert build.call_count == 2
# The prior cache is handed to build_smart_stats for spin-down reuse.
assert build.call_args_list[1].args[0] == {"n": 1}
assert result == {"n": 2}
class TestGetAdaptiveSleep:
"""Tests for adaptive sleep calculation."""
@ -121,11 +198,14 @@ class TestAggregatedStatsEndpoint:
for key in (
"cpu_stats", "memory_stats", "load_stats", "drives_stats",
"nic_bandwidth_stats", "power_stats", "last_status",
"nic_bandwidth_stats", "power_stats", "smart_stats", "last_status",
):
assert key in data
# last_status is an ISO timestamp.
datetime.fromisoformat(data["last_status"])
# SMART carries its thresholds for the frontend warning.
for field in ("drives", "high_temperature", "critical_temperature"):
assert field in data["smart_stats"]
# Sub-field shape spot-checks.
for field in ("percent", "cores"):
assert field in data["cpu_stats"]