fix(web): report real disk usage and MemAvailable on the live status stream (#558)

The SSE status stream sent 'disk_used_percent': 0 as a literal, so every
consumer of the live view showed 0% disk no matter how full the card was.
/api/v3/system/status computed it correctly; the stream that the dashboard
actually watches did not. On a Pi with a modest SD card that is the warning a
user most needs, and it was guaranteed to never appear.

The stream also omitted memory_available_mb. /api/v3/system/status carries it
with a comment spelling out why it matters: MemAvailable accounts for
reclaimable page cache, so it is what separates a board reading 70% "used" that
is fine from one reading 70% that is about to fail fork(). A 1GB Pi 3B+ can sit
at either. The number that predicts the failure was missing from the live view.

An unreadable disk now reports None rather than 0. The UI already renders null
as '--'; a confident 0 reads as "plenty of room", which is worse than a blank.

Metric collection moves to web_interface/system_metrics.py, with no Flask or app
imports. That is not cosmetic: importing web_interface.app constructs the Flask
application and a CacheManager, and the latter claims the cache directory with a
cleanup thread. The first version of these tests imported the generator directly
and broke test_cache_cleanup_thread_ownership ("one thread per directory") plus
four starlark route tests through that side effect. Reading a CPU percentage
should not boot a web application, and testing it should not either.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-11 08:42:30 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent ad5bc4b819
commit dcd6e39c96
3 changed files with 175 additions and 21 deletions
+10 -21
View File
@@ -32,6 +32,8 @@ _JOURNALCTL = shutil.which('journalctl')
_SYSTEMCTL = shutil.which('systemctl')
_VCGENCMD = shutil.which('vcgencmd')
from web_interface.system_metrics import collect_system_metrics
# Create Flask app
app = Flask(__name__)
app.secret_key = os.urandom(24)
@@ -606,26 +608,12 @@ def system_status_generator():
"""Generate system status updates"""
while True:
try:
# Try to import psutil for system stats
try:
import psutil
# interval=None is non-blocking; primed at module startup above
cpu_percent = round(psutil.cpu_percent(interval=None), 1)
memory = psutil.virtual_memory()
memory_used_percent = round(memory.percent, 1)
# Try to get CPU temperature (Raspberry Pi specific)
cpu_temp = 0
try:
with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f:
cpu_temp = round(float(f.read()) / 1000.0, 1)
except (OSError, ValueError):
pass
except ImportError:
cpu_percent = 0
memory_used_percent = 0
cpu_temp = 0
metrics = collect_system_metrics()
cpu_percent = metrics['cpu_percent']
memory_used_percent = metrics['memory_used_percent']
memory_available_mb = metrics['memory_available_mb']
disk_used_percent = metrics['disk_used_percent']
cpu_temp = metrics['cpu_temp']
# Check if display service is running (cached to avoid per-client subprocess forks)
now = time.time()
@@ -646,8 +634,9 @@ def system_status_generator():
'service_active': service_active,
'cpu_percent': cpu_percent,
'memory_used_percent': memory_used_percent,
'memory_available_mb': memory_available_mb,
'cpu_temp': cpu_temp,
'disk_used_percent': 0,
'disk_used_percent': disk_used_percent,
'power': _get_power_status()
}
yield status
+62
View File
@@ -0,0 +1,62 @@
"""System metrics for the status stream.
Deliberately free of Flask and app imports: importing web_interface.app
constructs the Flask application and a CacheManager, and the latter claims the
cache directory with a cleanup thread. Reading a CPU percentage should not do
that, and neither should testing it.
Every value is best-effort. A number that cannot be taken comes back as None so
the UI can render '--', because a confident wrong number is worse than a blank:
``disk_used_percent`` used to be hardcoded to 0, which read as "plenty of room"
on a card that was filling up.
"""
from typing import Any, Dict, Optional
_THERMAL_ZONE = '/sys/class/thermal/thermal_zone0/temp'
def _cpu_temp_c() -> Optional[float]:
"""CPU temperature in degrees C, or None off a Raspberry Pi."""
try:
with open(_THERMAL_ZONE, 'r') as f:
return round(float(f.read()) / 1000.0, 1)
except (OSError, ValueError):
return None
def collect_system_metrics() -> Dict[str, Any]:
"""Collect the numbers that predict trouble on a small board.
``memory_available_mb`` is MemAvailable rather than a used percentage: it
accounts for reclaimable page cache, so it is what separates a board at 70%
"used" that is fine from one at 70% that is about to fail fork(). A 1GB Pi
can sit at either and only this number tells them apart.
"""
try:
import psutil
except ImportError:
return {
'cpu_percent': 0,
'memory_used_percent': 0,
'memory_available_mb': None,
'disk_used_percent': None,
'cpu_temp': _cpu_temp_c() or 0,
}
# interval=None is non-blocking; app startup primes psutil's internal state.
cpu_percent = round(psutil.cpu_percent(interval=None), 1)
memory = psutil.virtual_memory()
try:
disk_used_percent: Optional[float] = round(psutil.disk_usage('/').percent, 1)
except OSError:
disk_used_percent = None
return {
'cpu_percent': cpu_percent,
'memory_used_percent': round(memory.percent, 1),
'memory_available_mb': round(memory.available / (1024 * 1024), 1),
'disk_used_percent': disk_used_percent,
'cpu_temp': _cpu_temp_c() or 0,
}