Files
LEDMatrix/web_interface/start.py
T
ChuckandClaude Opus 5 869e36fb2f feat(web): weekly automatic updates with health check and rollback (#581)
* feat(web): weekly automatic updates with health check and rollback

A General-tab toggle (off by default) checks for and installs LEDMatrix and
plugin updates once a week, overnight in the configured timezone.

- Pre-update checks skip (and report) instead of forcing: local edits or
  commits, merge/live rebase, no upstream, low disk, missing health check, or
  a version that was already rolled back. An abandoned rebase (HEAD back on a
  branch) is cleared, since it would otherwise block every pull.
- The pull reuses the Update Code path (now perform_core_update(), which
  reports dependency install failures as data).
- ledmatrix-update-verify.service, started via a .path unit from a request
  file, restarts the services from its own cgroup, requires them to come up
  and stay up, and otherwise resets to the previous commit and reinstalls the
  previous requirements. It runs a copy of the checker taken before the pull.
- No SSH needed: switching the toggle on restarts the display service, which
  (as root) installs the two units from the repo templates for the web user.
  first_time_install.sh installs them too and takes --enable-auto-update /
  LEDMATRIX_AUTO_UPDATE (passed through by one-shot-install.sh).
- Plugins update after the code passes its check; failures, blocks and
  rollbacks raise an Overview banner and show under the toggle.

Tested end to end on a Pi: web-UI setup, a good update, a broken web service
and a broken display (both rolled back), a blocked local edit, and an
abandoned rebase found on the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(auto-update): address static-analysis findings

- Replace the subprocess.CompletedProcess the verifier fabricated for a
  command that could not start with a plain namedtuple; nothing is executed
  there, but the scanner flags any CompletedProcess built from variables.
- Mark the subprocess imports with the repo's standard B404 annotation (all
  calls are list-form argv, no shell).
- Mark the rollback-failed message as not SQL (B608 matched its wording).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): CI failures on Linux

- Keep the setup result when chown fails. CI runs as a non-root user, where
  chown to the web user raises; that discarded the result file, so the
  General tab would never learn whether setup worked. Regression test added.
- Register the two new /api/v3/system/auto-update routes in the URL map
  snapshot.
- Use utility classes app.css defines (space-y-1, hover:text-red-600).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): address review feedback

- Health check: a failed restart command no longer lets the check run
  against the still-running old process; it counts as a failure (and after a
  rollback, as a failed rollback). An unreadable restart count is never
  treated as stable, since a crash loop looks healthy between attempts.
- Installer writes the auto_update setting to a temp file and swaps it in,
  keeping mode and owner, so a running config watcher never reads a
  truncated config.json.
- Verify unit quotes its command-line paths (install folders with spaces);
  setup refuses folder names systemd would reinterpret (%, quotes,
  backslashes, control characters) and says so on the General tab.
- The auto-update status route no longer returns exception text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): keep error detail in the status route's 500

test_web_error_detail requires every 5xx handler to log the traceback and
return describe_exception(e), which redacts credentials, so failures are
diagnosable from the web UI. Dropping it for CodeQL broke that policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): dismiss route rejects non-object JSON with 400

A JSON array or scalar body made `.get('alert_id')` raise, returning 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auto-update): let the app-wide handler answer status-route errors

CodeQL (py/stack-trace-exposure, #709) flagged the route's own except,
which returned describe_exception(e). web_interface/app.py's error handler
already logs the traceback and returns the same redacted detail for any
unhandled exception, so the local copy is removed: same response, no new
exception-to-response flow, and test_web_error_detail's policy still holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:58:57 -04:00

142 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""
LED Matrix Web Interface V3 Startup Script
Modern web interface with real-time display preview and plugin management.
"""
import os
import socket
import subprocess
import sys
import logging
from pathlib import Path
def get_local_ips():
"""Get list of local IP addresses the service will be accessible on."""
ips = []
# Check if AP mode is active
try:
result = subprocess.run(
["systemctl", "is-active", "hostapd"],
capture_output=True,
text=True,
timeout=2
)
if result.returncode == 0 and result.stdout.strip() == "active":
ips.append("192.168.4.1 (AP Mode)")
except Exception: # nosec B110 - AP mode IP detection is non-critical startup info; systemctl may not exist
pass
# Get IPs from hostname -I
try:
result = subprocess.run(
["hostname", "-I"],
capture_output=True,
text=True,
timeout=2
)
if result.returncode == 0:
for ip in result.stdout.strip().split():
ip = ip.strip()
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
ips.append(ip)
except Exception: # nosec B110 - hostname -I output parsing; non-critical startup info
pass
# Fallback: try socket method
if not ips:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
if ip and not ip.startswith("127."):
ips.append(ip)
finally:
s.close()
except Exception:
pass
return ips if ips else ["localhost"]
def main():
"""Main startup function."""
# Change to project root directory
project_root = Path(__file__).parent.parent
os.chdir(project_root)
# Add to Python path
sys.path.insert(0, str(project_root))
# Configure logging to suppress non-critical socket errors
# These occur when clients disconnect and are harmless
werkzeug_logger = logging.getLogger('werkzeug')
original_log_exception = werkzeug_logger.error
def log_exception_filtered(message, *args, **kwargs):
"""Filter out non-critical socket errors from werkzeug logs."""
if isinstance(message, str):
# Suppress "No route to host" and similar connection errors
if 'No route to host' in message or 'errno 113' in message:
# Log at debug level instead of error
werkzeug_logger.debug(message, *args, **kwargs)
return
# Suppress broken pipe errors (client disconnected)
if 'Broken pipe' in message or 'errno 32' in message:
werkzeug_logger.debug(message, *args, **kwargs)
return
# For exceptions, check if it's a socket error
if 'exc_info' in kwargs and kwargs['exc_info']:
exc_type, exc_value, exc_tb = kwargs['exc_info']
if isinstance(exc_value, OSError):
# Suppress common non-critical socket errors
if exc_value.errno in (113, 32, 104): # No route to host, Broken pipe, Connection reset
werkzeug_logger.debug(message, *args, **kwargs)
return
# Log everything else normally
original_log_exception(message, *args, **kwargs)
werkzeug_logger.error = log_exception_filtered
# Import and run the Flask app
from web_interface.app import app, start_auto_update_scheduler
start_auto_update_scheduler()
print("Starting LED Matrix Web Interface V3...")
print("Web server binding to: 0.0.0.0:5000")
# Get and display accessible IP addresses
ips = get_local_ips()
if ips:
print("Access the interface at:")
for ip in ips:
if "AP Mode" in ip:
print(" - http://192.168.4.1:5000 (AP Mode - connect to LEDMatrix-Setup WiFi)")
else:
print(f" - http://{ip}:5000")
else:
print(" - http://localhost:5000 (local only)")
print(" - http://<your-pi-ip>:5000 (replace with your Pi's IP address)")
# Run the web server with error handling for client disconnections
try:
# threaded=True is Flask's default since 1.0, but set it explicitly
# so it's self-documenting: the two /api/v3/stream/* SSE endpoints
# hold long-lived connections and would starve other requests under
# a single-threaded server.
app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)
except (OSError, BrokenPipeError) as e:
# Suppress non-critical socket errors (client disconnections)
if isinstance(e, OSError) and e.errno in (113, 32, 104): # No route to host, Broken pipe, Connection reset
werkzeug_logger.debug(f"Client disconnected: {e}", exc_info=True)
# Re-raise only if it's not a client disconnection error
if e.errno not in (113, 32, 104):
raise
else:
raise
if __name__ == '__main__':
main()