2 Commits

Author SHA1 Message Date
Chuck
f67b9c25f1 fix(tests): thread cleanup on assertion failure, reduce oversized image
- test_health_monitor.py: wrap start_monitoring calls in try/finally so
  the background thread is always stopped even when an assertion fails
- test_scroll_helper.py: reduce 50,000px test image to 5,000px to avoid
  unnecessary memory pressure on Raspberry Pi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 09:37:49 -04:00
Claude
4977c5fbc9 test: add 306 new tests covering previously untested modules
Adds test coverage for six major untested areas:
- src/base_classes/api_extractors.py — ESPN football, baseball, hockey, soccer extractors
- src/base_classes/data_sources.py — ESPN, MLB, and soccer API data sources (HTTP mocked)
- src/common/game_helper.py — game extraction, filtering, sorting, and summaries
- src/common/utils.py — all utility functions (normalise, format, validate, parse)
- src/common/scroll_helper.py — ScrollHelper init, create, update, visible portion, duration
- src/background_data_service.py — cache hit/miss paths, retry, cancel, cleanup, singleton
- src/vegas_mode/config.py — VegasModeConfig from_config, validate, update, ordering
- src/logo_downloader.py — normalize_abbreviation, filename variations, directory helpers
- src/plugin_system/health_monitor.py — HealthStatus determination, metrics, suggestions, lifecycle

https://claude.ai/code/session_015792DiGo27JbgH5mk3KBjk
2026-05-24 02:45:27 +00:00
14 changed files with 537 additions and 804 deletions

View File

@@ -67,9 +67,8 @@ def main():
print(" 📍 Will run on: http://0.0.0.0:5000") print(" 📍 Will run on: http://0.0.0.0:5000")
print(" ⏹️ Press Ctrl+C to stop") print(" ⏹️ Press Ctrl+C to stop")
# Run the app (debug mode controlled by env var to satisfy security scanners) # Run the app (this should start the server)
_debug = os.environ.get('LEDMATRIX_FLASK_DEBUG', '0') == '1' app.run(host='0.0.0.0', port=5000, debug=True)
app.run(host='0.0.0.0', port=5000, debug=_debug)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n ⏹️ Server stopped by user") print("\n ⏹️ Server stopped by user")

View File

@@ -410,8 +410,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
try: try:
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8") manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
manifest = json.loads(manifest_raw) manifest = json.loads(manifest_raw)
except (OSError, UnicodeDecodeError, json.JSONDecodeError): except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
return False, "Invalid manifest.json", {} return False, f"Invalid manifest.json: {e}", {}
if not isinstance(manifest, dict) or "schema_version" not in manifest: if not isinstance(manifest, dict) or "schema_version" not in manifest:
return False, "Invalid manifest structure", {} return False, "Invalid manifest structure", {}
@@ -456,8 +456,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
return True, "", result_manifest return True, "", result_manifest
except zipfile.BadZipFile: except zipfile.BadZipFile:
return False, "File is not a valid ZIP archive", {} return False, "File is not a valid ZIP archive", {}
except OSError: except OSError as e:
return False, "Could not read backup", {} return False, f"Could not read backup: {e}", {}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -190,7 +190,7 @@ class DisplayManager:
json.dump(_hw_status, _f) json.dump(_hw_status, _f)
_f.flush() _f.flush()
os.fsync(_f.fileno()) os.fsync(_f.fileno())
os.chmod(_tmp_path, 0o644) os.chmod(_tmp_path, 0o600)
os.replace(_tmp_path, _status_path) os.replace(_tmp_path, _status_path)
except Exception: except Exception:
try: try:

View File

@@ -8,7 +8,6 @@ Extracted from PluginManager to improve separation of concerns.
import json import json
import importlib import importlib
import importlib.util import importlib.util
import os
import sys import sys
import subprocess import subprocess
import threading import threading
@@ -69,11 +68,6 @@ class PluginLoader:
Returns: Returns:
Path to plugin directory or None if not found Path to plugin directory or None if not found
""" """
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
return None
# Strategy 1: Use mapping from discovery # Strategy 1: Use mapping from discovery
if plugin_directories and plugin_id in plugin_directories: if plugin_directories and plugin_id in plugin_directories:
plugin_dir = plugin_directories[plugin_id] plugin_dir = plugin_directories[plugin_id]
@@ -81,16 +75,14 @@ class PluginLoader:
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir) self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
return plugin_dir return plugin_dir
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir # Strategy 2: Direct paths
plugins_dir_resolved = plugins_dir.resolve() plugin_dir = plugins_dir / plugin_id
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"): if plugin_dir.exists():
_candidate = (plugins_dir_resolved / _candidate_name).resolve() return plugin_dir
try:
_candidate.relative_to(plugins_dir_resolved) plugin_dir = plugins_dir / f"ledmatrix-{plugin_id}"
except ValueError: if plugin_dir.exists():
continue return plugin_dir
if _candidate.exists():
return _candidate
# Strategy 3: Case-insensitive search # Strategy 3: Case-insensitive search
normalized_id = plugin_id.lower() normalized_id = plugin_id.lower()
@@ -151,21 +143,12 @@ class PluginLoader:
Returns: Returns:
True if dependencies installed or not needed, False on error True if dependencies installed or not needed, False on error
""" """
plugin_id = os.path.basename(plugin_id or '') requirements_file = plugin_dir / "requirements.txt"
if not plugin_id:
return False
# Resolve and validate plugin_dir before constructing any derived paths
try:
plugin_dir_resolved = plugin_dir.resolve(strict=True)
except OSError:
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
return False
requirements_file = plugin_dir_resolved / "requirements.txt"
if not requirements_file.exists(): if not requirements_file.exists():
return True # No dependencies needed return True # No dependencies needed
marker_path = plugin_dir_resolved / ".dependencies_installed"
# Check if already installed # Check if already installed
marker_path = plugin_dir / ".dependencies_installed"
if marker_path.exists(): if marker_path.exists():
self.logger.debug("Dependencies already installed for %s", plugin_id) self.logger.debug("Dependencies already installed for %s", plugin_id)
return True return True
@@ -188,24 +171,10 @@ class PluginLoader:
self.logger.info("Dependencies installed successfully for %s", plugin_id) self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True return True
else: else:
stderr = result.stderr or ""
# uninstall-no-record-file means the package is already present at the
# system level (e.g. installed via dnf/apt without a pip RECORD file).
# pip can't replace it, but it IS installed — write the marker so we
# don't retry on every restart.
if "uninstall-no-record-file" in stderr:
self.logger.warning(
"Dependencies for %s include system-managed packages (no pip RECORD). "
"Assuming they are satisfied: %s",
plugin_id, stderr.strip()
)
marker_path.touch()
ensure_file_permissions(marker_path, get_plugin_file_mode())
return True
self.logger.warning( self.logger.warning(
"Dependency installation returned non-zero exit code for %s: %s", "Dependency installation returned non-zero exit code for %s: %s",
plugin_id, plugin_id,
stderr result.stderr
) )
return False return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
@@ -380,20 +349,9 @@ class PluginLoader:
Returns: Returns:
Loaded module or None on error Loaded module or None on error
""" """
plugin_id = os.path.basename(plugin_id or '') entry_file = plugin_dir / entry_point
if not plugin_id:
raise PluginError("Invalid plugin ID")
try:
plugin_dir_resolved = plugin_dir.resolve(strict=True)
except OSError:
raise PluginError("Plugin directory not found", plugin_id=plugin_id)
entry_file = (plugin_dir_resolved / entry_point).resolve()
try:
entry_file.relative_to(plugin_dir_resolved)
except ValueError:
raise PluginError("Invalid entry point path", plugin_id=plugin_id)
if not entry_file.exists(): if not entry_file.exists():
error_msg = f"Entry point file not found for plugin {plugin_id}" error_msg = f"Entry point file not found: {entry_file} for plugin {plugin_id}"
self.logger.error(error_msg) self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)}) raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})

View File

@@ -21,8 +21,6 @@ from pathlib import Path
from typing import List, Dict, Optional, Any, Tuple from typing import List, Dict, Optional, Any, Tuple
import logging import logging
from urllib.parse import urlparse
from src.common.permission_utils import sudo_remove_directory from src.common.permission_utils import sudo_remove_directory
try: try:
@@ -358,8 +356,7 @@ class PluginStoreManager:
# Extract owner/repo from URL # Extract owner/repo from URL
try: try:
# Handle different URL formats # Handle different URL formats
_parsed_url = urlparse(repo_url) if 'github.com' in repo_url:
if _parsed_url.hostname in ('github.com', 'www.github.com'):
parts = repo_url.strip('/').split('/') parts = repo_url.strip('/').split('/')
if len(parts) >= 2: if len(parts) >= 2:
owner = parts[-2] owner = parts[-2]
@@ -521,10 +518,9 @@ class PluginStoreManager:
# Try to find plugins.json in common locations # Try to find plugins.json in common locations
# First try root directory # First try root directory
registry_urls = [] registry_urls = []
# Extract owner/repo from URL # Extract owner/repo from URL
_parsed_repo_url = urlparse(repo_url) if 'github.com' in repo_url:
if _parsed_repo_url.hostname in ('github.com', 'www.github.com'):
parts = repo_url.split('/') parts = repo_url.split('/')
if len(parts) >= 2: if len(parts) >= 2:
owner = parts[-2] owner = parts[-2]
@@ -779,8 +775,7 @@ class PluginStoreManager:
try: try:
# Convert repo URL to raw content URL # Convert repo URL to raw content URL
# https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json # https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json
_parsed_manifest_url = urlparse(repo_url) if 'github.com' in repo_url:
if _parsed_manifest_url.hostname in ('github.com', 'www.github.com'):
# Handle different URL formats # Handle different URL formats
repo_url = repo_url.rstrip('/') repo_url = repo_url.rstrip('/')
if repo_url.endswith('.git'): if repo_url.endswith('.git'):

View File

@@ -2,10 +2,8 @@ from flask import Flask, request, redirect, url_for, jsonify, Response, send_fro
import json import json
import logging import logging
import os import os
import queue
import sys import sys
import subprocess import subprocess
import threading
import time import time
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -206,12 +204,24 @@ def serve_plugin_asset(plugin_id, filename):
# Use send_from_directory to serve the file # Use send_from_directory to serve the file
return send_from_directory(str(assets_dir), filename, mimetype=content_type) return send_from_directory(str(assets_dir), filename, mimetype=content_type)
except Exception: except Exception as e:
# Log the exception with full traceback server-side
import traceback
app.logger.exception('Error serving plugin asset file') app.logger.exception('Error serving plugin asset file')
return jsonify({
'status': 'error', # Return generic error message to client (avoid leaking internal details)
'message': 'Internal server error' # Only include detailed error information when in debug mode
}), 500 if app.debug:
return jsonify({
'status': 'error',
'message': str(e),
'traceback': traceback.format_exc()
}), 500
else:
return jsonify({
'status': 'error',
'message': 'Internal server error'
}), 500
# Prime psutil CPU measurement once at startup so interval=None returns a real value # Prime psutil CPU measurement once at startup so interval=None returns a real value
try: try:
@@ -332,25 +342,35 @@ def not_found_error(error):
@app.errorhandler(500) @app.errorhandler(500)
def internal_error(error): def internal_error(error):
"""Handle 500 errors.""" """Handle 500 errors."""
import traceback
error_details = traceback.format_exc()
# Log the error
import logging import logging
logger = logging.getLogger('web_interface') logger = logging.getLogger('web_interface')
logger.error("Internal server error", exc_info=True) logger.error(f"Internal server error: {error}", exc_info=True)
# Return user-friendly error (hide internal details in production)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'error_code': 'INTERNAL_ERROR', 'error_code': 'INTERNAL_ERROR',
'message': 'An internal error occurred; see logs for details', 'message': 'An internal error occurred',
'details': error_details if app.debug else None
}), 500 }), 500
@app.errorhandler(Exception) @app.errorhandler(Exception)
def handle_exception(error): def handle_exception(error):
"""Handle all unhandled exceptions.""" """Handle all unhandled exceptions."""
import traceback
import logging import logging
logger = logging.getLogger('web_interface') logger = logging.getLogger('web_interface')
logger.error("Unhandled exception", exc_info=True) logger.error(f"Unhandled exception: {error}", exc_info=True)
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'error_code': 'UNKNOWN_ERROR', 'error_code': 'UNKNOWN_ERROR',
'message': 'An error occurred; see logs for details', 'message': str(error) if app.debug else 'An error occurred',
'details': traceback.format_exc() if app.debug else None
}), 500 }), 500
# Captive portal redirect middleware # Captive portal redirect middleware
@@ -415,44 +435,13 @@ def add_security_headers(response):
return response return response
class _StreamBroadcaster: # SSE helper function
"""Fan-out broadcaster: one background generator thread pushes to all SSE clients. def sse_response(generator_func):
"""Helper to create SSE responses"""
This means N browser tabs share one generator instead of each running their own, def generate():
keeping PIL encodes / subprocess forks constant regardless of how many tabs are open. for data in generator_func():
""" yield f"data: {json.dumps(data)}\n\n"
return Response(generate(), mimetype='text/event-stream')
def __init__(self, generator_factory):
self._generator_factory = generator_factory
self._clients: set = set()
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
def subscribe(self) -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=5)
with self._lock:
self._clients.add(q)
if not (self._thread and self._thread.is_alive()):
self._thread = threading.Thread(target=self._broadcast, daemon=True)
self._thread.start()
return q
def unsubscribe(self, q: queue.Queue) -> None:
with self._lock:
self._clients.discard(q)
def _broadcast(self):
for data in self._generator_factory():
with self._lock:
if not self._clients:
continue
dead = set()
for q in self._clients:
try:
q.put_nowait(data)
except queue.Full:
dead.add(q)
self._clients -= dead
# System status generator for SSE # System status generator for SSE
def system_status_generator(): def system_status_generator():
@@ -503,8 +492,7 @@ def system_status_generator():
} }
yield status yield status
except Exception as e: except Exception as e:
app.logger.error("SSE generator error", exc_info=True) yield {'error': str(e)}
yield {'error': 'An error occurred; see server logs'}
time.sleep(10) # Update every 10 seconds (reduced frequency for better performance) time.sleep(10) # Update every 10 seconds (reduced frequency for better performance)
# Display preview generator for SSE # Display preview generator for SSE
@@ -567,8 +555,7 @@ def display_preview_generator():
} }
except Exception as e: except Exception as e:
app.logger.error("SSE generator error", exc_info=True) yield {'error': str(e)}
yield {'error': 'An error occurred; see server logs'}
time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s
@@ -611,68 +598,36 @@ def logs_generator():
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
# Timeout - just skip this update # Timeout - just skip this update
pass pass
except Exception: except Exception as e:
app.logger.error("Error running journalctl", exc_info=True)
error_data = { error_data = {
'timestamp': time.time(), 'timestamp': time.time(),
'logs': 'Error running journalctl; see server logs' 'logs': f'Error running journalctl: {str(e)}'
} }
yield error_data yield error_data
except Exception: except Exception as e:
app.logger.error("Unexpected error in logs generator", exc_info=True)
error_data = { error_data = {
'timestamp': time.time(), 'timestamp': time.time(),
'logs': 'Unexpected error in logs generator; see server logs' 'logs': f'Unexpected error in logs generator: {str(e)}'
} }
yield error_data yield error_data
time.sleep(5) # Update every 5 seconds (reduced frequency for better performance) time.sleep(5) # Update every 5 seconds (reduced frequency for better performance)
# One broadcaster per stream — shared across all SSE clients
_stats_broadcaster = _StreamBroadcaster(system_status_generator)
_display_broadcaster = _StreamBroadcaster(display_preview_generator)
_logs_broadcaster = _StreamBroadcaster(logs_generator)
def _sse_stream(broadcaster: _StreamBroadcaster) -> Response:
"""Return a streaming SSE response backed by a shared broadcaster."""
q = broadcaster.subscribe()
def generate():
try:
while True:
try:
data = q.get(timeout=30)
yield f"data: {json.dumps(data)}\n\n"
except queue.Empty:
# Send an SSE comment heartbeat to keep the connection alive
# through proxies that close idle connections.
yield ": heartbeat\n\n"
except GeneratorExit:
pass
finally:
broadcaster.unsubscribe(q)
return Response(generate(), mimetype='text/event-stream')
# SSE endpoints # SSE endpoints
@app.route('/api/v3/stream/stats') @app.route('/api/v3/stream/stats')
def stream_stats(): def stream_stats():
return _sse_stream(_stats_broadcaster) return sse_response(system_status_generator)
@app.route('/api/v3/stream/display') @app.route('/api/v3/stream/display')
def stream_display(): def stream_display():
return _sse_stream(_display_broadcaster) return sse_response(display_preview_generator)
@app.route('/api/v3/stream/logs') @app.route('/api/v3/stream/logs')
def stream_logs(): def stream_logs():
return _sse_stream(_logs_broadcaster) return sse_response(logs_generator)
# Exempt SSE streams from CSRF and apply a generous rate limit. # Exempt SSE streams from CSRF and add rate limiting
# SSE connections are long-lived HTTP requests, not repeated API calls, so the
# tight "20 per minute" default would be exhausted quickly on reconnects.
if csrf: if csrf:
csrf.exempt(stream_stats) csrf.exempt(stream_stats)
csrf.exempt(stream_display) csrf.exempt(stream_display)
@@ -680,9 +635,9 @@ if csrf:
# Note: api_v3 blueprint is exempted above after registration # Note: api_v3 blueprint is exempted above after registration
if limiter: if limiter:
limiter.limit("200 per minute")(stream_stats) limiter.limit("20 per minute")(stream_stats)
limiter.limit("200 per minute")(stream_display) limiter.limit("20 per minute")(stream_display)
limiter.limit("200 per minute")(stream_logs) limiter.limit("20 per minute")(stream_logs)
# Main route - redirect to v3 interface as default # Main route - redirect to v3 interface as default
@app.route('/') @app.route('/')

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,6 @@ from flask import Blueprint, render_template, flash
from markupsafe import escape from markupsafe import escape
import json import json
import logging import logging
import os
import re
from pathlib import Path from pathlib import Path
from src.web_interface.secret_helpers import mask_secret_fields from src.web_interface.secret_helpers import mask_secret_fields
@@ -86,11 +84,10 @@ def load_partial(partial_name):
elif partial_name == 'operation-history': elif partial_name == 'operation-history':
return _load_operation_history_partial() return _load_operation_history_partial()
else: else:
return "Partial not found", 404 return f"Partial '{partial_name}' not found", 404
except Exception as e: except Exception as e:
logger.error("Error loading partial %s", partial_name, exc_info=True) return f"Error loading partial '{partial_name}': {str(e)}", 500
return "Error loading partial", 500
@pages_v3.route('/partials/plugin-config/<plugin_id>') @pages_v3.route('/partials/plugin-config/<plugin_id>')
@@ -98,9 +95,8 @@ def load_plugin_config_partial(plugin_id):
"""Load plugin configuration partial via HTMX - server-side rendered form""" """Load plugin configuration partial via HTMX - server-side rendered form"""
try: try:
return _load_plugin_config_partial(plugin_id) return _load_plugin_config_partial(plugin_id)
except Exception: except Exception as e:
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True) return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
def _load_overview_partial(): def _load_overview_partial():
"""Load overview partial with system stats""" """Load overview partial with system stats"""
@@ -111,8 +107,7 @@ def _load_overview_partial():
return render_template('v3/partials/overview.html', return render_template('v3/partials/overview.html',
main_config=main_config) main_config=main_config)
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_general_partial(): def _load_general_partial():
"""Load general settings partial""" """Load general settings partial"""
@@ -122,8 +117,7 @@ def _load_general_partial():
return render_template('v3/partials/general.html', return render_template('v3/partials/general.html',
main_config=main_config) main_config=main_config)
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_display_partial(): def _load_display_partial():
"""Load display settings partial""" """Load display settings partial"""
@@ -133,8 +127,7 @@ def _load_display_partial():
return render_template('v3/partials/display.html', return render_template('v3/partials/display.html',
main_config=main_config) main_config=main_config)
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_durations_partial(): def _load_durations_partial():
"""Load display durations partial""" """Load display durations partial"""
@@ -144,8 +137,7 @@ def _load_durations_partial():
return render_template('v3/partials/durations.html', return render_template('v3/partials/durations.html',
main_config=main_config) main_config=main_config)
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_schedule_partial(): def _load_schedule_partial():
"""Load schedule settings partial""" """Load schedule settings partial"""
@@ -161,8 +153,7 @@ def _load_schedule_partial():
dim_schedule_config=dim_schedule_config, dim_schedule_config=dim_schedule_config,
normal_brightness=normal_brightness) normal_brightness=normal_brightness)
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_weather_partial(): def _load_weather_partial():
@@ -173,8 +164,7 @@ def _load_weather_partial():
return render_template('v3/partials/weather.html', return render_template('v3/partials/weather.html',
main_config=main_config) main_config=main_config)
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_stocks_partial(): def _load_stocks_partial():
"""Load stocks configuration partial""" """Load stocks configuration partial"""
@@ -184,8 +174,7 @@ def _load_stocks_partial():
return render_template('v3/partials/stocks.html', return render_template('v3/partials/stocks.html',
main_config=main_config) main_config=main_config)
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_plugins_partial(): def _load_plugins_partial():
"""Load plugins management partial""" """Load plugins management partial"""
@@ -219,7 +208,7 @@ def _load_plugins_partial():
plugin_info.update(fresh_manifest) plugin_info.update(fresh_manifest)
except Exception as e: except Exception as e:
# If we can't read the fresh manifest, use the cached one # If we can't read the fresh manifest, use the cached one
logger.warning("Could not read fresh manifest for plugin: %s", plugin_id) print(f"Warning: Could not read fresh manifest for {plugin_id}: {e}")
# Get enabled status from config (source of truth) # Get enabled status from config (source of truth)
# Read from config file first, fall back to plugin instance if config doesn't have the key # Read from config file first, fall back to plugin instance if config doesn't have the key
@@ -267,13 +256,12 @@ def _load_plugins_partial():
'branch': branch 'branch': branch
}) })
except Exception as e: except Exception as e:
logger.error("Error loading plugin data", exc_info=True) print(f"Error loading plugin data: {e}")
return render_template('v3/partials/plugins.html', return render_template('v3/partials/plugins.html',
plugins=plugins_data) plugins=plugins_data)
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_fonts_partial(): def _load_fonts_partial():
"""Load fonts management partial""" """Load fonts management partial"""
@@ -283,16 +271,14 @@ def _load_fonts_partial():
return render_template('v3/partials/fonts.html', return render_template('v3/partials/fonts.html',
fonts=fonts_data) fonts=fonts_data)
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_logs_partial(): def _load_logs_partial():
"""Load logs viewer partial""" """Load logs viewer partial"""
try: try:
return render_template('v3/partials/logs.html') return render_template('v3/partials/logs.html')
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_raw_json_partial(): def _load_raw_json_partial():
"""Load raw JSON editor partial""" """Load raw JSON editor partial"""
@@ -309,16 +295,14 @@ def _load_raw_json_partial():
main_config_path=pages_v3.config_manager.get_config_path(), main_config_path=pages_v3.config_manager.get_config_path(),
secrets_config_path=pages_v3.config_manager.get_secrets_path()) secrets_config_path=pages_v3.config_manager.get_secrets_path())
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_backup_restore_partial(): def _load_backup_restore_partial():
"""Load backup & restore partial.""" """Load backup & restore partial."""
try: try:
return render_template('v3/partials/backup_restore.html') return render_template('v3/partials/backup_restore.html')
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
@pages_v3.route('/setup') @pages_v3.route('/setup')
def captive_setup(): def captive_setup():
@@ -330,24 +314,21 @@ def _load_wifi_partial():
try: try:
return render_template('v3/partials/wifi.html') return render_template('v3/partials/wifi.html')
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_cache_partial(): def _load_cache_partial():
"""Load cache management partial""" """Load cache management partial"""
try: try:
return render_template('v3/partials/cache.html') return render_template('v3/partials/cache.html')
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_operation_history_partial(): def _load_operation_history_partial():
"""Load operation history partial""" """Load operation history partial"""
try: try:
return render_template('v3/partials/operation_history.html') return render_template('v3/partials/operation_history.html')
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) return f"Error: {str(e)}", 500
return "Error loading partial", 500
def _load_plugin_config_partial(plugin_id): def _load_plugin_config_partial(plugin_id):
@@ -355,11 +336,6 @@ def _load_plugin_config_partial(plugin_id):
Load plugin configuration partial - server-side rendered form. Load plugin configuration partial - server-side rendered form.
This replaces the client-side generateConfigForm() JavaScript. This replaces the client-side generateConfigForm() JavaScript.
""" """
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
plugin_id = os.path.basename(plugin_id or '')
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._\-:]*$', plugin_id):
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
try: try:
if not pages_v3.plugin_manager: if not pages_v3.plugin_manager:
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500 return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
@@ -368,85 +344,80 @@ def _load_plugin_config_partial(plugin_id):
if plugin_id.startswith('starlark:'): if plugin_id.startswith('starlark:'):
return _load_starlark_config_partial(plugin_id[len('starlark:'):]) return _load_starlark_config_partial(plugin_id[len('starlark:'):])
# Resolve and validate all plugin paths against the plugins base directory
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
_plugin_dir = (_plugins_base / plugin_id).resolve()
try:
_plugin_dir.relative_to(_plugins_base)
except ValueError:
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
# Try to get plugin info first # Try to get plugin info first
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id) plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
# If not found, re-discover plugins (handles plugins added after startup) # If not found, re-discover plugins (handles plugins added after startup)
if not plugin_info: if not plugin_info:
pages_v3.plugin_manager.discover_plugins() pages_v3.plugin_manager.discover_plugins()
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id) plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
if not plugin_info: if not plugin_info:
return '<div class="text-red-500 p-4">Plugin not found</div>', 404 return f'<div class="text-red-500 p-4">Plugin "{escape(plugin_id)}" not found</div>', 404
# Get plugin instance (may be None if not loaded) # Get plugin instance (may be None if not loaded)
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id) plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
# Get plugin configuration from config file # Get plugin configuration from config file
config = {} config = {}
if pages_v3.config_manager: if pages_v3.config_manager:
full_config = pages_v3.config_manager.load_config() full_config = pages_v3.config_manager.load_config()
config = full_config.get(plugin_id, {}) config = full_config.get(plugin_id, {})
# Load uploaded images from metadata file if images field exists in schema # Load uploaded images from metadata file if images field exists in schema
schema_path_temp = _plugin_dir / "config_schema.json" # This ensures uploaded images appear even if config hasn't been saved yet
schema_path_temp = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
if schema_path_temp.exists(): if schema_path_temp.exists():
try: try:
with open(schema_path_temp, 'r', encoding='utf-8') as f: with open(schema_path_temp, 'r', encoding='utf-8') as f:
temp_schema = json.load(f) temp_schema = json.load(f)
# Check if schema has an images field with x-widget: file-upload
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'): temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
_assets_base = (Path(__file__).parent.parent.parent / 'assets' / 'plugins').resolve() # Load metadata file
metadata_file = (_assets_base / plugin_id / 'uploads' / '.metadata.json').resolve() # Get PROJECT_ROOT relative to this file
try: project_root = Path(__file__).parent.parent.parent
metadata_file.relative_to(_assets_base) metadata_file = project_root / 'assets' / 'plugins' / plugin_id / 'uploads' / '.metadata.json'
except ValueError: if metadata_file.exists():
metadata_file = None
if metadata_file and metadata_file.exists():
try: try:
with open(metadata_file, 'r', encoding='utf-8') as mf: with open(metadata_file, 'r', encoding='utf-8') as mf:
metadata = json.load(mf) metadata = json.load(mf)
# Convert metadata dict to list of image objects
images_from_metadata = list(metadata.values()) images_from_metadata = list(metadata.values())
# Only use metadata images if config doesn't have images or config images is empty
if not config.get('images') or len(config.get('images', [])) == 0: if not config.get('images') or len(config.get('images', [])) == 0:
config['images'] = images_from_metadata config['images'] = images_from_metadata
else: else:
# Merge: add metadata images that aren't already in config
config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')} config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')}
new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids] new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids]
if new_images: if new_images:
config['images'] = config.get('images', []) + new_images config['images'] = config.get('images', []) + new_images
except Exception as e: except Exception as e:
logger.warning("Could not load plugin upload metadata: %s", e) print(f"Warning: Could not load metadata for {plugin_id}: {e}")
except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below
logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e) logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e)
# Get plugin schema # Get plugin schema
schema = {} schema = {}
schema_path = _plugin_dir / "config_schema.json" schema_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
if schema_path.exists(): if schema_path.exists():
try: try:
with open(schema_path, 'r', encoding='utf-8') as f: with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f) schema = json.load(f)
except Exception as e: except Exception as e:
logger.warning("Could not load schema for plugin: %s", e) print(f"Warning: Could not load schema for {plugin_id}: {e}")
# Get web UI actions from plugin manifest # Get web UI actions from plugin manifest
web_ui_actions = [] web_ui_actions = []
manifest_path = _plugin_dir / "manifest.json" manifest_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
if manifest_path.exists(): if manifest_path.exists():
try: try:
with open(manifest_path, 'r', encoding='utf-8') as f: with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f) manifest = json.load(f)
web_ui_actions = manifest.get('web_ui_actions', []) web_ui_actions = manifest.get('web_ui_actions', [])
except Exception as e: except Exception as e:
logger.warning("Could not load manifest for plugin: %s", e) print(f"Warning: Could not load manifest for {plugin_id}: {e}")
# Mask secret fields before rendering template (fail closed — never leak secrets) # Mask secret fields before rendering template (fail closed — never leak secrets)
schema_properties = schema.get('properties') if isinstance(schema, dict) else None schema_properties = schema.get('properties') if isinstance(schema, dict) else None
@@ -482,24 +453,20 @@ def _load_plugin_config_partial(plugin_id):
) )
except Exception as e: except Exception as e:
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True) import traceback
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500 traceback.print_exc()
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
def _load_starlark_config_partial(app_id): def _load_starlark_config_partial(app_id):
"""Load configuration partial for a Starlark app.""" """Load configuration partial for a Starlark app."""
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
app_id = os.path.basename(app_id or '')
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_\-]*$', app_id):
return '<div class="text-red-500 p-4">Invalid app ID</div>', 400
try: try:
starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None
if starlark_plugin and hasattr(starlark_plugin, 'apps'): if starlark_plugin and hasattr(starlark_plugin, 'apps'):
app = starlark_plugin.apps.get(app_id) app = starlark_plugin.apps.get(app_id)
if not app: if not app:
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404 return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
return render_template( return render_template(
'v3/partials/starlark_config.html', 'v3/partials/starlark_config.html',
app_id=app_id, app_id=app_id,
@@ -515,45 +482,36 @@ def _load_starlark_config_partial(app_id):
) )
# Standalone: read from manifest file # Standalone: read from manifest file
starlark_base = (Path(__file__).resolve().parent.parent.parent / 'starlark-apps').resolve() manifest_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / 'manifest.json'
manifest_file = starlark_base / 'manifest.json'
if not manifest_file.exists(): if not manifest_file.exists():
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404 return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
with open(manifest_file, 'r') as f: with open(manifest_file, 'r') as f:
manifest = json.load(f) manifest = json.load(f)
app_data = manifest.get('apps', {}).get(app_id) app_data = manifest.get('apps', {}).get(app_id)
if not app_data: if not app_data:
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404 return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
# Load schema from schema.json if it exists — validate path stays within starlark_base # Load schema from schema.json if it exists
schema = None schema = None
schema_file = (starlark_base / app_id / 'schema.json').resolve() schema_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'schema.json'
try: if schema_file.exists():
schema_file.relative_to(starlark_base)
except ValueError:
schema_file = None
if schema_file and schema_file.exists():
try: try:
with open(schema_file, 'r') as f: with open(schema_file, 'r') as f:
schema = json.load(f) schema = json.load(f)
except (OSError, json.JSONDecodeError) as e: except (OSError, json.JSONDecodeError) as e:
logger.warning("Could not load starlark schema for app: %s", e) logger.warning(f"[Pages V3] Could not load schema for {app_id}: {e}", exc_info=True)
# Load config from config.json if it exists — validate path stays within starlark_base # Load config from config.json if it exists
config = {} config = {}
config_file = (starlark_base / app_id / 'config.json').resolve() config_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'config.json'
try: if config_file.exists():
config_file.relative_to(starlark_base)
except ValueError:
config_file = None
if config_file and config_file.exists():
try: try:
with open(config_file, 'r') as f: with open(config_file, 'r') as f:
config = json.load(f) config = json.load(f)
except (OSError, json.JSONDecodeError) as e: except (OSError, json.JSONDecodeError) as e:
logger.warning("Could not load starlark config for app: %s", e) logger.warning(f"[Pages V3] Could not load config for {app_id}: {e}", exc_info=True)
return render_template( return render_template(
'v3/partials/starlark_config.html', 'v3/partials/starlark_config.html',
@@ -570,5 +528,5 @@ def _load_starlark_config_partial(app_id):
) )
except Exception as e: except Exception as e:
logger.error("[Pages V3] Error loading starlark config for app", exc_info=True) logger.exception(f"[Pages V3] Error loading starlark config for {app_id}")
return '<div class="text-red-500 p-4">Error loading starlark config; see logs for details</div>', 500 return f'<div class="text-red-500 p-4">Error loading starlark config: {str(e)}</div>', 500

View File

@@ -51,7 +51,7 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
} }
}); });
// SSE reconnection helper — closes and reopens both SSE streams. // SSE reconnection helper
window.reconnectSSE = function() { window.reconnectSSE = function() {
if (window.statsSource) { if (window.statsSource) {
window.statsSource.close(); window.statsSource.close();
@@ -65,9 +65,8 @@ window.reconnectSSE = function() {
if (window.displaySource) { if (window.displaySource) {
window.displaySource.close(); window.displaySource.close();
window.displaySource = new EventSource('/api/v3/stream/display'); window.displaySource = new EventSource('/api/v3/stream/display');
window.displaySource.onmessage = function(event) { window.displaySource.onmessage = function() {
const data = JSON.parse(event.data); // Handle display updates
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
}; };
} }
}; };

View File

@@ -51,10 +51,8 @@
sanitizeValue(value) { sanitizeValue(value) {
// Base implementation - widgets should override for specific needs // Base implementation - widgets should override for specific needs
if (typeof value === 'string') { if (typeof value === 'string') {
// Strip all HTML tags via the DOM parser to prevent XSS // Basic XSS prevention
const div = document.createElement('div'); return value.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
div.textContent = value;
return div.textContent;
} }
return value; return value;
} }

View File

@@ -1442,14 +1442,9 @@ function renderInstalledPlugins(plugins) {
return; return;
} }
// Helper function to escape values for use in HTML attributes // Helper function to escape attributes for use in HTML
const escapeAttr = (text) => { const escapeAttr = (text) => {
return (text || '') return (text || '').replace(/'/g, "\\'").replace(/"/g, '&quot;');
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}; };
// Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping) // Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping)
@@ -4512,8 +4507,6 @@ function syncFormToJson() {
// Deep merge with existing config to preserve nested structures // Deep merge with existing config to preserve nested structures
function deepMerge(target, source) { function deepMerge(target, source) {
for (const key in source) { for (const key in source) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) { if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) {
target[key] = {}; target[key] = {};
@@ -7480,28 +7473,17 @@ setTimeout(function() {
console.log('installed-plugins-grid not found yet, will retry via event listeners'); console.log('installed-plugins-grid not found yet, will retry via event listeners');
} }
// Also try to attach install button handler after a delay (fallback). // Also try to attach install button handler after a delay (fallback)
// Only run if the install button element is already in the DOM (i.e. the
// plugins partial has been loaded); otherwise the htmx:afterSettle listener
// below handles it when the tab is first visited.
setTimeout(() => { setTimeout(() => {
if (typeof window.attachInstallButtonHandler === 'function' && if (typeof window.attachInstallButtonHandler === 'function') {
document.getElementById('install-plugin-from-url')) { console.log('[FALLBACK] Attempting to attach install button handler...');
window.attachInstallButtonHandler(); window.attachInstallButtonHandler();
} else {
console.warn('[FALLBACK] attachInstallButtonHandler not available on window');
} }
}, 500); }, 500);
}, 200); }, 200);
// Re-run install button wiring after HTMX settles the plugins tab content.
// Guard with element check so it only fires when the plugins partial is in the DOM,
// preventing spurious warnings on other tab loads.
document.addEventListener('htmx:afterSettle', function() {
if (document.getElementById('install-plugin-from-url') &&
typeof window.attachInstallButtonHandler === 'function') {
window.attachInstallButtonHandler();
}
});
// ─── Starlark Apps Integration ────────────────────────────────────────────── // ─── Starlark Apps Integration ──────────────────────────────────────────────
(function() { (function() {

View File

@@ -136,7 +136,6 @@
setTimeout(function() { setTimeout(function() {
if (typeof htmx !== 'undefined') { if (typeof htmx !== 'undefined') {
console.log('HTMX loaded from fallback'); console.log('HTMX loaded from fallback');
window.dispatchEvent(new Event('htmx:ready'));
// Load extensions after core loads // Load extensions after core loads
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js'); loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js'); loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
@@ -153,7 +152,6 @@
} }
} else { } else {
console.log('HTMX loaded successfully'); console.log('HTMX loaded successfully');
window.dispatchEvent(new Event('htmx:ready'));
// Load extensions after core loads // Load extensions after core loads
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js'); loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js'); loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
@@ -351,20 +349,6 @@
} }
} }
}); });
// Set data-loaded on tab containers after HTMX settles their content,
// preventing repeated re-fetches on every tab switch.
// Scoped to elements with hx-trigger="revealed" (tab containers only) so
// modals and plugin config panels that legitimately reload are unaffected.
document.body.addEventListener('htmx:afterSettle', function(event) {
if (event.detail && event.detail.target) {
var target = event.detail.target;
var trigger = target.getAttribute('hx-trigger') || '';
if (trigger.includes('revealed')) {
target.setAttribute('data-loaded', 'true');
}
}
});
} else { } else {
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupScriptExecution); document.addEventListener('DOMContentLoaded', setupScriptExecution);
@@ -427,9 +411,6 @@
.then(html => { .then(html => {
clearTimeout(timeout); clearTimeout(timeout);
content.innerHTML = html; content.innerHTML = html;
if (typeof htmx !== 'undefined') {
htmx.process(content);
}
// Trigger full initialization chain // Trigger full initialization chain
if (window.pluginManager) { if (window.pluginManager) {
window.pluginManager.initialized = false; window.pluginManager.initialized = false;
@@ -449,7 +430,7 @@
} }
// Fallback if HTMX doesn't load within 5 seconds // Fallback if HTMX doesn't load within 5 seconds
var _pluginsFallbackTimer = setTimeout(() => { setTimeout(() => {
if (typeof htmx === 'undefined') { if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins'); console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
// Load plugins tab content directly regardless of active tab, // Load plugins tab content directly regardless of active tab,
@@ -457,7 +438,6 @@
loadPluginsDirect(); loadPluginsDirect();
} }
}, 5000); }, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true });
</script> </script>
<!-- Alpine.js app function - defined early so it's available when Alpine initializes --> <!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script> <script>
@@ -1050,9 +1030,6 @@
.then(html => { .then(html => {
overviewContent.innerHTML = html; overviewContent.innerHTML = html;
overviewContent.setAttribute('data-loaded', 'true'); overviewContent.setAttribute('data-loaded', 'true');
if (typeof htmx !== 'undefined') {
htmx.process(overviewContent);
}
// Re-initialize Alpine.js for the new content // Re-initialize Alpine.js for the new content
if (window.Alpine) { if (window.Alpine) {
window.Alpine.initTree(overviewContent); window.Alpine.initTree(overviewContent);
@@ -1081,7 +1058,7 @@
}); });
// Also try direct load if HTMX doesn't load within 5 seconds // Also try direct load if HTMX doesn't load within 5 seconds
var _overviewFallbackTimer = setTimeout(() => { setTimeout(() => {
if (typeof htmx === 'undefined') { if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content'); console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
const appElement = document.querySelector('[x-data="app()"]'); const appElement = document.querySelector('[x-data="app()"]');
@@ -1093,7 +1070,6 @@
} }
} }
}, 5000); }, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
</script> </script>
<!-- General tab --> <!-- General tab -->
@@ -1370,58 +1346,33 @@
<!-- SSE connection for real-time updates --> <!-- SSE connection for real-time updates -->
<script> <script>
// Assign to window so reconnectSSE() in app.js can reach them. // Connect to SSE streams
window.statsSource = new EventSource('/api/v3/stream/stats'); const statsSource = new EventSource('/api/v3/stream/stats');
window.displaySource = new EventSource('/api/v3/stream/display'); const displaySource = new EventSource('/api/v3/stream/display');
window.statsSource.onmessage = function(event) { statsSource.onmessage = function(event) {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
updateSystemStats(data); updateSystemStats(data);
}; };
window.displaySource.onmessage = function(event) { displaySource.onmessage = function(event) {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
updateDisplayPreview(data); updateDisplayPreview(data);
}; };
function _setConnectionStatus(connected, reconnecting) { // Connection status
const el = document.getElementById('connection-status'); statsSource.addEventListener('open', function() {
if (!el) return; document.getElementById('connection-status').innerHTML = `
if (connected) { <div class="w-2 h-2 bg-green-500 rounded-full"></div>
el.innerHTML = ` <span class="text-gray-600">Connected</span>
<div class="w-2 h-2 bg-green-500 rounded-full"></div> `;
<span class="text-gray-600">Connected</span>
`;
} else if (reconnecting) {
el.innerHTML = `
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
<span class="text-gray-600">Reconnecting…</span>
`;
} else {
el.innerHTML = `
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
<span class="text-gray-600" title="Connection lost — try refreshing the page">Disconnected</span>
`;
}
}
var _statsErrorCount = 0;
window.statsSource.addEventListener('open', function() {
_statsErrorCount = 0;
_setConnectionStatus(true, false);
}); });
window.statsSource.addEventListener('error', function() { statsSource.addEventListener('error', function() {
_statsErrorCount++; document.getElementById('connection-status').innerHTML = `
// EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED <div class="w-2 h-2 bg-red-500 rounded-full"></div>
var reconnecting = window.statsSource.readyState === EventSource.CONNECTING; <span class="text-gray-600">Disconnected</span>
_setConnectionStatus(false, reconnecting && _statsErrorCount <= 3); `;
});
window.displaySource.addEventListener('error', function() {
// Display stream errors don't change the status badge but log to console
// so failures aren't completely silent.
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
}); });
function updateSystemStats(data) { function updateSystemStats(data) {
@@ -1865,18 +1816,13 @@
htmx.trigger(contentEl, 'revealed'); htmx.trigger(contentEl, 'revealed');
} }
} else { } else {
// HTMX is still loading asynchronously — retry when it signals ready, // HTMX not available, use direct fetch
// or fall back to direct fetch if it fails to load entirely. console.warn('HTMX not available, using direct fetch for tab:', tab);
const self = this; if (tab === 'overview' && typeof loadOverviewDirect === 'function') {
function onReady() { window.removeEventListener('htmx-load-failed', onFailed); self.loadTabContent(tab); } loadOverviewDirect();
function onFailed() { } else if (tab === 'wifi' && typeof loadWifiDirect === 'function') {
window.removeEventListener('htmx:ready', onReady); loadWifiDirect();
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
} }
window.addEventListener('htmx:ready', onReady, { once: true });
window.addEventListener('htmx-load-failed', onFailed, { once: true });
} }
}, },

View File

@@ -73,7 +73,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}' hx-vals='{"action": "start_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-play mr-2"></i> <i class="fas fa-play mr-2"></i>
Start Display Start Display
@@ -82,7 +82,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}' hx-vals='{"action": "stop_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
<i class="fas fa-stop mr-2"></i> <i class="fas fa-stop mr-2"></i>
Stop Display Stop Display
@@ -91,7 +91,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "git_pull"}' hx-vals='{"action": "git_pull"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i> <i class="fas fa-download mr-2"></i>
Update Code Update Code
@@ -101,7 +101,7 @@
hx-vals='{"action": "reboot_system"}' hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?" hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-power-off mr-2"></i> <i class="fas fa-power-off mr-2"></i>
Reboot System Reboot System

View File

@@ -151,7 +151,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}' hx-vals='{"action": "start_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-play mr-2"></i> <i class="fas fa-play mr-2"></i>
Start Display Start Display
@@ -160,7 +160,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}' hx-vals='{"action": "stop_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
<i class="fas fa-stop mr-2"></i> <i class="fas fa-stop mr-2"></i>
Stop Display Stop Display
@@ -170,7 +170,7 @@
hx-vals='{"action": "git_pull"}' hx-vals='{"action": "git_pull"}'
hx-confirm="This will stash any local changes and update the code. Continue?" hx-confirm="This will stash any local changes and update the code. Continue?"
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i> <i class="fas fa-download mr-2"></i>
Update Code Update Code
@@ -180,7 +180,7 @@
hx-vals='{"action": "reboot_system"}' hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?" hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-power-off mr-2"></i> <i class="fas fa-power-off mr-2"></i>
Reboot System Reboot System
@@ -190,7 +190,7 @@
hx-vals='{"action": "shutdown_system"}' hx-vals='{"action": "shutdown_system"}'
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi." hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System shutting down...', event.detail.xhr.responseJSON.status || 'info'); }"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
<i class="fas fa-power-off mr-2"></i> <i class="fas fa-power-off mr-2"></i>
Shutdown System Shutdown System
@@ -199,7 +199,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_display_service"}' hx-vals='{"action": "restart_display_service"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display service restarted', event.detail.xhr.responseJSON.status || 'success'); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i> <i class="fas fa-redo mr-2"></i>
Restart Display Service Restart Display Service
@@ -208,7 +208,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_web_service"}' hx-vals='{"action": "restart_web_service"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Web service restarted', event.detail.xhr.responseJSON.status || 'success'); }"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i> <i class="fas fa-redo mr-2"></i>
Restart Web Service Restart Web Service