1 Commits

Author SHA1 Message Date
Chuck
45bf5db2b1 chore: remove march-madness from bundled plugin-repos
March Madness is now available in the ledmatrix-plugins monorepo store
(ChuckBuilds/ledmatrix-plugins/plugins/march-madness) and should be
installed via the Plugin Store like any other plugin.

Removing the bundled copy so new installs don't automatically include it.
Existing users keep their installed version until they choose to uninstall.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:58:10 -04:00
17 changed files with 544 additions and 972 deletions

View File

@@ -1,10 +1,5 @@
# LEDMatrix # LEDMatrix
[![License](https://img.shields.io/badge/license-GPL--3.0-green)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-community-5865F2?logo=discord&logoColor=white)](https://discord.gg/RdrC37rEag)
[![GitHub Stars](https://img.shields.io/github/stars/ChuckBuilds/ledmatrix?style=flat&color=yellow)](https://github.com/ChuckBuilds/ledmatrix)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/77fc9b446a5948e5b0aed7a7aaeb1bab)](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/77fc9b446a5948e5b0aed7a7aaeb1bab)](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
## Welcome to LEDMatrix! ## Welcome to LEDMatrix!
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together. Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
@@ -132,15 +127,10 @@ The system supports live, recent, and upcoming game information for multiple spo
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. | | This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
### Raspberry Pi ### Raspberry Pi
- Raspberry Pi Zero's don't have enough processing power for this project. - Raspberry Pi Zero's don't have enough processing power for this project and the Pi 5 is unsupported due to new GPIO output.
- **Raspberry Pi 3B, 4, or 5** - **Raspberry Pi 3B or 4 (NOT RPi 5!)**
[Amazon Affiliate Link Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX) [Amazon Affiliate Link Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
[Amazon Affiliate Link Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F) [Amazon Affiliate Link Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
- **Pi 5 users**: the installer automatically detects Pi 5 and builds the `rpi-rgb-led-matrix` library with RP1 support. If you previously installed on a Pi 4 and migrated the SD card, or if you see `mmap` errors in the logs, force a fresh library build:
```bash
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
```
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
### RGB Matrix Bonnet / HAT ### RGB Matrix Bonnet / HAT
@@ -592,7 +582,7 @@ These settings control runtime behavior and GPIO timing:
- **Critical setting**: Must match your Raspberry Pi model for stability - **Critical setting**: Must match your Raspberry Pi model for stability
- **Raspberry Pi 3**: Use 3 - **Raspberry Pi 3**: Use 3
- **Raspberry Pi 4**: Use 4 - **Raspberry Pi 4**: Use 4
- **Raspberry Pi 5**: Use 12 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering - **Raspberry Pi 5**: Use 5 (or higher if needed)
- **Raspberry Pi Zero/1**: Use 1-2 - **Raspberry Pi Zero/1**: Use 1-2
- Incorrect values can cause display corruption, flickering, or system instability - Incorrect values can cause display corruption, flickering, or system instability
- If you experience issues, try adjusting this value up or down by 1 - If you experience issues, try adjusting this value up or down by 1

View File

@@ -36,17 +36,9 @@ if [ -r /proc/device-tree/model ]; then
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model) DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
echo "Detected device: $DEVICE_MODEL" echo "Detected device: $DEVICE_MODEL"
else else
DEVICE_MODEL=""
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)" echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
fi fi
# Detect Pi 5 for hardware-specific install decisions (RP1 library verification)
IS_PI5=0
if echo "${DEVICE_MODEL:-}" | grep -qi "Raspberry Pi 5"; then
IS_PI5=1
echo "Raspberry Pi 5 detected — will verify RP1 library support."
fi
# Check OS version - must be Raspberry Pi OS Lite (Trixie) # Check OS version - must be Raspberry Pi OS Lite (Trixie)
echo "" echo ""
echo "Checking operating system requirements..." echo "Checking operating system requirements..."
@@ -791,28 +783,9 @@ CURRENT_STEP="Build and install rpi-rgb-led-matrix"
echo "Step 6: Building and installing rpi-rgb-led-matrix..." echo "Step 6: Building and installing rpi-rgb-led-matrix..."
echo "-----------------------------------------------------" echo "-----------------------------------------------------"
# On Pi 5, also check that the installed library has rp1_rio support. # If already installed and not forcing rebuild, skip expensive build
# A library built before Pi 5 support was added imports fine but maps to the
# Pi 3 peripheral bus address (0x3f000000) instead of the RP1 chip at runtime.
_HAS_RP1=0
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
_HAS_RP1=1
fi
_SKIP_BUILD=0
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
if [ "$IS_PI5" = "1" ] && [ "$_HAS_RP1" = "0" ]; then echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
echo "⚠ Pi 5 detected: installed rgbmatrix lacks rp1_rio support (older build)."
echo " Forcing rebuild to get Pi 5 RP1 support..."
else
_SKIP_BUILD=1
fi
fi
if [ "$_SKIP_BUILD" = "1" ]; then
_skip_suffix=""
if [ "$IS_PI5" = "1" ]; then _skip_suffix=" with Pi 5 RP1 support"; fi
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
else else
# Ensure rpi-rgb-led-matrix submodule is initialized # Ensure rpi-rgb-led-matrix submodule is initialized
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
@@ -879,17 +852,6 @@ except Exception as e:
PY PY
then then
echo "✓ rpi-rgb-led-matrix installed and verified" echo "✓ rpi-rgb-led-matrix installed and verified"
# Pi 5: confirm the freshly-built library has rp1_rio support
if [ "$IS_PI5" = "1" ]; then
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
echo "✓ Pi 5 RP1 (rp1_rio) support confirmed"
else
echo "⚠ rp1_rio not found after rebuild — the submodule may be an older version."
echo " Try updating the submodule and rebuilding:"
echo " git submodule update --remote rpi-rgb-led-matrix-master"
echo " sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
fi
fi
else else
echo "✗ rpi-rgb-led-matrix import test failed" echo "✗ rpi-rgb-led-matrix import test failed"
exit 1 exit 1

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

@@ -110,10 +110,9 @@ class DisplayManager:
options.rp1_rio = runtime_config.get('rp1_rio') options.rp1_rio = runtime_config.get('rp1_rio')
else: else:
logger.warning( logger.warning(
"rp1_rio is set in config but the installed rgbmatrix library does " "rp1_rio is set in config but the current RGBMatrixOptions "
"not support it — the library was likely built without Pi 5 RP1 " "implementation does not support it (RGBMatrixEmulator or older "
"support (mmap to 0x3f000000 instead of RP1 chip). " "library version) — value will be ignored"
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
) )
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}") logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
@@ -190,7 +189,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

@@ -10,7 +10,6 @@ import json
import stat import stat
import subprocess import subprocess
import shutil import shutil
import threading
import zipfile import zipfile
import tempfile import tempfile
import requests import requests
@@ -21,8 +20,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:
@@ -103,10 +100,6 @@ class PluginStoreManager:
# handlers. Bumping the cached-entry timestamp on failure serves # handlers. Bumping the cached-entry timestamp on failure serves
# the stale payload cheaply until the backoff expires. # the stale payload cheaply until the backoff expires.
self._failure_backoff_seconds = 60 self._failure_backoff_seconds = 60
# Prevents concurrent callers from each firing a network request when
# the registry cache expires. Only one thread fetches; others wait and
# then get the result from the warm cache (double-checked locking).
self._registry_fetch_lock = threading.Lock()
# Ensure plugins directory exists # Ensure plugins directory exists
self.plugins_dir.mkdir(exist_ok=True) self.plugins_dir.mkdir(exist_ok=True)
@@ -358,8 +351,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]
@@ -523,8 +515,7 @@ class PluginStoreManager:
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]
@@ -584,50 +575,41 @@ class PluginStoreManager:
(current_time - self.registry_cache_time) < self.registry_cache_timeout): (current_time - self.registry_cache_time) < self.registry_cache_timeout):
return self.registry_cache return self.registry_cache
with self._registry_fetch_lock: try:
# Re-check inside the lock — a concurrent caller that was waiting self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
# may have already populated the cache while we blocked. response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
current_time = time.time() response.raise_for_status()
if (self.registry_cache and self.registry_cache_time and self.registry_cache = response.json()
not force_refresh and self.registry_cache_time = current_time
(current_time - self.registry_cache_time) < self.registry_cache_timeout): self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
return self.registry_cache
except requests.RequestException as e:
self.logger.error(f"Error fetching registry: {e}")
if raise_on_failure:
raise
# Prefer stale cache over an empty list so the plugin list UI
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
# registry_cache_time into a short backoff window so the next
# request serves the stale payload cheaply instead of
# re-hitting the network on every request (matches the
# pattern used by github_cache / commit_info_cache).
if self.registry_cache:
self.logger.warning("Falling back to stale registry cache")
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache return self.registry_cache
return {"plugins": []}
try: except json.JSONDecodeError as e:
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}") self.logger.error(f"Error parsing registry JSON: {e}")
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10) if raise_on_failure:
response.raise_for_status() raise
self.registry_cache = response.json() if self.registry_cache:
self.registry_cache_time = current_time self.registry_cache_time = (
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins") time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache return self.registry_cache
except requests.RequestException as e: return {"plugins": []}
self.logger.error(f"Error fetching registry: {e}")
if raise_on_failure:
raise
# Prefer stale cache over an empty list so the plugin list UI
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
# registry_cache_time into a short backoff window so the next
# request serves the stale payload cheaply instead of
# re-hitting the network on every request (matches the
# pattern used by github_cache / commit_info_cache).
if self.registry_cache:
self.logger.warning("Falling back to stale registry cache")
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache
return {"plugins": []}
except json.JSONDecodeError as e:
self.logger.error(f"Error parsing registry JSON: {e}")
if raise_on_failure:
raise
if self.registry_cache:
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache
return {"plugins": []}
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]: def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
""" """
@@ -779,8 +761,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

@@ -204,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:
@@ -330,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
@@ -470,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
@@ -534,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
@@ -578,19 +598,17 @@ 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
@@ -698,41 +716,6 @@ def _run_startup_reconciliation() -> None:
"manual 'Reconcile' action to resolve.", "manual 'Reconcile' action to resolve.",
len(result.inconsistencies_manual), len(result.inconsistencies_manual),
) )
# Write status file so the web UI can surface unresolved issues as a
# banner without the user having to read journalctl. Mirrors the
# hw_status pattern (/tmp/led_matrix_hw_status.json).
import json as _json, tempfile as _tempfile, os as _os
_recon_status = {
"done": True,
"successful": result.reconciliation_successful,
"fixed_count": len(result.inconsistencies_fixed),
"unresolved": [
{
"plugin_id": inc.plugin_id,
"type": inc.inconsistency_type.value,
"description": inc.description,
}
for inc in result.inconsistencies_manual
],
}
_recon_path = _os.path.join(_tempfile.gettempdir(), "ledmatrix_reconciliation.json")
_tmp = None
try:
if not _os.path.islink(_recon_path):
_fd, _tmp = _tempfile.mkstemp(dir=_tempfile.gettempdir(), prefix=".led_recon_")
with _os.fdopen(_fd, "w") as _f:
_json.dump(_recon_status, _f)
_os.replace(_tmp, _recon_path)
_tmp = None # Rename succeeded; nothing to clean up
except (OSError, ValueError, TypeError) as _e:
_logger.warning("[Reconciliation] Could not write status file: %s", _e)
finally:
if _tmp is not None and _os.path.exists(_tmp):
try:
_os.unlink(_tmp)
except OSError:
pass
except Exception as e: except Exception as e:
_logger.error("[Reconciliation] Error: %s", e, exc_info=True) _logger.error("[Reconciliation] Error: %s", e, exc_info=True)
finally: finally:

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_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,14 +344,6 @@ 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)
@@ -385,7 +353,7 @@ def _load_plugin_config_partial(plugin_id):
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)
@@ -397,56 +365,59 @@ def _load_plugin_config_partial(plugin_id):
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,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 -->
@@ -1840,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

@@ -843,14 +843,6 @@ async function updateFontPreview() {
return; return;
} }
// BDF bitmap fonts cannot be rendered server-side — skip the API call
if (family.toLowerCase().endsWith('.bdf')) {
previewImage.style.display = 'none';
loadingText.style.display = 'block';
loadingText.textContent = 'Preview not available for BDF bitmap fonts';
return;
}
// Show loading state // Show loading state
loadingText.textContent = 'Loading preview...'; loadingText.textContent = 'Loading preview...';
loadingText.style.display = 'block'; loadingText.style.display = 'block';

View File

@@ -1,66 +1,3 @@
<!-- Reconciliation warning banner: shown when startup reconciliation found stale plugin config entries -->
<div id="reconciliation-banner" class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-4 flex items-start" style="display:none !important" role="alert">
<div class="flex-shrink-0 mr-3 mt-0.5">
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-800">Plugin Config Warning</p>
<p class="text-sm text-yellow-700 mt-1" id="reconciliation-banner-text"></p>
</div>
<button type="button" onclick="window.dismissReconciliationBanner()" class="ml-4 flex-shrink-0 text-yellow-500 hover:text-yellow-700" aria-label="Dismiss">
<i class="fas fa-times"></i>
</button>
</div>
<script>
(function () {
var DISMISS_KEY = 'ledmatrix-recon-dismissed';
var _recon_timer = null;
function checkReconciliation() {
fetch('/api/v3/plugins/reconciliation-status')
.then(function (r) { return r.json(); })
.then(function (resp) {
var d = resp.data || {};
if (!d.done) {
// Reconciliation still running — poll again shortly
_recon_timer = setTimeout(checkReconciliation, 2000);
return;
}
_recon_timer = null;
if (!d.unresolved || d.unresolved.length === 0) return;
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
var ids = d.unresolved.map(function (i) { return i.plugin_id; }).join(', ');
document.getElementById('reconciliation-banner-text').textContent =
'Stale plugin config entries found: ' + ids +
'. Remove them from config.json or reinstall via the Plugin Store.';
var banner = document.getElementById('reconciliation-banner');
banner.dataset.dismissKey = key;
banner.style.setProperty('display', 'flex', 'important');
})
.catch(function () {});
}
checkReconciliation();
window.dismissReconciliationBanner = function () {
var banner = document.getElementById('reconciliation-banner');
banner.style.setProperty('display', 'none', 'important');
if (_recon_timer !== null) {
clearTimeout(_recon_timer);
_recon_timer = null;
}
// Persist dismissal immediately so the banner won't reappear on reload
// even if the background sync fetch below fails.
var key = banner.dataset.dismissKey;
if (key) {
try { sessionStorage.setItem(DISMISS_KEY, key); } catch (e) {}
}
// Background sync only — do not rely on this for DISMISS_KEY or hiding.
fetch('/api/v3/plugins/reconciliation-status').catch(function () {});
};
}());
</script>
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2> <h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
@@ -151,7 +88,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 +97,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 +107,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 +117,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 +127,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 +136,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 +145,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

View File

@@ -9,8 +9,7 @@
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %} {% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %} {% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
{% set description = prop.description if prop.description else '' %} {% set description = prop.description if prop.description else '' %}
{% set _pt = prop.get('type') %} {% set field_type = prop.type if prop.type is string else (prop.type[0] if prop.type is iterable else 'string') %}
{% set field_type = _pt if (_pt is string) else ((_pt | first) if (_pt and _pt is iterable and _pt is not string) else 'string') %}
{# Handle nested objects - check for widget first #} {# Handle nested objects - check for widget first #}
{% if field_type == 'object' %} {% if field_type == 'object' %}