mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-11 01:26:37 +00:00
Merge origin/main into claude/remove-skins-and-base-classes
Resolves the README.md / docs/CONFIG_REFERENCE.md conflicts with #607 (its corrected text and function-name references, without the skins section; this branch's use_short_date_format row), and drops the skin/skin_options entries #607 documented in PLUGIN_CONFIG_CORE_PROPERTIES.md, since this branch retires those keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
+3
-34
@@ -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')
|
||||
@@ -154,11 +153,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 +175,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 +919,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 +1006,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
@@ -3161,195 +3161,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.
|
||||
|
||||
@@ -3507,210 +3318,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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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');
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
@@ -2122,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).
|
||||
@@ -2260,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) {
|
||||
@@ -2518,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');
|
||||
@@ -3323,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;
|
||||
@@ -4529,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;
|
||||
|
||||
@@ -4915,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}?`)) {
|
||||
@@ -4986,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) {
|
||||
@@ -5060,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;
|
||||
@@ -5088,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 {
|
||||
@@ -5106,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];
|
||||
@@ -5516,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');
|
||||
@@ -5542,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:', {
|
||||
@@ -5576,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:', {
|
||||
@@ -5610,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) {
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user