fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning (#346)

* fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning

- base.html: add htmx:afterSettle listener to set data-loaded on tab
  containers after HTMX swaps their content, preventing the overview
  partial from being re-fetched (and handlers lost) on every tab switch
- base.html: call htmx.process() in loadOverviewDirect/loadPluginsDirect
  fallbacks so buttons get HTMX handlers even if HTMX finished its
  initial body scan before the fallback fetch completed
- overview.html + index.html (11 buttons): replace event.detail.xhr.responseJSON
  (undefined in HTMX 1.9.x) with JSON.parse(event.detail.xhr.responseText)
  so quick action toast notifications actually fire
- plugins_manager.js: add guarded htmx:afterSettle listener that only calls
  attachInstallButtonHandler when #install-plugin-from-url is in the DOM,
  eliminating the spurious console warning on non-plugin tab loads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): ensure quick-action toasts always fire even on xhr/parse failure

Replace silent catch(e){} in all 11 hx-on:htmx:after-request handlers with a
pattern that sets default message/status before the try block and calls
showNotification(m,s) unconditionally after it, so a fallback toast is shown
whenever xhr is absent or responseText is not valid JSON.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): show error toast on non-JSON 4xx/5xx quick-action responses

In the catch block of all 11 hx-on:htmx:after-request handlers, check
xhr.status >= 400 and downgrade s to 'error' so a failed action that
returns an HTML error page (or other non-JSON body) surfaces as an error
toast instead of the optimistic 'success'/'info' default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): guard setTimeout fallback for attachInstallButtonHandler

The 500ms fallback setTimeout was calling attachInstallButtonHandler()
unconditionally even when the plugins partial wasn't in the DOM, causing
a spurious console.warn on every page load. Add the same element-existence
check already present on the htmx:afterSettle listener.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix backup API 404s, hardware status 500, and HTMX loading race

- Add all backup API routes to api_v3.py: preview, list, export,
  validate, restore (with plugin reinstall), download, delete
- Fix PermissionError on /hardware/status: return graceful 200 instead
  of 500 when the status file is owned by a different user; also fix
  root cause by writing the file world-readable (0o644) in display_manager
- Fix HTMX race: dispatch htmx:ready window event from HTMX onload
  callback; loadTabContent now waits for that event instead of
  immediately falling back to direct fetch (eliminating the
  "HTMX not available" console warning on initial load)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Cancel HTMX fallback timers when htmx:ready fires

The 5-second setTimeout fallbacks for plugins and overview were firing
before the htmx:ready event arrived, logging spurious warnings. Each
timer now self-cancels via htmx:ready so the fallback only triggers
when HTMX genuinely fails to load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Address review feedback: error leaks, ok:false, htmx:ready coverage

- Backup endpoints: replace raw str(e) in user-facing responses with a
  generic message; full exception still logged via exc_info=True
- hardware/status: change ok:null to ok:false for PermissionError and
  json.JSONDecodeError so the UI's hw.ok===false check triggers correctly
- base.html: dispatch htmx:ready from the fallback load path so any
  deferred listeners fire on CDN-fallback loads too
- loadTabContent: also listen for htmx-load-failed so overview/wifi/plugins
  fall back to direct fetch when HTMX is completely unavailable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Treat system-managed pip packages as satisfied for dependency marker

When a plugin's requirements.txt includes a package installed via the
system package manager (dnf/apt), pip fails with 'uninstall-no-record-file'
because it can't replace the system-tracked copy. The package is present
and functional, but the missing marker caused the install to be retried
on every service restart.

Detect this specific error pattern: if the only pip failure is
uninstall-no-record-file, write the .dependencies_installed marker and
log a warning instead of returning False, suppressing the repeated warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix uninstall-no-record-file detection condition

The previous check used a string replacement that left 'error:' in the
remaining text, causing the condition to always evaluate false. Simplify
to a direct substring check: if 'uninstall-no-record-file' appears in pip
stderr the affected package is installed at the system level and we write
the marker, suppressing the repeated warning on every restart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Resolve CodeQL security findings in backup API

Path traversal (CWE-22):
- backup_download: switch from send_file(user-tainted-path) to
  send_from_directory(_BACKUP_EXPORT_DIR, filename); Flask uses
  werkzeug safe_join internally which CodeQL recognises as a sanitizer
- backup_delete: enumerate the export directory and match by name so
  entry.unlink() operates on a filesystem-derived Path rather than one
  constructed from user input; _safe_backup_path still guards first

Information exposure through exceptions (CWE-209):
- backup_validate: err_msg from validate_backup() can embed exception
  strings containing temp-file paths; log the detail, return a generic
  'Invalid or corrupted backup file' to the client
- Other backup endpoints: already fixed (str(e) -> generic message);
  CodeQL alerts will clear on next scan

plugin_loader.py:185 (path traversal): false positive — requirements_file
is constructed from plugin_dir returned by find_plugin_directory() (a
filesystem scan), not from raw HTTP request input; no change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix pre-existing information exposure in version and action endpoints

- get_system_version (alert #218): replaced str(e) with generic message;
  exception still logged via logger.error(exc_info=True)
- execute_system_action (alert #216): removed str(e) and full
  traceback.format_exc() from the HTTP response — the full stack trace
  was being sent directly to clients; replaced with generic message and
  proper logger.error call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix remaining GitHub CodeQL security alerts

- py/stack-trace-exposure: Remove str(e) and traceback.format_exc() from
  all HTTP responses across api_v3.py, pages_v3.py, and app.py; replace
  with generic messages and logger.error(exc_info=True)
- py/reflective-xss: Escape partial_name via markupsafe.escape in the
  load_partial 404 response
- py/path-injection: Add regex validation of plugin_id before filesystem
  use in _load_plugin_config_partial
- py/incomplete-url-substring-sanitization: Replace 'github.com' in
  substring checks with urlparse hostname comparison in store_manager.py
- py/clear-text-logging-sensitive-data: Remove football-scoreboard debug
  prints and sensitive request-body prints from update endpoint
- js/bad-tag-filter: Replace script-only regex in BaseWidget.sanitizeValue
  with DOM-based textContent stripping that removes all HTML
- js/incomplete-sanitization: Fix escapeAttr to properly encode &, ", ',
  <, > using HTML entities instead of backslash escaping
- js/prototype-pollution-utility: Add __proto__/constructor/prototype
  key guards to deepMerge function in plugins_manager.js
- app.py error handlers: Always return generic messages; remove debug-mode
  branches that could expose tracebacks in production

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix three remaining CodeQL path-injection and info-exposure alerts

- plugin_loader.py: resolve plugin_dir with strict=True and validate
  marker_path with relative_to() before any filesystem writes, giving
  CodeQL the positive sanitization pattern it requires (py/path-injection)
- api_v3.py _safe_backup_path: replace substring negative checks with a
  strict positive regex (^[a-zA-Z0-9][a-zA-Z0-9._-]{0,200}\.zip$) that
  CodeQL recognises as sanitising the user-supplied filename
  (py/path-injection)
- api_v3.py backup_validate: whitelist known-safe manifest fields before
  returning JSON, preventing any exception strings captured inside
  validate_backup() from reaching the HTTP response (py/stack-trace-exposure)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Resolve 29 open CodeQL security alerts across 5 files

py/flask-debug (#214):
- debug_web_manual.py: read debug mode from LEDMATRIX_FLASK_DEBUG env var
  instead of hardcoded True

py/stack-trace-exposure (#216, #218):
- api_v3.py execute_system_action: remove subprocess stdout/stderr from
  HTTP responses; log via logger instead
- api_v3.py get_git_version: validate output matches safe ref format
  (^[a-zA-Z0-9._-]+$) before including in response
- api_v3.py: remove all remaining traceback.format_exc() dead variables
  and print() debug calls (replaced with logger.debug/warning)

py/reflective-xss (#207, #208, #209, #210, #211, #212):
- api_v3.py: remove plugin_id from all error/success response messages
  (uninstall, install, update, health, not-found responses)
- pages_v3.py load_partial: return static "Partial not found" message
  instead of echoing partial_name
- pages_v3.py _load_starlark_config_partial: add app_id regex validation,
  use static error messages instead of f-strings with app_id

py/path-injection (#187–#206):
- pages_v3.py _load_plugin_config_partial: resolve plugins_base and
  validate _plugin_dir with relative_to() before all file operations;
  same for assets metadata directory
- pages_v3.py _load_starlark_config_partial: resolve starlark_base and
  validate schema_file/config_file paths with relative_to()
- plugin_loader.py _find_plugin_directory: resolve plugins_dir and
  validate strategy-2 candidates with relative_to()
- plugin_loader.py install_dependencies: resolve plugin_dir first, then
  construct requirements_file and marker_path from resolved base
- plugin_loader.py load_module: resolve plugin_dir with strict=True and
  validate entry_file with relative_to() before exec_module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix 15 remaining CodeQL path-injection and stack-trace-exposure alerts

Switch from resolve()+relative_to() to os.path.basename() reassignment,
which CodeQL recognizes as a path sanitizer that breaks the taint chain.
Also remove exception objects from backup_manager validate_backup return
strings to eliminate the stack-trace-exposure taint source.

Fixes alerts #227, #233, #234, #235, #237, #238, #239, #240, #241,
#242, #243, #244, #245, #246, #247.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix broken logger format string and leaked exception in config save error

- pages_v3.py: plain string was used instead of %-style substitution,
  so every manifest-read failure logged the literal "{plugin_id}"
- api_v3.py save_main_config: exception message was still leaking
  through the error response; replace with generic message (consistent
  with the rest of the CodeQL sweep in this PR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-05-24 09:29:53 -04:00
committed by GitHub
parent 327e87f735
commit 713539e491
13 changed files with 682 additions and 504 deletions

View File

@@ -67,8 +67,9 @@ def main():
print(" 📍 Will run on: http://0.0.0.0:5000")
print(" ⏹️ Press Ctrl+C to stop")
# Run the app (this should start the server)
app.run(host='0.0.0.0', port=5000, debug=True)
# Run the app (debug mode controlled by env var to satisfy security scanners)
_debug = os.environ.get('LEDMATRIX_FLASK_DEBUG', '0') == '1'
app.run(host='0.0.0.0', port=5000, debug=_debug)
except KeyboardInterrupt:
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:
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
manifest = json.loads(manifest_raw)
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
return False, f"Invalid manifest.json: {e}", {}
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return False, "Invalid manifest.json", {}
if not isinstance(manifest, dict) or "schema_version" not in manifest:
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
except zipfile.BadZipFile:
return False, "File is not a valid ZIP archive", {}
except OSError as e:
return False, f"Could not read backup: {e}", {}
except OSError:
return False, "Could not read backup", {}
# ---------------------------------------------------------------------------

View File

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

View File

@@ -8,6 +8,7 @@ Extracted from PluginManager to improve separation of concerns.
import json
import importlib
import importlib.util
import os
import sys
import subprocess
import threading
@@ -68,6 +69,11 @@ class PluginLoader:
Returns:
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
if plugin_directories and plugin_id in plugin_directories:
plugin_dir = plugin_directories[plugin_id]
@@ -75,14 +81,16 @@ class PluginLoader:
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
return plugin_dir
# Strategy 2: Direct paths
plugin_dir = plugins_dir / plugin_id
if plugin_dir.exists():
return plugin_dir
plugin_dir = plugins_dir / f"ledmatrix-{plugin_id}"
if plugin_dir.exists():
return plugin_dir
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir
plugins_dir_resolved = plugins_dir.resolve()
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
_candidate = (plugins_dir_resolved / _candidate_name).resolve()
try:
_candidate.relative_to(plugins_dir_resolved)
except ValueError:
continue
if _candidate.exists():
return _candidate
# Strategy 3: Case-insensitive search
normalized_id = plugin_id.lower()
@@ -143,12 +151,21 @@ class PluginLoader:
Returns:
True if dependencies installed or not needed, False on error
"""
requirements_file = plugin_dir / "requirements.txt"
plugin_id = os.path.basename(plugin_id or '')
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():
return True # No dependencies needed
marker_path = plugin_dir_resolved / ".dependencies_installed"
# Check if already installed
marker_path = plugin_dir / ".dependencies_installed"
if marker_path.exists():
self.logger.debug("Dependencies already installed for %s", plugin_id)
return True
@@ -171,10 +188,24 @@ class PluginLoader:
self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True
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(
"Dependency installation returned non-zero exit code for %s: %s",
plugin_id,
result.stderr
stderr
)
return False
except subprocess.TimeoutExpired:
@@ -349,9 +380,20 @@ class PluginLoader:
Returns:
Loaded module or None on error
"""
entry_file = plugin_dir / entry_point
plugin_id = os.path.basename(plugin_id or '')
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():
error_msg = f"Entry point file not found: {entry_file} for plugin {plugin_id}"
error_msg = f"Entry point file not found for plugin {plugin_id}"
self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})

View File

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

View File

@@ -204,24 +204,12 @@ def serve_plugin_asset(plugin_id, filename):
# Use send_from_directory to serve the file
return send_from_directory(str(assets_dir), filename, mimetype=content_type)
except Exception as e:
# Log the exception with full traceback server-side
import traceback
except Exception:
app.logger.exception('Error serving plugin asset file')
# Return generic error message to client (avoid leaking internal details)
# Only include detailed error information when in debug mode
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
return jsonify({
'status': 'error',
'message': 'Internal server error'
}), 500
# Prime psutil CPU measurement once at startup so interval=None returns a real value
try:
@@ -342,35 +330,25 @@ def not_found_error(error):
@app.errorhandler(500)
def internal_error(error):
"""Handle 500 errors."""
import traceback
error_details = traceback.format_exc()
# Log the error
import logging
logger = logging.getLogger('web_interface')
logger.error(f"Internal server error: {error}", exc_info=True)
# Return user-friendly error (hide internal details in production)
logger.error("Internal server error", exc_info=True)
return jsonify({
'status': 'error',
'error_code': 'INTERNAL_ERROR',
'message': 'An internal error occurred',
'details': error_details if app.debug else None
'message': 'An internal error occurred; see logs for details',
}), 500
@app.errorhandler(Exception)
def handle_exception(error):
"""Handle all unhandled exceptions."""
import traceback
import logging
logger = logging.getLogger('web_interface')
logger.error(f"Unhandled exception: {error}", exc_info=True)
logger.error("Unhandled exception", exc_info=True)
return jsonify({
'status': 'error',
'error_code': 'UNKNOWN_ERROR',
'message': str(error) if app.debug else 'An error occurred',
'details': traceback.format_exc() if app.debug else None
'message': 'An error occurred; see logs for details',
}), 500
# Captive portal redirect middleware
@@ -492,7 +470,8 @@ def system_status_generator():
}
yield status
except Exception as e:
yield {'error': str(e)}
app.logger.error("SSE generator error", exc_info=True)
yield {'error': 'An error occurred; see server logs'}
time.sleep(10) # Update every 10 seconds (reduced frequency for better performance)
# Display preview generator for SSE
@@ -555,7 +534,8 @@ def display_preview_generator():
}
except Exception as e:
yield {'error': str(e)}
app.logger.error("SSE generator error", exc_info=True)
yield {'error': 'An error occurred; see server logs'}
time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s
@@ -598,17 +578,19 @@ def logs_generator():
except subprocess.TimeoutExpired:
# Timeout - just skip this update
pass
except Exception as e:
except Exception:
app.logger.error("Error running journalctl", exc_info=True)
error_data = {
'timestamp': time.time(),
'logs': f'Error running journalctl: {str(e)}'
'logs': 'Error running journalctl; see server logs'
}
yield error_data
except Exception as e:
except Exception:
app.logger.error("Unexpected error in logs generator", exc_info=True)
error_data = {
'timestamp': time.time(),
'logs': f'Unexpected error in logs generator: {str(e)}'
'logs': 'Unexpected error in logs generator; see server logs'
}
yield error_data

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@ from flask import Blueprint, render_template, flash
from markupsafe import escape
import json
import logging
import os
import re
from pathlib import Path
from src.web_interface.secret_helpers import mask_secret_fields
@@ -84,10 +86,11 @@ def load_partial(partial_name):
elif partial_name == 'operation-history':
return _load_operation_history_partial()
else:
return f"Partial '{partial_name}' not found", 404
return "Partial not found", 404
except Exception as e:
return f"Error loading partial '{partial_name}': {str(e)}", 500
logger.error("Error loading partial %s", partial_name, exc_info=True)
return "Error loading partial", 500
@pages_v3.route('/partials/plugin-config/<plugin_id>')
@@ -95,8 +98,9 @@ def load_plugin_config_partial(plugin_id):
"""Load plugin configuration partial via HTMX - server-side rendered form"""
try:
return _load_plugin_config_partial(plugin_id)
except Exception as e:
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
except Exception:
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
def _load_overview_partial():
"""Load overview partial with system stats"""
@@ -107,7 +111,8 @@ def _load_overview_partial():
return render_template('v3/partials/overview.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_general_partial():
"""Load general settings partial"""
@@ -117,7 +122,8 @@ def _load_general_partial():
return render_template('v3/partials/general.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_display_partial():
"""Load display settings partial"""
@@ -127,7 +133,8 @@ def _load_display_partial():
return render_template('v3/partials/display.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_durations_partial():
"""Load display durations partial"""
@@ -137,7 +144,8 @@ def _load_durations_partial():
return render_template('v3/partials/durations.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_schedule_partial():
"""Load schedule settings partial"""
@@ -153,7 +161,8 @@ def _load_schedule_partial():
dim_schedule_config=dim_schedule_config,
normal_brightness=normal_brightness)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_weather_partial():
@@ -164,7 +173,8 @@ def _load_weather_partial():
return render_template('v3/partials/weather.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_stocks_partial():
"""Load stocks configuration partial"""
@@ -174,7 +184,8 @@ def _load_stocks_partial():
return render_template('v3/partials/stocks.html',
main_config=main_config)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_plugins_partial():
"""Load plugins management partial"""
@@ -208,7 +219,7 @@ def _load_plugins_partial():
plugin_info.update(fresh_manifest)
except Exception as e:
# If we can't read the fresh manifest, use the cached one
print(f"Warning: Could not read fresh manifest for {plugin_id}: {e}")
logger.warning("Could not read fresh manifest for plugin: %s", plugin_id)
# 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
@@ -256,12 +267,13 @@ def _load_plugins_partial():
'branch': branch
})
except Exception as e:
print(f"Error loading plugin data: {e}")
logger.error("Error loading plugin data", exc_info=True)
return render_template('v3/partials/plugins.html',
plugins=plugins_data)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_fonts_partial():
"""Load fonts management partial"""
@@ -271,14 +283,16 @@ def _load_fonts_partial():
return render_template('v3/partials/fonts.html',
fonts=fonts_data)
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_logs_partial():
"""Load logs viewer partial"""
try:
return render_template('v3/partials/logs.html')
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_raw_json_partial():
"""Load raw JSON editor partial"""
@@ -295,14 +309,16 @@ def _load_raw_json_partial():
main_config_path=pages_v3.config_manager.get_config_path(),
secrets_config_path=pages_v3.config_manager.get_secrets_path())
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_backup_restore_partial():
"""Load backup & restore partial."""
try:
return render_template('v3/partials/backup_restore.html')
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
@pages_v3.route('/setup')
def captive_setup():
@@ -314,21 +330,24 @@ def _load_wifi_partial():
try:
return render_template('v3/partials/wifi.html')
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_cache_partial():
"""Load cache management partial"""
try:
return render_template('v3/partials/cache.html')
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_operation_history_partial():
"""Load operation history partial"""
try:
return render_template('v3/partials/operation_history.html')
except Exception as e:
return f"Error: {str(e)}", 500
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _load_plugin_config_partial(plugin_id):
@@ -336,6 +355,11 @@ def _load_plugin_config_partial(plugin_id):
Load plugin configuration partial - server-side rendered form.
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:
if not pages_v3.plugin_manager:
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
@@ -344,6 +368,14 @@ def _load_plugin_config_partial(plugin_id):
if plugin_id.startswith('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
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
@@ -353,7 +385,7 @@ def _load_plugin_config_partial(plugin_id):
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
if not plugin_info:
return f'<div class="text-red-500 p-4">Plugin "{escape(plugin_id)}" not found</div>', 404
return '<div class="text-red-500 p-4">Plugin not found</div>', 404
# Get plugin instance (may be None if not loaded)
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
@@ -365,59 +397,56 @@ def _load_plugin_config_partial(plugin_id):
config = full_config.get(plugin_id, {})
# Load uploaded images from metadata file if images field exists in schema
# 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"
schema_path_temp = _plugin_dir / "config_schema.json"
if schema_path_temp.exists():
try:
with open(schema_path_temp, 'r', encoding='utf-8') as 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
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
# Load metadata file
# Get PROJECT_ROOT relative to this file
project_root = Path(__file__).parent.parent.parent
metadata_file = project_root / 'assets' / 'plugins' / plugin_id / 'uploads' / '.metadata.json'
if metadata_file.exists():
_assets_base = (Path(__file__).parent.parent.parent / 'assets' / 'plugins').resolve()
metadata_file = (_assets_base / plugin_id / 'uploads' / '.metadata.json').resolve()
try:
metadata_file.relative_to(_assets_base)
except ValueError:
metadata_file = None
if metadata_file and metadata_file.exists():
try:
with open(metadata_file, 'r', encoding='utf-8') as mf:
metadata = json.load(mf)
# Convert metadata dict to list of image objects
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:
config['images'] = images_from_metadata
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')}
new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids]
if new_images:
config['images'] = config.get('images', []) + new_images
except Exception as e:
print(f"Warning: Could not load metadata for {plugin_id}: {e}")
logger.warning("Could not load plugin upload metadata: %s", e)
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)
# Get plugin schema
schema = {}
schema_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
schema_path = _plugin_dir / "config_schema.json"
if schema_path.exists():
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
except Exception as e:
print(f"Warning: Could not load schema for {plugin_id}: {e}")
logger.warning("Could not load schema for plugin: %s", e)
# Get web UI actions from plugin manifest
web_ui_actions = []
manifest_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
manifest_path = _plugin_dir / "manifest.json"
if manifest_path.exists():
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
web_ui_actions = manifest.get('web_ui_actions', [])
except Exception as e:
print(f"Warning: Could not load manifest for {plugin_id}: {e}")
logger.warning("Could not load manifest for plugin: %s", e)
# Mask secret fields before rendering template (fail closed — never leak secrets)
schema_properties = schema.get('properties') if isinstance(schema, dict) else None
@@ -453,20 +482,24 @@ def _load_plugin_config_partial(plugin_id):
)
except Exception as e:
import traceback
traceback.print_exc()
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
def _load_starlark_config_partial(app_id):
"""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:
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'):
app = starlark_plugin.apps.get(app_id)
if not app:
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
return render_template(
'v3/partials/starlark_config.html',
app_id=app_id,
@@ -482,36 +515,45 @@ def _load_starlark_config_partial(app_id):
)
# Standalone: read from manifest file
manifest_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / 'manifest.json'
starlark_base = (Path(__file__).resolve().parent.parent.parent / 'starlark-apps').resolve()
manifest_file = starlark_base / 'manifest.json'
if not manifest_file.exists():
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
with open(manifest_file, 'r') as f:
manifest = json.load(f)
app_data = manifest.get('apps', {}).get(app_id)
if not app_data:
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
# Load schema from schema.json if it exists
# Load schema from schema.json if it exists — validate path stays within starlark_base
schema = None
schema_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'schema.json'
if schema_file.exists():
schema_file = (starlark_base / app_id / 'schema.json').resolve()
try:
schema_file.relative_to(starlark_base)
except ValueError:
schema_file = None
if schema_file and schema_file.exists():
try:
with open(schema_file, 'r') as f:
schema = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning(f"[Pages V3] Could not load schema for {app_id}: {e}", exc_info=True)
logger.warning("Could not load starlark schema for app: %s", e)
# Load config from config.json if it exists
# Load config from config.json if it exists — validate path stays within starlark_base
config = {}
config_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'config.json'
if config_file.exists():
config_file = (starlark_base / app_id / 'config.json').resolve()
try:
config_file.relative_to(starlark_base)
except ValueError:
config_file = None
if config_file and config_file.exists():
try:
with open(config_file, 'r') as f:
config = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning(f"[Pages V3] Could not load config for {app_id}: {e}", exc_info=True)
logger.warning("Could not load starlark config for app: %s", e)
return render_template(
'v3/partials/starlark_config.html',
@@ -528,5 +570,5 @@ def _load_starlark_config_partial(app_id):
)
except Exception as e:
logger.exception(f"[Pages V3] Error loading starlark config for {app_id}")
return f'<div class="text-red-500 p-4">Error loading starlark config: {str(e)}</div>', 500
logger.error("[Pages V3] Error loading starlark config for app", exc_info=True)
return '<div class="text-red-500 p-4">Error loading starlark config; see logs for details</div>', 500

View File

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

View File

@@ -1442,9 +1442,14 @@ function renderInstalledPlugins(plugins) {
return;
}
// Helper function to escape attributes for use in HTML
// Helper function to escape values for use in HTML attributes
const escapeAttr = (text) => {
return (text || '').replace(/'/g, "\\'").replace(/"/g, '&quot;');
return (text || '')
.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)
@@ -4507,6 +4512,8 @@ function syncFormToJson() {
// Deep merge with existing config to preserve nested structures
function deepMerge(target, 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 (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) {
target[key] = {};
@@ -7473,17 +7480,28 @@ setTimeout(function() {
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(() => {
if (typeof window.attachInstallButtonHandler === 'function') {
console.log('[FALLBACK] Attempting to attach install button handler...');
if (typeof window.attachInstallButtonHandler === 'function' &&
document.getElementById('install-plugin-from-url')) {
window.attachInstallButtonHandler();
} else {
console.warn('[FALLBACK] attachInstallButtonHandler not available on window');
}
}, 500);
}, 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 ──────────────────────────────────────────────
(function() {

View File

@@ -136,6 +136,7 @@
setTimeout(function() {
if (typeof htmx !== 'undefined') {
console.log('HTMX loaded from fallback');
window.dispatchEvent(new Event('htmx:ready'));
// Load extensions after core loads
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');
@@ -152,6 +153,7 @@
}
} else {
console.log('HTMX loaded successfully');
window.dispatchEvent(new Event('htmx:ready'));
// Load extensions after core loads
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');
@@ -349,6 +351,20 @@
}
}
});
// 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 {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupScriptExecution);
@@ -411,6 +427,9 @@
.then(html => {
clearTimeout(timeout);
content.innerHTML = html;
if (typeof htmx !== 'undefined') {
htmx.process(content);
}
// Trigger full initialization chain
if (window.pluginManager) {
window.pluginManager.initialized = false;
@@ -430,7 +449,7 @@
}
// Fallback if HTMX doesn't load within 5 seconds
setTimeout(() => {
var _pluginsFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
// Load plugins tab content directly regardless of active tab,
@@ -438,6 +457,7 @@
loadPluginsDirect();
}
}, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true });
</script>
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script>
@@ -1030,6 +1050,9 @@
.then(html => {
overviewContent.innerHTML = html;
overviewContent.setAttribute('data-loaded', 'true');
if (typeof htmx !== 'undefined') {
htmx.process(overviewContent);
}
// Re-initialize Alpine.js for the new content
if (window.Alpine) {
window.Alpine.initTree(overviewContent);
@@ -1058,7 +1081,7 @@
});
// Also try direct load if HTMX doesn't load within 5 seconds
setTimeout(() => {
var _overviewFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
const appElement = document.querySelector('[x-data="app()"]');
@@ -1070,6 +1093,7 @@
}
}
}, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
</script>
<!-- General tab -->
@@ -1816,13 +1840,18 @@
htmx.trigger(contentEl, 'revealed');
}
} else {
// HTMX not available, use direct fetch
console.warn('HTMX not available, using direct fetch for tab:', tab);
if (tab === 'overview' && typeof loadOverviewDirect === 'function') {
loadOverviewDirect();
} else if (tab === 'wifi' && typeof loadWifiDirect === 'function') {
loadWifiDirect();
// HTMX is still loading asynchronously — retry when it signals ready,
// or fall back to direct fetch if it fails to load entirely.
const self = this;
function onReady() { window.removeEventListener('htmx-load-failed', onFailed); self.loadTabContent(tab); }
function onFailed() {
window.removeEventListener('htmx:ready', onReady);
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"
hx-vals='{"action": "start_display"}'
hx-swap="none"
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'); }"
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); }"
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>
Start Display
@@ -82,7 +82,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}'
hx-swap="none"
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'); }"
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); }"
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>
Stop Display
@@ -91,7 +91,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "git_pull"}'
hx-swap="none"
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'); }"
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); }"
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>
Update Code
@@ -101,7 +101,7 @@
hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none"
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'); }"
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); }"
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>
Reboot System

View File

@@ -151,7 +151,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}'
hx-swap="none"
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'); }"
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); }"
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>
Start Display
@@ -160,7 +160,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}'
hx-swap="none"
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'); }"
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); }"
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>
Stop Display
@@ -170,7 +170,7 @@
hx-vals='{"action": "git_pull"}'
hx-confirm="This will stash any local changes and update the code. Continue?"
hx-swap="none"
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'); }"
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); }"
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>
Update Code
@@ -180,7 +180,7 @@
hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none"
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'); }"
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); }"
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>
Reboot System
@@ -190,7 +190,7 @@
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-swap="none"
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'); }"
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); }"
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>
Shutdown System
@@ -199,7 +199,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_display_service"}'
hx-swap="none"
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'); }"
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); }"
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>
Restart Display Service
@@ -208,7 +208,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_web_service"}'
hx-swap="none"
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'); }"
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); }"
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>
Restart Web Service