Merge origin/main into fix/starlark-apps-dir-ownership

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 16:30:26 -04:00
co-authored by Claude Opus 5.5
255 changed files with 5655 additions and 24206 deletions
-7
View File
@@ -17,7 +17,6 @@ This directory contains the active V3 web interface with the following features:
web_interface/
├── app.py # Main Flask application
├── start.py # Startup script
├── run.sh # Shell runner script
├── requirements.txt # Python dependencies
├── blueprints/ # Flask blueprints
│ ├── api_v3/ # API endpoints (package: config, display,
@@ -27,7 +26,6 @@ web_interface/
├── templates/ # HTML templates
│ └── v3/
│ ├── base.html
│ ├── index.html
│ └── partials/
└── static/ # CSS/JS assets
└── v3/
@@ -49,11 +47,6 @@ From the project root:
python3 web_interface/start.py
```
Or using the shell script:
```bash
./web_interface/run.sh
```
### As a Service (Production)
The web interface can run as a systemd service that starts automatically based on the `web_display_autostart` configuration setting:
+8 -36
View File
@@ -30,7 +30,6 @@ from src.plugin_system.schema_manager import SchemaManager
from src.plugin_system.operation_queue import PluginOperationQueue
from src.plugin_system.state_manager import PluginStateManager
from src.plugin_system.operation_history import OperationHistory
from src.plugin_system.health_monitor import PluginHealthMonitor
_JOURNALCTL = shutil.which('journalctl')
_SYSTEMCTL = shutil.which('systemctl')
@@ -90,12 +89,15 @@ config = config_manager.load_config()
plugin_system_config = config.get('plugin_system', {})
plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos')
# Project root (LEDMatrix directory). Needed below for data/ and assets/ paths
# whether or not the plugins directory is absolute.
project_root = Path(__file__).parent.parent
# Resolve plugin directory - handle both absolute and relative paths
if os.path.isabs(plugins_dir_name):
plugins_dir = Path(plugins_dir_name)
else:
# If relative, resolve relative to the project root (LEDMatrix directory)
project_root = Path(__file__).parent.parent
# If relative, resolve relative to the project root
plugins_dir = project_root / plugins_dir_name
plugin_manager = PluginManager(
@@ -154,11 +156,6 @@ operation_history = OperationHistory(
lazy_load=True
)
# Initialize health monitoring (if health tracker is available)
# Deferred until first request to improve startup time
health_monitor = None
_health_monitor_initialized = False
# Plugin discovery is deferred until first API request that needs it
# This improves startup time - endpoints will call discover_plugins() when needed
@@ -181,7 +178,6 @@ api_v3.schema_manager = schema_manager
api_v3.operation_queue = operation_queue
api_v3.plugin_state_manager = plugin_state_manager
api_v3.operation_history = operation_history
api_v3.health_monitor = health_monitor
# Initialize cache manager for API endpoints
from src.cache_manager import CacheManager
api_v3.cache_manager = CacheManager()
@@ -926,28 +922,6 @@ def favicon():
"""Return 204 No Content for favicon to avoid 404 errors"""
return '', 204
def _initialize_health_monitor():
"""Initialize health monitoring after server is ready to accept requests."""
global health_monitor, _health_monitor_initialized
if _health_monitor_initialized:
return
if health_monitor is None and hasattr(plugin_manager, 'health_tracker') and plugin_manager.health_tracker:
try:
health_monitor = PluginHealthMonitor(
health_tracker=plugin_manager.health_tracker,
check_interval=60.0, # Check every minute
degraded_threshold=0.5,
unhealthy_threshold=0.8,
max_response_time=5.0
)
health_monitor.start_monitoring()
print("✓ Plugin health monitoring started")
except Exception as e:
print(f"⚠ Could not start health monitoring: {e}")
_health_monitor_initialized = True
_reconciliation_done = False
_reconciliation_started = False
import threading as _threading
@@ -1035,13 +1009,11 @@ def _run_startup_reconciliation() -> None:
# retrigger reconciliation on every subsequent request.
_reconciliation_done = True
# Initialize health monitor and run reconciliation on first request
# Run reconciliation in the background on first request
@app.before_request
def check_health_monitor():
"""Ensure health monitor is initialized; launch reconciliation in background."""
def start_startup_reconciliation():
"""Launch startup reconciliation in the background once."""
global _reconciliation_started
if not _health_monitor_initialized:
_initialize_health_monitor()
with _reconciliation_lock:
if not _reconciliation_started:
_reconciliation_started = True
+5 -20
View File
@@ -85,20 +85,12 @@ def _scrub_git_remote_url(url: str) -> str:
except Exception:
pass
return url
# Will be initialized when blueprint is registered
# NOTE: the managers live on the blueprint object (app.py sets
# api_v3.config_manager / api_v3.plugin_manager). Deliberately not
# mirrored as module globals: a bare `config_manager` used to resolve to
# a None that was never assigned, which silently disabled the /health
# checks and made /display/current fall back to a hardcoded 128x64.
plugin_store_manager = None
saved_repositories_manager = None
cache_manager = None
schema_manager = None
operation_queue = None
plugin_state_manager = None
operation_history = None
sync_manager = None # Optional DisplaySyncManager instance (set by app.py if available)
# api_v3.config_manager, api_v3.plugin_manager, api_v3.cache_manager and
# the rest). Deliberately not mirrored as module globals: a bare
# `config_manager` used to resolve to a None that was never assigned, which
# silently disabled the /health checks and made /display/current fall back
# to a hardcoded 128x64.
# Get project root directory (web_interface/../..)
# web_interface/blueprints/api_v3/_common.py -> up four to the project root.
# This was three levels when everything lived in web_interface/blueprints/api_v3.py;
@@ -156,13 +148,6 @@ def _is_plugin_update_available(installed_version: str, latest_version: str) ->
"""
from src.plugin_system.compatibility import is_update_available
return is_update_available(installed_version, latest_version)
def _ensure_cache_manager():
"""Ensure cache manager is initialized."""
global cache_manager
if cache_manager is None:
from src.cache_manager import CacheManager
cache_manager = CacheManager()
return cache_manager
def _save_config_atomic(config_manager, config_data, create_backup=True):
"""
Save configuration using atomic save if available, fallback to regular save.
+2 -3
View File
@@ -5,8 +5,7 @@ endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3,
datetime, json, jsonify, logger, os, plugin_store_manager, request,
tempfile,
datetime, json, jsonify, logger, os, request, tempfile,
)
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
@@ -151,7 +150,7 @@ def backup_restore():
# Reinstall plugins if requested and store manager available
if options.reinstall_plugins and result.plugins_to_install:
psm = getattr(api_v3, 'plugin_store_manager', None) or plugin_store_manager
psm = getattr(api_v3, 'plugin_store_manager', None)
for plug in result.plugins_to_install:
pid = plug.get('plugin_id')
if not pid:
@@ -985,6 +985,10 @@ def save_main_config():
mode_key = field[len('duration__'):]
if not mode_key:
continue
if raw_value is None or str(raw_value).strip() == '':
# Blank means "use the plugin's own duration".
current_config['display']['display_durations'].pop(mode_key, None)
continue
try:
int_value = int(raw_value)
except (ValueError, TypeError, OverflowError):
+18 -5
View File
@@ -4,7 +4,7 @@ Routes decorate the shared `api_v3` Blueprint from ._common, so their
endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
_ensure_cache_manager, _ensure_display_service_running,
_ensure_display_service_running,
_get_display_service_status, _stop_display_service, api_v3,
describe_exception, jsonify, logger, os, request, uuid,
)
@@ -15,6 +15,19 @@ import web_interface.blueprints.api_v3 as _pkg
# package is the only patch point that covers every caller.
def _cache_manager():
"""The web process's CacheManager, the one app.py puts on the blueprint.
Created on first use when nothing set it (a test app, an embedder), and
stored back on the blueprint so every route shares that one instance.
"""
cache = getattr(api_v3, 'cache_manager', None)
if cache is None:
from src.cache_manager import CacheManager
cache = api_v3.cache_manager = CacheManager()
return cache
@api_v3.route('/display/current', methods=['GET'])
def get_display_current():
"""Get current display state"""
@@ -137,7 +150,7 @@ def get_display_modes():
def get_on_demand_status():
"""Return the current on-demand display state."""
try:
cache = _ensure_cache_manager()
cache = _cache_manager()
# memory_ttl=0: the display service writes this key, so only the file
# is current. This process's memory tier would keep serving the first
# copy it read for the full max_age -- "active" for two minutes after
@@ -209,7 +222,7 @@ def start_on_demand_display():
# Set the on-demand request in cache FIRST (before starting service)
# This ensures the request is available when the service starts/restarts
cache = _ensure_cache_manager()
cache = _cache_manager()
request_id = data.get('request_id') or str(uuid.uuid4())
request_payload = {
'request_id': request_id,
@@ -277,7 +290,7 @@ def stop_on_demand_display():
# Set the stop request in cache FIRST
# The display controller will poll this and restart without the on-demand filter
cache = _ensure_cache_manager()
cache = _cache_manager()
request_id = data.get('request_id') or str(uuid.uuid4())
request_payload = {
'request_id': request_id,
@@ -313,7 +326,7 @@ def get_current_display_status():
process directly.
"""
try:
cache = _ensure_cache_manager()
cache = _cache_manager()
# memory_ttl=0: written by the display service; see get_on_demand_status.
state = cache.get('display_current_state', max_age=120, memory_ttl=0)
if state is None:
+100 -33
View File
@@ -10,10 +10,11 @@ from web_interface.blueprints.api_v3 import (
_MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value,
_get_display_service_status, _mqtt_bridge_service_state,
_read_mqtt_bridge_config, api_v3, contextlib, describe_exception,
error_response, get_error_aggregator, json, jsonify, logger, os, request,
error_response, json, jsonify, logger, os, redact_text, request,
subprocess, success_response, tempfile,
)
from src.common.path_safety import safe_path_component
from src import error_aggregator as _errors
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch.
@@ -326,17 +327,62 @@ def delete_cache_file():
except Exception as e:
logger.error('Error in delete_cache_file', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
def _errors_cache():
"""The shared cache the display service publishes its errors to."""
if not api_v3.cache_manager:
from src.cache_manager import CacheManager
api_v3.cache_manager = CacheManager()
return api_v3.cache_manager
def _redact_error_text(text, keep_lines=False):
"""Credentials out of plugin exception text, which can quote a URL with
an API key in it. Stack traces keep their line breaks and indentation."""
if not isinstance(text, str):
return text
if not keep_lines:
return redact_text(text, max_length=len(text) + 1)
return '\n'.join(
line[:len(line) - len(line.lstrip())] + redact_text(line, max_length=len(line) + 1)
for line in text.splitlines()
)
def _redact_error_record(record):
if not isinstance(record, dict):
return record
record = dict(record)
record['message'] = _redact_error_text(record.get('message'))
record['stack_trace'] = _redact_error_text(record.get('stack_trace'), keep_lines=True)
if isinstance(record.get('context'), dict):
record['context'] = {k: _redact_error_text(v) for k, v in record['context'].items()}
return record
def _read_errors():
snapshot, clear_request = _errors.read_error_report(_errors_cache())
return snapshot, clear_request
@api_v3.route('/errors/summary', methods=['GET'])
def get_error_summary():
"""
Get summary of all errors for monitoring and debugging.
Returns error counts, detected patterns, and recent errors.
Returns error counts, detected patterns, and recent errors, as last
reported by the display service (which runs the plugins, so it is the
only process that records their errors). ``snapshot_available`` is false
until it has reported; ``generated_at`` says when it did.
"""
try:
aggregator = get_error_aggregator()
summary = aggregator.get_error_summary()
return success_response(data=summary, message="Error summary retrieved")
summary = _errors.error_summary_from_report(*_read_errors())
summary['recent_errors'] = [_redact_error_record(r) for r in summary['recent_errors']]
for pattern in summary['active_patterns'].values():
if isinstance(pattern, dict) and isinstance(pattern.get('sample_messages'), list):
pattern['sample_messages'] = [_redact_error_text(m) for m in pattern['sample_messages']]
message = ("Error summary retrieved" if summary['snapshot_available']
else "The display service has not reported any errors yet")
return success_response(data=summary, message=message)
except Exception as e:
logger.error(f"Error getting error summary: {e}", exc_info=True)
return error_response(
@@ -352,11 +398,13 @@ def get_plugin_errors(plugin_id):
Args:
plugin_id: Plugin identifier
Returns health status and error statistics for the plugin.
Returns health status and error statistics for the plugin, from the
display service's last report (see get_error_summary). A plugin with no
recorded errors is "healthy".
"""
try:
aggregator = get_error_aggregator()
health = aggregator.get_plugin_health(plugin_id)
health = _errors.plugin_health_from_report(*_read_errors(), plugin_id)
health['last_error'] = _redact_error_record(health['last_error'])
return success_response(data=health, message="Plugin health retrieved")
except Exception as e:
logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True)
@@ -372,42 +420,61 @@ def clear_old_errors():
Request body (optional):
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
all: true clears every error recorded so far (max_age_hours ignored)
The errors live in the display service, so this records a clear request
that it applies within a few seconds. Reads hide the cleared errors from
the moment the request is recorded.
"""
try:
data = request.get_json(silent=True) or {}
clear_all = _coerce_to_bool(data.get('all'))
raw_max_age = data.get('max_age_hours', 24)
# Validate and coerce max_age_hours
max_age_hours = None
if not clear_all:
try:
max_age_hours = int(raw_max_age)
if max_age_hours < 1:
return error_response(
error_code=ErrorCode.INVALID_INPUT,
message="max_age_hours must be at least 1",
context={'provided_value': raw_max_age},
status_code=400
)
if max_age_hours > 8760: # 1 year max
return error_response(
error_code=ErrorCode.INVALID_INPUT,
message="max_age_hours cannot exceed 8760 (1 year)",
context={'provided_value': raw_max_age},
status_code=400
)
except (ValueError, TypeError, OverflowError):
return error_response(
error_code=ErrorCode.INVALID_INPUT,
message="max_age_hours must be a valid integer",
context={'provided_value': str(raw_max_age)},
status_code=400
)
now = _pkg.time.time()
cutoff = now if clear_all else now - max_age_hours * 3600
try:
max_age_hours = int(raw_max_age)
if max_age_hours < 1:
return error_response(
error_code=ErrorCode.INVALID_INPUT,
message="max_age_hours must be at least 1",
context={'provided_value': raw_max_age},
status_code=400
)
if max_age_hours > 8760: # 1 year max
return error_response(
error_code=ErrorCode.INVALID_INPUT,
message="max_age_hours cannot exceed 8760 (1 year)",
context={'provided_value': raw_max_age},
status_code=400
)
except (ValueError, TypeError, OverflowError):
result = _errors.request_error_clear(_errors_cache(), cutoff)
except OSError as e:
logger.error("Could not record an error clear request: %s", e)
return error_response(
error_code=ErrorCode.INVALID_INPUT,
message="max_age_hours must be a valid integer",
context={'provided_value': str(raw_max_age)},
status_code=400
error_code=ErrorCode.SYSTEM_ERROR,
message="Could not record the clear request in the shared cache",
status_code=500
)
aggregator = get_error_aggregator()
cleared_count = aggregator.clear_old_records(max_age_hours=max_age_hours)
scope = "all errors" if clear_all else f"errors older than {max_age_hours} hours"
return success_response(
data={'cleared_count': cleared_count},
message=f"Cleared {cleared_count} error records older than {max_age_hours} hours"
data=result,
message=(f"Clear of {scope} requested; the display service applies it "
f"within about {int(_errors.SNAPSHOT_TICK_INTERVAL)} seconds")
)
except Exception as e:
logger.error(f"Error clearing old errors: {e}", exc_info=True)
+12 -403
View File
@@ -16,7 +16,7 @@ from web_interface.blueprints.api_v3 import (
api_v3, datetime, deep_merge, describe_exception, error_response,
find_secret_fields, hashlib, json, jsonify, logger, logging,
merge_secrets, os, redact_text, remove_empty_secrets, request,
separate_secrets, shutil, stat, subprocess, success_response, sys,
separate_secrets, shutil, stat, subprocess, success_response,
tempfile, uuid, validate_request_json,
)
from src.common.path_safety import (
@@ -409,6 +409,8 @@ def manage_plugin_limits(plugin_id):
@api_v3.route('/plugins/toggle', methods=['POST'])
def toggle_plugin():
"""Toggle plugin enabled/disabled"""
plugin_id = None
enabled = None
try:
if not api_v3.plugin_manager or not api_v3.config_manager:
return jsonify({'status': 'error', 'message': 'Plugin or config manager not initialized'}), 500
@@ -506,21 +508,21 @@ def toggle_plugin():
message=f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} successfully"
)
except Exception as e:
from src.web_interface.errors import WebInterfaceError
error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_OPERATION_CONFLICT)
# Not PLUGIN_OPERATION_CONFLICT: that told the user "an operation is
# already in progress" whatever actually went wrong.
logger.error('Error toggling plugin %s', plugin_id, exc_info=True)
if api_v3.operation_history:
toggle_type = "enable" if ('data' in locals() and data.get('enabled')) else "disable"
api_v3.operation_history.record_operation(
toggle_type,
plugin_id=data.get('plugin_id') if 'data' in locals() else None,
"enable" if enabled else "disable",
plugin_id=plugin_id,
status="failed",
error=str(e)
)
action = 'enable' if enabled else 'disable' if enabled is not None else 'toggle'
return error_response(
error.error_code,
error.message,
details=error.details,
context=error.context,
ErrorCode.UNKNOWN_ERROR,
f"Failed to {action} plugin {plugin_id or ''}".rstrip(),
details=describe_exception(e),
status_code=500
)
@api_v3.route('/plugins/operation/<operation_id>', methods=['GET'])
@@ -3179,195 +3181,6 @@ sys.exit(proc.returncode)
except Exception as e:
logger.error('Error in execute_plugin_action', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/authenticate/spotify', methods=['POST'])
def authenticate_spotify():
"""Run Spotify authentication script"""
try:
data = request.get_json(silent=True) or {}
redirect_url = data.get('redirect_url', '').strip()
# Get plugin directory
plugin_id = 'ledmatrix-music'
if api_v3.plugin_manager:
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
else:
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
if not plugin_dir or not Path(plugin_dir).exists():
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
auth_script = Path(plugin_dir) / 'authenticate_spotify.py'
if not auth_script.exists():
return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404
# Set LEDMATRIX_ROOT environment variable
env = os.environ.copy()
env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT)
if redirect_url:
# Step 2: Complete authentication with redirect URL
# Create a wrapper script that provides the redirect URL as input
import tempfile
# Create a wrapper script that provides the redirect URL
import json
redirect_url_escaped = json.dumps(redirect_url) # Properly escape the URL
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper:
wrapper.write(f'''import sys
import subprocess
import os
# Set LEDMATRIX_ROOT
os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}"
# Run the auth script and provide redirect URL
proc = subprocess.Popen(
[sys.executable, r"{auth_script}"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=os.environ
)
# Send redirect URL to stdin
redirect_url = {redirect_url_escaped}
stdout, _ = proc.communicate(input=redirect_url + "\\n", timeout=120)
print(stdout)
sys.exit(proc.returncode)
''')
wrapper_path = wrapper.name
try:
result = subprocess.run(
['python3', wrapper_path],
capture_output=True,
text=True,
timeout=120,
env=env
)
if result.returncode == 0:
return jsonify({
'status': 'success',
'message': 'Spotify authentication completed successfully',
'output': result.stdout
})
else:
return jsonify({
'status': 'error',
'message': 'Spotify authentication failed',
'output': result.stdout + result.stderr
}), 400
except subprocess.TimeoutExpired:
return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408
finally:
# The wrapper carries the user's redirect URL, so it must not
# survive the request on any path — including a failure to
# launch, which the previous per-branch unlinks missed.
if os.path.exists(wrapper_path):
os.unlink(wrapper_path)
else:
# Step 1: Get authorization URL
# Import the script's functions directly to get the auth URL
import sys
import importlib.util
# Load the authentication script as a module
spec = importlib.util.spec_from_file_location("auth_spotify", auth_script)
auth_module = importlib.util.module_from_spec(spec)
sys.modules["auth_spotify"] = auth_module
# Set LEDMATRIX_ROOT before loading
os.environ['LEDMATRIX_ROOT'] = str(PROJECT_ROOT)
try:
spec.loader.exec_module(auth_module)
# Get credentials and create OAuth object
client_id, client_secret, redirect_uri = auth_module.load_spotify_credentials()
if not all([client_id, client_secret, redirect_uri]):
return jsonify({
'status': 'error',
'message': 'Could not load Spotify credentials. Please check config/config_secrets.json.'
}), 400
from spotipy.oauth2 import SpotifyOAuth
sp_oauth = SpotifyOAuth(
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
scope=auth_module.SCOPE,
cache_path=auth_module.SPOTIFY_AUTH_CACHE_PATH,
open_browser=False
)
auth_url = sp_oauth.get_authorize_url()
return jsonify({
'status': 'success',
'message': 'Authorization URL generated',
'auth_url': auth_url
})
except Exception as e:
logger.error("Error getting Spotify auth URL", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
}), 500
except Exception as e:
logger.error('Error in authenticate_spotify', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/authenticate/ytm', methods=['POST'])
def authenticate_ytm():
"""Run YouTube Music authentication script"""
try:
# Get plugin directory
plugin_id = 'ledmatrix-music'
if api_v3.plugin_manager:
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
else:
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
if not plugin_dir or not Path(plugin_dir).exists():
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
auth_script = Path(plugin_dir) / 'authenticate_ytm.py'
if not auth_script.exists():
return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404
# Set LEDMATRIX_ROOT environment variable
env = os.environ.copy()
env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT)
# Run the authentication script
result = subprocess.run(
['python3', str(auth_script)],
capture_output=True,
text=True,
timeout=60,
env=env
)
if result.returncode == 0:
return jsonify({
'status': 'success',
'message': 'YouTube Music authentication completed successfully',
'output': result.stdout
})
else:
return jsonify({
'status': 'error',
'message': 'YouTube Music authentication failed',
'output': result.stdout + result.stderr
}), 400
except subprocess.TimeoutExpired:
return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408
except Exception as e:
logger.error('Error in authenticate_ytm', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
def _plugin_uploads_dir(plugin_id):
"""assets/plugins/<plugin_id>/uploads for a request-supplied id, or None.
@@ -3525,210 +3338,6 @@ def upload_plugin_asset():
'total_files': len(metadata)
})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/of-the-day/json/upload', methods=['POST'])
def upload_of_the_day_json():
"""Upload JSON files for of-the-day plugin"""
try:
if 'files' not in request.files:
return jsonify({'status': 'error', 'message': 'No files provided'}), 400
files = request.files.getlist('files')
if not files or all(not f.filename for f in files):
return jsonify({'status': 'error', 'message': 'No files provided'}), 400
# Get plugin directory
plugin_id = 'ledmatrix-of-the-day'
if api_v3.plugin_manager:
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
else:
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
if not plugin_dir or not Path(plugin_dir).exists():
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
# Setup of_the_day directory
data_dir = Path(plugin_dir) / 'of_the_day'
data_dir.mkdir(parents=True, exist_ok=True)
uploaded_files = []
max_size_per_file = 5 * 1024 * 1024 # 5MB
for file in files:
if not file.filename:
continue
# Validate file extension
if not file.filename.lower().endswith('.json'):
return jsonify({
'status': 'error',
'message': f'File {file.filename} must be a JSON file (.json)'
}), 400
# Read and validate file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > max_size_per_file:
return jsonify({
'status': 'error',
'message': f'File {file.filename} exceeds 5MB limit'
}), 400
# Read and validate JSON content
try:
file_content = file.read().decode('utf-8')
json_data = json.loads(file_content)
except json.JSONDecodeError as e:
return jsonify({
'status': 'error',
'message': 'Invalid JSON in request body'
}), 400
except UnicodeDecodeError:
return jsonify({
'status': 'error',
'message': f'File {file.filename} is not valid UTF-8 text'
}), 400
# Validate JSON structure (must be object with day number keys)
if not isinstance(json_data, dict):
return jsonify({
'status': 'error',
'message': f'JSON in {file.filename} must be an object with day numbers (1-365) as keys'
}), 400
# Check if keys are valid day numbers
for key in json_data.keys():
try:
day_num = int(key)
if day_num < 1 or day_num > 365:
return jsonify({
'status': 'error',
'message': f'Day number {day_num} in {file.filename} is out of range (must be 1-365)'
}), 400
except ValueError:
return jsonify({
'status': 'error',
'message': f'Invalid key "{key}" in {file.filename}: must be a day number (1-365)'
}), 400
# Generate safe filename from original (preserve user's filename)
original_filename = file.filename
safe_filename = original_filename.lower().replace(' ', '_')
# Ensure it's a valid filename
safe_filename = ''.join(c for c in safe_filename if c.isalnum() or c in '._-')
if not safe_filename.endswith('.json'):
safe_filename += '.json'
file_path = data_dir / safe_filename
# If file exists, add counter
counter = 1
base_name = safe_filename.replace('.json', '')
while file_path.exists():
safe_filename = f"{base_name}_{counter}.json"
file_path = data_dir / safe_filename
counter += 1
# Save file
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(json_data, f, indent=2, ensure_ascii=False)
# Make file readable
os.chmod(file_path, 0o644)
# Extract category name from filename (remove .json extension)
category_name = safe_filename.replace('.json', '')
display_name = category_name.replace('_', ' ').title()
# Update plugin config to add category
try:
sys.path.insert(0, str(plugin_dir))
from scripts.update_config import add_category_to_config
add_category_to_config(category_name, f'of_the_day/{safe_filename}', display_name)
except Exception as e:
logger.warning("Could not update config: %s", e)
# Continue anyway - file is uploaded
# Generate file ID (use category name as ID for simplicity)
file_id = category_name
uploaded_files.append({
'id': file_id,
'filename': safe_filename,
'original_filename': original_filename,
'path': f'of_the_day/{safe_filename}',
'size': file_size,
'uploaded_at': datetime.utcnow().isoformat() + 'Z',
'category_name': category_name,
'display_name': display_name,
'entry_count': len(json_data)
})
return jsonify({
'status': 'success',
'uploaded_files': uploaded_files,
'total_files': len(uploaded_files)
})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/of-the-day/json/delete', methods=['POST'])
def delete_of_the_day_json():
"""Delete a JSON file from of-the-day plugin"""
try:
data = request.get_json(silent=True) or {}
file_id = data.get('file_id') # This is the category_name
if not file_id:
return jsonify({'status': 'error', 'message': 'file_id is required'}), 400
# file_id names a file that is about to be unlinked, and it arrives
# straight from the request body. Unvalidated, a file_id of
# "../../../../etc/cron" made this endpoint delete any .json file on
# the device the service could write to.
safe_file_id = safe_path_component(file_id)
if not safe_file_id:
return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400
# Get plugin directory
plugin_id = 'ledmatrix-of-the-day'
if api_v3.plugin_manager:
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
else:
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
if not plugin_dir or not Path(plugin_dir).exists():
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
filename = f"{safe_file_id}.json"
file_path = resolve_under(Path(plugin_dir) / 'of_the_day', filename)
if file_path is None:
return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400
if not file_path.exists():
return jsonify({'status': 'error', 'message': f'File {filename} not found'}), 404
# Delete file
file_path.unlink()
# Update config to remove category
try:
sys.path.insert(0, str(plugin_dir))
from scripts.update_config import remove_category_from_config
remove_category_from_config(safe_file_id)
except Exception as e:
logger.warning("Could not update config: %s", e)
return jsonify({
'status': 'success',
'message': f'File {filename} deleted successfully'
})
except Exception as e:
logger.error('Unhandled exception', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
+38 -44
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, Response, render_template, flash, jsonify, url_for
from flask import Blueprint, Response, render_template, jsonify, url_for
from jinja2 import TemplateNotFound
from markupsafe import escape
from html.parser import HTMLParser
@@ -20,12 +20,8 @@ from web_interface import widget_bundle
logger = logging.getLogger(__name__)
# Will be initialized when blueprint is registered
config_manager = None
plugin_manager = None
plugin_store_manager = None
schema_manager = None
# The managers live on the blueprint object: app.py sets
# pages_v3.config_manager, pages_v3.plugin_manager and the rest.
pages_v3 = Blueprint('pages_v3', __name__)
@@ -162,37 +158,8 @@ _SEARCH_INDEX_CACHE = {'sig': None, 'fields': None}
@pages_v3.route('/')
def index():
"""Main v3 interface page"""
try:
if pages_v3.config_manager:
# Load configuration data
main_config = pages_v3.config_manager.load_config()
schedule_config = main_config.get('schedule', {})
# Get raw config files for JSON editor
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
secrets_config_data = pages_v3.config_manager.get_raw_file_content('secrets')
main_config_json = json.dumps(main_config_data, indent=4)
secrets_config_json = json.dumps(secrets_config_data, indent=4)
else:
raise Exception("Config manager not initialized")
except Exception as e:
flash(f"Error loading configuration: {e}", "error")
schedule_config = {}
main_config_json = "{}"
secrets_config_json = "{}"
main_config_data = {}
secrets_config_data = {}
return render_template('v3/index.html',
schedule_config=schedule_config,
main_config_json=main_config_json,
secrets_config_json=secrets_config_json,
main_config_path=pages_v3.config_manager.get_config_path() if pages_v3.config_manager else "",
secrets_config_path=pages_v3.config_manager.get_secrets_path() if pages_v3.config_manager else "",
main_config=main_config_data,
secrets_config=secrets_config_data)
"""Main v3 interface page: the app shell. Every tab loads as a partial."""
return render_template('v3/base.html')
@pages_v3.route('/partials/<partial_name>')
def load_partial(partial_name):
@@ -552,14 +519,39 @@ def _load_display_partial():
logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500
def _plugin_default_duration(plugin_id, plugin_config):
"""Seconds a plugin shows each screen when the Rotation page sets none.
Mirrors BasePlugin.get_display_duration's config fallback: the plugin's
display_duration, else its schema default, else 15.
"""
def _valid(v):
return isinstance(v, (int, float)) and not isinstance(v, bool) and v > 0
value = plugin_config.get('display_duration')
if not _valid(value):
schema = None
schema_mgr = getattr(pages_v3, 'schema_manager', None)
if schema_mgr is not None:
try:
schema = schema_mgr.load_schema(plugin_id)
except Exception:
logger.debug("durations: no schema for %s", plugin_id, exc_info=True)
value = plugin_config_defaults(schema if isinstance(schema, dict) else None).get(
'display_duration')
if not _valid(value):
value = 15
return int(value) if float(value).is_integer() else value
def _load_durations_partial():
"""Load rotation & durations partial.
Builds one duration entry per display mode of every enabled plugin
(falling back to the display controller's 30s default), overlaid with any
values saved in display.display_durations. Historically the template only
looped over saved keys, and nothing ever populated them, so the page
rendered empty.
Builds one duration entry per display mode of every enabled plugin. A mode
with a value saved in display.display_durations shows it; the rest are
blank, with the plugin's own duration as the placeholder, because a saved
value overrides the plugin (see DisplayController._get_display_duration).
Pre-filling every mode would pin them all on the first save.
"""
try:
if pages_v3.config_manager:
@@ -578,10 +570,12 @@ def _load_durations_partial():
continue
modes = pages_v3.plugin_manager.get_plugin_display_modes(pid) or [pid]
covered_keys.update(modes)
default = _plugin_default_duration(pid, main_config.get(pid, {}) or {})
duration_groups.append({
'plugin_id': pid,
'plugin_name': info.get('name') or pid,
'modes': [{'key': m, 'value': saved.get(m, 30)} for m in modes],
'modes': [{'key': m, 'value': saved.get(m, ''), 'default': default}
for m in modes],
})
# Saved keys not owned by any enabled plugin (disabled or
# uninstalled plugins) stay visible rather than vanishing.
-26
View File
@@ -108,29 +108,3 @@ def log_api_request(method: str, path: str, status_code: int, duration_ms: float
logger.warning(f"{method} {path} - {status_code} ({duration_ms}ms)", extra=extra)
else:
logger.info(f"{method} {path} - {status_code} ({duration_ms}ms)", extra=extra)
def log_config_change(change_type: str, target: str, success: bool, **kwargs):
"""
Log a configuration change.
Args:
change_type: Type of change (save, delete, update)
target: What was changed (e.g., 'main_config', 'plugin_config:football-scoreboard')
success: Whether the change was successful
**kwargs: Additional context
"""
logger = logging.getLogger('web_interface.config')
extra = {
'change_type': change_type,
'target': target,
'success': success,
**kwargs
}
if success:
logger.info(f"Config {change_type}: {target}", extra=extra)
else:
logger.error(f"Config {change_type} failed: {target}", extra=extra)
-17
View File
@@ -1,17 +0,0 @@
#!/bin/bash
# LED Matrix Web Interface V3 Runner
# This script runs the web interface using system Python
set -e
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
echo "Starting LED Matrix Web Interface V3..."
# Run the web interface from project root
python3 web_interface/start.py
+1 -187
View File
@@ -1,30 +1,7 @@
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx, debugLog */
/* global showNotification */
// LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration
// Global notification system — implemented by widgets/notification.js.
// This fallback only exists if nothing defined showNotification earlier; it
// hands off to the widget when registered, otherwise queues the message for
// the widget to show once it loads.
if (typeof window.showNotification !== 'function') {
window.showNotification = function(message, type = 'info') {
const registry = window.LEDMatrixWidgets;
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
if (widget && typeof widget.show === 'function') {
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
}
if (!Array.isArray(window.__pendingNotifications)) {
window.__pendingNotifications = [];
}
window.__pendingNotifications.push([message, type]);
document.dispatchEvent(new CustomEvent('show-notification', {
detail: { message, type }
}));
// User-facing last resort, so never gated
console.info(`${(type && type.type) || type}: ${message}`);
};
}
// HTMX response handlers
document.body.addEventListener('htmx:beforeRequest', function(event) {
// Show loading states for buttons
@@ -191,37 +168,6 @@ document.addEventListener('DOMContentLoaded', function() {
// SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in
// js/app-shell.js — do not open EventSources for stats/display here.
// Utility functions
window.hexToRgb = function(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
};
window.rgbToHex = function(r, g, b) {
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
};
// Form validation helpers
window.validateForm = function(form) {
const inputs = form.querySelectorAll('input[required], select[required], textarea[required]');
let isValid = true;
inputs.forEach(input => {
if (!input.value.trim()) {
input.classList.add('border-red-500');
isValid = false;
} else {
input.classList.remove('border-red-500');
}
});
return isValid;
};
// Auto-resize textareas
document.addEventListener('DOMContentLoaded', function() {
const textareas = document.querySelectorAll('textarea');
@@ -251,144 +197,12 @@ document.addEventListener('keydown', function(e) {
}
});
// Plugin management helpers
window.installPlugin = function(pluginId) {
fetch('/api/v3/plugins/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId })
})
.then(response => response.json())
.then(data => {
showNotification(data.message, data.status);
if (data.status === 'success') {
// Refresh plugin list
htmx.ajax('GET', '/v3/partials/plugins', '#plugins-content');
}
})
.catch(error => {
showNotification('Error installing plugin: ' + error.message, 'error');
});
};
// Font management helpers
window.uploadFont = function(fileInput) {
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('font_file', file);
formData.append('font_family', file.name.replace(/\.[^/.]+$/, '').toLowerCase().replace(/[^a-z0-9]/g, '_'));
fetch('/api/v3/fonts/upload', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
showNotification(data.message, data.status);
if (data.status === 'success') {
// Refresh fonts list
htmx.ajax('GET', '/v3/partials/fonts', '#fonts-content');
}
})
.catch(error => {
showNotification('Error uploading font: ' + error.message, 'error');
});
};
// Tab switching helper
window.switchTab = function(tabName) {
// Update Alpine.js active tab if available
if (window.Alpine) {
// Dispatch event for Alpine.js
const event = new CustomEvent('switch-tab', {
detail: { tab: tabName }
});
document.dispatchEvent(event);
}
};
// Error handling for unhandled promise rejections
window.addEventListener('unhandledrejection', function(event) {
console.error('Unhandled promise rejection:', event.reason);
showNotification('An unexpected error occurred', 'error');
});
// Performance monitoring
window.performanceMonitor = {
startTime: performance.now(),
mark: function(name) {
if (window.performance.mark) {
performance.mark(name);
}
},
measure: function(name, start, end) {
if (window.performance.measure) {
performance.measure(name, start, end);
}
},
getMeasures: function() {
if (window.performance && window.performance.getEntriesByType) {
return window.performance.getEntriesByType('measure');
}
return [];
},
getMetrics: function() {
if (!window.performance || !window.performance.getEntriesByType) {
return {};
}
const navigation = window.performance.getEntriesByType('navigation')[0];
const paint = window.performance.getEntriesByType('paint');
const resources = window.performance.getEntriesByType('resource');
return {
domContentLoaded: navigation ? navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart : 0,
loadComplete: navigation ? navigation.loadEventEnd - navigation.fetchStart : 0,
firstPaint: paint.find(p => p.name === 'first-paint')?.startTime || 0,
firstContentfulPaint: paint.find(p => p.name === 'first-contentful-paint')?.startTime || 0,
resourceCount: resources.length,
totalResourceSize: resources.reduce((sum, r) => sum + (r.transferSize || 0), 0),
measures: this.measures
};
},
logMetrics: function() {
const metrics = this.getMetrics();
console.group('Performance Metrics');
debugLog('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms');
debugLog('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms');
debugLog('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms');
debugLog('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms');
debugLog('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB');
if (Object.keys(metrics.measures || {}).length > 0) {
debugLog('Custom Measures:', metrics.measures);
}
console.groupEnd();
}
};
// Initialize performance monitoring
document.addEventListener('DOMContentLoaded', function() {
window.performanceMonitor.mark('app-start');
// Log metrics after page load
window.addEventListener('load', function() {
setTimeout(() => {
window.performanceMonitor.mark('app-loaded');
window.performanceMonitor.measure('app-load-time', 'app-start', 'app-loaded');
if (window.location.search.includes('debug=perf')) {
window.performanceMonitor.logMetrics();
}
}, 100);
});
});
// ===== Floating live preview =====
// A mini preview of the display, available on every tab except Overview
// (which has the full-size one). Open/closed state persists per browser;
-17
View File
@@ -2572,23 +2572,6 @@
// Assign to window for global access
window.uninstallPlugin = uninstallPlugin;
async function refreshPlugin(pluginId) {
try {
// Switch to the plugin manager tab briefly to refresh
const originalTab = app.activeTab;
app.activeTab = 'plugins';
// Wait a moment then switch back
setTimeout(() => {
app.activeTab = originalTab;
app.showNotification(`Refreshed ${pluginId}`, 'success');
}, 100);
} catch (error) {
app.showNotification('Error refreshing plugin: ' + error.message, 'error');
}
}
// Format commit information for display
function formatCommitInfo(commit, branch) {
if (!commit && !branch) return 'Unknown';
@@ -1,300 +0,0 @@
/**
* Configuration diff viewer.
*
* Shows what changed in configuration before saving.
*/
const ConfigDiffViewer = {
/**
* Original configuration state (before changes).
*/
originalConfigs: new Map(),
/**
* Store original configuration for a plugin.
*
* @param {string} pluginId - Plugin identifier
* @param {Object} config - Original configuration
*/
storeOriginal(pluginId, config) {
this.originalConfigs.set(pluginId, JSON.parse(JSON.stringify(config)));
},
/**
* Get original configuration for a plugin.
*
* @param {string} pluginId - Plugin identifier
* @returns {Object|null} Original configuration
*/
getOriginal(pluginId) {
return this.originalConfigs.get(pluginId) || null;
},
/**
* Clear stored original configuration.
*
* @param {string} pluginId - Plugin identifier
*/
clearOriginal(pluginId) {
this.originalConfigs.delete(pluginId);
},
/**
* Compare two configuration objects and return differences.
*
* @param {Object} oldConfig - Old configuration
* @param {Object} newConfig - New configuration
* @returns {Object} Differences object with added, removed, and changed keys
*/
compare(oldConfig, newConfig) {
const differences = {
added: {},
removed: {},
changed: {},
unchanged: {}
};
// Get all keys from both configs
const allKeys = new Set([
...Object.keys(oldConfig || {}),
...Object.keys(newConfig || {})
]);
for (const key of allKeys) {
const oldValue = oldConfig?.[key];
const newValue = newConfig?.[key];
if (!(key in (oldConfig || {}))) {
// Key was added
differences.added[key] = newValue;
} else if (!(key in (newConfig || {}))) {
// Key was removed
differences.removed[key] = oldValue;
} else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
// Key was changed
differences.changed[key] = {
old: oldValue,
new: newValue
};
} else {
// Key unchanged
differences.unchanged[key] = oldValue;
}
}
return differences;
},
/**
* Check if there are any differences.
*
* @param {Object} differences - Differences object from compare()
* @returns {boolean} True if there are changes
*/
hasChanges(differences) {
return Object.keys(differences.added).length > 0 ||
Object.keys(differences.removed).length > 0 ||
Object.keys(differences.changed).length > 0;
},
/**
* Format differences for display.
*
* @param {Object} differences - Differences object
* @returns {string} HTML formatted diff
*/
formatDiff(differences) {
const parts = [];
// Added keys
if (Object.keys(differences.added).length > 0) {
parts.push(`
<div class="mb-4">
<h4 class="text-sm font-semibold text-green-800 mb-2">
<i class="fas fa-plus-circle mr-1"></i>Added
</h4>
<div class="bg-green-50 border border-green-200 rounded p-3">
${Object.entries(differences.added).map(([key, value]) => `
<div class="mb-2">
<code class="text-xs font-mono bg-green-100 px-1 py-0.5 rounded">${this.escapeHtml(key)}</code>
<span class="text-sm text-gray-700 ml-2">=</span>
<pre class="mt-1 text-xs bg-white p-2 rounded border border-green-200 overflow-auto">${this.escapeHtml(JSON.stringify(value, null, 2))}</pre>
</div>
`).join('')}
</div>
</div>
`);
}
// Removed keys
if (Object.keys(differences.removed).length > 0) {
parts.push(`
<div class="mb-4">
<h4 class="text-sm font-semibold text-red-800 mb-2">
<i class="fas fa-minus-circle mr-1"></i>Removed
</h4>
<div class="bg-red-50 border border-red-200 rounded p-3">
${Object.entries(differences.removed).map(([key, value]) => `
<div class="mb-2">
<code class="text-xs font-mono bg-red-100 px-1 py-0.5 rounded">${this.escapeHtml(key)}</code>
<span class="text-sm text-gray-700 ml-2">=</span>
<pre class="mt-1 text-xs bg-white p-2 rounded border border-red-200 overflow-auto line-through text-gray-500">${this.escapeHtml(JSON.stringify(value, null, 2))}</pre>
</div>
`).join('')}
</div>
</div>
`);
}
// Changed keys
if (Object.keys(differences.changed).length > 0) {
parts.push(`
<div class="mb-4">
<h4 class="text-sm font-semibold text-yellow-800 mb-2">
<i class="fas fa-edit mr-1"></i>Changed
</h4>
<div class="bg-yellow-50 border border-yellow-200 rounded p-3">
${Object.entries(differences.changed).map(([key, change]) => `
<div class="mb-3">
<code class="text-xs font-mono bg-yellow-100 px-1 py-0.5 rounded">${this.escapeHtml(key)}</code>
<div class="mt-2 grid grid-cols-2 gap-2">
<div>
<div class="text-xs font-medium text-red-700 mb-1">Old Value:</div>
<pre class="text-xs bg-white p-2 rounded border border-red-200 overflow-auto">${this.escapeHtml(JSON.stringify(change.old, null, 2))}</pre>
</div>
<div>
<div class="text-xs font-medium text-green-700 mb-1">New Value:</div>
<pre class="text-xs bg-white p-2 rounded border border-green-200 overflow-auto">${this.escapeHtml(JSON.stringify(change.new, null, 2))}</pre>
</div>
</div>
</div>
`).join('')}
</div>
</div>
`);
}
if (parts.length === 0) {
return '<div class="text-sm text-gray-600 text-center py-4">No changes detected</div>';
}
return parts.join('');
},
/**
* Show diff modal before saving.
*
* @param {string} pluginId - Plugin identifier
* @param {Object} newConfig - New configuration
* @returns {Promise<boolean>} Promise resolving to true if user confirms, false if cancelled
*/
async showDiffModal(pluginId, newConfig) {
return new Promise((resolve) => {
const original = this.getOriginal(pluginId);
if (!original) {
// No original to compare, proceed without diff
resolve(true);
return;
}
const differences = this.compare(original, newConfig);
if (!this.hasChanges(differences)) {
// No changes, proceed without showing modal
resolve(true);
return;
}
// Create modal
const modalContainer = document.createElement('div');
modalContainer.id = 'config-diff-modal-container';
modalContainer.className = 'fixed inset-0 z-50 overflow-y-auto';
modalContainer.innerHTML = `
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" data-diff-backdrop aria-hidden="true"></div>
<div id="config-diff-modal-panel" class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full" style="position:relative;">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10" aria-hidden="true">
<i class="fas fa-code-branch text-blue-600"></i>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 id="config-diff-modal-title" class="text-lg leading-6 font-medium text-gray-900">Review Configuration Changes</h3>
<div class="mt-4 max-h-96 overflow-y-auto" tabindex="0" role="region" aria-label="Changes to save">
${this.formatDiff(differences)}
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" id="config-diff-confirm-btn"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
<i class="fas fa-check mr-2" aria-hidden="true"></i>Save Changes
</button>
<button type="button" id="config-diff-cancel-btn"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Cancel
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modalContainer);
let release = null;
let settled = false;
// Single close path: remove the modal, release the focus trap
// (returning focus to where the user was) and settle the promise.
function finish(result) {
if (settled) return;
settled = true;
modalContainer.remove();
if (window.__configDiffResolve === finish) window.__configDiffResolve = undefined;
if (release) release();
resolve(result);
}
// Kept for backwards compatibility with code that calls it directly.
window.__configDiffResolve = finish;
// Attach event listeners
const confirmBtn = modalContainer.querySelector('#config-diff-confirm-btn');
const cancelBtn = modalContainer.querySelector('#config-diff-cancel-btn');
const backdrop = modalContainer.querySelector('[data-diff-backdrop]');
confirmBtn.addEventListener('click', () => finish(true));
cancelBtn.addEventListener('click', () => finish(false));
if (backdrop) backdrop.addEventListener('click', () => finish(false));
if (window.LEDDialog) {
release = window.LEDDialog.trap(modalContainer.querySelector('#config-diff-modal-panel'), {
labelledBy: 'config-diff-modal-title',
initialFocus: confirmBtn,
onEscape: () => finish(false)
});
}
});
},
/**
* Escape HTML to prevent XSS.
*/
escapeHtml(text) {
if (typeof text !== 'string') {
text = String(text);
}
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};
// Export
if (typeof module !== 'undefined' && module.exports) {
module.exports = ConfigDiffViewer;
} else {
window.ConfigDiffViewer = ConfigDiffViewer;
}
-61
View File
@@ -53,67 +53,6 @@
}
});
// Suppress HTMX insertBefore errors and other noisy errors - they're harmless but noisy
const originalError = console.error;
const originalWarn = console.warn;
console.error = function(...args) {
const errorStr = args.join(' ');
const errorStack = args.find(arg => arg && typeof arg === 'string' && arg.includes('htmx')) || '';
// Suppress HTMX insertBefore errors (comprehensive check)
// These occur when HTMX tries to swap content but the target element is null
// Usually happens due to timing/race conditions and is harmless
if (errorStr.includes("insertBefore") ||
errorStr.includes("Cannot read properties of null") ||
errorStr.includes("reading 'insertBefore'")) {
// Check if it's from HTMX by looking at stack trace or error string
// Also check the call stack if available
const isHtmxError = errorStr.includes('htmx') ||
errorStack.includes('htmx') ||
args.some(arg => {
if (typeof arg === 'string') {
return arg.includes('htmx');
}
// Check error objects for stack traces
if (arg && typeof arg === 'object' && arg.stack) {
return arg.stack.includes('htmx');
}
return false;
});
if (isHtmxError) {
return; // Suppress - this is a harmless HTMX timing/race condition issue
}
}
// Suppress script execution errors from malformed HTML
if (errorStr.includes("Failed to execute 'appendChild' on 'Node'") ||
errorStr.includes("Failed to execute 'insertBefore' on 'Node'")) {
if (errorStr.includes('Unexpected token')) {
return; // Suppress malformed HTML errors
}
}
originalError.apply(console, args);
};
console.warn = function(...args) {
const warnStr = args.join(' ');
// Suppress Permissions-Policy warnings (harmless browser warnings)
if (warnStr.includes('Permissions-Policy header') ||
warnStr.includes('Unrecognized feature') ||
warnStr.includes('Origin trial controlled feature') ||
warnStr.includes('browsing-topics') ||
warnStr.includes('run-ad-auction') ||
warnStr.includes('join-ad-interest-group') ||
warnStr.includes('private-state-token') ||
warnStr.includes('private-aggregation') ||
warnStr.includes('attribution-reporting')) {
return; // Suppress - these are harmless browser feature warnings
}
originalWarn.apply(console, args);
};
// Handle HTMX errors gracefully with detailed logging
document.body.addEventListener('htmx:responseError', function(event) {
const detail = event.detail;
-356
View File
@@ -1,356 +0,0 @@
/* global htmx */
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api;
htmx.defineExtension("sse", {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef;
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource;
}
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
switch (name) {
case "htmx:beforeCleanupElement":
var internalData = api.getInternalData(evt.target)
// Try to remove remove an EventSource when elements are removed
if (internalData.sseEventSource) {
internalData.sseEventSource.close();
}
return;
// Try to create EventSources when elements are processed
case "htmx:afterProcessNode":
ensureEventSourceOnElement(evt.target);
registerSSE(evt.target);
}
}
});
///////////////////////////////////////////////
// HELPER FUNCTIONS
///////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true });
}
function splitOnWhitespace(trigger) {
return trigger.trim().split(/\s+/);
}
function getLegacySSEURL(elt) {
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
if (legacySSEValue) {
var values = splitOnWhitespace(legacySSEValue);
for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/);
if (value[0] === "connect") {
return value[1];
}
}
}
}
function getLegacySSESwaps(elt) {
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
var returnArr = [];
if (legacySSEValue != null) {
var values = splitOnWhitespace(legacySSEValue);
for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/);
if (value[0] === "swap") {
returnArr.push(value[1]);
}
}
}
return returnArr;
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource);
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null; // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement);
var source = internalData.sseEventSource;
// Add message handlers for every `sse-swap` attribute
queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
var sseSwapAttr = api.getAttributeValue(child, "sse-swap");
if (sseSwapAttr) {
var sseEventNames = sseSwapAttr.split(",");
} else {
var sseEventNames = getLegacySSESwaps(child);
}
for (var i = 0; i < sseEventNames.length; i++) {
var sseEventName = sseEventNames[i].trim();
var listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return;
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(child)) {
source.removeEventListener(sseEventName, listener);
}
// swap the response into the DOM and trigger a notification
swap(child, event.data);
api.triggerEvent(elt, "htmx:sseMessage", event);
};
// Register the new listener
api.getInternalData(child).sseEventListener = listener;
source.addEventListener(sseEventName, listener);
}
});
// Add message handlers for every `hx-trigger="sse:*"` attribute
queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
var sseEventName = api.getAttributeValue(child, "hx-trigger");
if (sseEventName == null) {
return;
}
// Only process hx-triggers for events with the "sse:" prefix
if (sseEventName.slice(0, 4) != "sse:") {
return;
}
// remove the sse: prefix from here on out
sseEventName = sseEventName.substr(4);
var listener = function() {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(child)) {
source.removeEventListener(sseEventName, listener);
}
}
});
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null;
}
// handle extension source creation attribute
queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) {
var sseURL = api.getAttributeValue(child, "sse-connect");
if (sseURL == null) {
return;
}
ensureEventSource(child, sseURL, retryCount);
});
// handle legacy sse, remove for HTMX2
queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) {
var sseURL = getLegacySSEURL(child);
if (sseURL == null) {
return;
}
ensureEventSource(child, sseURL, retryCount);
});
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url);
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source });
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return;
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0;
var timeout = Math.random() * (2 ^ retryCount) * 500;
window.setTimeout(function() {
ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1));
}, timeout);
}
};
source.onopen = function(evt) {
api.triggerEvent(elt, "htmx:sseOpen", { source: source });
}
api.getInternalData(elt).sseEventSource = source;
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource;
if (source != undefined) {
source.close();
// source = null
return true;
}
}
return false;
}
/**
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
*
* @param {HTMLElement} elt
* @param {string} attributeName
*/
function queryAttributeOnThisOrChildren(elt, attributeName) {
var result = [];
// If the parent element also contains the requested attribute, then add it to the results too.
if (api.hasAttribute(elt, attributeName)) {
result.push(elt);
}
// Search all child nodes that match the requested attribute
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
result.push(node);
});
return result;
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt);
});
var swapSpec = api.getSwapSpecification(elt);
var target = api.getTarget(elt);
var settleInfo = api.makeSettleInfo(elt);
api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
settleInfo.elts.forEach(function(elt) {
if (elt.classList) {
elt.classList.add(htmx.config.settlingClass);
}
api.triggerEvent(elt, 'htmx:beforeSettle');
});
// Handle settle tasks (with delay if requested)
if (swapSpec.settleDelay > 0) {
setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
} else {
doSettle(settleInfo)();
}
}
/**
* doSettle mirrors much of the functionality in htmx that
* settles elements after their content has been swapped.
* TODO: this should be published by htmx, and not duplicated here
* @param {import("../htmx").HtmxSettleInfo} settleInfo
* @returns () => void
*/
function doSettle(settleInfo) {
return function() {
settleInfo.tasks.forEach(function(task) {
task.call();
});
settleInfo.elts.forEach(function(elt) {
if (elt.classList) {
elt.classList.remove(htmx.config.settlingClass);
}
api.triggerEvent(elt, 'htmx:afterSettle');
});
}
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null;
}
})();
@@ -1,101 +0,0 @@
/**
* Plugin store management.
*
* Handles plugin store browsing, searching, and installation.
*/
const PluginStoreManager = {
/**
* Cache for plugin store data.
*/
cache: null,
cacheTimestamp: null,
CACHE_DURATION: 5 * 60 * 1000, // 5 minutes
/**
* Load plugin store.
*
* @param {boolean} useCache - Whether to use cached data
* @returns {Promise<Array>} List of plugins
*/
async loadStore(useCache = true) {
// Check cache
if (useCache && this.cache && this.cacheTimestamp) {
const age = Date.now() - this.cacheTimestamp;
if (age < this.CACHE_DURATION) {
return this.cache;
}
}
try {
const plugins = await window.PluginAPI.getPluginStore();
this.cache = plugins;
this.cacheTimestamp = Date.now();
return plugins;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, 'Failed to load plugin store');
}
throw error;
}
},
/**
* Search plugin store.
*
* @param {string} query - Search query
* @returns {Promise<Array>} Filtered list of plugins
*/
async searchStore(query) {
const plugins = await this.loadStore();
if (!query || query.trim() === '') {
return plugins;
}
const lowerQuery = query.toLowerCase();
return plugins.filter(plugin => {
const name = (plugin.name || '').toLowerCase();
const description = (plugin.description || '').toLowerCase();
const author = (plugin.author || '').toLowerCase();
const category = (plugin.category || '').toLowerCase();
return name.includes(lowerQuery) ||
description.includes(lowerQuery) ||
author.includes(lowerQuery) ||
category.includes(lowerQuery);
});
},
/**
* Install plugin from store.
*
* @param {string} pluginId - Plugin identifier
* @param {string} branch - Optional branch name to install from
* @returns {Promise<Object>} Installation result
*/
async installPlugin(pluginId, branch = null) {
try {
const result = await window.PluginAPI.installPlugin(pluginId, branch);
// Clear cache
this.cache = null;
this.cacheTimestamp = null;
return result;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, `Failed to install plugin ${pluginId}`);
}
throw error;
}
}
};
// Export
if (typeof module !== 'undefined' && module.exports) {
module.exports = PluginStoreManager;
} else {
window.PluginStoreManager = PluginStoreManager;
}
@@ -32,7 +32,7 @@
version: '2.0.0',
render: function(container, config, value, options) {
console.log('[ArrayTableWidget] Render called (server-side rendered)');
if (window.debugLog) window.debugLog('[ArrayTableWidget] Render called (server-side rendered)');
},
getValue: function(fieldId) {
@@ -918,6 +918,4 @@
} else {
initArrayTableButtons();
}
console.log('[ArrayTableWidget] Array table widget registered (v2.0.0)');
})();
@@ -201,6 +201,4 @@
if (typeof window !== 'undefined') {
window.BaseWidget = BaseWidget;
}
console.log('[BaseWidget] Base widget class loaded');
})();
@@ -31,7 +31,7 @@
render: function(container, config, value, options) {
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
console.log('[CheckboxGroupWidget] Render called (server-side rendered)');
if (window.debugLog) window.debugLog('[CheckboxGroupWidget] Render called (server-side rendered)');
},
/**
@@ -116,6 +116,4 @@
});
hiddenInput.dispatchEvent(event);
};
console.log('[CheckboxGroupWidget] Checkbox group widget registered');
})();
@@ -258,6 +258,4 @@
}
}
});
console.log('[ColorPickerWidget] Color picker widget registered');
})();
@@ -31,7 +31,7 @@
render: function(container, config, value, options) {
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
console.log('[CustomFeedsWidget] Render called (server-side rendered)');
if (window.debugLog) window.debugLog('[CustomFeedsWidget] Render called (server-side rendered)');
},
/**
@@ -523,6 +523,4 @@
event.target.value = '';
});
};
console.log('[CustomFeedsWidget] Custom feeds widget registered');
})();
@@ -189,6 +189,4 @@
}
}
});
console.log('[DatePickerWidget] Date picker widget registered');
})();
@@ -254,6 +254,4 @@
// Expose DAYS constant for external use
window.LEDMatrixWidgets.get('day-selector').DAYS = DAYS;
window.LEDMatrixWidgets.get('day-selector').DAY_LABELS = DAY_LABELS;
console.log('[DaySelectorWidget] Day selector widget registered');
})();
@@ -167,6 +167,4 @@
}
}
});
console.log('[EmailInputWidget] Email input widget registered');
})();
@@ -197,6 +197,4 @@
}
}
});
console.log('[ColorPickerWidget] Color picker widget registered (example)');
})();
@@ -286,6 +286,4 @@
}
}
});
console.log('[FileUploadSingleWidget] File upload single widget registered');
})();
@@ -32,7 +32,7 @@
render: function(container, config, value, options) {
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
console.log('[FileUploadWidget] Render called (server-side rendered)');
if (window.debugLog) window.debugLog('[FileUploadWidget] Render called (server-side rendered)');
},
/**
@@ -1136,6 +1136,4 @@
window.updateImageList(fieldId, currentImages);
}
};
console.log('[FileUploadWidget] File upload widget registered');
})();
@@ -311,6 +311,4 @@
generateDisplayName: generateDisplayName
}
});
console.log('[FontSelectorWidget] Font selector widget registered');
})();
@@ -192,6 +192,4 @@
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
console.log('[GoogleCalendarPickerWidget] registered');
})();
@@ -828,8 +828,5 @@
getValue() { return null; },
setValue() {}
});
console.log('[JsonFileManager] Registered with LEDMatrixWidgets');
} else {
console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)');
}
})();
@@ -472,6 +472,4 @@
} else {
flushPending();
}
console.log('[NotificationWidget] Notification widget registered');
})();
@@ -239,6 +239,4 @@
}
}
});
console.log('[NumberInputWidget] Number input widget registered');
})();
@@ -316,6 +316,4 @@
}
}
});
console.log('[PasswordInputWidget] Password input widget registered');
})();
@@ -866,6 +866,4 @@
getValue: function () { return null; }, // file ops are immediate; nothing to submit
setValue: function (fieldId) { loadFiles(fieldId); }
});
console.log('[PluginFileManager] plugin-file-manager widget registered');
})();
@@ -29,7 +29,7 @@
// Check if widget is already registered
if (this.has(widgetName)) {
console.log(`[PluginWidgetLoader] Widget ${widgetName} already registered`);
if (window.debugLog) window.debugLog(`[PluginWidgetLoader] Widget ${widgetName} already registered`);
return;
}
@@ -45,7 +45,7 @@
try {
// Dynamic import of plugin widget
await import(widgetPath);
console.log(`[PluginWidgetLoader] Loaded plugin widget: ${pluginId}/${widgetName} from ${widgetPath}`);
if (window.debugLog) window.debugLog(`[PluginWidgetLoader] Loaded plugin widget: ${pluginId}/${widgetName} from ${widgetPath}`);
// Verify widget was registered
if (this.has(widgetName)) {
@@ -124,6 +124,4 @@
return loadedWidgets;
};
console.log('[PluginWidgetLoader] Plugin widget loader initialized');
})();
@@ -144,6 +144,4 @@
}
}
});
console.log('[RadioGroupWidget] Radio group widget registered');
})();
@@ -50,7 +50,7 @@
this._handlers.set(widgetName, definition.handlers);
}
console.log(`[WidgetRegistry] Registered widget: ${widgetName}`);
if (window.debugLog) window.debugLog(`[WidgetRegistry] Registered widget: ${widgetName}`);
return true;
},
@@ -212,6 +212,4 @@
})));
};
}
console.log('[WidgetRegistry] Widget registry initialized');
})();
@@ -128,6 +128,4 @@
}
}
});
console.log('[SelectDropdownWidget] Select dropdown widget registered');
})();
@@ -173,6 +173,4 @@
}
}
});
console.log('[SliderWidget] Slider widget registered');
})();
@@ -768,6 +768,4 @@
return null;
}
});
console.log('[StyleEditor] widget registered');
})();
@@ -239,6 +239,4 @@
}
}
});
console.log('[TextInputWidget] Text input widget registered');
})();
@@ -175,6 +175,4 @@
}
}
});
console.log('[TextareaWidget] Textarea widget registered');
})();
@@ -166,6 +166,4 @@
}
}
});
console.log('[TimePickerWidget] Time picker widget registered');
})();
@@ -370,6 +370,4 @@
// Expose utility functions for external use
window.LEDMatrixWidgets.get('time-range').parseTimeToMinutes = parseTimeToMinutes;
window.LEDMatrixWidgets.get('time-range').calculateDuration = calculateDuration;
console.log('[TimeRangeWidget] Time range widget registered');
})();
@@ -414,6 +414,4 @@
}, 50);
});
})();
console.log('[TimezoneSelectorWidget] Timezone selector widget registered');
})();
@@ -220,6 +220,4 @@
}
}
});
console.log('[ToggleSwitchWidget] Toggle switch widget registered');
})();
@@ -284,6 +284,4 @@
}
}
});
console.log('[UrlInputWidget] URL input widget registered');
})();
+16 -579
View File
@@ -41,164 +41,6 @@ const safeLocalStorage = {
const _PLUGIN_DEBUG_EARLY = safeLocalStorage.getItem('pluginDebug') === 'true';
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS SCRIPT] Defining configurePlugin and togglePlugin at top level...');
// Expose on-demand functions early as stubs (will be replaced when IIFE runs)
window.openOnDemandModal = function(pluginId) {
console.warn('openOnDemandModal called before initialization, waiting...');
// Wait for the real function to be available
let attempts = 0;
const maxAttempts = 50; // 2.5 seconds
const checkInterval = setInterval(() => {
attempts++;
if (window.__openOnDemandModalImpl) {
clearInterval(checkInterval);
window.__openOnDemandModalImpl(pluginId);
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
console.error('openOnDemandModal not available after waiting');
if (typeof showNotification === 'function') {
showNotification('On-demand modal unavailable. Please refresh the page.', 'error');
}
}
}, 50);
};
window.requestOnDemandStop = function({ stopService = false } = {}) {
console.warn('requestOnDemandStop called before initialization, waiting...');
// Wait for the real function to be available
let attempts = 0;
const maxAttempts = 50; // 2.5 seconds
const checkInterval = setInterval(() => {
attempts++;
if (window.__requestOnDemandStopImpl) {
clearInterval(checkInterval);
return window.__requestOnDemandStopImpl({ stopService });
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
console.error('requestOnDemandStop not available after waiting');
if (typeof showNotification === 'function') {
showNotification('On-demand stop unavailable. Please refresh the page.', 'error');
}
return Promise.reject(new Error('Function not available'));
}
}, 50);
return Promise.resolve();
};
// Define updatePlugin early as a stub to ensure it's always available
window.updatePlugin = window.updatePlugin || function(pluginId) {
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] updatePlugin called for', pluginId);
// Validate pluginId
if (!pluginId || typeof pluginId !== 'string') {
console.error('Invalid pluginId:', pluginId);
if (typeof showNotification === 'function') {
showNotification('Invalid plugin ID', 'error');
}
return Promise.reject(new Error('Invalid plugin ID'));
}
// Show immediate feedback
if (typeof showNotification === 'function') {
showNotification(`Updating ${pluginId}...`, 'info');
}
// Prepare request body
const requestBody = { plugin_id: pluginId };
const requestBodyJson = JSON.stringify(requestBody);
debugLog('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson });
// Make the API call directly
return fetch('/api/v3/plugins/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: requestBodyJson
})
.then(async response => {
// Check if response is OK before parsing
if (!response.ok) {
// Try to parse error response
let errorData;
try {
const text = await response.text();
console.error('[UPDATE] Error response:', { status: response.status, statusText: response.statusText, body: text });
errorData = JSON.parse(text);
} catch (e) {
errorData = { message: `Server error: ${response.status} ${response.statusText}` };
}
if (typeof showNotification === 'function') {
showNotification(errorData.message || `Update failed: ${response.status}`, 'error');
}
throw new Error(errorData.message || `Update failed: ${response.status}`);
}
// Parse successful response
return response.json();
})
.then(data => {
if (typeof showNotification === 'function') {
showNotification(data.message || 'Update initiated', data.status || 'info');
}
// Refresh installed plugins if available
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
window.pluginManager.loadInstalledPlugins();
}
return data;
})
.catch(error => {
console.error('[UPDATE] Error updating plugin:', error);
if (typeof showNotification === 'function') {
showNotification('Error updating plugin: ' + error.message, 'error');
}
throw error;
});
};
// Define uninstallPlugin early as a stub
window.uninstallPlugin = window.uninstallPlugin || function(pluginId) {
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] uninstallPlugin called for', pluginId);
if (!confirm(`Are you sure you want to uninstall ${pluginId}?`)) {
return Promise.resolve({ cancelled: true });
}
if (typeof showNotification === 'function') {
showNotification(`Uninstalling ${pluginId}...`, 'info');
}
return fetch('/api/v3/plugins/uninstall', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId })
})
.then(response => response.json())
.then(data => {
if (typeof showNotification === 'function') {
showNotification(data.message || 'Uninstall initiated', data.status || 'info');
}
// Refresh installed plugins if available
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
window.pluginManager.loadInstalledPlugins();
}
return data;
})
.catch(error => {
console.error('Error uninstalling plugin:', error);
if (typeof showNotification === 'function') {
showNotification('Error uninstalling plugin: ' + error.message, 'error');
}
throw error;
});
};
// Define configurePlugin early to ensure it's always available
window.configurePlugin = window.configurePlugin || async function(pluginId) {
if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] configurePlugin called for', pluginId);
@@ -401,124 +243,25 @@ window.__pendingInstalledPlugins = window.__pendingInstalledPlugins || null;
window.__pendingStorePlugins = window.__pendingStorePlugins || null;
window.__pluginDomReady = window.__pluginDomReady || false;
// Set up global event delegation for plugin actions (works even before plugins are loaded)
// Document-level delegation for plugin card actions, so a card works even if
// it was rendered before the grid's own listener was attached. It hands the
// event to handlePluginAction, which the plugin-manager IIFE below exposes on
// window. (It used to test `typeof handlePluginAction`, which is IIFE-scoped
// and so never visible here: every click took a copied fallback instead, which
// asked to confirm an uninstall twice and sent Starlark app uninstalls to the
// plugin endpoint.)
(function setupGlobalEventDelegation() {
// Use document-level delegation so it works for dynamically added content
const handleGlobalPluginAction = function(event) {
// Only handle if it's a plugin action
const button = event.target.closest('button[data-action][data-plugin-id]') ||
event.target.closest('input[data-action][data-plugin-id]');
if (!button) return;
const action = button.getAttribute('data-action');
const pluginId = button.getAttribute('data-plugin-id');
// For toggle and configure, ensure functions are available
if (action === 'toggle' || action === 'configure') {
const funcName = action === 'toggle' ? 'togglePlugin' : 'configurePlugin';
if (!window[funcName] || typeof window[funcName] !== 'function') {
// Prevent default and stop propagation immediately to avoid double handling
event.preventDefault();
event.stopPropagation();
console.warn(`[GLOBAL DELEGATION] ${funcName} not available yet, waiting...`);
// Capture state synchronously from plugin data (source of truth)
let targetChecked = false;
if (action === 'toggle') {
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
let currentEnabled;
if (plugin) {
currentEnabled = Boolean(plugin.enabled);
} else if (button.type === 'checkbox') {
currentEnabled = button.checked;
} else {
currentEnabled = false;
}
targetChecked = !currentEnabled; // Toggle to opposite state
}
// Wait for function to be available
let attempts = 0;
const maxAttempts = 20; // 1 second total
const checkInterval = setInterval(() => {
attempts++;
if (window[funcName] && typeof window[funcName] === 'function') {
clearInterval(checkInterval);
// Call the function directly
if (action === 'toggle') {
window.togglePlugin(pluginId, targetChecked);
} else {
window.configurePlugin(pluginId);
}
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
console.error(`[GLOBAL DELEGATION] ${funcName} not available after ${maxAttempts} attempts`);
if (typeof showNotification === 'function') {
showNotification(`${funcName} not loaded. Please refresh the page.`, 'error');
}
}
}, 50);
return; // Don't proceed with normal handling
}
}
// Prevent default and stop propagation to avoid double handling
event.preventDefault();
event.stopPropagation();
// If handlePluginAction exists, use it; otherwise handle directly
if (typeof handlePluginAction === 'function') {
handlePluginAction(event);
} else {
// Fallback: handle directly if functions are available
if (action === 'toggle' && window.togglePlugin) {
// Get the current enabled state from plugin data (source of truth)
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
let currentEnabled;
if (plugin) {
currentEnabled = Boolean(plugin.enabled);
} else if (button.type === 'checkbox') {
currentEnabled = button.checked;
} else {
currentEnabled = false;
}
// Toggle the state - we want the opposite of current state
const isChecked = !currentEnabled;
// Prevent default behavior to avoid double-toggling and change event
// (Already done at start of function, but safe to repeat)
event.preventDefault();
event.stopPropagation();
debugLog('[DEBUG toggle fallback] Plugin:', pluginId, 'Current enabled (from data):', currentEnabled, 'New state:', isChecked);
window.togglePlugin(pluginId, isChecked);
} else if (action === 'configure' && window.configurePlugin) {
event.preventDefault();
event.stopPropagation();
window.configurePlugin(pluginId);
} else if (action === 'update' && window.updatePlugin) {
event.preventDefault();
event.stopPropagation();
debugLog('[DEBUG update fallback] Updating plugin:', pluginId);
window.updatePlugin(pluginId);
} else if (action === 'uninstall' && window.uninstallPlugin) {
event.preventDefault();
event.stopPropagation();
debugLog('[DEBUG uninstall fallback] Uninstalling plugin:', pluginId);
if (confirm(`Are you sure you want to uninstall ${pluginId}?`)) {
window.uninstallPlugin(pluginId);
}
}
}
const target = event.target;
if (!target || typeof target.closest !== 'function') return;
const el = target.closest('button[data-action][data-plugin-id]') ||
target.closest('input[data-action][data-plugin-id]');
if (!el || typeof window.handlePluginAction !== 'function') return;
window.handlePluginAction(event);
};
// Set up delegation on document (capture phase for better reliability)
// Capture phase, so this runs before the grid's own listener;
// handlePluginAction stops propagation, so an action is handled once.
document.addEventListener('click', handleGlobalPluginAction, true);
document.addEventListener('change', handleGlobalPluginAction, true);
debugLog('[PLUGINS SCRIPT] Global event delegation set up');
@@ -1846,6 +1589,7 @@ function handlePluginAction(event) {
break;
}
}
window.handlePluginAction = handlePluginAction;
function findInstalledPlugin(pluginId) {
const plugins = window.installedPlugins || installedPlugins || [];
@@ -2220,7 +1964,6 @@ window.__openOnDemandModalImpl = function(pluginId) {
});
};
// Replace the stub with the real implementation
window.openOnDemandModal = window.__openOnDemandModalImpl;
// Release handle for the on-demand modal's focus trap (window.LEDDialog).
@@ -2358,8 +2101,6 @@ function stopOnDemand(event) {
requestOnDemandStop({ stopService });
}
// Store the real implementation and replace the stub
window.__requestOnDemandStopImpl = requestOnDemandStop;
window.requestOnDemandStop = requestOnDemandStop;
function closeOnDemandModalOnBackdrop(event) {
@@ -2616,98 +2357,6 @@ window.updateKeyValuePairData = function(fieldId, fullKey) {
hiddenInput.value = JSON.stringify(pairs);
};
// Functions to handle array-of-objects
window.addArrayObjectItem = function(fieldId, fullKey, maxItems) {
const itemsContainer = document.getElementById(fieldId + '_items');
const hiddenInput = document.getElementById(fieldId + '_data');
if (!itemsContainer || !hiddenInput) return;
const currentItems = itemsContainer.querySelectorAll('.array-object-item');
if (currentItems.length >= maxItems) {
alert(`Maximum ${maxItems} items allowed`);
return;
}
// Get schema for item properties from the hidden input's data attribute or currentPluginConfig
const schema = (typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema) || (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema);
if (!schema) return;
// Navigate to the items schema
const keys = fullKey.split('.');
let itemsSchema = schema.properties;
for (const key of keys) {
if (itemsSchema && itemsSchema[key]) {
itemsSchema = itemsSchema[key];
if (itemsSchema.type === 'array' && itemsSchema.items) {
itemsSchema = itemsSchema.items;
break;
}
}
}
if (!itemsSchema || !itemsSchema.properties) return;
const newIndex = currentItems.length;
const itemHtml = renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema);
itemsContainer.insertAdjacentHTML('beforeend', itemHtml);
updateArrayObjectData(fieldId);
// Update add button state
const addButton = itemsContainer.nextElementSibling;
if (addButton && currentItems.length + 1 >= maxItems) {
addButton.disabled = true;
addButton.style.opacity = '0.5';
addButton.style.cursor = 'not-allowed';
}
};
window.removeArrayObjectItem = function(fieldId, index) {
const itemsContainer = document.getElementById(fieldId + '_items');
if (!itemsContainer) return;
const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`);
if (item) {
item.remove();
// Re-index remaining items
const remainingItems = itemsContainer.querySelectorAll('.array-object-item');
remainingItems.forEach((itemEl, newIndex) => {
itemEl.setAttribute('data-index', newIndex);
// Update the id attribute to match new index (used by file upload selectors)
const newItemId = `${fieldId}_item_${newIndex}`;
itemEl.id = newItemId;
// Update all inputs within this item - need to update name/id attributes
itemEl.querySelectorAll('input, select, textarea').forEach(input => {
const name = input.getAttribute('name') || input.id;
if (name) {
// Update name/id attribute with new index
const newName = name.replace(/\[\d+\]/, `[${newIndex}]`);
if (input.getAttribute('name')) input.setAttribute('name', newName);
if (input.id) input.id = input.id.replace(/\d+/, newIndex);
}
});
// Update button onclick attributes
itemEl.querySelectorAll('button[onclick]').forEach(button => {
const onclick = button.getAttribute('onclick');
if (onclick) {
button.setAttribute('onclick', onclick.replace(/\d+/, newIndex));
}
});
});
updateArrayObjectData(fieldId);
// Update add button state
const addButton = itemsContainer.nextElementSibling;
if (addButton) {
const maxItems = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]);
if (remainingItems.length < maxItems) {
addButton.disabled = false;
addButton.style.opacity = '1';
addButton.style.cursor = 'pointer';
}
}
}
};
window.updateArrayObjectData = function(fieldId) {
const itemsContainer = document.getElementById(fieldId + '_items');
const hiddenInput = document.getElementById(fieldId + '_data');
@@ -3421,78 +3070,6 @@ window.executePluginAction = function(actionId, actionIndex, pluginIdParam = nul
// togglePlugin is already defined at the top of the script - no need to redefine
// Only override updatePlugin if it doesn't already have improved error handling
if (!window.updatePlugin || window.updatePlugin.toString().includes('[UPDATE]')) {
window.updatePlugin = function(pluginId) {
// Validate pluginId
if (!pluginId || typeof pluginId !== 'string') {
console.error('[UPDATE] Invalid pluginId:', pluginId);
if (typeof showNotification === 'function') {
showNotification('Invalid plugin ID', 'error');
}
return Promise.reject(new Error('Invalid plugin ID'));
}
showNotification(`Updating ${pluginId}...`, 'info');
// Prepare request body
const requestBody = { plugin_id: pluginId };
const requestBodyJson = JSON.stringify(requestBody);
debugLog('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson });
return fetch('/api/v3/plugins/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: requestBodyJson
})
.then(async response => {
// Check if response is OK before parsing
if (!response.ok) {
// Try to parse error response
let errorData;
try {
const text = await response.text();
console.error('[UPDATE] Error response:', { status: response.status, statusText: response.statusText, body: text });
errorData = JSON.parse(text);
} catch (e) {
errorData = { message: `Server error: ${response.status} ${response.statusText}` };
}
if (typeof showNotification === 'function') {
showNotification(errorData.message || `Update failed: ${response.status}`, 'error');
}
throw new Error(errorData.message || `Update failed: ${response.status}`);
}
// Parse successful response
return response.json();
})
.then(data => {
showNotification(data.message || 'Update initiated', data.status || 'info');
if (data.status === 'success') {
// Refresh the list
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
window.pluginManager.loadInstalledPlugins();
}
}
return data;
})
.catch(error => {
console.error('[UPDATE] Error updating plugin:', error);
if (typeof showNotification === 'function') {
showNotification('Error updating plugin: ' + error.message, 'error');
}
throw error;
});
};
}
window.uninstallPlugin = function(pluginId) {
const plugin = (window.installedPlugins || installedPlugins || []).find(p => p.id === pluginId);
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
@@ -4627,34 +4204,6 @@ function jsStringAttr(value) {
return escapeAttribute(JSON.stringify(value == null ? '' : String(value)));
}
// Format date for display
function formatDate(dateString) {
if (!dateString) return 'Unknown';
try {
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 1) {
return 'Today';
} else if (diffDays < 2) {
return 'Yesterday';
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
} else {
// Return formatted date for older items
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
} catch (e) {
return dateString;
}
}
function isNewPlugin(lastUpdated) {
if (!lastUpdated) return false;
@@ -5013,10 +4562,6 @@ window.handleCredentialsUpload = async function(event, fieldId, uploadEndpoint,
// handleFiles is now defined exclusively in file-upload.js widget
window.deleteUploadedImage = async function(fieldId, imageId, pluginId) {
return window.deleteUploadedFile(fieldId, imageId, pluginId, 'image', null);
}
window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType, customDeleteEndpoint) {
const fileTypeLabel = fileType === 'json' ? 'file' : 'image';
if (!confirm(`Are you sure you want to delete this ${fileTypeLabel}?`)) {
@@ -5084,18 +4629,6 @@ window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType,
// getUploadConfig is defined in file-upload.js widget which loads first.
// No override needed here — file-upload.js owns this function.
window.getCurrentImages = function(fieldId) {
const hiddenInput = document.getElementById(`${fieldId}_images_data`);
if (hiddenInput && hiddenInput.value) {
try {
return JSON.parse(hiddenInput.value);
} catch (e) {
console.error('Error parsing images data:', e);
}
}
return [];
}
window.updateImageList = function(fieldId, images) {
const hiddenInput = document.getElementById(`${fieldId}_images_data`);
if (hiddenInput) {
@@ -5158,17 +4691,6 @@ window.updateImageList = function(fieldId, images) {
}
}
window.showUploadProgress = function(fieldId, totalFiles) {
const dropZone = document.getElementById(`${fieldId}_drop_zone`);
if (dropZone) {
dropZone.innerHTML = `
<i class="fas fa-spinner fa-spin text-3xl text-blue-500 mb-2"></i>
<p class="text-sm text-gray-600">Uploading ${totalFiles} file(s)...</p>
`;
dropZone.style.pointerEvents = 'none';
}
}
window.hideUploadProgress = function(fieldId) {
const uploadConfig = window.getUploadConfig(fieldId);
const maxFiles = uploadConfig.max_files || 10;
@@ -5186,14 +4708,6 @@ window.hideUploadProgress = function(fieldId) {
}
}
window.formatFileSize = function(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function formatDate(dateString) {
if (!dateString) return 'Unknown date';
try {
@@ -5204,30 +4718,6 @@ function formatDate(dateString) {
}
}
window.getScheduleSummary = function(schedule) {
if (!schedule || !schedule.enabled || schedule.mode === 'always') {
return 'Always shown';
}
if (schedule.mode === 'time_range') {
return `${schedule.start_time || '08:00'} - ${schedule.end_time || '18:00'} (daily)`;
}
if (schedule.mode === 'per_day' && schedule.days) {
const enabledDays = Object.entries(schedule.days)
.filter(([day, config]) => config && config.enabled)
.map(([day]) => day.charAt(0).toUpperCase() + day.slice(1, 3));
if (enabledDays.length === 0) {
return 'Never shown';
}
return enabledDays.join(', ') + ' only';
}
return 'Scheduled';
}
window.openImageSchedule = function(fieldId, imageId, imageIdx) {
const currentImages = getCurrentImages(fieldId);
const image = currentImages[imageIdx];
@@ -5614,14 +5104,6 @@ if (typeof window !== 'undefined') {
}
};
// updateArrayObjectData is defined earlier in the file (line ~3596)
// Only define stub if it doesn't already exist (defensive fallback)
if (typeof window.updateArrayObjectData === 'undefined') {
window.updateArrayObjectData = function(fieldId) {
console.warn('updateArrayObjectData stub called - implementation should be defined earlier');
};
}
window.updateCheckboxGroupData = function(fieldId) {
// Update hidden _data input with currently checked values
const hiddenInput = document.getElementById(fieldId + '_data');
@@ -5640,22 +5122,6 @@ if (typeof window !== 'undefined') {
hiddenInput.value = JSON.stringify(selectedValues);
};
// handleArrayObjectFileUpload and removeArrayObjectFile are defined earlier in the file
// Only define stubs if they don't already exist (defensive fallback)
if (typeof window.handleArrayObjectFileUpload === 'undefined') {
window.handleArrayObjectFileUpload = function(event, fieldId, itemIndex, propKey, pluginId) {
console.warn('handleArrayObjectFileUpload stub called - implementation should be defined earlier');
window.updateArrayObjectData(fieldId);
};
}
if (typeof window.removeArrayObjectFile === 'undefined') {
window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) {
console.warn('removeArrayObjectFile stub called - implementation should be defined earlier');
window.updateArrayObjectData(fieldId);
};
}
// Debug logging (only if pluginDebug is enabled)
if (_PLUGIN_DEBUG_EARLY) {
debugLog('[ARRAY-OBJECTS] Functions defined on window:', {
@@ -5674,23 +5140,6 @@ window.currentPluginConfig = null;
// Force initialization immediately when script loads (for HTMX swapped content)
debugLog('Plugins script loaded, checking for elements...');
// Ensure all functions are globally available (in case IIFE didn't expose them properly)
// These should already be set inside the IIFE, but this ensures they're available
if (typeof initializePluginPageWhenReady !== 'undefined') {
window.initializePluginPageWhenReady = initializePluginPageWhenReady;
}
if (typeof initializePlugins !== 'undefined') {
window.initializePlugins = initializePlugins;
}
if (typeof loadInstalledPlugins !== 'undefined') {
window.loadInstalledPlugins = loadInstalledPlugins;
}
if (typeof renderInstalledPlugins !== 'undefined') {
window.renderInstalledPlugins = renderInstalledPlugins;
}
// GitHub install handlers are now exposed inside the IIFE (see above).
// searchPluginStore is also exposed inside the IIFE after its definition.
// Verify critical functions are available
if (_PLUGIN_DEBUG_EARLY) {
debugLog('Plugin functions available:', {
@@ -5708,18 +5157,6 @@ if (window.checkGitHubAuthStatus && document.getElementById('github-auth-warning
window.checkGitHubAuthStatus();
}
// Initialize on-demand modal immediately since it's in base.html
if (typeof initializeOnDemandModal === 'function') {
// Run immediately and also after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeOnDemandModal);
} else {
initializeOnDemandModal();
}
// Also try after a short delay to ensure elements are available
setTimeout(initializeOnDemandModal, 100);
}
setTimeout(function() {
const installedGrid = document.getElementById('installed-plugins-grid');
if (installedGrid) {
+1 -8
View File
@@ -110,10 +110,8 @@
<script>
(function() {
const htmxSrc = '/static/v3/js/htmx.min.js';
const sseSrc = '/static/v3/js/htmx-sse.js';
const jsonEncSrc = '/static/v3/js/htmx-json-enc.js';
const htmxCdn = 'https://unpkg.com/htmx.org@1.9.10';
const sseCdn = 'https://unpkg.com/htmx.org@1.9.10/dist/ext/sse.js';
const jsonEncCdn = 'https://unpkg.com/htmx.org@1.9.10/dist/ext/json-enc.js';
// Load a script with an optional one-shot fallback source
@@ -138,8 +136,7 @@
function htmxReady() {
debugLog('HTMX loaded successfully');
window.dispatchEvent(new Event('htmx:ready'));
// Load extensions after core loads
loadScript(sseSrc, sseCdn);
// Load the json-enc extension after core loads
loadScript(jsonEncSrc, jsonEncCdn);
}
@@ -979,15 +976,11 @@
<script src="{{ url_for('static', filename='v3/js/plugins/api_client.js') }}" defer></script>
<!-- Load plugin management modules -->
<script src="{{ url_for('static', filename='v3/js/plugins/store_manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
<!-- Shared search/filter/sort controller for the plugin card grids -->
<script src="{{ url_for('static', filename='v3/js/plugins/list_filter.js') }}" defer></script>
<!-- Load config utilities -->
<script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script>
<!-- Widget Registry System: all widget scripts in one request (see
web_interface/widget_bundle.py). The individual files stay served at
their own URLs for plugin-loader.js and debugging. -->
-161
View File
@@ -1,161 +0,0 @@
{% extends "v3/base.html" %}
{% block content %}
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
<p class="mt-1 text-sm text-gray-600">Monitor system status and manage your LED matrix display.</p>
</div>
<!-- System Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-microchip text-blue-600 text-xl"></i>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">CPU Usage</dt>
<dd class="text-lg font-medium text-gray-900" id="cpu-usage">--%</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-memory text-green-600 text-xl"></i>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Memory Usage</dt>
<dd class="text-lg font-medium text-gray-900" id="memory-usage">--%</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-thermometer-half text-red-600 text-xl"></i>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">CPU Temperature</dt>
<dd class="text-lg font-medium text-gray-900" id="cpu-temp">--°C</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-desktop text-purple-600 text-xl"></i>
</div>
<div class="ml-3 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Display Status</dt>
<dd class="text-lg font-medium text-gray-900" id="display-status">Unknown</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="border-b border-gray-200 pb-4 mb-6">
<h3 class="text-md font-medium text-gray-900 mb-4">Quick Actions</h3>
<div class="flex flex-wrap gap-3" hx-ext="json-enc">
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}'
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); }"
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
</button>
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}'
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); }"
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
</button>
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "git_pull"}'
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); }"
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
</button>
<button hx-post="/api/v3/system/action"
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') { 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
</button>
</div>
</div>
<!-- Display Preview -->
<div>
<h3 class="text-md font-medium text-gray-900 mb-4">Display Preview</h3>
<div class="bg-gray-900 rounded-lg p-4 aspect-video flex items-center justify-center">
<div class="text-center text-gray-400">
<i class="fas fa-desktop text-4xl mb-2"></i>
<p>Display preview will appear here</p>
<p class="text-sm mt-1">Connect to see live updates</p>
</div>
</div>
</div>
</div>
<!-- Update stats via SSE -->
<script>
// Listen for system stats updates
if (typeof statsSource !== 'undefined') {
statsSource.onmessage = function(event) {
const data = JSON.parse(event.data);
// Update CPU
const cpuEl = document.getElementById('cpu-usage');
if (cpuEl && data.cpu_percent !== undefined) {
cpuEl.textContent = data.cpu_percent + '%';
}
// Update Memory
const memEl = document.getElementById('memory-usage');
if (memEl && data.memory_used_percent !== undefined) {
memEl.textContent = data.memory_used_percent + '%';
}
// Update Temperature
const tempEl = document.getElementById('cpu-temp');
if (tempEl && data.cpu_temp !== undefined) {
tempEl.textContent = data.cpu_temp + '°C';
}
// Update Display Status
const displayEl = document.getElementById('display-status');
if (displayEl) {
displayEl.textContent = data.service_active ? 'Active' : 'Inactive';
displayEl.className = data.service_active ?
'text-lg font-medium text-green-600' :
'text-lg font-medium text-red-600';
}
};
}
</script>
{% endblock %}
@@ -36,7 +36,7 @@
<div class="bg-gray-50 rounded-lg p-4 space-y-5">
<div>
<h3 class="text-md font-medium text-gray-900 mb-1">Screen Durations</h3>
<p class="text-sm text-gray-600">How long each screen stays on before rotating to the next one, in seconds (5&ndash;600, default 30).</p>
<p class="text-sm text-gray-600">How long each screen stays on before rotating to the next one, in seconds (5&ndash;600). Leave a screen blank to use its plugin's own duration, shown greyed out.</p>
</div>
{% for group in duration_groups %}
<div>
@@ -45,12 +45,13 @@
{% for mode in group.modes %}
<div class="form-group" id="setting-durations-{{ mode.key }}" data-setting-key="display.display_durations.{{ mode.key }}">
<label for="duration__{{ mode.key }}" class="block text-sm font-medium text-gray-700">
{{ mode.key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (mode.key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5–600. Currently ' ~ mode.value ~ 's.', mode.key | replace('_', ' ') | title) }}
{{ mode.key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (mode.key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5–600.' ~ (' Blank uses the plugin\'s own ' ~ mode.default ~ 's.' if mode.default else ''), mode.key | replace('_', ' ') | title) }}
</label>
<input type="number"
id="duration__{{ mode.key }}"
name="duration__{{ mode.key }}"
value="{{ mode.value }}"
{% if mode.default %}placeholder="{{ mode.default }}"{% endif %}
min="5"
max="600"
class="form-control">
@@ -152,7 +152,7 @@ function initializeFontsTab() {
console.error('Fonts tab elements not found after max retries, giving up');
return;
}
console.log('Fonts tab elements not found, retrying...', {
debugLog('Fonts tab elements not found, retrying...', {
availableFonts: !!availableEl,
attempt: initRetryCount
});
@@ -178,7 +178,7 @@ function initializeFontsTab() {
};
}
console.log('Initializing font management...');
debugLog('Initializing font management...');
initializeFontManagement();
// Event listeners (use event delegation or ensure elements exist)
@@ -229,7 +229,7 @@ function initializeFontsTab() {
});
}
console.log('Fonts tab initialized successfully');
debugLog('Fonts tab initialized successfully');
}
// Expose initializeFontsTab to window for re-initialization after HTMX reload
@@ -244,7 +244,7 @@ window.initializeFontsTab = initializeFontsTab;
const fontsContent = document.getElementById('fonts-content');
if (fontsContent) {
console.log('Fonts content detected, initializing...');
debugLog('Fonts content detected, initializing...');
setTimeout(() => {
initializeFontsTab();
}, 50);
@@ -257,7 +257,7 @@ window.initializeFontsTab = initializeFontsTab;
// Check if the event target is the fonts-content container or contains it
const target = event.target;
if (target && (target.id === 'fonts-content' || target.querySelector && target.querySelector('#fonts-content'))) {
console.log('HTMX loaded fonts content, initializing...', target.id);
debugLog('HTMX loaded fonts content, initializing...', target.id);
tryInitializeFontsTab();
}
});
@@ -334,7 +334,7 @@ async function loadFontData() {
// Update displays
updateAvailableFontsDisplay();
console.log('Font data loaded successfully', {
debugLog('Font data loaded successfully', {
catalogSize: Object.keys(fontCatalog).length,
tokensSize: Object.keys(fontTokens).length
});
@@ -490,7 +490,7 @@ function populateFontSelects() {
previewSelect.value = fontEntries[0].filename;
}
console.log(`Populated font selects with ${fontEntries.length} fonts`);
debugLog(`Populated font selects with ${fontEntries.length} fonts`);
}
async function updateFontPreview() {
@@ -104,6 +104,25 @@
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected to log stream</span>
</div>
<!-- Plugin errors, as recorded by the display service -->
<div id="plugin-errors-panel" class="mt-6 border-t border-gray-200 pt-4">
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<div>
<h3 class="text-base font-semibold text-gray-900">Plugin errors</h3>
<p id="plugin-errors-meta" class="text-xs text-gray-600">Recorded by the display service</p>
</div>
<div class="flex items-center gap-2">
<button id="plugin-errors-refresh-btn" type="button" class="btn bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm">
<i class="fas fa-sync-alt mr-1"></i>Refresh
</button>
<button id="plugin-errors-clear-btn" type="button" class="btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm">
<i class="fas fa-eraser mr-1"></i>Clear
</button>
</div>
</div>
<div id="plugin-errors-body" class="text-sm text-gray-600" aria-live="polite">Loading...</div>
</div>
</div>
<script>
@@ -218,10 +237,18 @@ window._filteredLogs = [];
// Current-plugin poll and the realtime log stream run only while the Logs
// tab is active and the page is visible (both restart with an immediate
// refresh). Re-registering on partial reload replaces the old one.
const pluginErrorsRefreshBtn = document.getElementById('plugin-errors-refresh-btn');
if (pluginErrorsRefreshBtn) pluginErrorsRefreshBtn.onclick = refreshPluginErrors;
const pluginErrorsClearBtn = document.getElementById('plugin-errors-clear-btn');
if (pluginErrorsClearBtn) pluginErrorsClearBtn.onclick = clearPluginErrors;
function startLogsActivity() {
refreshCurrentPluginStatus();
if (window._currentPluginPollTimer) clearInterval(window._currentPluginPollTimer);
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
refreshPluginErrors();
if (window._pluginErrorsPollTimer) clearInterval(window._pluginErrorsPollTimer);
window._pluginErrorsPollTimer = setInterval(refreshPluginErrors, 15000);
if (window._isRealtime && !window._logsEventSource) setupRealtimeLogs();
}
function stopLogsActivity() {
@@ -229,6 +256,10 @@ window._filteredLogs = [];
clearInterval(window._currentPluginPollTimer);
window._currentPluginPollTimer = null;
}
if (window._pluginErrorsPollTimer) {
clearInterval(window._pluginErrorsPollTimer);
window._pluginErrorsPollTimer = null;
}
stopRealtimeLogs();
}
if (window.LEDVisibility) {
@@ -787,6 +818,120 @@ function refreshCurrentPluginStatus() {
});
}
// Plugin errors panel. The display service records them and publishes a
// snapshot every few seconds; /api/v3/errors/summary serves that snapshot.
// Plugin ids and messages come from plugins: everything goes through escapeHtml.
function formatErrorTime(iso) {
return iso ? String(iso).replace('T', ' ').slice(0, 19) : '';
}
function renderPluginErrors(summary) {
const body = document.getElementById('plugin-errors-body');
const meta = document.getElementById('plugin-errors-meta');
if (!body) return;
if (meta) {
let text = 'Recorded by the display service';
if (summary.generated_at) text += ' · updated ' + formatErrorTime(summary.generated_at);
if (summary.clear_pending) text += ' · clearing…';
meta.textContent = text;
}
if (!summary.snapshot_available) {
body.innerHTML = '<p class="text-gray-600"><i class="fas fa-hourglass-half mr-1"></i>' +
'Display service hasn’t reported yet. Errors appear here once it is running.</p>';
return;
}
const plugins = Object.entries(summary.plugin_error_counts || {}).map(([id, types]) => {
const total = Object.values(types || {}).reduce((sum, n) => sum + (Number(n) || 0), 0);
return { id, types: types || {}, total };
}).sort((a, b) => b.total - a.total);
const patterns = Object.values(summary.active_patterns || {});
if (!Number(summary.total_errors) && plugins.length === 0 && patterns.length === 0) {
body.innerHTML = '<p class="text-gray-600"><i class="fas fa-check-circle text-green-600 mr-1"></i>' +
'No plugin errors recorded</p>';
return;
}
let html = '';
if (plugins.length) {
html += '<div class="overflow-x-auto"><table class="min-w-full text-sm">' +
'<thead><tr class="text-left text-xs uppercase text-gray-500 border-b border-gray-200">' +
'<th class="py-1 pr-4 font-medium">Plugin</th><th class="py-1 pr-4 font-medium">Errors</th>' +
'<th class="py-1 font-medium">Types</th></tr></thead><tbody>';
plugins.forEach(p => {
const types = Object.entries(p.types)
.map(([type, n]) => `${escapeHtml(type)} ×${escapeHtml(String(Number(n) || 0))}`)
.join(', ');
html += '<tr class="border-b border-gray-100">' +
`<td class="py-1 pr-4"><code class="bg-gray-100 px-1 rounded">${escapeHtml(p.id)}</code></td>` +
`<td class="py-1 pr-4 font-semibold text-gray-900">${escapeHtml(String(p.total))}</td>` +
`<td class="py-1 text-gray-700 break-words">${types}</td></tr>`;
});
html += '</tbody></table></div>';
}
if (patterns.length) {
const badge = {
critical: 'bg-red-100 text-red-800',
error: 'bg-amber-100 text-amber-800',
warning: 'bg-yellow-100 text-yellow-800'
};
html += '<h4 class="mt-4 mb-2 text-sm font-semibold text-gray-900">Repeating errors</h4><ul class="list-none pl-0 space-y-2">';
patterns.forEach(p => {
const severity = String(p.severity || 'warning');
const affected = (p.affected_plugins || []).map(id => escapeHtml(id)).join(', ') || 'unknown';
const sample = (p.sample_messages || [])[0];
html += '<li class="border border-gray-200 rounded-lg px-3 py-2">' +
'<div class="flex flex-wrap items-center gap-2">' +
`<span class="px-2 py-0.5 rounded text-xs font-semibold ${badge[severity] || badge.warning}">${escapeHtml(severity)}</span>` +
`<code class="font-semibold text-gray-900">${escapeHtml(p.error_type || '')}</code>` +
`<span class="text-gray-700">×${escapeHtml(String(Number(p.count) || 0))}</span>` +
`<span class="text-xs text-gray-500 ml-auto">last seen ${escapeHtml(formatErrorTime(p.last_seen))}</span></div>` +
`<div class="mt-1 text-xs text-gray-600">Plugins: ${affected}</div>` +
(sample ? `<div class="mt-1 text-xs font-mono text-gray-700 break-words">${escapeHtml(sample)}</div>` : '') +
'</li>';
});
html += '</ul>';
}
body.innerHTML = html;
}
function refreshPluginErrors() {
fetch('/api/v3/errors/summary')
.then(response => response.json())
.then(data => {
if (data.status !== 'success' || !data.data) throw new Error(data.message || 'Request failed');
renderPluginErrors(data.data);
})
.catch(() => {
const body = document.getElementById('plugin-errors-body');
if (body) body.textContent = 'Could not load plugin errors.';
});
}
function clearPluginErrors() {
if (!confirm('Clear all recorded plugin errors?')) return;
fetch('/api/v3/errors/clear', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all: true })
})
.then(response => response.json())
.then(data => {
if (typeof showNotification !== 'undefined') {
showNotification(data.status === 'success' ? 'Plugin errors cleared'
: (data.message || 'Could not clear plugin errors'),
data.status === 'success' ? 'success' : 'error');
}
refreshPluginErrors();
})
.catch(() => {
if (typeof showNotification !== 'undefined') showNotification('Could not clear plugin errors', 'error');
});
}
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (window._logsEventSource) {
@@ -796,5 +941,9 @@ window.addEventListener('beforeunload', function() {
clearInterval(window._currentPluginPollTimer);
window._currentPluginPollTimer = null;
}
if (window._pluginErrorsPollTimer) {
clearInterval(window._pluginErrorsPollTimer);
window._pluginErrorsPollTimer = null;
}
});
</script>
@@ -286,11 +286,11 @@ function wifiSetup() {
try {
const response = await fetch('/api/v3/wifi/scan');
const data = await response.json();
console.log('WiFi scan response:', data); // Debug log
debugLog('WiFi scan response:', data);
if (data.status === 'success') {
// Ensure data.data is an array
const networksArray = Array.isArray(data.data) ? data.data : [];
console.log('WiFi scan found networks:', networksArray.length); // Debug log
debugLog('WiFi scan found networks:', networksArray.length);
// Set the networks array - Alpine.js will automatically update the select dropdown via x-for
this.networks = networksArray;