Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Chuck
2026-09-24 15:52:39 -04:00
40 changed files with 2838 additions and 781 deletions
+48 -66
View File
@@ -16,6 +16,17 @@ from datetime import datetime, timedelta
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
# Configure logging before anything below logs: the same setup as the display
# service (run.py), so this process's journal lines carry their real syslog
# priority too (`journalctl -p err -u ledmatrix-web`). LEDMATRIX_DEBUG=true
# turns on DEBUG, which includes the routine per-request lines.
from src.logging_config import setup_logging
setup_logging(format_type=(
'json' if os.environ.get('LEDMATRIX_JSON_LOGGING', 'false').lower() == 'true'
else 'readable'))
logging.getLogger('werkzeug').setLevel(logging.WARNING) # request_logging covers requests
logging.getLogger('urllib3').setLevel(logging.WARNING)
from src.config_manager import ConfigManager
from src.web_interface.error_handler import describe_exception
from src.common.path_safety import (
@@ -284,34 +295,47 @@ try:
except ImportError:
pass
# Cached AP mode check — avoids creating a WiFiManager per request
_ap_mode_cache = {'value': False, 'timestamp': 0}
# systemctl answers, memoised so they are not a subprocess fork per request
# (AP mode) or per SSE tick (display service). A failed check keeps the last
# known answer for the same TTL rather than retrying on every request.
from web_interface.cache import TTLCache
_service_status_cache = TTLCache()
_AP_MODE_CACHE_TTL = 30 # seconds — AP mode is user-initiated; 30s is fine
# Cached ledmatrix service status for SSE stats stream
_ledmatrix_service_cache = {'active': False, 'timestamp': 0}
_LEDMATRIX_SERVICE_CACHE_TTL = 15 # seconds
# The only units _unit_is_active() may ask systemctl about: its argv is built
# from these literals, never from request data.
_CHECKABLE_UNITS = frozenset({'hostapd', 'ledmatrix'})
def _unit_is_active(unit, ttl):
"""`systemctl is-active <unit>`, cached for ``ttl`` seconds.
False where there is no systemctl (a dev machine); on a failed check, the
last known answer.
"""
if unit not in _CHECKABLE_UNITS:
raise ValueError(f"not a checkable unit: {unit!r}")
active = _service_status_cache.get(unit)
if active is not None:
return active
active = _service_status_cache.peek(unit, False)
if _SYSTEMCTL:
try:
result = subprocess.run([_SYSTEMCTL, 'is-active', unit], # nosec B603 - list argv, unit is from _CHECKABLE_UNITS # nosemgrep
capture_output=True, text=True, timeout=2)
active = result.stdout.strip() == 'active'
except (subprocess.SubprocessError, OSError) as e:
logging.getLogger('web_interface').warning(
"systemctl is-active %s failed: %s", unit, e)
_service_status_cache.set(unit, active, ttl=ttl)
return active
def is_ap_mode_active():
"""
Check if access point mode is currently active (cached, 30s TTL).
Uses a direct systemctl check instead of instantiating WiFiManager.
"""
now = time.time()
if (now - _ap_mode_cache['timestamp']) < _AP_MODE_CACHE_TTL:
return _ap_mode_cache['value']
try:
result = subprocess.run(
['systemctl', 'is-active', 'hostapd'],
capture_output=True, text=True, timeout=2
)
active = result.stdout.strip() == 'active'
_ap_mode_cache['value'] = active
_ap_mode_cache['timestamp'] = now
return active
except (subprocess.SubprocessError, OSError) as e:
logging.getLogger('web_interface').error(f"AP mode check failed: {e}")
return _ap_mode_cache['value']
return _unit_is_active('hostapd', _AP_MODE_CACHE_TTL)
# Captive portal detection endpoints
# When AP mode is active, return responses that TRIGGER the captive portal popup.
@@ -346,41 +370,9 @@ def success_txt():
return redirect(url_for('pages_v3.captive_setup'), code=302)
return 'success', 200
# Initialize logging
try:
from web_interface.logging_config import setup_web_interface_logging, log_api_request
# Use JSON logging in production, readable logs in development
use_json_logging = os.environ.get('LEDMATRIX_JSON_LOGGING', 'false').lower() == 'true'
setup_web_interface_logging(level='INFO', use_json=use_json_logging)
except ImportError:
# Logging config not available, use default
log_api_request = None
# Request timing and logging middleware
@app.before_request
def before_request():
"""Track request start time for logging."""
from flask import request
request.start_time = time.time()
@app.after_request
def after_request_logging(response):
"""Log API requests after response."""
if log_api_request:
try:
from flask import request
duration_ms = (time.time() - getattr(request, 'start_time', time.time())) * 1000
ip_address = request.remote_addr if hasattr(request, 'remote_addr') else None
log_api_request(
method=request.method,
path=request.path,
status_code=response.status_code,
duration_ms=duration_ms,
ip_address=ip_address
)
except Exception: # nosec B110 - request logging must never interrupt a live HTTP response
pass # Don't break response if logging fails
return response
# Request timing and logging (routine reads at DEBUG; see request_logging)
from web_interface import request_logging
request_logging.init_app(app)
# Global error handlers
@app.errorhandler(404)
@@ -693,17 +685,7 @@ def system_status_generator():
cpu_temp = metrics['cpu_temp']
# Check if display service is running (cached to avoid per-client subprocess forks)
now = time.time()
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
if _SYSTEMCTL:
try:
result = subprocess.run([_SYSTEMCTL, 'is-active', 'ledmatrix'],
capture_output=True, text=True, timeout=2)
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
except (subprocess.SubprocessError, OSError) as e:
app.logger.warning("systemctl status check failed: %s", e)
_ledmatrix_service_cache['timestamp'] = now
service_active = _ledmatrix_service_cache['active']
service_active = _unit_is_active('ledmatrix', _LEDMATRIX_SERVICE_CACHE_TTL)
status = {
'timestamp': time.time(),
+1 -1
View File
@@ -1712,7 +1712,7 @@ def _get_starlark_device_location() -> DeviceLocationResolver:
global _starlark_device_location
if _starlark_device_location is None:
_starlark_device_location = DeviceLocationResolver(
getattr(api_v3, 'cache_manager', None) or _ensure_cache_manager(), logger)
getattr(api_v3, 'cache_manager', None), logger)
return _starlark_device_location
@@ -11,6 +11,7 @@ from web_interface.blueprints.api_v3 import (
_PIXLET_EDITOR_DEFAULT_TIMEOUT, _PIXLET_EDITOR_MAX_TIMEOUT,
_PIXLET_EDITOR_SCRIPT, _PIXLET_EDITOR_STATE, _clear_pixlet_editor_state,
_find_pixlet_binary, _install_star_file, _pixlet_editor_alive,
_run_systemctl_command,
_pixlet_editor_status, _read_pixlet_editor_state,
_STARLARK_APPS_DIR, _standalone_render_starlark_app,
_starlark_github_token, _starlark_manifest_lock,
+90 -31
View File
@@ -1,48 +1,107 @@
"""
Simple in-memory cache for expensive operations.
Separated from app.py to avoid circular import issues.
In-process TTL cache for the web interface.
The one place the web process memoises cheap-to-recompute values for a few
seconds or minutes (the font catalog, the system-status snapshot, systemctl
checks). It is per-process and in-memory only; data shared with the display
service goes through ``src.cache_manager.CacheManager`` instead.
Separated from app.py to avoid circular imports: blueprints import the
module-level helpers below lazily, inside their request handlers.
"""
import threading
import time
from typing import Any, Optional
from typing import Any, Callable, Dict, Optional, Tuple
# Simple in-memory cache for expensive operations
_cache = {}
_cache_timestamps = {}
class TTLCache:
"""A small thread-safe key/value store whose entries expire.
Each entry keeps the TTL it was stored with. A reader may additionally
pass ``max_age`` to ask for something fresher than that; an entry is only
returned while it is younger than both.
Expired entries are not dropped on read: :meth:`peek` still returns them,
which is what a "keep the last known answer if the refresh fails" caller
needs. They are replaced by the next :meth:`set` of the same key, so this
is meant for a small, fixed set of keys, not an unbounded key space.
Ages are measured with ``time.monotonic`` so a wall-clock jump (NTP sync
on a Pi that booted without an RTC) neither expires nor immortalises
everything at once.
"""
def __init__(self, default_ttl: float = 60,
clock: Callable[[], float] = time.monotonic):
self._default_ttl = default_ttl
self._clock = clock
self._lock = threading.Lock()
# key -> (value, stored_at, ttl)
self._entries: Dict[str, Tuple[Any, float, float]] = {}
def get(self, key: str, default: Any = None,
max_age: Optional[float] = None) -> Any:
"""The value for ``key`` if it is still fresh, else ``default``."""
with self._lock:
entry = self._entries.get(key)
if entry is None:
return default
value, stored_at, ttl = entry
age = self._clock() - stored_at
if age >= ttl or (max_age is not None and age >= max_age):
return default
return value
def peek(self, key: str, default: Any = None) -> Any:
"""The last value stored for ``key``, fresh or not."""
with self._lock:
entry = self._entries.get(key)
return default if entry is None else entry[0]
def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
"""Store ``value`` for ``ttl`` seconds (the cache default if None)."""
ttl = self._default_ttl if ttl is None else ttl
with self._lock:
self._entries[key] = (value, self._clock(), ttl)
def delete(self, key: str) -> None:
"""Remove ``key`` if present."""
with self._lock:
self._entries.pop(key, None)
def clear(self, pattern: Optional[str] = None) -> None:
"""Remove every entry, or only those whose key contains ``pattern``."""
with self._lock:
if pattern is None:
self._entries.clear()
else:
for key in [k for k in self._entries if pattern in k]:
del self._entries[key]
def get_cached(key: str, ttl_seconds: int = 60) -> Optional[Any]:
"""Get value from cache if not expired."""
if key in _cache:
if time.time() - _cache_timestamps[key] < ttl_seconds:
return _cache[key]
else:
# Expired, remove
del _cache[key]
del _cache_timestamps[key]
return None
# The shared cache behind the functional helpers the blueprints use.
_default_cache = TTLCache(default_ttl=60)
def set_cached(key: str, value: Any, ttl_seconds: int = 60) -> None:
"""Set value in cache with TTL."""
_cache[key] = value
_cache_timestamps[key] = time.time()
def get_cached(key: str, ttl_seconds: Optional[float] = None) -> Optional[Any]:
"""Get a value from the cache if it has not expired.
The entry expires after the TTL it was stored with; ``ttl_seconds``, when
given, is an extra upper bound on its age for this read.
"""
return _default_cache.get(key, max_age=ttl_seconds)
def set_cached(key: str, value: Any, ttl_seconds: float = 60) -> None:
"""Store a value in the cache for ``ttl_seconds``."""
_default_cache.set(key, value, ttl=ttl_seconds)
def delete_cached(key: str) -> None:
"""Remove a single key from the cache if present."""
_cache.pop(key, None)
_cache_timestamps.pop(key, None)
_default_cache.delete(key)
def invalidate_cache(pattern: Optional[str] = None) -> None:
"""Invalidate cache entries matching pattern, or all if pattern is None."""
if pattern is None:
_cache.clear()
_cache_timestamps.clear()
else:
keys_to_remove = [k for k in _cache.keys() if pattern in k]
for key in keys_to_remove:
del _cache[key]
del _cache_timestamps[key]
_default_cache.clear(pattern)
-110
View File
@@ -1,110 +0,0 @@
"""
Structured logging configuration for the web interface.
Provides JSON-formatted logs for production and readable logs for development.
"""
import logging
import json
import sys
from datetime import datetime
from typing import Optional
class JSONFormatter(logging.Formatter):
"""Formatter that outputs logs as JSON for structured logging."""
def format(self, record: logging.LogRecord) -> str:
"""Format log record as JSON."""
log_data = {
'timestamp': datetime.utcnow().isoformat(),
'level': record.levelname,
'logger': record.name,
'message': record.getMessage(),
'module': record.module,
'function': record.funcName,
'line': record.lineno,
}
# Add exception info if present
if record.exc_info:
log_data['exception'] = self.formatException(record.exc_info)
# Add extra fields if present
if hasattr(record, 'request_id'):
log_data['request_id'] = record.request_id
if hasattr(record, 'user_id'):
log_data['user_id'] = record.user_id
if hasattr(record, 'ip_address'):
log_data['ip_address'] = record.ip_address
if hasattr(record, 'duration_ms'):
log_data['duration_ms'] = record.duration_ms
return json.dumps(log_data)
def setup_web_interface_logging(level: str = 'INFO', use_json: bool = False):
"""
Set up logging for the web interface.
Args:
level: Log level (DEBUG, INFO, WARNING, ERROR)
use_json: If True, use JSON formatting (for production)
"""
# Get root logger
logger = logging.getLogger()
logger.setLevel(getattr(logging, level.upper()))
# Remove existing handlers
logger.handlers.clear()
# Create console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(getattr(logging, level.upper()))
# Set formatter
if use_json:
formatter = JSONFormatter()
else:
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# Set levels for specific loggers
logging.getLogger('werkzeug').setLevel(logging.WARNING) # Reduce Flask noise
logging.getLogger('urllib3').setLevel(logging.WARNING) # Reduce HTTP noise
def log_api_request(method: str, path: str, status_code: int, duration_ms: float,
ip_address: Optional[str] = None, **kwargs):
"""
Log an API request with structured data.
Args:
method: HTTP method
path: Request path
status_code: HTTP status code
duration_ms: Request duration in milliseconds
ip_address: Client IP address
**kwargs: Additional context
"""
logger = logging.getLogger('web_interface.api')
extra = {
'method': method,
'path': path,
'status_code': status_code,
'duration_ms': round(duration_ms, 2),
'ip_address': ip_address,
**kwargs
}
# Log at appropriate level based on status code
if status_code >= 500:
logger.error(f"{method} {path} - {status_code} ({duration_ms}ms)", extra=extra)
elif status_code >= 400:
logger.warning(f"{method} {path} - {status_code} ({duration_ms}ms)", extra=extra)
else:
logger.info(f"{method} {path} - {status_code} ({duration_ms}ms)", extra=extra)
+62
View File
@@ -0,0 +1,62 @@
"""
Per-request logging for the web interface.
Logging itself is configured by ``src.logging_config.setup_logging`` (the same
formatter and journald priorities as the display service); this module only
decides what one HTTP request is worth logging, and at which level.
The UI polls: the error summary, system status, display preview and log
streams are fetched every few seconds by every open tab. Logging each of those
at INFO buried everything else in the journal (``GET /api/v3/errors/summary -
200`` once a minute per tab, forever). So a request that only read something
and succeeded is DEBUG; one that changed something, or failed, is logged at a
level that shows up by default.
"""
import logging
import time
from flask import Flask, request
logger = logging.getLogger('web_interface.api')
#: Methods that do not change server state. A successful one is routine.
_READ_ONLY_METHODS = frozenset({'GET', 'HEAD', 'OPTIONS'})
def request_log_level(method: str, status_code: int) -> int:
"""The level a finished request is logged at."""
if status_code >= 500:
return logging.ERROR
if status_code >= 400:
return logging.WARNING
if method.upper() in _READ_ONLY_METHODS:
return logging.DEBUG
return logging.INFO
def log_request(method: str, path: str, status_code: int,
duration_ms: float) -> None:
"""Log one finished request."""
level = request_log_level(method, status_code)
if logger.isEnabledFor(level):
logger.log(level, "%s %s - %d (%.1fms)",
method, path, status_code, duration_ms)
def init_app(app: Flask) -> None:
"""Time every request and log it when its response is ready."""
@app.before_request
def _start_request_timer():
request.start_time = time.perf_counter()
@app.after_request
def _log_finished_request(response):
try:
started = getattr(request, 'start_time', None)
duration_ms = 0.0 if started is None else (time.perf_counter() - started) * 1000
log_request(request.method, request.path, response.status_code,
duration_ms)
except Exception: # nosec B110 - request logging must never interrupt a live HTTP response
pass
return response