mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
* 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>
222 lines
9 KiB
Python
222 lines
9 KiB
Python
"""
|
|
Tests for the status worker background task and aggregated stats endpoint.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
|
|
class TestCollectAllStatus:
|
|
"""Tests for collect_all_status function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_collects_all_stats_and_iso_timestamp(self):
|
|
"""Should collect every status type and include a parseable ISO timestamp."""
|
|
from controller.lib.status_worker import collect_all_status
|
|
|
|
status = await collect_all_status()
|
|
|
|
for key in (
|
|
"cpu_stats", "memory_stats", "load_stats", "drives_stats",
|
|
"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),
|
|
("get_memory_status", "memory_stats", None),
|
|
("get_drive_status", "drives_stats", []),
|
|
("get_network_status", "nic_bandwidth_stats", {}),
|
|
])
|
|
async def test_individual_collector_failure(self, collector, result_key, failure_value):
|
|
"""A single collector raising should leave the failed key at its fallback value
|
|
and let the other collectors still populate the result."""
|
|
from controller.lib.status_worker import collect_all_status
|
|
|
|
with mock.patch(
|
|
f"controller.lib.status_worker.{collector}",
|
|
side_effect=Exception(f"{collector} error"),
|
|
):
|
|
status = await collect_all_status()
|
|
assert status[result_key] == failure_value
|
|
# At least one other collector should still have produced real data.
|
|
other_keys = [k for k in ("cpu_stats", "memory_stats") if k != result_key]
|
|
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."""
|
|
|
|
def test_returns_base_sleep_when_load_is_low(self):
|
|
"""Should return base sleep when load is below CPU count."""
|
|
from controller.lib.status_worker import get_adaptive_sleep
|
|
|
|
with mock.patch("multiprocessing.cpu_count", return_value=4):
|
|
load_stats = {"minute_1": "0.5"}
|
|
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
|
|
assert sleep == 5.0
|
|
|
|
def test_returns_base_sleep_when_load_equals_cpu_count(self):
|
|
"""Should return base sleep when load equals CPU count."""
|
|
from controller.lib.status_worker import get_adaptive_sleep
|
|
|
|
with mock.patch("multiprocessing.cpu_count", return_value=4):
|
|
load_stats = {"minute_1": "4.0"}
|
|
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
|
|
assert sleep == 5.0
|
|
|
|
def test_increases_sleep_when_load_exceeds_cpu_count(self):
|
|
"""Should increase sleep when load exceeds CPU count."""
|
|
from controller.lib.status_worker import get_adaptive_sleep
|
|
|
|
with mock.patch("multiprocessing.cpu_count", return_value=4):
|
|
load_stats = {"minute_1": "8.0"} # 2x CPU count
|
|
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
|
|
# Expected: (5.0 * 8.0) / 4 = 10.0
|
|
assert sleep == 10.0
|
|
|
|
def test_handles_none_load_stats(self):
|
|
"""Should return base sleep when load_stats is None."""
|
|
from controller.lib.status_worker import get_adaptive_sleep
|
|
|
|
sleep = get_adaptive_sleep(None, base_sleep=5.0)
|
|
assert sleep == 5.0
|
|
|
|
def test_handles_empty_load_stats(self):
|
|
"""Should return base sleep when load_stats is empty dict."""
|
|
from controller.lib.status_worker import get_adaptive_sleep
|
|
|
|
sleep = get_adaptive_sleep({}, base_sleep=5.0)
|
|
assert sleep == 5.0
|
|
|
|
def test_handles_invalid_load_value(self):
|
|
"""Should return base sleep when load value is invalid."""
|
|
from controller.lib.status_worker import get_adaptive_sleep
|
|
|
|
load_stats = {"minute_1": "invalid"}
|
|
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
|
|
assert sleep == 5.0
|
|
|
|
def test_handles_missing_minute_1_key(self):
|
|
"""Should return base sleep when minute_1 key is missing."""
|
|
from controller.lib.status_worker import get_adaptive_sleep
|
|
|
|
load_stats = {"minute_5": "2.0"}
|
|
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
|
|
assert sleep == 5.0
|
|
|
|
|
|
class TestAggregatedStatsEndpoint:
|
|
"""Tests for the /api/stats aggregated endpoint."""
|
|
|
|
def test_aggregated_stats_payload(self, test_client):
|
|
"""Endpoint should return 200 with every status key and the expected sub-shapes."""
|
|
response = test_client.get("/api/stats")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
for key in (
|
|
"cpu_stats", "memory_stats", "load_stats", "drives_stats",
|
|
"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"]
|
|
for field in ("total", "used", "free"):
|
|
assert field in data["memory_stats"]
|
|
for field in ("minute_1", "minute_5", "minute_15"):
|
|
assert field in data["load_stats"]
|
|
assert isinstance(data["drives_stats"], list)
|
|
assert isinstance(data["nic_bandwidth_stats"], dict)
|
|
|
|
def test_aggregated_stats_trailing_slash(self, test_client):
|
|
"""Aggregated stats should work with trailing slash."""
|
|
response = test_client.get("/api/stats/")
|
|
assert response.status_code == 200
|